Packet — P4.2.3 Axiom Drift Tracker

0. Pre-flight

  • Worktree: .worktrees/claude/p4-2-3-drift-tracker
  • Branch: feature/p4-2-3-drift-tracker
  • Base SHA: 49560518 (P4.1.1 envelope just merged)
  • npm deps already installed (npm install ran cleanly)
  • Audit + contract committed at 258f610e + c19e2d9e

1. File plan

1.1. src/domains/integrity/detectors/drift.ts (NEW)

Structure (section-numbered for grep parity with schema.ts):

§1 Imports
   - Advisory, AdvisoryCheck (etc.) from '../schema.js'
   - computeDecisionHash from '../schema.js'

§2 Constants
   - WINDOW_MS, WARN_THRESHOLD_BPS, BLOCK_THRESHOLD_BPS, AXIOM_IDS

§3 Types
   - AxiomId (derived from AXIOM_IDS tuple)
   - ParameterChange (domain, delta_bps, timestamp_logical)
   - StagedProposal (id, domain, would_reduce_invariant callback)

§4 Pure helpers
   - absBigint(x: bigint): bigint   — (x < 0n ? -x : x)
   - stringifyBigint(b: bigint): string  — String(b)
   - makeDriftAdvisory(...)
   - makeRegressionAdvisory(...)

§5 Public function
   - checkAxiomDrift(domain, now, changes, stagedProposals): Advisory[]

1.2. src/__tests__/domains/integrity/detectors/drift.test.ts (NEW)

Test groups (mirroring contract §6):

G1  Empty / zero advisories
G2  WARN boundary
G3  BLOCK boundary
G4  Window filter (in/out exact)
G5  Domain filter
G6  Abs-magnitude (negative deltas)
G7  Single AX regression
G8  Multiple AX regressions in one proposal
G9  Combined drift + regression
G10 12-month synthetic corpus stub
G11 Determinism ×100
G12 Static scanner (no Date.now, no Math.random, no Math.abs)
G13 AXIOM_IDS exhaustive
G14 Constants exact
G15 Advisory shape passes AdvisorySchema

1.3. Files NOT touched (firewall)

  • src/domains/integrity/schema.ts — frozen at P4.1.1.
  • src/domains/integrity/detectors/circular.ts — owned by P4.2.1 (parallel agent).
  • src/domains/integrity/detectors/coercion.ts — owned by P4.2.2 (parallel agent).
  • All other source files — read-only references.

2. Implementation order

Wave (a) — drift.ts skeleton (~80 LOC)

  1. Imports + constants + types
  2. Stub checkAxiomDrift returning []
  3. Run npm run build — expect clean
  4. Run npm run lint — expect clean

Wave (b) — windowed magnitude + drift advisory emission (~60 LOC)

  1. absBigint helper
  2. Filter windowed (domain + timestamp_logical floor)
  3. Reduce to magnitude
  4. Emit drift advisory at WARN / BLOCK tiers
  5. Test groups G1–G6 written + green

Wave (c) — AX regression advisory (~40 LOC)

  1. Loop over stagedProposals.filter(p.domain === domain)
  2. Inner loop over AXIOM_IDS
  3. Emit regression advisory on would_reduce_invariant === true
  4. Test groups G7–G9 + G13 written + green

Wave (d) — determinism + scanners + corpus stub (~30 LOC test-only)

  1. Test groups G10 (synthetic 12-month corpus) + G11 (×100 sweep)
  2. Test group G12 (static scanner reads drift.ts source as text)
  3. Test group G14 (constants exact) + G15 (AdvisorySchema parses each)

Wave (e) — verify

  1. npm run build && npm run lint && npm test
  2. Commit feat
  3. Write verification doc with test counts

3. Advisory shape — final wire form

3.1. axiom_drift

