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
aa6ba630 — docs(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 +\uXXXXlowercase 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:
- Compose canonical inputs through
canonicalize(...) createHash('sha256').update(...utf8...).digest('hex')- 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— usesz.enum,z.object,z.number().int(),z.string().min(1),z.infer<typeof ...>. λ declares enums asexport const DomainSchema = z.enum([...]).src/domains/trail/repository.ts— usesz.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 intools.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:
- Missing
rolefield — s14 omits it; integrity.md adds it as the primary Translator/Sentinel/Guide discriminator. - Severity labels — s14 implicitly inherits donor
INFO/WARNING/CRITICAL(per the “spec-only” implementation-status table at the bottom); integrity.md usesLOW/MED/HIGHper λ. - 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 ataa6ba630)src/domains/integrity/schema.tssrc/__tests__/domains/integrity/schema.test.ts(the tests live atsrc/__tests__/domains/<axis>/, NOTsrc/domains/<axis>/__tests__/— confirmed viasrc/__tests__/domains/reputation/schema.test.ts)mcp_advisoriesmigration — 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):
docs/audits/p4-1-1-advisory-envelope-audit.md— this filedocs/contracts/p4-1-1-advisory-envelope-contract.mddocs/packets/p4-1-1-advisory-envelope-packet.mdsrc/domains/integrity/schema.tssrc/__tests__/domains/integrity/schema.test.tsdocs/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
-
Hash preimage ordering risk. The four ingredients in the
decision_hashpreimage arerole,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. -
Lamport clock placement. The integrity.md schema includes
timestamp_logicalBUT 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. -
Determinism corpus self-scan. The κ corpus self-scan rejects
Date.*,Math.*, async, etc. insrc/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 inschema.test.ts(read the file content, assert noDate.now, noMath.random, noperformance.now). Future P4.7.1 (parity harness) may centralize this for all P4 modules. -
Lint posture. ESLint at
aa6ba630allows nullish coalescing, does NOT allow unused vars, requires explicit return types on exported functions, requiresnode:crypto(not barecrypto). 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 forrole/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 θ ataa6ba630.
The contract step can proceed with confidence; the open questions above are pinned and resolved.