Contract — P4.2.3 Axiom Drift Tracker
1. Surface
P4.2.3 ships a single pure-function module:
src/domains/integrity/detectors/drift.ts
1.1. Exports (public)
| Name | Kind | Type / Value |
|---|---|---|
WINDOW_MS |
const bigint | 15_552_000_000n — 6-month Lamport-equivalent (180 days × 86_400_000 ms) |
WARN_THRESHOLD_BPS |
const bigint | 800n — 8% cumulative-magnitude threshold |
BLOCK_THRESHOLD_BPS |
const bigint | 1_000n — 10% AX-06 cap (BLOCK_NEW_PROPOSALS) |
AXIOM_IDS |
const readonly tuple | ['AX-01','AX-02','AX-03','AX-04','AX-05','AX-06','AX-07'] |
type AxiomId |
TS literal union | derived from (typeof AXIOM_IDS)[number] |
type ParameterChange |
TS shape | { domain: string; delta_bps: bigint; timestamp_logical: bigint } |
type StagedProposal |
TS shape | { id: string; domain: string; would_reduce_invariant: (ax: AxiomId) => boolean } |
function checkAxiomDrift |
pure function | (domain, now, changes, stagedProposals) => Advisory[] |
function absBigint |
pure helper | (x: bigint) => bigint (κ-style abs; private to module if not re-exported in implementation) |
The Advisory type is consumed verbatim from ../schema.js (P4.1.1); no
re-export.
1.2. Module placement
- Path:
src/domains/integrity/detectors/drift.ts - Test mirror:
src/__tests__/domains/integrity/detectors/drift.test.ts
No index.ts in detectors/ for Wave 2 — each detector is imported by
its full path. Wave 3 P4.4.1 (escalation FSM) may add a barrel re-export
once all three detectors land.
2. checkAxiomDrift — full signature
export function checkAxiomDrift(
domain: string,
now: bigint,
changes: readonly ParameterChange[],
stagedProposals: readonly StagedProposal[],
): Advisory[];
2.1. Parameters
| Param | Type | Notes |
|---|---|---|
domain |
string |
The governance / rule domain being audited (e.g. 'execution', 'reputation', 'governance'). Used as filter key for both changes and stagedProposals. |
now |
bigint |
Lamport-equivalent logical clock value, in the same units as WINDOW_MS (milliseconds-equivalent in the κ Lamport namespace). The caller is responsible for clock provision — no Date.now() inside the detector. |
changes |
readonly ParameterChange[] |
Caller-supplied feed of parameter-change events. Read-only. May be empty. |
stagedProposals |
readonly StagedProposal[] |
Caller-supplied list of governance proposals currently staged at π intake. Read-only. May be empty. |
2.2. Return
Advisory[] — an array of advisories that pass AdvisorySchema.parse(...).
Order is deterministic per §I3.
3. Behavior
3.1. Algorithm (pseudo-TS)
function checkAxiomDrift(domain, now, changes, stagedProposals):
advisories: Advisory[] = []
// (1) Filter to in-window same-domain changes.
windowed = changes.filter(c =>
c.domain === domain &&
c.timestamp_logical >= now - WINDOW_MS
)
// (2) Cumulative absolute magnitude.
magnitude = 0n
for c in windowed:
magnitude = magnitude + abs(c.delta_bps)
// (3) WARN tier — magnitude ∈ [WARN, BLOCK).
if magnitude >= WARN_THRESHOLD_BPS and magnitude < BLOCK_THRESHOLD_BPS:
advisories.push(makeDriftAdvisory(domain, magnitude, now,
severity='MED', result='WARN',
windowed))
// (4) BLOCK tier — magnitude ≥ BLOCK.
else if magnitude >= BLOCK_THRESHOLD_BPS:
advisories.push(makeDriftAdvisory(domain, magnitude, now,
severity='HIGH', result='BLOCK',
windowed))
// (5) Per-AX regression — orthogonal to (3)/(4).
for proposal in stagedProposals.filter(p => p.domain === domain):
for ax in AXIOM_IDS:
if proposal.would_reduce_invariant(ax):
advisories.push(makeRegressionAdvisory(domain, proposal.id, ax, now))
return advisories
3.2. axiom_drift advisory shape
{
role: 'Sentinel',
check: 'axiom_drift',
result: 'WARN' | 'BLOCK',
severity: 'MED' | 'HIGH',
evidence: [
{ kind: 'parameter_change_window',
domain,
magnitude_bps: <stringified bigint>,
window_ms: <stringified bigint>,
threshold_bps: <stringified bigint>,
change_count: <number>,
},
... one element per windowed change:
{ kind: 'parameter_change',
domain,
timestamp_logical: <stringified bigint>,
delta_bps: <stringified bigint>,
},
],
recommendation: 'Cumulative parameter-change magnitude in domain "<domain>" reached <magnitude_bps> bps within a 6-month window. ...',
decision_hash: <64-char hex>,
timestamp_logical: now,
}
The recommendation text is a deterministic template — see §I4.
The decision_hash preimage uses
computeDecisionHash('Sentinel', 'axiom_drift', input, result) where
input = { domain, magnitude_bps, window_ms, threshold_bps } (the four
identity-defining fields; not the full evidence array — evidence is
metadata orthogonal to identity).
evidence array contents are stringified bigints inside plain objects so
they survive canonical JSON (κ P1.5.4 emits bigint as decimal string
already; using stringified bigint in the evidence shape keeps the encoded
form predictable across JSON.stringify-equivalent paths).
3.3. axiom_regression advisory shape
{
role: 'Sentinel',
check: 'axiom_regression',
result: 'BLOCK',
severity: 'HIGH',
evidence: [
{ kind: 'staged_proposal',
proposal_id,
domain,
axiom: <one of AX-01..AX-07>,
},
],
recommendation: 'Staged proposal "<proposal_id>" would reduce axiom <axiom> invariant in domain "<domain>". HARD BLOCK — escalate to α tool-lock per integrity.md §Escalation mapping.',
decision_hash: <64-char hex>,
timestamp_logical: now,
}
decision_hash preimage uses
computeDecisionHash('Sentinel', 'axiom_regression', input, result)
where input = { proposal_id, axiom } (the two identity-defining
fields).
One advisory per (proposal × axiom) pair that returns true from
would_reduce_invariant(ax). A single proposal that violates three
axioms produces three advisories.
3.4. Combined-result invariant (acceptance criterion §11)
A single checkAxiomDrift call may emit BOTH an axiom_drift advisory
(WARN or BLOCK) AND any number of axiom_regression advisories. The two
check codes are NOT mutually exclusive — they are orthogonal signals per
design invariant 9. Collapsing them into a single advisory would lose
escalation-routing information (axiom_drift BLOCK → π proposal intake,
axiom_regression BLOCK → α tool-lock).
4. Invariants
§I1. Pure function
No DB, no filesystem, no network, no async, no I/O. imports are
restricted to:
node:crypto— re-exported transitively via../schema.js(no direct import from drift.ts needed;computeDecisionHashalready wraps the namedcreateHashcall).../schema.js—Advisory,AdvisoryCheck,AdvisoryRole,AdvisoryResult,AdvisorySeverity,computeDecisionHash.
Forbidden by static scanner (mirroring P4.1.1 envelope test §G9):
Date.now,Date(),new Date,performance.now, any wall-clock read.Math.random,crypto.randomBytes,crypto.randomUUID, any RNG call.- Top-level expressions other than
import/export/const/function(no module-level mutable state). Math.abs— does NOT accept bigint; would silently TypeError at runtime (or coerce). Use the(x < 0n ? -x : x)ternary or a helper.
§I2. Lamport clock injection
now is supplied by the caller as a bigint. The detector subtracts
WINDOW_MS arithmetically (now - WINDOW_MS) to get the window floor.
Bigint subtraction yields negative values when now < WINDOW_MS — the
filter still works correctly (any timestamp_logical >= <negative_floor>
trivially holds, so an early-Lamport call admits all changes).
The detector does NOT validate now for non-negativity. The caller’s
clock provider is responsible. P4.1.1’s envelope requires
timestamp_logical be non-negative, but the floor of the window
calculation may legitimately go negative when the system is young.
§I3. Deterministic output
For identical inputs (same domain, same now, same elements of
changes and stagedProposals in same order), checkAxiomDrift returns
a byte-identical Advisory[] array (same length, same order, same field
values). This implies:
- Stable iteration over
windowed—Array.prototype.filterpreserves source order, soevidenceordering is fully determined bychangesordering. - Stable proposal enumeration — outer loop is
stagedProposals.filter(...)in source order; inner loop isAXIOM_IDSin declaration order. - Bigint formatting in evidence uses
String(b)(decimal, nonsuffix), matching κ canonical’s bigint encoding.
Tested by ×100 sweep producing identical JSON-stringified output across runs.
§I4. Deterministic recommendation text
The recommendation strings are template-rendered with deterministic field
order. Two calls with identical inputs produce byte-identical
recommendation strings. This is required for the dedup invariant
inherited from P4.1.1 — even though recommendation is NOT a
decision_hash preimage field, two callers with identical inputs that
disagree on recommendation text would diverge at canonical-serialize time
and break parity tests.
§I5. Two-check-code preservation (design invariant 9)
axiom_drift and axiom_regression are NEVER collapsed into a single
advisory. The check codes route to different escalation paths in P4.4.1
(Wave 3): axiom_drift BLOCK → π proposal intake (recoverable);
axiom_regression BLOCK → α tool-lock (hard, requires governance path
to clear). Collapsing them would lose the routing.
Test: a scenario with magnitude = 1500n AND one would_reduce_invariant
proposal produces exactly TWO advisories (one axiom_drift HIGH/BLOCK
plus one axiom_regression HIGH/BLOCK), not one combined “HIGH/BLOCK”.
§I6. AX-01..AX-07 exhaustive enumeration
AXIOM_IDS MUST contain all seven values, in canonical numeric order. A
TypeScript exhaustiveness check (or runtime assertion in test) guards
against accidental removal of an axiom.
§I7. BPS bigint throughout
All numeric operations on delta_bps, magnitude, WARN_THRESHOLD_BPS,
BLOCK_THRESHOLD_BPS use bigint. No Number(...), no parseInt(...),
no float arithmetic. The Number(...) cast appears at most at:
- evidence string serialization (
String(bigint)→ decimal string).
change_count IS allowed to be a number because it’s a count of array
elements (always int32-safe; arrays are bounded by JavaScript’s max array
length).
§I8. No clock drift — now invariance
Every emitted advisory has timestamp_logical: now (the now passed to
the function). The detector never reads a different clock. P4.4.1 (Wave 3)
will sequence calls so each detector receives a consistent now.
§I9. Bounded recursion / loop
Outer loop iterates at most stagedProposals.length items; inner loop
iterates exactly AXIOM_IDS.length === 7. There is no recursion. Loop
bounds are caller-supplied + constant, never dependent on bigint range.
§I10. Zero-length input → zero advisories
checkAxiomDrift(d, now, [], []) returns []. No spurious empty
advisories. The function is identity-on-empty.
5. Edge cases (the test plan codifies all)
| # | Scenario | Expected |
|---|---|---|
| 1 | changes=[], stagedProposals=[] |
[] |
| 2 | changes=[+500] in window |
[] (under WARN) |
| 3 | changes=[+800] in window |
1 drift MED/WARN |
| 4 | changes=[+999] in window |
1 drift MED/WARN |
| 5 | changes=[+1000] in window |
1 drift HIGH/BLOCK |
| 6 | changes=[+1500] in window |
1 drift HIGH/BLOCK |
| 7 | changes=[-500, +500] in window |
1 drift MED/WARN (abs sums to 1000? — actually 1000n exact → HIGH/BLOCK; revise: -500+500 has abs-sum 1000 → BLOCK) |
| 7b | changes=[-400, +400] in window |
[] (abs-sum 800 → exactly WARN boundary). Hmm — 800 is WARN. Let me re-test 7: -500+500 → abs-sum 1000 → BLOCK. Use distinct-domain tests below. |
| 8 | changes=[+1500] with timestamp_logical = now - WINDOW_MS - 1n |
[] (out of window) |
| 9 | changes=[+1500] with domain='other' |
[] (cross-domain excluded) |
| 10 | empty changes, stagedProposals=[{would_reduce_invariant(AX_01)=true}] |
1 regression HIGH/BLOCK |
| 11 | empty changes, stagedProposals=[{returns true for AX_01,AX_03,AX_05}] |
3 regressions HIGH/BLOCK |
| 12 | changes=[+1500] AND one AX_03-regressing proposal |
2 advisories (1 drift HIGH/BLOCK + 1 regression HIGH/BLOCK) |
| 13 | 12-month synthetic corpus, only last 6 months counted | drift advisory count matches expected window-filtered count |
| 14 | 100× determinism sweep on case (12) | all 100 results structurally equal (JSON.stringify equivalent) |
| 15 | Static scanner — no Date.now, no Math.random in drift.ts |
scanner returns 0 matches |
| 16 | exact boundary: timestamp_logical === now - WINDOW_MS |
in-window (>= boundary, inclusive lower bound) |
| 17 | exact boundary: timestamp_logical === now - WINDOW_MS - 1n |
out-of-window |
| 18 | proposal with domain differs from domain arg |
excluded from regression check |
| 19 | very large bigint (10^18) delta_bps — overflow-safe? |
summed cleanly (bigint is unbounded; only test that we don’t break) |
| 20 | NEW proposal returns false for ALL axioms |
0 advisories (the proposal is benign) |
(Cases 7 and 7b were dialed in during contract drafting — kept as a reminder that boundary magnitudes are tight; tests pick canonical values 500, 800, 999, 1000, 1500 to nail boundaries cleanly.)
6. Test plan (G-groups, mirroring P4.1.1 §6)
| Group | Coverage |
|---|---|
| G1 | Empty inputs / zero advisories |
| G2 | WARN boundary (800, 999) |
| G3 | BLOCK boundary (1000, 1500) |
| G4 | Window filter (in/out exact, 1-Lamport-tick over) |
| G5 | Domain filter (cross-domain excluded) |
| G6 | Abs-magnitude (negative + positive deltas sum by abs) |
| G7 | AX regression — single axiom |
| G8 | AX regression — multiple axioms in one proposal |
| G9 | Combined drift + regression |
| G10 | Synthetic 12-month corpus stub |
| G11 | Determinism ×100 |
| G12 | Static scanner forbids |
| G13 | AXIOM_IDS exhaustive (all 7 present, no duplicates) |
| G14 | Constants exact values (WINDOW_MS, WARN, BLOCK) |
| G15 | Advisory shape validates against AdvisorySchema |
7. Acceptance criteria (rolled up)
WINDOW_MS === 15_552_000_000n,WARN_THRESHOLD_BPS === 800n,BLOCK_THRESHOLD_BPS === 1_000nexact (G14).AXIOM_IDScontains AX-01..AX-07 exactly, in numeric order, no duplicates (G13).checkAxiomDrift(d, now, [], [])returns[](§I10, G1).- Magnitude under WARN → no drift advisory (G2).
- Magnitude in
[WARN, BLOCK)→ 1 drift MED/WARN (G2). - Magnitude ≥ BLOCK → 1 drift HIGH/BLOCK (G3).
- Cross-domain changes excluded (G5).
- Out-of-window changes excluded (G4).
- Negative + positive deltas sum by abs (G6).
- One AX regression → 1 advisory (G7).
- N AX regressions in one proposal → N advisories (G8).
- Combined: 1500 bps + AX_03 regression → 2 advisories (G9, §I5).
- 12-month corpus stub passes (G10).
- 100× determinism (G11, §I3).
- Static scanner — 0 hits for
Date.now,Math.random,Math.abs(G12, §I1). - Every emitted advisory parses against
AdvisorySchema(G15). decision_hashmatchesDECISION_HASH_REGEX(G15).npm run build && npm run lint && npm testall pass.
8. Non-goals (explicit out-of-scope)
- No persistence — advisories are returned, never written. P4.5.1 owns
the
mcp_advisoriestable writes. - No MCP tool surface — P4.6.1 wraps detectors in a tool registration.
- No escalation FSM — P4.4.1 routes drift BLOCK and regression BLOCK to π / α respectively.
- No
would_reduce_invariantsimulator — the AX simulation runs in the caller’s domain (governance / β). P4.2.3 only enumerates the seven axioms and consumes the callback. - No fork-hook subscription — P4.8.1 attaches drift to post-fork sweeps.
- No false-positive measurement — P4.7.1 ships the full FP corpus.
9. Dependencies summary
- Upstream (consumed): P4.1.1 (
src/domains/integrity/schema.ts), integrity.md §3, constitution.md §AX-01..AX-07, κ P1.1.1 bigint idiom (via documentation, not direct import). - Downstream (unblocked by this): P4.4.1 (escalation FSM), P4.7.1 (FP corpus), P4.8.1 (fork-hook subscriber).
- Parallel (Wave 2 swarm, file-disjoint): P4.2.1 (
circular.ts), P4.2.2 (coercion.ts).
10. References
docs/audits/p4-2-3-drift-tracker-audit.md(Step 1).docs/3-world/physics/enforcement/integrity.md§3 L87-115 (algorithm), §Advisory record schema L129-146, §Escalation mapping L148-157.docs/3-world/physics/constitution.md§AX-01..AX-07 (axiom enumeration).docs/guides/implementation/task-prompts/p4.1-mu-integrity.md§P4.2.3 L668-847 (full task spec).src/domains/integrity/schema.ts(P4.1.1 envelope at base49560518).src/__tests__/domains/integrity/schema.test.ts(test layout reference).