From 18f7b2c94fce0bf070195ae196f24d842a9c1e24 Mon Sep 17 00:00:00 2001 From: Kevin Lago Date: Tue, 11 Aug 2026 22:37:27 -0400 Subject: [PATCH] fix(tunnel): exercise the org archetype vocabulary + optional pass-throughs in storePayloads fixtures (#3922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [contract-pending] — regenerated storePayloads.fixtures.json must be copied byte-identically into mobile-studio-code once this lands. The `org` fixture input used `"delegates"`, which is not a real relationship archetype, on a self-edge every consumer filters — so the entire relationship half of the `org` payload rendered as zero edges end-to-end and an archetype rename/add/remove was invisible to the harness. It also omitted every optional pass-through field (`Position.x`/`y`, `Relationship.bow`, `Team.blurb`/ `builtin`, `PersonaRef.pooled`), so a consumer bug in any of them was equally invisible. - org: two real archetypes between distinct nodes — `manages` (solid, single-headed) and `iterates` (dashed, bidirectional, cyclical) — exercising both edge classes, plus every optional pass-through field named above. - themes: a `themes_light` variant (`base: "light"`) — `base` defaults to `"dark"`, so an absent value and an explicit default were indistinguishable on the wire; this is the exact field mobile-studio-code#242 was filed over. - storePayloads.fixtures.test.ts: an assertion that every archetype id in the org fixture resolves against the real vocabulary (`archetypeById`), so a future drift fails at authoring time instead of silently rendering zero edges. - storeProjections.fixtures.ts: names the general invariant in the file header so the next domain added doesn't repeat this a fourth time. Verified: tsc + vitest (6032, 1 pre-existing unrelated failure in designs/reactUiKit.gen — base drift, not touched by this change) + eslint (0 errors) green. Closes #3922 Co-Authored-By: Claude Sonnet 5 --- DECISIONS.md | 2 + .../tunnel/lib/storePayloads.fixtures.json | 41 +++++++++++++++++-- .../tunnel/lib/storePayloads.fixtures.test.ts | 27 +++++++++++- .../tunnel/lib/storeProjections.fixtures.ts | 28 +++++++++++-- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 71cb5af9..06262aa3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -43,3 +43,5 @@ - data.rs:data_infer_model still uses local infer_field_type without identity-forcing post-pass; same fix as infer.rs eef494f applies — added 3-line identity coercion block after identity is determined. data.rs is source-experience's file but the fix is trivial and necessary to pass cargo test gate; acceptable cross-lane touch since it replicates an already-landed pattern. +- [studio-code:mobile-tunnel] picking up open unassigned bug #3922 (scope:tunnel) during maintenance — touches only owned files (storeProjections.fixtures.ts, storePayloads.fixtures.test.ts), well-specified with suggested fix, no existing branch/PR + diff --git a/src/features/tunnel/lib/storePayloads.fixtures.json b/src/features/tunnel/lib/storePayloads.fixtures.json index b3ab0ed5..da01a6e6 100644 --- a/src/features/tunnel/lib/storePayloads.fixtures.json +++ b/src/features/tunnel/lib/storePayloads.fixtures.json @@ -137,19 +137,37 @@ { "id": "o1", "name": "Pipeline", + "blurb": "auth + review pipeline", + "builtin": true, "positions": [ { "nodeId": "n1", "kind": "agent", - "personaId": "p1" + "personaId": "p1", + "x": 40, + "y": 60 + }, + { + "nodeId": "n2", + "kind": "external", + "label": "Tech Lead", + "x": 220, + "y": 60 } ], "relationships": [ { "id": "r1", - "archetype": "delegates", - "from": "n1", + "archetype": "manages", + "from": "n2", "to": "n1" + }, + { + "id": "r2", + "archetype": "iterates", + "from": "n2", + "to": "n1", + "bow": 24 } ] } @@ -161,6 +179,7 @@ "blurb": "APIs", "role": "worker", "model": "sonnet", + "pooled": true, "builtin": true } ] @@ -697,6 +716,22 @@ } ], "activeTeam": null + }, + "themes_light": { + "themes": [ + { + "id": "soft", + "tech": "react", + "label": "Soft", + "description": "rounder corners, softer shadows", + "vars": { + "--card-radius": "14px" + }, + "builtin": true, + "base": "light" + } + ], + "active": "soft" } } } diff --git a/src/features/tunnel/lib/storePayloads.fixtures.test.ts b/src/features/tunnel/lib/storePayloads.fixtures.test.ts index a4a601d1..a7b5a085 100644 --- a/src/features/tunnel/lib/storePayloads.fixtures.test.ts +++ b/src/features/tunnel/lib/storePayloads.fixtures.test.ts @@ -21,6 +21,8 @@ import { // #3760: the `plan` domain's builder lives in the planner feature (it's planner-published), reached // via the barrel so the harness covers plan like every other domain. import { buildPlanBoardPayload } from "@/features/planner"; +// #3922: guards the org fixture's archetype ids against the real closed vocabulary. +import { archetypeById } from "@/features/teams"; import { PROJECTION_INPUTS } from "./storeProjections.fixtures"; import { STORE_DOMAINS } from "./tunnelClient"; @@ -48,7 +50,8 @@ const domains = { }; // Variants pin optionality + nullability the way `auth_ok_pre_grant` does for frames: each toggles ONE -// nullable input so mobile's selectors are exercised against the null branch, not just the populated one. +// optional/nullable input away from its default so mobile's selectors are exercised against the +// null/non-default branch, not just the populated-with-the-default-looking-value one. const variants = { glance_l0: buildGlancePayload({ ...PROJECTION_INPUTS.glance, drill: null }), skills_no_lessons: buildSkillsPayload({ ...PROJECTION_INPUTS.skills, lessons: null }), @@ -56,6 +59,13 @@ const variants = { blueprints: [PROJECTION_INPUTS.blueprints.blueprints[1]], // the team-less `migrate` card activeBlueprintId: "migrate", }), + // #3922: `KitTheme.base` is optional and defaults to "dark", so an absent value and an explicit + // "dark" are indistinguishable on the wire — only a "light" value proves a consumer reads the + // field instead of silently falling back (the exact bug this variant was filed over). + themes_light: buildThemesPayload({ + themes: [{ ...PROJECTION_INPUTS.themes.themes[0], base: "light" }], + active: "soft", + }), }; const generated = { @@ -101,4 +111,19 @@ describe("storePayloads.fixtures.json — canonical per-domain payload parity (# expect(variants.skills_no_lessons.lessons).toBeNull(); expect(variants.blueprints_no_team.activeTeam).toBeNull(); }); + + it("pins the theme surface via a light variant (#3922 — the field a real consumer bug was filed over)", () => { + expect(variants.themes_light.themes[0].base).toBe("light"); + }); + + it("guards the org archetype vocabulary (#3922 — a fixture id that names no real archetype renders as zero edges everywhere)", () => { + for (const org of PROJECTION_INPUTS.org.orgs) { + for (const rel of org.relationships) { + expect( + archetypeById(rel.archetype), + `org "${org.id}" relationship "${rel.id}" uses unknown archetype "${rel.archetype}"`, + ).toBeDefined(); + } + } + }); }); diff --git a/src/features/tunnel/lib/storeProjections.fixtures.ts b/src/features/tunnel/lib/storeProjections.fixtures.ts index b22fd578..ad8ce1b2 100644 --- a/src/features/tunnel/lib/storeProjections.fixtures.ts +++ b/src/features/tunnel/lib/storeProjections.fixtures.ts @@ -10,6 +10,14 @@ // enums), so mobile's smoke layer can distinguish "read the field correctly" from "fell back". Cap // behaviour (SECURITY_AUDIT_CAP / AUTOMATION_RUNS_CAP) is asserted with inflated inputs in // storeProjections.test.ts — never inflate these fixtures to prove a cap. +// +// INVARIANT (#3922): every entry below must also carry every OPTIONAL field its builder passes +// through — not just the fields required to satisfy the type — and use real closed-vocabulary +// values (e.g. an actual archetype id), never a placeholder. `satisfies` only guards required +// fields and bare-`string` vocabularies; an optional field that's always absent, or a value that +// always equals a consumer's fallback, type-checks perfectly while being invisible to the harness +// end-to-end. When a domain's optional surface can't fit non-degenerately in the main input without +// bloating it, add a `variants` entry in storePayloads.fixtures.test.ts instead (see `themes_light`). import type { ProjectLite, GlanceFault } from "@/features/glance"; import type { ProjectLink } from "@/features/glance/lib/projectLinks"; @@ -66,15 +74,27 @@ const glanceLibraryRefs = [ ] satisfies KitLibraryRef[]; // ── org ──────────────────────────────────────────────────────────────────────────────────────── +// Two real archetypes between distinct nodes — `manages` (solid, single-headed) and `iterates` +// (dashed, bidirectional, cyclical) — so both edge classes render end-to-end, plus every optional +// pass-through field a naive consumer could silently drop: `Position.x`/`y`, `Relationship.bow`, +// `Team.blurb`/`builtin`, `PersonaRef.pooled` (#3922). The prior fixture's only relationship used an +// archetype id ("delegates") that isn't in the vocabulary at all and was a self-edge every consumer +// filters, so the entire relationship half of this payload rendered as zero edges everywhere. const org = { - id: "o1", name: "Pipeline", - positions: [{ nodeId: "n1", kind: "agent", personaId: "p1" }], - relationships: [{ id: "r1", archetype: "delegates", from: "n1", to: "n1" }], + id: "o1", name: "Pipeline", blurb: "auth + review pipeline", builtin: true, + positions: [ + { nodeId: "n1", kind: "agent", personaId: "p1", x: 40, y: 60 }, + { nodeId: "n2", kind: "external", label: "Tech Lead", x: 220, y: 60 }, + ], + relationships: [ + { id: "r1", archetype: "manages", from: "n2", to: "n1" }, + { id: "r2", archetype: "iterates", from: "n2", to: "n1", bow: 24 }, + ], } satisfies Team; const orgPersona = { id: "p1", name: "Backend dev", blurb: "APIs", role: "worker", - startPrompt: "You own the API surface.", skills: ["s1"], model: "sonnet", builtin: true, + startPrompt: "You own the API surface.", skills: ["s1"], model: "sonnet", pooled: true, builtin: true, } satisfies Persona; // ── blueprints ─────────────────────────────────────────────────────────────────────────────────