D74 — Observability app-code slices: log-Context enrichment (tenant/actor uuids), outbound trace propagation, and the HTTP client-idempotency table (distinct from the bus dedup)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
The three app-code observability slices from docs/tracking/observability-and-request-identity.md (#1, #2, #6)
land together (the OpenTelemetry-SDK / true span export, #3, stays deferred).
Log-Context enrichment (#6). Every log line (not only the CQRS audit channel) now carries tenant_id +
actor_id — both the PUBLIC uuid, never the internal bigint (D25). Modules\Tenancy\Listeners\ShareTenantContext
stamps the tenant uuid into Laravel Context on TenancyInitialized (forgets it on TenancyEnded), wired into
TenancyServiceProvider::events() beside the stancl BootstrapTenancy/RevertToCentralContext listeners;
Modules\Foundation\Listeners\ShareActorContext stamps the actor uuid on the framework Authenticated event
(guard-agnostic: web User + tenant ClientUser, read off the model's uuid), registered in
FoundationServiceProvider::boot(). Both keys were added to CorrelationProcessor::KEYS. No clash with the
bus audit line, which records the bigint tenant_id/actor id in its own log context array (a distinct
mechanism), not in Laravel Context.
Outbound trace propagation (#1). Modules\Rails\Logging\PropagateTraceContext — a named
(propagate-trace-context) Saloon onRequest middleware attached (config-by-class, guarded by pipe-name, the
RecordVendorRequest precedent) to every enabled finance connector in RailsServiceProvider. It forwards
X-Correlation-Id = Context::get('correlation_id') and a W3C traceparent (00-<trace-id>-<span-id>-01) whose
trace-id is adopted from the Context trace_id (or freshly minted 32-hex when absent) and whose span-id is
ALWAYS a fresh 16-hex (bin2hex(random_bytes())) — the outbound leg is its own span. It never clobbers an
already-present traceparent/Idempotency-Key/x-api-key (the SDK's auth + idempotency headers win).
HTTP client idempotency (#2) — a table DISTINCT from command_dedup_records. client_idempotency_keys (the
new tenant-scoped, RLS-forced table; mandatory ClientIdempotencyKeyIsolationTest) backs
Modules\Foundation\Http\Middleware\EnforceClientIdempotency (alias idempotency-key) via
Modules\Foundation\Idempotency\ClientIdempotencyStore (a sanctioned out-of-bus DB-facade user by exact class in
CqrsBoundaryTest, the CommandDeduplicator precedent; the Http middleware itself touches no DB). It is the
client-facing, Stripe-style HTTP idempotency: opt-in per the Idempotency-Key header, keyed on
(tenant_id, idempotency_key, route_name) with a sha256(method|path|body) fingerprint, replaying the stored
HTTP response and releasing the claim on a non-2xx so a failed request stays retryable. This is deliberately
DISTINCT from the internal bus command_dedup_records (D28/D56): that is INSIDE the command transaction, keyed on
the command class, replaying the handler RESULT, and a rollback removes the claim. Mounted on the payments
money-movement POSTs (orders create/cancel; payouts create/approve/cancel; registered-address create;
whitelist-recipient create), after tenancy-init + auth:sanctum. A fingerprint mismatch is the new
ErrorCode::IdempotencyKeyConflict (409); an in-flight duplicate reuses DuplicateRequest (409).
RETENTION/PRUNING of client_idempotency_keys is a DEFERRED follow-up needing an owner-set window, mirroring
foundation:prune-command-dedup-records (the partial unique index is WHERE deleted_at IS NULL so a pruned key
can be re-used).
D74.1 — HTTP client-idempotency hardening: actor-scoped claim + self-healing in-flight TTL + header validation + replay/OpenAPI observability
Follow-on to D74 (this branch). Four fixes harden the client_idempotency_keys slice:
- Actor-scoped claim (security). The claim gained an
actor_idcolumn — the authenticated actor's PUBLIC uuid (guard-agnostic: webUser+ tenantClientUserboth exposeuuid) — and the partial unique index is now(tenant_id, actor_id, idempotency_key, route_name) WHERE deleted_at IS NULL. Previously keyed on(tenant_id, idempotency_key, route_name)only, a DIFFERENT actor in the SAME tenant presenting the SAME key would replay the FIRST actor's stored response WITHOUT their own busauthorize()/step-up — a cross-actor authorization-bypass. Now such an actor claims FRESH and runs their own request. The middleware reads$request->user()?->getAttribute('uuid')and threads it intoclaim()/complete()/release()/classify()/row(). RLS is unchanged (still ontenant_id); the isolation test is extended for the actor dimension. - Self-healing in-flight TTL (availability). A claim is inserted before the request runs and only
completed on a 2xx (released on failure); a crash between claim and complete/release previously wedged the
key at 409 (in-flight) forever.
classify()now treats an uncompleted claim older thanconfig('foundation.idempotency_inflight_ttl_seconds')as ABANDONED and reclaims it (Proceed) via an optimisticcreated_at-guarded in-place update that serialises concurrent retries; a still-recent uncompleted claim staysInFlight. This unwedges stale claims only — pruning of COMPLETED rows remains the D74 deferred follow-up. - Header validation (robustness). The middleware validates the
Idempotency-KeyBEFORE the store — ≤255 chars (matching the column) of[A-Za-z0-9._~-]— rejecting a hostile/over-long key with a coded400 IDEMPOTENCY_KEY_INVALIDinstead of a column-overflow 500. - Observability. A REPLAY carries an
Idempotency-Replayed: trueresponse header. The optionalIdempotency-Keyheader + the two idempotency 409s (DUPLICATE_REQUEST/IDEMPOTENCY_KEY_CONFLICT) + the 400 (IDEMPOTENCY_KEY_INVALID) are now in the OpenAPI contract via a#[SupportsIdempotencyKey]marker attribute + theIdempotencyContractExtensionScramble operation extension (all invisible to controller inference because they are middleware-thrown).
Round-2 hardening (this branch). Two correctness fixes on the self-healing reclaim:
- Fencing token (money-movement correctness). The reclaim (finding 2) cannot distinguish a CRASHED
original from a SLOW-BUT-ALIVE one — a provider call can hang past the TTL without accruing PHP
max_execution_time. Withcomplete()/release()previously matching only(tenant_id, actor_id, key, route), a slow original returning AFTER a retry reclaimed its row would hard-DELETE or overwrite the CURRENT owner's (the retry's) row — re-opening duplicate execution on money movement. FIX: aclaim_token(uuid) column, minted afresh on the initial insert AND on every reclaim (the same in-placeclassifyInFlight()update that already fences the reclaim optimistically).claim()RETURNS the owning token (IdempotencyResult::proceed(string $claimToken)); the middleware threads it intocomplete()/release(), which addWHERE claim_token = :token. A superseded original holds the OLD token → affects ZERO rows. (Migration edited in place — the table is unshipped on this branch, mirroring howactor_idwas added.) - TTL raised + invariant documented.
idempotency_inflight_ttl_secondsdefault raised 90 → 900 and the INVARIANT documented inconfig/foundation.php+ the store docblock: it MUST exceed the longest possible request lifetime (provider timeouts + retries), so a live request is essentially never reclaimed within its own lifetime; the fencing token is the correctness backstop if it ever is.