From a1fc3330797e4392c24b0243452153bf00280035 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Tue, 25 Aug 2026 15:36:07 +0200 Subject: [PATCH] Attended-bucket lint rule; app-lifetime resources fix first-nav corruption validate_questions now rejects content where a record_as bucket is read by nothing: every bucket must be listed by some Kv resource in the same repo (a desk) or carry aggregates.yaml's new attended_by annotation naming the automation that consumes it - no publicly collected answer may land where nothing reads. Separately, all server-fn resources (question/user/nav) move to a PortalShell above the routes, created once and provided via context. Per-page Resources broke on the first client-side navigation: the remounted component's fresh Resource consumed a stale SSR hydration buffer - the nav list [[id, name], ..] deserialized as a Page (serde fills structs from sequences in field order), so uhhm.no's landing question rendered chain-gated behind its own nav entry instead of the /develop/proposal form. Co-Authored-By: Claude Fable 5 --- src/aggregates/mod.rs | 9 +++++ src/app.rs | 61 ++++++++++++++++++++++++++------ src/content.rs | 82 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/src/aggregates/mod.rs b/src/aggregates/mod.rs index 9688071..0040807 100644 --- a/src/aggregates/mod.rs +++ b/src/aggregates/mod.rs @@ -30,6 +30,12 @@ pub struct AggregateSchema { pub event_for_state: HashMap, pub state_for_event: HashMap, pub transitions: HashMap>, + /// Names what consumes this bucket's answers when no page in the + /// content repo reads it (e.g. "n8n newsletter compose"). + /// Free-text - it exists so `validate_questions`' attended-bucket + /// rule has an explicit, auditable opt-out instead of a silent + /// one, and so the next reader knows where the answers go. + pub attended_by: Option, } impl AggregateSchema { @@ -57,6 +63,8 @@ struct RawAggregateSchema { states: HashMap, #[serde(default)] transitions: HashMap>, + #[serde(default)] + attended_by: Option, } #[derive(Debug, serde::Deserialize)] @@ -123,6 +131,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result impl IntoView { }} - }> - - - + } } +/// Every server-fn resource the pages read, created exactly once for +/// the app's lifetime and handed down via context. Wrapper structs +/// because a bare `Resource` context is claimed by whoever provides +/// that T last. +#[derive(Clone, Copy)] +struct QuestionRes(Resource, ServerFnError>>); +#[derive(Clone, Copy)] +struct UserRes(Resource, ServerFnError>>); +#[derive(Clone, Copy)] +struct NavRes(Resource, ServerFnError>>); + +/// Owns the app's data resources, above the routes and reactive on the +/// location instead of recreated per page. Route components creating +/// their own resources broke on the first client-side navigation: the +/// remounted component's fresh Resource consumed a stale SSR hydration +/// buffer instead of fetching - concretely, the nav list +/// `[[id, name], ..]` deserialized as a `Page` (serde fills a struct +/// from a sequence in field order), rendering the landing question +/// gated behind its own nav entry. Stable resources + context makes +/// that class of misalignment impossible: navigation only changes a +/// key, and a key change always refetches. #[component] -fn QuestionPage() -> impl IntoView { +fn PortalShell() -> impl IntoView { let location = use_location(); let query = use_query_map(); let path = Memo::new(move |_| { @@ -101,16 +119,36 @@ fn QuestionPage() -> impl IntoView { p } }); - let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain"))); - let query_email = Memo::new(move |_| query.with(|q| q.get("email"))); + let chain = Memo::new(move |_| query.with(|q| q.get("chain"))); let question = Resource::new( - move || (path.get(), parent_hash.get()), + move || (path.get(), chain.get()), |(path, chain)| get_question(path, chain), ); let user = Resource::new(|| (), |_| current_user()); - // App's shared site resource (see App) - one fetch per session, - // and the Title tied to it survives navigation. + let nav = Resource::new(move || chain.get().is_some(), list_qualifying_questions); + provide_context(QuestionRes(question)); + provide_context(UserRes(user)); + provide_context(NavRes(nav)); + + view! { + }> + + + + } +} + +#[component] +fn QuestionPage() -> impl IntoView { + let query = use_query_map(); + let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain"))); + let query_email = Memo::new(move |_| query.with(|q| q.get("email"))); + + // All shared, app-lifetime resources (see PortalShell / App) - + // never created per navigation. + let QuestionRes(question) = expect_context(); + let UserRes(user) = expect_context(); let site = expect_context::>>(); view! { @@ -295,7 +333,8 @@ fn QuestionView( /// claiming front-page space (an owner also sees the gated desks here). #[component] fn QuestionNav(current_id: String, has_chain: bool) -> impl IntoView { - let nav = Resource::new(move || has_chain, list_qualifying_questions); + let _ = has_chain; // keyed into the shared resource by PortalShell + let NavRes(nav) = expect_context(); view! { {move || { diff --git a/src/content.rs b/src/content.rs index 7cd7fb6..935b472 100644 --- a/src/content.rs +++ b/src/content.rs @@ -948,6 +948,54 @@ pub fn validate_questions( } } } + + // Attended buckets: no publicly collected answer may land somewhere + // nothing reads. Every `record_as` bucket must either be read back + // by some Kv resource in this same content repo (a desk or listing) + // or carry an explicit `attended_by:` annotation in aggregates.yaml + // naming the automation that consumes it. This is a contract, not a + // convention, because convention already failed once: a question + // was dropped and its bucket - answers included - silently fell out + // of every reader's view. + let mut read_buckets = std::collections::HashSet::new(); + let note_resource = |spec: &ResourceSpec, set: &mut std::collections::HashSet| { + if let ResourceSource::Kv { bucket } = &spec.source { + set.insert(bucket.clone()); + } + }; + for question in questions.values() { + for alternative in &question.alternatives { + for feature in &alternative.features { + if let Some(spec) = &feature.resource { + note_resource(spec, &mut read_buckets); + } + for requirement in &feature.requirements { + if let Some(spec) = &requirement.resource { + note_resource(spec, &mut read_buckets); + } + if let Some(bind) = &requirement.bind { + note_resource(&bind.resource, &mut read_buckets); + } + } + } + } + } + for question in questions.values() { + for alternative in &question.alternatives { + if let Some(bucket) = &alternative.record_as { + let attended = read_buckets.contains(bucket) + || aggregates + .get(bucket) + .is_some_and(|schema| schema.attended_by.is_some()); + if !attended { + anyhow::bail!( + "question {:?} alternative {:?}: record_as {bucket:?} is unattended - no page reads that bucket back, and aggregates.yaml declares no attended_by for it. Add a desk/listing resource over it, or annotate the automation that consumes it.", + question.id, alternative.name + ); + } + } + } + } Ok(()) } @@ -1501,6 +1549,40 @@ alternatives: assert!(validate_questions(&questions, &Default::default()).is_err()); } + #[test] + fn recorded_buckets_must_be_attended() { + let write_only = vec![( + "index.yaml".to_string(), + "name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(), + )]; + let questions = build_questions(&write_only).unwrap(); + let err = validate_questions(&questions, &Default::default()).unwrap_err(); + assert!(err.to_string().contains("unattended"), "got: {err}"); + + // A desk (any Kv resource over the bucket, anywhere in the + // repo) attends it. + let with_desk = vec![ + ( + "index.yaml".to_string(), + "name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(), + ), + ( + "review/index.yaml".to_string(), + "name: Desk\nalternatives:\n - name: Inbox\n features:\n - name: \"\"\n resource:\n requires_group: owners\n source:\n kind: kv\n bucket: inbox\n".to_string(), + ), + ]; + let questions = build_questions(&with_desk).unwrap(); + assert!(validate_questions(&questions, &Default::default()).is_ok()); + + // ..or an explicit attended_by annotation in aggregates.yaml. + let aggregates = crate::aggregates::parse_aggregates_yaml( + "aggregates:\n - bucket: black_hole\n initial: open\n attended_by: n8n nightly digest\n states:\n open: { event: submitted }\n", + ) + .unwrap(); + let questions = build_questions(&write_only).unwrap(); + assert!(validate_questions(&questions, &aggregates).is_ok()); + } + #[test] fn actions_may_not_target_dynamic_pages() { let files = vec![