Plan: Single-flight Idempotency-Key execution across replicas
On this page
- Status
- Context
- Decisions (settled)
- Acceptance criteria (from #1003)
- Ground truth (verified against source)
- Design
- Schema (evolve
CREATE_TABLE_DDL;SET lock_timeout, CHECKNOT VALID) - Cache key — actor-scoped, canonical, validated (MR1)
- Atomic claim — one committed statement (MR3)
- Renewed + fenced lease (MR3)
- Control flow, follower, status matrix (MR3)
- Follower-amplification bound (MR3)
- Response-header allowlist (MR3; AC #3)
- Error contract (RFC 9457)
- Cleanup fix + interval stagger
- Metrics (AC #8) — two counters
SingleFlightConfig(validated, likeRetryPolicy)
- Schema (evolve
- Steps (MR sequence)
- Testing
- Review-resolution map
- Delivery mechanics
- Follow-ups (file &
/relate #1003)
Status
| MR | Description | Status |
|---|---|---|
MR1 |
Actor-scoped, canonical, validated cache key ( |
Done (2026-07-11) — !807 |
MR2 |
Expand phase: additive nullable schema + forward-compatible reader that fails-closed (503) on any non-replayable row; cleanup first-tick stagger; |
Done (2026-07-11) — !809 |
MR3 |
Core single-flight: claim CTE + renewed/fenced lease + body-hash conflict + follower wait + crash recovery + status matrix + header allowlist + configurable response cap + 5xx failure-cooldown + two metric counters + validated |
Done (2026-07-11) — !810 |
MR4 |
Contract/observability + docs: global |
Done (2026-07-11) — final MR |
Epic: &44 (Security, CI/CD & Documentation Remediation)
Umbrella issue: #1003
Child issues: #1027 (MR1), #1028 (MR2), #1029 (MR3), #1030 (MR4) — each Relates to #1003; only MR4 Closes #1003
Branches: feature/1003-idempotency-*
Context
crates/canopy-api/src/idempotency.rs is a middleware every Canopy service mounts
(ApiServer::router, lib.rs:117-132) to stop a client retry from double-issuing
benefits / notices / audit-chain writes (lib.rs:98-107). Under concurrency it does
not. The hot path is check-then-execute: SELECT (miss = no row, :411-430) → run
the handler (next.run, :451) → INSERT … ON CONFLICT DO NOTHING (:482-497).
Two overlapping same-key POSTs (a retry racing a slow first request; across
replicas both read the same empty row) both miss and both run the side effects;
ON CONFLICT DO NOTHING only picks which response is stored, not which domain
writes happen. Observed at commit 0fa79f7a.
Goal: run the handler once per key (happy path), one replica and across replicas sharing the service DB, with defined follower/conflict/crash-recovery behavior and no DB transaction held across the handler.
This is an epic, not one MR (same finding class as #1004). External review established that a correct fix also needs an enforceable liveness mechanism (a fixed 60s lease proves nothing — reporting/eligibility POSTs run minutes), actor-scoped cache identity (a live cross-actor authorization bypass), rolling-deploy safety on a compliance-sensitive shared table, a response-header allowlist, and an RFC 9457 error contract.
Irreducible limit (documented, not fixed here): middleware cannot make a
non-transactional handler’s domain writes atomic with the cache finalize without
the handler joining the same transaction (AC #5 forbids holding a tx across the
handler). Guarantee = exactly-once on the happy path, at-least-once if a winner
crashes after its side effect but before finalize, or returns a retryable 5xx
whose side effect already landed. Renewal + claim_id fencing bound how often
recovery re-runs and make a false-steal at-least-once, never corruption. True
exactly-once (handler write + finalize in one tx, outbox-style) is a filed
follow-up.
Decisions (settled)
-
Rollout: expand/contract, 2 releases (ADR-016; migrations.adoc expand-contract section) — a forward-compatible reader (MR2) ships and fully rolls out before the single-flight change (MR3), so old + new replicas coexist safely and every step is rollback-safe.
-
Liveness: renewed + fenced lease — a short lease renewed by a background task while the handler runs;
claim_idfences finalize/release/renew. A global request-timeout is ruled out (multi-minute reporting/eligibility POSTs); a held DB connection / advisory lock is ruled out (10-connection pool). -
Metrics: replace (pure) — drop the flat
hits/misses/persist_errorscounters; emit an outcome counter + an event counter (see Design › Metrics).
Acceptance criteria (from #1003)
-
Two concurrent same-key POSTs run the handler exactly once on one replica.
-
Same guarantee across two replicas sharing the DB.
-
Followers get the winner’s status/body/supported headers, or a documented retryable response while pending.
-
Reusing a key with a different body → deterministic conflict, no handler run.
-
Handler failure + process death have bounded, tested recovery; no permanent wedge. (No DB tx across the handler.)
-
Existing sequential replay, restart-survival, TTL, cleanup stays covered.
-
A concurrency regression test fails on current code, passes on the fix.
-
Metrics distinguish claimed, pending-wait, replay, conflict, abandoned, failed.
Ground truth (verified against source)
| Fact | Where |
|---|---|
Postgres-only prod backend; in-memory |
idempotency.rs:205-220 |
Table via raw DDL under |
idempotency.rs:142-152, 240-269 |
Deploy is rolling (k8s, 2+ replicas); expand/contract mandated; forward-only migrations; |
deployment-guide.adoc:216-218; migrations.adoc; ADR-016 |
sqlx 0.8.6 errors decoding SQL NULL → non- |
idempotency.rs:411-429 |
On-behalf-of actor is |
claims.rs:23,75; assignments.rs:34; enrollment mod.rs:87; idempotency.rs:353 |
No inbound request timeout anywhere; reporting FNS-388/ACF-199 + eligibility |
lib.rs:167-201; reporting/fns388.rs:53; eligibility/orchestrator.rs:1338; canopy-db/lib.rs:90-95 |
|
notices/api/mod.rs:155-173 |
Error convention = RFC 9457 (coding-conventions.adoc:249); shared |
canopy-common/error.rs:7-133 |
OpenAPI = per-endpoint |
lib.rs:76-91; xtask/api_docs.rs |
|
portal/ratelimit.rs:401 |
Cleanup DELETE matches by |
idempotency.rs:301-337, 276-283 |
|
canopy-api/Cargo.toml |
Concurrency-test idiom ( |
discovery.rs:339-363; csrf.rs:85-111 |
Design
Schema (evolve CREATE_TABLE_DDL; SET lock_timeout, CHECK NOT VALID)
Base CREATE TABLE for fresh installs + idempotent ALTER`s for deployed tables,
all under the existing advisory lock. `SET LOCAL lock_timeout='3s' first (bounded;
fail fast rather than block traffic). Add CHECKs NOT VALID (no full-table scan
under ACCESS EXCLUSIVE), validate separately. state DEFAULT 'completed' makes
every legacy row a replayable completed row with request_hash NULL.
New columns: state TEXT NOT NULL DEFAULT 'completed' (values pending /
completed / failed), request_hash BYTEA (NULL or 32 bytes), claim_id UUID,
lease_expires_at TIMESTAMPTZ, response_headers JSONB (allowlisted headers),
replayable BOOLEAN NOT NULL DEFAULT true (false = a successful-but-over-cap
response), response_status/response_body → DROP NOT NULL.
State-dependent invariants as per-state implications (so failed and
completed-unreplayable rows are legal), added NOT VALID then validated:
CHECK (state <> 'completed' OR replayable = false
OR (response_status IS NOT NULL AND response_body IS NOT NULL))
CHECK (state <> 'pending'
OR (request_hash IS NOT NULL AND claim_id IS NOT NULL AND lease_expires_at IS NOT NULL))
CHECK (state <> 'failed' OR lease_expires_at IS NOT NULL)
CHECK (request_hash IS NULL OR octet_length(request_hash) = 32)
Each idempotent constraint-add DO-block is scoped by conrelid =
'idempotency_keys'::regclass (not just conname).
Cache key — actor-scoped, canonical, validated (MR1)
Replace the collision-prone colon-join (idempotency.rs:363) with a
length-delimited canonical tuple, then a Sha256 digest that is what gets
stored/logged (never the raw caller-controlled string). Tuple = (effective
principal = actor.sub else sub, method, full path + query, idempotency-key).
Roles/programs are NOT in the key (a mid-window role change must not turn a retry
into a miss → re-run). Key validation: header absent → proceed unguarded
(unchanged); header present but empty / non-UTF-8 / > 255 bytes / duplicated → 400.
Atomic claim — one committed statement (MR3)
$1=cache_key, $2=request_hash, $3=Uuid::now_v7(), $4=lease secs (bound
i64, $4::bigint). Use clock_timestamp() (advances during the statement), not
now() (frozen at tx start), so a lock-wait cannot mint an already-expired lease.
Three CTEs distinguish the metric-relevant outcomes:
WITH ins AS (
INSERT INTO idempotency_keys
(cache_key, state, request_hash, claim_id, lease_expires_at, created_at)
VALUES ($1,'pending',$2,$3, clock_timestamp() + ($4::bigint * interval '1 second'), clock_timestamp())
ON CONFLICT (cache_key) DO NOTHING
RETURNING 'claimed'::text AS how
),
ttl AS ( -- >24h old: reusable regardless of body
UPDATE idempotency_keys k SET state='pending', request_hash=$2, claim_id=$3,
lease_expires_at=clock_timestamp() + ($4::bigint * interval '1 second'),
response_status=NULL, response_body=NULL, response_content_type=NULL,
response_headers=NULL, replayable=true, created_at=clock_timestamp()
WHERE k.cache_key=$1 AND NOT EXISTS (SELECT 1 FROM ins)
AND k.created_at < clock_timestamp() - interval '24 hours'
RETURNING 'recovered_ttl'::text AS how
),
steal AS ( -- crashed winner (pending) or elapsed 5xx-cooldown (failed), SAME body only
UPDATE idempotency_keys k SET state='pending', request_hash=$2, claim_id=$3,
lease_expires_at=clock_timestamp() + ($4::bigint * interval '1 second'),
response_status=NULL, response_body=NULL, response_content_type=NULL,
response_headers=NULL, replayable=true, created_at=clock_timestamp()
WHERE k.cache_key=$1 AND NOT EXISTS (SELECT 1 FROM ins) AND NOT EXISTS (SELECT 1 FROM ttl)
AND k.created_at >= clock_timestamp() - interval '24 hours'
AND k.state IN ('pending','failed') AND k.lease_expires_at < clock_timestamp()
AND k.request_hash = $2
RETURNING 'recovered_abandoned'::text AS how
)
SELECT how FROM ins UNION ALL SELECT how FROM ttl UNION ALL SELECT how FROM steal;
claimed/recovered_ttl/recovered_abandoned ⇒ won; no row ⇒ lost
(live-completed < 24h, in-flight pending, or an expired-lease pending/failed with a
different body → the loser reads it and 409s). ttl/steal predicates are
mutually exclusive (<24h vs >=24h) so ≤1 row updates. Verified SOUND by a
Postgres-semantics review (fresh-key race loses by snapshot invisibility; steal
race by EvalPlanQual re-check; CTE NOT EXISTS(ins) reads the CTE result; no
deadlock/livelock). The steal branch requires request_hash=$2 (AC #4: a different
body must not steal a crashed winner’s key).
Renewed + fenced lease (MR3)
On winning, spawn a renewal task: every renew_interval (≈lease/3) run UPDATE …
SET lease_expires_at=clock_timestamp()+lease WHERE cache_key=$1 AND claim_id=$3 AND
state='pending'; stop if rows_affected==0 (stolen/finalized). A drop-guard
aborts the task when the handler returns or panics, so a dead handler stops
renewing and its lease expires within lease, becoming recoverable.
finalize/release/renew are all claim_id-scoped, so a stolen-from winner’s writes
are no-ops (it returns its response to its own client and never corrupts the new
owner).
Control flow, follower, status matrix (MR3)
validate + digest key; buffer + hash body under a permit (cap exceeded → 413; `to_bytes` cannot distinguish a lower-level body error at this layer and such errors are unreachable here, so all buffer failures map to 413)
claim_id = Uuid::now_v7(); deadline = Instant::now() + follower_wait_budget
loop (deadline- + hard-iteration-cap-bounded; each query wrapped in a remaining-deadline timeout):
claim → Won => run_winner() # events: claimed | recovered_ttl | recovered_abandoned
→ Lost => read_existing (state, request_hash, replayable, response_*, response_headers):
None => continue # vanished (TTL-cleaned) → re-claim wins
Completed{hash, replayable} => hash != mine (non-legacy) ? 409
: replayable ? replay(resp+hdrs) : 409 completed-not-replayable
Pending{hash} => hash != mine ? 409
: now>=deadline ? 503+Retry-After
: { event pending_wait; backoff-sleep; continue }
Failed{hash} => hash != mine ? 409
: lease live ? 503+Retry-After (event retryable_cooldown)
: continue # cooldown elapsed → next claim reclaims via steal
run_winner: rebuild request → next.run (no tx) → buffer response (configurable
cap) → status matrix:
| Status | Action |
|---|---|
2xx, and 4xx ∈ {400,404,409,422} (deterministic) |
|
401/403 (authz), 3xx |
do not cache (authz is revocable; redirects need Location) — return, |
5xx / 408 / 429 (matches |
|
response > cache cap (success) |
|
finalize rows_affected==0 ⇒ lease was stolen mid-handler ⇒ return own
response, do not touch the cache (WARN; at-least-once boundary).
Follower-amplification bound (MR3)
Concurrent callers serialize, but on a 5xx release the next waiter re-claims and
re-runs → N sequential executions of a persistently-failing handler. Bound it: the
5xx transition is an owner-scoped (WHERE claim_id=mine) UPDATE to a short failed
cooldown (state='failed', claim_id=NULL, response_*=NULL, request_hash
retained, lease_expires_at=clock_timestamp()+cooldown). Within the cooldown,
same-body followers get 503+Retry-After (event retryable_cooldown) instead of
promoting; after cooldown the steal branch reclaims (hash-matched). Caps re-exec
to ≈1/cooldown.
Response-header allowlist (MR3; AC #3)
Persist an allowlist in response_headers JSONB: content-type,
content-disposition, content-language, cache-control, etag,
x-canopy-signature. Never persist set-cookie, authorization, or any
auth/session header (a replay must not cross sessions). Replay reconstructs status
+ body + allowlisted headers + x-idempotency-replay: true. This makes
POST /documents/render’s signature/`Content-Disposition/no-store replay
correctly. Headers outside the allowlist are dropped.
Error contract (RFC 9457)
Add ApiError::ServiceUnavailable(503) (MR2) and ApiError::PayloadTooLarge(413)
(MR3) to canopy_common::error; new responses (409/413/503) return ProblemDetails
JSON via ApiError (matches the codebase’s application/json shape; the
application/problem+json media-type gap is pre-existing → follow-up). 503s carry
Retry-After (integer seconds, header::RETRY_AFTER).
Cleanup fix + interval stagger
Outer DELETE re-checks age + only prunes terminal rows:
DELETE FROM idempotency_keys
WHERE cache_key IN (SELECT cache_key FROM idempotency_keys
WHERE created_at < clock_timestamp() - interval '24 hours' LIMIT $1)
AND created_at < clock_timestamp() - interval '24 hours'
AND state IN ('completed','failed');
Never deletes a live pending/reclaimed row. First tick staggered via
interval_at(Instant::now() + jittered_initial_delay, period) so replicas don’t
herd at boot. (Stagger lands in MR2 since it is a boot-behavior change; the DELETE
predicate lands in MR3 with the reclaim path.)
Metrics (AC #8) — two counters
Terminal outcome (exactly one per request):
canopy_idempotency_requests_total{outcome} ∈ won_completed | replay | conflict |
timeout | failed | completed_uncacheable. Lifecycle events (may fire several per
request): canopy_idempotency_events_total{event} ∈ claimed | recovered_ttl |
recovered_abandoned | pending_wait | retryable_cooldown | lease_stolen |
finalize_error | release_error. Keep ttl_deletes. Replaces the flat
hits/misses/persist_errors.
SingleFlightConfig (validated, like RetryPolicy)
Private fields + a validated builder (reject zero/negative poll, inverted backoff
bounds, zero/huge lease, renew_interval >= lease, unnamed iteration cap): lease
(30s), renew_interval (10s), follower_wait_budget (5s), poll (50ms→1s ±25%
jitter), retry_after (1s), failure_cooldown (2s), max_request_body_bytes
(= router body_limit), max_cacheable_response_bytes (default 2 MiB),
max_response_buffer_bytes (hard buffer ceiling, default 32 MiB — an over-cap
success still returns its full body to the caller while being stored
non-replayable), max_iterations. Concurrent body buffering is bounded by a
process-wide BUFFER_SEMAPHORE const, not a per-config field. Stored on
Backend::Postgres { pool, config } (touch the 5 match sites: variant :200,
with_pool :286 = SingleFlightConfig::default(), check_cache :399,
execute_and_cache :477, test matcher :540). with_pool keeps defaults; a
pub fn with_config(self, cfg) → Self builder (doc’d — lib.rs:3 missing_docs
-D warnings) lets ApiServer::router set the body cap (opts.body_limit is in
scope at lib.rs:117 — no router-signature change) and lets integration tests
shorten budgets. Deadline/backoff math uses Instant::checked_add /
saturating_* (J7); LEASE/renew bounded via const _ floor asserts.
Steps (MR sequence)
Each MR: own feature/… branch, own child issue (claim @me,
workflow::in-progress), own tests, Relates to #1003 (only MR4 Closes #1003;
verify #1003 stays open after each non-final merge). Free helpers return
Result<_, sqlx::Error> (no anyhow/Box<dyn Error> at pub boundaries); response
build reuses the unwrap_or_else(|_| 500) idiom (idempotency.rs:378), no new
expect; all functions ≤ 40 lines; new .rs gets the SPDX header; new pub items
get doc comments.
MR1 — security: actor-scoped, canonical, validated cache key
Files: crates/canopy-api/src/idempotency.rs
Rewrite extract_cache_key: effective principal actor.sub ‖ sub; full path
query; length-delimited canonical tuple → Sha256 digest (store/log the digest);
key validation → 400 for present-but-invalid. Tests: actor isolation,
delimiter-collision, query-param distinctness, invalid/dup/oversized key → 400.
MR2 — expand (deploy-safety prep)
Files: crates/canopy-api/src/idempotency.rs, crates/canopy-common/src/error.rs
Additive DDL (nullable columns, CHECK NOT VALID, lock_timeout, conrelid-scoped
constraint add). Reader decodes new columns as Option and fails-closed 503 on any
non-replayable row (state <> 'completed' OR response IS NULL OR replayable=false)
instead of re-running, so it safely defers to any MR3 replica’s pending/failed rows.
Add ApiError::ServiceUnavailable. Stagger the cleanup first tick. Keeps old
check-then-execute writes (creates no pending rows). Must be deployed + rolled out
to all replicas before MR3. Tests: old-reader-defers (503) on pending/failed/over-cap
row; fresh-install + legacy-upgrade DDL determinism.
MR3 — core single-flight
Files: crates/canopy-api/src/idempotency.rs, crates/canopy-api/Cargo.toml,
crates/canopy-common/src/error.rs, crates/canopy-api/tests/concurrency/…
Claim CTE, renewed+fenced lease, body-hash 409, follower wait/503, crash recovery,
status matrix + response-header allowlist + configurable response cap + uncacheable
path, 5xx-release + failure cooldown, cleanup race fix, two metric counters,
ApiError::PayloadTooLarge, validated SingleFlightConfig, sha2/proptest
deps, Memory-backend refactor (helpers take &Arc<DashMap<…>> so no dead Postgres
arm / no unreachable!). Full test suite (see Testing).
MR4 — contract/observability + docs (final; Closes #1003)
Files: crates/canopy-api/src/lib.rs, OpenAPI snapshots, docs,
CHANGELOG.adoc, nav.adoc, this plan.
Deviation from the original scope (implemented + why): the IdempotencyResponsesAddon
utoipa::Modify hook injecting 409/413/503 into every guarded POST is applied
centrally in ApiServer::router (immediately before the SwaggerUi merge),
not as per-service modifiers(…) on each of the 17 #[openapi] derives. The
cargo xtask api-docs snapshots are fetched from each running service’s live
/api-doc/openapi.json — which the router serves — so applying the modifier at the
router (the same layer that mounts idempotency_middleware) makes it appear in
every service’s snapshot from one edit, keeps a new service in sync automatically,
and cannot be forgotten per-service. canopy-web (which does not mount the
middleware) is correctly excluded, since it serves its own OpenAPI outside
ApiServer::router. Then cargo xtask api-docs --update regenerates the
snapshots. Docs updated: shared-crates.adoc, migrations.adoc, the idempotency
module docs. CHANGELOG === Removed (the three flat counters, each mapped, with
the no-1:1 note for misses), === Changed (single-flight behavior + dashboard
migration), === Fixed, === Security. This plan moves to plans/archive/ with
the nav.adoc xref updated, all Statuses Done.
Testing
Integration tests → a concurrency test target (per testing.adoc — a
crates/canopy-api/tests/concurrency/ module or a named target, not a bare flat
filename); SPDX; infrastructure_available() guard; reuse the
idempotency_persistence_test.rs harness. Unit tests (claim-outcome mapping,
ClaimState↔TEXT, key canonicalization, deadline math, metric emission with
asserted labels, const _ floors) live in src/idempotency.rs #[cfg(test)]
(private helpers aren’t reachable from an external test crate). Property
(proptest): key canonicalization is injective / no-panic; hash_request_body
deterministic.
Deterministic overlap (no sleep — the repo has timer starvation). Handler
signals entered (mpsc) on entry, then awaits a watch::<bool> release. On the
fixed code A’s claim commits before A enters the handler, so when A signals
entered the pending row is already committed → spawning B then guarantees B loses
→ handler_count==1. On current code A writes nothing until after the handler, so
B (spawned after A’s entered) misses → runs → a second entered fires →
handler_count==2. The count assertion is the invariant; no sleep gates it. Staged
so the regression runs on current code: MR3’s first commit adds the concurrency
test against the pre-fix logic (old API, no with_config) proving count==2; the
fix commit flips it to count==1.
Matrix (each row = one property test): AC#1 one-replica exactly-once; AC#2
two-replica exactly-once on two independently-constructed pools; AC#7
fails-on-current; AC#4 same-key-different-body → 409 + racing-different-bodies;
expired-lease steal with a DIFFERENT hash → 409, no run; two simultaneous
expired-lease stealers → one wins; healthy handler crossing the lease keeps
renewing → not stolen; crashed-winner (expired lease, no renewal) → stealable; AC#3
live-lease pending → 503+Retry-After (short budget); stolen-lease finalize/renew is
a no-op; AC#6 TTL-expired row re-executes; cleanup-vs-reclaim (reclaim refreshes
created_at while cleanup runs → live row survives); 413 (length) vs 400 (other body
error); legacy NULL-hash replays (24h transition exception, no 409);
response-header allowlist replayed (incl. x-canopy-signature) + set-cookie
dropped; over-cap success → completed-uncacheable → follower 409; every error path
(claim/read/finalize/release DB error, query-timeout, ownership loss, all release
statuses incl 408); actor isolation (two actors, one bearer, same key → no
cross-serve); role-change-mid-window still replays same-actor; metric
names+labels+values asserted. AC#6 regression: run idempotency_persistence_test.rs
unmodified green under the new schema + add the missing cleanup-behavior test;
strengthen restart/two-replica to close+recreate pools.
Review-resolution map
| Finding | Resolution | MR |
|---|---|---|
B1 expired-lease steal ignores body |
|
MR3 |
B2 fixed lease unenforceable |
renewed+fenced lease (global-timeout & held-lock ruled out) |
MR3 |
B3 |
|
MR3 |
B4 rolling deploy unsafe |
expand/contract: MR2 forward-compat 503 reader first |
MR2→MR3 |
B5 legacy NULL-hash vs AC#4 |
documented ≤24h transition exception; ages out |
MR3 |
B6 cache key ignores actor |
key = effective principal |
MR1 |
B7 cleanup deletes reclaimed row |
outer DELETE re-checks age + |
MR2 stagger / MR3 predicate |
B8 header decision breaks render |
persisted header allowlist incl. |
MR3 |
fingerprint incomplete / colon-collision |
canonical length-delimited digest incl. full path+query |
MR1 |
follower latency unbounded |
per-query remaining-deadline timeout + |
MR3 |
retryable amplification |
5xx release + |
MR3 |
first-party retry ignores Retry-After |
documented: internal |
MR4 docs |
2 MiB response cap unsafe |
configurable cap; over-cap success → completed-uncacheable → 409 |
MR3 |
request-buffer memory amplification |
bounded concurrent-buffer semaphore (config) |
MR3 |
body-read error conflated with 413 |
length-error → 413, other body error → 400 |
MR3 |
key validation unsafe |
present-but-invalid key → 400; store/log digest |
MR1 |
cached-response privacy |
pre-existing; documented + follow-up |
MR4 / follow-up |
weak schema invariants |
state-dependent CHECKs + 32-byte hash CHECK (NOT VALID) |
MR2/MR3 |
constraint detection not relation-scoped |
|
MR2 |
boot DDL blocks traffic |
|
MR2 |
cleanup herd on boot |
|
MR2 |
status classification contradictory |
explicit matrix (don’t cache 401/403/3xx) |
MR3 |
RFC 9457 error bodies |
|
MR2 (503) / MR3 (413) |
SingleFlightConfig unvalidated |
private fields + validated builder |
MR3 |
run_winner omits pool; u64 lease |
pool param added; lease bound |
MR3 |
metrics incoherent |
split requests_total{outcome} vs events_total{event}; TTL vs abandoned split |
MR3 |
OpenAPI regen unjustified |
global |
MR4 |
stale docs |
shared-crates/testing/migrations/module docs updated |
MR4 |
CHANGELOG maps 1 of 3 metrics |
map all three removed counters + no-1:1 note |
MR4 |
plan archive "after merge" |
nav/archive in MR4 |
MR4 |
Delivery mechanics
Per MR: cargo fmt --all → cargo clippy -p canopy-api -p canopy-common
--all-targets --profile test — -D warnings → cargo xtask quality-budgets →
cargo xtask validate → cargo nextest run -p canopy-api --profile integration
(source .ports.env) on warm devstack; MR4 also cargo xtask api-docs --update.
Commit as the human (signed), body ending Co-Authored-By: the actual
implementing-session model. Force-merge per project protocol (CI never passes here
— the pre-push battery is the functional gate). Closing comment on each child issue;
Closes #1003 only on MR4; epic &44 updated after each merge.
Follow-ups (file & /relate #1003)
-
application/problem+jsonmedia type codebase-wide. -
Cached-response privacy / crypto-shred coordination + storage bound.
-
Global inbound request-timeout as DoS hygiene (17 services).
-
Exactly-once outbox (handler write + finalize in one tx).
-
SingleFlightConfig→ per-serviceServiceSettingsenv. -
response_locationfor 201-Created idempotency.