Audit — P4.1.1 Advisory Envelope (Wave 1 solo, μ Phase 4)

Purpose

Inventory the surface that P4.1.1 builds on (κ canonical, κ versioning hash, λ severity bands, θ messaging, the live integrity.md schema, the s14 spec) and identify exactly what does NOT exist in the tree at base SHA aa6ba630, so the contract step can pin the contribution precisely.

Base SHA

aa6ba630docs(r94a): graduate μ Phase 4 staging file staged → ready (#270). Branched from origin/main.

What exists upstream (consumed via REUSE, never duplicated)

κ P1.5.4 canonical serializer — src/domains/rules/canonical.ts

Public surface confirmed live at aa6ba630:

  • canonicalize(value: unknown): string — emits canonical JSON: sorted keys (UTF-16 codepoint order, locale-independent), bigint → decimal toString form, integer numbers only, strings escaped with the 7 single- char + \uXXXX lowercase hex form. Forward slash NOT escaped.
  • byteLength(value: unknown): number — UTF-8 byte length of the canonical form.
  • CanonicalSerializationError — thrown on un-representable values (undefined, function, symbol, non-integer number, non-plain object, reference cycle).

Determinism guarantee: byte-identical across Node ≥ 20, any host, any locale, any process. P1.5.1 (version hash), P3.1.1 (θ messages), P3.6.1 (VRF stub) all reuse this without exception. P4.1.1 follows the same pattern.

κ P1.5.1 version-hash construction — src/domains/rules/versioning.ts

The hashing pattern P4.1.1 mirrors:

  1. Compose canonical inputs through canonicalize(...)
  2. createHash('sha256').update(...utf8...).digest('hex')
  3. Constant-time string compare via verifyRuleVersion

createHash is imported as a NAMED import from node:crypto, NOT crypto.createHash — the κ determinism corpus self-scan rejects the dotted form in the rules domain. The same convention is used in src/domains/consensus/messages.ts (θ P3.1.1). P4.1.1 will follow this NAMED-import convention even though the integrity domain has no forbidden-token scanner (yet); the convention is now the project default.

θ P3.1.1 canonical-wire helpers — src/domains/consensus/messages.ts

Pattern reference: P3.1.1 uses canonicalize + createHash('sha256') + Lamport bigint clock + Buffer-to-hex pre-pass for binary fields. The P4.1.1 envelope does NOT carry Buffer fields, so the hex pre-pass is unnecessary. The clock and SHA-256 pattern transfer directly.

Imports in messages.ts § 2:

import { createHash, sign, verify, type KeyObject } from 'node:crypto';
import { canonicalize, CanonicalSerializationError } from '../rules/canonical.js';

P4.1.1 inherits the same import shape minus sign / verify (no Ed25519 in this slice).

λ P2.1.1 severity bands — src/domains/reputation/penalties.ts (penalty severity), bps-constants.ts

The λ Phase 2 schema lives at src/domains/reputation/schema.ts. λ does NOT export a public Severity enum; the band labels live as constants / penalty multipliers in penalties.ts. The integrity.md schema and R91 audit Q6 resolution name the three λ-aligned severity bands as LOW, MED, HIGH. The P4.1.1 envelope uses these three literal labels. The older s14 spec uses INFO/WARNING/CRITICAL; this is superseded by the post-R91 alignment with λ — the staging file makes this explicit (§P4.1.1 acceptance criterion §Severity).

β / ε / ζ / η / λ / θ Zod patterns

Inventoried Zod usage points:

  • src/domains/reputation/schema.ts — uses z.enum, z.object, z.number().int(), z.string().min(1), z.infer<typeof ...>. λ declares enums as export const DomainSchema = z.enum([...]).
  • src/domains/trail/repository.ts — uses z.string().uuid(), z.string().min(1), z.literal(...), z.object({...}).strict().
  • src/domains/consensus/messages.ts — uses interface types directly (not Zod for the message shape; Zod validation happens at the MCP-tool boundary in tools.ts).

The staging file’s acceptance criterion explicitly requires Zod for the 8-field envelope (NOT plain interfaces). P4.1.1 will use z.object with 4 nested enum schemas, the explicit z.bigint() for the Lamport timestamp, and z.string().regex(/^[a-f0-9]{64}$/) for the decision_hash shape check. Types are derived via z.infer<typeof AdvisorySchema> so the TS shape stays single-sourced from the Zod schema.

Live integrity.md schema (concept doc) — L129-146

The current schema as documented:

{
  "role": "Translator" | "Sentinel" | "Guide",
  "check": "circular_logic" | "coercion_trap" | "axiom_drift" | "axiom_regression",
  "result": "PASS" | "WARN" | "BLOCK",
  "severity": "LOW" | "MED" | "HIGH",
  "evidence": [ <references to records/events/rules> ],
  "recommendation": "<free-form human-readable>",
  "decision_hash": "SHA-256(role || check || canonical(input) || result)",
  "timestamp_logical": <uint64>
}

Hash formula confirmed: SHA-256(role || check || canonical(input) || result). This is the R91 audit Q7 resolution and supersedes the older s14 §Output formula SHA-256(check + input + result + model_identity) which embedded model_identity (donor framing).

The decision_hash is the dedup key — identical inputs produce identical advisories. The envelope’s INSERT-only semantics on the future mcp_advisories table land in P4.5.1, not here; P4.1.1 only proves the hash function is deterministic over the canonical-serialized input.

s14 spec — docs/spec/s14-integrity-monitor.md

s14 documents the OLDER schema:

{check, result: PASS|WARN, severity, details, evidence, decision_hash, reasoning_trace}

With formula:

decision_hash = SHA-256(check + input + result + model_identity)

This is the pre-R91 form. Three drift points from integrity.md / R91 audit Q7:

  1. Missing role field — s14 omits it; integrity.md adds it as the primary Translator/Sentinel/Guide discriminator.
  2. Severity labels — s14 implicitly inherits donor INFO/WARNING/CRITICAL (per the “spec-only” implementation-status table at the bottom); integrity.md uses LOW/MED/HIGH per λ.
  3. Hash preimage — s14 includes model_identity; integrity.md drops it (because the advisory is over the input, not over a particular model producing it).

P4.1.1 follows integrity.md (R91 audit Q7 wins). The s14 spec is staged for a reconcile in a later Phase 4 slice (likely the post-Phase-4-close seal); not in P4.1.1.

What does NOT exist (P4.1.1 will create)

  • src/domains/integrity/ directory (zero files at aa6ba630)
  • src/domains/integrity/schema.ts
  • src/__tests__/domains/integrity/schema.test.ts (the tests live at src/__tests__/domains/<axis>/, NOT src/domains/<axis>/__tests__/ — confirmed via src/__tests__/domains/reputation/schema.test.ts)
  • mcp_advisories migration — Out of scope for P4.1.1. Lands in P4.5.1.
  • MCP tool registrations — Out of scope. Land in P4.6.1.

Inventory of files touched by P4.1.1

Touched (CREATE):

  1. docs/audits/p4-1-1-advisory-envelope-audit.md — this file
  2. docs/contracts/p4-1-1-advisory-envelope-contract.md
  3. docs/packets/p4-1-1-advisory-envelope-packet.md
  4. src/domains/integrity/schema.ts
  5. src/__tests__/domains/integrity/schema.test.ts
  6. docs/verification/p4-1-1-advisory-envelope-verification.md

Untouched (READ-only confirmed): src/domains/rules/canonical.ts, src/domains/rules/versioning.ts, src/domains/consensus/messages.ts, src/domains/reputation/schema.ts, docs/3-world/physics/enforcement/integrity.md, docs/spec/s14-integrity-monitor.md, package.json, tsconfig.json, jest.config.ts.

Risk surface

  1. Hash preimage ordering risk. The four ingredients in the decision_hash preimage are role, check, canonical(input), result. If the concatenation is parameter-order-sensitive AND ambiguous over the boundary (e.g. role "BlockA" + check "B" could collide with role "Block" + check "AB"), the dedup invariant fails. P1.5.1 fixed this with the literal || separator, AND the role/check fields are closed enums so the alphabet is bounded. P4.1.1 will use the literal || separator AND a closed enum AND length-prefix-free safe alphabet (enum values are short ASCII). Mitigation lock: contract step §I3.

  2. Lamport clock placement. The integrity.md schema includes timestamp_logical BUT does NOT include it in the hash preimage. The hash is over the SEMANTIC input (role + check + input + result); the timestamp is metadata for ordering, not identity. This matches λ’s pattern (history rows are deduped by event_id, not by epoch). P4.1.1 pins this in the contract.

  3. Determinism corpus self-scan. The κ corpus self-scan rejects Date.*, Math.*, async, etc. in src/domains/rules/. The integrity domain has NO scanner yet, but the staging file forbids the same tokens. P4.1.1 will include a one-off static check in schema.test.ts (read the file content, assert no Date.now, no Math.random, no performance.now). Future P4.7.1 (parity harness) may centralize this for all P4 modules.

  4. Lint posture. ESLint at aa6ba630 allows nullish coalescing, does NOT allow unused vars, requires explicit return types on exported functions, requires node:crypto (not bare crypto). The reputation/schema.ts and messages.ts files give the working template.

Open questions resolved at audit time

Q Resolution
Does the staging file forbid Math.* and Date.* in the integrity domain at large, or just in schema.ts? Just schema.ts per the staging file §P4.1.1 acceptance criteria (line 191: “No Date.now(), Math.random() in the module”). Subsequent slices (detectors) will declare their own posture; this slice scans only schema.ts.
Do we need a Buffer pre-pass like θ messages? No. The envelope’s evidence field is z.array(z.unknown()) and the four hash-preimage fields are pure ASCII strings or canonical-encoded values. No Buffer leaks into either the schema or the hash preimage.
Where does the test file live? src/__tests__/domains/integrity/schema.test.ts (matches the reputation/consensus convention, NOT src/domains/integrity/__tests__/).
Should computeDecisionHash accept Buffer inputs? No. The input parameter is unknown — exactly what κ canonicalize accepts. If a caller passes a Buffer, canonicalize will throw (non-plain object); that is the correct behavior. Callers must pre-encode binary references as hex strings (matching the θ pattern).
Does the SHA-256 prefix 'sha256:' carry over from versioning.ts? No. integrity.md L141 documents the hash as a bare 64-char hex string. The envelope’s decision_hash field is /^[a-f0-9]{64}$/, no prefix. This is a deliberate divergence from P1.5.1 — μ is downstream of κ versioning, not parallel to it; the prefix would be noise.
What is the engine_version mixin? None. The advisory hash signs the SEMANTIC content (role + check + canonical(input) + result). Engine version is a κ-internal concern that P4.1.1 does NOT mix in. If μ’s algorithm changes incompatibly in a future round, a new check value will be added (e.g. circular_logic_v2), and the discriminator inside check will distinguish the algorithm. This is simpler than a per-detector engine_version field and matches the “advisory is over the input” framing.

Conclusion

The audit confirms:

  • All upstream surfaces (κ canonical, κ SHA-256 pattern, integrity.md schema, Zod patterns from β/ε/ζ/η/λ/θ) are in tree at aa6ba630.
  • The P4.1.1 contribution is two new files (schema + test) plus three doc chain files plus one verification file — six files total, all in net-new paths.
  • The hash formula SHA-256(role || check || canonical(input) || result) is unambiguous given the literal || separator and closed enum alphabet for role / check / result.
  • The Lamport clock is metadata, NOT in the hash preimage. Dedup invariant holds across processes and reboots.
  • Tests live under src/__tests__/domains/integrity/ per the project convention validated against λ and θ at aa6ba630.

The contract step can proceed with confidence; the open questions above are pinned and resolved.


Back to top

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

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