| M1-20/1 |
2026-10-12 |
Fingerprint (request_sha256): SHA-256 over the upper-case method, a line feed, r.URL.EscapedPath() (with /api/v1, without query), a line feed and the canonical body: the RFC 8785 form when the body is one JSON value without a duplicate member (platform/jsoncanon over github.com/gowebpki/jcs v1.0.2, Apache-2.0), the raw bytes otherwise, empty without a body. Recorded in 06 "Idempotency" point 6. |
04 and 03 name "method, path and canonical body"; RFC 8785 makes member order, whitespace and escapes irrelevant, so a client may re-serialise its outbox entry. The raw bytes keep a malformed body comparable, and StageBody answers it with a final 400 that is stored under the same fingerprint. |
| M1-20/2 |
2026-10-12 |
The header has no effect on an operation without the parameter IdempotencyKey and for the anonymous actor; the request runs as without it. Where it acts, a value that is not one UUIDv7 is 400 validation_failed (#header/Idempotency-Key, invalid_format) and nothing is stored. Recorded in 06 "Idempotency" point 6. |
idempotency_keys.principal_id references principals, and no public operation declares the parameter; storing per anonymous client would key on nothing stable. |
| M1-20/3 |
2026-10-12 |
jsoncanon.Canonicalize refuses an integer literal (no fraction, no exponent) that a double cannot hold exactly; the fingerprint then uses the raw bytes. Literals with a fraction or an exponent are rounded as RFC 8785 says. |
RFC 8785 rounds every number to a double, so 2^64 - 1 and 2^64 - 2 written out as integers would share one fingerprint and the second request would get the first one's response instead of 422. The API carries no such integer (ADR 0053), so the rule costs nothing for valid requests. |
| M1-20/4 |
2026-10-12 |
The stage runs the rest of the chain in the outermost unit of work without txn.RightsChange. A keyed use case that needs RightsChange (M1-48 createGrant, revokeGrant, addGroupMember; M1-52 to M1-54) gets the internal error of platform/pg for a nested RightsChange until the ticket that builds the first such handler resolves it. Recommended there: the route metadata marks rights-changing operations and the stage opens its unit with RightsChange, or platform/pg lets a nested RightsChange run the rights begin hook when the unit has written nothing but the key row (the key row is the first and only lock of idempotency_keys in every unit, so taking permission_version after it cannot close a cycle). |
02 "Idempotency in the Unit of Work" makes the stage open the unit, and 02 "Flow" lets only the outermost unit declare RightsChange; the affected principals are known only inside the use case. Changing kernel/txn or platform/pg (M1-13) is outside the scope of this ticket, and no handler with RightsChange exists yet (api/operations.yaml enables no operation). |
| M1-20/5 |
2026-10-12 |
A deadlock or lock timeout (SQLSTATE 40P01 or 55P03, read through SQLState()) that reaches WriteProblem inside an attempt of the stage ends the attempt with that error, so the unit of work of the stage retries it. |
Inside the stage the use case's unit of work joins the stage's one and returns the database error to its handler, which writes a problem; without this the retry of 02 "Lock order" would be lost for every keyed operation. Each attempt reads the body again and starts with an empty buffer (TestRetry_ReinvokesWithFreshBuffer, TestRetry_HandlerDeadlockReachesTheUnitOfWork). |
| M1-20/6 |
2026-10-12 |
A keyed operation with a file body (multipart or octet-stream) answers 500 internal_error; a keyed 2xx whose body is not JSON rolls back and answers 500; a final error without a JSON body is sent but not stored. |
The generator refuses all three forms for the contract (api/routes/gen), so they can only come from a hand-built operation or a handler bug; failing closed never hashes an upload in memory and never stores a body that jsonb cannot hold. |
| M1-20/7 |
2026-10-12 |
A stored success that carried a one-time secret is recognised by its body {"one_time_secret": true} with a 2xx status; its replay is 409 conflict, reason: one_time_secret, hint: create a new one, Idempotent-Replay: true. |
The brief fixes the placeholder; the table has no column for the mark, and a contract response never consists of exactly this member. |
| M1-20/8 |
2026-10-12 |
Generator checks for every keyed operation of openapi.yaml, enabled or not: no query parameter, request body absent or application/json, every 2xx with application/json or no content, and a 2xx with ETag has a required integer member version (also through allOf). |
Brief "Generator checks"; checking all operations now keeps a later handler ticket from enabling an operation whose replay cannot be rebuilt (TestRealSpec_KeyedOperationsReplayable). |
| M1-20/9 |
2026-10-12 |
Built on feat/M1-20-idempotency-keys from int/m1-wave6 (785916c) in its own worktree; not merged locally, not pushed; the PR lane ports it. The migration takes the next free number of the base, 00007. |
The launcher's task overrides the brief's "merge locally into the base branch"; several tickets run in parallel, and the integration renumbers migrations if another ticket took the number (ADR 0007 rule 4). |
| M1-20/10 |
2026-10-12 |
The method of kernel/idempotency.Store that claims a key is Claim, not Begin as in the brief's sketch; the outgoing port of sync names its insert Insert. |
The gate rule bothy-go-tx-only-in-platform-pg (.semgrep/) refuses every $X.Begin(...) outside platform/pg, because a transaction begun elsewhere skips the phases; the claim begins no transaction, and a gate is not lowered for a name. |
| M1-20/11 |
2026-10-12 |
A keyed 2xx body runs through SanitizeJSON before Store.Complete: the stored body is the one the response writer sends (null members removed), and a body it would refuse (a null element, an integer beyond 2^53 - 1, not JSON) rolls the unit back and answers 500. |
Otherwise the effect would commit while the client gets 500 from the response writer of StageRoute, and every replay would answer 500 as well (TestRefusedSuccess_RollsBack). |
| M1-20/12 |
2026-10-12 |
A keyed 2xx without a check of the route's action in the check log (the test of routeWriter.proven, 02 9.3) rolls the unit back and is not stored; the response writer answers it with 500. The generator checks of M1-20/8 also cover a 2XX range response. |
Red team, first round, Major 1: the effect committed and the row was stored while the client got 500, and the retry then received the held-back body as a replay, which 06 "Idempotency" point 4 allows only for an answer the client was already allowed to get (TestUnprovenSuccess_RollsBack). The range case was a Minor of the same round (TestGenerate_RejectsKeyedOperationWithNonJSONRangeResponse). |
| M1-20/13 |
2026-10-13 |
Port onto main: the chapter references name the English section titles (02 "Idempotency in the Unit of Work", "Lock order", "Rules for jobs", "Job catalogue", 04 "All other writes", 06 "Idempotency"), jsoncanon_test.go is listed in tools/docs/language-allow.txt, and the migration keeps 00007 with a checksum over the English comments. |
The sorting example of RFC 8785 section 3.2.3 (retrieved 2026-10-13) has a key with U+00F6, which the language gate refuses even escaped; the test keeps the published vector byte for byte. |
| M1-20/14 |
2026-10-14 |
sonar.coverage.exclusions also leaves the test helper packages of the platform layer, services/server/internal/platform/*/*test/ (httpservertest, pgtest, jobstest), out of the coverage measure, as M3-01/20 does for the test helpers of the modules; sync/app gets unit tests for the idempotency store and the cleanup. |
The port lands after M3-01, and the analysis counted the new harness code of httpservertest as uncovered new code, although only tests of other packages call it; the store and the cleanup were covered only through the tests of other packages. |