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(
|
||||
|
||||
Reference in New Issue
Block a user