Contract — P4.2.1 Circular Logic Detector
§1. Scope
P4.2.1 ships a pure, read-only DFS cycle detector over a graph derived
from ζ ThoughtRecords, optionally augmented with caller-supplied
rule-dependency edges. The detector emits a P4.1.1 Advisory
envelope per distinct cycle. No state is mutated; no I/O is
performed; no wall-clock is read; no randomness is consumed.
§2. Public surface (locked)
export type CycleNodeKind = 'record' | 'rule';
export interface Cycle {
readonly records: readonly string[]; // canonical-rotated path; length N
readonly length: number; // N (≥ 1)
}
export interface DirectedGraph {
readonly nodes: ReadonlySet<string>;
readonly edges: ReadonlyMap<string, readonly string[]>;
}
export interface BuildDagOptions {
readonly getOutgoingEdges?: (
record: Pick<ThoughtRecord, 'id' | 'prev_hash'>,
) => readonly string[];
readonly registryEdges?: ReadonlyMap<string, readonly string[]>;
}
export interface DetectCircularLogicOptions extends BuildDagOptions {
readonly lamportNow: bigint;
}
export function buildDag(
records: readonly Pick<ThoughtRecord, 'id' | 'prev_hash'>[],
options?: BuildDagOptions,
): DirectedGraph;
export function findCycles(
records: readonly Pick<ThoughtRecord, 'id' | 'prev_hash'>[],
options?: BuildDagOptions,
): readonly Cycle[];
export function detectCircularLogic(
records: readonly Pick<ThoughtRecord, 'id' | 'prev_hash'>[],
options: DetectCircularLogicOptions,
): readonly Advisory[];
The ThoughtRecord import is type-only — the detector accepts
structural subtypes (e.g. ThoughtRecordWithSession) without
modification.
§3. Behavior
§3.1 — buildDag
Builds a deterministic directed graph in three phases:
- Record nodes: every record contributes one node, ID
record.id, raw — no prefix. Records are added in input array order. - Record edges: for every record, the outgoing-edge list is:
options.getOutgoingEdges?.(record) ?? [record.prev_hash]- Filtered:
ZERO_HASHis dropped (genesis sentinel, not a real parent). Any edge target that does NOT match an existing node ID in the graph is dropped (a back-pointer to a record that is not inrecords[]is treated as a dangling reference, not a graph edge — the detector cannot decide what to do with it without hydration).
- Rule edges: if
options.registryEdgesis provided, for every(ruleName, dependsOnNames)entry:- Add node
rule:<ruleName>. - For each dep in
dependsOnNames, add noderule:<dep>, then add an edgerule:<ruleName> → rule:<dep>. - Rule nodes and record nodes share the same node-ID namespace; the
rule:prefix prevents collisions with raw record IDs. A record ID that begins with the literal substring'rule:'would collide with a rule node and is the caller’s responsibility to avoid (P4.2.1 documents the namespace; future hardening could reject such IDs at validation time).
- Add node
The graph is returned with Object.freeze-d structures; node iteration
is sorted lexicographically by ID.
§3.2 — Default getOutgoingEdges
The default adapter is (record) => [record.prev_hash], with
ZERO_HASH and unknown-hash filtering applied inside buildDag
(not the adapter). This means a custom adapter does NOT bypass the
zero-hash drop; the adapter only chooses the outgoing-edge set, and
post-processing applies uniformly.
§3.3 — findCycles
Implements the integrity.md L40-51 pseudocode as an iterative DFS (explicit stack, no recursion) with the IN_PROGRESS / DONE two-color scheme:
for each node in sorted(graph.nodes):
if color[node] == DONE: continue
stack.push({ node, edgesPending: edges_from(node) })
color[node] = IN_PROGRESS
path.push(node); pathSet.add(node)
while stack not empty:
frame = stack.top()
if frame.edgesPending empty:
stack.pop()
color[frame.node] = DONE
path.pop(); pathSet.delete(frame.node)
continue
next = frame.edgesPending.shift()
if pathSet.has(next):
cycle = path[path.indexOf(next):] + [next]
emit canonicalize(cycle)
elif color[next] != DONE:
color[next] = IN_PROGRESS
path.push(next); pathSet.add(next)
stack.push({ node: next, edgesPending: edges_from(next) })
Cycles are canonicalized post-detection:
- Length-1 self-loop (
A → A): the raw cycle path is[A, A]; canonicalized torecords: ['A'],length: 1. - Length-N (N ≥ 2): the raw cycle path is
[A, B, C, A](N+1 entries closing back to start). Canonicalized to drop the trailing duplicate, then rotated so the lex-smallest ID is first; finalrecordshas exactly N entries. - De-duplication: each unique cycle (by canonical key
records.join(' ')) is emitted exactly once. The same cycle discovered from multiple DFS starts collapses to one report.
Output ordering: cycles are sorted by canonical key
(records.join(' ')) ascending. This makes the output
deterministic across all node-iteration orders.
§3.4 — detectCircularLogic
Calls findCycles(records, options); for each cycle, emits one
Advisory:
{
role: 'Sentinel',
check: 'circular_logic',
result: 'WARN',
severity: 'HIGH',
evidence: [{ kind: 'cycle_path', records: cycle.records, length: cycle.length }],
recommendation: 'Cycle of length <N> detected in citation graph: <id1> → <id2> → ... → <id1>',
decision_hash: computeDecisionHash('Sentinel', 'circular_logic', { records, length }, 'WARN'),
timestamp_logical: options.lamportNow,
}
The advisory array is sorted in the same order as findCycles
returns. Two cycles in the same input → two advisories, deterministic
order.
The recommendation close uses → <id1> (the trailing repeat) to make
the cyclic nature visible in the prose. For length-1, the
recommendation is "Cycle of length 1 detected in citation graph: <id1> → <id1>".
§3.5 — Input validation
The detector trusts its input structurally. Specifically:
records[]may be empty → empty advisories.records[].idis required to be a non-empty string (Zod-enforced upstream at the trail layer); the detector does NOT re-validate.records[].prev_hashis required (always non-empty by the ζ schema); the detector treatsZERO_HASHspecially per §3.2.- Duplicate
ids across records: the detector treats them as a single node (Map collapses); this matches “graph identity is by node ID”. Callers wishing to detect duplicate-ID hazards should validate upstream. options.registryEdgeskeys and values may be any strings (after therule:prefix is applied). Empty values arrays are permitted.
The detector throws no errors on shape-valid input. The only
error path is AdvisorySerializationError propagated from
computeDecisionHash if a future caller injects un-representable
evidence — the current evidence shape is plain-JSON-clean so this
cannot happen with the in-process default.
§4. Invariants
| ID | Statement |
|---|---|
| I1 | Determinism: identical inputs (records[], options) → byte-identical advisory arrays. No wall-clock, no randomness, no platform-dependent ordering. |
| I2 | Read-only: the detector does not mutate records[], options.registryEdges, or any caller-supplied structure. Result types are frozen via Object.freeze. |
| I3 | Diamond is not a cycle: A→B→D + A→C→D produces zero advisories. The DONE coloring distinguishes forward/cross edges from back-edges. |
| I4 | Self-loops are cycles: A → A produces exactly one advisory with evidence.records = ['A'], length 1. |
| I5 | De-duplication: a length-N cycle is emitted exactly once regardless of how many DFS starts re-discover it. |
| I6 | Canonical start: each cycle’s records array begins with the lex-smallest ID. |
| I7 | Cross-rule cycles work: a rule-dep map R1 → R2, R2 → R1 produces one advisory with evidence.records = ['rule:R1', 'rule:R2'] (or rotated to lex-smallest). |
| I8 | No wall-clock: circular.ts body contains no Date.now, Date.UTC, Date.parse, new Date, performance.now, Math.random. |
| I9 | No dotted-crypto: circular.ts body contains no \bcrypto\.[a-zA-Z]\b token (matching P4.1.1 NAMED-import convention). |
| I10 | P4.1.1 reuse: circular.ts imports computeDecisionHash, AdvisoryRoleSchema / AdvisoryCheckSchema (or just uses them as constants) from ../schema.js. It does NOT re-import node:crypto directly; SHA-256 hashing flows through computeDecisionHash. |
| I11 | FP profile: on 100 randomly-generated DAGs (deterministically seeded — see §6.3), findCycles returns []. False-positive rate is 0% on the corpus stub. |
§5. Public errors
None. The detector is total over shape-valid input. Future hardening
slices may add explicit InvalidGraphError for cases the detector
currently silently absorbs (duplicate IDs, empty input). Out of scope
for P4.2.1.
§6. Test plan
§6.1 — Group 1: Core graph shapes (AC1–AC6)
- Empty graph (zero records) → 0 advisories.
- Single record (no edges) → 0 advisories.
- Linear chain A→B→C → 0 advisories.
- Diamond A→{B,C}→D → 0 advisories (regression-guard for forward-edge classification).
- Self-loop A→A → 1 advisory, length 1,
evidence.records = ['A']. - Triangle A→B→C→A → 1 advisory, length 3, records canonical rotation.
- Two disjoint cycles (A↔B + C→D→E→C) → 2 advisories, deterministic order.
§6.2 — Group 2: Cross-rule edges (AC7)
- Pure record graph: empty graph +
registryEdges = { R1: [R2], R2: [R1] }→ 1 advisory, length 2,evidence.records = ['rule:R1', 'rule:R2']. - Mixed graph: record edge A→A + rule edge R1→R1 → 2 advisories.
§6.3 — Group 3: FP corpus stub (AC8)
A 100-element corpus of pseudorandom DAGs generated by a
deterministic LCG (seeded constant). Each DAG has 5–20 nodes and is
constructed as for i in 1..N: edges_from[i] = [j for j in some
sample of 0..i-1] — i.e. forward edges only, by construction acyclic.
findCycles returns [] on every DAG. Full corpus measurement lives
in P4.7.1.
§6.4 — Group 4: Determinism (AC9)
- Run
findCycleson a fixed multi-cycle input 100 times → output array (canonical-key string) is identical across all 100 runs. - Same input × shuffled input order → same advisory order (the detector sorts).
§6.5 — Group 5: Advisory shape (AC10)
- Validate the emitted advisories pass
AdvisorySchema.safeParse. - Validate
decision_hashmatchesDECISION_HASH_REGEX. - Validate identical cycles in different
lamportNowcalls produce identicaldecision_hash(timestamp is NOT part of the preimage). - Validate
recommendationtext format matches the contract.
§6.6 — Group 6: Static scanner (AC11–AC12)
Mirrors the P4.1.1 Group 9 / Group 10 style:
- Forbidden-token scan on
circular.ts(comment-stripped body): noDate.now,Date.UTC,Date.parse,new Date,performance.now,Math.random,JSON.stringify. - NAMED-import scanner: no dotted
crypto.Xtoken in body. - Imports section contains the relative
'../schema.js'(P4.1.1 reuse) and nothing from'node:crypto'.
§6.7 — Group 7: Reuse + I2 read-only
- After running
findCycles(records, options), snapshotJSON.stringify(records)andJSON.stringify(Array.from(options.registryEdges ?? []))are unchanged. findCyclesresult arrays are frozen.
§7. Acceptance criteria
| # | Criterion |
|---|---|
| AC1 | Empty graph → 0 advisories |
| AC2 | Linear chain → 0 advisories |
| AC3 | Diamond DAG → 0 advisories |
| AC4 | Self-loop → 1 advisory, length 1 |
| AC5 | Triangle → 1 advisory, length 3 |
| AC6 | Two disjoint cycles → 2 advisories |
| AC7 | Cross-rule cycle via registryEdges → 1 advisory |
| AC8 | FP corpus stub: 100 random DAGs → 0 cycles each |
| AC9 | 100-run determinism: identical canonical-key output array |
| AC10 | Advisory shape passes AdvisorySchema.safeParse; matches contract |
| AC11 | Static scanner — no wall-clock / RNG tokens |
| AC12 | NAMED-import scanner — no dotted crypto.X |
| AC13 | Build + lint + jest all green |
§8. Out-of-scope
- Persistence into
mcp_advisories(P4.5.1). - MCP tool surface (
integrity_*family) (P4.6.1). - Escalation FSM consuming advisories (P4.4.1).
- κ AST-walking rule-dep extractor — P4.2.1 takes the dep map as a parameter.
- Multi-edge
refs[]onThoughtRecord— adapter ready, but the upstream schema extension is not part of this slice.
§9. Backward-compatibility
P4.2.1 introduces a new module under src/domains/integrity/detectors/;
no existing module is modified. There is no public-API churn.