A person stays in dialogue with their case
Five things a building authority needed and portal could not say,
found by building one and replaying its cases through the engine.
The one that mattered: a submitter could only ever act on a record
nobody had touched. `access.rs` pinned a self_transition's from-state
to `open`, though the runtime had always passed the real state and
asked. So the applicant written to for a missing drawing could not
send it, and nobody could withdraw once anything had happened.
`self_transition` now takes `from: [state, ...]` and compiles one rule
per state; omitted it still means the initial state alone, which is
what every unsubscribe link already meant.
Then the four beside it. A record can belong to another record
(`type: record`, `of:`), with `where: { field, equals }` on a listing
as the other half, so an application's page lists the objections that
name it. A public view can be narrowed to the states worth showing,
so a register that is public by law is public about what was decided
and not about what was withdrawn. A state can grant a group, so an
approval that means membership needs no second step. And a state can
be moved by a clock (`after: { days, to }`, src/deadlines.rs) - held
to the same declared edge as a desk button, firing the same mail,
publishing the same event.
Also: the README is a README again, and everything that was crowding
it is in docs/ - architecture, content reference, state machines,
access, operations.
95 tests, up from 82.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
26c1762dfa
commit
f18d1a5181
@@ -344,6 +344,19 @@ pub struct SelfTransition {
|
||||
pub bucket: String,
|
||||
pub to: String,
|
||||
pub label: String,
|
||||
/// The states this move may be made from. A person is in dialogue
|
||||
/// with a case for as long as it runs, not only on the day they
|
||||
/// sent it: an applicant written to for a missing drawing answers
|
||||
/// from `incomplete`, and may withdraw from anywhere along the
|
||||
/// way. Defaults to the initial state alone, which is what every
|
||||
/// one-shot "unsubscribe" link wants and what this was before it
|
||||
/// could be said.
|
||||
#[serde(default = "initial_state_only")]
|
||||
pub from: Vec<String>,
|
||||
}
|
||||
|
||||
fn initial_state_only() -> Vec<String> {
|
||||
vec![crate::answers::OPEN_STATE.to_string()]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -391,6 +404,21 @@ pub struct ResourceSpec {
|
||||
/// `requires_group` regardless of this flag.
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
/// The record states this resource shows, for a `Kv` source; empty
|
||||
/// shows every one. A register that is public by law is public
|
||||
/// about what was decided, not about what was withdrawn or turned
|
||||
/// down, and the desk that works the same bucket is a different
|
||||
/// spec and still sees all of it.
|
||||
#[serde(default)]
|
||||
pub states: Vec<String>,
|
||||
/// Show only the records whose `where.field` holds `where.equals`,
|
||||
/// for a `Kv` source. With a `type: record` field on the other
|
||||
/// side, this is the half that reads: an application's page lists
|
||||
/// the objections that name it, because each objection stored the
|
||||
/// application's id in a field and this asks for exactly those.
|
||||
/// `equals` takes the page's own `{key}` like a resource key does.
|
||||
#[serde(default, rename = "where")]
|
||||
pub filter: Option<FieldIs>,
|
||||
/// The moves a listed answer may make, one button each - empty
|
||||
/// means read-only. Server calls are checked against this
|
||||
/// allow-list, so a client can never fire a transition content
|
||||
@@ -505,6 +533,18 @@ pub struct Requirement {
|
||||
/// page's key into the answer.
|
||||
#[serde(default)]
|
||||
pub value: Option<String>,
|
||||
/// `type: record` only - the bucket the referenced record lives in.
|
||||
/// This is how one case belongs to another: an objection names the
|
||||
/// application it objects to, an appeal the decision it appeals.
|
||||
/// The stored value is the parent's record id, and a submission
|
||||
/// naming a record that is not there is refused, so a reference in
|
||||
/// a record is one that resolved at least once.
|
||||
///
|
||||
/// A person never types one of these: the id is a chain hash. It
|
||||
/// arrives the way it was given out - `value: "{key}"` on a dynamic
|
||||
/// page, so the link in the letter carries the case it is about.
|
||||
#[serde(default)]
|
||||
pub of: Option<String>,
|
||||
/// `type: gesture` / `type: voice` - ws(s):// URL of a redoal-relay instance
|
||||
/// the drawing widget connects to for live echoes of similar
|
||||
/// strokes. Absent means the widget works offline: the drawn path
|
||||
@@ -518,6 +558,16 @@ pub struct Requirement {
|
||||
/// value. When the sibling changes, `resource` is fetched with the
|
||||
/// value as a parameter and the result replaces this field's value
|
||||
/// (an empty sibling never fetches, and never clears an edit).
|
||||
/// "Show the ones whose `field` is `equals`." The whole of what a
|
||||
/// listing may ask about a record's own contents: one field, one
|
||||
/// value, compared as strings. Anything more is a query language, and
|
||||
/// a jq filter is already there for shaping what comes back.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FieldIs {
|
||||
pub field: String,
|
||||
pub equals: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Bind {
|
||||
/// The sibling requirement (same alternative) to watch.
|
||||
@@ -1075,6 +1125,94 @@ alternatives:
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
}
|
||||
|
||||
|
||||
fn question_with_record(field_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||
let question: Question = serde_yaml::from_str(&format!(
|
||||
r#"
|
||||
id: /object
|
||||
name: Object to it
|
||||
alternatives:
|
||||
- name: The register
|
||||
features:
|
||||
- name: All
|
||||
resource:
|
||||
public: true
|
||||
source: {{ kind: kv, bucket: things }}
|
||||
- name: Object
|
||||
record_as: things
|
||||
features:
|
||||
- name: Yours
|
||||
requirements:
|
||||
- name: what
|
||||
{field_yaml}
|
||||
"#
|
||||
))
|
||||
.unwrap();
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
}
|
||||
|
||||
// An objection belongs to the application it objects to. The
|
||||
// bucket it points at has to be one this repo declares, or the
|
||||
// reference could never resolve.
|
||||
#[test]
|
||||
fn a_record_can_belong_to_another_record() {
|
||||
let good = question_with_record(" - { name: case, type: record, of: things }");
|
||||
validate_questions(&good, &schema_fixture()).unwrap();
|
||||
|
||||
let nowhere = question_with_record(" - { name: case, type: record, of: nothing_declared }");
|
||||
let err = validate_questions(&nowhere, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("has no aggregate declared"), "{err}");
|
||||
|
||||
let unsaid = question_with_record(" - { name: case, type: record }");
|
||||
let err = validate_questions(&unsaid, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("must say `of:"), "{err}");
|
||||
|
||||
// `of` means one thing, so it belongs to one type.
|
||||
let stray = question_with_record(" - { name: case, of: things }");
|
||||
let err = validate_questions(&stray, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("only a type: record field has"), "{err}");
|
||||
|
||||
// The id arrives on the link; it is not looked up.
|
||||
let bound = question_with_record(" - name: case\n type: record\n of: things\n bind:\n field: what\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }");
|
||||
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
|
||||
assert!(err.to_string().contains("the link it came in on carries the id"), "{err}");
|
||||
}
|
||||
|
||||
// A state that grants a group makes an account for someone, so the
|
||||
// form that fills the bucket has to have asked who they are.
|
||||
#[test]
|
||||
fn a_state_that_grants_a_group_needs_a_name_and_an_address_on_file() {
|
||||
let granting = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: things\n initial: open\n states:\n open: { event: opened }\n middle: { event: advanced }\n done: { event: finished, grants: approved_firms }\n transitions:\n open: [middle]\n middle: [done]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let asks = question_with_record(" - { name: name }\n - { name: email, type: email }");
|
||||
validate_questions(&asks, &granting).unwrap();
|
||||
|
||||
let no_name = question_with_record(" - { name: email, type: email }");
|
||||
let err = validate_questions(&no_name, &granting).unwrap_err();
|
||||
assert!(err.to_string().contains("grant a group"), "{err}");
|
||||
|
||||
// The same form is fine against a schema that grants nothing.
|
||||
validate_questions(&no_name, &schema_fixture()).unwrap();
|
||||
}
|
||||
|
||||
// A deadline may only make a move a person could have made.
|
||||
#[test]
|
||||
fn a_states_deadline_is_held_to_the_same_graph_as_a_button() {
|
||||
let ok = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions:\n open: [no_answer]\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ok["hearings"].after_for("open").unwrap().days, 42);
|
||||
|
||||
let undeclared = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: hearings\n initial: open\n states:\n open: { event: opened, after: { days: 42, to: no_answer } }\n no_answer: { event: gave_up }\n transitions: {}\n",
|
||||
);
|
||||
assert!(undeclared.unwrap_err().to_string().contains("not a declared transition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_edge_passes() {
|
||||
let questions = question_with_transitions(
|
||||
|
||||
@@ -115,6 +115,38 @@ pub fn validate_questions(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A state that grants a group makes an account for the person the
|
||||
// record is about, so the record has to know who they are. Same
|
||||
// shape as an invite's own rule, one step further back: the form
|
||||
// that fills the bucket is where the name and the address have to
|
||||
// be asked for, because by the time a desk presses approve there is
|
||||
// nobody left to ask.
|
||||
for (bucket, schema) in aggregates {
|
||||
let mut granting: Vec<&String> = schema.grants_for_state.keys().collect();
|
||||
if granting.is_empty() {
|
||||
continue;
|
||||
}
|
||||
granting.sort();
|
||||
for question in questions.values() {
|
||||
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
|
||||
let required = |name: &str, kind: &str| {
|
||||
alternative
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| &f.requirements)
|
||||
.any(|r| r.name == name && r.kind == kind && !r.optional)
|
||||
};
|
||||
if !required("email", "email") || !required("name", "text") {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: records into {bucket:?}, whose state(s) {:?} grant a group, so it must collect a required `name` and a required `email` field of type email",
|
||||
question.id, alternative.name, granting
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for question in questions.values() {
|
||||
if let Some(event) = &question.event {
|
||||
if question.is_dynamic() {
|
||||
@@ -210,6 +242,30 @@ pub fn validate_questions(
|
||||
question.id, alternative.name, st.to, st.bucket
|
||||
);
|
||||
}
|
||||
if st.from.is_empty() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.from is empty, so the move can never be made",
|
||||
question.id, alternative.name
|
||||
);
|
||||
}
|
||||
for from in &st.from {
|
||||
if !schema.has_state(from) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, from, st.bucket
|
||||
);
|
||||
}
|
||||
// The policy would otherwise permit a move the
|
||||
// engine refuses: the reader of the content
|
||||
// should hear about that here, not from a
|
||||
// person who clicked the button.
|
||||
if !schema.allowed(from).iter().any(|s| s == &st.to) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition {:?} -> {:?} is not a declared edge for bucket {:?}",
|
||||
question.id, alternative.name, from, st.to, st.bucket
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bind targets resolve across the whole alternative, same
|
||||
@@ -275,6 +331,35 @@ pub fn validate_questions(
|
||||
}
|
||||
}
|
||||
}
|
||||
// One case belonging to another. The bucket has to
|
||||
// be one this repo declares, or the reference could
|
||||
// never resolve; and it is the only thing `of`
|
||||
// means, so it belongs to no other type.
|
||||
if requirement.kind == "record" {
|
||||
let Some(of) = &requirement.of else {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - it must say `of: <bucket>`, the bucket the record it points at lives in",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
};
|
||||
if !aggregates.contains_key(of) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} points at bucket {of:?}, which has no aggregate declared",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
if requirement.resource.is_some() || requirement.bind.is_some() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} is type: record - the link it came in on carries the id, it can't also load one from a resource or bind",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
} else if requirement.of.is_some() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: requirement {:?} has `of`, which only a type: record field has",
|
||||
question.id, alternative.name, feature.name, requirement.name
|
||||
);
|
||||
}
|
||||
if let Some(bind) = &requirement.bind {
|
||||
if bind.field == requirement.name
|
||||
|| !sibling_names.contains(bind.field.as_str())
|
||||
@@ -295,6 +380,14 @@ pub fn validate_questions(
|
||||
let Some(schema) = aggregates.get(bucket) else {
|
||||
continue;
|
||||
};
|
||||
for state in &resource.states {
|
||||
if !schema.has_state(state) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?} feature {:?}: states {:?} is not a declared state for bucket {:?}",
|
||||
question.id, alternative.name, feature.name, state, bucket
|
||||
);
|
||||
}
|
||||
}
|
||||
for transition in &resource.transitions {
|
||||
if !schema.has_state(&transition.from) {
|
||||
anyhow::bail!(
|
||||
|
||||
Reference in New Issue
Block a user