Storylines: the same site told several ways, one per visitor, counted
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m1s

Any text a person reads may be a list, one entry per storyline, every
list the same length. A visitor is given one storyline for the whole
site and reads it on every page and in every mail about their case.
Records and events carry the storyline; GET /report gives counts per
bucket, storyline and state, and nothing else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 15:11:27 +02:00
co-authored by Claude Fable 5.1
parent cc3107e502
commit f395ace556
24 changed files with 561 additions and 45 deletions
+13 -6
View File
@@ -2358,8 +2358,9 @@ pub async fn get_question(
use crate::content::{fill_from_user, path_matches, resolve_question};
use crate::server::AppState;
let state = expect_context::<AppState>();
let questions = state.questions.load();
let Some(mut question) = resolve_question(&questions, &path) else {
let storylines = state.questions.load();
let questions = storylines.at(crate::server::storyline::current().await);
let Some(mut question) = resolve_question(questions, &path) else {
return Ok(None);
};
let session: tower_sessions::Session = leptos_axum::extract().await?;
@@ -2401,7 +2402,7 @@ pub async fn get_question(
pub async fn get_site() -> Result<SiteConfig, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
let mut site = state.site.load().as_ref().clone();
let mut site = state.site.load().at(crate::server::storyline::current().await).clone();
// Not content: whether this instance can serve tiles at all is its
// own configuration, and the browser has to know before it decides
// whether to draw a map or ask for two numbers.
@@ -2435,7 +2436,8 @@ pub async fn list_qualifying_questions(
.map_err(|e| ServerFnError::new(e.to_string()))?;
let now_ms = chrono::Utc::now().timestamp_millis();
let questions = state.questions.load();
let storylines = state.questions.load();
let questions = storylines.at(crate::server::storyline::current().await);
// What the chain verifiably answered - the same lookup a
// `requires_chain` gate does, so a forged or foreign hash resolves
// to nothing - and therefore where it sent the visitor.
@@ -2583,6 +2585,7 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
SUGGESTION_ALTERNATIVE,
&responses,
now_ms,
0,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
@@ -2593,6 +2596,7 @@ pub async fn suggest_page_edit(question_id: String, text: String) -> Result<(),
alternative: SUGGESTION_ALTERNATIVE.to_string(),
responses: responses.clone(),
timestamp_ms: now_ms,
storyline: 0,
};
if let Err(e) = emit_answer_submitted(&state.nats, &event).await {
tracing::warn!(question_id, "the suggestion is on file but was not announced: {e}");
@@ -2679,7 +2683,8 @@ pub async fn submit_answer(
// resolve_question, not a plain map get: a dynamic page's concrete
// path (what the client holds as its question id) resolves to the
// pattern page it came from.
let question = crate::content::resolve_question(&state.questions.load(), &question_id)
let storyline = crate::server::storyline::current().await;
let question = crate::content::resolve_question(state.questions.load().at(storyline), &question_id)
.ok_or_else(|| ServerFnError::new("unknown question"))?;
let session: tower_sessions::Session = leptos_axum::extract().await?;
@@ -2796,6 +2801,7 @@ pub async fn submit_answer(
alternative: alternative.clone(),
responses: responses.clone(),
timestamp_ms,
storyline: storyline as u32,
},
)
.await
@@ -2821,6 +2827,7 @@ pub async fn submit_answer(
&alternative,
&responses,
timestamp_ms,
storyline as u32,
)
.await
{
@@ -2829,7 +2836,7 @@ pub async fn submit_answer(
// Arrival is a state too: what the schema says the first
// state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
crate::mail::on_state(&state, bucket, &chain_hash, &initial, &responses, storyline as u32).await;
crate::people::on_state(&state, bucket, &chain_hash, &initial, &responses).await;
}
}