Contract — P4.1.1 Advisory Envelope
1. Scope
This contract defines the runtime + structural invariants of the 8-field typed advisory envelope shipped by P4.1.1. Every downstream μ slice (P4.2.x detectors, P4.3.1 roles, P4.4.1 escalation FSM, P4.5.1 persistence, P4.6.1 MCP tools, P4.7.1 parity harness, P4.8.1 fork hook subscriber) consumes this envelope without redefining it.
Out of scope (later slices):
mcp_advisoriesmigration + INSERT/SELECT helpers (P4.5.1)- Three detector implementations (P4.2.1 / P4.2.2 / P4.2.3)
- Translator/Sentinel/Guide role adapters (P4.3.1)
- Escalation FSM (P4.4.1)
- MCP tool surface (P4.6.1)
2. Public surface
src/domains/integrity/schema.ts exports:
| Name | Kind | Purpose |
|---|---|---|
AdvisoryRoleSchema |
z.ZodEnum |
closed set Translator/Sentinel/Guide |
AdvisoryCheckSchema |
z.ZodEnum |
closed set circular_logic/coercion_trap/axiom_drift/axiom_regression |
AdvisoryResultSchema |
z.ZodEnum |
closed set PASS/WARN/BLOCK |
AdvisorySeveritySchema |
z.ZodEnum |
closed set LOW/MED/HIGH (λ-aligned per R91 audit Q6) |
AdvisorySchema |
z.ZodObject |
full 8-field envelope |
AdvisoryRole |
TS union | z.infer<typeof AdvisoryRoleSchema> |
AdvisoryCheck |
TS union | z.infer<typeof AdvisoryCheckSchema> |
AdvisoryResult |
TS union | z.infer<typeof AdvisoryResultSchema> |
AdvisorySeverity |
TS union | z.infer<typeof AdvisorySeveritySchema> |
Advisory |
TS interface | z.infer<typeof AdvisorySchema> |
DECISION_HASH_REGEX |
RegExp |
/^[a-f0-9]{64}$/ (single source) |
HASH_FIELD_SEPARATOR |
string |
the literal '||' separator |
computeDecisionHash |
function | returns 64-char lowercase hex |
serializeAdvisory |
function | returns canonical UTF-8 bytes |
AdvisorySerializationError |
error class | wraps CanonicalSerializationError |
No other names. No default export. No re-exports from κ (callers import
canonical directly from src/domains/rules/canonical.js).
3. Field invariants (the eight)
3.1. role (closed enum)
| Property | Value |
|---|---|
| Type | 'Translator' \| 'Sentinel' \| 'Guide' |
| Zod | z.enum(['Translator','Sentinel','Guide']) |
| Source of truth | integrity.md L135 + L121-126 (three advisory roles table) |
| Hash preimage | YES — first field |
| Closed | YES; adding a fourth role is a breaking change |
3.2. check (closed enum)
| Property | Value |
|---|---|
| Type | 'circular_logic' \| 'coercion_trap' \| 'axiom_drift' \| 'axiom_regression' |
| Zod | z.enum(['circular_logic','coercion_trap','axiom_drift','axiom_regression']) |
| Source of truth | integrity.md L136 |
| Hash preimage | YES — second field |
| Closed | YES; algorithm changes add a new value (e.g. circular_logic_v2), not mutate the existing one |
3.3. result (closed enum)
| Property | Value |
|---|---|
| Type | 'PASS' \| 'WARN' \| 'BLOCK' |
| Zod | z.enum(['PASS','WARN','BLOCK']) |
| Source of truth | integrity.md L137; escalation table L148-155 |
| Hash preimage | YES — fourth field (last) |
| HARD BLOCK | Not a separate result — represented as result: 'BLOCK' + severity: 'HIGH' per escalation L155. The HARD/soft distinction is a downstream consumer concern (α tool-lock for HARD, π for soft). |
3.4. severity (closed enum)
| Property | Value |
|---|---|
| Type | 'LOW' \| 'MED' \| 'HIGH' |
| Zod | z.enum(['LOW','MED','HIGH']) |
| Source of truth | integrity.md L138 (post-R91 audit Q6) |
| Hash preimage | NO — orthogonal to identity (same input may map to a different severity in a future engine bump; dedup must NOT collapse on severity alone) |
| Supersedes | s14 INFO/WARNING/CRITICAL (older donor framing) |
3.5. evidence (array, unknown elements)
| Property | Value |
|---|---|
| Type | readonly unknown[] |
| Zod | z.array(z.unknown()) |
| Source of truth | integrity.md L139 — “references to records/events/rules” |
| Hash preimage | Indirectly via the input parameter to computeDecisionHash; the schema field itself stores the rendered evidence. The hash signs the SEMANTIC input (which produced the evidence), NOT the evidence array after the fact. |
| Empty allowed | YES — a passing advisory may have empty evidence |
3.6. recommendation (string)
| Property | Value |
|---|---|
| Type | string |
| Zod | z.string() (no length floor — empty string is allowed for result: 'PASS') |
| Source of truth | integrity.md L140 — “free-form human-readable” |
| Hash preimage | NO — orthogonal to identity. Different wording with the same input/result still represents the same advisory; dedup MUST collapse them. |
3.7. decision_hash (string, 64-char lowercase hex)
| Property | Value |
|---|---|
| Type | string matching /^[a-f0-9]{64}$/ |
| Zod | z.string().regex(DECISION_HASH_REGEX) |
| Formula | sha256(role || check || canonical(input) || result) (literal '||' separator between fields) |
| Source of truth | integrity.md L141 (R91 audit Q7); supersedes s14 SHA-256(check + input + result + model_identity) |
| Determinism | Byte-identical across Node ≥ 20, any locale, any host, any process. Inherits κ P1.5.4 canonical guarantees plus SHA-256 determinism. |
| No prefix | NOT prefixed with 'sha256:' — bare 64-char hex per integrity.md L141. Diverges deliberately from κ P1.5.1 ('sha256:' prefix) — μ is downstream of κ versioning, not parallel. |
3.8. timestamp_logical (bigint)
| Property | Value |
|---|---|
| Type | bigint (uint64 semantics; non-negative) |
| Zod | z.bigint().nonnegative() |
| Source of truth | integrity.md L142 |
| Hash preimage | NO — metadata for ordering, NOT identity (per audit Q3 and λ history-row dedup pattern) |
| Source | Caller-supplied. P4.1.1 does NOT provide a clock; the Lamport counter lives in the detector or escalation FSM layer (later slices), mirrors θ P3.1.1’s module-local nextLogical(). |
| Forbidden | Date.now(), performance.now(), Date.UTC, wall-clock anywhere in schema.ts. Same posture as θ P3.1.1 + κ. |
4. Behavioral invariants
I1. Dedup invariant (the most-load-bearing)
For any two advisories A and B, if:
A.role === B.role
A.check === B.check
canonicalize(A.input) === canonicalize(B.input)
A.result === B.result
then A.decision_hash === B.decision_hash.
The contrapositive: if any one of (role, check, canonical(input), result) differs, the hash MUST differ (with SHA-256 collision probability, i.e. effectively never).
The dedup invariant is the foundation of P4.5.1’s INSERT-only schema: identical advisories are collapsed on write rather than appearing as duplicate rows.
I2. Determinism invariant
computeDecisionHash(role, check, input, result) called 1000 times on
the same arguments returns 1000 byte-identical strings.
serializeAdvisory(advisory) called 1000 times on the same advisory
object (structurally identical) returns 1000 byte-identical Buffers.
I3. No-wall-clock invariant (static scanner)
The source file src/domains/integrity/schema.ts MUST NOT contain the
tokens:
Date.nowDate.UTCDate.parsenew DateMath.randomMath.floor(caught by lint as an unused-import normally; included for completeness — μ uses bigint only)performance.now
Plus the test file MUST grep the source for the above tokens and
expect them to be absent (one assertion per token).
I4. No-RNG invariant
Same as I3 — Math.random and crypto.randomBytes / crypto.randomUUID
MUST NOT appear in schema.ts. The detectors and escalation FSM may
require deterministic identifiers (e.g. for advisory row IDs) but that
is downstream of P4.1.1.
I5. REUSE-κ-canonical invariant
schema.ts MUST import canonicalize from ../rules/canonical.js. It
MUST NOT define a parallel canonical encoder, sort-keys helper, or
escape helper. Verification: a grep for the literal token JSON.stringify
on schema.ts MUST return zero matches.
I6. NAMED-import invariant (forward-compat with κ-style determinism)
createHash is imported by NAME from node:crypto. The dotted form
crypto.createHash MUST NOT appear in the source. Same convention as
θ P3.1.1 and κ P1.5.1.
I7. Engine-version omission invariant
computeDecisionHash MUST NOT mix an engine version into the preimage.
This diverges from κ P1.5.1 deliberately — μ is observational; algorithm
changes are expressed as new check values (algorithm-discriminator
embedded in the closed enum), NOT as a per-call version mixin. The
divergence is pinned by audit Q7 and acceptance criterion AC#7 below.
I8. INSERT-only friendly (Phase 0 AX-01)
The schema field types are all readonly / closed. The TS interface
exposed via z.infer<typeof AdvisorySchema> MUST satisfy
readonly-able semantics — i.e. callers may freeze instances and the
type system does not push back. Verified by spreading into a frozen
object literal in tests.
5. Function contracts
computeDecisionHash(role, check, input, result): string
function computeDecisionHash(
role: AdvisoryRole,
check: AdvisoryCheck,
input: unknown,
result: AdvisoryResult,
): string;
Algorithm:
body = role + '||' + check + '||' + canonicalize(input) + '||' + resultdigest = createHash('sha256').update(body, 'utf8').digest('hex')- Return
digest(64-char lowercase hex)
Throws:
AdvisorySerializationError(wrappingCanonicalSerializationError) ifinputis un-representable in canonical JSON (cycles, undefined, function, symbol, non-integer number, non-plain object).
Does NOT throw:
- For empty-string role/check/result (TypeScript prevents this — the function signature is typed against the closed enums).
- For a string longer than 2^32 — defensive note only; canonical’s
internal
Stringoperations have no length cap in practice.
serializeAdvisory(advisory): Buffer
function serializeAdvisory(advisory: Advisory): Buffer;
Algorithm:
- Convert the advisory to a canonical structure by replacing the
biginttimestamp_logicalwith its decimal string. (Reason: κ canonical handles bigint viatoString()natively; no pre-pass is strictly needed. But θ P3.1.1 uses a Buffer pre-pass; we follow the same shape for forward compat — see §I5.) canonicalize(advisory)directly. bigint is canonicalized as decimal integer; arrays + plain objects fall through unchanged.Buffer.from(<canonical-string>, 'utf8')and return.
Throws:
AdvisorySerializationError(wrappingCanonicalSerializationError) if theevidencearray contains an un-representable element. The caller is responsible for ensuring evidence elements are plain JSON (strings, numbers, booleans, plain objects, arrays, null, bigint).
6. Test plan (matched 1:1 to acceptance criteria; see §7)
Tests live at src/__tests__/domains/integrity/schema.test.ts.
| Group | Tests | AC |
|---|---|---|
| G1 Schema | Parses a valid advisory; rejects each invalid case (bad role, bad check, bad result, bad severity, non-bigint timestamp, non-hex decision_hash, missing fields) | AC#1, AC#2, AC#3, AC#4, AC#5, AC#8, AC#9 |
| G2 Roundtrip | parse → serialize → reparse → structural equality | AC#1 |
| G3 Serialize determinism | serializeAdvisory(advisory) ×1000 → byte-identical |
AC#11 |
| G4 Hash determinism | computeDecisionHash(role, check, input, result) ×1000 → identical hex |
AC#11 |
| G5 Dedup invariant | Same (role, check, canonical(input), result) → same hash, even with different evidence/recommendation/severity/timestamp_logical |
AC#10 |
| G6 Preimage discrimination | Same (role, check, input) with different result → different hash; same (role, check, result) with different input → different hash; same (check, input, result) with different role → different hash |
AC#10 |
| G7 Hash shape | computeDecisionHash output matches /^[a-f0-9]{64}$/ |
AC#7 |
| G8 No engine_version | computeDecisionHash signature has no version parameter; passing the same input across “model identities” (caller-supplied semantics) does not require a version arg |
AC#7 |
| G9 Static scanner | Read schema.ts content; assert absence of Date.now, Date.UTC, Date.parse, new Date, Math.random, performance.now, JSON.stringify (last one is the REUSE-κ check) |
AC#12, AC#13 |
| G10 NAMED import | Read schema.ts; assert absence of the dotted crypto. token in any non-comment line (defensive — comment-strip via a simple regex) |
AC#14 |
| G11 Error class | AdvisorySerializationError is a named Error subclass; wraps a CanonicalSerializationError cause |
AC#15 |
| G12 Bigint timestamp | Advisory with timestamp_logical: 0n parses; with -1n rejects (nonnegative constraint); with number type rejects |
AC#5 |
Target ≥ 30 individual it() cases. Estimated 35-45.
7. Acceptance criteria (atomized)
- AC#1 —
AdvisorySchema.parse(validAdvisory)returns the same structure (deep equality). - AC#2 — Invalid
role(e.g.'Auditor') is rejected by zod withZodError. - AC#3 — Invalid
check(e.g.'unknown') rejected. - AC#4 — Invalid
result(e.g.'OK') rejected. - AC#5 — Invalid
severity(e.g.'INFO', the s14 donor label) rejected. - AC#6 —
evidenceisz.array(z.unknown()); an empty array passes; a non-array (e.g.'foo') fails. - AC#7 —
decision_hashmatches/^[a-f0-9]{64}$/; not prefixed withsha256:. - AC#8 —
timestamp_logicalmust bebigint; number is rejected;0naccepted; negative bigint rejected. - AC#9 — Missing fields are rejected by zod’s strict-object default.
- AC#10 — Dedup invariant: same
(role, check, canonical(input), result)→ same hash across all four permutations of differing metadata. - AC#11 — Determinism over 1000 iterations for both
serializeAdvisoryandcomputeDecisionHash. - AC#12 — Static scanner: source has no
Date.*,Math.random,performance.now,JSON.stringify. - AC#13 — Static scanner: source has no
new Date. - AC#14 — Static scanner: source uses NAMED
createHashimport, no dottedcrypto.token. - AC#15 —
AdvisorySerializationErroris a named error subclass whosecauseis aCanonicalSerializationErrorwheninputis un-representable.
All 15 ACs trace to npm run build && npm run lint && npm test. The
gate (CLAUDE.md §5) must pass with zero new test failures.
8. Forbidden patterns (locks)
| Forbidden | Reason |
|---|---|
Date.* anywhere in schema.ts |
I3 no-wall-clock |
Math.random anywhere |
I4 no-RNG |
performance.now anywhere |
I3 (perf clock is wall-clock-derived) |
JSON.stringify anywhere |
I5 REUSE-κ-canonical |
Dotted crypto.<X> |
I6 NAMED-import |
Re-export canonicalize from this module |
I5 (caller imports from ../rules/canonical.js directly) |
Engine version mixin in computeDecisionHash |
I7 |
| Padding the hash to a non-64 length | §3.7 |
'sha256:' prefix on the hash |
§3.7 |
Including severity / evidence / recommendation / timestamp_logical in the hash preimage |
§4 I1 + §3.4 / §3.5 / §3.6 / §3.8 |
Including model_identity in the hash preimage |
§3.7 + audit Q7 |
9. Ratification
This contract is ratified at audit-step close (5c8a8676). No open
issues remain. Proceed to Step 3 (packet).