Packet — P1.5.10 ζ Decision-Trail Integration

Round: R92, Wave 7 (parallel slice 2/2) Branch: feature/p1-5-10-zeta-integration Base SHA: 6cfd269b

§1. File-by-file edits

§1.1. NEW — src/domains/router/trail.ts (~210 lines)

A single new module exporting:

  • Types: RoutingDecisionRecord, RoutingMode, ZetaEmitter, DecisionHashInputs, ThoughtRecordEmitterArgs.
  • Functions: computeDecisionHash, buildRoutingDecisionRecord, normaliseRuleVersionHash, createThoughtRecordEmitter.
  • Constant: NO_OP_ZETA_EMITTER.

Imports canonicalize from ../trail/schema.js (re-using the ζ canonical-JSON primitive) and createThoughtRecord from ../trail/repository.js (re-using the ζ insert primitive). Imports Database type from better-sqlite3.

§1.2. EDIT — src/domains/router/fallback.ts (additive)

Three edits:

  1. Import the new symbols at the top: RoutingDecisionRecord, ZetaEmitter, DecisionHashInputs, NO_OP_ZETA_EMITTER, buildRoutingDecisionRecord, computeDecisionHash, normaliseRuleVersionHash from './trail.js'. Add import computeScoringRuleVersionHash + KappaRulesUnavailableError from './scoring.js' (already re-exported from scoring-weights.ts).

  2. Extend RouteOptions with the single optional field readonly zetaEmitter?: ZetaEmitter;. Add a JSDoc paragraph noting the no-op default and the parent-prompt rationale.

  3. routeRequest body modifications:
    • Right after const chain = orderedChain(decision.scores); add:
      const emitter: ZetaEmitter = options.zetaEmitter ?? NO_OP_ZETA_EMITTER;
      const ruleVersionHash = resolveRuleVersionHash(options.logger);
      const decisionHashInputs = projectDecisionHashInputs(prompt, options, ruleVersionHash, chain);
      
    • Just before return Object.freeze({...}) (success terminus), build and emit the success record:
      emitDecisionRecord(emitter, options.logger, {
        routing_mode: 'single',
        chosen_model_id: modelId,
        candidates_considered: chain,
        scores: decision.scores,
        fallback_attempts: Math.max(0, modelsAttempted.length - 1),
        rule_version_hash: ruleVersionHash,
        decisionHashInputs,
      });
      
    • Just before throw new FallbackChainExhaustedError(attempts) (fail terminus), emit the fail record:
      emitDecisionRecord(emitter, options.logger, {
        routing_mode: 'fail',
        chosen_model_id: '',
        candidates_considered: chain,
        scores: decision.scores,
        fallback_attempts: attempts.length,
        rule_version_hash: ruleVersionHash,
        decisionHashInputs,
      });
      throw new FallbackChainExhaustedError(attempts);
      
  4. Three new private helpers at file end:
    • resolveRuleVersionHash(logger?) — wraps computeScoringRuleVersionHash in try/catch, runs through normaliseRuleVersionHash, falls back to 'rv:sha256:unavailable' on KappaRulesUnavailableError.
    • projectDecisionHashInputs(prompt, options, ruleVersionHash, chain) — builds DecisionHashInputs with the documented projection (task, operatorPreference, candidatesSnapshot.model_id[], weightsSnapshot bigint-stringified) excluding non-deterministic / secret fields.
    • emitDecisionRecord(emitter, logger, args) — composes decision_hash via computeDecisionHash, builds the record via buildRoutingDecisionRecord, calls emitter(record) inside a try/catch that logs but does not propagate.

§1.3. NEW — src/__tests__/domains/router/zeta-emission.test.ts (~310 lines)