// makeDriftAdvisory inputs: domain, magnitude, now, severity, result, windowedChanges
const advisory: Advisory = {
  role: 'Sentinel',
  check: 'axiom_drift',
  result,                    // 'WARN' | 'BLOCK'
  severity,                  // 'MED' | 'HIGH'
  evidence: [
    {
      kind: 'parameter_change_window',
      domain,
      magnitude_bps: stringifyBigint(magnitude),
      window_ms: stringifyBigint(WINDOW_MS),
      threshold_bps: stringifyBigint(
        result === 'BLOCK' ? BLOCK_THRESHOLD_BPS : WARN_THRESHOLD_BPS,
      ),
      change_count: windowedChanges.length,
    },
    ...windowedChanges.map((c) => ({
      kind: 'parameter_change' as const,
      domain: c.domain,
      timestamp_logical: stringifyBigint(c.timestamp_logical),
      delta_bps: stringifyBigint(c.delta_bps),
    })),
  ],
  recommendation:
    result === 'BLOCK'
      ? `Cumulative parameter-change magnitude in domain "${domain}" ` +
        `reached ${stringifyBigint(magnitude)} bps within a 6-month window, ` +
        `meeting or exceeding the AX-06 cap of ` +
        `${stringifyBigint(BLOCK_THRESHOLD_BPS)} bps. BLOCK new proposals ` +
        `in this domain until the rolling magnitude falls below ` +
        `${stringifyBigint(WARN_THRESHOLD_BPS)} bps.`
      : `Cumulative parameter-change magnitude in domain "${domain}" ` +
        `reached ${stringifyBigint(magnitude)} bps within a 6-month window, ` +
        `meeting or exceeding the WARN threshold of ` +
        `${stringifyBigint(WARN_THRESHOLD_BPS)} bps (AX-06 cap is ` +
        `${stringifyBigint(BLOCK_THRESHOLD_BPS)} bps). Operator review ` +
        `recommended.`,
  decision_hash: computeDecisionHash(
    'Sentinel',
    'axiom_drift',
    {
      domain,
      magnitude_bps: stringifyBigint(magnitude),
      window_ms: stringifyBigint(WINDOW_MS),
      threshold_bps: stringifyBigint(
        result === 'BLOCK' ? BLOCK_THRESHOLD_BPS : WARN_THRESHOLD_BPS,
      ),
    },
    result,
  ),
  timestamp_logical: now,
};

3.2. axiom_regression

const advisory: Advisory = {
  role: 'Sentinel',
  check: 'axiom_regression',
  result: 'BLOCK',
  severity: 'HIGH',
  evidence: [
    {
      kind: 'staged_proposal',
      proposal_id: proposal.id,
      domain,
      axiom,                  // AxiomId — '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 (the proposal ` +
    `must be amended or withdrawn before re-submission).`,
  decision_hash: computeDecisionHash(
    'Sentinel',
    'axiom_regression',
    { proposal_id: proposal.id, axiom },
    'BLOCK',
  ),
  timestamp_logical: now,
};

4. Determinism strategy

  • windowed and the evidence array preserve source order from changes.
  • stagedProposals.filter(...) preserves source order.
  • AXIOM_IDS is a const tuple in declaration order; inner loop walks it.
  • stringifyBigint(b: bigint): string uses String(b) — produces decimal string with no n suffix; matches κ canonical encoding.
  • computeDecisionHash is already deterministic per P4.1.1.
  • recommendation is template-rendered with deterministic field order.

The ×100 sweep test invokes checkAxiomDrift(...) 100 times on identical inputs and asserts JSON.stringify(...) of every result is byte-identical to the first (using replacer to handle bigint timestamp_logical).

5. Static scanner (G12) approach

Read the drift.ts source as text from the worktree (resolve path from import.meta.url), then assert:

  • No \bDate\.now\b occurrence
  • No \bnew Date\b occurrence (block constructor calls)
  • No \bperformance\.now\b occurrence
  • No \bMath\.random\b occurrence
  • No \bMath\.abs\b occurrence (bigint-incompatible)
  • No \bcrypto\.randomBytes\b occurrence
  • No \bcrypto\.randomUUID\b occurrence

(The scanner does NOT block comments containing the forbidden tokens — the regex matches identifier shapes, not commented mentions. For belt-and- suspenders we keep code comments free of the forbidden tokens in identifier form; we may refer to them in prose like “no Math.abs”.)

To avoid the scanner false-positive on its own assertion strings, the test reads drift.ts (not drift.test.ts).

6. Synthetic 12-month corpus (G10)

A stub corpus to demonstrate the window-filter behavior across a full year. Concrete fixture:

const synth = (months: number) => {
  const out: ParameterChange[] = [];
  for (let m = 0; m < months; m++) {
    out.push({
      domain: 'execution',
      delta_bps: 50n,                              // +0.5% per month
      timestamp_logical: BigInt(m) * MONTH_MS,
    });
  }
  return out;
};
const MONTH_MS = WINDOW_MS / 6n;                   // 2_592_000_000n
const corpus = synth(12);
const now = BigInt(11) * MONTH_MS;                 // "now" at month 11
const result = checkAxiomDrift('execution', now, corpus, []);
// Window: [now - WINDOW_MS, now] = [month 5, month 11]
// Filter keeps months 5,6,7,8,9,10,11 = 7 changes × 50 bps = 350 bps
// 350 < 800 → no advisory
expect(result).toHaveLength(0);

A second case bumps delta_bps to e.g. 150n to push past WARN:

// 7 changes × 150 bps = 1050 bps → HIGH/BLOCK
const corpus = synth(12).map((c) => ({ ...c, delta_bps: 150n }));
const result = checkAxiomDrift('execution', now, corpus, []);
expect(result).toHaveLength(1);
expect(result[0].check).toBe('axiom_drift');
expect(result[0].result).toBe('BLOCK');

(The full corpus / FP profile measurement lives in P4.7.1 per the dispatch packet. G10 is a stub demonstrating the detector handles multi-event corpora correctly.)

7. Lint anticipated issues + mitigation

ESLint config (inherited from project root). Likely complaints + preempt-fixes:

Likely warning Fix
no-unused-vars on evidence field if narrowed badly Use proper return types throughout
@typescript-eslint/no-explicit-any on evidence array elements Use unknown (matches AdvisorySchema.evidence: z.array(z.unknown()))
no-magic-numbers for 2_592_000_000n in test Define MONTH_MS = WINDOW_MS / 6n in tests
@typescript-eslint/consistent-type-imports Use import type for type-only imports from ../schema.js
Prefer as const on inline string literals in evidence Use kind: '...' as const

If a lint warning surfaces an unexpected issue, fix it in the implement commit (Wave e), not in a separate commit.

8. TypeScript strictness considerations

tsconfig.json has strict: true, noUncheckedIndexedAccess: true, exactOptionalPropertyTypes: true. Implications:

  • windowedChanges[i] indexing is ParameterChange | undefined — prefer for ... of windowedChanges to avoid the undefined narrow.
  • The would_reduce_invariant callback signature must accept AxiomId exactly (no widened string).
  • AXIOM_IDS as const tuple — derive AxiomId via (typeof AXIOM_IDS)[number].

9. Test layout

Path: src/__tests__/domains/integrity/detectors/drift.test.ts

Import paths (NodeNext ESM rewriting, see jest.config.ts moduleNameMapper):

import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { AdvisorySchema } from '../../../../domains/integrity/schema.js';
import {
  AXIOM_IDS,
  BLOCK_THRESHOLD_BPS,
  WARN_THRESHOLD_BPS,
  WINDOW_MS,
  type AxiomId,
  type ParameterChange,
  type StagedProposal,
  checkAxiomDrift,
} from '../../../../domains/integrity/detectors/drift.js';

(The four .. levels descend from src/__tests__/domains/integrity/detectors/ to src/domains/.... Same shape as schema.test.ts modulo one extra level.)

10. Commit sequence

Five commits, mirroring the 5-step chain template:

  1. (already) audit(p4-2-3-drift-tracker): inventory surface258f610e
  2. (already) contract(p4-2-3-drift-tracker): behavioral contractc19e2d9e
  3. (this) packet(p4-2-3-drift-tracker): execution plan
  4. (next) feat(p4-2-3-drift-tracker): sliding-window drift + AX regression detector
  5. (last) verify(p4-2-3-drift-tracker): test evidence

11. Quality gate

npm run build && npm run lint && npm test

Baseline at 49560518 per dispatch packet: 3553 tests / 80 suites. Target: 3553 + ~25 = ~3578, no regressions.

12. Risks

Risk Mitigation
Math.abs accidental use Scanner forbid in G12
Date.now accidental use Scanner forbid in G12
Wide-string AxiomId allows non-AX values AXIOM_IDS as const + (typeof)[number] literal union
Boundary off-by-one (>= vs >) Tests pin 800 (WARN-inclusive), 999 (still WARN), 1000 (BLOCK-inclusive), 1500 (over)
Out-of-window boundary Test 16/17 pin now - WINDOW_MS (in) and now - WINDOW_MS - 1n (out)
Mutually exclusive collapse of drift+regression §I5 invariant explicit; G9 test pins 2-advisory output
Lint blocks build Anticipated fixes in §7
Determinism: object key order in evidence Object literals in TS preserve source order; canonical serialization in decision_hash already sorts. Test G11 catches drift.
Bigint serialization breaks JSON.stringify Use replacer: (k, v) => typeof v === 'bigint' ? String(v) : v in determinism test

13. Sign-off

Packet approved for implementation per §6 of CLAUDE.md (packet gates Step 4). Proceeding to feat(...) commit next.


Back to top

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

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