Overlapping roles on one aggregate, relay routes, and a dry run
plan.yaml: `moves_by` says who presses the button into a stage when it is not `handled_by` - other groups, several groups, or `submitter`, the person who sent the form, through their own link (the form must then collect a required email). `watched_by` puts a bucket on a group's desk with no buttons. Each group's desk carries its own buttons only; a submitter's moves become a followup page of self_transitions; needs.yaml gains `also_moved_by` for portal's verifier. simulate: a route is now a relay across every role that holds a legal button, the submitter included, and each step is judged as the role that presses it. `--dry` asks no model anything and walks every simple route of every aggregate, for needs_replay to put through the real engine. plans/contractor-full.yaml: 13 needs, 6 roles, 10 aggregates. Verifier green by construction; 40 routes, 78 transitions accepted by the real engine, none refused. What it exposed is in its run README. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
aef0fe1396
commit
baf36319b7
+163
-10
@@ -70,6 +70,17 @@ pub struct PlanNeed {
|
||||
/// were complete).
|
||||
#[serde(default)]
|
||||
pub skippable: Vec<String>,
|
||||
/// Who presses the button that moves a case INTO a stage, where it
|
||||
/// is not `handled_by`: another group, several groups, or
|
||||
/// `submitter` - the person who sent the form, through their own
|
||||
/// link (the form must then collect a required `email`). This is
|
||||
/// how roles overlap on one aggregate: a project manager prices a
|
||||
/// change, the client approves it, a site manager completes it.
|
||||
#[serde(default)]
|
||||
pub moves_by: BTreeMap<String, Vec<String>>,
|
||||
/// Groups that see these cases on their desk and move nothing.
|
||||
#[serde(default)]
|
||||
pub watched_by: Vec<String>,
|
||||
pub collects: Vec<Field>,
|
||||
#[serde(default)]
|
||||
pub max_fields: Option<usize>,
|
||||
@@ -113,6 +124,22 @@ impl Plan {
|
||||
if need.stages.len() < 2 {
|
||||
bail!(at("stages needs a first stage and at least one finished one"));
|
||||
}
|
||||
for (state, movers) in &need.moves_by {
|
||||
if !need.stages[1..].contains(state) {
|
||||
bail!(at(&format!("moves_by names {state:?}, which is not a stage a case can move into")));
|
||||
}
|
||||
if movers.is_empty() || movers.iter().any(|m| !is_id(m)) {
|
||||
bail!(at(&format!("moves_by {state:?} must list groups or `submitter`")));
|
||||
}
|
||||
if movers.iter().any(|m| m == SUBMITTER)
|
||||
&& !need.collects.iter().any(|f| f.name == "email" && f.kind == "email" && !f.optional)
|
||||
{
|
||||
bail!(at("a move by the submitter is authorized by their email, so the form must collect a required `email` of type email"));
|
||||
}
|
||||
}
|
||||
if need.watched_by.iter().any(|g| !is_id(g)) {
|
||||
bail!(at("watched_by must list group identifiers"));
|
||||
}
|
||||
for state in need.done_when.iter().chain(&need.skippable) {
|
||||
if !need.stages.contains(state) {
|
||||
bail!(at(&format!("{state:?} is not one of its stages")));
|
||||
@@ -139,8 +166,14 @@ impl Plan {
|
||||
for (bucket, needs) in self.by_bucket() {
|
||||
let first = needs[0];
|
||||
for other in &needs[1..] {
|
||||
if other.stages != first.stages || other.done_when != first.done_when || other.skippable != first.skippable {
|
||||
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when and skippable", first.id, other.id);
|
||||
if other.stages != first.stages
|
||||
|| other.done_when != first.done_when
|
||||
|| other.skippable != first.skippable
|
||||
|| other.moves_by != first.moves_by
|
||||
|| other.watched_by != first.watched_by
|
||||
|| other.handled_by != first.handled_by
|
||||
{
|
||||
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable and who moves and watches it", first.id, other.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,11 +289,12 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
let buckets: Map<String, Value> = plan
|
||||
.by_bucket()
|
||||
.into_iter()
|
||||
.filter(|(_, needs)| needs.iter().any(|n| n.handled_by == group))
|
||||
.filter(|(_, needs)| on_desk(needs[0], &group))
|
||||
.map(|(bucket, needs)| {
|
||||
let moves: Map<String, Value> = machine(needs[0])
|
||||
// Only the buttons this group presses; a watcher gets none.
|
||||
let moves: Map<String, Value> = moves_of(needs[0], &group)
|
||||
.into_iter()
|
||||
.flat_map(|(from, tos)| tos.into_iter().map(move |to| (format!("{from}->{to}"), Value::String(humanize(&to)))))
|
||||
.map(|(from, to)| (format!("{from}->{to}"), Value::String(humanize(&to))))
|
||||
.collect();
|
||||
(bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves }))
|
||||
})
|
||||
@@ -276,11 +310,68 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }),
|
||||
);
|
||||
}
|
||||
// The submitter's own page: reached by their private link, it
|
||||
// offers the moves that are theirs to make.
|
||||
for need in &plan.needs {
|
||||
let theirs = moves_of(need, SUBMITTER);
|
||||
if theirs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let targets: BTreeSet<&String> = theirs.iter().map(|(_, to)| to).collect();
|
||||
words.insert(
|
||||
decide_path(need),
|
||||
json!({
|
||||
"question": "What would you like to do?",
|
||||
"description": "",
|
||||
"moves": targets.iter().map(|to| ((*to).clone(), json!({
|
||||
"heading": humanize(to), "description": "", "button": humanize(to),
|
||||
}))).collect::<Map<_, _>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
fn decide_path(need: &PlanNeed) -> String {
|
||||
format!("questions/decide/{}.yaml", need.id)
|
||||
}
|
||||
|
||||
const SUBMITTER: &str = "submitter";
|
||||
|
||||
/// Who presses the button into `to`.
|
||||
fn movers<'a>(need: &'a PlanNeed, to: &str) -> Vec<&'a str> {
|
||||
match need.moves_by.get(to) {
|
||||
Some(list) => list.iter().map(String::as_str).collect(),
|
||||
None => vec![need.handled_by.as_str()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The (from, to) moves of a bucket that `who` may make.
|
||||
fn moves_of(need: &PlanNeed, who: &str) -> Vec<(String, String)> {
|
||||
machine(need)
|
||||
.into_iter()
|
||||
.flat_map(|(from, tos)| tos.into_iter().map(move |to| (from.clone(), to)))
|
||||
.filter(|(_, to)| movers(need, to).contains(&who))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A group's desk shows a bucket when it moves something in it, is
|
||||
/// answerable for it, or watches it.
|
||||
fn on_desk(need: &PlanNeed, group: &str) -> bool {
|
||||
need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty()
|
||||
}
|
||||
|
||||
fn groups(plan: &Plan) -> BTreeSet<String> {
|
||||
plan.needs.iter().flat_map(|n| [Some(n.handled_by.clone()), n.enters_as.clone()]).flatten().collect()
|
||||
plan.needs
|
||||
.iter()
|
||||
.flat_map(|n| {
|
||||
[Some(n.handled_by.clone()), n.enters_as.clone()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(n.watched_by.iter().cloned())
|
||||
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Takes the model's strings where it gave them, key by key, and keeps
|
||||
@@ -422,7 +513,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
let w = &words[&format!("questions/{group}/index.yaml")];
|
||||
let mut alternatives = Vec::new();
|
||||
for (bucket, needs) in plan.by_bucket() {
|
||||
if !needs.iter().any(|n| n.handled_by == group) {
|
||||
if !on_desk(needs[0], &group) {
|
||||
continue;
|
||||
}
|
||||
let bw = &w["buckets"][bucket];
|
||||
@@ -434,8 +525,11 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"features": [{ "name": "", "description": "", "resource": {
|
||||
"source": { "kind": "kv", "bucket": bucket },
|
||||
"requires_group": group,
|
||||
"transitions": machine(needs[0]).into_iter().flat_map(|(from, tos)| {
|
||||
tos.into_iter().map(|to| json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })).collect::<Vec<_>>()
|
||||
// This group's buttons only. The same bucket sits on
|
||||
// other groups' desks with theirs; a watcher's list
|
||||
// is empty, which portal renders read-only.
|
||||
"transitions": moves_of(needs[0], &group).into_iter().map(|(from, to)| {
|
||||
json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })
|
||||
}).collect::<Vec<_>>(),
|
||||
}}],
|
||||
}));
|
||||
@@ -456,6 +550,31 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
);
|
||||
}
|
||||
|
||||
// A submitter's own moves: a followup page (nested, not an index)
|
||||
// whose alternatives are self_transitions, authorized at runtime by
|
||||
// the record's link plus the email they gave.
|
||||
for need in &plan.needs {
|
||||
let theirs = moves_of(need, SUBMITTER);
|
||||
if theirs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let path = decide_path(need);
|
||||
let w = &words[&path];
|
||||
let targets: BTreeSet<&String> = theirs.iter().map(|(_, to)| to).collect();
|
||||
files.insert(
|
||||
path,
|
||||
json!({
|
||||
"name": s(&w["question"]),
|
||||
"description": s(&w["description"]),
|
||||
"alternatives": targets.iter().map(|to| json!({
|
||||
"name": s(&w["moves"][to.as_str()]["heading"]),
|
||||
"description": s(&w["moves"][to.as_str()]["description"]),
|
||||
"self_transition": { "bucket": need.lands_in, "to": to, "label": s(&w["moves"][to.as_str()]["button"]) },
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// The spec the verifier holds the site to. `via` is the heading the
|
||||
// words gave that need, filled in mechanically.
|
||||
files.insert(
|
||||
@@ -474,6 +593,8 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"max_steps": 1, "max_fields": n.max_fields.unwrap_or(required),
|
||||
});
|
||||
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
|
||||
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
|
||||
if !others.is_empty() { need["also_moved_by"] = json!(others); }
|
||||
need
|
||||
}).collect::<Vec<_>>(),
|
||||
"personas": plan.personas,
|
||||
@@ -484,7 +605,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
}
|
||||
|
||||
fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
|
||||
let purpose = if path == "questions/index.yaml" {
|
||||
let purpose = if path.contains("/decide/") {
|
||||
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
|
||||
} else if path == "questions/index.yaml" {
|
||||
"the front door: the first page any visitor sees. `question` is the page's one question; under `needs`, each entry is one alternative the visitor can pick, with the form behind it."
|
||||
} else if path.ends_with("/index.yaml") {
|
||||
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open."
|
||||
@@ -572,6 +695,7 @@ mod tests {
|
||||
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None,
|
||||
lands_in: "b".into(), handled_by: "g".into(),
|
||||
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
|
||||
moves_by: BTreeMap::new(), watched_by: vec![],
|
||||
collects: vec![], max_fields: None,
|
||||
}
|
||||
}
|
||||
@@ -634,6 +758,35 @@ mod tests {
|
||||
assert!(files["needs.yaml"].contains("enters: /crew/report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roles_overlap_on_one_aggregate_each_with_their_own_buttons() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n";
|
||||
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
|
||||
plan.validate().unwrap();
|
||||
let files = render(&plan, &fallback_words(&plan)).unwrap();
|
||||
|
||||
// The project manager prices and may decline; approving and completing are not theirs.
|
||||
let pm = &files["questions/project_managers/index.yaml"];
|
||||
assert!(pm.contains("to: priced") && pm.contains("to: declined"));
|
||||
assert!(!pm.contains("to: approved") && !pm.contains("to: completed"));
|
||||
// The site manager completes, and nothing else.
|
||||
let site = &files["questions/site_managers/index.yaml"];
|
||||
assert!(site.contains("to: completed") && !site.contains("to: priced"));
|
||||
// Accounts see the bucket and move nothing.
|
||||
let accounts = &files["questions/accounts/index.yaml"];
|
||||
assert!(accounts.contains("bucket: change_orders") && accounts.contains("transitions: []"));
|
||||
// The client decides through their own link.
|
||||
let decide = &files["questions/decide/change.yaml"];
|
||||
assert!(decide.contains("self_transition") && decide.contains("to: approved") && decide.contains("to: declined"));
|
||||
// The spec tells the verifier whose desks count.
|
||||
assert!(files["needs.yaml"].contains("also_moved_by:\n - site_managers"));
|
||||
|
||||
// A submitter's move without an email to authorize it is refused.
|
||||
let no_email = yaml.replace(" - { name: email, type: email }\n", "");
|
||||
let plan: Plan = serde_yaml::from_str(&no_email).unwrap();
|
||||
assert!(plan.validate().unwrap_err().to_string().contains("required `email`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused() {
|
||||
let bad: Plan = serde_yaml::from_str(&PLAN.replace("lost]", "avslått]")).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user