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

49560518feat(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 enums
  • Advisory TS type derived via z.infer<typeof AdvisorySchema>
  • DECISION_HASH_REGEX/^[a-f0-9]{64}$/
  • HASH_FIELD_SEPARATOR — the literal '||'
  • computeDecisionHash(role, check, input, result) — returns 64-char lowercase hex
  • serializeAdvisory(advisory) — returns canonical UTF-8 Buffer
  • AdvisorySerializationError — wraps κ CanonicalSerializationError via the ES2022 cause chain

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:

  • RuleRegistry class with .getAll() → readonly CategorizedRule[], .getRule(name) → RuleNode | null, .getByTransitionType(type) → readonly RuleNode[], .size, .computeVersionHash().
  • CategorizedRule = { rule: RuleNode; category }
  • RuleNode shape: { type, location, name, guards, effects }no dependsOn field.

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 at 49560518).
  • 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}→D is a DAG: when DFS descends A→B→D, marks D DONE, then descends A→C→D — D is already DONE, return without emitting a cycle.
  • Self-loop A→A: DFS appends A to path, iterates successors, encounters A already in path → emits length-1 cycle (per the path[path.index(node):] + [node] rule, the cycle is [A, A], which the detector reports as records: ['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 — ζ ThoughtRecord type (imported)
  • src/domains/trail/repository.tsThoughtRecordWithSession (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)

Back to top

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

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