P3 Slice Zero, Task 4 — replace Web free-text selection with the structured harness catalog #1170

Merged
Mos merged 2 commits from feat/p3-slice0-task4-web-catalog-selection into next 2026-08-12 02:50:19 +00:00
Owner

P3 Slice Zero, Task 4 — replace Web free-text selection with the structured harness catalog

Replaces the chat composer's free-text provider/model inputs with structured selects sourced from the merged Task-3 gateway surfaces. The client persists a full {harnessId, providerId, modelId} tuple through PUT /api/chat/preferences/selection before send is enabled, and only catalog-returned options can reach onSend. Client change only — the Socket contract (use-chat-connection.ts) is untouched; that cutover is Task 5.

Traceability

  • Approved design: jarvis-brain docs/plans/2026-08-11-p3-pi-first-harness-adapter-design.md.
  • Approved implementation plan: jarvis-brain …-implementation.md, § Task Four.
  • Base: branched off next @ ebe41513 (SLICE_ZERO_TASK4_BASE_SHA = the Task-3 merge).
  • Candidate HEAD: d752db93 (parent ef66b0e7).

Surfaces consumed (all from merged Task 3, unchanged here)

GET /api/harnesses · GET /api/harnesses/:harnessId/catalog (unknown → typed adapter_unavailable 404) · GET/PUT /api/chat/preferences/selection (structured tuple; typed 422 selection_invalid/model_unavailable, requested tuple echoed).

Files (9, path-fenced — 4 created + 5 modified, all under apps/web/src/)

Create spa/chat/chat-api.ts (+ .spec.ts), spa/chat/use-harness-selection.ts (+ .spec.tsx); modify spa/chat/composer.tsx, spa/pages/chat.tsx (+ chat.spec.tsx), spa/chat/runtime-guards.ts, lib/types.ts.

