Packet — P4.1.1 Advisory Envelope

1. Inputs

  • Audit (Step 1): docs/audits/p4-1-1-advisory-envelope-audit.md, commit 5c8a8676.
  • Contract (Step 2): docs/contracts/p4-1-1-advisory-envelope-contract.md, commit 8a13969f.
  • Base SHA: aa6ba630 (origin/main at branch creation).
  • Worktree: E:/AMS/.worktrees/claude/p4-1-1-advisory-envelope.
  • Branch: feature/p4-1-1-advisory-envelope.

2. Files to create

File LOC est. Role
src/domains/integrity/schema.ts ~210 Public surface (Zod + helpers + error class)
src/__tests__/domains/integrity/schema.test.ts ~440 12 groups, ~40 it() cases
docs/verification/p4-1-1-advisory-envelope-verification.md ~120 Step 5 (post-implement)

Touched: 3 (plus the audit + contract + this packet already in tree).

Untouched: every other source/doc file. No edits to canonical.ts, versioning.ts, messages.ts, or any existing schema. No edits to package.json / tsconfig.json / jest.config.ts / lint config.

3. Implementation plan — schema.ts (in section order)

The file is structured to mirror the κ + θ source-comment layout (file-level JSDoc → §1 Imports → §2 Constants → §3 Errors → §4 Types → §5 Zod schemas → §6 Helpers).

§0. File-level JSDoc

Mirrors the κ canonical.ts comment shape:

  • Purpose: define the 8-field advisory envelope (μ Phase 4 Wave 1).
  • Public surface listing (every export, one bullet).
  • Determinism guarantee: byte-identical across Node ≥ 20.
  • REUSE statement: imports canonicalize + CanonicalSerializationError from κ P1.5.4 — no duplicate canonicalizer.
  • No-wall-clock / no-RNG / NAMED-import declarations.
  • Canonical references (audit / contract / integrity.md / s14 / R91 audit).

§1. Imports

import { createHash } from 'node:crypto';
import { z } from 'zod';

import { canonicalize, CanonicalSerializationError } from '../rules/canonical.js';

Three imports. No other.

§2. Constants

  • DECISION_HASH_REGEX = /^[a-f0-9]{64}$/ — single source for the hash shape; used by Zod schema, helper return-shape assertion in tests, and documented in contract §3.7.
  • HASH_FIELD_SEPARATOR = '||' — exported so downstream slices that need to recompute the preimage (e.g. tests in P4.5.1 persistence) can reuse the constant; also matches the κ P1.5.1 RULESET_VERSION_SEPARATOR convention.

§3. Error class

export class AdvisorySerializationError extends Error {
  override readonly name = 'AdvisorySerializationError';
  constructor(message: string, options?: { cause?: unknown }) {
    super(message);
    if (options && 'cause' in options) {
      (this as { cause?: unknown }).cause = options.cause;
    }
  }
}

Pattern lifted from src/domains/consensus/messages.ts:91-100 (θ P3.1.1). One named subclass; cause-chain preserved.

§4. Zod schemas

In declaration order:

export const AdvisoryRoleSchema = z.enum(['Translator','Sentinel','Guide']);
export const AdvisoryCheckSchema = z.enum(['circular_logic','coercion_trap','axiom_drift','axiom_regression']);
export const AdvisoryResultSchema = z.enum(['PASS','WARN','BLOCK']);
export const AdvisorySeveritySchema = z.enum(['LOW','MED','HIGH']);

export const AdvisorySchema = z.object({
  role: AdvisoryRoleSchema,
  check: AdvisoryCheckSchema,
  result: AdvisoryResultSchema,
  severity: AdvisorySeveritySchema,
  evidence: z.array(z.unknown()),
  recommendation: z.string(),
  decision_hash: z.string().regex(DECISION_HASH_REGEX),
  timestamp_logical: z.bigint().nonnegative(),
});

export type AdvisoryRole = z.infer<typeof AdvisoryRoleSchema>;
export type AdvisoryCheck = z.infer<typeof AdvisoryCheckSchema>;
export type AdvisoryResult = z.infer<typeof AdvisoryResultSchema>;
export type AdvisorySeverity = z.infer<typeof AdvisorySeveritySchema>;
export type Advisory = z.infer<typeof AdvisorySchema>;

