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_TYPESvalues reach the DB for thetypecolumn. - Hash determinism:
contentis 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:
- Resolve scoring callable (default
scoreIntent; overrideoptions.scoringFn). - Compute
decision = scoring(prompt, options)→{winner, scores}(full 9-member score map). - Sort scores by descending →
chain: ReadonlyArray<ModelId>. - Read
COLIBRI_MODEL_TIMEOUT(default 30 s). - For each
modelIdinchain:- Reset CB if cooldown elapsed.
- Push
CircuitOpenErrortoattemptsand continue if open. - Resolve adapter; push
NoAdapterErrorand continue if absent. attemptStart = clock();modelsAttempted.push(modelId).- Race adapter against
timeoutMs. On success:recordSuccess,recordRouterCall(success), computecostUsd, return frozenRouteResult. On failure:recordFailure,recordRouterCall(fail), push toattempts, continue.
- If loop exits without returning, throw
FallbackChainExhaustedError(attempts).
Two terminal points to instrument:
- Success terminus: between
recordRouterCall(success)andreturn Object.freeze({...})— we havemodelId(= winner),decision.scores,modelsAttempted.length - 1(=fallback_attempts),chain(=candidates_considered), andoptions(forrule_version_hash+ decision-hash inputs). - Failure terminus: between
throw new FallbackChainExhaustedError(attempts)and the loop exit — we haveattempts,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:
- Add import:
import { type ZetaEmitter, type RoutingDecisionRecord, buildRoutingDecisionRecord, computeDecisionHash, NO_OP_ZETA_EMITTER } from './trail.js'; - Extend
RouteOptionswithreadonly zetaEmitter?: ZetaEmitter;(also re-export the type viaindex.ts— thoughindex.tsis the only barrel we touch for this slice). - In
routeRequest:- Read
const emitter = options.zetaEmitter ?? NO_OP_ZETA_EMITTER; - Capture
const ruleVersionHash = options.ruleVersionHash ?? '<unset>';— see §4.3 for sourcing. - Build
decisionHashInputsonce outside the loop. - After
return Object.freeze({...})body but BEFORE the actualreturn, wrap the emission intry { emitter(buildRoutingDecisionRecord(...)) } catch (err) { (options.logger ?? console.error)(...) }. - Before
throw new FallbackChainExhaustedError(attempts), do the same withrouting_mode: 'fail',chosen_model_id: '',fallback_attempts: attempts.length.
- Read
§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.