tests/test.sh exit code (0 → resolved); the classification below is post-hoc and cannot change it./home/user/instruction.md
1 # Ticket: Implement the Fenced Idempotency Protocol for the charges API
2
3 ## Context
4
5 `payments-api` is a small Express service (TypeScript, Node 20) exposing
6 `POST /v1/charges`. It sits behind a load balancer where clients retry
7 aggressively, and where a client may also *deliberately re-issue* an operation
8 it knows it wants to run again. A naive retry today creates a **duplicate
9 charge**.
10
11 We are adopting a house protocol called the **Fenced Idempotency Protocol
12 (FIP/1)**, specified in full below. Implement it exactly as specified.
13
14 The project ships every supporting piece complete , an `IdempotencyStore`
15 interface with an in-memory implementation (atomic `get` / `compareAndSet` /
16 `delete`), a response-capture helper, option handling, the charges route, the
17 clock abstraction, and the app wiring. **Two files are unimplemented stubs and
18 are yours to write:**
19
20 src/lib/fingerprint.ts -> export function computeFingerprint(...)
21 src/middleware/idempotency.ts -> export function idempotency(options)
22
23 `idempotency(options)` returns an Express `RequestHandler`. Keep the public
24 exports (`idempotency`, `REPLAY_HEADER`, `computeFingerprint`). You do not need
25 to modify any other file.
26
27 ---
28
29 ## Definitions
30
31 - **Key** , the value of the `Idempotency-Key` request header (configurable via
32 `options.keyHeaderName`; compared case-insensitively as Express lower-cases
33 header names). A key whose value is empty or only whitespace counts as
34 **blank**.
35 - **Fence** , the value of the `Idempotency-Fence` request header (configurable
36 via `options.fenceHeaderName`). A **valid fence** is a canonical, non-negative,
37 base-10 integer: it matches `^(0|[1-9][0-9]*)$` (so `"0"`, `"1"`, `"42"` are
38 valid; `""`, `"-1"`, `"01"`, `"007"`, `"1.0"`, `"+1"`, `"1e3"`, `"abc"` are
39 not) and is a safe integer. The fence is a monotonic sequence number the
40 client assigns to an operation.
41 - **Fingerprint** , a string derived from the request that binds a key to the
42 *shape* of the request first seen under it. See **Fingerprint** below.
43 - **In scope** , a request whose method is in `options.methods` (default
44 `POST`, `PUT`, `PATCH`, `DELETE`). Any other method is passed straight through
45 with `next()` and must never touch the store, require headers, or be
46 fingerprinted.
47 - **Side effect** , running the downstream handler exactly once (`next()`),
48 which is what creates a charge.
49 - **High-water fence** , for a given key, the largest fence value ever observed
50 under it (across every attempt, including superseded, released, and
51 fence-rejected ones). It never decreases while a record for the key is live.
52
53 Error responses are JSON of the form `{ "error": <code>, "message": <string> }`
54 (the provided `apiError` helper produces exactly this). Only the `error` code
55 and HTTP status are asserted; the `message` is free text.
56
57 ---
58
59 ## Behavioural contract (this is the spec)
60
61 Process an **in-scope** request in this order. The first matching rule wins.
62
63 ### 1. Header validation (in this order)
64
65 1. If the **key** is missing or blank → respond **`400`** with error
66 `idempotency_key_required`. Do not run the handler.
67 2. Otherwise, if the **fence** is missing or not a valid fence → respond
68 **`428`** with error `idempotency_fence_required`. Do not run the handler.
69
70 (Key is validated before fence.)
71
72 ### 2. Look up the live record for the key
73
74 A record's lifetime is fixed when its fence is **acquired**: it **expires**
75 `options.ttlMs` after the moment of acquisition (using `options.clock.now()`),
76 and settling the attempt (completing or releasing it) does **not** change that
77 expiry. A request whose record has expired is treated as if no record exists. A
78 subsequent acquire (a fresh key, a re-run after release, or a higher fence)
79 starts a new lifetime from its own acquisition time. Let `F` be this request's
80 fence.
81
82 **If there is no live record:** acquire the key at fence `F`, run the handler
83 (one side effect), and remember the request's fingerprint. The high-water fence
84 becomes `F`. Settle when the response finishes (see **Settling**).
85
86 **If there is a live record**, let `M` be its high-water fence and apply
87 **fence precedence first**:
88
89 - **`F < M` (stale fence).** This is a retry of an attempt that has been
90 superseded by a higher fence. Respond **`409`** with error
91 `idempotency_fence_stale`, and set the response header
92 `Idempotency-Fence-Current` to `M` (as a decimal string). Do not run the
93 handler.
94
95 - **`F === M` (current fence).** Compare the record's state:
96 - If the record is **still running** (an attempt at this fence is in
97 progress) and the request's fingerprint **matches** the running attempt →
98 respond **`409`** with error `idempotency_in_flight`. Do not run the handler.
99 - If the record is **still running** and the fingerprint **does not match** →
100 respond **`409`** with error `idempotency_fingerprint_mismatch`. Do not run
101 the handler.
102 - If the record is **finished and cached** (a completed attempt) and the
103 fingerprint **matches** → **replay** it (see **Replay**). Do not run the
104 handler.
105 - If the record is **finished and cached** and the fingerprint **does not
106 match** → respond **`409`** with error `idempotency_fingerprint_mismatch`.
107 Do not run the handler.
108 - If the record was **released** (a previous attempt at this fence failed
109 transiently , see **Settling**) → acquire the key again at fence `F`, run
110 the handler (a new side effect), and remember the new fingerprint. The
111 high-water fence stays `M` (`=F`).
112
113 - **`F > M` (advancing fence).** A strictly higher fence **supersedes** whatever
114 is currently held , *regardless* of the held record's state or fingerprint
115 (running, completed, released; matching or not). Acquire the key at fence `F`,
116 run the handler (a new side effect), and remember the new fingerprint. The
117 high-water fence becomes `F`. Any attempt that was still running at the old
118 fence is abandoned: when it finishes, its result must be **discarded** (it
119 must not become the cached/replayable response, and it must not delete or
120 alter the superseding attempt's record).
121
122 ### 3. Settling (after an acquired attempt's response finishes)
123
124 When a handler you ran produces its final response, settle the record for that
125 attempt , **but only if your attempt still owns the key at its fence** (if a
126 higher fence superseded you in the meantime, discard: do nothing). Settle based
127 on the **final HTTP status code**:
128
129 - **Exactly `503`** → **release**: the attempt is treated as transiently failed.
130 A later request at the *same* fence may run the handler again (see the
131 "released" case above). Releasing must **preserve the high-water fence** (a
132 later lower fence is still stale). Do not cache a `503` for replay.
133 - **Any other status, including `500`, `502`, and `504`** → **cache** the
134 response (status, headers, body) so a later request at the same fence with a
135 matching fingerprint replays it.
136 - If the connection is **aborted** before the response finishes → **release**
137 (as for `503`).
138
139 ### 4. Replay
140
141 To replay a cached response: write its stored status code, its stored headers,
142 and its stored body verbatim, and additionally set the response header
143 `Idempotency-Replay` to the **fence value** of the cached attempt (a decimal
144 string , *not* the literal `"true"`). A freshly executed (non-replayed) response
145 must **not** carry an `Idempotency-Replay` header.
146
147 ### Invariants
148
149 - A request that is *not in scope* is forwarded unchanged and never touches the
150 store.
151 - The handler runs **at most once per acquired attempt**. Two identical
152 concurrent requests with the same key **and** the same fence produce exactly
153 one side effect; the loser gets `409 idempotency_in_flight`.
154 - Across **different fences** there is **no de-duplication**: a higher fence
155 always re-runs the handler (new side effect), even with a byte-identical body.
156 - This protocol **never** responds `422`, and never uses the
157 `idempotent-replayed` header. Conflicts are `409` (with the specific `error`
158 codes above); a stale fence is `409`; a missing/invalid fence is `428`.
159
160 ---
161
162 ## Fingerprint
163
164 `computeFingerprint({ method, path, body })` returns a **lowercase hexadecimal**
165 string. It must be deterministic and induce exactly these equivalences (two
166 requests share a fingerprint **iff** all of the following agree):
167
168 - the **method**, upper-cased; and
169 - the **path** (`req.path`, excluding any query string); and
170 - the **body**, under this canonicalization:
171 - **Object key order is irrelevant** (`{a:1,b:2}` ≡ `{b:2,a:1}`).
172 - **Keys whose name begins with an underscore (`_`) are excluded entirely**,
173 recursively, at every object depth. They are client-side annotations and do
174 not affect the fingerprint (`{amount:5,_trace:"a"}` ≡ `{amount:5,_trace:"b"}`
175 ≡ `{amount:5}`).
176 - **A string that is a canonical decimal integer is treated as the equal
177 integer.** "Canonical decimal integer" means it matches
178 `^-?(0|[1-9][0-9]*)$` and is a safe integer (`"100"` ≡ `100`, `"-5"` ≡ `-5`,
179 `"0"` ≡ `0`). Strings that are *not* canonical integers are left as strings
180 and are therefore **not** equal to any number (`"007"`, `"1.0"`, `"+1"`,
181 `" 1"`, `"01"`, `"1.5"` stay strings).
182 - **Arrays are order-sensitive** (`[1,2,3]` ≢ `[3,2,1]`); each element is
183 canonicalized.
184 - `null`, booleans, and numbers are distinct from each other and from their
185 string spellings (`null` ≢ absent ≢ `0` ≢ `""`; `true` ≢ `1` ≢ `"true"`).
186
187 ---
188
189 ## Definition of done
190
191 - `npm run typecheck` is clean.
192 - The behaviour above is fully implemented in `src/lib/fingerprint.ts` and
193 `src/middleware/idempotency.ts`. A hidden suite grades it against an
194 independent reference (correctness, all edges, randomized cross-checks, and
195 the concurrency invariants). The trivial `test/smoke.test.ts` that ships with
196 the project is *not* the grader.
197 - No changes to the store, the route, the response-capture helper, the option
198 resolver, the clock, or the error helper are required.
199
200 ## Running locally
201
202 ```bash
203 npm install # already done in the provided environment
204 npm run typecheck
205 npm test # runs the shipped smoke test only
206 ```
207find src test -type f && echo "---PACKAGE---" && cat package.json
test/smoke.test.ts
src/app.ts
src/server.ts
src/middleware/config.ts
src/middleware/idempotency.ts
src/middleware/responseCapture.ts
src/store/memoryStore.ts
src/store/types.ts
src/lib/clock.ts
src/lib/errors.ts
src/lib/fingerprint.ts
src/routes/charges.ts
---PACKAGE---
{
"name": "payments-api",
"version": "1.2.0",
"private": true,
"description": "Internal payments service exposing a charge-creation API with idempotent writes.",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"dev": "tsx src/server.ts"
},
"dependencies": {
"express": "4.19.2"
},
"devDependencies": {
"@types/express": "4.17.21",
"@types/node": "20.14.10",
"@types/supertest": "6.0.2",
"supertest": "7.0.0",
"tsx": "4.16.2",
"typescript": "5.5.3",
"vitest": "1.6.0"
}
}/home/user/app/src/middleware/idempotency.ts
1 import type { NextFunction, Request, RequestHandler, Response } from "express";
2 import { apiError } from "../lib/errors.js";
3 import { computeFingerprint } from "../lib/fingerprint.js";
4 import type { IdempotencyOptions } from "./config.js";
5 import { resolveOptions } from "./config.js";
6 import { ResponseCapture } from "./responseCapture.js";
7
8 /**
9 * Response header that flags a replayed response. Its required NAME and VALUE
10 * are specified in `instruction.md`. (The constant value below is a placeholder;
11 * set it to the name required by the contract.)
12 */
13 export const REPLAY_HEADER = "idempotency-replay";
14
15 /**
16 * Express middleware implementing the Fenced Idempotency Protocol (FIP/1).
17 *
18 * The full behavioural contract is specified in `instruction.md`; that document
19 * is the spec. Implement it here.
20 *
21 * Use the provided helpers (`computeFingerprint`, `ResponseCapture`, `apiError`,
22 * `resolveOptions`) and the injected `options.store`. Keep the public exports
23 * (`idempotency`, `REPLAY_HEADER`).
24 */
25 export function idempotency(options: IdempotencyOptions): RequestHandler {
26 const resolved = resolveOptions(options);
27
28 // Referenced so a partial implementation still type-checks; use as needed.
29 void apiError;
30 void computeFingerprint;
31 void ResponseCapture;
32 void REPLAY_HEADER;
33 void resolved;
34
35 return async function idempotencyMiddleware(
36 _req: Request,
37 _res: Response,
38 next: NextFunction,
39 ): Promise<void> {
40 // TODO(idempotency): implement the FIP/1 contract documented in instruction.md.
41 //
42 // This stub leaves the route wired but non-functional so the behavioural
43 // suite fails until the real protocol is implemented here.
44 next(new Error("idempotency middleware not implemented"));
45 };
46 }
47/home/user/app/src/lib/fingerprint.ts
1 import { createHash } from "node:crypto";
2
3 /**
4 * Compute the request fingerprint that binds an idempotency key to the shape of
5 * the request that used it. The fingerprint is a lowercase hex string; its
6 * canonicalization rules are specified in `instruction.md`. Implement them here.
7 *
8 * `createHash` from `node:crypto` is imported for convenience; how (and whether)
9 * you use it is up to you, as long as the output is a lowercase hex string with
10 * the documented equivalence behaviour.
11 *
12 * TODO(fingerprint): implement the canonicalization contract.
13 */
14 export function computeFingerprint(input: {
15 method: string;
16 path: string;
17 body: unknown;
18 }): string {
19 void createHash;
20 void input;
21 throw new Error("computeFingerprint not implemented");
22 }
23/home/user/app/src/store/types.ts
1 /**
2 * Persistence contract for idempotency entries.
3 *
4 * The store is a *dumb* persistence + atomic compare-and-swap layer: it holds
5 * opaque {@link IdempotencyEntry} records keyed by the client-supplied
6 * idempotency key, and exposes exactly enough primitives for the middleware to
7 * implement the protocol. The store knows nothing about fences, fingerprints,
8 * replay, or status codes , all of that policy lives in the middleware. This
9 * keeps the in-memory store used in tests swappable for a Redis/Postgres-backed
10 * store in production without changing request-handling logic.
11 *
12 * You do NOT need to modify the store to complete the task.
13 */
14
15 /** A captured HTTP response, replayed verbatim on a later request. */
16 export interface StoredResponse {
17 statusCode: number;
18 /** Header name (lower-cased) -> value. Hop-by-hop headers are not stored. */
19 headers: Record<string, string>;
20 /** Raw response body as sent to the client. */
21 body: string;
22 }
23
24 /** Lifecycle of a single attempt held under a key. */
25 export type EntryStatus = "in_flight" | "completed" | "released";
26
27 /**
28 * The record stored under one idempotency key.
29 *
30 * The middleware owns the *meaning* of every field; the store only persists and
31 * returns these objects verbatim. A single key holds at most one entry at a
32 * time (a later attempt overwrites an earlier one , see {@link IdempotencyStore}).
33 */
34 export interface IdempotencyEntry {
35 /** The idempotency key this entry belongs to. */
36 key: string;
37 /** Monotonic fence token of the attempt that currently owns the key. */
38 fence: number;
39 /**
40 * The highest fence value ever observed for this key, across every attempt ,
41 * including attempts that were superseded, released, or whose fence was
42 * rejected as stale. Never decreases while the entry is live.
43 */
44 maxFence: number;
45 /** Request fingerprint of the attempt that currently owns the key. */
46 fingerprint: string;
47 /**
48 * Lifecycle state the middleware assigns to this attempt. The store does not
49 * interpret it beyond the `(fence,status)` precondition of
50 * {@link IdempotencyStore.compareAndSet}.
51 */
52 status: EntryStatus;
53 /** Present when the attempt produced a response to be replayed. */
54 response?: StoredResponse;
55 /** Epoch ms at which this entry was first created (initial reserve). */
56 createdAt: number;
57 /** Epoch ms after which this entry is considered expired and swept. */
58 expiresAt: number;
59 }
60
61 export interface IdempotencyStore {
62 /**
63 * Atomically read the live entry for `key`, or `undefined` if none exists or
64 * the existing one has expired (`expiresAt <= now`). Expired entries are
65 * lazily deleted on read.
66 *
67 * This read is consistent with {@link compareAndSet}: there is no `await`
68 * between the read and a subsequent set within the same synchronous turn, so
69 * a read-then-set sequence is atomic with respect to other handlers on Node's
70 * single-threaded event loop (the contract relied on by the tests).
71 */
72 get(key: string, now: number): Promise<IdempotencyEntry | undefined>;
73
74 /**
75 * Atomic compare-and-set. Writes `next` for `key` *iff* the entry currently
76 * stored under `key` is referentially equivalent to `expected` (by the
77 * `(fence,status)` pair and presence , see the in-memory implementation):
78 *
79 * - `expected === undefined` succeeds only if no live entry exists.
80 * - otherwise it succeeds only if the live entry has the same `fence` and
81 * `status` as `expected`.
82 *
83 * Returns `true` on success (the write happened) and `false` if the
84 * precondition failed (a concurrent attempt got there first). The store
85 * performs no `await` between its internal read and write.
86 */
87 compareAndSet(
88 key: string,
89 expected: IdempotencyEntry | undefined,
90 next: IdempotencyEntry,
91 now: number,
92 ): Promise<boolean>;
93
94 /** Unconditionally delete the entry for `key`. */
95 delete(key: string): Promise<void>;
96 }
97/home/user/app/src/store/memoryStore.ts
1 import type { IdempotencyEntry, IdempotencyStore } from "./types.js";
2
3 /**
4 * In-memory {@link IdempotencyStore}.
5 *
6 * Node runs request handlers cooperatively on a single thread, so the
7 * compare-and-set in {@link compareAndSet} is atomic as long as it performs no
8 * `await` between reading and writing the map. This is sufficient for a
9 * single-process deployment and for the test-suite's concurrency scenarios; a
10 * multi-process deployment would supply a Redis-backed implementation of the
11 * same interface.
12 *
13 * This store is intentionally policy-free: it does not interpret fences,
14 * fingerprints, or status beyond what {@link compareAndSet} needs to detect a
15 * lost race. All protocol decisions live in the middleware.
16 */
17 export class MemoryStore implements IdempotencyStore {
18 private readonly entries = new Map<string, IdempotencyEntry>();
19
20 async get(key: string, now: number): Promise<IdempotencyEntry | undefined> {
21 const existing = this.entries.get(key);
22 if (!existing) return undefined;
23 if (existing.expiresAt <= now) {
24 this.entries.delete(key);
25 return undefined;
26 }
27 return clone(existing);
28 }
29
30 async compareAndSet(
31 key: string,
32 expected: IdempotencyEntry | undefined,
33 next: IdempotencyEntry,
34 now: number,
35 ): Promise<boolean> {
36 // NOTE: no awaits between the read and the write below , keep it that way so
37 // the check-and-set stays atomic on the single-threaded event loop.
38 const liveRaw = this.entries.get(key);
39 const live = liveRaw && liveRaw.expiresAt > now ? liveRaw : undefined;
40
41 if (expected === undefined) {
42 if (live !== undefined) return false;
43 } else {
44 if (live === undefined) return false;
45 if (live.fence !== expected.fence || live.status !== expected.status) {
46 return false;
47 }
48 }
49
50 this.entries.set(key, clone(next));
51 return true;
52 }
53
54 async delete(key: string): Promise<void> {
55 this.entries.delete(key);
56 }
57
58 /** Test helper: number of entries currently held (live or not-yet-swept). */
59 size(): number {
60 return this.entries.size;
61 }
62 }
63
64 function clone(entry: IdempotencyEntry): IdempotencyEntry {
65 return {
66 ...entry,
67 response: entry.response
68 ? { ...entry.response, headers: { ...entry.response.headers } }
69 : undefined,
70 };
71 }
72/home/user/app/src/middleware/config.ts
1 import type { Clock } from "../lib/clock.js";
2 import { systemClock } from "../lib/clock.js";
3 import type { IdempotencyStore } from "../store/types.js";
4
5 /** Tunable behaviour for {@link idempotency}. */
6 export interface IdempotencyOptions {
7 store: IdempotencyStore;
8 /** HTTP methods that participate in idempotency. Defaults to mutating verbs. */
9 methods?: readonly string[];
10 /** Lifetime of a stored entry, in milliseconds. Defaults to 24h. */
11 ttlMs?: number;
12 /** Header carrying the client-supplied key. Defaults to `Idempotency-Key`. */
13 keyHeaderName?: string;
14 /** Header carrying the client-supplied fence token. Defaults to `Idempotency-Fence`. */
15 fenceHeaderName?: string;
16 /** Injectable clock; defaults to the system clock. */
17 clock?: Clock;
18 }
19
20 export interface ResolvedIdempotencyOptions {
21 store: IdempotencyStore;
22 methods: ReadonlySet<string>;
23 ttlMs: number;
24 keyHeaderName: string;
25 fenceHeaderName: string;
26 clock: Clock;
27 }
28
29 const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
30 const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
31 const DEFAULT_KEY_HEADER = "idempotency-key";
32 const DEFAULT_FENCE_HEADER = "idempotency-fence";
33
34 export function resolveOptions(options: IdempotencyOptions): ResolvedIdempotencyOptions {
35 const methods = (options.methods ?? DEFAULT_METHODS).map((m) => m.toUpperCase());
36 return {
37 store: options.store,
38 methods: new Set(methods),
39 ttlMs: options.ttlMs ?? DEFAULT_TTL_MS,
40 keyHeaderName: (options.keyHeaderName ?? DEFAULT_KEY_HEADER).toLowerCase(),
41 fenceHeaderName: (options.fenceHeaderName ?? DEFAULT_FENCE_HEADER).toLowerCase(),
42 clock: options.clock ?? systemClock,
43 };
44 }
45/home/user/app/src/middleware/responseCapture.ts
1 import type { Response } from "express";
2 import type { StoredResponse } from "../store/types.js";
3
4 /**
5 * Hop-by-hop and volatile headers that must NOT be captured/replayed.
6 * (Replaying e.g. `Date` or `ETag` from an old response would be incorrect.)
7 */
8 const NON_REPLAYABLE_HEADERS = new Set([
9 "date",
10 "connection",
11 "keep-alive",
12 "transfer-encoding",
13 "content-length",
14 "etag",
15 ]);
16
17 /**
18 * Wrap an Express {@link Response} so the final status, headers, and body can be
19 * captured for later replay, without changing how the route handler writes its
20 * response. Call {@link ResponseCapture.snapshot} after the handler has produced
21 * its response (i.e. on the `finish` event or after `await`-ing the handler).
22 *
23 * This helper is provided complete , the idempotency middleware uses it to
24 * obtain a {@link StoredResponse}.
25 */
26 export class ResponseCapture {
27 private bodyChunks: Buffer[] = [];
28 private finished = false;
29
30 constructor(private readonly res: Response) {
31 const originalWrite = res.write.bind(res) as Response["write"];
32 const originalEnd = res.end.bind(res) as Response["end"];
33
34 res.write = ((chunk: unknown, ...rest: unknown[]): boolean => {
35 this.record(chunk);
36 // @ts-expect-error - passthrough to the original signature
37 return originalWrite(chunk, ...rest);
38 }) as Response["write"];
39
40 res.end = ((chunk: unknown, ...rest: unknown[]): Response => {
41 if (chunk) this.record(chunk);
42 this.finished = true;
43 // @ts-expect-error - passthrough to the original signature
44 return originalEnd(chunk, ...rest);
45 }) as Response["end"];
46 }
47
48 private record(chunk: unknown): void {
49 if (chunk == null) return;
50 if (Buffer.isBuffer(chunk)) {
51 this.bodyChunks.push(chunk);
52 } else if (typeof chunk === "string") {
53 this.bodyChunks.push(Buffer.from(chunk));
54 } else {
55 this.bodyChunks.push(Buffer.from(String(chunk)));
56 }
57 }
58
59 get isFinished(): boolean {
60 return this.finished;
61 }
62
63 /** Build a {@link StoredResponse} from what the handler has written so far. */
64 snapshot(): StoredResponse {
65 const headers: Record<string, string> = {};
66 for (const [name, value] of Object.entries(this.res.getHeaders())) {
67 const lower = name.toLowerCase();
68 if (NON_REPLAYABLE_HEADERS.has(lower)) continue;
69 if (value == null) continue;
70 headers[lower] = Array.isArray(value) ? value.join(", ") : String(value);
71 }
72 return {
73 statusCode: this.res.statusCode,
74 headers,
75 body: Buffer.concat(this.bodyChunks).toString("utf8"),
76 };
77 }
78 }
79/home/user/app/src/lib/errors.ts
1 /**
2 * Problem-detail style error payloads emitted by the API.
3 *
4 * The shape is intentionally small and stable so clients can switch on `error`.
5 */
6 export interface ApiErrorBody {
7 error: string;
8 message: string;
9 }
10
11 export function apiError(error: string, message: string): ApiErrorBody {
12 return { error, message };
13 }
14/home/user/app/src/lib/clock.ts
1 /**
2 * A monotonic-ish wall clock abstraction.
3 *
4 * Injecting the clock keeps TTL / expiry logic deterministic and unit-testable:
5 * tests advance a {@link ManualClock} instead of sleeping in real time.
6 */
7 export interface Clock {
8 /** Current time in epoch milliseconds. */
9 now(): number;
10 }
11
12 export const systemClock: Clock = {
13 now: () => Date.now(),
14 };
15
16 /** A controllable clock for tests. */
17 export class ManualClock implements Clock {
18 constructor(private current = 0) {}
19
20 now(): number {
21 return this.current;
22 }
23
24 /** Advance the clock by `ms` milliseconds. */
25 advance(ms: number): void {
26 this.current += ms;
27 }
28
29 /** Set the clock to an absolute epoch-ms value. */
30 set(ms: number): void {
31 this.current = ms;
32 }
33 }
34/home/user/app/src/routes/charges.ts
1 import type { Request, Response } from "express";
2 import { Router } from "express";
3 import { randomUUID } from "node:crypto";
4 import { apiError } from "../lib/errors.js";
5
6 /**
7 * A deliberately side-effectful "create charge" endpoint.
8 *
9 * Each successful POST mints a brand-new charge id and pushes onto an in-memory
10 * ledger. Idempotency is what prevents a retried POST from creating duplicate
11 * charges , without it, two identical requests would create two charges with
12 * two different ids. The tests assert on exactly this property.
13 */
14
15 export interface Charge {
16 id: string;
17 amount: number;
18 currency: string;
19 description: string | null;
20 createdAt: number;
21 }
22
23 export class ChargeLedger {
24 private readonly charges: Charge[] = [];
25
26 create(input: { amount: number; currency: string; description: string | null }): Charge {
27 const charge: Charge = {
28 id: `ch_${randomUUID().replace(/-/g, "").slice(0, 24)}`,
29 amount: input.amount,
30 currency: input.currency,
31 description: input.description,
32 createdAt: Date.now(),
33 };
34 this.charges.push(charge);
35 return charge;
36 }
37
38 count(): number {
39 return this.charges.length;
40 }
41
42 all(): readonly Charge[] {
43 return this.charges;
44 }
45 }
46
47 export function createChargesRouter(ledger: ChargeLedger): Router {
48 const router = Router();
49
50 router.post("/", (req: Request, res: Response) => {
51 const body = (req.body ?? {}) as Record<string, unknown>;
52 const amount = body.amount;
53 const currency = body.currency;
54
55 if (typeof amount !== "number" || !Number.isInteger(amount) || amount <= 0) {
56 res
57 .status(400)
58 .json(apiError("invalid_amount", "amount must be a positive integer (minor units)"));
59 return;
60 }
61 if (typeof currency !== "string" || currency.length !== 3) {
62 res
63 .status(400)
64 .json(apiError("invalid_currency", "currency must be a 3-letter ISO code"));
65 return;
66 }
67
68 const description =
69 typeof body.description === "string" ? body.description : null;
70
71 const charge = ledger.create({ amount, currency: currency.toLowerCase(), description });
72 res.status(201).json(charge);
73 });
74
75 return router;
76 }
77/home/user/app/src/app.ts
1 import type { Express, NextFunction, Request, Response } from "express";
2 import express from "express";
3 import type { Clock } from "./lib/clock.js";
4 import { systemClock } from "./lib/clock.js";
5 import { apiError } from "./lib/errors.js";
6 import { idempotency } from "./middleware/idempotency.js";
7 import { ChargeLedger, createChargesRouter } from "./routes/charges.js";
8 import { MemoryStore } from "./store/memoryStore.js";
9 import type { IdempotencyStore } from "./store/types.js";
10
11 export interface AppDeps {
12 store?: IdempotencyStore;
13 ledger?: ChargeLedger;
14 clock?: Clock;
15 ttlMs?: number;
16 }
17
18 export interface BuiltApp {
19 app: Express;
20 store: IdempotencyStore;
21 ledger: ChargeLedger;
22 }
23
24 /**
25 * Build the payments API. The idempotency middleware is mounted in front of the
26 * mutating `/v1/charges` routes; read-only routes are not affected.
27 */
28 export function buildApp(deps: AppDeps = {}): BuiltApp {
29 const store = deps.store ?? new MemoryStore();
30 const ledger = deps.ledger ?? new ChargeLedger();
31 const clock = deps.clock ?? systemClock;
32
33 const app = express();
34 app.use(express.json());
35
36 app.get("/health", (_req: Request, res: Response) => {
37 res.status(200).json({ status: "ok", charges: ledger.count() });
38 });
39
40 app.use(
41 "/v1/charges",
42 idempotency({ store, clock, ttlMs: deps.ttlMs }),
43 createChargesRouter(ledger),
44 );
45
46 // Centralised error handler so a thrown handler / failed reservation surfaces
47 // as a clean 500 rather than a hung socket.
48 app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
49 if (res.headersSent) return;
50 const message = err instanceof Error ? err.message : "unexpected error";
51 res.status(500).json(apiError("internal_error", message));
52 });
53
54 return { app, store, ledger };
55 }
56/home/user/app/test/smoke.test.ts
1 import request from "supertest";
2 import { describe, expect, it } from "vitest";
3 import { buildApp } from "../src/app.js";
4 import { REPLAY_HEADER } from "../src/middleware/idempotency.js";
5
6 /**
7 * Trivial smoke test that ships with the project. It only checks that the app
8 * boots and the (idempotency-free) health endpoint responds. The real
9 * behavioural grading lives in a separate, hidden suite. Implement the protocol
10 * described in instruction.md and src/middleware/idempotency.ts.
11 */
12 describe("payments-api smoke", () => {
13 it("boots and serves /health", async () => {
14 const { app } = buildApp();
15 const res = await request(app).get("/health");
16 expect(res.status).toBe(200);
17 expect(res.body.status).toBe("ok");
18 });
19
20 it("exposes the replay header constant", () => {
21 expect(typeof REPLAY_HEADER).toBe("string");
22 expect(REPLAY_HEADER.length).toBeGreaterThan(0);
23 });
24 });
25import { createHash } from "node:crypto";
/**
* Compute the request fingerprint that binds an idempotency key to the shape of
* the request that used it. The fingerprint is a lowercase hex string; its
* canonicalization rules are specified in `instruction.md`.
*
* Two requests share a fingerprint iff their (upper-cased) method, path, and
* canonicalized body all agree. The body canonicalization:
* - object key order is irrelevant;
* - keys whose name begins with `_` are dropped recursively at every depth;
* - a string that is a canonical decimal integer is treated as that integer;
* - arrays are order-sensitive; each element is canonicalized;
* - null, booleans, and numbers are distinct from each other and from their
* string spellings.
*/
export function computeFingerprint(input: {
method: string;
path: string;
body: unknown;
}): string {
const canonical = JSON.stringify([
input.method.toUpperCase(),
input.path,
canonicalize(input.body),
]);
return createHash("sha256").update(canonical).digest("hex");
}
const CANONICAL_INT = /^-?(0|[1-9][0-9]*)$/;
function isCanonicalInt(s: string): boolean {
if (!CANONICAL_INT.test(s)) return false;
return Number.isSafeInteger(Number(s));
}
function canonicalize(value: unknown): unknown {
if (value === null) return null;
const t = typeof value;
if (t === "boolean" || t === "number") return value;
if (t === "string") {
const s = value as string;
// A canonical decimal integer string collapses to the equal integer; any
// other string stays a string (and so never equals a number).
return isCanonicalInt(s) ? Number(s) : s;
}
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (t === "object") {
const obj = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
// Sorting the kept keys makes object key order irrelevant; `_`-prefixed
// keys are excluded entirely at every depth.
for (const key of Object.keys(obj).sort()) {
if (key.startsWith("_")) continue;
out[key] = canonicalize(obj[key]);
}
return out;
}
// undefined / function / symbol , not produced by JSON parsing; normalize.
return null;
}
The file /home/user/app/src/lib/fingerprint.ts has been updated successfully. (file state is current in your context , no need to Read it back)
import type { NextFunction, Request, RequestHandler, Response } from "express";
import { apiError } from "../lib/errors.js";
import { computeFingerprint } from "../lib/fingerprint.js";
import type { IdempotencyOptions, ResolvedIdempotencyOptions } from "./config.js";
import { resolveOptions } from "./config.js";
import { ResponseCapture } from "./responseCapture.js";
import type { IdempotencyEntry } from "../store/types.js";
/**
* Response header set on a replayed response. Its value is the fence of the
* cached attempt (a decimal string), NOT the literal "true".
*/
export const REPLAY_HEADER = "Idempotency-Replay";
/** Response header advertising the current high-water fence on a stale reply. */
const FENCE_CURRENT_HEADER = "Idempotency-Fence-Current";
const VALID_FENCE = /^(0|[1-9][0-9]*)$/;
/**
* Express middleware implementing the Fenced Idempotency Protocol (FIP/1).
* The behavioural contract is specified in `instruction.md`.
*/
export function idempotency(options: IdempotencyOptions): RequestHandler {
const resolved = resolveOptions(options);
return async function idempotencyMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const method = req.method.toUpperCase();
// Out of scope: forward unchanged, never touch the store.
if (!resolved.methods.has(method)) {
next();
return;
}
// 1. Header validation: key first, then fence.
const key = readHeader(req, resolved.keyHeaderName);
if (key === undefined || key.trim() === "") {
res
.status(400)
.json(apiError("idempotency_key_required", "an Idempotency-Key header is required"));
return;
}
const fenceRaw = readHeader(req, resolved.fenceHeaderName);
if (fenceRaw === undefined || !isValidFence(fenceRaw)) {
res
.status(428)
.json(apiError("idempotency_fence_required", "a valid Idempotency-Fence header is required"));
return;
}
const F = Number(fenceRaw);
const fingerprint = computeFingerprint({
method: req.method,
path: req.path,
body: req.body,
});
// 2. Acquire / dispatch loop. The loop only re-iterates when an atomic
// compare-and-set loses a race to a concurrent request.
while (true) {
const now = resolved.clock.now();
const live = await resolved.store.get(key, now);
if (live === undefined) {
const entry = makeEntry(key, F, F, fingerprint, now, resolved.ttlMs);
const won = await resolved.store.compareAndSet(key, undefined, entry, now);
if (!won) continue;
runAndSettle(req, res, next, resolved, entry, key);
return;
}
const M = live.maxFence;
if (F < M) {
// Stale fence: superseded by a higher fence.
res.setHeader(FENCE_CURRENT_HEADER, String(M));
res
.status(409)
.json(apiError("idempotency_fence_stale", "this fence has been superseded"));
return;
}
if (F === M) {
if (live.status === "in_flight") {
const code =
live.fingerprint === fingerprint
? "idempotency_in_flight"
: "idempotency_fingerprint_mismatch";
res.status(409).json(apiError(code, conflictMessage(code)));
return;
}
if (live.status === "completed") {
if (live.fingerprint === fingerprint && live.response) {
replay(res, live);
} else {
res
.status(409)
.json(
apiError(
"idempotency_fingerprint_mismatch",
conflictMessage("idempotency_fingerprint_mismatch"),
),
);
}
return;
}
// released: a previous attempt at this fence failed transiently. A new
// attempt at the same fence may run the handler again. High-water (M)
// is preserved.
const entry = makeEntry(key, F, M, fingerprint, now, resolved.ttlMs);
const won = await resolved.store.compareAndSet(key, live, entry, now);
if (!won) continue;
runAndSettle(req, res, next, resolved, entry, key);
return;
}
// F > M: advancing fence supersedes whatever is held, regardless of its
// state or fingerprint. The old attempt (if running) is abandoned.
const entry = makeEntry(key, F, F, fingerprint, now, resolved.ttlMs);
const won = await resolved.store.compareAndSet(key, live, entry, now);
if (!won) continue;
runAndSettle(req, res, next, resolved, entry, key);
return;
}
};
}
function runAndSettle(
_req: Request,
res: Response,
next: NextFunction,
resolved: ResolvedIdempotencyOptions,
entry: IdempotencyEntry,
key: string,
): void {
const capture = new ResponseCapture(res);
let settled = false;
const settle = (aborted: boolean): void => {
if (settled) return;
settled = true;
const release = aborted || res.statusCode === 503;
const next: IdempotencyEntry = release
? { ...entry, status: "released", response: undefined }
: { ...entry, status: "completed", response: capture.snapshot() };
// Only settle if this attempt still owns the key at its fence. If a higher
// fence superseded us in the meantime the compare-and-set fails and we
// discard the result (do nothing).
void resolved.store.compareAndSet(key, entry, next, resolved.clock.now());
};
res.on("finish", () => settle(false));
res.on("close", () => {
if (!capture.isFinished) settle(true);
});
next();
}
function replay(res: Response, entry: IdempotencyEntry): void {
const stored = entry.response!;
for (const [name, value] of Object.entries(stored.headers)) {
res.setHeader(name, value);
}
res.setHeader(REPLAY_HEADER, String(entry.fence));
res.status(stored.statusCode).end(stored.body);
}
function makeEntry(
key: string,
fence: number,
maxFence: number,
fingerprint: string,
now: number,
ttlMs: number,
): IdempotencyEntry {
return {
key,
fence,
maxFence,
fingerprint,
status: "in_flight",
createdAt: now,
expiresAt: now + ttlMs,
};
}
function readHeader(req: Request, name: string): string | undefined {
const raw = req.headers[name];
if (raw === undefined) return undefined;
return Array.isArray(raw) ? raw[0] : raw;
}
function isValidFence(value: string): boolean {
if (!VALID_FENCE.test(value)) return false;
return Number.isSafeInteger(Number(value));
}
function conflictMessage(code: string): string {
switch (code) {
case "idempotency_in_flight":
return "a request with this key and fence is already in progress";
case "idempotency_fingerprint_mismatch":
return "this key and fence were used with a different request";
default:
return "idempotency conflict";
}
}
The file /home/user/app/src/middleware/idempotency.ts has been updated successfully. (file state is current in your context , no need to Read it back)
/home/user/app/src/middleware/idempotency.ts
const release = aborted || res.statusCode === 503;
const next: IdempotencyEntry = release
? { ...entry, status: "released", response: undefined }
: { ...entry, status: "completed", response: capture.snapshot() };
// Only settle if this attempt still owns the key at its fence. If a higher
// fence superseded us in the meantime the compare-and-set fails and we
// discard the result (do nothing).
void resolved.store.compareAndSet(key, entry, next, resolved.clock.now()); const release = aborted || res.statusCode === 503;
const settledEntry: IdempotencyEntry = release
? { ...entry, status: "released", response: undefined }
: { ...entry, status: "completed", response: capture.snapshot() };
// Only settle if this attempt still owns the key at its fence. If a higher
// fence superseded us in the meantime the compare-and-set fails and we
// discard the result (do nothing).
void resolved.store.compareAndSet(key, entry, settledEntry, resolved.clock.now());The file /home/user/app/src/middleware/idempotency.ts has been updated successfully. (file state is current in your context , no need to Read it back)
npm run typecheck 2>&1 | tail -20 && echo "---TEST---" && npm test 2>&1 | tail -20
> payments-api@1.2.0 typecheck
> tsc -p tsconfig.json --noEmit
---TEST---
> payments-api@1.2.0 test
> vitest run
RUN v1.6.0 /home/user/app
✓ test/smoke.test.ts (2 tests) 22ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 01:18:30
Duration 561ms (transform 114ms, setup 0ms, collect 288ms, tests 22ms, environment 0ms, prepare 114ms)/home/user/app/src/middleware/idempotency.ts
130 runAndSettle(req, res, next, resolved, entry, key);
131 return;
132 }
133 };
134 }
135
136 function runAndSettle(
137 _req: Request,
138 res: Response,
139 next: NextFunction,
140 resolved: ResolvedIdempotencyOptions,
141 entry: IdempotencyEntry,
142 key: string,
143 ): void {
144 const capture = new ResponseCapture(res);
145 let settled = false;
146
147 const settle = (aborted: boolean): void => {
148 if (settled) return;
149 settled = true;
150
151 const release = aborted || res.statusCode === 503;
152 const settledEntry: IdempotencyEntry = release
153 ? { ...entry, status: "released", response: undefined }
154 : { ...entry, status: "completed", response: capture.snapshot() };
155
156 // Only settle if this attempt still owns the key at its fence. If a higher
157 // fence superseded us in the meantime the compare-and-set fails and we
158 // discard the result (do nothing).
159 void resolved.store.compareAndSet(key, entry, settledEntry, resolved.clock.now());
160 };
161
162 res.on("finish", () => settle(false));
163 res.on("close", () => {
164 if (!capture.isFinished) settle(true);
165 });
166
167 next();
168 }
169
170 function replay(res: Response, entry: IdempotencyEntry): void {
171 const stored = entry.response!;
172 for (const [name, value] of Object.entries(stored.headers)) {
173 res.setHeader(name, value);
174 }
175 res.setHeader(REPLAY_HEADER, String(entry.fence));
176 res.status(stored.statusCode).end(stored.body);
177 }
178
179 function makeEntry(No file changes, the agent left the workspace untouched.
=== staging hidden test suite into /home/user/app/test ===
=== running hidden test suite in /home/user/app ===
RUN v1.6.0 /home/user/app
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > passes non-mutating methods straight through (no key/fence needed)
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > missing key -> 400 idempotency_key_required (key checked before fence)
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > blank/whitespace key -> 400 idempotency_key_required
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > missing fence -> 428 idempotency_fence_required
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required -1
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required 1.0
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required 01
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required 007
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required +1
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required 1e3
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required abc
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required 0x1
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required 1,2
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > invalid fence %p -> 428 idempotency_fence_required 0b1
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > canonical non-negative fence %p is accepted (executes) 0
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > canonical non-negative fence %p is accepted (executes) 1
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > canonical non-negative fence %p is accepted (executes) 42
✓ test/idempotency.fip.test.ts > FIP/1 , header requirements & scope > canonical non-negative fence %p is accepted (executes) 9007199254740991
✓ test/idempotency.fip.test.ts > FIP/1 , basic execute / replay > first request executes and is NOT marked as a replay
× test/idempotency.fip.test.ts > FIP/1 , basic execute / replay > same key+fence retry replays verbatim and sets Idempotency-Replay:<fence>
→ expected undefined to be '5' // Object.is equality
× test/idempotency.fip.test.ts > FIP/1 , basic execute / replay > replay reproduces a handler-produced 4xx verbatim
→ expected undefined to be '1' // Object.is equality
× test/idempotency.fip.test.ts > FIP/1 , basic execute / replay > charges API: identical retry replays the same charge id, single side effect
→ expected undefined to be '1' // Object.is equality
✓ test/idempotency.fip.test.ts > FIP/1 , fence precedence (the core trap) > a strictly-LOWER fence after a completed attempt -> 409 idempotency_fence_stale + current header
✓ test/idempotency.fip.test.ts > FIP/1 , fence precedence (the core trap) > a strictly-HIGHER fence with the SAME body RE-EXECUTES (does NOT replay)
✓ test/idempotency.fip.test.ts > FIP/1 , fence precedence (the core trap) > charges: higher fence, same body => SECOND charge created (no dedup across fences)
✓ test/idempotency.fip.test.ts > FIP/1 , fence precedence (the core trap) > a higher fence with a DIFFERENT body still re-executes (fence beats fingerprint)
✓ test/idempotency.fip.test.ts > FIP/1 , fence precedence (the core trap) > after a higher fence supersedes, the previous (now-stale) fence replays nothing
✓ test/idempotency.fip.test.ts > FIP/1 , fingerprint mismatch is 409 (never 422) > same fence, different body after completion -> 409 idempotency_fingerprint_mismatch
✓ test/idempotency.fip.test.ts > FIP/1 , fingerprint mismatch is 409 (never 422) > no 422 is ever produced by the middleware for key reuse
× test/idempotency.fip.test.ts > FIP/1 , bespoke fingerprint canonicalization > underscore-prefixed keys are excluded => same fingerprint => replay
→ expected undefined to be '1' // Object.is equality
× test/idempotency.fip.test.ts > FIP/1 , bespoke fingerprint canonicalization > integer-as-string equals integer => same fingerprint => replay
→ expected undefined to be '1' // Object.is equality
✓ test/idempotency.fip.test.ts > FIP/1 , bespoke fingerprint canonicalization > NON-canonical numeric strings are NOT normalized ("007" != 7) => mismatch
✓ test/idempotency.fip.test.ts > FIP/1 , bespoke fingerprint canonicalization > key ordering is irrelevant (canonical) => replay
✓ test/idempotency.fip.test.ts > FIP/1 , bespoke fingerprint canonicalization > array order IS significant => different fingerprint => mismatch
✓ test/idempotency.fip.test.ts > FIP/1 , bespoke fingerprint canonicalization > path is part of the fingerprint (same key+fence, different path => mismatch)
× test/idempotency.fip.test.ts > FIP/1 , bespoke 5xx policy (only 503 releases) > a handler 500 is CACHED and replayed at the same fence
→ expected undefined to be '1' // Object.is equality
× test/idempotency.fip.test.ts > FIP/1 , bespoke 5xx policy (only 503 releases) > status 500 is cached (replayed, not retried)
→ expected undefined to be '1' // Object.is equality
× test/idempotency.fip.test.ts > FIP/1 , bespoke 5xx policy (only 503 releases) > status 502 is cached (replayed, not retried)
→ expected undefined to be '1' // Object.is equality
× test/idempotency.fip.test.ts > FIP/1 , bespoke 5xx policy (only 503 releases) > status 504 is cached (replayed, not retried)
→ expected undefined to be '1' // Object.is equality
✓ test/idempotency.fip.test.ts > FIP/1 , bespoke 5xx policy (only 503 releases) > a handler 503 is RELEASED: a same-fence retry RE-EXECUTES
✓ test/idempotency.fip.test.ts > FIP/1 , bespoke 5xx policy (only 503 releases) > 503 release preserves the fence high-water mark (a lower fence is still stale)
× test/idempotency.fip.test.ts > FIP/1 , bespoke 5xx policy (only 503 releases) > a thrown handler (=> 500 via error handler) is CACHED, not released
→ expected undefined to be '1' // Object.is equality
✓ test/idempotency.fip.test.ts > FIP/1 , TTL (sliding on every transition) > an expired completed entry behaves as brand-new (re-executes at any fence)
× test/idempotency.fip.test.ts > FIP/1 , TTL (sliding on every transition) > within TTL the completed entry still replays
→ expected undefined to be '1' // Object.is equality
× test/idempotency.fip.test.ts > FIP/1 , TTL (sliding on every transition) > a record's lifetime is measured from when its fence was ACQUIRED
→ expected undefined to be '2' // Object.is equality
✓ test/idempotency.fip.test.ts > FIP/1 , concurrency > two concurrent same-key+fence requests: exactly one executes, other gets 409 in_flight
× test/idempotency.fip.test.ts > FIP/1 , concurrency > a higher fence arriving while a lower fence is in-flight SUPERSEDES (no 409), and supersedes its result
→ expected undefined to be '2' // Object.is equality
✓ test/idempotency.fip.test.ts > FIP/1 , added edge cases (hardening) > a higher fence supersedes a RELEASED entry and re-executes
× test/idempotency.fip.test.ts > FIP/1 , added edge cases (hardening) > a non-201 success (200) is cached and replayed
→ expected undefined to be '1' // Object.is equality
✓ test/idempotency.fip.test.ts > FIP/1 , added edge cases (hardening) > fingerprint: whitespace in a numeric string is NOT normalized (mismatch)
× test/idempotency.fip.test.ts > FIP/1 , added edge cases (hardening) > fingerprint: underscore keys excluded recursively inside arrays (replay)
→ expected undefined to be '1' // Object.is equality
× test/idempotency.fip.test.ts > FIP/1 , added edge cases (hardening) > TTL is fixed at acquire: live just before expiresAt, expired at it
→ expected undefined to be '1' // Object.is equality
✓ test/random.crosscheck.test.ts > FIP/1 , randomized cross-check vs independent oracle > agrees with the oracle over many random sequences 1490ms
✓ test/fingerprint.test.ts > computeFingerprint , output shape > is a lowercase hex string
✓ test/fingerprint.test.ts > computeFingerprint , output shape > is deterministic
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > object key order does not matter
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > underscore-prefixed keys are excluded
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > underscore exclusion is recursive (nested objects too)
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > canonical integer strings normalize to integers
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > non-canonical numeric strings are NOT normalized
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > float numbers are not equal to their non-canonical string forms
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > arrays preserve order
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > method and path participate
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > null is distinct from absent and from 0/""
✓ test/fingerprint.test.ts > computeFingerprint , equivalence classes match the spec > boolean is distinct from numeric/string forms
⎯⎯⎯⎯⎯⎯ Failed Tests 16 ⎯⎯⎯⎯⎯⎯⎯
FAIL test/idempotency.fip.test.ts > FIP/1 , basic execute / replay > same key+fence retry replays verbatim and sets Idempotency-Replay:<fence>
AssertionError: expected undefined to be '5' // Object.is equality
- Expected:
"5"
+ Received:
undefined
❯ test/idempotency.fip.test.ts:174:36
172| expect(second.status).toBe(201);
173| expect(second.body.id).toBe(first.body.id); // same captured respo…
174| expect(second.headers[REPLAY]).toBe("5"); // fence value, not "tru…
| ^
175| expect(second.headers["idempotent-replayed"]).toBeUndefined();
176| expect(h.executions()).toBe(1);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/16]⎯
FAIL test/idempotency.fip.test.ts > FIP/1 , basic execute / replay > replay reproduces a handler-produced 4xx verbatim
AssertionError: expected undefined to be '1' // Object.is equality
- Expected:
"1"
+ Received:
undefined
❯ test/idempotency.fip.test.ts:187:36
185| expect(second.status).toBe(422);
186| expect(second.body.error).toBe("domain_rejected");
187| expect(second.headers[REPLAY]).toBe("1");
| ^
188| expect(h.executions()).toBe(1);
189| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/16]⎯
FAIL test/idempotency.fip.test.ts > FIP/1 , basic execute / replay > charges API: identical retry replays the same charge id, single side effect
AssertionError: expected undefined to be '1' // Object.is equality
- Expected:
"1"
+ Received:
undefined
❯ test/idempotency.fip.test.ts:197:36
195| expect(first.status).toBe(201);
196| expect(second.body.id).toBe(first.body.id);
197| expect(second.headers[REPLAY]).toBe("1");
| ^
198| expect(built.ledger.count()).toBe(1);
199|
… (truncated at 12,000 chars, full verifier log is in the trial artifacts)Reproduce this trial: git checkout 2f94510 && PYTHONPATH=src python3 scripts/build_site.py , then open trial/trial_a1f6e47b615f4f33. Re-running the agent live requires EVAL_PLATFORM_ENABLE_OAUTH_SMOKE=1 and is non-deterministic.
Trial trial_a1f6e47b615f4f33 · verifier authoritative; classifier explanatory.