Packet — P4.1.1 Advisory Envelope
1. Inputs
- Audit (Step 1):
docs/audits/p4-1-1-advisory-envelope-audit.md, commit5c8a8676. - Contract (Step 2):
docs/contracts/p4-1-1-advisory-envelope-contract.md, commit8a13969f. - 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+CanonicalSerializationErrorfrom κ 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.1RULESET_VERSION_SEPARATORconvention.
§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_hashis 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
AdvisorySerializationErrorisinstanceof Errore.name === 'AdvisorySerializationError'computeDecisionHash(..., undefinedValue, ...)(where undefinedValue isundefinedcast through unknown) throws AdvisorySerializationError with causeinstanceof CanonicalSerializationErrorserializeAdvisory(advisoryWithBadEvidence)throws similarly when evidence contains a function
Group 12 — Bigint timestamp
timestamp_logical: 0nacceptedtimestamp_logical: 1nacceptedtimestamp_logical: -1nrejectedtimestamp_logical: 1(number) rejectedtimestamp_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: implicitanyin 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 useimport typefor type-only TS imports).test— Jest ESM. The new suite name issrc/__tests__/domains/integrity/schema.test.ts. Coverage from the new file should reach 100% branches onschema.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.