Audit — P4.2.1 Circular Logic Detector (Wave 2, μ Phase 4)
Purpose
Inventory the surface that P4.2.1 builds on (P4.1.1 envelope, ζ
ThoughtRecord shape, κ RuleRegistry API, the live integrity.md DFS
pseudocode) and identify exactly what does NOT exist in the tree at
base SHA 49560518, so the contract step can pin the DFS detector’s
edge-construction sources precisely.
The dispatch packet from PM (R94 Wave 2) specifies a DFS over
thought_records.cites, extended with “κ rule-registry edges for
cross-rule cycles”. This audit confirms that the κ registry does
NOT expose a dependsOn edge API natively — the detector must
accept an injected rule-dep adapter, mirroring the κ-canonical reuse
pattern from P4.1.1 (the integrity domain does not duplicate
upstream-domain primitives).
Base SHA
49560518 — feat(p4-1-1-advisory-envelope): μ Phase 4 Wave 1 — 8-field
advisory envelope (P4.1.1) (#271). Branched from origin/main.
What exists upstream (consumed via REUSE, never duplicated)
P4.1.1 advisory envelope — src/domains/integrity/schema.ts
Public surface confirmed live at 49560518:
AdvisorySchema— Zod 8-field envelope (role, check, result, severity, evidence, recommendation, decision_hash, timestamp_logical)AdvisoryRoleSchema/AdvisoryCheckSchema/AdvisoryResultSchema/AdvisorySeveritySchema— closed enumsAdvisoryTS type derived viaz.infer<typeof AdvisorySchema>DECISION_HASH_REGEX—/^[a-f0-9]{64}$/HASH_FIELD_SEPARATOR— the literal'||'computeDecisionHash(role, check, input, result)— returns 64-char lowercase hexserializeAdvisory(advisory)— returns canonical UTF-8BufferAdvisorySerializationError— wraps κCanonicalSerializationErrorvia the ES2022causechain
P4.2.1 imports these via the ../schema.js relative path; it does
not re-implement any of the four enum schemas, the dedup hash
function, or the serializer.
ζ P0.7.1 / P0.7.2 thought-record substrate — src/domains/trail/
schema.ts exports the canonical 8-field ThoughtRecord:
export type ThoughtRecord = z.infer<typeof ThoughtRecordSchema>;
// shape: { id, type, task_id, agent_id, content, timestamp, prev_hash, hash }
Critical finding — there is NO refs[] field on ThoughtRecord.
The integrity.md DFS pseudocode (L30) cites
edge_source = "parent_hash" OR refs[]. The Phase 0 substrate ships
prev_hash ONLY; the refs[] field is a spec-level placeholder for a
future multi-edge extension (likely a follow-up to ζ extending the row
shape, or a stand-off table of citation rows). Wave-3 escalation FSM
(P4.4.1) consumers MUST be aware that today every thought record has
at most one outgoing edge in the ζ trail graph — the prev_hash
back-pointer. Multi-citation graphs require additional infrastructure
that does not yet exist.
The detector contract handles this by accepting a
getOutgoingEdges?: (record) => readonly string[] optional adapter,
defaulting to [record.prev_hash] (with ZERO_HASH and unknown-hash
entries filtered out — see contract §3.2). When a future schema
extension materializes citation edges, callers inject the adapter; no
detector change is required.
repository.ts exposes ThoughtRecordWithSession = ThoughtRecord & {
session_id: string | null }. The detector accepts both shapes — it
only reads the canonical 8 fields.
κ P1.2.4 RuleRegistry — src/domains/rules/registry.ts
Public surface at 49560518:
RuleRegistryclass with.getAll() → readonly CategorizedRule[],.getRule(name) → RuleNode | null,.getByTransitionType(type) → readonly RuleNode[],.size,.computeVersionHash().CategorizedRule = { rule: RuleNode; category }RuleNodeshape:{ type, location, name, guards, effects }— nodependsOnfield.
Critical finding — the κ registry does NOT expose a rule-dependency
edge API. The integrity.md pseudocode L54 mentions “rule-dependency
edges added” without specifying their source. Rule dependencies in κ
are implicit — a rule’s guards and effects reference other rules
indirectly via $dot.path VarRefs and FuncCalls in the AST. A
proper static-dependency extractor is non-trivial (it has to walk every
expression and decide which VarRefs denote other rules vs. context
state vs. external symbols) and is out of scope for P4.2.1 — it
belongs in a Phase 4 follow-up if μ ever needs to ground rule-dep
cycles in the κ AST.
The detector contract handles this by accepting a
registryEdges?: ReadonlyMap<string, readonly string[]> optional
adapter — a name-to-name dependency map supplied by the caller. The
detector treats rule names as graph nodes (rule:<name> prefix to
disambiguate from record:<id> nodes) and inserts the supplied edges
verbatim. The κ RuleRegistry reference appears only as a type-level
import so a future cross-rule cycle detector can build the map from a
live registry, but P4.2.1 ships with the map-as-parameter contract.
Wave-3 P4.4.1 consumers should expect a separate
κ-rule-dep-extractor slice (not yet staged) when actual cross-rule
cycles need to be ground in the live registry.
θ P3.1.1 Lamport-clock pattern — src/domains/consensus/messages.ts
nextLogical(): bigint — caller-supplied clock minting. P4.1.1
inherits the pattern; P4.2.1 follows: the detector accepts an injected
lamportNow: bigint per emit, NOT Date.now(). Determinism
guarantee: identical inputs produce byte-identical advisories given
identical lamportNow.
κ P1.5.4 canonical / P4.1.1 hash chain
Already inherited via computeDecisionHash. P4.2.1 does NOT call
canonicalize or createHash directly — only computeDecisionHash
from the P4.1.1 surface.
What does NOT exist (P4.2.1 will create)
src/domains/integrity/detectors/directory (zero files at49560518).src/domains/integrity/detectors/circular.ts— DFS detector.src/domains/integrity/detectors/__tests__/circular.test.ts— cycle/non-cycle fixtures + cross-rule edges + determinism + static scanner.
Phase 4 reference: integrity.md §1 Circular logic (L22-55)
Pseudocode (L28-52):
fn find_cycles(records):
graph = build_dag(records, edge_source = "parent_hash" OR refs[])
cycles = []
for start in graph.nodes:
visited = {}
path = []
dfs(start, graph, visited, path, cycles)
return cycles
fn dfs(node, graph, visited, path, cycles):
if node in path:
cycles.append(path[path.index(node):] + [node])
return
if visited.get(node) == DONE:
return
visited[node] = IN_PROGRESS
path.append(node)
for successor in graph.edges_from(node):
dfs(successor, graph, visited, path, cycles)
path.pop()
visited[node] = DONE
Pseudocode notes:
- IN_PROGRESS coloring is set BEFORE iterating successors; DONE is set
AFTER
path.pop(). This distinguishes a true back-edge (cycle) from a forward/cross edge (DAG, no cycle). - Diamond
A→{B,C}→Dis a DAG: when DFS descendsA→B→D, marks D DONE, then descendsA→C→D— D is already DONE, return without emitting a cycle. - Self-loop
A→A: DFS appendsAto path, iterates successors, encountersAalready in path → emits length-1 cycle (per thepath[path.index(node):] + [node]rule, the cycle is[A, A], which the detector reports asrecords: ['A']length 1 after canonicalization).
Threshold (L54): ANY cycle → emit advisory with severity=HIGH.
Acceptance-criterion inventory (from PM dispatch packet)
| AC | Source | Test name |
|---|---|---|
| AC1 | Empty graph → no cycles | empty graph returns [] |
| AC2 | Linear chain A→B→C → [] | linear chain returns [] |
| AC3 | Diamond A→{B,C}→D → [] | diamond DAG returns [] |
| AC4 | Self-loop A→A → 1 length-1 cycle | self-loop returns 1 cycle |
| AC5 | Triangle A→B→C→A → 1 length-3 cycle | triangle returns 1 length-3 cycle |
| AC6 | Two disjoint cycles → 2 cycles | two disjoint cycles returns 2 cycles |
| AC7 | Cross-rule cycle via registry edges | cross-rule cycle returns 1 cycle via registryEdges |
| AC8 | FP corpus stub: 100 random DAGs → 0 cycles | FP corpus — 100 random DAGs return 0 cycles |
| AC9 | Determinism: same input → same order ×100 | same input produces same cycle order across 100 runs |
| AC10 | Advisory shape: P4.1.1 envelope; check circular_logic, severity HIGH, result WARN | advisory matches P4.1.1 envelope |
| AC11 | No Date.now / Math.random / performance.now / new Date in circular.ts | static scanner — no forbidden tokens |
| AC12 | NAMED import scanner — no dotted crypto.X |
NAMED-import scanner — circular.ts has no \crypto.X` token` |
Decisions taken (pinned to contract)
| Decision | Resolution | Source |
|---|---|---|
| Edge source default | [record.prev_hash] filtered for ZERO_HASH and unknown hashes |
spec gap — P4.2.1 contract §3.2 |
Multi-edge / refs[] support |
Optional getOutgoingEdges adapter |
ζ spec gap; integrity.md L30 |
| κ rule-dep edges | Optional registryEdges map injected by caller |
κ has no native API; integrity.md L54 |
| Cycle path canonical start | Rotate to lex-smallest ID at start; report each unique cycle once | spec gap — gotchas note in P4.2.1 staging file |
evidence shape |
[{ kind: 'cycle_path', records: [...], length: N }] — single element per cycle |
integrity.md L139 (<references>); shape matches P4.1.1 examples |
recommendation text |
"Cycle of length N detected in citation graph: <id1> → <id2> → ... → <id1>" |
integrity.md L140 free-form |
Advisory role |
"Sentinel" — flagging behaviour, per integrity.md L124 |
role-by-purpose mapping |
Advisory result |
"WARN" per integrity.md L54 + L153 |
spec |
Advisory severity |
"HIGH" per integrity.md L54 |
spec |
| Lamport clock | Caller-injected lamportNow: bigint parameter |
θ P3.1.1 pattern + P4.1.1 inheritance |
Out-of-scope for P4.2.1
- The κ rule-dep extractor that walks AST
VarRefs — a Phase 4 follow-up if μ needs to ground rule cycles in live registry state. - Persistence into
mcp_advisories— P4.5.1 owns this. - Surfacing the advisory through MCP tool calls — P4.6.1 owns this.
- The escalation FSM that turns WARN advisories into operator-visible alerts — P4.4.1 (Wave 3) owns this.
Risk inventory
| Risk | Mitigation |
|---|---|
Spec/code drift on refs[] field (integrity.md mentions it; ζ schema lacks it) |
Detector accepts adapter; contract documents the gap; PR body flags the gap to Wave-3 P4.4.1 |
| Stack overflow on huge cycle (recursive DFS) | Iterative DFS using explicit stack — same coloring semantics, no recursion limit |
| Quadratic cycle enumeration on dense graphs | P4.2.1 returns ALL cycles; bounded by graph size. For Phase 4’s expected workload (per-task chains of tens of records, not thousands), the cost is acceptable. Future optimization (Johnson’s algorithm) deferred. |
| Non-determinism in JS Map iteration order | The detector sorts node IDs lexicographically before iteration. ECMA-262 already guarantees Map insertion-order iteration; the explicit sort makes the determinism contractually load-bearing. |
| Spec ambiguity on cycle path normalization | Contract pins: rotate to lex-smallest ID at start; report once per unique cycle. |
Source inventory
src/domains/integrity/schema.ts— P4.1.1 envelope (imported)src/domains/trail/schema.ts— ζThoughtRecordtype (imported)src/domains/trail/repository.ts—ThoughtRecordWithSession(imported for structural-subtype permissiveness)src/domains/rules/registry.ts— κRuleRegistry(type-only reference in JSDoc; not imported)src/__tests__/domains/integrity/schema.test.ts— pattern for static-scanner test blocks (NOT imported; referenced as style guide)