spec(SPEC-011): idempotent submission for the ingress endpoint #8

Open
anuna-02 wants to merge 8 commits from spec/SPEC-011-idempotent-submission into main
anuna-02 commented 2026-05-17 02:08:17 +00:00 (Migrated from codeberg.org)

Summary

Wires the standard HTTP Idempotency-Key header into a working dedup path on POST /ingress/v1/messages. Pre-existing state: the header was read at cbcl-ingress-handler.lfe:39 but never threaded to the dispatcher, so producer retries across transient network failures caused the same work to be dispatched twice. This PR closes that gap.

What's in this PR

  • SPEC-011 (draft, v0.2.0) — focused spec for idempotent submission. See specs/SPEC-011-idempotent-submission.md. Carved out of the broader (and parked) SPEC-010; SPEC-010 is included as a record of the original design exploration and marked status: superseded.
  • IMPL-011 — implementation across cbcl-storage, cbcl-dispatcher, cbcl-ingress-handler, and cbcl-agent-ws.
  • Planplans/idempotent-submission-impl.spl for hence-driven coordination.
  • Tests — 35 new tests across two new test modules.
  • Docsdocs/HTTP-API.md gains a full Idempotent Submission section with a runnable Python recipe; docs/status.md updated.

Semantics (settled across the review iterations)

  • Scope: ask submissions only. Meta requests (teach/query/subscribe) are content-idempotent at the protocol layer and skip the dedup machinery; any Idempotency-Key header on a meta request is ignored and logged at debug level.
  • Key namespace: globally unique with originating-principal owner tracking. Same-principal retry → 202 with idempotent_replay: true and the receipt's actual current state. Cross-principal collision → 409 with a byte-exact constant body that discloses nothing.
  • Validation: 1–255 bytes, printable ASCII (0x21–0x7E only). Invalid → 400 with stable problem+json body.
  • Retention: under V1 (in-memory ETS), keys live as long as the receipt log — which is until router restart. The 24h Cache-Control hint planned in v0.1.0 has been explicitly dropped from V1 (NFR-002 v0.2.0); it returns once SPEC-041 lands durable storage. Producers MUST handle restart-window resubmission as fresh.
  • 503 claim-retry-exhausted: distinct from 409. Reserved for future retention-pressure paths; today unreachable under V1.
  • dispatched_to on the receipt row: persisted at append time, survives cbcl-inflight clearance on terminal, so replays of completed receipts can report the handling agent.

Response shape (REQ-004)

{
  "receipt_id":        "rcp-...",
  "seq":               0,
  "status":            "dispatched" | "terminal" | "pending",
  "capability":        "<dialect>:<verb>" | null,
  "dispatched_to":     "<agent-id>" | null,
  "idempotent_replay": true | false
}

Plus Location: /receipts/v1/<receipt_id> header on every 202.

Adversarial review trajectory

Per PROTO-001 §"Adversarial Review" (Constitutional Principle 12), the work was reviewed by four fresh-context agents — no session validated its own output.

Round HEAD Verdict Findings
1 212353f BLOCK 4 S2, 7 S3, 9 S4
2 725aced BLOCK 2 S2, 7 S3, 7 S4
3 f28678b BLOCK 1 S2, 5 S3, 5 S4
4 c8520d0 APPROVE + PROTO-001 §"Adversary Exhaustion" declared 0 S1, 0 S2, 2 S3, 6 S4 (all polish, addressed in b364b69)

