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; computeDecisionHash already wraps the named createHash call).
  • ../schema.jsAdvisory, 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 windowedArray.prototype.filter preserves source order, so evidence ordering is fully determined by changes ordering.
  • Stable proposal enumeration — outer loop is stagedProposals.filter(...) in source order; inner loop is AXIOM_IDS in declaration order.
  • Bigint formatting in evidence uses String(b) (decimal, no n suffix), 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_000n exact (G14).
  • AXIOM_IDS contains 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_hash matches DECISION_HASH_REGEX (G15).
  • npm run build && npm run lint && npm test all pass.

8. Non-goals (explicit out-of-scope)

  • No persistence — advisories are returned, never written. P4.5.1 owns the mcp_advisories table 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_invariant simulator — 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 base 49560518).
  • src/__tests__/domains/integrity/schema.test.ts (test layout reference).

Back to top

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

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