Audit — P1.5.10 ζ Decision-Trail Integration

Round: R92, Wave 7 (parallel slice 2/2) Branch: feature/p1-5-10-zeta-integration Base SHA: 6cfd269b (post-P1.5.7 #258 merge) Slice authority: docs/guides/implementation/task-prompts/p1.5-delta-router-graduation.md §P1.5.10 (L1396–L1569) Parent override: “REAL ζ emission, NO STUBS — must be opt-in via options.zetaEmitter with a no-op default.”

§1. Goal

Wire every routeRequest call (success path + FallbackChainExhaustedError exhaustion path) to emit a routing-decision ζ record with the exact 8-field shape from docs/3-world/social/llm.md §Decision-trail recording. Emission MUST be opt-in via an injection seam so Phase 0 / existing Phase 1.5 callers that do not supply a zetaEmitter continue to compile and run with zero behavioural change.

§2. Current ζ surface (read-only)

Located at src/domains/trail/ — directory name is trail/ per R75 Wave H correction (the donor-era thought/ is gone).

§2.1. src/domains/trail/repository.ts

Exports (verified at 6cfd269b):

Symbol Type Notes
CreateThoughtRecordInput interface {type, task_id, agent_id, content, session_id?} — 5 fields, all but session_id required
ThoughtRecordWithSession type alias ThoughtRecord & { session_id: string \| null } (8-field hash subset + repository-level session pointer)
ListThoughtRecordsFilters interface {task_id?, limit?}
CreateOptions interface {idFn?, nowFn?} injection seams
ThoughtRecordToolInputSchema Zod Validates the MCP input shape
ThoughtRecordListToolInputSchema Zod Validates list input
createThoughtRecord(db, input, options?) function Append + hash-chain inside a transaction; reads last rowid for prev_hash
getThoughtRecord(db, id) function Single fetch by id
listThoughtRecords(db, filters?) function Ordered by rowid ASC
registerThoughtTools(ctx) function Registers thought_record + thought_record_list MCP tools (lazy getDb())

Hash subset. Per src/domains/trail/schema.ts:172-189, computeHash hashes EXACTLY 6 fields: {id, type, task_id, content, timestamp, prev_hash}. agent_id and session_id are excluded from the chain. This means the routing-decision record can include arbitrary author metadata without breaking chain integrity.

ZERO_HASH genesis. src/domains/trail/schema.ts:65'0'.repeat(64). Used as prev_hash for the first record on any task chain.

Type constraint. THOUGHT_TYPES = ['plan', 'analysis', 'decision', 'reflection'] as const (schema.ts:47-52). The routing-decision record specified in the concept doc has type: 'routing_decision' — which is NOT one of the 4 canonical types. The slice doc’s contract pins type: 'routing_decision' (acceptance criterion §1 + ready-to-paste prompt §FILES TO CREATE) but the Zod schema would reject it at insert time.

Resolution. The ζ repository’s chain validation type uses Zod’s enum, but the chain integrity primitives (computeHash) treat type as opaque content. The emitRoutingDecision helper stores type='decision' (one of the 4 canonical types — semantically the closest match) and packs the full routing_decision envelope (including the literal string "routing_decision" inside the JSON payload) into the content field as canonical-JSON. This preserves:

  • Slice doc shape: a caller JSON.parse(record.content) recovers the literal {type: "routing_decision", routing_mode, …} envelope.
  • ζ-layer invariant: only the 4 canonical THOUGHT_TYPES values reach the DB for the type column.
  • Hash determinism: content is participatory in the hash, so two arbiters with identical inputs still produce identical decision_hashes.

This is consistent with how P1.5.7 already characterises the contract in src/domains/router/tools.ts:49-51: “the audit-sink will be the integration point — no edits to router_* tools needed when the sink is swapped.”

§2.2. src/domains/trail/schema.ts

Exports canonicalize(value) (sorted-key JSON, schema.ts:147-149) and computeHash(record) (SHA-256 over canonical-JSON of 6-field subset, schema.ts:172-190). These primitives are pure and stable across the Phase 0 → Phase 1.5 boundary; the routing-decision helper can re-use canonicalize to build the decision_hash input.

§2.3. src/domains/trail/verifier.ts

Read-only chain re-hash + adjacency check. Out of scope for this slice (we only WRITE).

§3. Current router lifecycle (src/domains/router/fallback.ts)

routeRequest(prompt, options) flow:

  1. Resolve scoring callable (default scoreIntent; override options.scoringFn).
  2. Compute decision = scoring(prompt, options){winner, scores} (full 9-member score map).
  3. Sort scores by descending → chain: ReadonlyArray<ModelId>.
  4. Read COLIBRI_MODEL_TIMEOUT (default 30 s).
  5. For each modelId in chain:
    • Reset CB if cooldown elapsed.
    • Push CircuitOpenError to attempts and continue if open.
    • Resolve adapter; push NoAdapterError and continue if absent.
    • attemptStart = clock(); modelsAttempted.push(modelId).
    • Race adapter against timeoutMs. On success: recordSuccess, recordRouterCall(success), compute costUsd, return frozen RouteResult. On failure: recordFailure, recordRouterCall(fail), push to attempts, continue.
  6. If loop exits without returning, throw FallbackChainExhaustedError(attempts).

Two terminal points to instrument:

  • Success terminus: between recordRouterCall(success) and return Object.freeze({...}) — we have modelId (= winner), decision.scores, modelsAttempted.length - 1 (= fallback_attempts), chain (= candidates_considered), and options (for rule_version_hash + decision-hash inputs).
  • Failure terminus: between throw new FallbackChainExhaustedError(attempts) and the loop exit — we have attempts, chain, no winner.

Wrapping requirement. Slice acceptance §6: “Emission failure does NOT swallow the original result — the router still returns the RouteResult (log the ζ emission error via options.logger).” Both emission sites must sit inside try {} catch { logger(...) } blocks.

§4. Where ζ emission is inserted

§4.1. New file: src/domains/router/trail.ts

Public surface:

export interface RoutingDecisionRecord {
  readonly type: 'routing_decision';
  readonly routing_mode: 'single' | 'ensemble' | 'pipeline' | 'fail';
  readonly chosen_model_id: string;
  readonly candidates_considered: readonly string[];
  readonly scores: Readonly<Record<string, number>>;
  readonly fallback_attempts: number;
  readonly rule_version_hash: string;
  readonly decision_hash: string;
}

export interface ZetaEmitter {
  (record: RoutingDecisionRecord): void;
}

export interface DecisionHashInputs {
  readonly prompt: string;
  readonly context: Readonly<Record<string, unknown>>;
  readonly rule_version_hash: string;
  readonly candidates_considered: readonly string[];
}

export function computeDecisionHash(
  inputs: DecisionHashInputs,
  chosenModelId: string,
): string;

export function buildRoutingDecisionRecord(args: {
  routing_mode: RoutingDecisionRecord['routing_mode'];
  chosen_model_id: string;
  candidates_considered: readonly string[];
  scores: Readonly<Record<string, number>>;
  fallback_attempts: number;
  rule_version_hash: string;
  decision_hash: string;
}): RoutingDecisionRecord;

export const NO_OP_ZETA_EMITTER: ZetaEmitter;

export function createThoughtRecordEmitter(args: {
  db: Database.Database;
  task_id: string;
  agent_id: string;
}): ZetaEmitter;

Why the indirection. Parent-prompt override mandates an injection seam (options.zetaEmitter) with a no-op default to preserve existing Phase 0 / Phase 1.5 callers. The NO_OP_ZETA_EMITTER IS the default. createThoughtRecordEmitter IS the real implementation, factored so callers that DO want ζ persistence can opt in by wiring it. This satisfies the parent prompt’s “REAL ζ emission, NO STUBS” — the helper is a real implementation (it really calls createThoughtRecord against the live ζ surface), and shipping it as the explicit factory means we are not erecting a fake.

§4.2. Modified file: src/domains/router/fallback.ts

Edits:

  1. Add import: import { type ZetaEmitter, type RoutingDecisionRecord, buildRoutingDecisionRecord, computeDecisionHash, NO_OP_ZETA_EMITTER } from './trail.js';
  2. Extend RouteOptions with readonly zetaEmitter?: ZetaEmitter; (also re-export the type via index.ts — though index.ts is the only barrel we touch for this slice).
  3. In routeRequest:
    • Read const emitter = options.zetaEmitter ?? NO_OP_ZETA_EMITTER;
    • Capture const ruleVersionHash = options.ruleVersionHash ?? '<unset>'; — see §4.3 for sourcing.
    • Build decisionHashInputs once outside the loop.
    • After return Object.freeze({...}) body but BEFORE the actual return, wrap the emission in try { emitter(buildRoutingDecisionRecord(...)) } catch (err) { (options.logger ?? console.error)(...) }.
    • Before throw new FallbackChainExhaustedError(attempts), do the same with routing_mode: 'fail', chosen_model_id: '', fallback_attempts: attempts.length.

§4.3. rule_version_hash sourcing

The κ rule pack hash is computed by computeScoringRuleVersionHash() from scoring-weights.ts:292. The current return shape is 'sha256:<64hex>' — NOT the 'rv:sha256:...' shape the slice doc specifies.

Resolution (per parent-override “no stubs”): trail.ts exports a normaliseRuleVersionHash(rawHash) helper that prepends 'rv:' if the input begins with 'sha256:'. The helper preserves a 'rv:sha256:'-prefixed input untouched, so a future κ change that emits the prefix natively is forward-compat. routeRequest calls computeScoringRuleVersionHash() once at the top, runs it through the normaliser, and stores the result for both success + fail emission paths.

Edge case. computeScoringRuleVersionHash can throw KappaRulesUnavailableError. The router catches it once at the top and substitutes the literal 'rv:sha256:unavailable' placeholder so emission still produces a record. The original error is logged via options.logger.

§4.4. decision_hash canonical-JSON inputs

canonicalize({prompt, context, rule_version_hash, candidates_considered})

  • chosenModelId string concatenation → SHA-256 hex.

For the fail-mode emission, chosenModelId is the empty string (the schema notes that chosen_model_id for fail mode is empty in the slice doc’s acceptance criterion §2 — the literal decision_hash input is well-defined across both terminals).

context here is restricted to the subset of RouteOptions that affects scoring (the same projection passed to scoreIntent): task, operatorPreference, candidatesSnapshot (frozen-snapshot reference key — we use only the model_ids), weightsSnapshot. The wider RouteOptions fields (completionFn, fetchFn, etc.) are filtered out because they are non-serializable and would non-deterministically affect the hash.

§5. Test surface (new file)

src/__tests__/domains/router/zeta-emission.test.ts — the slice doc names this as trail.test.ts; the parent prompt’s “Forbiddens” lists the new test file as zeta-emission.test.ts. Use the parent-prompt name to comply.

Test plan:

§ Cases Surface
§1 NO_OP_ZETA_EMITTER does nothing Trivial regression guard
§2 computeDecisionHash is deterministic over canonical-JSON Two identical inputs → same hash; different chosen → different hash
§3 buildRoutingDecisionRecord returns a frozen 8-field shape Shape match against concept-doc fixture
§4 routeRequest calls emitter on success (routing_mode='single') Capture-emitter spy; assert chosen_model_id, scores, fallback_attempts, decision_hash shape
§5 routeRequest calls emitter on FallbackChainExhaustedError (routing_mode='fail') Capture spy; assert fallback_attempts = attempts.length, chosen_model_id = ''
§6 routeRequest without zetaEmitter is byte-identical to Phase 0 path No-op default preserved; spy NOT installed
§7 Emission failure does NOT throw out of routeRequest Spy throws; result still returned/error still thrown
§8 createThoughtRecordEmitter writes a real thought_record row In-memory DB; assert chain integrity (prev_hash threading)

src/__tests__/domains/router/fallback.test.ts — light addition (kept minimal to avoid sibling-race conflict surface). One new test verifying that the default behaviour (no zetaEmitter passed) returns identical RouteResult shape to the pre-P1.5.10 baseline.

§6. Backward-compat surface

The RouteOptions shape gets ONE additive field (zetaEmitter?). All Phase 0 and Phase 1.5 W1–W6 callers that destructure into {prompt, options} and pass opts without the field continue to compile (TypeScript structural typing tolerates absent optional fields). RouteResult is not modified.

The 4 MCP tool handlers in tools.ts are not modified by this slice. They will not opt into emission until a follow-up slice (or a P1.5.11+) wires the emitter via bootstrap(). P1.5.7’s docstring already pre-commits this stance (tools.ts:49-51).

§7. Forbiddens (per parent prompt)

  • No edit to src/domains/trail/* (the ζ repository is fixed contract).
  • No edit to src/domains/router/adapters/*.ts.
  • No edit to scoring.ts / circuit.ts / cost.ts.
  • No MCP tool registration.
  • No new env vars.

§8. Sibling guard

P1.5.8 parity tests live in src/__tests__/domains/router/parity.test.ts (a new file owned by the sibling). The slice does NOT modify fallback.ts’s type exports; we only add the optional zetaEmitter field to RouteOptions. The sibling’s tests should remain green because they exercise routeRequest without supplying the field.

The only shared-surface risk is fallback.test.ts — I touch it for one new “no-op default preserved” test. Per the parent prompt, the sibling “WILL NOT touch src/domains/router/fallback.ts” but may touch fallback.test.ts. I limit my edit to one new test added at the end of the file to minimize merge conflict surface; the sibling’s parity test file is disjoint.

§9. Test count baseline

Per CLAUDE.md §5 (worktree copy): 3102 tests across 69 suites at 3ba5b5ac (R89 Phase B seal). Parent prompt baseline at 6cfd269b (post-P1.5.7): 3353 tests. New file zeta-emission.test.ts adds ~12–15 cases; fallback.test.ts gains 1 case. Expected delta: +13 to +16.


Back to top

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

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