diff --git a/CHANGELOG.md b/CHANGELOG.md index 5380517..75144c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## 0.5.1 (2026-09-28) + +- A `self_transition` that names no `from` means the bucket's own initial state, not the word `open`. 0.5.0 read the literal, which is only right for a bucket whose records start there: redoal's watchers arrive at `pending` and confirm themselves into `open`, so the rule compiled as `open -> open` and the confirm link in the mail could never match. Wrong the same way before 0.5.0, and now visible because the lint checks the edge exists. + ## 0.5.0 (2026-09-28) - Sessions live in NATS KV (`src/sessions.rs`), not in memory. Every restart used to sign out every desk on the site being deployed; they now survive it, in the JetStream that is already under everything else - no new service and no new store to run. The bucket's `max_age` collects what nobody returns for, and a record's own expiry is what answers a load. diff --git a/Cargo.lock b/Cargo.lock index 31acdfb..574c57c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "portal" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index ed0bf08..e027020 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "portal" -version = "0.5.0" +version = "0.5.1" edition = "2021" [lib] diff --git a/src/access.rs b/src/access.rs index aa576d2..c03bca8 100644 --- a/src/access.rs +++ b/src/access.rs @@ -223,8 +223,15 @@ impl Policy { if let Some(st) = &alt.self_transition { // One rule per state the move may be made from: a // person stays in dialogue with their case for as - // long as it runs, and `from` says how long that is. - for from in &st.from { + // long as it runs, and `from` says how long that + // is. Saying nothing means the bucket's own + // initial state - the bucket's, not the word + // `open`, since a bucket may start anywhere. + let initial = aggregates + .get(&st.bucket) + .map(|s| s.initial.clone()) + .unwrap_or_else(|| crate::answers::OPEN_STATE.to_string()); + for from in &st.from_states(&initial) { emit( format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition", format!("bucket {}", st.bucket), format!("{from} -> {}", st.to), @@ -577,6 +584,34 @@ mod tests { assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1); } + // "On arrival" is the bucket's own first state, not the word + // `open`. redoal's watchers arrive at `pending` and confirm + // themselves into `open`; reading the literal made that rule + // `open -> open`, which the engine declares nowhere, so the + // confirm link in the mail could never have worked. + #[test] + fn arrival_is_wherever_the_bucket_actually_starts() { + let files = vec![( + "index.yaml".to_string(), + "name: Home\nalternatives:\n - name: Ask\n record_as: watchers\n - name: Yes, watch it\n self_transition: { bucket: watchers, to: open, label: Yes }\n".to_string(), + )]; + let questions = build_questions(&files).unwrap(); + let aggregates = parse_aggregates_yaml( + "aggregates:\n - bucket: watchers\n initial: pending\n states:\n pending: { event: requested }\n open: { event: confirmed }\n transitions:\n pending: [open]\n", + ) + .unwrap(); + let p = Policy::from_content(&questions, &aggregates); + assert_eq!(p.validate(), Ok(()), "{}", p.text); + assert!( + p.rules.iter().any(|r| r.id == "self:watchers:pending>open"), + "the rule must start where the records do: {:?}", + p.rules.iter().map(|r| &r.id).collect::>() + ); + let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() }; + let bucket = Resource::Bucket { name: "watchers".into() }; + assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow); + } + #[test] fn invites_and_automation() { let p = site(); diff --git a/src/answers.rs b/src/answers.rs index 34ec1bd..c8e3d5c 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -376,7 +376,13 @@ pub async fn self_transition_answer( // and the answer must not confirm that the record exists. If it // is not, the case has moved past this move and saying so is no // longer a way to learn anything about it. - let in_reach = self_transition.from.iter().any(|s| s == &answer.state); + let initial = state + .aggregates + .load() + .get(&self_transition.bucket) + .map(|s| s.initial.clone()) + .unwrap_or_else(|| OPEN_STATE.to_string()); + let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state); return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" })); } diff --git a/src/content/mod.rs b/src/content/mod.rs index 75ba36c..d5c4395 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -348,15 +348,29 @@ pub struct SelfTransition { /// 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")] + /// way. + /// + /// Empty means the bucket's own initial state, which is the + /// one-shot "confirm" or "unsubscribe" link every one of these was + /// before it could say otherwise. Its own initial state, not the + /// word `open`: a bucket whose records start at `pending` has a + /// confirmation link that fires from `pending`, and reading the + /// literal here is what made redoal's watch-confirm button a rule + /// the engine would never match. + #[serde(default)] pub from: Vec, } -fn initial_state_only() -> Vec { - vec![crate::answers::OPEN_STATE.to_string()] +impl SelfTransition { + /// The states this move may be made from, with the bucket's + /// initial state standing in when the content named none. + pub fn from_states(&self, initial: &str) -> Vec { + if self.from.is_empty() { + vec![initial.to_string()] + } else { + self.from.clone() + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/content/validate.rs b/src/content/validate.rs index 74f2482..a9f8a04 100644 --- a/src/content/validate.rs +++ b/src/content/validate.rs @@ -242,13 +242,7 @@ 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 { + for from in &st.from_states(&schema.initial) { if !schema.has_state(from) { anyhow::bail!( "question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}",