@spendguard/sdk 0.5.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,121 @@
1
+ /**
2
+ * Classification bucket for an RPC error per Python `_classify_rpc_error`.
3
+ *
4
+ * - `"transient"` → the sidecar / network is temporarily unavailable. Safe to
5
+ * retry IF the caller has a stable idempotency key.
6
+ * - `"permanent"` → all other failures (invalid argument, decision denied,
7
+ * precondition failed, etc.). MUST NOT retry — retrying a permanent error
8
+ * either does nothing (waste of latency budget) or worse, re-asserts a
9
+ * broken request shape.
10
+ */
11
+ type RpcErrorClassification = "transient" | "permanent";
12
+ /**
13
+ * The gRPC status codes that mirror Python's transient-error bucket.
14
+ * Listed here as a frozen set so tests can assert the bucket is the locked
15
+ * three (review-standards.md §1.2 verbatim-signature gate).
16
+ *
17
+ * The string values are the canonical `@grpc/grpc-js` status names; same
18
+ * spelling as `grpc.StatusCode.<NAME>` and `protobuf-ts`'s `RpcError.code`.
19
+ */
20
+ declare const TRANSIENT_STATUS_CODES: ReadonlySet<string>;
21
+ /**
22
+ * Classify an error as transient or permanent.
23
+ *
24
+ * Inputs accepted (in order of dispatch):
25
+ * 1. `SidecarUnavailable` instance — `"transient"` (we already classified it
26
+ * upstream via `mapGrpcStatusToError`).
27
+ * 2. Object with `.code` matching one of `TRANSIENT_STATUS_CODES` — typed
28
+ * `"transient"`. This is the `protobuf-ts` `RpcError` shape AND the
29
+ * `@grpc/grpc-js` ServiceError shape AND a duck-typed mock for tests.
30
+ * 3. Everything else — `"permanent"`.
31
+ *
32
+ * Matches Python `_classify_rpc_error` line-for-line: same three statuses,
33
+ * same fallthrough, same conservative default ("if we don't know, don't retry").
34
+ *
35
+ * @param err The error to classify. Accepts any thrown value (RpcError,
36
+ * SpendGuardError subclasses, plain Error, string, undefined, …).
37
+ * @returns `"transient"` or `"permanent"`.
38
+ *
39
+ * @example
40
+ * import { classifyRpcError } from "@spendguard/sdk/retry";
41
+ *
42
+ * try {
43
+ * await client.reserve(req);
44
+ * } catch (err) {
45
+ * if (classifyRpcError(err) === "transient") {
46
+ * // queue for retry
47
+ * } else {
48
+ * throw err;
49
+ * }
50
+ * }
51
+ */
52
+ declare function classifyRpcError(err: unknown): RpcErrorClassification;
53
+ /**
54
+ * Options for `runWithRetry`.
55
+ *
56
+ * - `idempotencyKey` — REQUIRED for any retry to actually occur. Without one,
57
+ * `runWithRetry` runs `fn` exactly once and rethrows a pointed
58
+ * `SidecarUnavailable(cause: err)` on a transient error so the adapter can
59
+ * route. With one, `fn` runs up to `maxAttempts` times on transient errors.
60
+ * - `maxAttempts` — total attempts (initial + retries). Default 2; legal
61
+ * range [1, 5]. The default 2 matches design.md §6.5 line 428 ("initial +
62
+ * 1 retry"); values ≥ 5 require an explicit opt-in to discourage runaway
63
+ * retry loops in the substrate.
64
+ * - `baseBackoffMs` — fixed delay between attempts. Default 25 ms per
65
+ * design.md §6.5 line 429.
66
+ * - `jitterMs` — random delay added on top of `baseBackoffMs`. Default 25 ms;
67
+ * sampled uniformly from `[0, jitterMs]`. Avoids retry-storm pile-ups when
68
+ * N adapters share a single sidecar.
69
+ * - `sleep` — pluggable sleeper for tests. Defaults to `setTimeout`.
70
+ */
71
+ interface RunWithRetryOptions {
72
+ idempotencyKey?: string;
73
+ maxAttempts?: number;
74
+ baseBackoffMs?: number;
75
+ jitterMs?: number;
76
+ sleep?: (ms: number) => Promise<void>;
77
+ }
78
+ /**
79
+ * Run `fn()` with bounded retry per design.md §6.5 + Python
80
+ * `_classify_rpc_error` parity.
81
+ *
82
+ * Algorithm:
83
+ * 1. Run `fn()`. If it succeeds, return the value.
84
+ * 2. If it throws, classify the error via `classifyRpcError(err)`.
85
+ * - PERMANENT → rethrow as-is (NEVER retry).
86
+ * - TRANSIENT without `idempotencyKey` → throw
87
+ * `SidecarUnavailable(cause: err)` immediately. Caller's adapter is
88
+ * responsible for routing; the substrate refuses to retry without a
89
+ * stable key because a fresh decision on retry would double-reserve.
90
+ * - TRANSIENT with `idempotencyKey` AND attempts remaining → sleep
91
+ * `baseBackoffMs + uniform(0, jitterMs)`, then go to step 1.
92
+ * - TRANSIENT with `idempotencyKey` AND attempts exhausted → throw the
93
+ * LAST error (preserves the original `cause` if it's already a
94
+ * `SidecarUnavailable`).
95
+ *
96
+ * Permanent errors NEVER retry — design.md §6.5 line 426 spells out the
97
+ * three-status transient cluster as the only retry-eligible surface.
98
+ *
99
+ * The `sleep` function in tests defaults to `setTimeout` — for unit tests,
100
+ * pass a no-op (`async () => {}`) so the retry loop runs synchronously.
101
+ *
102
+ * @param fn The function to run. Awaited; thrown errors are inspected.
103
+ * @param opts Retry options. `idempotencyKey` MUST be set for retries to
104
+ * actually occur.
105
+ *
106
+ * @returns The result of `fn()` on success.
107
+ *
108
+ * @throws SidecarUnavailable when `fn` fails transiently without an
109
+ * idempotency key.
110
+ * @throws The last error from `fn` when attempts are exhausted (or the
111
+ * error type is permanent).
112
+ *
113
+ * @example
114
+ * await runWithRetry(
115
+ * () => client.requestDecision(grpcReq, { timeout: 250 }),
116
+ * { idempotencyKey: req.idempotencyKey, maxAttempts: 2 },
117
+ * );
118
+ */
119
+ declare function runWithRetry<T>(fn: () => Promise<T>, opts?: RunWithRetryOptions): Promise<T>;
120
+
121
+ export { type RpcErrorClassification, type RunWithRetryOptions, TRANSIENT_STATUS_CODES, classifyRpcError, runWithRetry };
package/dist/retry.js ADDED
@@ -0,0 +1,92 @@
1
+ // src/errors.ts
2
+ var SpendGuardError = class extends Error {
3
+ name = "SpendGuardError";
4
+ constructor(message, opts) {
5
+ super(message);
6
+ if (opts?.cause !== void 0) {
7
+ this.cause = opts.cause;
8
+ }
9
+ Object.defineProperty(this, "name", {
10
+ value: this.name,
11
+ enumerable: true,
12
+ configurable: true,
13
+ writable: true
14
+ });
15
+ }
16
+ };
17
+ var SidecarUnavailable = class extends SpendGuardError {
18
+ name = "SidecarUnavailable";
19
+ statusCode = 503;
20
+ };
21
+
22
+ // src/retry.ts
23
+ var TRANSIENT_STATUS_CODES = /* @__PURE__ */ new Set([
24
+ "UNAVAILABLE",
25
+ "DEADLINE_EXCEEDED",
26
+ "CANCELLED"
27
+ ]);
28
+ function classifyRpcError(err) {
29
+ if (err instanceof SidecarUnavailable) return "transient";
30
+ if (err !== null && typeof err === "object" && "code" in err) {
31
+ const code = err.code;
32
+ if (typeof code === "string" && TRANSIENT_STATUS_CODES.has(code)) {
33
+ return "transient";
34
+ }
35
+ }
36
+ return "permanent";
37
+ }
38
+ async function runWithRetry(fn, opts = {}) {
39
+ const idempotencyKey = opts.idempotencyKey;
40
+ const maxAttempts = clampMaxAttempts(opts.maxAttempts ?? 2);
41
+ const baseBackoffMs = opts.baseBackoffMs ?? 25;
42
+ const jitterMs = opts.jitterMs ?? 25;
43
+ const sleep = opts.sleep ?? defaultSleep;
44
+ let lastErr;
45
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
46
+ try {
47
+ return await fn();
48
+ } catch (err) {
49
+ const classification = classifyRpcError(err);
50
+ if (classification === "permanent") {
51
+ throw err;
52
+ }
53
+ if (idempotencyKey === void 0 || idempotencyKey.length === 0) {
54
+ if (err instanceof SidecarUnavailable) {
55
+ throw err;
56
+ }
57
+ throw new SidecarUnavailable(
58
+ `transient RPC failure with no idempotency key; refusing to retry: ${errorMessage(err)}`,
59
+ { cause: err }
60
+ );
61
+ }
62
+ lastErr = err;
63
+ if (attempt < maxAttempts) {
64
+ const delay = baseBackoffMs + Math.floor(Math.random() * (jitterMs + 1));
65
+ await sleep(delay);
66
+ continue;
67
+ }
68
+ throw err;
69
+ }
70
+ }
71
+ throw lastErr ?? new SpendGuardError("runWithRetry: unreachable");
72
+ }
73
+ function clampMaxAttempts(value) {
74
+ if (!Number.isFinite(value) || !Number.isInteger(value)) return 2;
75
+ if (value < 1) return 1;
76
+ if (value > 5) return 5;
77
+ return value;
78
+ }
79
+ function defaultSleep(ms) {
80
+ return new Promise((resolve) => {
81
+ setTimeout(resolve, ms);
82
+ });
83
+ }
84
+ function errorMessage(err) {
85
+ if (err instanceof Error) return err.message;
86
+ if (typeof err === "string") return err;
87
+ return String(err);
88
+ }
89
+
90
+ export { TRANSIENT_STATUS_CODES, classifyRpcError, runWithRetry };
91
+ //# sourceMappingURL=retry.js.map
92
+ //# sourceMappingURL=retry.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Caller-declared plan for one logical agent run.
3
+ *
4
+ * - `plannedCalls` — expected number of LLM calls in the run. Non-negative integer.
5
+ * - `plannedTools` — expected number of tool calls in the run. Non-negative integer.
6
+ *
7
+ * The SDK ships `plannedStepsHint = plannedCalls + plannedTools` on every
8
+ * `DecisionRequest` issued inside the active scope (per spec §5.1: steps are
9
+ * the disjoint union of LLM + tool calls). The sidecar-side projector
10
+ * enforces an upper bound `[0, MAX_PLANNED_STEPS]`
11
+ * (`services/run_cost_projector/src/server.rs::MAX_PLANNED_STEPS`); we don't
12
+ * repeat the bound here so a future Rust-side bump doesn't require an SDK
13
+ * release.
14
+ */
15
+ interface RunPlan {
16
+ plannedCalls: number;
17
+ plannedTools: number;
18
+ }
19
+ /**
20
+ * Read the in-scope `RunPlan` if one exists, otherwise `null`.
21
+ *
22
+ * Returns `null` (not `undefined`) per LOCKED design.md §4.7 line 300 — the
23
+ * shape is `RunPlan | null` for parity with the Python `current_run_plan()`
24
+ * reference (which returns `None`) and review-standards §8.3.
25
+ *
26
+ * Returned object is the SAME reference `withRunPlan` stored (defensively
27
+ * copied at scope entry); adapters MUST treat the returned value as
28
+ * immutable.
29
+ */
30
+ declare function currentRunPlan(): RunPlan | null;
31
+ /**
32
+ * Higher-order function that installs `plan` in async-local scope around `fn`.
33
+ *
34
+ * CURRIED form per design.md §4.7 lines 295-298 — calling `withRunPlan(plan, fn)`
35
+ * returns a NEW callable; the wrapped `fn` is only invoked when the returned
36
+ * callable is called. Mirrors the `@with_run_plan(...)` decorator pattern in
37
+ * `sdk/python/src/spendguard/run_plan.py`.
38
+ *
39
+ * ## Validation (LOCKED §8.4)
40
+ *
41
+ * `plannedCalls` and `plannedTools` are validated at HOF construction time
42
+ * (NOT at wrapped-call time): if either is missing, non-integer, or negative,
43
+ * a `TypeError` is thrown synchronously from `withRunPlan` — surfacing the
44
+ * misuse at decorator application rather than first call. `plannedTools` is
45
+ * optional on input and defaults to `0`.
46
+ *
47
+ * ## Nesting (LOCKED §8.2 — OUTER WINS)
48
+ *
49
+ * When the returned callable is invoked inside an existing `withRunPlan`
50
+ * scope, the inner call is a NO-OP for plan storage — the outer plan stays
51
+ * active and `currentRunPlan()` continues to return it. The inner `fn` is
52
+ * still invoked with its arguments; only the storage swap is skipped. This
53
+ * matches `run_plan.py` lines 183-191 (outer wins) and protects the budget
54
+ * envelope from being silently rewritten by a sub-agent helper.
55
+ *
56
+ * @param plan The run plan to install. Validated immediately.
57
+ * @param fn The (sync or async) function to run inside the plan scope.
58
+ * @returns A NEW async function that, when called, runs `fn(...args)` with
59
+ * the plan in scope and returns `Promise<TRet>`.
60
+ *
61
+ * @throws TypeError if `plannedCalls` or `plannedTools` is not a non-negative
62
+ * integer.
63
+ */
64
+ declare function withRunPlan<TArgs extends unknown[], TRet>(plan: {
65
+ plannedCalls: number;
66
+ plannedTools?: number;
67
+ }, fn: (...args: TArgs) => TRet | Promise<TRet>): (...args: TArgs) => Promise<TRet>;
68
+
69
+ export { type RunPlan, currentRunPlan, withRunPlan };
@@ -0,0 +1,35 @@
1
+ import { AsyncLocalStorage } from 'async_hooks';
2
+
3
+ // src/runPlan.ts
4
+ var storage = new AsyncLocalStorage();
5
+ function currentRunPlan() {
6
+ return storage.getStore() ?? null;
7
+ }
8
+ function withRunPlan(plan, fn) {
9
+ if (!Number.isInteger(plan.plannedCalls) || plan.plannedCalls < 0) {
10
+ throw new TypeError(
11
+ `withRunPlan: plannedCalls must be a non-negative integer, got ${String(plan.plannedCalls)}`
12
+ );
13
+ }
14
+ const tools = plan.plannedTools ?? 0;
15
+ if (!Number.isInteger(tools) || tools < 0) {
16
+ throw new TypeError(
17
+ `withRunPlan: plannedTools must be a non-negative integer, got ${String(plan.plannedTools)}`
18
+ );
19
+ }
20
+ const fullPlan = Object.freeze({
21
+ plannedCalls: plan.plannedCalls,
22
+ plannedTools: tools
23
+ });
24
+ return async (...args) => {
25
+ const existing = storage.getStore();
26
+ if (existing !== void 0) {
27
+ return await fn(...args);
28
+ }
29
+ return await storage.run(fullPlan, () => fn(...args));
30
+ };
31
+ }
32
+
33
+ export { currentRunPlan, withRunPlan };
34
+ //# sourceMappingURL=runPlan.js.map
35
+ //# sourceMappingURL=runPlan.js.map
@@ -0,0 +1,327 @@
1
+ {
2
+ "version": 1,
3
+ "generated_at": "2026-06-07",
4
+ "generated_with": {
5
+ "python_reference": "spendguard.ids.* + spendguard.prompt_hash.compute",
6
+ "note": "Python implementation is the reference. TS asserts the SAME expected_output for the SAME inputs. Drift in either direction is a P0 review-standards §2 blocker."
7
+ },
8
+ "fixtures": [
9
+ {
10
+ "id": "FX1",
11
+ "fn": "derive_idempotency_key",
12
+ "description": "ASCII numeric IDs, LLM_CALL_PRE trigger",
13
+ "inputs": {
14
+ "tenant_id": "t-1",
15
+ "session_id": "s-1",
16
+ "run_id": "r-1",
17
+ "step_id": "step-1",
18
+ "llm_call_id": "llm-1",
19
+ "trigger": "LLM_CALL_PRE"
20
+ },
21
+ "expected_output": "sg-df6a372619ee74530c2d9e6e4cbbc4b9"
22
+ },
23
+ {
24
+ "id": "FX2",
25
+ "fn": "derive_idempotency_key",
26
+ "description": "ASCII numeric IDs, alternate values",
27
+ "inputs": {
28
+ "tenant_id": "t-2",
29
+ "session_id": "s-2",
30
+ "run_id": "r-2",
31
+ "step_id": "step-2",
32
+ "llm_call_id": "llm-2",
33
+ "trigger": "LLM_CALL_PRE"
34
+ },
35
+ "expected_output": "sg-faefca72f21e98f85ca1428a07ff74cf"
36
+ },
37
+ {
38
+ "id": "FX3",
39
+ "fn": "derive_idempotency_key",
40
+ "description": "Canonical UUID tenant",
41
+ "inputs": {
42
+ "tenant_id": "00000000-0000-0000-0000-000000000001",
43
+ "session_id": "sess-1",
44
+ "run_id": "run-1",
45
+ "step_id": "step-1",
46
+ "llm_call_id": "llm-1",
47
+ "trigger": "LLM_CALL_PRE"
48
+ },
49
+ "expected_output": "sg-8f58c05cb80e39934ac161f5a4c7db40"
50
+ },
51
+ {
52
+ "id": "FX4",
53
+ "fn": "derive_idempotency_key",
54
+ "description": "Empty trigger field",
55
+ "inputs": {
56
+ "tenant_id": "tenant-abc",
57
+ "session_id": "sess-1",
58
+ "run_id": "run-1",
59
+ "step_id": "step-1",
60
+ "llm_call_id": "llm-1",
61
+ "trigger": ""
62
+ },
63
+ "expected_output": "sg-3a2090b41777421828f4362daca7aaa5"
64
+ },
65
+ {
66
+ "id": "FX5",
67
+ "fn": "derive_idempotency_key",
68
+ "description": "All fields empty",
69
+ "inputs": {
70
+ "tenant_id": "",
71
+ "session_id": "",
72
+ "run_id": "",
73
+ "step_id": "",
74
+ "llm_call_id": "",
75
+ "trigger": ""
76
+ },
77
+ "expected_output": "sg-ff302dc3560c000bc0cf9b9f72359b10"
78
+ },
79
+ {
80
+ "id": "FX6",
81
+ "fn": "derive_idempotency_key",
82
+ "description": "AGENT_STEP_PRE trigger boundary",
83
+ "inputs": {
84
+ "tenant_id": "tenant-xyz",
85
+ "session_id": "sess-42",
86
+ "run_id": "run-42",
87
+ "step_id": "step-7",
88
+ "llm_call_id": "llm-7",
89
+ "trigger": "AGENT_STEP_PRE"
90
+ },
91
+ "expected_output": "sg-a0c3ef27b9ef67c2649e3f54763f28cd"
92
+ },
93
+ {
94
+ "id": "FX7",
95
+ "fn": "derive_idempotency_key",
96
+ "description": "Multi-byte UTF-8 tenant id (CJK)",
97
+ "inputs": {
98
+ "tenant_id": "租户-甲",
99
+ "session_id": "sess-1",
100
+ "run_id": "run-1",
101
+ "step_id": "step-1",
102
+ "llm_call_id": "llm-1",
103
+ "trigger": "LLM_CALL_PRE"
104
+ },
105
+ "expected_output": "sg-95f41144d9bcd120386eeba22b83b74c"
106
+ },
107
+ {
108
+ "id": "FX8",
109
+ "fn": "derive_idempotency_key",
110
+ "description": "Unit-Separator probe (long tenant, empty session)",
111
+ "inputs": {
112
+ "tenant_id": "abcd",
113
+ "session_id": "",
114
+ "run_id": "x",
115
+ "step_id": "y",
116
+ "llm_call_id": "z",
117
+ "trigger": "T"
118
+ },
119
+ "expected_output": "sg-28cc1690aed794c64279c30f8ba49dfb"
120
+ },
121
+ {
122
+ "id": "FXP1",
123
+ "fn": "compute_prompt_hash",
124
+ "description": "ASCII prompt + canonical UUID tenant",
125
+ "inputs": {
126
+ "prompt_text": "hello world",
127
+ "tenant_id": "00000000-0000-0000-0000-000000000001"
128
+ },
129
+ "expected_output": "5d55a1ebc9782455de0979780fd6cf686127dadcba580f230ddc3fea31516d0d"
130
+ },
131
+ {
132
+ "id": "FXP2",
133
+ "fn": "compute_prompt_hash",
134
+ "description": "Empty prompt + canonical UUID tenant",
135
+ "inputs": {
136
+ "prompt_text": "",
137
+ "tenant_id": "00000000-0000-0000-0000-000000000001"
138
+ },
139
+ "expected_output": "698e521970ba6005a5555a4dc63797488a0f673a3386adfe0410aa11c9b6757b"
140
+ },
141
+ {
142
+ "id": "FXP3",
143
+ "fn": "compute_prompt_hash",
144
+ "description": "Whitespace-padded prompt + non-UUID tenant (strip gate)",
145
+ "inputs": {
146
+ "prompt_text": " trim me ",
147
+ "tenant_id": "tenant-abc"
148
+ },
149
+ "expected_output": "d97fa1377ce7133eeae08a7b9d67eaf50f80edc917d4550720ac6e9fccbd89e4"
150
+ },
151
+ {
152
+ "id": "FXP4",
153
+ "fn": "compute_prompt_hash",
154
+ "description": "Multi-byte UTF-8 prompt (CJK punctuation)",
155
+ "inputs": {
156
+ "prompt_text": "Hello, 世界!",
157
+ "tenant_id": "00000000-0000-0000-0000-000000000042"
158
+ },
159
+ "expected_output": "7caa95cf8b5b9118721f192d4998515655d570c40be02c4c8402a201c6e2f7e5"
160
+ },
161
+ {
162
+ "id": "FXP5",
163
+ "fn": "compute_prompt_hash",
164
+ "description": "UTF-8 BOM-prefixed prompt (non-ASCII whitespace preserved)",
165
+ "inputs": {
166
+ "prompt_text": "test prompt",
167
+ "tenant_id": "00000000-0000-0000-0000-000000000007"
168
+ },
169
+ "expected_output": "83476ff5befaf561d2b1b72b8dd803f2dce55219abb0041ee283cd451e6e8d66"
170
+ },
171
+ {
172
+ "id": "FXP6",
173
+ "fn": "compute_prompt_hash",
174
+ "description": "Embedded control characters NUL/BEL/VT preserved",
175
+ "inputs": {
176
+ "prompt_text": "before\u0000\u0007\u000bafter",
177
+ "tenant_id": "tenant-control"
178
+ },
179
+ "expected_output": "25274faa082e3f462b3956657b1e7f4624f4210f863b755e497349693013f8c7"
180
+ },
181
+ {
182
+ "id": "FXP7",
183
+ "fn": "compute_prompt_hash",
184
+ "description": "10KB ASCII prompt + non-UUID tenant",
185
+ "inputs": {
186
+ "prompt_text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
187
+ "tenant_id": "tenant-long"
188
+ },
189
+ "expected_output": "b4c8c062972c201681f906fbb86560d6304628bf0664d5375929cbc9379fa88d"
190
+ },
191
+ {
192
+ "id": "FXP8",
193
+ "fn": "compute_prompt_hash",
194
+ "description": "Mixed-case UUID tenant (canonicaliser lowercases hex)",
195
+ "inputs": {
196
+ "prompt_text": "hello world",
197
+ "tenant_id": "ABCDEF12-3456-7890-ABCD-EF1234567890"
198
+ },
199
+ "expected_output": "27ad8586fff06972454564d1fe1f447877a52807142815d2eb3e03f962152486"
200
+ },
201
+ {
202
+ "id": "FXU1",
203
+ "fn": "derive_uuid_from_signature",
204
+ "description": "decision_id scope",
205
+ "inputs": {
206
+ "signature": "sig-abc",
207
+ "scope": "decision_id"
208
+ },
209
+ "expected_output": "5f870046-6d3e-4e1d-87bd-d3cbb46ec8e8"
210
+ },
211
+ {
212
+ "id": "FXU2",
213
+ "fn": "derive_uuid_from_signature",
214
+ "description": "llm_call_id scope (same sig as FXU1)",
215
+ "inputs": {
216
+ "signature": "sig-abc",
217
+ "scope": "llm_call_id"
218
+ },
219
+ "expected_output": "7c0559d1-18d5-4a6b-9e3e-22a039284498"
220
+ },
221
+ {
222
+ "id": "FXU3",
223
+ "fn": "derive_uuid_from_signature",
224
+ "description": "audit_chain scope",
225
+ "inputs": {
226
+ "signature": "audit-row-v1|tenant-a|2026-06-07",
227
+ "scope": "audit_chain"
228
+ },
229
+ "expected_output": "443453c9-c3d0-4638-a95e-f30abcf3e000"
230
+ },
231
+ {
232
+ "id": "FXU4",
233
+ "fn": "derive_uuid_from_signature",
234
+ "description": "Custom scope string",
235
+ "inputs": {
236
+ "signature": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
237
+ "scope": "custom-test-scope"
238
+ },
239
+ "expected_output": "cf460197-b943-49a9-b936-dfb4b830c9b2"
240
+ },
241
+ {
242
+ "id": "FXOA1",
243
+ "fn": "derive_agent_signature",
244
+ "description": "D08 openai_agents — simple string input, no system instructions",
245
+ "inputs": {
246
+ "input": "Say hi",
247
+ "system_instructions": null
248
+ },
249
+ "expected_output": "0b8e468f6a5faab3cf0459d24035a33c"
250
+ },
251
+ {
252
+ "id": "FXOA2",
253
+ "fn": "derive_agent_signature",
254
+ "description": "D08 openai_agents — string input + system instructions",
255
+ "inputs": {
256
+ "input": "Say hi",
257
+ "system_instructions": "Be polite"
258
+ },
259
+ "expected_output": "bb805e8a571a9ee6772e1b3985575deb"
260
+ },
261
+ {
262
+ "id": "FXOA3",
263
+ "fn": "derive_agent_signature",
264
+ "description": "D08 openai_agents — short ASCII input + sys instructions (string-shape, cross-language byte-equal)",
265
+ "inputs": {
266
+ "input": "tools test",
267
+ "system_instructions": "Use the calculator tool"
268
+ },
269
+ "_note": "v0.1.x cross-language byte equality is limited to string-shape inputs because Python repr(list-of-dict) and TS JSON.stringify(list-of-dict) emit different byte sequences (single vs double quotes). The TS adapter falls back to JSON.stringify for non-string inputs, the Python adapter uses repr; the design.md §8 rendering quirk documents this divergence. A future v2.json could mint a canonical JSON form on both sides to close this gap; for now, FXOA* rows ship string-input only.",
270
+ "expected_output": "d2b0f7783bcbf3be27cc841b98e4c4ec"
271
+ },
272
+ {
273
+ "id": "FXOA4",
274
+ "fn": "derive_agent_signature",
275
+ "description": "D08 openai_agents — empty string input + empty system",
276
+ "inputs": {
277
+ "input": "",
278
+ "system_instructions": ""
279
+ },
280
+ "expected_output": "7d6395228c93bac41591f4c6b65aea03"
281
+ },
282
+ {
283
+ "id": "FXOA5",
284
+ "fn": "derive_agent_signature",
285
+ "description": "D08 openai_agents — multi-byte UTF-8 input + CJK tenant",
286
+ "inputs": {
287
+ "input": "Hello, 世界!",
288
+ "system_instructions": null
289
+ },
290
+ "expected_output": "3941e46a71c2a2cf4b196f5bb6c21e87"
291
+ },
292
+ {
293
+ "id": "FXOA1_IK",
294
+ "fn": "derive_idempotency_key",
295
+ "description": "D08 openai_agents — FXOA1 signature → idempotencyKey for runId=r-1",
296
+ "inputs": {
297
+ "tenant_id": "t-1",
298
+ "session_id": "s-1",
299
+ "run_id": "r-1",
300
+ "step_id": "r-1:oai-call:0b8e468f6a5faab3",
301
+ "llm_call_id": "9fa6e431-89e0-403e-8eee-2b252a7bd77c",
302
+ "trigger": "LLM_CALL_PRE"
303
+ },
304
+ "expected_output": "sg-e4d1ba4a538f53e1f25839e227de8f1f"
305
+ },
306
+ {
307
+ "id": "FXOA1_DEC",
308
+ "fn": "derive_uuid_from_signature",
309
+ "description": "D08 openai_agents — FXOA1 signature → decision_id UUID",
310
+ "inputs": {
311
+ "signature": "0b8e468f6a5faab3cf0459d24035a33c",
312
+ "scope": "decision_id"
313
+ },
314
+ "expected_output": "b18ca2dd-a146-4d69-952f-c6215b6b4d75"
315
+ },
316
+ {
317
+ "id": "FXOA1_LLM",
318
+ "fn": "derive_uuid_from_signature",
319
+ "description": "D08 openai_agents — FXOA1 signature → llm_call_id UUID",
320
+ "inputs": {
321
+ "signature": "0b8e468f6a5faab3cf0459d24035a33c",
322
+ "scope": "llm_call_id"
323
+ },
324
+ "expected_output": "9fa6e431-89e0-403e-8eee-2b252a7bd77c"
325
+ }
326
+ ]
327
+ }