Files
portal/src/chain.rs
T
Bendik Aagaard LynghaugandClaude Fable 5.1 e67e841f8c 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
2026-09-22 16:58:40 +02:00

92 lines
3.2 KiB
Rust

//! SHA-256 hashed answer chain. `parent_hashes` is DAG-shaped, but
//! nothing produces more than one parent yet - the shape is reserved
//! for follow-up questions posted back into a visitor's chain.
#![cfg(feature = "ssr")]
use sha2::{Digest, Sha256};
/// Hashes one chain node from its question, its parent hash(es), the
/// submitted responses, and a timestamp. Parents are sorted first so the
/// hash doesn't depend on the order multiple parents happened to arrive
/// in.
/// Where submitted chain nodes are indexed - hash → which question was
/// answered. Small on purpose (no responses), and shared by every
/// portal instance on the JetStream (hashes are globally unique, so
/// cross-site collisions can't happen). This is what lets
/// `requires_chain` pages verify a visitor's `?chain=` actually ends
/// at the question they claim to have answered.
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<String>,
pub timestamp_ms: i64,
}
/// Indexes one submitted node. Best-effort by design (the caller logs
/// and continues): the NATS event is the durable record, this is a
/// lookup convenience.
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 {
Ok(store) => store,
Err(_) => {
js.create_key_value(async_nats::jetstream::kv::Config {
bucket: CHAIN_BUCKET.to_string(),
..Default::default()
})
.await?
}
};
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?;
Ok(())
}
/// Looks a chain hash up - `None` covers both "no such node" and
/// "bucket not created yet" (no submissions anywhere), which read the
/// same to a `requires_chain` check: the claimed lineage can't be
/// verified, so the gate stays shut.
pub async fn lookup_node(
js: &async_nats::jetstream::Context,
chain_hash: &str,
) -> Option<ChainNode> {
let store = js.get_key_value(CHAIN_BUCKET).await.ok()?;
let bytes = store.get(chain_hash).await.ok()??;
serde_json::from_slice(&bytes).ok()
}
pub fn hash_node(
question_id: &str,
parent_hashes: &[String],
responses: &serde_json::Value,
timestamp_ms: i64,
) -> String {
let mut parents = parent_hashes.to_vec();
parents.sort();
let payload = serde_json::json!({
"questionId": question_id,
"parentHashes": parents,
"responses": responses,
"timestamp": timestamp_ms,
});
let data = serde_json::to_vec(&payload).expect("json values always serialize");
let mut hasher = Sha256::new();
hasher.update(&data);
format!("{:x}", hasher.finalize())
}