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:
Bendik Aagaard Lynghaug
2026-09-21 21:46:12 +02:00
co-authored by Claude Fable 5.1
parent aef0fe1396
commit baf36319b7
5 changed files with 669 additions and 41 deletions
+163 -10
View File
@@ -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();