Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/audit-events-schema.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
110 changes: 109 additions & 1 deletion apps/dashboard/src/lib/__tests__/safeJson.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -116,3 +117,110 @@ describe("safeParseJSON", () => {
expect(safeParseJSON<string>('""', "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 });
}
});
});
61 changes: 61 additions & 0 deletions apps/dashboard/src/lib/safeJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -55,3 +57,62 @@ export function safeParseJSON<T>(
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<T> =
| { 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<S extends ZodTypeAny>(
input: string | null | undefined,
schema: S,
context?: string,
): TypedParseResult<z.output<S>> {
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 };
}
12 changes: 12 additions & 0 deletions packages/core/tests/guard-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): GuardEvaluateInput {
return {
Expand Down Expand Up @@ -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);
});
});
96 changes: 96 additions & 0 deletions packages/schemas/src/audit-events.ts
Original file line number Diff line number Diff line change
@@ -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<typeof consensusMetaSchema>;

// ── 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<typeof finalDecisionPayloadSchema>;

// ── 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<typeof participantMetadataSchema>;
10 changes: 10 additions & 0 deletions packages/schemas/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading