Skip to content

feat(dashboard): zod trust boundary — schema-validated audit-event parsing (PR3/3)#37

Merged
kaicianflone merged 6 commits into
mainfrom
fix/dashboard-zod-trust-boundary
May 6, 2026
Merged

feat(dashboard): zod trust boundary — schema-validated audit-event parsing (PR3/3)#37
kaicianflone merged 6 commits into
mainfrom
fix/dashboard-zod-trust-boundary

Conversation

@kaicianflone
Copy link
Copy Markdown
Collaborator

Summary

PR3 of 3 from ~/.gstack/projects/consensus-tools-toolkit/ceo-plans/dashboard-zod-trust-boundary.md. Closes round-3 findings B, D, E, F (and implicitly C, J).

Wires the dashboard render paths through PR2's Tier-0 finalDecisionPayloadSchema + parseTypedPayload helper. Replaces ad-hoc shape checks (typeof === 'object' && !Array.isArray(), !e.payload_json || e.payload_json === '') scattered across components with a single validated boundary. Same parser called from every consumer eliminates the page-divergence bug (finding F).

Architecture

events (untrusted server data)
    │
    ▼
parseTypedPayload(payload, schemaForType(type), context)
    │  uses Tier-0 finalDecisionPayloadSchema (PR2)
    │  returns {status: 'empty' | 'invalid' | 'ok', value?}
    ▼
discriminated tagged result — TypeScript exhaustive switch forces
explicit handling of each branch (closes silent-fallback class of bug)
    │
    ├─ 'empty' → render "decision pending" degraded card
    ├─ 'invalid' → render explicit "malformed event" placeholder + driftCount++
    └─ 'ok' → use canonical camelCase fields (riskScore, guardType, etc.)

Implementation (orchestrated via /gstack-orchestrate)