Design conformance (independently reviewed)

  • Structured tuple, not free text: free-text provider/model inputs removed; onSend receives a projection derived only from the validated persisted tuple, gated on canSend.
  • Catalog-sourced options only: provider/model options come solely from /api/harnesses/:id/catalog; nothing hardcoded; no /api/providers* call on this surface.
  • Provider-scoped model list (review fix M-A): the model <select> is filtered to the intentionally selected providerId; with no provider chosen it offers only its placeholder and stays disabled — a cross-provider model can never be picked, and there is no first-row auto-select.
  • Collision-safe option identity (review fix M-B): each <option> value is the composite ${providerId}:${modelId} and the change handler resolves it back to the exact catalog row (reconstruct-and-compare via find, never split(':')), persisting that row's own tuple — a bare model id is never combined with ambient provider state.
  • No send-before-persist race: canSend requires persistedSelection !== null && tuplesEqual(draft, persisted) && isAvailableInCatalog(...); a monotonic persistRequestRef drops stale PUT responses.
  • Safe client normalization: catalog 404 → fixed CATALOG_UNAVAILABLE_MESSAGE; selection 422 → fixed SELECTION_REJECTED_MESSAGE reading only the closed code enum; the untrusted gateway body.message is never rendered. The requested tuple is preserved verbatim on 422 (separately tested).
  • No client authority smuggling: the PUT body is exactly the 3-id tuple; no scope/userId/tenant/seat field.
  • Runtime guards: all gateway JSON normalized through runtime-guards.ts (array/record checks; enums default to the safe unavailable).
  • Fence/re-export: the two @mosaicstack/types re-exports (HarnessAuthState, HarnessModelAvailability) live inside allowlisted lib/types.ts as pure re-exports — no packages/** edit.

Independent gates (author = pi/subagent ≠ integrator-verifier ≠ code/security reviewer)

  • Integrator-verify: PASS (6 gates) — 9-file fence + scope-fence; red-first re-derived; branch-HEAD focused 40 / full @mosaicstack/web 175 passed / 0 failed; typecheck·lint·Vite-build rc 0. All FOUR anti-masking mutations turned their named test RED on both branch HEAD and the trial-merged tree (empty-catalog→send-disabled, persistence-race, tuple-substitution, raw-body-leak), green-after-restore; the four new selector tests are genuine (each RED under a deliberate violation). The selectModel(providerId, modelId) signature change did not weaken M2/M3/tuple-preservation. Tuple-preservation separately tested.
  • Code + security review: PASS — no blocker/major/minor/nit. Both prior findings (M-A, M-B) confirmed resolved with no new issues (safe composite resolution, no stale highlight, safe unresolvable-value no-op, correct dep array); security boundary + fence intact.

Drift note (for the merge-gate — path 4b)

origin/next advanced ebe41513 → aca28405 via #1165 (docs/framework MOS-STE standard) + #1164 (.woodpecker/ci.yml Pi-runtime provisioning), touching only packages/mosaic/framework/** and .woodpecker/ci.ymldisjoint from this fence. Verified: trial-merge is conflict-free and full merged-tree Web evidence stays green. The merge-gate re-derives this from scratch.

Pre-push hook (typecheck/lint/format) passed on push. Independent merge-gate re-derivation runs before merge. Refs P3 Slice Zero Task 4.

## P3 Slice Zero, Task 4 — replace Web free-text selection with the structured harness catalog Replaces the chat composer's **free-text** provider/model inputs with **structured selects** sourced from the merged Task-3 gateway surfaces. The client persists a full `{harnessId, providerId, modelId}` tuple through `PUT /api/chat/preferences/selection` **before** send is enabled, and only catalog-returned options can reach `onSend`. Client change only — the Socket contract (`use-chat-connection.ts`) is untouched; that cutover is Task 5. ### Traceability - **Approved design:** jarvis-brain `docs/plans/2026-08-11-p3-pi-first-harness-adapter-design.md`. - **Approved implementation plan:** jarvis-brain `…-implementation.md`, **§ Task Four**. - **Base:** branched off `next` @ `ebe41513` (`SLICE_ZERO_TASK4_BASE_SHA` = the Task-3 merge). - **Candidate HEAD:** `d752db93` (parent `ef66b0e7`). ### Surfaces consumed (all from merged Task 3, unchanged here) `GET /api/harnesses` · `GET /api/harnesses/:harnessId/catalog` (unknown → typed `adapter_unavailable` 404) · `GET`/`PUT /api/chat/preferences/selection` (structured tuple; typed 422 `selection_invalid`/`model_unavailable`, requested tuple echoed). ### Files (9, path-fenced — 4 created + 5 modified, all under `apps/web/src/`) Create `spa/chat/chat-api.ts` (+ `.spec.ts`), `spa/chat/use-harness-selection.ts` (+ `.spec.tsx`); modify `spa/chat/composer.tsx`, `spa/pages/chat.tsx` (+ `chat.spec.tsx`), `spa/chat/runtime-guards.ts`, `lib/types.ts`. ### Design conformance (independently reviewed) - **Structured tuple, not free text:** free-text provider/model inputs removed; `onSend` receives a projection derived only from the validated persisted tuple, gated on `canSend`. - **Catalog-sourced options only:** provider/model options come solely from `/api/harnesses/:id/catalog`; nothing hardcoded; **no `/api/providers*` call** on this surface. - **Provider-scoped model list (review fix M-A):** the model `<select>` is filtered to the intentionally selected `providerId`; with no provider chosen it offers only its placeholder and stays disabled — a cross-provider model can never be picked, and there is no first-row auto-select. - **Collision-safe option identity (review fix M-B):** each `<option>` value is the composite `${providerId}:${modelId}` and the change handler resolves it back to the **exact catalog row** (reconstruct-and-compare via `find`, never `split(':')`), persisting that row's own tuple — a bare model id is never combined with ambient provider state. - **No send-before-persist race:** `canSend` requires `persistedSelection !== null && tuplesEqual(draft, persisted) && isAvailableInCatalog(...)`; a monotonic `persistRequestRef` drops stale PUT responses. - **Safe client normalization:** catalog 404 → fixed `CATALOG_UNAVAILABLE_MESSAGE`; selection 422 → fixed `SELECTION_REJECTED_MESSAGE` reading only the closed `code` enum; the untrusted gateway `body.message` is **never** rendered. The requested tuple is preserved verbatim on 422 (separately tested). - **No client authority smuggling:** the PUT body is exactly the 3-id tuple; no scope/userId/tenant/seat field. - **Runtime guards:** all gateway JSON normalized through `runtime-guards.ts` (array/record checks; enums default to the *safe* `unavailable`). - **Fence/re-export:** the two `@mosaicstack/types` re-exports (`HarnessAuthState`, `HarnessModelAvailability`) live inside allowlisted `lib/types.ts` as pure re-exports — no `packages/**` edit. ### Independent gates (author = pi/subagent ≠ integrator-verifier ≠ code/security reviewer) - **Integrator-verify:** PASS (6 gates) — 9-file fence + scope-fence; red-first re-derived; branch-HEAD **focused 40 / full @mosaicstack/web 175 passed / 0 failed**; typecheck·lint·Vite-build rc 0. All FOUR anti-masking mutations turned their named test RED on **both** branch HEAD and the trial-merged tree (empty-catalog→send-disabled, persistence-race, tuple-substitution, raw-body-leak), green-after-restore; the four new selector tests are genuine (each RED under a deliberate violation). The `selectModel(providerId, modelId)` signature change did **not** weaken M2/M3/tuple-preservation. Tuple-preservation separately tested. - **Code + security review:** PASS — **no blocker/major/minor/nit**. Both prior findings (M-A, M-B) confirmed resolved with no new issues (safe composite resolution, no stale highlight, safe unresolvable-value no-op, correct dep array); security boundary + fence intact. ### Drift note (for the merge-gate — path 4b) `origin/next` advanced `ebe41513 → aca28405` via #1165 (docs/framework MOS-STE standard) + #1164 (`.woodpecker/ci.yml` Pi-runtime provisioning), touching only `packages/mosaic/framework/**` and `.woodpecker/ci.yml` — **disjoint from this fence**. Verified: trial-merge is conflict-free and full merged-tree Web evidence stays green. The merge-gate re-derives this from scratch. Pre-push hook (typecheck/lint/format) passed on push. Independent merge-gate re-derivation runs before merge. `Refs P3 Slice Zero Task 4`.
jason.woltje added 2 commits 2026-08-12 02:36:10 +00:00
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
Fix-in-lane for the two Task-4 review findings, red-first TDD, inside the
existing nine-file fence.

M-A: the composer now filters model <option>s to the intentionally selected
provider (harness.providerId). With no provider chosen the model select offers
only its placeholder, so a user can never pick a model that belongs to a
different provider.

M-B: the model <option> value is now the collision-safe composite
`${providerId}:${modelId}` (was the bare modelId), the controlled select value
mirrors that same identity so the exact catalog row highlights, and the change
handler resolves the composite back to the exact catalog row and persists that
row's own {harnessId, providerId, modelId}. selectModel(providerId, modelId) no
longer combines a bare model id with ambient provider state, so two providers
exposing the same modelId stay distinct.

New red-first tests prove: cross-provider models absent, identical modelIds
under two providers stay distinct and resolve to the intended tuple, a provider
change invalidates the old model and keeps send disabled until the new tuple
persists, and a model pick does not enable send until its exact PUT resolves.
The four existing anti-masking invariants and the 422 tuple-preservation test
are intact.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
Mos merged commit 9cd9409089 into next 2026-08-12 02:50:19 +00:00
Sign in to join this conversation.