Attended-bucket lint rule; app-lifetime resources fix first-nav corruption
Test / test (push) Successful in 25s

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 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-25 15:36:07 +02:00
co-authored by Claude Fable 5
parent 0d6cdb8a00
commit a1fc333079
3 changed files with 141 additions and 11 deletions
+9
View File
@@ -30,6 +30,12 @@ pub struct AggregateSchema {
pub event_for_state: HashMap<String, String>,
pub state_for_event: HashMap<String, String>,
pub transitions: HashMap<String, Vec<String>>,
/// 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<String>,
}
impl AggregateSchema {
@@ -57,6 +63,8 @@ struct RawAggregateSchema {
states: HashMap<String, RawState>,
#[serde(default)]
transitions: HashMap<String, Vec<String>>,
#[serde(default)]
attended_by: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
@@ -123,6 +131,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
event_for_state,
state_for_event,
transitions: raw_schema.transitions,
attended_by: raw_schema.attended_by,
},
);
}
+50 -11
View File
@@ -81,16 +81,34 @@ pub fn App() -> impl IntoView {
}}
</Suspense>
<Router>
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
<PortalShell/>
</Router>
}
}
/// 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<T>` context is claimed by whoever provides
/// that T last.
#[derive(Clone, Copy)]
struct QuestionRes(Resource<Result<Option<Page>, ServerFnError>>);
#[derive(Clone, Copy)]
struct UserRes(Resource<Result<Option<User>, ServerFnError>>);
#[derive(Clone, Copy)]
struct NavRes(Resource<Result<Vec<(String, String)>, 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! {
<Routes fallback=|| view! { <NotFound/> }>
<Route path=path!("") view=QuestionPage/>
<Route path=path!("/*any") view=QuestionPage/>
</Routes>
}
}
#[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::<Resource<Result<SiteConfig, ServerFnError>>>();
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! {
<Suspense fallback=|| ()>
{move || {
+82
View File
@@ -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<String>| {
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![