Task File Purpose
T01 vitest.config.ts, test-setup.ts, deps jsdom env + @testing-library/react@^16 + @testing-library/jest-dom@^6 + jsdom@^25
T02 components/dashboard/DriftBanner.tsx (new) Component primitive: count > 0 → banner with role="alert" aria-live="polite"; count = 0 → null. No PII / payload data in banner text. Created + tested but not yet mounted on any page — see Out of Scope below.
T03 pages/BoardDetailPage.tsx + new buildRunDecisions.ts helper Replace inline safeParseJSON with parseTypedPayload. On invalid: do NOT overwrite map[run_id]. Read canonical decision.riskScore / decision.guardType. Closes findings B (empty-input misclassification) and F (BoardDetail/RunDetail divergence). Track driftCount.
T04 pages/RunDetailPage.tsx + new parseRunDecision.ts helper Tagged-result switch handles all three statuses. 'invalid' non-empty input renders explicit malformed-event placeholder. Risk badge conditionally rendered (handles chat-approval-limited shape). Closes finding D (NaN% from missing risk_score).
T05 components/agents/AgentsPanel.tsx + new parseAgentMetadata.ts helper Replace short-circuit-to-p.metadata with participantMetadataSchema-backed parse. Schema's .safeParse() always returns fresh object → mutations don't affect source. Closes finding E (live-reference mutation hazard) + finding C (Date/Map/Set/class instances rejected).
T06 components/workflow/EventTimeline.tsx + new parseEventList.ts helper Memoize parsed events at component top — parse once per events array change, not per render row. Drop dual-case payload.guard_type || payload.guardType reads (schema's .transform() normalizes). Tracks driftCount.

Round-3 findings closed

ID Severity Finding Closed by
B High (multi-source) Empty-input check misclassifies 0/false/null/whitespace; can overwrite valid decisions with {} T03 — parseTypedPayload returns 'invalid' for non-empty malformed; map write only on 'ok'
C (implicit) parseMetadata accepts Date/Map/Set/class instances T05 — participantMetadataSchema rejects non-plain objects
D High (Codex pivot) NaN% rendered when valid JSON has unexpected shape T04 — degraded-card / malformed-placeholder render branches; riskScore !== undefined guard
E High (Claude pivot) parseMetadata short-circuits to live p.metadata reference T05 — schema returns fresh object on every parse
F Medium BoardDetail uses last-write-wins, RunDetail uses .find() first match — same run shows diverging FINAL_DECISION outcomes T03 — both pages call same parseTypedPayload(..., finalDecisionPayloadSchema), same canonical output
J (implicit) TypeScript any narrowing in parseMetadata T05 — schema returns inferred typed object

Mandatory regression tests (IRON RULE per /plan-eng-review)

All 4 mandatory regressions are tested with failing-before-PR / passing-after-PR semantics:

  • B: BoardDetailPage.test.tsxpayload_json='null' literal does not overwrite valid map[run_id]; payload_json='' does not write; type-mismatch does not overwrite
  • D: RunDetailPage.test.tsx — chat-approval emit shape (no risk fields) renders without NaN%; malformed riskScore renders explicit placeholder
  • E: AgentsPanel.test.tsx — mutating returned metadata does not mutate source p.metadata
  • F: BoardDetailPage.test.tsx — canonical camelCase event matches direct parseTypedPayload output (same canonical view across pages)

Test counts

Suite Before PR3 After PR3
safeJson.test.ts 27 27 (unchanged from PR2)
api.test.ts 21 21
DriftBanner.test.tsx 0 5 (new)
BoardDetailPage.test.tsx 0 6 (new — B + F regressions)
RunDetailPage.test.tsx 0 5 (new — D regression)
AgentsPanel.test.tsx 0 7 (new — E + C regressions)
EventTimeline.test.tsx 0 8 (new — memoization)
Dashboard total 48 79 (+31)

Verification

  • pnpm --filter @consensus-tools/dashboard test — 79/79 pass
  • pnpm --filter @consensus-tools/dashboard typecheck — clean
  • pnpm --filter @consensus-tools/dashboard build — passes (chunk-size warning is pre-existing, not introduced)
  • pnpm test — 47/47 turbo tasks pass across the whole monorepo
  • pnpm dep-check — clean (233 modules, 432 deps — gained the new helper files + dashboard deps)

Out of scope for this PR

  1. DriftBanner is not yet mounted on any page. Each page (BoardDetail, RunDetail, EventTimeline) tracks driftCount via state but does not render the <DriftBanner> JSX. Per /plan-eng-review report: "DriftBanner component (PR3) would benefit from /plan-design-review before implementation." Mounting decisions (placement, exact copy, color, dismissibility) need a design pass first. Tracked as P2 follow-up: a small PR4 can run /plan-design-review on the banner UX and then mount it.
  2. Findings A, G, H, I from round-3 — file as separate P1 follow-ups (per plan).
  3. WorkflowsDashboard.tsx:127 raw JSON.parse of workflow.definition — different concern (workflow data, not audit event); P3 follow-up.
  4. Telemetry backend for drift counter — banner is in-page only; cross-session aggregation deferred.
  5. node-executor.ts decomposition (TODOS T2) — the camelCase change in PR1 was scoped tight; full decomposition is a separate effort.
  6. Backwards-compat sunset for legacy shapes after 2026-09-01 — PR4 cleanup per the changeset note in PR2 (feat(schemas): add Tier-0 finalDecisionPayloadSchema + parseTypedPayload helper (PR2/3) #36).

Test plan

  • Tier-0 schema unchanged from PR2 (feat(schemas): add Tier-0 finalDecisionPayloadSchema + parseTypedPayload helper (PR2/3) #36)
  • All 4 mandatory regression tests for findings B/D/E/F land in this PR
  • All 79 dashboard tests pass under jsdom env
  • Full repo test (pnpm test) passes — 47/47 turbo tasks
  • Typecheck + build + dep-check clean
  • Post-merge: visual smoke check that BoardDetailPage / RunDetailPage / AgentsPanel / EventTimeline still render correctly (no regressions on the happy path)
  • Post-merge: P2 follow-up to /plan-design-review the DriftBanner and mount it

🤖 Generated with Claude Code

kaicianflone and others added 6 commits May 6, 2026 17:16
…omponent tests

- Add @testing-library/react@^16, @testing-library/jest-dom@^6, jsdom@^25 as devDependencies
- Create vitest.config.ts with jsdom environment and test-setup.ts for jest-dom matchers
- Update tsconfig.json to include vitest/globals and node types (skipLibCheck for jest-dom compatibility)
- All 48 existing tests pass under jsdom environment

@testing-library/react@^16 chosen over @^15 as it supports React 18+ with better alignment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…on tests for B and F

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… test for D

Replace inline safeParseJSON<any> with parseTypedPayload(finalDecisionPayloadSchema)
at the FINAL_DECISION trust boundary. Tagged-result switch handles all three
statuses explicitly — closes finding D (chat-approval shape no longer renders NaN%).
Extract parse logic to parseRunDecision helper for testability. Add 5 regression tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract parseEventList helper (pure, testable) and wire useMemo([events])
in EventTimeline. Drops dual-case guard_type||guardType reads; reads canonical
camelCase from schema-normalized parsed.value. Computes driftCount for future
DriftBanner integration. Adds 5-test memoization suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@kaicianflone kaicianflone merged commit 797f86b into main May 6, 2026
3 checks passed
@kaicianflone kaicianflone deleted the fix/dashboard-zod-trust-boundary branch May 6, 2026 21:28
kaicianflone added a commit that referenced this pull request May 6, 2026
Slop-scan rerun on apps/dashboard after the 3-PR zod trust-boundary refactor
flagged two strong findings that survived. Both are 1-line classes that the
manual subagent audit also caught:

1. defensive.error-swallowing at WorkflowsDashboard.tsx:346
   handleLoadTemplate caught and only console.error'd. User-clicked
   template loads now surface the failure via setApiError, mirroring
   executeWorkflow's pattern at the same file.

2. structure.pass-through-wrappers at AgentsPanel.tsx:57
   parseMetadata was a literal forward to parseAgentMetadata introduced
   in PR #37 (T05) for testability. Drop the wrapper and call
   parseAgentMetadata at the 3 sites directly. Required a type guard at
   line 498 because parseAgentMetadata returns the typed schema output
   (where passthrough fields are 'unknown') instead of Record<string, any>.

Verification:
- pnpm --filter @consensus-tools/dashboard test  79/79 pass
- pnpm --filter @consensus-tools/dashboard typecheck  clean
- pnpm --filter @consensus-tools/dashboard build  passes
- npx slop-scan@latest scan apps/dashboard --lint  3 findings (was 5)
  Both 'strong' findings closed; remaining 3 are 'medium' directory
  fan-out hotspots that are React app shape, not slop.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant