@cr1ms0n/pi-subagent 0.8.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ import { isThinkingLevel } from "./thinking.js";
2
+ import {
3
+ DEFAULT_API_KEY_ENV,
4
+ DEFAULT_ROUTING_TIMEOUT_MS,
5
+ DEFAULT_SELECTOR_MODEL,
6
+ MAX_ROUTING_MODEL_ID_LENGTH,
7
+ MAX_ROUTING_MODELS,
8
+ MAX_ROUTING_SELECTOR_MODEL_LENGTH,
9
+ ROUTING_TIMEOUT_MAX_MS,
10
+ ROUTING_TIMEOUT_MIN_MS,
11
+ type JevRoutingConfig,
12
+ type JevRoutingModelEntry,
13
+ type RoutingModelCandidate,
14
+ type RoutingToolCandidate,
15
+ } from "./routing-types.js";
16
+
17
+ // Re-exported so integration can import routing types from their owning routing module.
18
+ export type {
19
+ JevRoutingConfig,
20
+ JevRoutingModelEntry,
21
+ RoutingModelCandidate,
22
+ RoutingToolCandidate,
23
+ } from "./routing-types.js";
24
+
25
+ /**
26
+ * Strict, pure parser/formatter for the mandatory `jevRouting` subtree.
27
+ *
28
+ * This module owns only the user-controlled routing configuration:
29
+ *
30
+ * - `parseJevRouting(raw, source?)` parses the `jevRouting` **subtree** (not the whole
31
+ * `~/.pi/subagent.json` file) and returns an immutable snapshot. It throws a plain
32
+ * `Error` with an actionable message on any unknown field, duplicate/blank model ID,
33
+ * blank description, invalid environment-variable name, non-integer/out-of-range timeout
34
+ * or unsupported candidate count. Callers (`src/config.ts`) catch that message and expose
35
+ * it as a config error; no credential or provider catalog is read.
36
+ * - `formatJevRoutingPrompt` renders model-facing guidance and works with **no** config, no
37
+ * credential and no inference, so management actions stay independent of routing setup.
38
+ * - The candidate helpers are pure; they never contact Pi or TypeSafe.
39
+ *
40
+ * The credential *value* must never be stored in config: only the environment-variable name
41
+ * is parsed and returned. The parser rejects unknown fields, so an accidental `apiKey` or
42
+ * `apiKeyValue` field is an error rather than silently ignored.
43
+ */
44
+
45
+ /** Default config-file label used in prose (owned by `config.ts` for file reads). */
46
+ export const JEV_ROUTING_CONFIG_FILE = "~/.pi/subagent.json";
47
+
48
+ const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
49
+ /** Exact provider/model ID: at least one slash, further ID slashes allowed; no whitespace, control chars or globs. */
50
+ const MODEL_ID = /^[^\s\u0000-\u001f\u007f/*?]+(?:\/[^\s\u0000-\u001f\u007f/*?]+)+$/u;
51
+ const MAX_GUIDANCE_MODEL_LINES = 50;
52
+
53
+ function invalid(source: string, message: string): never {
54
+ throw new Error(`Invalid jevRouting in ${source}: ${message}`);
55
+ }
56
+
57
+ function requireObject(value: unknown, source: string, pathName: string): Record<string, unknown> {
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
59
+ invalid(source, `${pathName} must be an object`);
60
+ }
61
+ return value as Record<string, unknown>;
62
+ }
63
+
64
+ function rejectUnknownKeys(record: Record<string, unknown>, allowed: readonly string[], source: string, pathName: string): void {
65
+ const unknown = Object.keys(record).filter((key) => !allowed.includes(key));
66
+ if (unknown.length) invalid(source, `${pathName} has unknown field(s): ${unknown.join(", ")}`);
67
+ }
68
+
69
+ function parseSelectorModel(value: unknown, source: string): string {
70
+ if (value === undefined) return DEFAULT_SELECTOR_MODEL;
71
+ if (typeof value !== "string") invalid(source, "selectorModel must be a string");
72
+ const model = value.trim();
73
+ if (!model || model.length > MAX_ROUTING_SELECTOR_MODEL_LENGTH || /[\s\u0000-\u001f\u007f]/u.test(model)) {
74
+ invalid(source, `selectorModel must be a non-empty selector alias or version without whitespace or control characters`);
75
+ }
76
+ return model;
77
+ }
78
+
79
+ function parseApiKeyEnv(value: unknown, source: string): string {
80
+ if (value === undefined) return DEFAULT_API_KEY_ENV;
81
+ if (typeof value !== "string" || !ENV_VAR_NAME.test(value.trim())) {
82
+ invalid(source, "apiKeyEnv must be an environment-variable name such as TYPESAFE_API_KEY (the credential value must not be stored in config)");
83
+ }
84
+ return value.trim();
85
+ }
86
+
87
+ function parseTimeoutMs(value: unknown, source: string): number {
88
+ if (value === undefined) return DEFAULT_ROUTING_TIMEOUT_MS;
89
+ if (typeof value !== "number" || !Number.isInteger(value) || value < ROUTING_TIMEOUT_MIN_MS || value > ROUTING_TIMEOUT_MAX_MS) {
90
+ invalid(source, `timeoutMs must be an integer between ${ROUTING_TIMEOUT_MIN_MS} and ${ROUTING_TIMEOUT_MAX_MS}`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function parseModelEntry(value: unknown, pathName: string, source: string): JevRoutingModelEntry {
96
+ const record = requireObject(value, source, pathName);
97
+ rejectUnknownKeys(record, ["model", "description", "thinking"], source, pathName);
98
+
99
+ const rawModel = record.model;
100
+ const model = typeof rawModel === "string" ? rawModel.trim() : "";
101
+ if (!model || model.length > MAX_ROUTING_MODEL_ID_LENGTH || !MODEL_ID.test(model)) {
102
+ invalid(source, `${pathName}.model must be an exact provider/model-id without whitespace, control characters or glob patterns`);
103
+ }
104
+
105
+ const rawDescription = record.description;
106
+ if (typeof rawDescription !== "string" || !rawDescription.trim()) {
107
+ invalid(source, `${pathName}.description must be a non-blank user-written characteristics description`);
108
+ }
109
+
110
+ const thinking = record.thinking;
111
+ if (thinking !== undefined && !isThinkingLevel(thinking)) {
112
+ invalid(source, `${pathName}.thinking must be a non-empty Pi thinking-level string without whitespace or control characters`);
113
+ }
114
+
115
+ return Object.freeze({
116
+ model,
117
+ description: rawDescription,
118
+ ...(thinking === undefined ? {} : { thinking }),
119
+ });
120
+ }
121
+
122
+ function parseModels(value: unknown, source: string): readonly JevRoutingModelEntry[] {
123
+ if (value === undefined) invalid(source, "models is required and must list at least one candidate model");
124
+ if (!Array.isArray(value)) invalid(source, "models must be an array of candidate model entries");
125
+ if (value.length === 0) invalid(source, `models must list at least 1 candidate model`);
126
+ if (value.length > MAX_ROUTING_MODELS) invalid(source, `models must not list more than ${MAX_ROUTING_MODELS} candidates`);
127
+
128
+ const entries: JevRoutingModelEntry[] = [];
129
+ const seen = new Set<string>();
130
+ for (let index = 0; index < value.length; index++) {
131
+ const entry = parseModelEntry(value[index], `models[${index}]`, source);
132
+ if (seen.has(entry.model)) invalid(source, `models contains duplicate model ID ${JSON.stringify(entry.model)}`);
133
+ seen.add(entry.model);
134
+ entries.push(entry);
135
+ }
136
+ return Object.freeze(entries);
137
+ }
138
+
139
+ /**
140
+ * Parse and freeze the `jevRouting` subtree.
141
+ *
142
+ * @param raw the value at `config.jevRouting` (the subtree, not the whole config file)
143
+ * @param source human-readable source label used in error messages
144
+ */
145
+ export function parseJevRouting(raw: unknown, source = JEV_ROUTING_CONFIG_FILE): JevRoutingConfig {
146
+ const record = requireObject(raw, source, "jevRouting");
147
+ rejectUnknownKeys(record, ["selectorModel", "apiKeyEnv", "timeoutMs", "models"], source, "jevRouting");
148
+
149
+ const snapshot: JevRoutingConfig = {
150
+ selectorModel: parseSelectorModel(record.selectorModel, source),
151
+ apiKeyEnv: parseApiKeyEnv(record.apiKeyEnv, source),
152
+ timeoutMs: parseTimeoutMs(record.timeoutMs, source),
153
+ models: parseModels(record.models, source),
154
+ };
155
+ return Object.freeze(snapshot);
156
+ }
157
+
158
+ /** A documented manual-migration/template snippet; contains no credential value. */
159
+ export function jevRoutingTemplate(): string {
160
+ return JSON.stringify({
161
+ jevRouting: {
162
+ selectorModel: DEFAULT_SELECTOR_MODEL,
163
+ apiKeyEnv: DEFAULT_API_KEY_ENV,
164
+ timeoutMs: DEFAULT_ROUTING_TIMEOUT_MS,
165
+ models: [
166
+ {
167
+ model: "<provider/model-id>",
168
+ description: "<user-written characteristics, including Chinese>",
169
+ thinking: "<optional opaque Pi thinking default>",
170
+ },
171
+ ],
172
+ },
173
+ }, null, 2);
174
+ }
175
+
176
+ function freezeCandidate(entry: JevRoutingModelEntry): RoutingModelCandidate {
177
+ return Object.freeze({
178
+ model: entry.model,
179
+ description: entry.description,
180
+ ...(entry.thinking === undefined ? {} : { thinking: entry.thinking }),
181
+ });
182
+ }
183
+
184
+ /** Frozen candidate list in configured order; descriptions are preserved unchanged. */
185
+ export function modelCandidates(config: JevRoutingConfig): readonly RoutingModelCandidate[] {
186
+ return Object.freeze(config.models.map(freezeCandidate));
187
+ }
188
+
189
+ /**
190
+ * Intersect the dedicated list with locally available exact model IDs, preserving the
191
+ * user's configured order. No ranking, cost or quality preset is applied. An empty result
192
+ * means the caller must reject before any HTTP request.
193
+ */
194
+ export function eligibleModelCandidates(
195
+ config: JevRoutingConfig,
196
+ availableModels: readonly string[],
197
+ ): readonly RoutingModelCandidate[] {
198
+ const available = new Set(availableModels);
199
+ return Object.freeze(config.models.filter((entry) => available.has(entry.model)).map(freezeCandidate));
200
+ }
201
+
202
+ /** The selected candidate's optional Pi thinking default, or `undefined`. */
203
+ export function candidateThinking(config: JevRoutingConfig, model: string): string | undefined {
204
+ return config.models.find((entry) => entry.model === model)?.thinking;
205
+ }
206
+
207
+ /**
208
+ * Build frozen tool candidates from local tool metadata. Blank names are dropped and
209
+ * duplicate names keep the first description; no schema, source path or executable
210
+ * definition is retained.
211
+ */
212
+ export function toToolCandidates(
213
+ tools: ReadonlyArray<{ name: string; description?: string }>,
214
+ ): readonly RoutingToolCandidate[] {
215
+ const seen = new Set<string>();
216
+ const candidates: RoutingToolCandidate[] = [];
217
+ for (const tool of tools) {
218
+ const name = typeof tool?.name === "string" ? tool.name.trim() : "";
219
+ if (!name || seen.has(name)) continue;
220
+ seen.add(name);
221
+ const description = typeof tool.description === "string" ? tool.description : "";
222
+ candidates.push(Object.freeze({ name, description }));
223
+ }
224
+ return Object.freeze(candidates);
225
+ }
226
+
227
+ function routingSummary(config: JevRoutingConfig): string[] {
228
+ const lines = [
229
+ "## Subagent routing (Jev / TypeSafe)",
230
+ "Every new task/tasks[] spawn, action:\"plan\" request, /btw, resume, fork and synthesis is routed by the Jev selector against the user's candidate-model list.",
231
+ "Do not pass model or fallback_models: those fields no longer select a route on new work and are rejected. Management actions (status/wait/cancel/steer/diff/apply/discard) never call the selector and need no credential.",
232
+ `Selector: ${config.selectorModel} (pin an exact version instead of the moving alias to make selection reproducible).`,
233
+ `Credential: environment variable ${config.apiKeyEnv}, read locally at request time; the value is never stored in config, prompts, logs or results.`,
234
+ `Logical selection deadline: ${config.timeoutMs} ms, covering all selector requests and waiting for one invocation.`,
235
+ "Only Pi-backed new dispatch is supported; native Codex/Claude new dispatches are rejected rather than routed.",
236
+ "Candidate models (exact IDs; the user's per-model characteristics are the matching criteria):",
237
+ ];
238
+ const listed = config.models.slice(0, MAX_GUIDANCE_MODEL_LINES);
239
+ for (const entry of listed) {
240
+ lines.push(`- ${entry.model}: thinking default ${entry.thinking ?? "(unset)"}`);
241
+ }
242
+ if (config.models.length > listed.length) {
243
+ lines.push(`- …and ${config.models.length - listed.length} more configured candidate(s); every configured candidate is eligible.`);
244
+ }
245
+ lines.push(
246
+ "The selector returns one execution model and an individual include/exclude decision per eligible tool. Unknown, unsafe or unavailable choices are rejected locally, and required Pi control-plane tools are added locally rather than chosen by the selector.",
247
+ );
248
+ return lines;
249
+ }
250
+
251
+ /**
252
+ * Model-facing guidance. Pure: renders correctly with no config and never reads the
253
+ * environment or performs inference, so management stays available while routing is
254
+ * missing or broken.
255
+ */
256
+ export function formatJevRoutingPrompt(config: JevRoutingConfig | undefined, error?: string): string {
257
+ if (!config) {
258
+ return [
259
+ "## Subagent routing (Jev / TypeSafe)",
260
+ error || `No valid jevRouting configuration was found in ${JEV_ROUTING_CONFIG_FILE}.`,
261
+ "Management actions (status/wait/cancel/steer/diff/apply/discard) remain available, but every new task/tasks[] spawn, plan, /btw, resume, fork and synthesis is rejected until jevRouting is configured.",
262
+ "Add jevRouting with selectorModel, apiKeyEnv and 1-255 candidate model entries (exact provider/model IDs plus user-written characteristics, including Chinese). The credential itself is read from the named environment variable at request time and is never stored in the config file.",
263
+ "Do not pass model or fallback_models; the selector chooses the execution model and tools.",
264
+ "Use the package routing template; do not invent model IDs or import legacy modelPolicy entries automatically.",
265
+ ].join("\n");
266
+ }
267
+ return routingSummary(config).join("\n");
268
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Shared Jev routing contract: DTOs, decisions, receipts and resource limits.
3
+ *
4
+ * This module deliberately has **no engine imports**. It must stay importable from the
5
+ * composition root, from route persistence/usage code, and from isolated offline harnesses
6
+ * without pulling in config, policy, registry or process code. Only Node builtins and the
7
+ * leaf `thinking.ts` helper may be used by dependants.
8
+ *
9
+ * Everything here describes the *selector boundary*. Local permission enforcement, profile
10
+ * filtering and mandatory Pi control-plane tools stay in `src/policy.ts` and the caller.
11
+ */
12
+
13
+ /**
14
+ * Fixed official TypeSafe endpoint. The first version has no custom base URL, proxy or
15
+ * task-selected endpoint; the transport always uses this constant with `redirect:"error"`.
16
+ */
17
+ export const TYPESAFE_SYSTEMONE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
18
+
19
+ /** Stable alias default. An exact supported version may be pinned through config. */
20
+ export const DEFAULT_SELECTOR_MODEL = "jev-latest";
21
+ /** Environment variable that holds the TypeSafe credential. Never the credential value. */
22
+ export const DEFAULT_API_KEY_ENV = "TYPESAFE_API_KEY";
23
+ /** Default logical selection deadline in milliseconds. */
24
+ export const DEFAULT_ROUTING_TIMEOUT_MS = 15_000;
25
+ export const ROUTING_TIMEOUT_MIN_MS = 100;
26
+ export const ROUTING_TIMEOUT_MAX_MS = 600_000;
27
+ /** Dedicated candidate-model allowlist bounds (Choice supports up to 255 options). */
28
+ export const MAX_ROUTING_MODELS = 255;
29
+ /** Eligible tool questions per logical selection. */
30
+ export const MAX_ROUTING_TOOL_QUESTIONS = 256;
31
+ /** Serialized request bound (local resource limit, not an advertised provider token limit). */
32
+ export const MAX_ROUTING_REQUEST_BYTES = 24 * 1024;
33
+ /** Response body bound; larger bodies are rejected, never truncated. */
34
+ export const MAX_ROUTING_RESPONSE_BYTES = 1024 * 1024;
35
+ /** Concurrent selector HTTP requests across overlapping router calls. */
36
+ export const DEFAULT_ROUTING_CONCURRENCY = 2;
37
+ /** Documented tolerance for a Choice probability distribution summing to 1. */
38
+ export const PROBABILITY_SUM_TOLERANCE = 0.02;
39
+ /** Bounds for opaque identifier strings read from untrusted responses/config. */
40
+ export const MAX_SELECTOR_VERSION_LENGTH = 128;
41
+ export const MAX_ROUTING_MODEL_ID_LENGTH = 256;
42
+ export const MAX_ROUTING_SELECTOR_MODEL_LENGTH = 128;
43
+
44
+ /** Why this logical selection ran. Metadata only; never part of the HTTP DTO. */
45
+ export type RoutingPurpose = "plan" | "dispatch" | "synthesis";
46
+ /** Local execution profile the caller already enforced. */
47
+ export type RoutingProfile = "explore" | "review" | "general";
48
+
49
+ /**
50
+ * Stable machine-readable failure codes. Messages are safe for models and the TUI and never
51
+ * contain raw provider bodies, headers, credentials, task text or filesystem paths.
52
+ */
53
+ export type RoutingFailureCode =
54
+ | "invalid_input"
55
+ | "missing_api_key"
56
+ | "no_candidate_models"
57
+ | "too_many_models"
58
+ | "too_many_tools"
59
+ | "request_too_large"
60
+ | "response_too_large"
61
+ | "transport_error"
62
+ | "timeout"
63
+ | "aborted"
64
+ | "unauthorized"
65
+ | "invalid_request"
66
+ | "rate_limited"
67
+ | "overloaded"
68
+ | "http_error"
69
+ | "malformed_response"
70
+ | "invalid_decision";
71
+
72
+ /** Outcome of one actual HTTP attempt. */
73
+ export type RoutingReceiptOutcome = "success" | "error" | "timeout" | "aborted";
74
+ /** `unknown` is the honest state whenever tokens were not validly reported. */
75
+ export type RoutingUsageStatus = "reported" | "unknown";
76
+
77
+ /** One configured candidate model entry, owned by the user's `jevRouting` config. */
78
+ export interface JevRoutingModelEntry {
79
+ /** Exact provider/model ID; no globs or Pi fuzzy-match patterns. */
80
+ readonly model: string;
81
+ /** User-written characteristics, including Chinese; the selector's matching criteria. */
82
+ readonly description: string;
83
+ /** Optional opaque Pi thinking default for this model. Local only, never a Jev question. */
84
+ readonly thinking?: string;
85
+ }
86
+
87
+ /**
88
+ * Immutable `jevRouting` snapshot parsed from the user config subtree.
89
+ * The credential value never appears here — only the environment-variable name.
90
+ */
91
+ export interface JevRoutingConfig {
92
+ readonly selectorModel: string;
93
+ readonly apiKeyEnv: string;
94
+ readonly timeoutMs: number;
95
+ readonly models: readonly JevRoutingModelEntry[];
96
+ }
97
+
98
+ /** A candidate model that is both configured and locally eligible. */
99
+ export interface RoutingModelCandidate {
100
+ readonly model: string;
101
+ readonly description: string;
102
+ readonly thinking?: string;
103
+ }
104
+
105
+ /**
106
+ * A tool offered to the selector. The caller must have already removed mandatory local
107
+ * additions (Pi control-plane tools) — those are never Jev questions.
108
+ */
109
+ export interface RoutingToolCandidate {
110
+ readonly name: string;
111
+ readonly description: string;
112
+ }
113
+
114
+ /** Necessary typed constraints. Schema examples, paths and session IDs must not be added. */
115
+ export interface RoutingConstraints {
116
+ readonly profile: RoutingProfile;
117
+ /** Opaque Pi thinking level requested by the task/agent/profile. */
118
+ readonly requestedThinking?: string;
119
+ readonly structuredOutput?: boolean;
120
+ }
121
+
122
+ /**
123
+ * The only routing input. It intentionally cannot carry a `TaskSpec`, `ParentContext`,
124
+ * agent config, system prompt, persona, tool schema or session identity.
125
+ */
126
+ export interface RoutingSelectInput {
127
+ /** Current delegated task text only. */
128
+ readonly task: string;
129
+ /** Locally eligible candidate models, preserving the user's configured order. */
130
+ readonly models: readonly RoutingModelCandidate[];
131
+ /** Eligible non-mandatory tools; an empty/absent list means a model-only question. */
132
+ readonly tools?: readonly RoutingToolCandidate[];
133
+ readonly constraints?: RoutingConstraints;
134
+ }
135
+
136
+ /** Everything that is metadata or lifecycle, kept out of the HTTP DTO. */
137
+ export interface RoutingSelectOptions {
138
+ readonly purpose: RoutingPurpose;
139
+ /** Zero-based task index for parallel/plan fanout; metadata only. */
140
+ readonly taskIndex?: number;
141
+ readonly signal?: AbortSignal;
142
+ /** Caller absolute deadline (epoch ms). The logical deadline is the smaller bound. */
143
+ readonly deadline?: number;
144
+ }
145
+
146
+ /**
147
+ * One receipt per **actual** HTTP attempt. `requestId` is always a full unique ID.
148
+ * Tokens are retained even when the decision is later rejected; when no valid usage was
149
+ * reported, `usageStatus` stays `unknown` rather than an invented zero.
150
+ */
151
+ export interface RoutingReceipt {
152
+ readonly requestId: string;
153
+ readonly purpose: RoutingPurpose;
154
+ readonly taskIndex?: number;
155
+ /** Selector model requested (alias or pinned version). */
156
+ readonly selectorModel: string;
157
+ /** Actual selector version reported by a parseable response. */
158
+ readonly selectorVersion?: string;
159
+ readonly outcome: RoutingReceiptOutcome;
160
+ readonly code?: RoutingFailureCode;
161
+ readonly httpStatus?: number;
162
+ readonly durationMs: number;
163
+ readonly inputTokens?: number;
164
+ readonly outputTokens?: number;
165
+ readonly usageStatus: RoutingUsageStatus;
166
+ /** TypeSafe reports tokens, not billed currency. Never inferred locally. */
167
+ readonly currency: "unknown";
168
+ }
169
+
170
+ /**
171
+ * A validated selection. `selectedTools` is the Jev-chosen subset only; the caller still
172
+ * applies local capability validation, adds mandatory Pi control-plane tools and recomputes
173
+ * writer capability.
174
+ */
175
+ export interface RoutingDecision {
176
+ /** Unique logical decision ID, separate from every receipt/request ID. */
177
+ readonly decisionId: string;
178
+ readonly purpose: RoutingPurpose;
179
+ readonly taskIndex?: number;
180
+ readonly selectedModel: string;
181
+ readonly selectedTools: readonly string[];
182
+ /** Confidence of the model Choice. Diagnostic only; never a permission threshold. */
183
+ readonly confidence?: number;
184
+ readonly selectorModel: string;
185
+ /** Primary selector version: the version reported by the model response. */
186
+ readonly selectorVersion?: string;
187
+ /**
188
+ * Every distinct selector version observed across this selection, in first-seen order.
189
+ * A moving alias such as `jev-latest` may resolve differently between the model and tool
190
+ * requests; that is recorded, never treated as an error.
191
+ */
192
+ readonly selectorVersions: readonly string[];
193
+ /** Total logical selection latency, including all requests and waiting. */
194
+ readonly latencyMs: number;
195
+ /** Receipt IDs backing this decision, in issue order. */
196
+ readonly receiptIds: readonly string[];
197
+ }
198
+
199
+ /** Discriminated result: failures never throw and always return available receipts. */
200
+ export type RoutingResult =
201
+ | {
202
+ readonly ok: true;
203
+ readonly decision: RoutingDecision;
204
+ readonly receipts: readonly RoutingReceipt[];
205
+ /**
206
+ * Safe, non-secret diagnostics when an injected receipt sink failed. Receipt tokens are
207
+ * still returned; the caller can retry persistence for the listed request IDs.
208
+ */
209
+ readonly persistenceErrors?: readonly string[];
210
+ }
211
+ | {
212
+ readonly ok: false;
213
+ readonly code: RoutingFailureCode;
214
+ readonly message: string;
215
+ readonly receipts: readonly RoutingReceipt[];
216
+ readonly persistenceErrors?: readonly string[];
217
+ };