A case can be sent back, and can end well partway along
A municipal building authority is the first sector that the line a
case walks could not describe. An application is returned for the
drawing it was missing and walks the same stretch again; an appeal
ends the moment the authority changes its own decision, without ever
reaching the board; an applicant may withdraw from anywhere, while a
refusal may only come once the papers are in.
`reached_from: { <stage>: [<stage>, ...] }` says where a stage is
entered from when the line does not. A stage named there is entered
from exactly those stages and nowhere else, so saying where a refusal
may come from also says where it may not - which `turned_down_from`
could not, being one list for every bad ending at once.
Found by replaying fifteen real building cases through portal's own
engine against a throwaway JetStream: it refused three legal paths and
accepted two illegal ones. Portal needed no change - hand-writing the
correct graph into aggregates.yaml, cycle and all, was accepted and
enforced exactly. The rebuilt site now produces that same graph from
the plan, and the access policy follows it.
Also: a public view is of a bucket, not of a need. A permit register
holds the owner's small application and the approved firm's large one
and is one page either way; the rule refused to publish a bucket the
moment any role filed into it. A bucket only a role ever writes to is
still refused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
443ddcca68
commit
d2aae383b2
+140
-3
@@ -196,6 +196,18 @@ pub struct PlanNeed {
|
||||
/// start and not after, where they depart instead.
|
||||
#[serde(default)]
|
||||
pub turned_down_from: Vec<String>,
|
||||
/// Where a stage is entered from, when the line does not say it:
|
||||
/// `{ incomplete: [complete, on_hearing] }` sends a case back for
|
||||
/// papers it was already past, and `{ changed_by_us: [reconsidered] }`
|
||||
/// puts a good ending somewhere other than the end of the line.
|
||||
/// Naming a stage here replaces the moves into it that the line
|
||||
/// would have given, and only those - every other stage keeps its
|
||||
/// own. A regulated case needs both: an authority may refuse only
|
||||
/// once the papers are complete, while the applicant may withdraw
|
||||
/// from anywhere, and the two cannot be one list.
|
||||
#[serde(default)]
|
||||
#[serde(deserialize_with = "one_or_many_by_key")]
|
||||
pub reached_from: BTreeMap<String, Vec<String>>,
|
||||
/// What the person must already have or be for this alternative to
|
||||
/// work: a membership, a reference number, the link from a mail,
|
||||
/// an account. Stated on the card before the form, because a
|
||||
@@ -381,8 +393,36 @@ impl Plan {
|
||||
if need.watched_by.iter().any(|g| !is_id(g)) {
|
||||
bail!(at("watched_by must list group identifiers, or `public`"));
|
||||
}
|
||||
if need.watched_by.iter().any(|g| g == PUBLIC) && need.enters_as.is_some() {
|
||||
bail!(at("a public view is for what the public sent; an internal need has its group's desk"));
|
||||
// A public view is of a bucket, not of a need. A role's own
|
||||
// need may be watched by the public when the public files
|
||||
// into the same bucket: a permit register holds the owner's
|
||||
// small application and the firm's large one, and is one
|
||||
// page either way. A bucket only a role ever writes to has
|
||||
// its group's desk and nothing to publish.
|
||||
if need.watched_by.iter().any(|g| g == PUBLIC)
|
||||
&& need.enters_as.is_some()
|
||||
&& !self
|
||||
.needs
|
||||
.iter()
|
||||
.any(|n| n.lands_in == need.lands_in && n.enters_as.is_none())
|
||||
{
|
||||
bail!(at("a public view is for what the public sent; an internal need whose bucket the public never files into has its group's desk"));
|
||||
}
|
||||
for (target, froms) in &need.reached_from {
|
||||
if !need.stages.contains(target) || target == &need.stages[0] {
|
||||
bail!(at(&format!("reached_from names {target:?}, which is not one of its stages after the first")));
|
||||
}
|
||||
if froms.is_empty() {
|
||||
bail!(at(&format!("reached_from {target:?}: a stage reached from nowhere can never be entered")));
|
||||
}
|
||||
for from in froms {
|
||||
if !need.stages.contains(from) || need.done_when.contains(from) {
|
||||
bail!(at(&format!("reached_from {target:?} names {from:?}, which is not one of its unfinished stages")));
|
||||
}
|
||||
if from == target {
|
||||
bail!(at(&format!("reached_from {target:?} names itself")));
|
||||
}
|
||||
}
|
||||
}
|
||||
for state in &need.turned_down_from {
|
||||
if need.done_when.contains(state) || !need.stages.contains(state) {
|
||||
@@ -480,6 +520,7 @@ impl Plan {
|
||||
|| other.mails != first.mails
|
||||
|| other.stages_text != first.stages_text
|
||||
|| other.turned_down_from != first.turned_down_from
|
||||
|| other.reached_from != first.reached_from
|
||||
{
|
||||
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails and who moves and watches it", first.id, other.id);
|
||||
}
|
||||
@@ -532,6 +573,14 @@ impl Plan {
|
||||
/// beyond a run of skippable ones, plus every finished state that is
|
||||
/// not a success (a case can be turned down at any point). Success is
|
||||
/// only reachable from the end of the line.
|
||||
///
|
||||
/// That line is what most work looks like, and it is not what a
|
||||
/// regulated case looks like. `reached_from` says where a named stage
|
||||
/// is entered from instead: back to an earlier one when a case is sent
|
||||
/// for the papers it was missing, or into a good ending partway along
|
||||
/// when an authority changes its own mind rather than passing the case
|
||||
/// on. A stage named there is entered from exactly those stages and
|
||||
/// nowhere else; every stage not named keeps the line's own answer.
|
||||
pub fn machine(need: &PlanNeed) -> BTreeMap<String, Vec<String>> {
|
||||
let line: Vec<&String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).collect();
|
||||
let ends_well: Vec<&String> = if need.success.is_empty() { need.done_when.iter().collect() } else { need.success.iter().collect() };
|
||||
@@ -560,6 +609,22 @@ pub fn machine(need: &PlanNeed) -> BTreeMap<String, Vec<String>> {
|
||||
}
|
||||
moves.insert((*stage).clone(), to);
|
||||
}
|
||||
// A stage the plan places by hand is entered from exactly the
|
||||
// stages it names: take it out of the line's answer everywhere
|
||||
// first, so that saying where a refusal may come from also says
|
||||
// where it may not.
|
||||
for (target, froms) in &need.reached_from {
|
||||
for tos in moves.values_mut() {
|
||||
tos.retain(|to| to != target);
|
||||
}
|
||||
for from in froms {
|
||||
if let Some(tos) = moves.get_mut(from) {
|
||||
if !tos.contains(target) {
|
||||
tos.push(target.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
moves
|
||||
}
|
||||
|
||||
@@ -1740,11 +1805,25 @@ mod tests {
|
||||
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![],
|
||||
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], requires: String::new(),
|
||||
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(),
|
||||
collects: vec![], max_fields: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn map(pairs: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
|
||||
pairs.iter().map(|(k, vs)| (k.to_string(), vs.iter().map(|v| v.to_string()).collect())).collect()
|
||||
}
|
||||
|
||||
fn m_of(need: &PlanNeed, stage: &str) -> Vec<String> {
|
||||
machine(need).remove(stage).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse(yaml: &str) -> anyhow::Result<Plan> {
|
||||
let plan: Plan = serde_yaml::from_str(yaml)?;
|
||||
plan.validate()?;
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stage_may_mail_the_submitter_or_an_address_and_the_words_fill_it() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: \"B <post@b.example>\", reply_to: i@b.example }\nneeds:\n - id: leak\n who: owners\n wants: a dry house\n lands_in: leaks\n handled_by: roofers\n stages: [open, fixed, dropped]\n done_when: [fixed, dropped]\n success: [fixed]\n moves_by: { dropped: [submitter, roofers] }\n mails: { open: submitter, fixed: submitter, dropped: board@b.example }\n max_fields: 2\n collects:\n - { name: name }\n - { name: email, type: email }\n";
|
||||
@@ -1819,6 +1898,64 @@ mod tests {
|
||||
assert!(!m.contains_key("won"));
|
||||
}
|
||||
|
||||
// A building case is sent back for the drawing it was missing and
|
||||
// walks the same stretch again. The line alone only goes forward,
|
||||
// so the plan says where `incomplete` is entered from.
|
||||
#[test]
|
||||
fn a_case_can_be_sent_back_for_what_it_was_missing() {
|
||||
let mut n = need(
|
||||
&["open", "incomplete", "complete", "on_hearing", "approved", "refused"],
|
||||
&["approved", "refused"],
|
||||
&["approved"],
|
||||
&["incomplete"],
|
||||
);
|
||||
assert_eq!(m_of(&n, "on_hearing"), ["approved", "refused"]);
|
||||
assert!(!m_of(&n, "on_hearing").contains(&"incomplete".to_string()));
|
||||
n.reached_from = map(&[("incomplete", &["complete", "on_hearing"][..])]);
|
||||
let m = machine(&n);
|
||||
assert_eq!(m["on_hearing"], ["approved", "refused", "incomplete"]);
|
||||
assert_eq!(m["complete"], ["on_hearing", "refused", "incomplete"]);
|
||||
// And nowhere else: `open` no longer reaches it, because saying
|
||||
// where a stage is entered from says where it is not.
|
||||
assert_eq!(m["open"], ["complete", "refused"]);
|
||||
}
|
||||
|
||||
// The authority that changes its own mind ends the case at
|
||||
// `changed_by_us`, which is a good ending in the middle of the
|
||||
// line, not at the end of it.
|
||||
#[test]
|
||||
fn a_good_ending_can_sit_partway_along() {
|
||||
let mut n = need(
|
||||
&["open", "reconsidered", "before_the_board", "changed_by_us", "upheld", "overturned"],
|
||||
&["changed_by_us", "upheld", "overturned"],
|
||||
&["changed_by_us", "upheld"],
|
||||
&[],
|
||||
);
|
||||
// By the line alone, we change our own mind only once the board
|
||||
// already has the case - which is backwards.
|
||||
assert!(m_of(&n, "before_the_board").contains(&"changed_by_us".to_string()));
|
||||
assert!(!m_of(&n, "reconsidered").contains(&"changed_by_us".to_string()));
|
||||
n.reached_from = map(&[
|
||||
("changed_by_us", &["reconsidered"][..]),
|
||||
("overturned", &["before_the_board"][..]),
|
||||
]);
|
||||
let m = machine(&n);
|
||||
assert_eq!(m["reconsidered"], ["before_the_board", "changed_by_us"]);
|
||||
assert_eq!(m["before_the_board"], ["upheld", "overturned"]);
|
||||
assert!(!m["open"].contains(&"overturned".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stage_reached_from_nowhere_real_is_refused() {
|
||||
let base = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: apply\n who: owners\n wants: permission\n lands_in: cases\n handled_by: office\n stages: [open, checked, approved, refused]\n done_when: [approved, refused]\n success: [approved]\n max_fields: 2\n collects:\n - { name: what }\n";
|
||||
let with = |line: &str| format!("{base} reached_from: {line}\n");
|
||||
assert!(parse(&with("{ approved: [checked] }")).is_ok());
|
||||
assert!(parse(&with("{ signed: [checked] }")).unwrap_err().to_string().contains("not one of its stages"));
|
||||
assert!(parse(&with("{ approved: [approved] }")).unwrap_err().to_string().contains("not one of its unfinished stages"));
|
||||
assert!(parse(&with("{ open: [checked] }")).unwrap_err().to_string().contains("after the first"));
|
||||
assert!(parse(&with("{ approved: [] }")).unwrap_err().to_string().contains("never be entered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stated_prerequisite_comes_before_the_form() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: renew\n who: members\n wants: another year\n requires: You need your membership number from last year's receipt.\n lands_in: renewals\n handled_by: office\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: number }\n";
|
||||
|
||||
+10
@@ -78,6 +78,16 @@ needs:
|
||||
# the unfinished stages a case can still be turned down from;
|
||||
# omit for every one. An applicant is declined before they
|
||||
# start, not after - after, they depart
|
||||
reached_from: { <stage>: [<stage>, ...] }
|
||||
# where a stage is entered from, when the line does not say it.
|
||||
# Two things need it and nothing else does: a case SENT BACK to
|
||||
# a stage it was already past ("papers missing" again, after the
|
||||
# hearing turned something up), and a GOOD ENDING partway along,
|
||||
# where a case finishes well without walking to the end ("we
|
||||
# changed our own decision", instead of passing it upwards).
|
||||
# A stage named here is entered from exactly those stages and
|
||||
# nowhere else, so it also says where a refusal may NOT come
|
||||
# from. Omit unless the business described one of those two
|
||||
moves_by: { <stage>: [<group>, ...] }
|
||||
# only where someone other than handled_by moves a case INTO a
|
||||
# stage: another group, or `submitter` (the person who sent the
|
||||
|
||||
Reference in New Issue
Block a user