Contract — P1.5.10 ζ Decision-Trail Integration
Round: R92, Wave 7 (parallel slice 2/2)
Branch: feature/p1-5-10-zeta-integration
Base SHA: 6cfd269b
Slice authority: docs/guides/implementation/task-prompts/p1.5-delta-router-graduation.md §P1.5.10
§1. Public surface (new module src/domains/router/trail.ts)
§1.1. Types
export interface RoutingDecisionRecord {
readonly type: 'routing_decision';
readonly routing_mode: RoutingMode;
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; // 'rv:sha256:<64hex>'
readonly decision_hash: string; // 64 hex chars (SHA-256)
}
export type RoutingMode = 'single' | 'ensemble' | 'pipeline' | 'fail';
export interface DecisionHashInputs {
readonly prompt: string;
readonly context: Readonly<Record<string, unknown>>;
readonly rule_version_hash: string;
readonly candidates_considered: readonly string[];
}
export interface ZetaEmitter {
(record: RoutingDecisionRecord): void;
}
export interface ThoughtRecordEmitterArgs {
readonly db: Database.Database;
readonly task_id: string;
readonly agent_id: string;
readonly session_id?: string;
}
§1.2. Functions
| Symbol | Signature | Behaviour |
|---|---|---|
computeDecisionHash |
(inputs, chosenModelId) → string |
SHA-256 hex over canonicalize(inputs) + ' ' + chosenModelId. Pure. |
buildRoutingDecisionRecord |
(args) → RoutingDecisionRecord |
Freezes and returns the 8-field shape. Pure. |
normaliseRuleVersionHash |
(raw) → string |
Prepends rv: to sha256:<hex> input; passes through if already rv:sha256:. Pure. |
NO_OP_ZETA_EMITTER |
ZetaEmitter |
A frozen () => undefined. No side effects. |
createThoughtRecordEmitter |
(args) → ZetaEmitter |
Returns an emitter that calls createThoughtRecord(args.db, {type: 'decision', task_id, agent_id, session_id?, content: canonicalize(record)}). Real persistence. |
§1.3. routing_decision envelope encoding inside thought_record.content
When the emitter persists to ζ, record.content is the canonical-JSON
serialization of the full 8-field RoutingDecisionRecord. The literal
string "routing_decision" is preserved inside the JSON payload via the
type field. The OUTER thought_record.type is fixed at 'decision' —
the closest canonical match in THOUGHT_TYPES (schema.ts:47-52).
Rationale: THOUGHT_TYPES is a closed enum at the schema layer. Adding
'routing_decision' would be a breaking change to the ζ surface that is
explicitly out-of-scope per parent forbidden (“Modifying ζ trail repository
(src/domains/trail/repository.ts) — that’s the LIVE ζ surface; do not
change its contract”). The envelope-in-content pattern (a) preserves the
slice doc’s nominal shape (JSON.parse(record.content).type ===
'routing_decision'), (b) keeps the ζ schema unchanged, (c) preserves the
chain-hash determinism property because content is one of the 6 hashed
fields.
§2. Modified surface (src/domains/router/fallback.ts)
§2.1. RouteOptions extension
Add ONE optional field:
export interface RouteOptions extends ScoreContext {
// ... existing 14 fields preserved byte-identically ...
/** P1.5.10: ζ Decision-Trail emitter. Defaults to NO_OP_ZETA_EMITTER. */
readonly zetaEmitter?: ZetaEmitter;
}
§2.2. routeRequest behavioural changes
The signature is byte-identical to the pre-P1.5.10 shape. The body emits TWO ζ records per call — one terminal:
| Terminus | routing_mode |
chosen_model_id |
fallback_attempts |
|---|---|---|---|
Success path (return RouteResult) |
'single' |
winner ModelId |
modelsAttempted.length - 1 |
Failure path (throw FallbackChainExhaustedError) |
'fail' |
'' |
attempts.length |
Emission ALWAYS happens. With zetaEmitter === undefined, the no-op
emitter is invoked (zero side effect). Phase 0 / Phase 1.5 W1–W6 callers
that never pass zetaEmitter see byte-identical behaviour.
§2.3. Emission failure semantics
The emitter call is wrapped:
try {
emitter(record);
} catch (err) {
const log = options.logger ?? console.error;
log(`[δ router] ζ emission failed: ${(err as Error).message}`);
}
A throwing emitter does NOT alter routeRequest’s return value or thrown
exception. This satisfies slice acceptance §6: “Emission failure does NOT
swallow the original result — the router still returns the RouteResult.”
§2.4. rule_version_hash resolution
At the top of routeRequest:
let ruleVersionHash: string;
try {
ruleVersionHash = normaliseRuleVersionHash(computeScoringRuleVersionHash());
} catch (err) {
const log = options.logger ?? console.error;
log(`[δ router] κ rule version unavailable: ${(err as Error).message}`);
ruleVersionHash = 'rv:sha256:unavailable';
}
This is computed exactly ONCE per routeRequest call (not once per
attempt), used identically in both terminal emissions, and survives
KappaRulesUnavailableError.
§2.5. decision_hash input projection
The context field of DecisionHashInputs is the same projection used
for scoring — a Record keyed by:
task(if present in options)operatorPreference(if present)candidatesSnapshot.model_id[](the array of model IDs only — frozen snapshot objects with bigint fields are not portable across canonical-JSON)weightsSnapshot.bps_map(if present — projected to a plain Record<string, string> with bigint stringified)
Other RouteOptions fields (completionFn, fetchFn, apiKey, …) are
explicitly EXCLUDED. They are non-deterministic, non-portable, or secret.
§3. Behavioural invariants
| ID | Invariant |
|---|---|
| I1 | routeRequest signature byte-identical to P1.5.9 baseline. |
| I2 | RouteResult shape byte-identical to P1.5.6 ({model, content, finishReason, promptTokens, completionTokens, latencyMs, costUsd, modelsAttempted} — no new fields). |
| I3 | Default emitter is no-op. Passing zero zetaEmitter produces zero ζ-side side effects. |
| I4 | Every success path emits exactly one record with routing_mode='single'. |
| I5 | Every exhaustion path emits exactly one record with routing_mode='fail' then throws. |
| I6 | decision_hash is deterministic over (prompt, context-projection, rule_version_hash, candidates_considered, chosen_model_id). |
| I7 | decision_hash is the lowercase hex SHA-256 (64 chars, matches /^[0-9a-f]{64}$/). |
| I8 | rule_version_hash matches /^rv:sha256:[0-9a-f]{64}$/ OR the literal 'rv:sha256:unavailable'. |
| I9 | Emission throws are caught and logged, never propagated. |
| I10 | RoutingDecisionRecord is frozen (Object.freeze). |
| I11 | createThoughtRecordEmitter writes a real thought_records row with type='decision' and content=canonicalize(record). |
| I12 | The slice does NOT modify src/domains/trail/*. |
| I13 | The slice does NOT modify any adapter. |
| I14 | The slice does NOT register new MCP tools. |
§4. Error taxonomy
| Error | Source | Handler |
|---|---|---|
KappaRulesUnavailableError |
computeScoringRuleVersionHash throws |
Caught in routeRequest; ruleVersionHash falls back to 'rv:sha256:unavailable'; logged via options.logger |
| Emitter throws | Caller’s zetaEmitter raises |
Caught in routeRequest; logged; original result/error unchanged |
createThoughtRecord throws |
DB insert fails | Bubbles out of createThoughtRecordEmitter → caught by routeRequest emission try/catch |
Unsupported RoutingMode |
N/A | Static type bounds; not reachable |
§5. Test contract (per slice acceptance criteria)
| AC | Surface tested |
|---|---|
| AC1 — exact 8-field shape | buildRoutingDecisionRecord shape check |
AC2 — deterministic decision_hash |
Two calls with identical inputs → same hash |
AC3 — routing_mode='fail' on exhaustion |
Spy emitter; force FallbackChainExhaustedError |
AC4 — routing_mode='single' on success |
Spy emitter; happy path |
AC5 — fallback_attempts count |
Multi-failure cascade test |
| AC6 — chain-thread on real ζ | createThoughtRecordEmitter against in-memory DB |
| AC7 — emission failure non-fatal | Throwing emitter does not propagate |
AC8 — npm run build && lint && test green |
CI gate |
§6. Out of scope (explicit deferrals)
- Wiring
zetaEmitterinto the 4 MCP tool handlers intools.ts. P1.5.7’s docstring (tools.ts:49-51) already pre-commits this stance and a follow-up slice (or operator action viabootstrap()) is the integration point. - Emitting per-attempt records (one record per failed model in the chain). The slice doc acceptance criteria require ONE record per call (success or fail terminus), so the slice emits ONE per call, not N.
- A
routing_mode='ensemble'or'pipeline'path. These remain spec-only per the δ concept doc; the union member is reserved in the type for future use but not exercised. - Updating the
THOUGHT_TYPESschema enum. - Audit-sink injection at the
tools.tshandler boundary (referenced intools.ts:49-51).