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_advisories migration + 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.now
  • Date.UTC
  • Date.parse
  • new Date
  • Math.random
  • Math.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:

  1. body = role + '||' + check + '||' + canonicalize(input) + '||' + result
  2. digest = createHash('sha256').update(body, 'utf8').digest('hex')
  3. Return digest (64-char lowercase hex)

Throws:

  • AdvisorySerializationError (wrapping CanonicalSerializationError) if input is 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 String operations have no length cap in practice.

serializeAdvisory(advisory): Buffer

function serializeAdvisory(advisory: Advisory): Buffer;

Algorithm:

  1. Convert the advisory to a canonical structure by replacing the bigint timestamp_logical with its decimal string. (Reason: κ canonical handles bigint via toString() 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.)
  2. canonicalize(advisory) directly. bigint is canonicalized as decimal integer; arrays + plain objects fall through unchanged.
  3. Buffer.from(<canonical-string>, 'utf8') and return.

Throws:

  • AdvisorySerializationError (wrapping CanonicalSerializationError) if the evidence array 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#1AdvisorySchema.parse(validAdvisory) returns the same structure (deep equality).
  • AC#2 — Invalid role (e.g. 'Auditor') is rejected by zod with ZodError.
  • 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#6evidence is z.array(z.unknown()); an empty array passes; a non-array (e.g. 'foo') fails.
  • AC#7decision_hash matches /^[a-f0-9]{64}$/; not prefixed with sha256:.
  • AC#8timestamp_logical must be bigint; number is rejected; 0n accepted; 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 serializeAdvisory and computeDecisionHash.
  • 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 createHash import, no dotted crypto. token.
  • AC#15AdvisorySerializationError is a named error subclass whose cause is a CanonicalSerializationError when input is 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).


Back to top

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

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