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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-22 16:58:40 +02:00
co-authored by Claude Fable 5.1
parent 366ff51a0a
commit e67e841f8c
5 changed files with 123 additions and 68 deletions
+18 -20
View File
@@ -2033,18 +2033,19 @@ pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
/// 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<String>,
) -> Result<Vec<(String, String)>, 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::<AppState>();
@@ -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<String> = 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<String> = 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, &current, &is_outcome);
let earned = earned_followups(&questions, &current, &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}");
}