Skip to content

fix(contacts): return post-update row from conditional engagement update; accept RFC-3986 Location in suite 30 - #775

Merged
jiashuoz merged 1 commit into
mainfrom
fix/engagement-cte-etag
Jul 31, 2026
Merged

fix(contacts): return post-update row from conditional engagement update; accept RFC-3986 Location in suite 30#775
jiashuoz merged 1 commit into
mainfrom
fix/engagement-cte-etag

Conversation

@jiashuoz

Copy link
Copy Markdown
Member

Summary

Fixes the two deterministic failures found by the first live staging run of the conformance suite (e2a-ops release-pipeline run 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.UpdateEngagementIfUnchanged read the row back as the outer SELECT of a data-modifying CTE:

WITH updated AS (UPDATE contact_engagements SET ... WHERE ... AND updated_at = $8 RETURNING id)
SELECT <joined columns> FROM contact_engagements ce ... WHERE ce.id = (SELECT id FROM updated)

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, old next_action_at, old updated_at). Since the response ETag derives from updated_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 mirrors UpsertEngagement's write-then-GetEngagement shape, so both paths now have identical read-back semantics. The ErrEngagementPreconditionFailed / ErrEngagementNotFound disambiguation is byte-for-byte unchanged.

Audit of other data-modifying CTEs under internal/ (grep WITH updated|touched|upd|candidates|children|requested):

  • identity/store.go GetPrincipalByAPIKey (WITH touched) — outer SELECT reads FROM touched (the RETURNING output) joined to unmodified users. Safe.
  • identity/store.go GetMessageWithContent (WITH upd) — outer SELECT reads FROM upd joined to unmodified webhook_deliveries. Safe.
  • webhook/store.go + webhookpub/worker.go lease 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.go detachThreadChildrenBatchTx (WITH children) — read-only CTEs. Safe.

UpdateEngagementIfUnchanged was 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, that updated_at advanced past expectedUpdatedAt (the ETag moves), that the returned row equals a subsequent GetEngagement, 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 in internal/httpapi/engagements_test.go is moot: the handler deps mock UpdateEngagementIfUnchanged outright 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-220 required the createContact Location to contain encodeURIComponent(address) (%40), but the server emits a raw @ via Go's url.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 encodeURIComponent use 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.yaml untouched.

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)
  • New regression test fails on old code, passes on fix (see above)
  • tests/e2e-prod: tsc --noEmit clean, npm run test:unit 18/18 pass, npm run test:unit:gates 6/6 OK

🤖 Generated with Claude Code

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>
@jiashuoz
jiashuoz merged commit 32ce45d into main Jul 31, 2026
28 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant