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 installran 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)
- Imports + constants + types
- Stub
checkAxiomDriftreturning[] - Run
npm run build— expect clean - Run
npm run lint— expect clean
Wave (b) — windowed magnitude + drift advisory emission (~60 LOC)
absBiginthelper- Filter
windowed(domain + timestamp_logical floor) - Reduce to
magnitude - Emit drift advisory at WARN / BLOCK tiers
- Test groups G1–G6 written + green
Wave (c) — AX regression advisory (~40 LOC)
- Loop over
stagedProposals.filter(p.domain === domain) - Inner loop over
AXIOM_IDS - Emit regression advisory on
would_reduce_invariant === true - Test groups G7–G9 + G13 written + green
Wave (d) — determinism + scanners + corpus stub (~30 LOC test-only)
- Test groups G10 (synthetic 12-month corpus) + G11 (×100 sweep)
- Test group G12 (static scanner reads
drift.tssource as text) - Test group G14 (constants exact) + G15 (AdvisorySchema parses each)
Wave (e) — verify
npm run build && npm run lint && npm test- Commit feat
- 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
windowedand theevidencearray preserve source order fromchanges.stagedProposals.filter(...)preserves source order.AXIOM_IDSis a const tuple in declaration order; inner loop walks it.stringifyBigint(b: bigint): stringusesString(b)— produces decimal string with nonsuffix; matches κ canonical encoding.computeDecisionHashis already deterministic per P4.1.1.recommendationis 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\boccurrence - No
\bnew Date\boccurrence (block constructor calls) - No
\bperformance\.now\boccurrence - No
\bMath\.random\boccurrence - No
\bMath\.abs\boccurrence (bigint-incompatible) - No
\bcrypto\.randomBytes\boccurrence - No
\bcrypto\.randomUUID\boccurrence
(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 isParameterChange | undefined— preferfor ... of windowedChangesto avoid theundefinednarrow.- The
would_reduce_invariantcallback signature must acceptAxiomIdexactly (no widenedstring). AXIOM_IDS as consttuple — deriveAxiomIdvia(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:
- (already)
audit(p4-2-3-drift-tracker): inventory surface—258f610e - (already)
contract(p4-2-3-drift-tracker): behavioral contract—c19e2d9e - (this)
packet(p4-2-3-drift-tracker): execution plan - (next)
feat(p4-2-3-drift-tracker): sliding-window drift + AX regression detector - (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.