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

  1. 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.
  2. Two-color visited: Map<string, 'IN_PROGRESS' | 'DONE'>. A third “WHITE” (unvisited) state is the implicit undefined.
  3. Sorted node iteration: Array.from(graph.nodes).sort() before the outer loop. JS Array.prototype.sort with no comparator is locale-independent code-unit order — matches κ canonical’s convention.
  4. Sorted edge iteration: each node’s outgoing edges are sorted ascending before iteration. Ensures DFS visit order is fully deterministic.
  5. 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 to records: ['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.
  6. Cycle de-duplication: Map<string, Cycle> keyed by records.join(' '). Insertion-first wins; sort key is the same string.
  7. 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.stringify in circular.ts body.
  • No dotted crypto.X in body (no node:crypto import at all in P4.2.1 — SHA-256 flows through computeDecisionHash).

§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:

  1. ζ ThoughtRecord has no refs[] field — multi-edge graphs need either a schema extension or a separate citation table.
  2. κ RuleRegistry has no dependsOn API — 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.


Back to top

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

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