fix(contacts): return post-update row from conditional engagement update; accept RFC-3986 Location in suite 30 - #775
Merged
Conversation
UpdateEngagementIfUnchanged read the row back as the outer SELECT of a data-modifying CTE. In Postgres that outer query runs on the statement snapshot, which predates the CTE's own UPDATE — so an accepted If-Match update committed the new state but returned the PRE-update row: old stage, old next_action_at, old updated_at. The derived ETag therefore never matched the stored row again, breaking every guarded read-modify-write chain (a chained conditional update with the returned validator always got 412). Split the statement: the UPDATE (version predicate unchanged, so the If-Match race stays closed) RETURNING id, then a second read of the full joined row by primary key in the same transaction, which sees the first statement's writes. Mirrors UpsertEngagement's write-then-GetEngagement shape. The not-found / precondition-failed disambiguation is unchanged. Audited every other data-modifying CTE under internal/ (GetPrincipalBy- APIKey, GetMessageWithContent, the webhook lease queries): all select FROM the CTE's RETURNING output, not from the modified table, so none share the bug. Also fix an over-strict assertion in e2e-prod suite 30: it required the createContact Location header to contain the percent-encoded address, but '@' is a legal raw pchar in an RFC 3986 path segment and the server (Go url.PathEscape) emits it raw. Compare the decoded trailing segment instead. The suite's only other Location assertion is encoding-agnostic. Both failures observed live in the first staging conformance run (e2a-ops release-pipeline run 30612956986), suite 30-contacts-outreach.test.ts:414 and :217. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 31, 2026
hyj28
pushed a commit
to hyj28/e2a
that referenced
this pull request
Aug 2, 2026
The first live staging run of the v1.5.0 conformance suite (e2a-ops release-pipeline run 30612956986) caught two bug classes that offline CI structurally missed: the generated TS client emitting optional If-Match headers unconditionally (literal "If-Match: undefined" on the wire, fixed in tokencanopy#774) and a data-modifying CTE whose outer SELECT read the statement snapshot (conditional engagement update returning the pre-update row and a dead ETag, fixed in tokencanopy#775). Those PRs fixed the instances; this PR pins the classes so they cannot be reintroduced. - Python SDK static audit (sdks/python/tests/test_generated_header_guards.py): the Python generator already None-guards every param-sourced header emission, but nothing pinned that — the freshness gate re-blesses whatever a new generator version emits. The audit fails per-PR CI if any generated api module sets a param-sourced header without its None guard, with non-vacuity and self-discrimination checks. Mirrors tokencanopy#774's TS audit from the Python side. - CTE-snapshot guard (internal/sqlguard): repo-wide static tripwire that resolves every SQL string in tracked Go source (including + concat chains through package consts — the original bug's outer FROM clause lived in the const engagementFrom, so a literal-only scan misses it), classifies CTE bodies, and fails when a data-modifying CTE's outer SELECT reads FROM/JOIN the modified table instead of the CTE's RETURNING output. Waiver: // cte-snapshot-safe: <reason>, with stale- waiver detection. Current tree passes with zero waivers; restoring the pre-tokencanopy#775 engagements.go flags exactly the removed query. - Live-contract coverage ratchet (sdks/typescript/test/v1/ contract-coverage.test.ts, wired into test:contract): the per-PR ts-contract job previously had no coverage denominator — nothing failed when a new SDK method shipped without a live contract test. Now every ergonomic client method (runtime-introspected denominator, same walker as the staging-side gate) must be exercised by contract-client.test.ts or carry an explicit allowlist entry; the 66-entry seed is the honest inventory of today's gap, pruned in both directions so it can only shrink truthfully. - AGENTS.md testing conventions: per-PR live coverage for new SDK surface, generated clients as pipeline artifacts (never hand-edited), and the data-modifying-CTE read-back rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hyj28
pushed a commit
to hyj28/e2a
that referenced
this pull request
Aug 2, 2026
PR tokencanopy#775 fixed the severe form of one bug class: a data-modifying CTE whose outer SELECT ran on the pre-update statement snapshot, so an accepted conditional engagement update committed the new state but returned the PRE- update row and a permanently dead ETag. Its recipe — do the write and the authoritative read in ONE transaction, or RETURN the full row from the write — was applied to that one function only. An audit found six weaker cousins still on main: "write, commit, then re-read from the pool". None can permanently break a validator the way tokencanopy#775 did, but each leaves a real window another transaction can commit into, so the response either describes somebody else's write or reports a spurious error on a write that succeeded. All six now follow the tokencanopy#775 recipe. 1. identity.UpsertEngagement (the unconditional PUT path) committed and then called GetEngagement on the pool. A DeleteEngagement, contact delete or import reversal in that gap returned ErrEngagementNotFound, which the handler reports as 412 precondition_failed on a request that sent no If-Match; activity hooks could repaint the row before a 201-created response described it. The upsert now RETURNs the engagement id and reads the joined row inside its own transaction, exactly like UpdateEngagementIfUnchanged. (tokencanopy#775's fix comment cited this function as its model; the two now agree.) 2. identity.UpdateAgentProtection did a plain UPDATE and the handler re-read with GetAgent. The protection PUT is a full replace, so a concurrent PUT handed the caller the OTHER writer's entire posture as the authoritative- looking result of their own request; a concurrent trash answered 500 "failed to reload agent" on a committed write. It now runs UPDATE + read in one transaction and returns the agent. 3. Agent PATCH and restore (httpapi/agents_write.go) re-read with GetAgent after the store call. Concurrent rename → the response showed the other name; concurrent trash → 500 after a committed rename; concurrent re-trash → 500 after a committed restore. UpdateAgentName and RestoreAgent now return the agent, read in-transaction. Restore keeps the LIVE projection, so it still proves the agent is visible again. 4/5. identity.UpdateWebhook and identity.UpdateTemplate ran UPDATE … RETURNING id in autocommit and then re-read with a separate Get. Concurrent PATCH → the other writer's config in the response; concurrent DELETE → 404/ template_not_found after a committed update. Both now RETURN the full row from the UPDATE, which is atomic by construction. The webhook column list is now one shared const + scanWebhook, so the read and the RETURNING projection cannot drift. 6. identity.RestoreMessage + the handler's GetMessage. A concurrent re-trash or purge answered 500 "failed to reload message" on a committed restore. RestoreMessage now builds the detail view inside its transaction, using the same projection (read-marking included). Contracts kept identical: no error mapping changes for legitimate cases — only the spurious 404/412/500s vanish. No response shape changes (spec-check green). Lock ordering is unchanged: every added read is a plain SELECT that takes no row locks, so the "one row lock then plain SELECTs" property tokencanopy#775's reviewer confirmed still holds, and the contacts→engagements FOR UPDATE order that DeleteImportBatch relies on is untouched. Deps signature changes are internal only: UpdateAgentName / UpdateAgentProtection now return *identity.AgentIdentity, and RestoreAgent / RestoreMessage move to new returning types (AgentRestoreOp / MessageRestoreOp). Regressions in internal/identity/post_write_read_test.go, one per site, using the blocking-interleaving technique from contacts_reversal_test.go: a holder transaction takes the lock the write path needs, the write path is confirmed blocked on it, a racer is confirmed blocked BEHIND it (lock waits are FIFO), then the holder commits — so the racer lands exactly in the window the buggy shape leaves open. Each racer carries no bind parameters, so it runs and commits entirely server-side and the post-commit re-read loses the race; the racer is identified by a marker comment in its own query text, so "queued behind" is checked on that exact backend rather than on a global waiter count. Each test also asserts the racer really landed, so a run that failed to interleave cannot pass silently. Verified against origin/main for the three sites whose signatures did not change (8 runs each): engagement upsert fails 8/8, webhook update 7/8, template update 5/8. All seven pass deterministically after the fix (6 consecutive runs) and the full internal/identity and internal/httpapi suites are green. Also noted, NOT fixed (different class): UpdateWebhook's re-enable cooldown is check-then-act — a separate SELECT of auto_disabled_at before the UPDATE. Two racing re-enables can both pass it, and more importantly the auto-disable worker can set auto_disabled_at between the check and the UPDATE, which then clears a cooldown that had just been armed. Folding the predicate into the UPDATE needs a new not-found/cooldown disambiguation branch, so it is left for its own change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the two deterministic failures found by the first live staging run of the conformance suite (e2a-ops
release-pipelinerun 30612956986). Must merge before the v1.5.0 tag — the conformance gate fails deterministically on both without it.Issue B — server bug: conditional engagement update returns the pre-update row
identity.Store.UpdateEngagementIfUnchangedread the row back as the outer SELECT of a data-modifying CTE:In Postgres the outer query of a data-modifying CTE runs on the statement snapshot, which predates the CTE's own writes — so the outer SELECT scans the pre-update version of the row. The UPDATE commits correctly; only the returned row is stale (old
stage, oldnext_action_at, oldupdated_at). Since the response ETag derives fromupdated_at, the returned validator can never match the stored row again, breaking every If-Match read-modify-write chain.Live evidence:
tests/e2e-prod/suites/30-contacts-outreach.test.ts:414— the If-Match update returned 200 with stage"prospect"(expected"nurture") and a stale ETag.Fix: split into two statements in one transaction — the UPDATE (version predicate unchanged, so the If-Match race stays closed exactly as before)
RETURNING id, then a read of the full joined row by primary key, which sees the prior statement's writes. This mirrorsUpsertEngagement's write-then-GetEngagementshape, so both paths now have identical read-back semantics. TheErrEngagementPreconditionFailed/ErrEngagementNotFounddisambiguation is byte-for-byte unchanged.Audit of other data-modifying CTEs under
internal/(grepWITH updated|touched|upd|candidates|children|requested):identity/store.goGetPrincipalByAPIKey(WITH touched) — outer SELECT readsFROM touched(the RETURNING output) joined to unmodifiedusers. Safe.identity/store.goGetMessageWithContent(WITH upd) — outer SELECT readsFROM updjoined to unmodifiedwebhook_deliveries. Safe.webhook/store.go+webhookpub/worker.golease queries (WITH candidates) — read-only SELECT … FOR UPDATE CTE feeding the main UPDATE, whose RETURNING carries post-update values. Safe.identity/thread_identity.go(WITH requested),identity/store.godetachThreadChildrenBatchTx(WITH children) — read-only CTEs. Safe.UpdateEngagementIfUnchangedwas the only instance of the stale pattern (outer SELECT over the modified table itself).Regression test:
TestUpdateEngagementIfUnchangedReturnsPostUpdateRow(DB-backed,internal/identity) asserts the accepted conditional update returns the post-update stage/next_action_at, thatupdated_atadvanced pastexpectedUpdatedAt(the ETag moves), that the returned row equals a subsequentGetEngagement, and that chaining a second conditional update on the returned validator succeeds. Verified it fails against the old implementation with exactly the live symptoms (stage stays"prospect", stale validator, chained update → precondition failed) and passes with the fix. Handler-level pinning ininternal/httpapi/engagements_test.gois moot: the handler deps mockUpdateEngagementIfUnchangedoutright and the ETag is computed from whatever the store returns, so the store test is the right (and only meaningful) level.Issue C — test over-strictness: Location-header encoding in suite 30
30-contacts-outreach.test.ts:217-220required the createContactLocationto containencodeURIComponent(address)(%40), but the server emits a raw@via Go'surl.PathEscape— valid RFC 3986 (pchar allows@in a path segment). Live failure:Location: /v1/contacts/ctdup-cd2014-6e046f@example.com.Fixed the test only (no server change): decode the trailing path segment and compare addresses, accepting either spelling. Audited every other
encodeURIComponentuse in the suite — all others build request URLs (encoding a request path is always legal) except the engagement-Location assertion at line 409, which only checks the/v1/agents/prefix and is encoding-agnostic.Client surface checklist
No API surface change — server-internal persistence fix + test relaxation. No spec, SDK, CLI, MCP, or web changes;
api/openapi.yamluntouched.Operational risk
Low. One extra statement inside a short transaction on the conditional-update path only (If-Match requests). No schema change, no migration.
Test evidence
go test -tags integration ./internal/identity/ ./internal/httpapi/— ok (118s / 5s)tests/e2e-prod:tsc --noEmitclean,npm run test:unit18/18 pass,npm run test:unit:gates6/6 OK🤖 Generated with Claude Code