import type { WiringManifestReceipt } from "./wire-types.js"; /** * In-memory, TTL'd dedup cache keyed by an opaque string (an Idempotency-Key). * * - `run(key, produce, shouldCache?)` invokes `produce` at most once per key — concurrent and retried calls * with the same key await the same promise — and retains a CACHEABLE resolution for `ttlMs` so a later retry * replays it. A rejection (a thrown error billed nothing) or a caller-marked un-cacheable resolution (e.g. a * transient 409) is evicted so a genuine retry re-runs. * - `peek(key)` returns the cached (in-flight or settled) promise without producing — letting a caller * short-circuit a retry BEFORE side gates (rate/quota), so the retry gets the same response, not a fresh 429. * An SSE stream wraps its live run in `run()` so the in-flight promise is stored too; a retry then peeks it. * * Per-entry TTL is enforced on access (O(1)); abandoned keys are reclaimed by a sweep that runs only once the * map grows past a threshold (no O(n) scan on the hot path). Single-threaded event-loop semantics: each HTTP * request is its own task, so there is no get→set race within a tick. * * (Caller context: the sema-registry review gateway mints/echoes the Idempotency-Key and routes a given key to * the same worker, so this PER-POD cache is the worker-side backstop against a retried submission re-running — * and re-billing. Cross-pod / cross-restart exactly-once needs store-backing; the durable async /v1/runs path * is the alternative for long tasks.) */ export declare class IdempotencyCache { private readonly ttlMs; private readonly sweepThreshold; private readonly entries; constructor(ttlMs?: number, sweepThreshold?: number); /** Live (un-expired) cached promise for a key, or undefined — without producing. */ peek(key: string): Promise | undefined; run(key: string | undefined, produce: () => Promise, shouldCache?: (value: T) => boolean): Promise; get size(): number; /** Return the cached promise if present AND within the TTL; drop it and return undefined if expired. */ private live; /** Reclaim expired entries only once the map is large — avoids an O(n) scan on every call (council). */ private maybeSweep; } /** * Namespace an Idempotency-Key by the authenticated identity (BL-3). The dedup cache is one per process; keying * it on the RAW client header let a different tenant (or anonymous caller) replaying the same key receive the * FIRST caller's response and skip the rate/quota gates. Folding the credential-derived `source` + the * `principal` into the key scopes a replay to the SAME caller only — cross-tenant collisions become cache * misses (re-run, correctly attributed). `\x1f` (unit separator) can't occur in an HTTP header value, so the * three fields can't be confused. undefined raw key → undefined (no dedup, unchanged). */ export declare function scopedIdempotencyKey(raw: string | undefined, source: string | null, principal: string | undefined): string | undefined; /** * 提交腿的**运行结局**(进幂等缓存的那一只)—— `status` + `body` 是它一直以来的两位;S-528 起多一位 * `wiringManifest`。 * * 🔴 **为什么回执挂在 outcome 上、不铸进 `body`**(codex 对抗复审 [medium],红先复现后修): * `Idempotency-Key` 的作用域是 `(credential source, principal, raw key)`,**不含端点** —— 同一个 key 先 * 走 `/v1/tasks/stream`、再走 `/v1/tasks`,命中的是前者缓存的那份体。回执若铸在同步腿的体里,这条 * **确实产了帧**的 run 在非流式面上就成了「键缺席」,而 present-iff 的读法会把它读成「这台引擎不给 * 回执」。挂在 outcome 上 = 它是**这条 run 的属性**(哪条腿跑的都一样),由各腿自己的应答构造器在 * 送出那一刻按自己的 wire 形挂。拆缓存键也能消歧,但那会让换腿的重试**重复计费** —— 比缺一个键坏。 * * 🔴 **租户面安全(别在复审里当成漏)**:缓存的那份回执是按**第一位调用方**的 `explicitOperatorOk` 投的, * 而键本身就按 `principal` 分区(`scopedIdempotencyKey`)⇒ 命中它的只可能是**同一个** principal,同一份 * 部署 operator 名单下求值恒同 ⇒ operator 面不可能经重放落到租户手里。跨租户同 key 是**缓存未命中** * (各自重跑、各自归账),这一位从 BL-3 起就是这么设计的。 * * 对照:同一条 200 体上的 `notice` 仍铸在 `body` 里,因为它说的是**本次请求**带没带 verify/cascade * (重放腿在 `prepareSpec` 之前就答了,结构上不知道这件事);回执说的是**那条 run**。两者的归属不同, * 不是可以统一的两处写法。 */ export interface SubmitOutcome { status: number; body: unknown; /** S-528:本条 run 的起手接线回执(present-iff 引擎真产了 `wiring_manifest` 帧)。 */ wiringManifest?: WiringManifestReceipt; } //# sourceMappingURL=idempotency.d.ts.map