Execution Packet — P4.2.1 Circular Logic Detector
§1. Files to create
| Path | Purpose | LOC budget |
|---|---|---|
src/domains/integrity/detectors/circular.ts |
DFS detector + advisory emitter | ~320 |
src/domains/integrity/detectors/__tests__/circular.test.ts |
Test suite covering AC1–AC13 | ~640 |
No existing files are modified.
§2. circular.ts outline
Layout (with section comments mirroring P4.1.1 schema.ts style):
§1 Module header (file-doc JSDoc — pure module, no I/O, no clock, no RNG)
§2 Imports
- import type { Advisory } from '../schema.js';
- import { computeDecisionHash } from '../schema.js';
- import { ZERO_HASH, type ThoughtRecord } from '../../trail/schema.js';
§3 Constants
- RULE_NODE_PREFIX = 'rule:'
- ADVISORY_ROLE = 'Sentinel' as const
- ADVISORY_CHECK = 'circular_logic' as const
- ADVISORY_RESULT = 'WARN' as const
- ADVISORY_SEVERITY = 'HIGH' as const
§4 Types
- Cycle, DirectedGraph, BuildDagOptions,
DetectCircularLogicOptions
§5 buildDag(records, options?) — § 3.1
§6 findCycles(records, options?) — § 3.3 (iterative DFS)
§7 canonicalizeCyclePath(rawPath) — § 3.3 canonicalization
§8 detectCircularLogic(records, options) — § 3.4
§9 formatRecommendation(records, length) — internal helper
Key algorithmic details
- Iterative DFS with explicit stack to avoid Node’s ~10K recursion-limit overflow on adversarial inputs. The frame shape carries the node ID, its outgoing-edge list (as a mutable cursor), and the path-position marker for cycle extraction.
- Two-color visited:
Map<string, 'IN_PROGRESS' | 'DONE'>. A third “WHITE” (unvisited) state is the implicitundefined. - Sorted node iteration:
Array.from(graph.nodes).sort()before the outer loop. JSArray.prototype.sortwith no comparator is locale-independent code-unit order — matches κ canonical’s convention. - Sorted edge iteration: each node’s outgoing edges are sorted ascending before iteration. Ensures DFS visit order is fully deterministic.
- Cycle canonicalization:
- Raw cycle path from DFS is
[..., A]ending with the back-edge target (possibly equal to the start of the cycle). - For length-1 self-loop: raw path is e.g.
['A', 'A']→ canonicalized torecords: ['A'], length 1. - For length-N (N ≥ 2): raw path is e.g.
['A', 'B', 'C', 'A']→ drop trailing duplicate →['A', 'B', 'C']→ find lex-smallest entry → rotate so it’s first.
- Raw cycle path from DFS is
- Cycle de-duplication:
Map<string, Cycle>keyed byrecords.join(' '). Insertion-first wins; sort key is the same string. - Output sort: cycles array sorted by canonical key (the
records.join(' ')string) ascending.
Determinism corpus posture
P4.2.1 inherits the project’s no-wall-clock / no-RNG / NAMED-import convention. The static scanner in the test file (Group 6) enforces:
- No
Date.now,Date.UTC,Date.parse,new Date,performance.now,Math.random,JSON.stringifyincircular.tsbody. - No dotted
crypto.Xin body (nonode:cryptoimport at all in P4.2.1 — SHA-256 flows throughcomputeDecisionHash).
§3. Test plan (file outline)
src/domains/integrity/detectors/__tests__/circular.test.ts mirrors
the P4.1.1 test groups:
G1 Core graph shapes — AC1..AC6
G1.1 empty records []
G1.2 single record, no edges
G1.3 linear chain A→B→C → []
G1.4 diamond A→{B,C}→D → []
G1.5 self-loop A→A → 1 cycle, length 1
G1.6 triangle A→B→C→A → 1 cycle, length 3
G1.7 two disjoint cycles → 2 cycles
G2 Cross-rule edges — AC7
G2.1 pure rule cycle R1↔R2 → 1 advisory, length 2
G2.2 mixed record + rule cycles → 2 advisories
G2.3 rule node ID prefix is 'rule:'
G3 FP corpus stub — AC8
G3.1 100 random DAGs (LCG-seeded) → 0 cycles each
G3.2 same seed → same DAGs across runs
G4 Determinism — AC9
G4.1 100-run output stability on multi-cycle input
G4.2 shuffled record input → same advisory order
G4.3 same input + same lamportNow → byte-identical advisories
G4.4 same input + DIFFERENT lamportNow → identical decision_hash
G5 Advisory shape — AC10
G5.1 AdvisorySchema.safeParse(advisory).success === true
G5.2 decision_hash matches DECISION_HASH_REGEX
G5.3 check === 'circular_logic', role === 'Sentinel',
result === 'WARN', severity === 'HIGH'
G5.4 evidence shape: [{ kind: 'cycle_path', records, length }]
G5.5 recommendation format
G6 Static scanner — AC11..AC12
G6.1 forbidden-token scan on comment-stripped body
G6.2 NAMED-import scan: no dotted crypto.X
G6.3 no node:crypto import in body
G6.4 imports include ../schema.js and ../../trail/schema.js only
G7 Read-only / I2 — AC13
G7.1 records[] not mutated after run
G7.2 options.registryEdges not mutated after run
G7.3 returned advisories array is frozen
G7.4 returned cycles arrays inside evidence are frozen
Total expected test count: ~32 individual it(...) cases.
§4. Test fixtures
§4.1 — makeRecord(id, prevHash) factory
Light helper that returns a ThoughtRecord-shaped object with the
fields the detector reads (id, prev_hash) and dummies for the
rest. Type-cast through Pick<ThoughtRecord, 'id' | 'prev_hash'> to
match the detector’s parameter constraint.
§4.2 — LCG deterministic RNG for FP corpus
function lcg(seed: number): () => number {
let state = seed >>> 0;
return () => {
// Numerical Recipes LCG; deterministic, period 2^32, no Math.random.
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 0x1_0000_0000;
};
}
This is in the TEST file, NOT in circular.ts. Tests legitimately
need pseudo-randomness; the detector source remains RNG-free.
§4.3 — stripComments(src) helper
Direct port of P4.1.1’s pattern in schema.test.ts lines ~85+. Stays
in the same shape so style review is trivial.
§5. Reuse points (no duplication)
| Symbol | Source module | Reuse pattern |
|---|---|---|
computeDecisionHash |
../schema.js (P4.1.1) |
Called for each cycle’s decision_hash |
AdvisorySchema |
../schema.js (P4.1.1) |
Imported in test for .safeParse validation |
DECISION_HASH_REGEX |
../schema.js (P4.1.1) |
Imported in test for hash regex check |
Advisory type |
../schema.js (P4.1.1) |
Type-only import in detector |
ZERO_HASH |
../../trail/schema.js (P0.7.1) |
Used to filter genesis edges |
ThoughtRecord type |
../../trail/schema.js (P0.7.1) |
Type-only import for parameter shape |
§6. Build / lint / test commands
From inside the worktree (E:/AMS/.worktrees/claude/p4-2-1-circular-detector):
npm run build # tsc --build
npm run lint # eslint . --max-warnings 0
npm test # jest --runInBand
Base test count at 49560518: 3553 / 80 suites.
Expected after P4.2.1: ~3585 / 81 suites (+32 tests, +1 suite from the new file). The acceptable range is anything ≥ 3553 with no regressions on existing tests (other than the documented G7.1 flake — retry once).
§7. Risk inventory + mitigations
| Risk | Mitigation |
|---|---|
| Recursive DFS hits stack limit on cycle-of-thousands | Iterative DFS with explicit stack |
| Cycle output ordering non-deterministic from Map iteration | Output array sorted by canonical key before return |
Self-loop emits length-2 cycle (['A','A'] not ['A']) |
Explicit length-1 special case in canonicalization |
| Diamond emits false positive | DFS DONE coloring per integrity.md L40-51; covered by G1.4 |
Lint rule rejects pattern (e.g. Object.freeze chain) |
Match P4.1.1 style; eslint passed there; same patterns here |
ts-jest ESM moduleNameMapper issue with .js extensions |
Match P4.1.1: import paths use '../schema.js'; moduleNameMapper rewrites at test time |
JSON.stringify ban implies tests can’t use it |
Tests can — the static scanner only inspects circular.ts body |
§8. Commit plan
| Commit | Files | Title |
|---|---|---|
| 1 | docs/audits/p4-2-1-circular-detector-audit.md |
audit(p4-2-1-circular-detector): inventory surface |
| 2 | docs/contracts/p4-2-1-circular-detector-contract.md |
contract(p4-2-1-circular-detector): behavioral contract |
| 3 | docs/packets/p4-2-1-circular-detector-packet.md |
packet(p4-2-1-circular-detector): execution plan |
| 4 | src/domains/integrity/detectors/circular.ts, .../__tests__/circular.test.ts |
feat(p4-2-1-circular-detector): DFS cycle detector |
| 5 | docs/verification/p4-2-1-circular-detector-verification.md |
verify(p4-2-1-circular-detector): test evidence |
Steps 1–3 land before any source code (gate per CLAUDE.md §6). Step 4 implements; Step 5 records the gate output.
§9. Out-of-scope (re-asserted)
The same out-of-scope list from the contract — no scope creep beyond what the contract pins. The two specific drift points to flag in the PR body for Wave-3 P4.4.1 consumers:
- ζ
ThoughtRecordhas norefs[]field — multi-edge graphs need either a schema extension or a separate citation table. - κ
RuleRegistryhas nodependsOnAPI — cross-rule cycles in live registries need an AST-walking extractor not yet staged.
Both gaps are absorbed by the optional adapter parameters in P4.2.1; the gaps surface only when consumers want native ζ/κ graph construction without explicit injection.