Defaults — zod v3.23 .object is strict by default on parse; missing fields are rejected with ZodError. No .strict() call needed because the test plan covers both missing-field and unknown-key rejection; zod’s default is reject-unknown-keys for .object, not strict-pass — verified against src/domains/reputation/schema.ts:156-163 (uses bare z.object({...}) and rejects missing fields in T11). Confirmed behavior matches integrity contract §I8.

§5. computeDecisionHash

export function computeDecisionHash(
  role: AdvisoryRole,
  check: AdvisoryCheck,
  input: unknown,
  result: AdvisoryResult,
): string {
  let body: string;
  try {
    body = canonicalize(input);
  } catch (err) {
    if (err instanceof CanonicalSerializationError) {
      throw new AdvisorySerializationError(
        `decision_hash: input is un-representable in canonical JSON: ${err.message}`,
        { cause: err },
      );
    }
    throw err;
  }
  const preimage =
    role + HASH_FIELD_SEPARATOR +
    check + HASH_FIELD_SEPARATOR +
    body + HASH_FIELD_SEPARATOR +
    result;
  return createHash('sha256').update(preimage, 'utf8').digest('hex');
}

Lowercase hex per Node spec (digest('hex') always emits lowercase). No prefix. The TS signature constrains the closed-enum fields at the type system; the literal '||' separator prevents preimage ambiguity because enum values contain no '||' substring (verified at compile time by the enum declarations).

§6. serializeAdvisory

export function serializeAdvisory(advisory: Advisory): Buffer {
  let body: string;
  try {
    body = canonicalize(advisory as unknown);
  } catch (err) {
    if (err instanceof CanonicalSerializationError) {
      throw new AdvisorySerializationError(
        `advisory is un-representable in canonical JSON: ${err.message}`,
        { cause: err },
      );
    }
    throw err;
  }
  return Buffer.from(body, 'utf8');
}

The advisory is a plain object whose values are all canonicalizable: the four enum fields are strings, decision_hash is a string, recommendation is a string, timestamp_logical is a bigint (κ canonical natively encodes bigint), evidence is an array of unknown (callers responsibility per contract §3.5). No Buffer pre-pass needed because no field carries Buffer data.

4. Implementation plan — schema.test.ts

The test file is structured group-by-group matching contract §6. Each group has a describe + its; total ~40 cases.

Imports

import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ZodError } from 'zod';

import {
  Advisory,
  AdvisoryCheck,
  AdvisoryCheckSchema,
  AdvisoryResult,
  AdvisoryResultSchema,
  AdvisoryRole,
  AdvisoryRoleSchema,
  AdvisorySchema,
  AdvisorySerializationError,
  AdvisorySeverity,
  AdvisorySeveritySchema,
  DECISION_HASH_REGEX,
  HASH_FIELD_SEPARATOR,
  computeDecisionHash,
  serializeAdvisory,
} from '../../../domains/integrity/schema.js';

Fixtures

function makeValidAdvisory(overrides: Partial<Advisory> = {}): Advisory {
  const role: AdvisoryRole = 'Sentinel';
  const check: AdvisoryCheck = 'circular_logic';
  const result: AdvisoryResult = 'WARN';
  const input = { record_a: 'rec-1', record_b: 'rec-2', cycle: true };
  const hash = computeDecisionHash(role, check, input, result);
  return {
    role,
    check,
    result,
    severity: 'MED',
    evidence: [{ kind: 'thought_record', id: 'rec-1' }],
    recommendation: 'Cycle detected; surface to operator console.',
    decision_hash: hash,
    timestamp_logical: 1n,
    ...overrides,
  };
}

Group 1 — AdvisorySchema parses & rejects

  • accepts a valid advisory (deep equality after parse)
  • rejects role = ‘Auditor’
  • rejects check = ‘unknown’
  • rejects result = ‘OK’
  • rejects severity = ‘INFO’ (legacy s14 label)
  • rejects evidence = ‘foo’ (not an array)
  • rejects decision_hash with uppercase hex
  • rejects decision_hash with length 63
  • rejects decision_hash with ‘sha256:’ prefix
  • rejects timestamp_logical = 1 (number, not bigint)
  • rejects timestamp_logical = -1n (negative)
  • rejects missing role
  • rejects missing decision_hash

Group 2 — Roundtrip

  • parse(advisory) then serialize then parse again — structural equality
  • the round-tripped advisory’s decision_hash is the same string

Group 3 — serializeAdvisory determinism (×1000)

  • 1000 calls produce identical Buffer bytes

