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 zetaEmitter into the 4 MCP tool handlers in tools.ts. P1.5.7’s docstring (tools.ts:49-51) already pre-commits this stance and a follow-up slice (or operator action via bootstrap()) 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_TYPES schema enum.
  • Audit-sink injection at the tools.ts handler boundary (referenced in tools.ts:49-51).

Back to top

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

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