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 ─────────────────────────────────────────────────────────────────────────────────