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:
-
Import the new symbols at the top:
RoutingDecisionRecord,ZetaEmitter,DecisionHashInputs,NO_OP_ZETA_EMITTER,buildRoutingDecisionRecord,computeDecisionHash,normaliseRuleVersionHashfrom'./trail.js'. Add importcomputeScoringRuleVersionHash+KappaRulesUnavailableErrorfrom'./scoring.js'(already re-exported fromscoring-weights.ts). -
Extend
RouteOptionswith the single optional fieldreadonly zetaEmitter?: ZetaEmitter;. Add a JSDoc paragraph noting the no-op default and the parent-prompt rationale. routeRequestbody 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);
- Right after
- Three new private helpers at file end:
resolveRuleVersionHash(logger?)— wrapscomputeScoringRuleVersionHashintry/catch, runs throughnormaliseRuleVersionHash, falls back to'rv:sha256:unavailable'onKappaRulesUnavailableError.projectDecisionHashInputs(prompt, options, ruleVersionHash, chain)— buildsDecisionHashInputswith the documented projection (task,operatorPreference,candidatesSnapshot.model_id[],weightsSnapshotbigint-stringified) excluding non-deterministic / secret fields.emitDecisionRecord(emitter, logger, args)— composesdecision_hashviacomputeDecisionHash, builds the record viabuildRoutingDecisionRecord, callsemitter(record)inside atry/catchthat logs but does not propagate.
§1.3. NEW — src/__tests__/domains/router/zeta-emission.test.ts (~310 lines)
Test sections per audit §5 plan:
NO_OP_ZETA_EMITTERsmoke — invoke, expect undefined return + no throw.normaliseRuleVersionHash— pass'sha256:abc'→'rv:sha256:abc'; pass'rv:sha256:def'→ unchanged.computeDecisionHash— determinism (two identical input → same hex); sensitivity (differentchosen_model_id→ different hex); shape (/^[0-9a-f]{64}$/).buildRoutingDecisionRecord— shape match against a canonical golden fixture; record is frozen.routeRequestsuccess emission — install spy emitter; assertrouting_mode='single',chosen_model_id='claude',fallback_attempts=0(single-success path).routeRequestexhaustion emission — install spy; force every adapter to throw; expectrouting_mode='fail',chosen_model_id='',fallback_attempts === attempts.length, then theFallbackChainExhaustedErrorpropagates.routeRequestcascade emission — failure on first adapter, success on second; expectrouting_mode='single',chosen_model_id=second,fallback_attempts === 1.routeRequestwithoutzetaEmitter— back-compat regression guard; no spy installed; assert the result is byte-equivalent to a baselineRouteResultshape.- Throwing emitter — spy raises;
routeRequeststill returns theRouteResult; logger called once. createThoughtRecordEmitterintegration — bring up an in-memory sqlite DB with the ζ schema, write 3 records, assert chain integrity (prev_hashof record N =hashof 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
zetaEmitterintotools.tsMCP handlers (deferred to a future slice or tobootstrap()operator action). - Per-attempt ζ records (slice doc requires only one record per call).
routing_mode='ensemble'/'pipeline'exercise paths.THOUGHT_TYPESenum 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 |