Notable defects caught and fixed in flight (each only by adversarial review, not by the implementing session):

  • Round 1: Test suite committed without ever being executed; tests as written couldn't pass (no sink agent registered for the test-d dialect); build-replay-reply returning placeholder strings (status="pending", capability="replay", dispatched_to="unknown") instead of real state; disclosure-property test was a tautology.
  • Round 2: Terminal-replay dispatched_to returned null (regression — cbcl-inflight is cleared on terminal); respond-202 hardcoded status: "pending" for every response.
  • Round 3: Location: /receipts/v1/<id> pointed at a route that didn't exist (404). HTTP test had encoded the wrong URL shape and passed silently against the buggy code.
  • Round 4: Meta-path response inconsistency (dispatched_to=#"router" placeholder vs. schema "null"); TEST-009 undispatched case unreachable under V1 architecture; 503 mapping in HTTP-API but absent from spec body.

The fourth-round reviewer's verdict: "Further adversarial passes would produce diminishing returns. The artifact is converged."

Test coverage

35 new tests in two new modules, plus 21 regression-verified pre-existing dialect-distribution tests:

$ rebar3 eunit --module=cbcl-idempotency-tests,cbcl-ingress-http-tests,cbcl-dialect-distribution-tests
  All 55 tests passed.
  • cbcl-idempotency-tests (31): TEST-001..010 with positive + negative-input + negative-output decomposition (per PROTO-001 RTTD). Property-based concurrency test (50 concurrent claims → exactly one winner). Boundary tests at 0, 1, 0x21, 0x7E, 255, 256 bytes. Principal type-confusion (map vs binary). REQ-005 retention (storage restart drops keys). claim-retry-exhausted distinct atom.
  • cbcl-ingress-http-tests (4): real cowboy listener on ephemeral port via httpc. Asserts Location header format AND that the URL resolves to 200. Asserts Cache-Control absence under V1. 400 and 409 bodies match the exported idempotency-conflict-body / invalid-idempotency-key-body constants byte-for-byte.

Known limitations / out of scope

  • OBS-001 / OBS-002 metrics (latency histogram, outcome counter) are specified but not implemented. Defer to a follow-up — NFR-003's 5ms p95 budget remains unverifiable until they land.
  • Full eunit run (the ~230-test suite including crypto/auth-shell) is gated on a pre-existing enacl NIF compatibility issue with newer OTP versions. The 55 SPEC-011-relevant tests run cleanly under erlang@26 with CFLAGS="-Wno-incompatible-function-pointer-types -Wno-int-conversion". Tracked separately as an environment issue.
  • /receipts/:receipt_id (unversioned) is kept as a back-compat alias alongside /receipts/v1/:receipt_id. New code should use /v1/.

Commits

Commit Purpose
1373c7a SPEC-011 draft (carved from SPEC-010, which is marked superseded)
c9e194a SPL plan for hence coordination
212353f Initial implementation
725aced Round-1 adversarial-review fixes
f28678b Round-2 adversarial-review fixes
c8520d0 Round-3 adversarial-review fixes
b364b69 Round-4 convergence polish

Merge strategy recommendation: --no-ff to preserve the adversarial-review history. The trajectory is the constitutional-principle evidence and is worth keeping visible. If a flatter history is preferred, rebase-squash before merging.

🤖 Generated with Claude Code

## Summary Wires the standard HTTP `Idempotency-Key` header into a working dedup path on `POST /ingress/v1/messages`. Pre-existing state: the header was read at `cbcl-ingress-handler.lfe:39` but never threaded to the dispatcher, so producer retries across transient network failures caused the same work to be dispatched twice. This PR closes that gap. ## What's in this PR - **SPEC-011** (draft, v0.2.0) — focused spec for idempotent submission. See `specs/SPEC-011-idempotent-submission.md`. Carved out of the broader (and parked) SPEC-010; SPEC-010 is included as a record of the original design exploration and marked `status: superseded`. - **IMPL-011** — implementation across `cbcl-storage`, `cbcl-dispatcher`, `cbcl-ingress-handler`, and `cbcl-agent-ws`. - **Plan** — `plans/idempotent-submission-impl.spl` for hence-driven coordination. - **Tests** — 35 new tests across two new test modules. - **Docs** — `docs/HTTP-API.md` gains a full Idempotent Submission section with a runnable Python recipe; `docs/status.md` updated. ## Semantics (settled across the review iterations) - **Scope:** ask submissions only. Meta requests (teach/query/subscribe) are content-idempotent at the protocol layer and skip the dedup machinery; any `Idempotency-Key` header on a meta request is ignored and logged at debug level. - **Key namespace:** globally unique with originating-principal owner tracking. Same-principal retry → 202 with `idempotent_replay: true` and the receipt's *actual current state*. Cross-principal collision → 409 with a byte-exact constant body that discloses nothing. - **Validation:** 1–255 bytes, printable ASCII (0x21–0x7E only). Invalid → 400 with stable problem+json body. - **Retention:** under V1 (in-memory ETS), keys live as long as the receipt log — which is until router restart. The 24h `Cache-Control` hint planned in v0.1.0 has been explicitly dropped from V1 (NFR-002 v0.2.0); it returns once SPEC-041 lands durable storage. Producers MUST handle restart-window resubmission as fresh. - **503 `claim-retry-exhausted`:** distinct from 409. Reserved for future retention-pressure paths; today unreachable under V1. - **`dispatched_to` on the receipt row:** persisted at append time, survives `cbcl-inflight` clearance on terminal, so replays of completed receipts can report the handling agent. ## Response shape (REQ-004) ```json { "receipt_id": "rcp-...", "seq": 0, "status": "dispatched" | "terminal" | "pending", "capability": "<dialect>:<verb>" | null, "dispatched_to": "<agent-id>" | null, "idempotent_replay": true | false } ``` Plus `Location: /receipts/v1/<receipt_id>` header on every 202. ## Adversarial review trajectory Per PROTO-001 §"Adversarial Review" (Constitutional Principle 12), the work was reviewed by four fresh-context agents — no session validated its own output. | Round | HEAD | Verdict | Findings | |---|---|---|---| | 1 | `212353f` | BLOCK | 4 S2, 7 S3, 9 S4 | | 2 | `725aced` | BLOCK | 2 S2, 7 S3, 7 S4 | | 3 | `f28678b` | BLOCK | 1 S2, 5 S3, 5 S4 | | 4 | `c8520d0` | **APPROVE** + PROTO-001 §"Adversary Exhaustion" declared | 0 S1, 0 S2, 2 S3, 6 S4 (all polish, addressed in `b364b69`) | Notable defects caught and fixed in flight (each only by adversarial review, not by the implementing session): - **Round 1:** Test suite committed without ever being executed; tests as written couldn't pass (no sink agent registered for the `test-d` dialect); `build-replay-reply` returning placeholder strings (`status="pending"`, `capability="replay"`, `dispatched_to="unknown"`) instead of real state; disclosure-property test was a tautology. - **Round 2:** Terminal-replay `dispatched_to` returned null (regression — `cbcl-inflight` is cleared on terminal); `respond-202` hardcoded `status: "pending"` for every response. - **Round 3:** `Location: /receipts/v1/<id>` pointed at a route that didn't exist (404). HTTP test had encoded the wrong URL shape and passed silently against the buggy code. - **Round 4:** Meta-path response inconsistency (`dispatched_to=#"router"` placeholder vs. schema "null"); TEST-009 undispatched case unreachable under V1 architecture; 503 mapping in HTTP-API but absent from spec body. The fourth-round reviewer's verdict: *"Further adversarial passes would produce diminishing returns. The artifact is converged."* ## Test coverage 35 new tests in two new modules, plus 21 regression-verified pre-existing dialect-distribution tests: ``` $ rebar3 eunit --module=cbcl-idempotency-tests,cbcl-ingress-http-tests,cbcl-dialect-distribution-tests All 55 tests passed. ``` - `cbcl-idempotency-tests` (31): TEST-001..010 with positive + negative-input + negative-output decomposition (per PROTO-001 RTTD). Property-based concurrency test (50 concurrent claims → exactly one winner). Boundary tests at 0, 1, 0x21, 0x7E, 255, 256 bytes. Principal type-confusion (map vs binary). REQ-005 retention (storage restart drops keys). claim-retry-exhausted distinct atom. - `cbcl-ingress-http-tests` (4): real cowboy listener on ephemeral port via httpc. Asserts Location header format AND that the URL resolves to 200. Asserts Cache-Control absence under V1. 400 and 409 bodies match the exported `idempotency-conflict-body` / `invalid-idempotency-key-body` constants byte-for-byte. ## Known limitations / out of scope - **OBS-001 / OBS-002 metrics** (latency histogram, outcome counter) are specified but not implemented. Defer to a follow-up — NFR-003's 5ms p95 budget remains unverifiable until they land. - **Full eunit run** (the ~230-test suite including crypto/auth-shell) is gated on a pre-existing `enacl` NIF compatibility issue with newer OTP versions. The 55 SPEC-011-relevant tests run cleanly under `erlang@26` with `CFLAGS="-Wno-incompatible-function-pointer-types -Wno-int-conversion"`. Tracked separately as an environment issue. - **`/receipts/:receipt_id`** (unversioned) is kept as a back-compat alias alongside `/receipts/v1/:receipt_id`. New code should use `/v1/`. ## Commits | Commit | Purpose | |---|---| | `1373c7a` | SPEC-011 draft (carved from SPEC-010, which is marked superseded) | | `c9e194a` | SPL plan for hence coordination | | `212353f` | Initial implementation | | `725aced` | Round-1 adversarial-review fixes | | `f28678b` | Round-2 adversarial-review fixes | | `c8520d0` | Round-3 adversarial-review fixes | | `b364b69` | Round-4 convergence polish | Merge strategy recommendation: `--no-ff` to preserve the adversarial-review history. The trajectory is the constitutional-principle evidence and is worth keeping visible. If a flatter history is preferred, rebase-squash before merging. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
anuna-02 commented 2026-05-17 04:01:58 +00:00 (Migrated from codeberg.org)

P1 addressed in fe2fa52.

Fix: Replaced the two-step try-claim (direct ets:insert_new from dispatcher) + append-ask (gen_server:cast) with a single synchronous cbcl-storage:claim-and-append-ask/7. Both ETS writes now happen inside the storage gen_server's handle_call, so they share lifecycle: either both land (gen_server:call returned) or both die (storage process crash drops both tables together). The orphan-claim window is eliminated by construction.

Side effect caught by the new test: the cleanest implementation passed the principal-map from cbcl-auth verbatim to storage; the subsequent same-principal-replay check then compared a map against a binary and 409'd legitimate retries. Normalising principal to its binary id at the storage boundary (principal->idem-id) fixes this. The HTTP test http_202_replay_shows_true_and_current_status_test caught it immediately — exactly the kind of cross-layer regression the integration tests are there for.

Regression tests added:

  • claim-and-receipt-are-atomic-no-orphan-window: pins the atomicity property at the storage layer. Asserts that on successful claim, both the receipt row AND the index entry are queryable from the test process before the call returns. A regression to cast-based append would produce get-receipt returning [] while the index says {ok, receipt-id} — exactly the defect window.
  • claim-conflict-does-not-insert-receipt: pins REQ-003 case 2's "no row created on conflict" post-condition at the storage layer.

Verification: 57/57 tests pass across cbcl-idempotency-tests (33), cbcl-ingress-http-tests (4), cbcl-dialect-distribution-tests (20).

P1 addressed in `fe2fa52`. **Fix:** Replaced the two-step `try-claim` (direct `ets:insert_new` from dispatcher) + `append-ask` (`gen_server:cast`) with a single synchronous `cbcl-storage:claim-and-append-ask/7`. Both ETS writes now happen inside the storage gen_server's `handle_call`, so they share lifecycle: either both land (gen_server:call returned) or both die (storage process crash drops both tables together). The orphan-claim window is eliminated by construction. **Side effect caught by the new test:** the cleanest implementation passed the principal-map from cbcl-auth verbatim to storage; the subsequent same-principal-replay check then compared a map against a binary and 409'd legitimate retries. Normalising principal to its binary id at the storage boundary (`principal->idem-id`) fixes this. The HTTP test `http_202_replay_shows_true_and_current_status_test` caught it immediately — exactly the kind of cross-layer regression the integration tests are there for. **Regression tests added:** - `claim-and-receipt-are-atomic-no-orphan-window`: pins the atomicity property at the storage layer. Asserts that on successful claim, both the receipt row AND the index entry are queryable from the test process before the call returns. A regression to cast-based append would produce `get-receipt` returning `[]` while the index says `{ok, receipt-id}` — exactly the defect window. - `claim-conflict-does-not-insert-receipt`: pins REQ-003 case 2's "no row created on conflict" post-condition at the storage layer. **Verification:** 57/57 tests pass across `cbcl-idempotency-tests` (33), `cbcl-ingress-http-tests` (4), `cbcl-dialect-distribution-tests` (20).
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin spec/SPEC-011-idempotent-submission:spec/SPEC-011-idempotent-submission
git switch spec/SPEC-011-idempotent-submission

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff spec/SPEC-011-idempotent-submission
git switch spec/SPEC-011-idempotent-submission
git rebase main
git switch main
git merge --ff-only spec/SPEC-011-idempotent-submission
git switch spec/SPEC-011-idempotent-submission
git rebase main
git switch main
git merge --no-ff spec/SPEC-011-idempotent-submission
git switch main
git merge --squash spec/SPEC-011-idempotent-submission
git switch main
git merge --ff-only spec/SPEC-011-idempotent-submission
git switch main
git merge spec/SPEC-011-idempotent-submission
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
anuna-research/cbcl-router!8
No description provided.