Group 4 — computeDecisionHash determinism (×1000)

  • 1000 calls on the same (role, check, input, result) produce identical hex strings

Group 5 — Dedup invariant

  • same (role, check, input, result) → same hash even with
    • different severity
    • different evidence
    • different recommendation
    • different timestamp_logical

Group 6 — Preimage discrimination

  • different result → different hash
  • different role → different hash
  • different check → different hash
  • different input → different hash

Group 7 — Hash shape

  • computeDecisionHash(...).match(DECISION_HASH_REGEX) is non-null
  • length === 64
  • all chars in [a-f0-9] (no uppercase)

Group 8 — No engine_version

  • computeDecisionHash.length (function arity) === 4
  • compile-time check is implicit; the test asserts the runtime arity

Group 9 — Static scanner

Read schema.ts content via readFileSync (path resolved via fileURLToPath(import.meta.url) + ../../../domains/integrity/schema.ts).

For each forbidden token in the list, assert expect(src).not.toMatch(token):

  • /\bDate\.now\b/
  • /\bDate\.UTC\b/
  • /\bDate\.parse\b/
  • /\bnew Date\b/
  • /\bMath\.random\b/
  • /\bperformance\.now\b/
  • /\bJSON\.stringify\b/

The patterns use \b word boundaries to avoid false positives in identifier-substring matches.

Group 10 — NAMED import

Read the same content; strip block + line comments (regex /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, replace with ''); then assert no match for /\bcrypto\.[a-zA-Z]/ — i.e. no dotted crypto.<X> token in the source body.

Group 11 — Error class

  • AdvisorySerializationError is instanceof Error
  • e.name === 'AdvisorySerializationError'
  • computeDecisionHash(..., undefinedValue, ...) (where undefinedValue is undefined cast through unknown) throws AdvisorySerializationError with cause instanceof CanonicalSerializationError
  • serializeAdvisory(advisoryWithBadEvidence) throws similarly when evidence contains a function

Group 12 — Bigint timestamp

  • timestamp_logical: 0n accepted
  • timestamp_logical: 1n accepted
  • timestamp_logical: -1n rejected
  • timestamp_logical: 1 (number) rejected
  • timestamp_logical: '1' (string) rejected

Plus: HASH_FIELD_SEPARATOR === '||' literal check (sanity).

5. Build / Lint / Test commands

From inside the worktree:

npm run build && npm run lint && npm test
  • build — TypeScript only, tsc --noEmit-style. Must pass with zero errors. Watch for: implicit any in test fixtures, unused-var on schema imports.
  • lint — ESLint. Watch for: @typescript-eslint/no-unused-vars, @typescript-eslint/consistent-type-imports (if used; check existing pattern — λ/θ both use import type for type-only TS imports).
  • test — Jest ESM. The new suite name is src/__tests__/domains/integrity/schema.test.ts. Coverage from the new file should reach 100% branches on schema.ts.

6. Out-of-scope guards

  • DB migration — P4.5.1 territory.
  • MCP tools — P4.6.1 territory.
  • Reputation linkage — out of P4.1.1.
  • Detectors — P4.2.x territory (consume this envelope).
  • Roles — P4.3.1 territory.

If any test references DB / MCP / detectors / roles, it’s the wrong slice. Verify before pushing.

7. Risk + mitigation

Risk Mitigation
z.bigint() not supported in the v3.23 schema vocabulary Verified: z.bigint() is on the v3.23 surface (used by θ P3.1.1 tools.ts implicitly through interfaces; explicit zod-validated bigint patterns are present in src/__tests__/domains/consensus/tools.test.ts). If .nonnegative() is unavailable on z.bigint(), fall back to .refine(v => v >= 0n, ...).
Lint rule consistent-type-imports flags import { z } from 'zod' if z is only used in types z is used at runtime (calling z.enum(...) etc.), so this is a value import, not a type-only import. No fallback needed.
evidence: z.array(z.unknown()) triggers no-explicit-any confusion unknown is not any; ESLint distinguishes. Should be clean.
Test relative path resolution fails on Windows The reputation/consensus tests use fileURLToPath(import.meta.url) + path.join; identical pattern in P4.1.1.
Zod parse + bigint round-trip loses precision None — bigint is a primitive; zod returns the exact bigint passed in. Verified in λ tests.

8. Approval

This packet is approved for Step 4 (implement) at the close of contract commit 8a13969f. All files are net-new; no in-tree edits.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.