From df5cd5fe4b296f0d00bb8cfc0866e8565a18c714 Mon Sep 17 00:00:00 2001 From: Kai Cianflone <30943151+kaicianflone@users.noreply.github.com> Date: Wed, 6 May 2026 15:11:27 -0400 Subject: [PATCH] feat(schemas): add Tier-0 finalDecisionPayloadSchema + parseTypedPayload helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR2 of 3 from dashboard-zod-trust-boundary plan. ## Schemas package Add packages/schemas/src/audit-events.ts (Tier 0, zero internal deps): - finalDecisionPayloadSchema — runtime-validated FINAL_DECISION audit payload. Single permissive z.object accepts canonical camelCase (post-PR1), legacy snake_case (pre-PR1 historical DB rows), and the chat-approval-limited shape. Transform normalizes to canonical camelCase output. Passthrough preserves unknown fields for forward-compat. - participantMetadataSchema — stricter than participantSchema.metadata (z.record(z.unknown()) at Tier 0 stays loose for backwards compat). Rejects non-plain objects (Date, Map, Set, class instances) — closes round-3 finding C. Transform returns fresh object — closes finding E. - consensusMetaSchema — extracted shape for consensusMeta sub-object. Includes 15 unit tests covering canonical/legacy/limited shapes, forward-compat passthrough, mutation-safety, and rejection of primitives. ## Dashboard helper Add parseTypedPayload to apps/dashboard/src/lib/safeJson.ts: - Generic primitive: parseTypedPayload(input, schema, context). - Returns discriminated tagged result: {status:'empty'|'invalid'|'ok'}. TypeScript exhaustive-switch catches missed cases at compile time — closes the silent-fallback bug class. - Empty input (null/undefined/""/whitespace) → 'empty' (legitimate pending state). JSON.parse failure or schema rejection → 'invalid' (data corruption / drift). Validated and transformed → 'ok'. - Dev-mode warns log input length only; never raw content (no token/PII leak). Includes 12 unit tests covering empty/invalid/ok branches, schema transforms, dev-mode logging, no-leak guarantee. ## Producer contract test promotion Producer contract tests added in PR1 (#35) now also validate emit shapes through the Tier-0 schema. Catches drift at the source if any future change reintroduces snake_case OR shapes the schema rejects. ## Dashboard deps Add @consensus-tools/schemas (workspace:*) and zod ^3.23.0 as runtime dependencies of apps/dashboard. PR3 wires the schema into the dashboard render paths and DriftBanner component. ## Changeset Minor bump for @consensus-tools/schemas (new public exports). ## Verification - pnpm build — 23/23 successful - pnpm test — 47/47 turbo tasks pass (schemas 38, core 73, workflows 35, sdk-node 33, dashboard 48, all others unchanged) - pnpm typecheck — 42/42 successful - pnpm dep-check — clean (226 modules, 424 deps) Co-Authored-By: Claude Opus 4.7 --- .changeset/audit-events-schema.md | 15 ++ apps/dashboard/package.json | 4 +- .../src/lib/__tests__/safeJson.test.ts | 110 ++++++++- apps/dashboard/src/lib/safeJson.ts | 61 +++++ packages/core/tests/guard-engine.test.ts | 12 + packages/schemas/src/audit-events.ts | 96 ++++++++ packages/schemas/src/index.ts | 10 + packages/schemas/tests/audit-events.test.ts | 223 ++++++++++++++++++ packages/sdk-node/tests/chat-approval.test.ts | 10 + .../workflows/tests/node-executor.test.ts | 4 + pnpm-lock.yaml | 6 + 11 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 .changeset/audit-events-schema.md create mode 100644 packages/schemas/src/audit-events.ts create mode 100644 packages/schemas/tests/audit-events.test.ts diff --git a/.changeset/audit-events-schema.md b/.changeset/audit-events-schema.md new file mode 100644 index 0000000..e3cdc61 --- /dev/null +++ b/.changeset/audit-events-schema.md @@ -0,0 +1,15 @@ +--- +"@consensus-tools/schemas": minor +--- + +Add Tier-0 audit-event payload schemas at `packages/schemas/src/audit-events.ts`. + +- **`finalDecisionPayloadSchema`** — runtime-validated FINAL_DECISION audit payload. Single permissive `z.object` accepts canonical camelCase, legacy snake_case (pre-PR1 historical DB rows), and the chat-approval-limited shape. `.transform()` normalizes to canonical camelCase output (`{auditId, runId, boardId, decision, reason, riskScore, guardType, consensusMeta, approver, votesReceived, votesRequired, idempotencyKey}`). `.passthrough()` preserves unknown fields for forward-compat. +- **`participantMetadataSchema`** — stricter shape than the loose `z.record(z.unknown())` on `participantSchema.metadata`. Used by dashboard consumers that need typed `agentType`, `model`, etc. Rejects non-plain objects (Date, Map, Set, class instances) — closes the round-3 finding C parseMetadata slip-through. Returns a fresh object on each parse so caller mutations don't affect input — closes finding E. +- **`consensusMetaSchema`** — extracted shape for the consensus-meta sub-object emitted by `node-executor.ts` and historically present in DB rows. + +Used by `apps/dashboard` via the new `parseTypedPayload` helper in `apps/dashboard/src/lib/safeJson.ts` (introduced in this PR) and consumed in PR3 by the dashboard render paths and a `DriftBanner` component that surfaces malformed payload counts. + +**Sunset deadline (informational):** legacy snake_case + chat-approval-limited shapes are accepted until 2026-09-01. After sunset, the schema rejects non-canonical shapes for live events and consumers must update. + +Part of the dashboard-zod-trust-boundary plan (PR2 of 3). PR1 (#35) canonicalized the audit-event producers; PR3 wires the dashboard through this schema. diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index dcbf849..e399fda 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -14,6 +14,7 @@ "test:watch": "vitest" }, "dependencies": { + "@consensus-tools/schemas": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -35,7 +36,8 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.28.0", "tailwind-merge": "^3.5.0", - "tailwindcss": "^3.4.19" + "tailwindcss": "^3.4.19", + "zod": "^3.23.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/apps/dashboard/src/lib/__tests__/safeJson.test.ts b/apps/dashboard/src/lib/__tests__/safeJson.test.ts index da55cbd..32c3239 100644 --- a/apps/dashboard/src/lib/__tests__/safeJson.test.ts +++ b/apps/dashboard/src/lib/__tests__/safeJson.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, afterEach } from "vitest"; +import { z } from "zod"; // We import and re-export the helper so we can swap the DEV flag tested below. // The actual DEV gating is tested via the exported isDev wrapper for unit tests. -import { safeParseJSON, _setDevForTesting } from "../safeJson"; +import { safeParseJSON, parseTypedPayload, _setDevForTesting } from "../safeJson"; afterEach(() => { vi.restoreAllMocks(); @@ -116,3 +117,110 @@ describe("safeParseJSON", () => { expect(safeParseJSON('""', "FALLBACK")).toBe(""); }); }); + +describe("parseTypedPayload", () => { + const decisionSchema = z.object({ + decision: z.string(), + riskScore: z.number().optional(), + }); + + it("returns status='empty' for null input", () => { + expect(parseTypedPayload(null, decisionSchema)).toEqual({ status: "empty" }); + }); + + it("returns status='empty' for undefined input", () => { + expect(parseTypedPayload(undefined, decisionSchema)).toEqual({ status: "empty" }); + }); + + it("returns status='empty' for empty string", () => { + expect(parseTypedPayload("", decisionSchema)).toEqual({ status: "empty" }); + }); + + it("returns status='empty' for whitespace-only string", () => { + expect(parseTypedPayload(" \n\t ", decisionSchema)).toEqual({ status: "empty" }); + }); + + it("returns status='ok' with parsed value for valid JSON matching schema", () => { + const result = parseTypedPayload('{"decision":"ALLOW","riskScore":0.2}', decisionSchema); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value).toEqual({ decision: "ALLOW", riskScore: 0.2 }); + } + }); + + it("returns status='invalid' when JSON.parse throws", () => { + _setDevForTesting(false); + const result = parseTypedPayload("{not valid json}", decisionSchema); + expect(result.status).toBe("invalid"); + if (result.status === "invalid") { + expect(result.reason).toBe("JSON parse failed"); + } + }); + + it("returns status='invalid' when valid JSON fails schema validation", () => { + _setDevForTesting(false); + const result = parseTypedPayload('{"decision":42}', decisionSchema); + expect(result.status).toBe("invalid"); + if (result.status === "invalid") { + expect(result.reason).toBe("schema validation failed"); + } + }); + + it("distinguishes 'null' literal (invalid for object schema) from empty input", () => { + _setDevForTesting(false); + // 'null' is valid JSON but is not a valid {decision: string} → invalid + const result = parseTypedPayload("null", decisionSchema); + expect(result.status).toBe("invalid"); + }); + + it("warns in DEV mode on JSON parse failure (no PII leak)", () => { + _setDevForTesting(true); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + parseTypedPayload("{bad", decisionSchema, "MyContext"); + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = String(warnSpy.mock.calls[0][0]); + expect(message).toContain("MyContext"); + expect(message).toContain("input length=4"); + // The raw payload must never appear in the warning + expect(message).not.toContain("{bad"); + }); + + it("warns in DEV mode on schema rejection (no PII leak)", () => { + _setDevForTesting(true); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + parseTypedPayload('{"decision":42}', decisionSchema, "MyContext"); + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = String(warnSpy.mock.calls[0][0]); + expect(message).toContain("MyContext"); + expect(message).toContain("schema rejected"); + expect(message).not.toContain("42"); + }); + + it("does NOT warn in production mode", () => { + _setDevForTesting(false); + const warnSpy = vi.spyOn(console, "warn"); + parseTypedPayload("{bad", decisionSchema); + parseTypedPayload('{"decision":42}', decisionSchema); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("supports schemas with .transform() — output is the transformed value", () => { + const transformingSchema = z.object({ + decision: z.string(), + risk_score: z.number().optional(), + riskScore: z.number().optional(), + }).transform((raw) => ({ + decision: raw.decision, + riskScore: raw.riskScore ?? raw.risk_score, + })); + + const result = parseTypedPayload( + '{"decision":"ALLOW","risk_score":0.5}', + transformingSchema, + ); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value).toEqual({ decision: "ALLOW", riskScore: 0.5 }); + } + }); +}); diff --git a/apps/dashboard/src/lib/safeJson.ts b/apps/dashboard/src/lib/safeJson.ts index ac2f95f..7fbcaac 100644 --- a/apps/dashboard/src/lib/safeJson.ts +++ b/apps/dashboard/src/lib/safeJson.ts @@ -7,6 +7,8 @@ * @returns The parsed value cast to T, or `fallback`. */ +import type { ZodTypeAny, z } from "zod"; + // Minimal augmentation so TypeScript recognises import.meta.env.DEV without // pulling in the full vite/client types (which conflict with workspace Vite v7). declare global { @@ -55,3 +57,62 @@ export function safeParseJSON( return fallback; } } + +// ── Tagged-result schema parser ───────────────────────────────────── +// Used by callers that need to distinguish "empty input" (legitimate +// pending state) from "malformed input" (data corruption / drift). +// Wave 2 of the dashboard-zod-trust-boundary plan: schema validation at +// the trust boundary instead of ad-hoc shape checks scattered across +// components. + +export type TypedParseResult = + | { status: "empty" } + | { status: "invalid"; reason: string } + | { status: "ok"; value: T }; + +/** + * Parses a JSON string and validates the result against a Zod schema. + * + * Returns a discriminated union so callers must explicitly handle each + * outcome — TypeScript's exhaustive switch catches missed cases at + * compile time, closing the silent-fallback class of bugs. + * + * - `status: "empty"` — `null`, `undefined`, `""`, or all-whitespace input. + * Legitimate "decision pending" / "no metadata yet" state. + * - `status: "invalid"` — `JSON.parse` threw, or the schema rejected the + * parsed value. Data corruption or producer drift; UI should render an + * explicit malformed-event placeholder rather than skip silently. + * - `status: "ok"` — Validated and normalized to canonical shape via + * schema's `.transform()` (when defined). + */ +export function parseTypedPayload( + input: string | null | undefined, + schema: S, + context?: string, +): TypedParseResult> { + if (input == null) return { status: "empty" }; + if (typeof input === "string" && input.trim() === "") return { status: "empty" }; + + let raw: unknown; + try { + raw = JSON.parse(input); + } catch { + if (isDevMode()) { + const label = context ? `[${context}] ` : ""; + const len = input.length; + console.warn(`parseTypedPayload ${label}JSON parse failed: input length=${len}`); + } + return { status: "invalid", reason: "JSON parse failed" }; + } + + const result = schema.safeParse(raw); + if (!result.success) { + if (isDevMode()) { + const label = context ? `[${context}] ` : ""; + const len = input.length; + console.warn(`parseTypedPayload ${label}schema rejected: input length=${len}`); + } + return { status: "invalid", reason: "schema validation failed" }; + } + return { status: "ok", value: result.data }; +} diff --git a/packages/core/tests/guard-engine.test.ts b/packages/core/tests/guard-engine.test.ts index caa5fd9..88287d3 100644 --- a/packages/core/tests/guard-engine.test.ts +++ b/packages/core/tests/guard-engine.test.ts @@ -3,6 +3,7 @@ import { GuardEngine } from "../src/engine/guard-engine.js"; import { AgentRegistry } from "../src/engine/agent-registry.js"; import { createTempStorage, makeConfig } from "./helpers.js"; import type { GuardEvaluateInput } from "@consensus-tools/schemas"; +import { finalDecisionPayloadSchema } from "@consensus-tools/schemas"; function makeGuardInput(overrides: Partial = {}): GuardEvaluateInput { return { @@ -83,4 +84,15 @@ describe("GuardEngine", () => { expect(details).not.toHaveProperty("guard_type"); expect(details).not.toHaveProperty("audit_id"); }); + + it("FINAL_DECISION emit validates against Tier-0 finalDecisionPayloadSchema", async () => { + const { storage } = await createTempStorage(); + const engine = new GuardEngine({ storage }); + await engine.evaluate(makeGuardInput()); + const state = await storage.getState(); + + const finalDecisionEvent = state.audit.find((e) => e.type === "FINAL_DECISION"); + const result = finalDecisionPayloadSchema.safeParse(finalDecisionEvent!.details); + expect(result.success).toBe(true); + }); }); diff --git a/packages/schemas/src/audit-events.ts b/packages/schemas/src/audit-events.ts new file mode 100644 index 0000000..b5a51d0 --- /dev/null +++ b/packages/schemas/src/audit-events.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +// Tier-0 schemas for audit-event payloads at the producer/consumer trust boundary. +// Three FINAL_DECISION emit sites send slightly different shapes: +// - core/guard-engine.ts:111 → canonical camelCase ({auditId, decision, reason, riskScore, guardType}) +// - workflows/node-executor.ts:596 → camelCase + {runId, boardId, consensusMeta} (camelCased in PR1, was snake_case) +// - sdk-node/chat-approval.ts:61 → no risk fields ({runId, decision, approver, votesReceived, votesRequired, idempotencyKey?}) +// Plus historical DB rows that may still use legacy snake_case (pre-PR1). +// +// The schema is a single permissive z.object that accepts canonical AND legacy +// keys, then `.transform()` normalizes to canonical camelCase. Consumers always +// see one shape regardless of producer or DB vintage. +// +// Sunset target: legacy snake_case + chat-approval-limited shapes are kept until +// 2026-09-01. After sunset, this schema rejects non-canonical shapes for live events. + +// ── consensusMeta ─────────────────────────────────────────────────── + +export const consensusMetaSchema = z.object({ + quorumMet: z.boolean(), + weightedYesRatio: z.number(), + voterCount: z.number().int().nonnegative(), +}).passthrough(); +export type ConsensusMeta = z.infer; + +// ── FINAL_DECISION payload ────────────────────────────────────────── + +const finalDecisionRawSchema = z.object({ + // Identity (some emitters include, some don't) + auditId: z.string().optional(), + audit_id: z.string().optional(), // legacy + runId: z.string().optional(), + boardId: z.string().optional(), + + // Required core + decision: z.string(), + + // Optional reason (every emitter includes it but human-approval flow doesn't always) + reason: z.string().optional(), + + // Risk fields: canonical OR legacy snake OR absent (chat-approval has none by design) + riskScore: z.number().optional(), + risk_score: z.number().optional(), + + guardType: z.string().optional(), + guard_type: z.string().optional(), + + // Consensus meta: canonical OR legacy + consensusMeta: consensusMetaSchema.optional(), + consensus_meta: consensusMetaSchema.optional(), + + // Human-approval limited shape (chat-approval.ts) + approver: z.string().optional(), + votesReceived: z.number().int().nonnegative().optional(), + votesRequired: z.number().int().nonnegative().optional(), + idempotencyKey: z.string().optional(), +}).passthrough(); + +export const finalDecisionPayloadSchema = finalDecisionRawSchema.transform((raw) => { + // Strip legacy snake_case keys after promoting to canonical, but preserve any + // unknown passthrough fields the server may emit (forward-compat). + const { + audit_id: _legacyAuditId, + risk_score: _legacyRiskScore, + guard_type: _legacyGuardType, + consensus_meta: _legacyConsensusMeta, + ...rest + } = raw; + + return { + ...rest, + auditId: raw.auditId ?? raw.audit_id, + riskScore: raw.riskScore ?? raw.risk_score, + guardType: raw.guardType ?? raw.guard_type, + consensusMeta: raw.consensusMeta ?? raw.consensus_meta, + }; +}); +export type FinalDecisionPayload = z.output; + +// ── Participant metadata ──────────────────────────────────────────── +// +// `participantSchema.metadata` at Tier 0 stays `z.record(z.unknown())` for +// backwards compat (other consumers). The dashboard needs a stricter shape; +// this schema is the dashboard's contract. Keeping it in audit-events.ts +// (Tier 0) lets future consumers reuse it without reinventing shape checks. + +export const participantMetadataSchema = z.object({ + agentType: z.string().optional(), + email: z.string().optional(), + url: z.string().optional(), + model: z.string().optional(), + provider: z.string().optional(), + apiKey: z.string().optional(), // typically encrypted ref, not raw + systemPrompt: z.string().optional(), +}).passthrough(); +export type ParticipantMetadata = z.infer; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 994c4dc..d2168f7 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -146,6 +146,16 @@ export { type ExplainResult, } from "./explain.js"; +// ── Audit Events ───────────────────────────────────────────────── +export { + consensusMetaSchema, + type ConsensusMeta, + finalDecisionPayloadSchema, + type FinalDecisionPayload, + participantMetadataSchema, + type ParticipantMetadata, +} from "./audit-events.js"; + // ── Agent ─────────────────────────────────────────────────────────── export { agentKindSchema, diff --git a/packages/schemas/tests/audit-events.test.ts b/packages/schemas/tests/audit-events.test.ts new file mode 100644 index 0000000..e5219ac --- /dev/null +++ b/packages/schemas/tests/audit-events.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from "vitest"; +import { + finalDecisionPayloadSchema, + participantMetadataSchema, +} from "../src/audit-events.js"; + +// Representative payload shapes from the three FINAL_DECISION emit sites. +// These match the actual emit code as of post-PR1; legacy entries cover +// historical DB rows that were written before PR1 canonicalized the producers. + +const HISTORICAL_PAYLOADS = { + // Pre-PR1: workflows/node-executor.ts emitted snake_case + legacyWorkflowsSnake: { + runId: "run-1", + boardId: "board-1", + decision: "ALLOW", + reason: "code_merge consensus: 2/2 YES votes", + risk_score: 0.32, + guard_type: "code_merge", + consensus_meta: { + quorumMet: true, + weightedYesRatio: 1.0, + voterCount: 2, + }, + }, + // Post-PR1: workflows/node-executor.ts canonical camelCase + canonicalWorkflows: { + runId: "run-2", + boardId: "board-1", + decision: "BLOCK", + reason: "code_merge consensus: 0/2 YES votes", + riskScore: 0.85, + guardType: "code_merge", + consensusMeta: { + quorumMet: false, + weightedYesRatio: 0.0, + voterCount: 2, + }, + }, + // Always canonical: core/guard-engine.ts (with auditId, no runId/boardId) + canonicalGuardEngine: { + auditId: "audit-3", + decision: "ALLOW", + reason: "evaluator consensus passed", + riskScore: 0.12, + guardType: "agent_action", + }, + // Limited by design: sdk-node/chat-approval.ts (no risk fields) + chatApprovalLimited: { + runId: "run-4", + decision: "YES", + approver: "operator", + votesReceived: 1, + votesRequired: 1, + }, + chatApprovalWithIdempotency: { + runId: "run-5", + decision: "NO", + approver: "operator", + votesReceived: 2, + votesRequired: 2, + idempotencyKey: "idem-abc", + }, +} as const; + +describe("finalDecisionPayloadSchema", () => { + it("accepts canonical workflows emit (post-PR1) and returns identity for camelCase fields", () => { + const result = finalDecisionPayloadSchema.safeParse(HISTORICAL_PAYLOADS.canonicalWorkflows); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toMatchObject({ + runId: "run-2", + boardId: "board-1", + decision: "BLOCK", + riskScore: 0.85, + guardType: "code_merge", + consensusMeta: { quorumMet: false, weightedYesRatio: 0.0, voterCount: 2 }, + }); + }); + + it("accepts canonical guard-engine emit (with auditId, no runId/boardId)", () => { + const result = finalDecisionPayloadSchema.safeParse(HISTORICAL_PAYLOADS.canonicalGuardEngine); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.auditId).toBe("audit-3"); + expect(result.data.riskScore).toBe(0.12); + }); + + it("accepts chat-approval limited shape (no risk fields)", () => { + const result = finalDecisionPayloadSchema.safeParse(HISTORICAL_PAYLOADS.chatApprovalLimited); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toMatchObject({ + runId: "run-4", + decision: "YES", + approver: "operator", + votesReceived: 1, + votesRequired: 1, + }); + expect(result.data.riskScore).toBeUndefined(); + expect(result.data.guardType).toBeUndefined(); + }); + + it("propagates idempotencyKey through transform", () => { + const result = finalDecisionPayloadSchema.safeParse(HISTORICAL_PAYLOADS.chatApprovalWithIdempotency); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.idempotencyKey).toBe("idem-abc"); + }); + + it("normalizes legacy snake_case to canonical camelCase (DB-replay scenario)", () => { + const result = finalDecisionPayloadSchema.safeParse(HISTORICAL_PAYLOADS.legacyWorkflowsSnake); + expect(result.success).toBe(true); + if (!result.success) return; + // Output is canonical camelCase regardless of input casing + expect(result.data.riskScore).toBe(0.32); + expect(result.data.guardType).toBe("code_merge"); + expect(result.data.consensusMeta).toEqual({ + quorumMet: true, + weightedYesRatio: 1.0, + voterCount: 2, + }); + }); + + it("prefers camelCase when both casings are present (canonical wins on drift)", () => { + const driftPayload = { + decision: "ALLOW", + riskScore: 0.5, // canonical + risk_score: 0.9, // legacy — must be ignored + guardType: "code_merge", // canonical + guard_type: "send_email", // legacy — must be ignored + }; + const result = finalDecisionPayloadSchema.safeParse(driftPayload); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.riskScore).toBe(0.5); + expect(result.data.guardType).toBe("code_merge"); + }); + + it("rejects payload without decision", () => { + const result = finalDecisionPayloadSchema.safeParse({ riskScore: 0.5 }); + expect(result.success).toBe(false); + }); + + it("rejects payload with wrong type for riskScore", () => { + const result = finalDecisionPayloadSchema.safeParse({ + decision: "ALLOW", + riskScore: "not a number", + }); + expect(result.success).toBe(false); + }); + + it("rejects primitives, arrays, dates", () => { + expect(finalDecisionPayloadSchema.safeParse(null).success).toBe(false); + expect(finalDecisionPayloadSchema.safeParse("string").success).toBe(false); + expect(finalDecisionPayloadSchema.safeParse(42).success).toBe(false); + expect(finalDecisionPayloadSchema.safeParse([]).success).toBe(false); + expect(finalDecisionPayloadSchema.safeParse(new Date()).success).toBe(false); + }); + + it("preserves unknown fields via passthrough (forward-compat)", () => { + const futureShape = { + decision: "ALLOW", + reason: "ok", + riskScore: 0.1, + guardType: "agent_action", + newField: "future server emit", + anotherFutureField: { nested: true }, + }; + const result = finalDecisionPayloadSchema.safeParse(futureShape); + expect(result.success).toBe(true); + // passthrough: caller can read unknown fields if they want + if (!result.success) return; + expect((result.data as Record).newField).toBe("future server emit"); + }); +}); + +describe("participantMetadataSchema", () => { + it("accepts known agent metadata shape", () => { + const meta = { agentType: "internal", model: "claude-sonnet-4-6" }; + const result = participantMetadataSchema.safeParse(meta); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.agentType).toBe("internal"); + expect(result.data.model).toBe("claude-sonnet-4-6"); + }); + + it("accepts empty object (legitimate 'no metadata' state)", () => { + const result = participantMetadataSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("rejects non-object inputs (closes round-3 finding C: Date/Map/Set slip-through)", () => { + expect(participantMetadataSchema.safeParse(null).success).toBe(false); + expect(participantMetadataSchema.safeParse("string").success).toBe(false); + expect(participantMetadataSchema.safeParse(42).success).toBe(false); + expect(participantMetadataSchema.safeParse([]).success).toBe(false); + expect(participantMetadataSchema.safeParse(new Date()).success).toBe(false); + expect(participantMetadataSchema.safeParse(new Map()).success).toBe(false); + expect(participantMetadataSchema.safeParse(new Set()).success).toBe(false); + }); + + it("returns a fresh object — caller mutations don't affect input (closes finding E)", () => { + const input = { agentType: "internal" }; + const result = participantMetadataSchema.safeParse(input); + expect(result.success).toBe(true); + if (!result.success) return; + // Zod's .parse() returns a new object — mutating output does not mutate input + (result.data as Record).agentType = "MUTATED"; + expect(input.agentType).toBe("internal"); + }); + + it("preserves unknown fields via passthrough", () => { + const meta = { + agentType: "internal", + customField: "from server", + }; + const result = participantMetadataSchema.safeParse(meta); + expect(result.success).toBe(true); + if (!result.success) return; + expect((result.data as Record).customField).toBe("from server"); + }); +}); diff --git a/packages/sdk-node/tests/chat-approval.test.ts b/packages/sdk-node/tests/chat-approval.test.ts index 51be001..2a3bd09 100644 --- a/packages/sdk-node/tests/chat-approval.test.ts +++ b/packages/sdk-node/tests/chat-approval.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { HitlTracker } from "@consensus-tools/core"; import { MemoryStorage } from "@consensus-tools/storage"; +import { finalDecisionPayloadSchema } from "@consensus-tools/schemas"; import { processHumanApproval } from "../src/handlers/chat-approval.js"; import type { WebhookHandlerContext } from "../src/types.js"; @@ -44,6 +45,15 @@ describe("processHumanApproval — producer contract", () => { expect(details).not.toHaveProperty("votes_required"); expect(details).not.toHaveProperty("risk_score"); expect(details).not.toHaveProperty("guard_type"); + + // Validates against Tier-0 schema (chat-approval limited shape — no risk fields by design) + const parsed = finalDecisionPayloadSchema.safeParse(details); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.riskScore).toBeUndefined(); + expect(parsed.data.guardType).toBeUndefined(); + expect(parsed.data.votesReceived).toBe(1); + } }); it("propagates idempotencyKey into FINAL_DECISION details (camelCase)", async () => { diff --git a/packages/workflows/tests/node-executor.test.ts b/packages/workflows/tests/node-executor.test.ts index 7e2720d..799f298 100644 --- a/packages/workflows/tests/node-executor.test.ts +++ b/packages/workflows/tests/node-executor.test.ts @@ -3,6 +3,7 @@ import { NodeExecutor, validateWorkflowDefinition } from "../src/node-executor.j import type { WorkflowNode, NodeExecIds } from "../src/node-executor.js"; import { createTempStorage } from "./helpers.js"; import { newId, nowIso } from "@consensus-tools/core"; +import { finalDecisionPayloadSchema } from "@consensus-tools/schemas"; const ids: NodeExecIds = { boardId: "board-1", runId: "run-1", workflowId: "wf-1" }; @@ -99,6 +100,9 @@ describe("NodeExecutor", () => { expect(details).not.toHaveProperty("risk_score"); expect(details).not.toHaveProperty("guard_type"); expect(details).not.toHaveProperty("consensus_meta"); + + // Validates against Tier-0 schema (catches drift if any field gets re-added under wrong name) + expect(finalDecisionPayloadSchema.safeParse(details).success).toBe(true); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d10d0c7..ad1f8bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: apps/dashboard: dependencies: + '@consensus-tools/schemas': + specifier: workspace:* + version: link:../../packages/schemas '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -95,6 +98,9 @@ importers: tailwindcss: specifier: ^3.4.19 version: 3.4.19(tsx@4.21.0) + zod: + specifier: ^3.23.0 + version: 3.25.76 devDependencies: '@types/node': specifier: ^20.0.0