Test sections per audit §5 plan:

  1. NO_OP_ZETA_EMITTER smoke — invoke, expect undefined return + no throw.
  2. normaliseRuleVersionHash — pass 'sha256:abc''rv:sha256:abc'; pass 'rv:sha256:def' → unchanged.
  3. computeDecisionHash — determinism (two identical input → same hex); sensitivity (different chosen_model_id → different hex); shape (/^[0-9a-f]{64}$/).
  4. buildRoutingDecisionRecord — shape match against a canonical golden fixture; record is frozen.
  5. routeRequest success emission — install spy emitter; assert routing_mode='single', chosen_model_id='claude', fallback_attempts=0 (single-success path).
  6. routeRequest exhaustion emission — install spy; force every adapter to throw; expect routing_mode='fail', chosen_model_id='', fallback_attempts === attempts.length, then the FallbackChainExhaustedError propagates.
  7. routeRequest cascade emission — failure on first adapter, success on second; expect routing_mode='single', chosen_model_id= second, fallback_attempts === 1.
  8. routeRequest without zetaEmitter — back-compat regression guard; no spy installed; assert the result is byte-equivalent to a baseline RouteResult shape.
  9. Throwing emitter — spy raises; routeRequest still returns the RouteResult; logger called once.
  10. createThoughtRecordEmitter integration — bring up an in-memory sqlite DB with the ζ schema, write 3 records, assert chain integrity (prev_hash of record N = hash of record N-1).

§1.4. EDIT — src/__tests__/domains/router/fallback.test.ts (additive only)

One new describe block at end of file:

describe('routeRequest — P1.5.10 zeta emission back-compat', () => {
  test('omitting zetaEmitter is byte-equivalent to pre-P1.5.10 baseline', async () => {
    const { fn } = makeMockCompletion(SUCCESS_COMPLETION);
    const result = await routeRequest(FAKE_PROMPT, { completionFn: fn });
    expect(result.model).toBe('claude');
    expect(result.content).toBe('Hello!');
    expect(Object.isFrozen(result)).toBe(true);
  });
});

(This mirrors an existing fixture-using test; it asserts the no-op default preserves the result shape. Minimal added surface to avoid sibling-race conflict with P1.5.8.)

§2. Gate commands

cd /e/AMS/.worktrees/claude/p1-5-10-zeta-integration
npm run build
npm run lint
npm test -- --runInBand

All three must pass before commit-implementation.

§3. Expected test delta

Baseline (6cfd269b): 3353 tests. New cases in zeta-emission.test.ts: 10 sections × 1–2 tests each ≈ 13. New case in fallback.test.ts: 1. Expected final count: 3367 ± 2.

§4. Risk + mitigations

Risk Mitigation
RouteOptions shape change breaks downstream Field is optional + additive → TS structural typing tolerates absent field. Verified at audit §6.
computeScoringRuleVersionHash throws on every call Wrapped in try/catch with fallback literal.
Emitter throws and crashes router Wrapped in try/catch with logger. Tested in §1.3 case 9.
canonicalize non-deterministic for nested bigint bigint serialization is undefined for JSON.stringify; the projection in projectDecisionHashInputs stringifies bigints before reaching canonicalize.
Sibling parity test (P1.5.8) collides P1.5.8 lives in a disjoint test file (parity.test.ts); my edits to fallback.test.ts are one new describe block appended.
ζ schema rejects type: 'routing_decision' Audit §2 resolution: outer type='decision', envelope in content.

§5. Out-of-scope (carry-forward)

  • Wiring zetaEmitter into tools.ts MCP handlers (deferred to a future slice or to bootstrap() operator action).
  • Per-attempt ζ records (slice doc requires only one record per call).
  • routing_mode='ensemble' / 'pipeline' exercise paths.
  • THOUGHT_TYPES enum expansion (out-of-scope per parent forbidden).

§6. Commit plan

Step Commit message
1 audit(p1-5-10-zeta-integration): inventory ζ surface + router lifecycle (already landed)
2 contract(p1-5-10-zeta-integration): behavioral contract for ζ emission (already landed)
3 packet(p1-5-10-zeta-integration): execution plan (this commit)
4 feat(p1-5-10-zeta-integration): emit ζ thought_record per router call (real impl, no stubs)
5 verify(p1-5-10-zeta-integration): test evidence + ζ event matrix

Back to top

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

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