From e67e841f8c3aeed49e399350407da4f1e6c380cd Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 22 Sep 2026 16:58:40 +0200 Subject: [PATCH] Question nav: a followup is offered only once it is earned The 0.3.34 rule listed every followup "further down" from the current page, so the front door offered the thank-you pages before anything was submitted, and a followup one typed in offered the ones beyond it. The chain half was weaker than it looked too: it matched the question the chain answered, which is a front door anyway. Now a followup is offered only when the visitor has earned it: their verified chain answered the alternative that leads there (the chain index records the alternative from here on; older nodes fall back to every recording alternative of the answered question, and nothing beyond it), or it hangs off an earned page by a link that records nothing. A page behind an answer they have not given is never offered. The walk ends at any front door, so "back to the start" never fans out. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH --- CHANGELOG.md | 1 + README.md | 7 ++- src/app.rs | 38 ++++++------- src/chain.rs | 7 +++ src/content/mod.rs | 138 ++++++++++++++++++++++++++++++--------------- 5 files changed, 123 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7e116..35e14af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ content repos pin the tag they run (see the end of this file). ## Unreleased +- Question nav: a followup is offered only once earned - the verified chain answered the alternative leading there, or a link that records nothing leads on from an earned page. Standing on a page no longer unlocks the pages behind its forms; the chain index now records which alternative was answered - One bounded HTTP client for every server-side fetch: 20 s budget, size caps per kind of fetch (`src/http.rs`) - Uploads are refused past 20 MB while streaming, and the route body limit that had capped them at axum's 2 MB default is raised to match - `tests/jetstream.rs`: the store, the event log and the projection tested against a real JetStream (CI starts a throwaway one) diff --git a/README.md b/README.md index ee0bebf..68b90f0 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,10 @@ stream. parent(s), so a visitor's path through the questions is a verifiable lineage; `?chain=` links carry it, and self-service transitions (unsubscribe) authorize by holding one. The question nav reads it - too: a `followup` page is offered only when it is on that path (the - page the chain verifiably answered) or further down from the current - page over `action` edges - one followup path never unlocks another. + too: a `followup` page is offered only once the visitor has earned + it - the chain verifiably answered the alternative that leads there, + or it hangs off an earned page by a link that records nothing. A + page behind an answer they have not given is never offered. - **Business needs as a checkable spec** (`src/needs.rs`, `question_lint`): a content repo may ship a `needs.yaml` saying who wants what, which bucket the answer must land in, and which group diff --git a/src/app.rs b/src/app.rs index 230fb9f..7f8fdcf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2033,18 +2033,19 @@ pub async fn get_site() -> Result { /// Every question the visitor may be offered from `current`, as (id, /// name) - the question nav's data. Front-door pages the visitor /// qualifies for are always in (an owner session sees the gated desks -/// too); `followup` pages and ended events only when they are on the -/// visitor's path - the page their verified `?chain=` answered - or -/// further down from `current` (see `content::outcomes_reachable_from`). -/// A chain unlocks the followup it came from, never every followup on -/// the site; an unverifiable hash unlocks nothing. +/// too). A `followup` page or an ended event is in only when the +/// visitor has earned it: their verified `?chain=` answered the +/// alternative that leads there, or it is reachable from an earned +/// page over links that record nothing (see `content::earned_followups`). +/// Nothing is offered that sits behind an answer they have not given; +/// an unverifiable hash unlocks nothing. #[server(endpoint = "list_qualifying_questions")] pub async fn list_qualifying_questions( current: String, chain: Option, ) -> Result, ServerFnError> { use crate::auth::{User, SESSION_USER_KEY}; - use crate::content::{is_qualified, outcomes_reachable_from, Question}; + use crate::content::{earned_followups, is_qualified, unlocked_by, Question}; use crate::server::AppState; let state = expect_context::(); @@ -2056,26 +2057,23 @@ pub async fn list_qualifying_questions( let now_ms = chrono::Utc::now().timestamp_millis(); let questions = state.questions.load(); - // The one page the chain verifiably answered - the same lookup a + // What the chain verifiably answered - the same lookup a // `requires_chain` gate does, so a forged or foreign hash resolves - // to nothing relevant. - let answered: Option = match &chain { - Some(hash) => crate::chain::lookup_node(&state.jetstream, hash) - .await - .map(|node| node.question_id), - None => None, + // to nothing - and therefore where it sent the visitor. + let seeds: Vec = match &chain { + Some(hash) => match crate::chain::lookup_node(&state.jetstream, hash).await { + Some(node) => unlocked_by(&questions, &node.question_id, node.alternative.as_deref()), + None => Vec::new(), + }, + None => Vec::new(), }; // An ended event page is a followup from then on. let is_outcome = |q: &Question| q.is_followup() || q.event_ended_at(now_ms); - let reach = outcomes_reachable_from(&questions, ¤t, &is_outcome); + let earned = earned_followups(&questions, ¤t, &seeds, &is_outcome); let mut out: Vec<(String, String)> = questions .values() .filter(|q| is_qualified(user.as_ref(), q)) - .filter(|q| { - !is_outcome(q) - || answered.as_deref() == Some(q.id.as_str()) - || reach.contains(&q.id) - }) + .filter(|q| !is_outcome(q) || earned.contains(&q.id)) // A live event is announced in the header instead. .filter(|q| !q.event_live_at(now_ms)) // A dynamic page has no URL of its own to link to. @@ -2225,7 +2223,7 @@ pub async fn submit_answer( // Index the node so requires_chain pages can verify lineage. // Best-effort: the NATS event above is the durable record. if let Err(e) = - crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, timestamp_ms).await + crate::chain::record_node(&state.jetstream, &chain_hash, &question_id, &alternative, timestamp_ms).await { tracing::error!("failed to index chain node: {e}"); } diff --git a/src/chain.rs b/src/chain.rs index 6a36b60..6c4a632 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -20,6 +20,11 @@ pub const CHAIN_BUCKET: &str = "portal_chains"; #[derive(serde::Serialize, serde::Deserialize)] pub struct ChainNode { pub question_id: String, + /// Which alternative was answered - what decides where the visitor + /// was sent next, and so which followup the chain has earned. Nodes + /// indexed before this field existed carry `None`. + #[serde(default)] + pub alternative: Option, pub timestamp_ms: i64, } @@ -30,6 +35,7 @@ pub async fn record_node( js: &async_nats::jetstream::Context, chain_hash: &str, question_id: &str, + alternative: &str, timestamp_ms: i64, ) -> anyhow::Result<()> { let store = match js.get_key_value(CHAIN_BUCKET).await { @@ -44,6 +50,7 @@ pub async fn record_node( }; let node = ChainNode { question_id: question_id.to_string(), + alternative: Some(alternative.to_string()), timestamp_ms, }; store.put(chain_hash, serde_json::to_vec(&node)?.into()).await?; diff --git a/src/content/mod.rs b/src/content/mod.rs index 90a7072..360aef8 100644 --- a/src/content/mod.rs +++ b/src/content/mod.rs @@ -65,11 +65,11 @@ pub struct Question { #[serde(default)] pub responsible: Option, /// A page that only makes sense after answering something (the - /// post-submission pages) - kept out of the question nav unless it is - /// on the visitor's path (the page their verified `?chain=` answered) - /// or further down from the page they are on, over `action` edges - /// (see `outcomes_reachable_from`). One followup path never unlocks - /// another. Unset means inferred + /// post-submission pages) - kept out of the question nav until the + /// visitor has earned it: their verified `?chain=` answered the + /// alternative that leads there, or it hangs off an earned page by + /// a link that records nothing (see `earned_followups`). Nothing + /// behind an answer they have not given is offered. Unset means inferred /// from the file's place in the tree: files nested in a /// subdirectory are followups unless they're the directory's /// `index.yaml`; top-level files keep the historical default @@ -705,45 +705,78 @@ pub fn action_target_id( .cloned() } -/// The outcome pages "further down" from `from`: every `is_outcome` -/// question reachable from it over `alternatives[].action` edges, -/// walking only *through* outcome pages. A front-door page (not an -/// outcome) is where the walk ends, so a "back to the start" action -/// never fans out into the whole site. `from` itself is never in the -/// result. This, plus the one page a visitor's verified chain answered, -/// decides which followups the question nav offers - one followup path -/// never unlocks another (an email-link page like redoal's `/watching`, -/// which nothing links to, stays out of every nav). -pub fn outcomes_reachable_from( +/// An alternative whose action asks nothing of the visitor: no answer +/// is recorded, so following it is navigation, not a step they have to +/// complete. +fn asks_nothing(alt: &Alternative) -> bool { + !alt.disabled && alt.record_as.is_none() && alt.self_transition.is_none() +} + +/// The pages a verified answer has earned: where the answered +/// alternative sends the visitor next (a templated action resolves to +/// its dynamic pattern). When the node predates alternatives being +/// indexed, every recording alternative of that question counts - the +/// visitor did answer it, only which alternative is unknown. +pub fn unlocked_by( questions: &std::collections::HashMap, - from: &str, + question_id: &str, + alternative: Option<&str>, +) -> Vec { + let Some(question) = resolve_question(questions, question_id) else { + return Vec::new(); + }; + question + .alternatives + .iter() + .filter(|a| match alternative { + Some(name) => a.name == name, + None => a.record_as.is_some(), + }) + .filter_map(|a| a.action.as_deref()) + .filter_map(|action| action_target_id(questions, action)) + .collect() +} + +/// The followups a visitor may be offered, given the pages they have +/// earned: `seeds` (the page a verified chain sent them to) plus every +/// outcome page reachable from a seed or from `current` over +/// alternatives that ask nothing - never through one that records an +/// answer, since that answer is exactly what they have not given. A +/// front door is never in the result and ends the walk, so "back to +/// the start" never fans out into the site; standing on a page earns +/// nothing by itself beyond what it links to for free. +pub fn earned_followups( + questions: &std::collections::HashMap, + current: &str, + seeds: &[String], is_outcome: &dyn Fn(&Question) -> bool, ) -> std::collections::HashSet { use std::collections::{HashSet, VecDeque}; - let mut seen: HashSet = HashSet::new(); + let mut earned: HashSet = HashSet::new(); let mut queue: VecDeque = VecDeque::new(); - if let Some(q) = resolve_question(questions, from) { + for seed in seeds { + if let Some(q) = questions.get(seed).filter(|q| is_outcome(q)) { + earned.insert(seed.clone()); + queue.push_back(q.clone()); + } + } + if let Some(q) = resolve_question(questions, current) { queue.push_back(q); } while let Some(q) = queue.pop_front() { - for action in q.alternatives.iter().filter_map(|a| a.action.as_deref()) { + for action in q.alternatives.iter().filter(|a| asks_nothing(a)).filter_map(|a| a.action.as_deref()) { let Some(id) = action_target_id(questions, action) else { continue; }; - // A page's own dynamic pattern (`/shape/abc` -> `/shape/[key]`) - // is itself, not somewhere further down. - if id == from || path_matches(&id, from).is_some() || seen.contains(&id) { - continue; - } let target = &questions[&id]; - if !is_outcome(target) { + if !is_outcome(target) || earned.contains(&id) || path_matches(&id, current).is_some() { continue; } - seen.insert(id.clone()); + earned.insert(id.clone()); queue.push_back(target.clone()); } } - seen + earned } /// Resolves a URL path to its question: an exact id first, else the @@ -1198,40 +1231,55 @@ alternatives: let files = vec![ // Front door with three outcomes, one of them templated onto // a dynamic page. - ("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n - name: subscribe\n action: subscribed\n - name: draw\n action: /shape/{curve.key}\n".to_string()), + ("index.yaml".to_string(), "name: Home\nalternatives:\n - name: apply\n action: applied\n record_as: applicants\n - name: subscribe\n action: subscribed\n record_as: subscribers\n - name: draw\n action: /shape/{curve.key}\n record_as: gestures\n".to_string()), ("applied.yaml".to_string(), "name: Applied\nfollowup: true\n".to_string()), ("subscribed.yaml".to_string(), "name: Subscribed\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()), // Nested -> followup by inference; leads on to /subscribed. - ("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n - name: back\n action: /\n".to_string()), + ("shape/[key].yaml".to_string(), "name: Shape\nalternatives:\n - name: watch\n action: /subscribed\n record_as: subscribers\n - name: back\n action: /\n".to_string()), // Reached only by an out-of-band link: nothing points here. ("watching.yaml".to_string(), "name: Watching\nfollowup: true\n".to_string()), // A second, unrelated path with its own outcome. - ("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n".to_string()), + ("kontakt.yaml".to_string(), "name: Kontakt\nalternatives:\n - name: send\n action: takk\n record_as: inquiries\n".to_string()), ("takk.yaml".to_string(), "name: Takk\nfollowup: true\nalternatives:\n - name: back\n action: /\n".to_string()), ]; let questions = build_questions(&files).unwrap(); let is_outcome = |q: &Question| q.is_followup(); - let reach = |from: &str| outcomes_reachable_from(&questions, from, &is_outcome); let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::>(); + let earned = |current: &str, seeds: &[&str]| { + let seeds: Vec = seeds.iter().map(|s| s.to_string()).collect(); + earned_followups(&questions, current, &seeds, &is_outcome) + }; // Templated actions resolve to the dynamic page's pattern. assert_eq!(action_target_id(&questions, "/shape/{curve.key}").as_deref(), Some("/shape/[key]")); assert_eq!(action_target_id(&questions, "/nowhere"), None); - // From the front door: its own outcomes, and what they lead on - // to - but not the other path's outcome, nor the orphan. - assert_eq!(reach("/"), set(&["/applied", "/subscribed", "/shape/[key]"])); - // The other path only sees its own outcome. Its "back to /" - // edge ends the walk at the front door instead of fanning out. - assert_eq!(reach("/kontakt"), set(&["/takk"])); - // Standing on an outcome: nothing further down through outcomes. - assert_eq!(reach("/subscribed"), set(&[])); - assert_eq!(reach("/takk"), set(&[])); - // The email-link page reaches nothing and nothing reaches it. - assert_eq!(reach("/watching"), set(&[])); - // A concrete dynamic path resolves to its pattern and walks on; - // the pattern itself is not "further down" from its own page. - assert_eq!(reach("/shape/abc"), set(&["/subscribed"])); + // No chain, no followups: standing on the front door earns none + // of the pages its answers lead to, and standing on a followup + // one typed in earns nothing either. + assert_eq!(earned("/", &[]), set(&[])); + assert_eq!(earned("/kontakt", &[]), set(&[])); + assert_eq!(earned("/applied", &[]), set(&[])); + + // A verified answer earns exactly where it sent the visitor. + assert_eq!(unlocked_by(&questions, "/", Some("apply")), vec!["/applied"]); + assert_eq!(unlocked_by(&questions, "/", Some("draw")), vec!["/shape/[key]"]); + assert_eq!(unlocked_by(&questions, "/kontakt", Some("send")), vec!["/takk"]); + assert_eq!(earned("/applied", &["/applied"]), set(&["/applied"])); + // An old node without the alternative: the answered question's + // recording alternatives, and nothing beyond that question. + let old: HashSet = unlocked_by(&questions, "/", None).into_iter().collect(); + assert_eq!(old, set(&["/applied", "/subscribed", "/shape/[key]"])); + assert!(!old.contains("/takk")); + + // From an earned page, only links that ask nothing carry on: + // "watch" records an answer, so /subscribed is not earned by + // having drawn a shape; "back" leads to the front door, which + // is never a followup and ends the walk. + assert_eq!(earned("/shape/abc", &["/shape/[key]"]), set(&["/shape/[key]"])); + assert_eq!(earned("/takk", &["/takk"]), set(&["/takk"])); + // The email-link page is earned by nothing on the site. + assert!(!earned("/", &["/applied"]).contains("/watching")); } #[test]