Skip to content

feat(outbound): close the provider seam for every sender (B7) - #1000

Merged
jiashuoz merged 14 commits into
mainfrom
feat/sending-provider-closure
Sep 5, 2026
Merged

feat(outbound): close the provider seam for every sender (B7)#1000
jiashuoz merged 14 commits into
mainfrom
feat/sending-provider-closure

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Sep 5, 2026

Copy link
Copy Markdown
Member

Slice B7 of the sending abuse prevention plan (ops PR #320): close the provider seam B5 opened. Stacked on #999 (B6); base is feat/sending-worker-cutover and will be retargeted to main once B6 merges.

What changes

The relay can no longer send without a token. outbound.SMTPRelay's exported Send* methods and outbound.Sender's Send/SendOnce/SubmitOnce* are deleted. The only socket-opening entry point is ProviderSubmitter.SubmitOnce(ctx, auth, envelope). A new tracked-closure test (internal/outbound/provider_authorization_guard_test.go) parses every production Go file and fails on any net/smtp import or any call to the relay's socket core outside the named exceptions, so a future bypass cannot land silently.

Notification paths cross the gate. hitlnotify (approval emails) and webhooknotify (webhook health notices):

  • the enqueue prepares a customer_notification operation in the same transaction as the source row (PrepareNotificationTx) and stamps it on the job (operation_ref);
  • the workers run the same Reserve → early hold → ConsumeAttempt → authorized submit order as the message worker, snoozing on a hold with no provider I/O;
  • a job from a pre-floor slot resolves its operation at fire time and stamps it once (jobs.StampJobArg, key-absent guard, existing fields preserved).

Public feedback mail (POST /api/feedback) is a public_feedback_notification operation keyed by a server-minted submission id; its envelope is the configured notify set, never the request. The in-request retry loop is bounded (4 attempts), every physical attempt is its own charged ordinal with its own attempt id on the wire, and a definite rejection or a lost acceptance stops the loop.

Operator command e2a -reconcile-legacy-sending-jobs: stamps an operation onto every pending outbound_send / hitl_notify / webhook_notify job that has none (through the exact Prepare path its enqueue would have used), cancels orphans whose source row is gone, leaves paused-account jobs to the worker's hold path, prints counts, and exits nonzero unless every scanned job was decided. Each job is one transaction; a failure leaves it untouched for a rerun.

Wiring: cmd/e2a/main.go, testutil.TestServer, and the contract server build the notifiers over the shared ProviderSubmitter and hand the API the submitter + gate (SetProviderSubmitter).

Review round 1 (correctness + adversarial, Opus) → fixes in the second commit

  • Notification operations are keyed by their source (op_hitl_<msg>, op_wh_<kind>_<webhook>_<episode>): PrepareNotificationTx is idempotent per hold / per health episode, and both notify workers cancel a job whose reference names any other operation — the binding the B6 message worker has.
  • Compose before any charge: Deliverer is now Compose + Submit; the notify workers run compose → Reserve → hold → ConsumeAttempt → Submit, so an owner-lookup / signing / DKIM failure costs no ordinal and the token is consumed immediately before the socket. The startup "notifier not wired yet" retry now also costs nothing.
  • Reconcile command re-reads each job under FOR UPDATE inside its own transaction and skips one a worker claimed or stamped meanwhile; output separates paused / skipped from remaining.
  • Feedback loop submits the token's canonical recipient set (an overlapping TO/CC config no longer fails every attempt), paces retries to fit the handler budget, keeps the real SMTP error on a deadline, releases a reserved attempt when authorize errors, and returns instead of panicking on an id-mint failure.
  • Closure guard: never skips (walks up to go.mod, git optional), matches method values / method expressions, exception is the SubmitOnce symbol not the file, asserts its sentinel is live, and fences the SES v2 SDK import to sender-identity provisioning.
  • Webhook health notices older than seven days are dropped instead of snoozing forever behind a paused account.
  • Wiring test for the three new composition-root edges (both notify bundles' gate, the API's submitter), plus StampJobArg tests for the internal/jobs coverage floor.

Review round 2 (mutation-tested re-review, Opus) → third commit

Every round-1 fix held under mutation. One blocker found in the fix commit itself: migration 113 (v1.8.7) stamped adopted notify jobs with op_<md5> references, which the new binding would have cancelled on any upgrade crossing v1.8.7. Now a reference that is not a derived id is treated as pre-derivation: the worker re-resolves it through the Prepare path and replaces it once (jobs.SetJobArg); the reconcile command scans and re-keys those too. Also: bounded the feedback attempt release (2s), symmetric 7-day age guard on HITL notices, episode key in microseconds, nil-receiver guards on the new compose path, doc/comment corrections, and the guard's residual scope stated in its comment.

Design-level, not changed here (needs a decision): public feedback charges the two global customer pools by spec (design §"public feedback consumes both global pools", A4 gate). The adversarial reviewer measured 8 probation units per request (4 attempts × 2 recipients) behind the 10/hr/IP limit, i.e. one IP can drain the 150/day probation pool in under 8 hours once budget_mode: enforce is armed. Options: a dedicated small feedback pool, a tighter per-IP limit, or a daily platform cap on feedback mail. Dormant while the policy is disabled.

Behaviour note: notification and feedback mail now carries X-SES-CONFIGURATION-SET, so SES publishes delivery feedback for it; it correlates to no message row and the SNS consumer acks it as unknown (a log line, no suppression).

Not in this PR

  • No public API change (no OpenAPI / SDK / CLI / MCP delta).
  • No policy activation; every environment still runs the disabled policy (pass-through admission, attempts durable).

Tests

  • internal/outbound: closure guard; relay tests over the unexported core.
  • internal/hitlnotify, internal/webhooknotify: gated-order worker tests (authorize-then-deliver, early/late hold and gate error snooze without I/O, legacy resolve+stamp once), notifier tests through the submitter, e2e harness over the gate.
  • internal/agent: feedback seam tests with a scripted SMTP server (retry is a new ordinal, 5xx not retried, lost acceptance not retried, bounded retries, envelope is configuration; wire carries the attempt header).
  • cmd/e2a: reconcile command (stamp live, cancel orphan, skip finalized and non-submitting kinds, stamped ref round-trips, second pass empty; undecided job reported and untouched, nonzero exit).

Local: fmt/vet clean; affected suites and -race on the three notify/outbound packages green. internal/e2e has known environmental failures on clean main locally; CI is the authority.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

jiashuoz and others added 13 commits September 4, 2026 22:01
…asons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
… paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Slice B7 of the sending abuse prevention plan. The relay no longer exports
a send method; ProviderSubmitter.SubmitOnce with a gate token is the only
way to reach the provider, and a tracked-closure test parses every
production file to keep it that way.

- hitlnotify + webhooknotify: enqueue prepares a customer_notification
  operation in the source transaction and stamps it on the job; workers
  run Reserve -> early hold -> ConsumeAttempt -> authorized submit and
  snooze on a hold without provider I/O; pre-floor jobs resolve at fire
  time and are stamped once (jobs.StampJobArg).
- public feedback: server-keyed public_feedback_notification operation
  with a bounded per-attempt Reserve/Consume/Submit loop; a definite
  rejection or a lost acceptance stops it.
- e2a -reconcile-legacy-sending-jobs: stamps pending outbound_send /
  hitl_notify / webhook_notify jobs that carry no operation, cancels
  orphans, exits nonzero unless every scanned job was decided.
- main, TestServer and the contract server build the notifiers over the
  shared submitter and hand the API the submitter + gate.
- design addendum in docs/design/async-message-pipeline.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Review round 1 of the provider-seam closure (B7).

- PrepareNotificationTx derives the operation id from its source
  (op_hitl_<message>, op_wh_<kind>_<webhook>_<episode>), so a repeat
  preparation yields one operation and the notify workers cancel a job
  whose reference names any other operation.
- The notify Deliverer is Compose + Submit; workers run compose ->
  Reserve -> hold -> ConsumeAttempt -> Submit, so a compose failure
  charges nothing and the token is consumed right before the socket.
- Reconcile command re-reads each job under FOR UPDATE and skips one a
  worker claimed or stamped meanwhile; counts separate paused/skipped.
- Feedback loop submits the token's canonical recipients, paces retries
  to fit the handler budget, keeps the SMTP error on a deadline, releases
  a reserved attempt when authorize errors, no panic on id mint.
- Closure guard never skips, matches method references, exempts the
  SubmitOnce symbol only, asserts its sentinel, fences the SES v2 import.
- Webhook health notices older than seven days are dropped.
- Wiring test for the notification bundles and the API seam; StampJobArg
  tests for the jobs coverage floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Re-review of the provider-seam closure (B7) found that migration 113
stamped adopted notify jobs with op_<md5> references, which the new
source binding would have cancelled on any upgrade crossing v1.8.7.

- A reference that is not a derived id is treated as pre-derivation: the
  notify workers re-resolve it through the Prepare path and replace it
  once (jobs.SetJobArg); a derived id for another source still cancels.
- The reconcile command scans and re-keys those references too.
- Bounded the feedback attempt release (2s); symmetric 7-day age guard on
  HITL notices; episode key in microseconds; nil-receiver guards on the
  compose path; doc and comment corrections; the closure guard states
  its residual scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
Round-3 re-review nits: COALESCE the conforming-reference predicate so a
reference with no id cannot fall out of a NOT scan, decode only the
source fields so such a reference is replaced rather than failing to
decode, and state in the workers that any non-derived shape re-derives
from the job's own source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX
@jiashuoz
jiashuoz changed the base branch from feat/sending-worker-cutover to main September 5, 2026 18:36
…-closure

# Conflicts:
#	cmd/e2a/outbound_wiring.go
#	cmd/e2a/sending_policy_wiring_test.go
#	docs/design/async-message-pipeline.md
#	internal/testutil/contract_server.go
#	internal/testutil/server.go
@jiashuoz
jiashuoz merged commit 0420cc3 into main Sep 5, 2026
29 checks passed
@jiashuoz
jiashuoz deleted the feat/sending-provider-closure branch September 5, 2026 18:49
jiashuoz added a commit that referenced this pull request Sep 10, 2026
…#980)

* fix(tether): resolve a working Python 3 instead of hardcoding python3

On Windows/Git Bash, python3 is commonly the Microsoft Store App
Installer redirector shim: it is on PATH, so `command -v python3`
succeeds, but it exits non-zero with no output on every invocation.
Every call site in lib.sh, tether.sh, and hooks/tether-notify.sh
invoked python3 directly with no exit-code check (this file
deliberately runs without `set -e`), so the shim's failure was
silent: helpers returned empty strings and execution limped forward
into misleading downstream errors instead of stopping.

Add t_python, which resolves and memoizes a Python 3 that actually
executes (tries $E2A_PYTHON, then python3, then python, verifying
each by running it, not just locating it) and route all ~20 python3
call sites through it. Add a _selftest check that reproduces the
broken-shim scenario and confirms both the fallback and the
loud-failure path.

Refs #367

* fix(auth): cascade configured OIDC logout (#984)

* fix(auth): cascade configured OIDC logout

* fix(auth): harden OIDC logout handoff

* fix(auth): use canonical web origin for logout

* fix(auth): normalize callback origin for logout

* test(auth): cover logout provenance edges

* fix(auth): request Google's account chooser on legacy login (#987)

Pass prompt=select_account when generating the Google authorization URL in HandleLogin. Without this parameter, Google silently re-authenticates the single currently signed-in account if consent was previously granted, preventing users from switching accounts after signing out.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: pi (gemini-3.8-flash) <noreply@tokencanopy.com>

* chore(release): bump ts-sdk 5.9.0, cli 2.5.1, python 5.8.1 (#988)

Cuts the three client packages against the diff since each one's last
PUBLISHED tag (ts-sdk-v5.8.0, cli-v2.5.0, python-v5.8.0) rather than since
the last server release, per the #935 post-mortem.

@e2a/sdk 5.9.0 (minor). #936 moves the dot-segment path guard down to the
generated RequestContext chokepoint, so every generated request is covered
instead of the ten hand-written wrappers 5.8.0 enumerated. Minor rather than
patch for the same reason 5.8.0 was: a path parameter of exactly "." or ".."
now throws instead of issuing a misdirected request. Generated model and
operation docs also pick up the outbound recipient-delivery metering
semantics and the messages_day / auth_unavailable vocabulary.

@e2a/cli 2.5.1 (patch). #947 stops the CSV parser leaking a raw carriage
return out of a quoted multi-line field, plus the #937 --send-at / review-hold
documentation correction.

e2a 5.8.1 (patch). Generated pydantic Field descriptions refreshed from the
current OpenAPI document; no name, signature, type, validation, or runtime
behavior differs from 5.8.0.

cli/package.json keeps its "@e2a/sdk": "^5.7.0" range: check-sdk-version-sync
compares majors, ^5.7.0 already resolves to 5.9.0 on a fresh install, and
leaving it avoids creating a window where cli-v2.5.1 is unsatisfiable.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(sending): add durable provider authorization gate (#990)

B3 of the sending-protection plan: the budget ledger, the durable
submission attempt, and the final pre-I/O authorization that later slices
hang the provider paths off.

The module answers one question — may this operation open a socket right
now — and answers it with a single-use token bound to a durable attempt
rather than with a boolean. That shape removes the check-then-act window:
a pause, a plan downgrade, a policy activation, a midnight rollover, or a
competing worker all invalidate the token instead of racing it.

- types.go: the closed vocabulary. Every ref has unexported fields; the
  only serialization is OperationRef's versioned {v,id} form, which
  survives a River process replacement while granting no authority.
- budget.go: pool mapping, per-pool limits, and one ordered locking pass
  over every counter row a transaction may touch, with the arithmetic
  applied afterwards so an all-or-nothing decision needs no savepoint.
- operations.go: purpose, attribution, and reputation class derived once
  from a locked source row and persisted immutable.
- gate.go: Reserve allocates the durable ordinal, ConsumeAttempt
  re-derives everything under the normative lock order, RedeemProviderCall
  re-proves the chain immediately before the socket.

Enforcement is off: generation zero has budget_mode disabled, which
charges nothing and writes no counter rows while still exercising the
authorization seam. Nothing calls the Gate yet and the compiled contract
stays at 0.

Three review passes (two parallel, one mutation-tested verification pass)
found eleven defects, all fixed here with regression tests — including a
50x envelope amplifier, a Free account able to park the whole platform
pool in reservations, a stale token able to retire another worker's live
attempt, a reserve-delete-repeat strand against the shared pools, and a
hold that made a message permanently unsendable.

256 tests, green under -race.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat: onboarding acquisition survey (/welcome, users.acquisition_*, onboarding_survey flag) (#993)

* docs(plans): onboarding acquisition survey implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): acquisition survey option list and /me types

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(db): add users acquisition survey columns (migration 120)

Deviation from the plan: the plan specified migration slot 108, but
108-119 were already claimed by unrelated migrations landed since the
plan was written. Uses the next free slot (120) per
TestEmbeddedMigrationNumbersAreUniqueFrom108; behaviour and test
contract are otherwise unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): gate the app shell on the onboarding survey

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): /welcome onboarding survey page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(identity): write-once acquisition survey store method

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(config): onboarding_survey.enabled flag (default off)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(auth): onboarding survey on /api/auth/me (pending flag, write-once PATCH)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* docs(plans): onboarding survey migration landed as 120

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* fix(onboarding-survey): apply review findings

- PATCH /api/auth/me writes the survey before the name so a 409 never
  leaves a partial name update; maps a vanished user row to 401; caps the
  body at 64 KB; rejects control characters in detail (NUL was a 500)
- 404/409 bodies use the handler's plain-text http.Error style, not a
  one-off JSON envelope
- enum test reads the CHECK list out of migration 120 instead of a second
  hand-copied literal; every-value handler test uses one DB, not ten
- web: pathname null guard + trailing-slash normalization in the gate,
  visible 0/200 counter on the detail field, Skip clears a stale error,
  migration comment says 120

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp): build the image from the committed lockfile (#994)

The MCP image had been unbuildable since 05:15, and would have broken
again after every client publish.

mcp/Dockerfile ran `npm install --package-lock=false --workspaces` in both
stages, discarding the committed lockfile and re-resolving the whole
workspace graph from the live registry on every build. That works only
while the workspace's own @e2a/sdk version does not also exist upstream.
Once both are true, live resolution has two candidates for one edge and
arborist dies with `Cannot read properties of null (reading 'edgesOut')`.

Timeline: last successful build 05:14:03; @e2a/sdk@5.9.0 published to npm
05:15:45; every build since failed, reruns included, with no repo change
in between.

Both stages now use `npm ci`. The committed lockfile was never ambiguous:
lockfileVersion 3, all four workspaces, node_modules/@e2a/sdk linked to
sdks/typescript. Also copies design-system/package.json, listed in the
root workspaces array since #333 but never copied by any build stage.

release.env pins the server and MCP images to the same version, so a
release whose MCP image cannot build cannot be promoted at all. This
blocked v1.8.8.

Verified locally in both directions (the workflow does not run on PRs):
old Dockerfile reproduces the failure, new one builds, the container
answers /healthz 200, and @e2a/sdk resolves to the workspace symlink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(web): move protection save button to top right with dirty state tracking (#995)

* feat(blog): add 'Your approval gate shouldn't live in your agent's code' (#997)

Argues the HITL review checkpoint belongs in the email infrastructure
(enforced on the send path, pending_review + 202) rather than in the
agent's application code. Names the beta surface and the #989
hold-everything config riddle openly.

* fix(sendramp): make a disabled sending ramp a true no-op (#996)

The per-domain sending ramp is off by default (`sending_ramp.enabled`
defaults to false and prod sets no `sending_ramp` block), but its
disabled branch was not a no-op: `outboundRampGate.Reserve` called
`sendramp.Store.Exempt` on every eligible send, flipping the sending
domain from 'inactive' to 'exempt'. Eligibility is
`sent_as == "own_address" && message_type != "test"`, so every verified
custom domain that sends at all is permanently stamped.

That is a grandfathering decision taken silently, once per send, by the
send path. It matters more now that 'exempt' means "established sender"
beyond the ramp itself: an exempt domain both loses its daily stage cap
and stops consuming the shared probation pool that bounds Sybil abuse,
and re-registering a domain makes it exempt-eligible again — a reset
primitive. It also pre-empts the audited one-shot that exists for
exactly this decision: sendingpolicy's
ActivationRequest.GrandfatherCurrentSendingDomains, which is marked for
replay and takes SHARE ROW EXCLUSIVE on `domains`.

Disabled now means pass-through, matching the newer gate's semantics:
allow the send, reserve nothing, count nothing, stamp nothing. Domains
stay 'inactive' (and read as 'inactive' through
`GET /v1/domains/{domain}.sending_ramp.status`) so the operator can make
that call deliberately. Confirm/Release/Resolve keep delegating: a
reservation taken before the ramp was switched off still has to settle,
and with the ramp disabled there is no reservation row to touch.

`Store.Exempt` is kept — no automatic caller, documented as the
store-level primitive for an explicit single-domain operator exemption.

Two regression tests, both failing before this change with
`sending_ramp_status = "exempt"`: the gate directly, and a real
ramp-eligible send driven through the send worker. Each asserts the
domain stays 'inactive' and that no `sending_ramp_scopes`,
`domain_send_counters`, or `sending_ramp_reservations` rows appear.

Rows already stamped 'exempt' by a running deployment are NOT remediated
here; the runbook now says so and describes the choice.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sending): compose ramp with protection gate (#992)

* feat(sending): compose ramp with protection gate

B4 of the sending-protection plan. The custom-domain ramp and the sending
budget answer different questions — "has this domain earned this volume
yet?" versus "has this account or the platform exposed SES enough today?"
— and this slice makes one transaction answer both, most restrictive
winning.

One lock order for the ramp. The store previously used three: Reserve
took domain → scope → reservation → counter, Confirm took reservation →
counter → scope, Release took reservation → counter. Three orders over
four keys is a deadlock waiting for traffic, and it becomes unavoidable
once the gate composes both ledgers into one transaction, because that
transaction already holds highly contended global budget counters when it
reaches the ramp. internal/sendramp/tx.go now holds the single order
(domain identity → registrable-domain scope → message reservation → UTC
day counter) and the pool-owning methods are thin wrappers, so there is
no second implementation to drift.

Composition, in internal/sendingpolicy/ramp.go:

- Probation is now the ramp's answer rather than a stand-in. Shared-relay
  traffic stays probationary at every plan level and never graduates; a
  custom domain is probationary until its scope has one qualified day.
  That classification decides which budget counters the transaction must
  lock, and the budget counters come BEFORE the ramp keys in the
  normative order — so it is read unlocked. That is sound only because
  ramp progress is monotonic: a stale answer can be stale in the strict
  direction and no other.
- The ramp is authorized last, after the budget has been reacquired. A
  ramp hold therefore arrives with budget units already taken, and those
  are released before returning the hold — keeping them would charge an
  account for a send its own domain was not allowed to make.
- SettleProvider now moves the ramp: acceptance advances a qualified day,
  a definite permanent rejection releases the units, and retryable or
  ambiguous results leave the reservation standing, because a message
  that might have been delivered must not release capacity.
- CancelAttempt releases both ledgers; DeferAttempt still releases only
  the budget. A rate deferral was not rejected by anyone, and giving back
  its ramp claim would let the same message re-qualify a stage it already
  qualified.

Disabled mode is genuinely pass-through: no scope row, no counter, and
above all no `exempt` write. Writing `exempt` while the ramp is off would
permanently grandfather every domain that happened to send during the
disabled window, and the phase-3 activation would then find nothing left
to ramp. Production ships in exactly this state.

A fixture note worth keeping: the ramp ledger is keyed by REGISTRABLE
domain, so `ramp-1.example.test` and `ramp-2.example.test` are one scope.
The first version of these tests read the hostname key, found empty rows,
and would have passed for the wrong reason. Each fixture domain is now
its own eTLD+1, and the one test that is about sharing builds two
hostnames under a single registrable domain deliberately.

Tests: 264 in the package plus the existing ramp suite, green under
-race. Covers the 150/213/277 stage caps and their 75/107/139
qualification bars, every probation class, budget-allow/ramp-hold and its
mirror, settlement idempotency, permanent-rejection release,
defer-versus-cancel, subdomain scope sharing, and a Free account that can
qualify stage one but not stage two without losing its progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(sending): keep the ramp binding under retry, refusal, and rebind

Review of the ramp composition found seven ways the stage cap stopped being
a cap. All of them come from one shape: the sending budget is keyed by
submission ATTEMPT while the ramp reservation is keyed by MESSAGE, and code
that treats the two as interchangeable gives away capacity that was already
spent.

The refund. CancelAttempt released the ramp for any cancelled attempt.
Attempt one authorizes and hands 100 recipients to SES, the result is
ambiguous so nothing settles and the reservation correctly stands, River
allocates attempt two, a suppression cancels it — and the ramp counter goes
to zero for mail that is already in flight. It repeats, so the cap became
advisory. cancelRamp now asks about the OPERATION rather than the ordinal: if
any attempt was ever authorized, only SettleProvider may give those units
back. A reservation no attempt has authorized — the shape today's outbound
worker produces before this module is involved — is still refundable, which
is also where the mirror bug lived: the `released` early return meant a
cancel following a deferral never reached the ramp at all.

The stranding. A permanent sendramp error (`reservation already released` is
reachable by Reserve-after-Cancel on one operation) came back as a hard
error, rolling the transaction back with the attempt still `reserved`. Every
later execution failed identically, so nothing could ever release its units:
50 recipients pinned on global_all, global_probation, and account_daily
until midnight, three of them enough to close probationary sending for the
platform. Permanent refusals are now a terminal hold that releases, exactly
as the envelope path already answers the same class of loss.

The rebind. A domain with an unverified SENDING identity was ramp
pass-through AND, because InspectScopeTx never read the column, reported
established once its scope had a qualified day — no cap and no probation
charge. The wire identity is frozen at acceptance but the agent's registered
domain is not: verifying a child subdomain rebinds the account's agents onto
it, the child's SES identity stays pending while its DKIM is never
published, and the ramp resolves the domain live. An accepted backlog went
out uncapped under the parent's frozen From. It now holds, and classifies as
probationary. The two legacy states that mean "this domain already earned
its volume" — `exempt` and `complete` — are checked first and are untouched.

Three smaller ones. Reserve still classified probation as `op.Shared` with a
comment deferring to a task this commit is; the early hold therefore never
bounded the probation pool, the stored column disagreed with the class every
release targets, and each authorization paid a needless release-and-reacquire
on the platform's hottest counter rows. The ramp's own source read answered a
vanished message with a RETRYABLE hold, and because it runs before the
envelope resolution that answers it terminally, the wrong answer won whenever
the ramp was armed — a worker snoozing forever instead of failing once. And
in the ledger itself: a released-to-confirmed restoration errored instead of
no-opping when maintenance had reaped the day's counter (the reservation
outlives it by design), while ReleaseTx recorded `released` even when its
guarded decrement matched nothing, so a later restoration added back units
the counter never returned.

Tests: every fix above has a regression test that fails before it. The file
also gained the coverage Task 4 Step 2 asks for and did not have — racing
workers against one stage cap with the budget counters contended (exactly one
cap admitted, no deadlock), cross-midnight re-age of BOTH ledgers in one
authorization, settlement arriving days after its attempt, and a
FOR UPDATE NOWAIT probe that proves the named domain → scope → reservation →
day-counter suborder is actually taken rather than merely documented. Weak
assertions were tightened: the probation tests now assert the hold REASON,
the ramp-hold test checks all three pools it charged, and the Free-plan test
proves progression RESUMES after upgrade rather than merely surviving it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendramp): pin InspectScopeTx classification in its own package

InspectScopeTx decides which budget pool a send charges before the ramp
lock is taken, but every test of it lived in sendingpolicy, so the
per-package coverage gate saw it at 0% and failed the package at 76.6%.

Pin each branch where the code lives: missing domain, legacy exempt,
stamped complete, unverified identity with an old scope (must stay in
probation and ignore the scope), verified with no scope (day zero), a
completed scope behind a still-ramping domain row, the day-zero/day-one
boundary, and registrable-domain scope lookup with a hostname-keyed
decoy. Removing the unverified guard makes the fourth test fail.

Package coverage: 76.6% -> 84.9%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendingpolicy): redeem before settling in the ramp tests

Settlement reports what the provider did, so it is only meaningful for an
attempt that opened the socket. The adapter (B5, #998) redeems the token
immediately before it dials, and the gate there refuses to settle an
attempt whose call_state is not 'started'. These tests settled straight
after ConsumeAttempt; route them through a consumeAndRedeem helper so the
two slices can land in either order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(outbound): require single-use provider authorization (B5) (#998)

* test(testutil): extract the database helper into a leaf package

testutil bundles the contract server, which imports outbound. Once
outbound imports sendingpolicy (the provider seam requires the gate's
token), any INTERNAL test file in outbound or sendingpolicy that calls
testutil.TestDB closes an import cycle.

Move db.go into internal/testutil/testdb, a leaf that depends only on
identity and migrations, and keep testutil.TestDB / TestDBURL /
OpenPreparedTestDB / TruncateAll as forwarding wrappers so every
existing caller is unchanged. The contract server and its River test
use the two newly exported helpers (Truncate, BaseTestDBURL) instead of
package-private ones. The one internal sendingpolicy test imports
testdb directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): require single-use provider authorization

Add ProviderSubmitter, the one path from a composed message to an SMTP
transaction with SES. It requires a sendingpolicy.ProviderAuthorization
for every call, proves the envelope is the authorized one, derives
X-E2A-Provider-Attempt and X-SES-TENANT only from the token (and
X-SES-CONFIGURATION-SET from configuration), strips every occurrence of
those headers — mixed case and folded forms — from the composed MIME,
redeems the token, and only then dials. Every early exit is I/O-free
and leaves the token intact; a misconfigured relay is refused before
redemption so a retry does not burn an ordinal.

A definite permanent rejection is settled as such; an acceptance is
settled with the provider's message id, which SettleProvider now binds
to the attempt's feedback correlation exactly once (same id replays,
a different id is refused). A settlement that fails after acceptance
is reported on the result, never as a send error, so the caller retries
settlement rather than resubmitting.

The legacy tokenless Sender.SubmitOnce path stays for now; Task 7
migrates its callers and makes the relay's socket-opening methods
package-private.

Tests: zero-network on missing, mismatched, stale, and reused tokens
(asserted against a socket counter); exact and single attempt/tenant
headers with smuggled spellings removed and the body untouched; a
physical retry redeems a distinct ordinal; provider id binding and its
conflict rule. Removing the header strip, reordering redemption before
envelope validation, or dropping the id binding each fail a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the provider seam after review

Two review passes (correctness + adversarial) over the first cut. Every
finding below has a named test, and removing each fix fails it.

Gate:
- RedeemProviderCall re-proves the abuse pause. ConsumeAttempt linearized
  it, but that transaction has committed; a pause landing before the dial
  now invalidates the token. Unlocked read, because the account-control
  key precedes the operation key this transaction already holds.
- SettleProvider requires call_state = 'started', not just a confirmed
  reservation: a provider id bound to an attempt that never dialed is a
  ledger claim about a send that did not happen.
- The provider message id is normalized to SES's bare form on bind and on
  compare (NormalizeProviderMessageID). The relay returns it qualified
  (<id@region.amazonses.com>); SNS feedback carries it bare; the worker
  and the feedback finalizer must not refuse each other's spelling.
- A tenant name that cannot be a header value holds at mint with
  ses_tenant_unnamed instead of wedging silently in the adapter.

Adapter:
- X-E2A-Message-ID is derived from the token (a customer operation IS its
  message id), not trusted from the caller; Envelope.MessageID is gone.
- RCPT TO is issued from AuthorizedRecipients(), the canonical envelope
  the budget priced, never the caller's spelling of it.
- A bare CR anywhere in the header section, or a leading continuation
  line, is refused before redemption (ErrMalformedHeaderSection): a
  receiver that treats a lone CR as a line break would see a header this
  walker did not.
- Provider headers are emitted in the legacy path's order (configuration
  set first) so the worker swap is byte-identical for shared headers.
- The relay marks a failure after the terminating dot with
  ErrProviderAcceptanceUnknown; the adapter leaves it unsettled and no
  classifier calls it permanent.

Tests: pause between consume and submit; settle without redeem; bare CR
and leading continuation refused with zero sockets; canonical RCPT on the
wire; lost 250 unsettled and marked; 4xx unsettled; accepted-but-unsettled
reported on the result with a nil error; a positive control proving the
socket counter observes a dial; provider id normalization across the three
spellings. The socket counter now waits for the accept goroutine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): keep the acceptance-unknown marker honest

Re-review of the previous fix commit found the marker leaking in both
directions. The relay's deferred context remap replaced the error
wholesale, so a deadline or cancellation after the terminating dot — the
likeliest way to lose a 250 — dropped ErrProviderAcceptanceUnknown and
classified as a connection outage the worker would re-drive. And the
marker was joined to every post-DATA failure, including a coded 554
content rejection, which is the provider's definite answer and must
classify permanent. Now the marker survives the remap and is attached
only when no reply code came back.

Also pins the customer-only guard on the redeem-time pause re-check: the
notice telling an account it was paused is sourced from that paused
account and must still go out. Reviewer-authored test adopted. And the
compare-side normalization of the provider id now has a test that writes
a qualified spelling directly and replays bare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(outbound): enforce sending policy at fire time (B6) (#999)

* feat(messagelifecycle): add policy-budget and sending-setup expiry reasons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(sendingpolicy): settle and look up operations by id for evidence paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(identity): carry finite-hold state on the send claim

The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): enforce sending policy at fire time

The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* style(messagelifecycle): gofmt the reason catalog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(api): publish sending_paused and the two hold-expiry reasons

The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the worker cutover after review

Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): settle, wire, and mark precisely after re-review

Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(outbound): close the provider seam for every sender (B7) (#1000)

* feat(messagelifecycle): add policy-budget and sending-setup expiry reasons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(sendingpolicy): settle and look up operations by id for evidence paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(identity): carry finite-hold state on the send claim

The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): enforce sending policy at fire time

The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* style(messagelifecycle): gofmt the reason catalog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(api): publish sending_paused and the two hold-expiry reasons

The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the worker cutover after review

Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): settle, wire, and mark precisely after re-review

Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): close the provider seam for every sender

Slice B7 of the sending abuse prevention plan. The relay no longer exports
a send method; ProviderSubmitter.SubmitOnce with a gate token is the only
way to reach the provider, and a tracked-closure test parses every
production file to keep it that way.

- hitlnotify + webhooknotify: enqueue prepares a customer_notification
  operation in the source transaction and stamps it on the job; workers
  run Reserve -> early hold -> ConsumeAttempt -> authorized submit and
  snooze on a hold without provider I/O; pre-floor jobs resolve at fire
  time and are stamped once (jobs.StampJobArg).
- public feedback: server-keyed public_feedback_notification operation
  with a bounded per-attempt Reserve/Consume/Submit loop; a definite
  rejection or a lost acceptance stops it.
- e2a -reconcile-legacy-sending-jobs: stamps pending outbound_send /
  hitl_notify / webhook_notify jobs that carry no operation, cancels
  orphans, exits nonzero unless every scanned job was decided.
- main, TestServer and the contract server build the notifiers over the
  shared submitter and hand the API the submitter + gate.
- design addendum in docs/design/async-message-pipeline.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): key notification operations by source and charge last

Review round 1 of the provider-seam closure (B7).

- PrepareNotificationTx derives the operation id from its source
  (op_hitl_<message>, op_wh_<kind>_<webhook>_<episode>), so a repeat
  preparation yields one operation and the notify workers cancel a job
  whose reference names any other operation.
- The notify Deliverer is Compose + Submit; workers run compose ->
  Reserve -> hold -> ConsumeAttempt -> Submit, so a compose failure
  charges nothing and the token is consumed right before the socket.
- Reconcile command re-reads each job under FOR UPDATE and skips one a
  worker claimed or stamped meanwhile; counts separate paused/skipped.
- Feedback loop submits the token's canonical recipients, paces retries
  to fit the handler budget, keeps the SMTP error on a deadline, releases
  a reserved attempt when authorize errors, no panic on id mint.
- Closure guard never skips, matches method references, exempts the
  SubmitOnce symbol only, asserts its sentinel, fences the SES v2 import.
- Webhook health notices older than seven days are dropped.
- Wiring test for the notification bundles and the API seam; StampJobArg
  tests for the jobs coverage floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): re-key pre-derivation notification references

Re-review of the provider-seam closure (B7) found that migration 113
stamped adopted notify jobs with op_<md5> references, which the new
source binding would have cancelled on any upgrade crossing v1.8.7.

- A reference that is not a derived id is treated as pre-derivation: the
  notify workers re-resolve it through the Prepare path and replace it
  once (jobs.SetJobArg); a derived id for another source still cancels.
- The reconcile command scans and re-keys those references too.
- Bounded the feedback attempt release (2s); symmetric 7-day age guard on
  HITL notices; episode key in microseconds; nil-receiver guards on the
  compose path; doc and comment corrections; the closure guard states
  its residual scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(cmd): keep the reconcile scan two-valued and replaceable

Round-3 re-review nits: COALESCE the conforming-reference predicate so a
reference with no id cannot fall out of a NOT scan, decode only the
source fields so such a reference is replaced rather than failing to
decode, and state in the workers that any non-derived shape re-derives
from the job's own source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(blog): add 'Your agent's inbox is storage, not transport' (#1007)

Argues inbox polling is a transport problem, not a discipline
problem, and lays out e2a's four inbound delivery channels (signed
webhooks, WebSocket with no public URL, REST polling, MCP) plus
e2a listen for the laptop case.

* fix(sendingpolicy): lock the agent FOR NO KEY UPDATE in the accept path (#1006)

* fix(sendingpolicy): lock the agent FOR NO KEY UPDATE in the accept path

The v1.9.0 staging conformance gate failed on eight parallel HITL holds:
SQLSTATE 40P01. Each accept transaction inserts its message first, which
takes a FOR KEY SHARE lock on the agent row through the foreign key and a
row lock on account_usage through the storage trigger, then prepares its
operation, which locked the agent FOR UPDATE. FOR UPDATE conflicts with
KEY SHARE, so two concurrent sends waited on each other. The direct send
path (PrepareExternalTx) has the identical shape and deadlocks the same
way under parallel sends; staging simply never ran that case.

FOR NO KEY UPDATE keeps every ordering the gate needs (callers serialize
against each other and against any update or delete of the row) and does
not conflict with a foreign-key share. Same change for the webhook row.

Two regression tests reproduce the deadlock deterministically at the
gate and through the API, and both fail with the old lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendingpolicy): force the deadlock interleaving instead of timing it

Review of the lock-order fix: the gate test's 300ms sleep could let A
finish before B ever blocked, passing vacuously against the bug. B now
reports its backend pid and A waits until pg_stat_activity shows it
blocked on a lock. The e2e test no longer calls t.Fatal from worker
goroutines, and the PrepareExternalTx ordering comment now describes the
function rather than every caller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(conformance): cover parallel direct sends and record the lock rule

The staging gate only sent in parallel from a HITL agent; the direct
accept path has the same insert-then-lock shape and carries almost all
traffic. Add the eight-parallel-direct-sends case, write the accept
transaction's lock order into the pipeline design doc, and record the
FOR KEY SHARE / FOR UPDATE rule in AGENTS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* docs(readme): highlight hosted service and MCP setup (#1008)

* docs(readme): highlight hosted service and MCP setup

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* docs(readme): match Token Canopy brand colors

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

---------

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* docs(readme): use warm gold for logo and hosted button (#1010)

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* deps: bump the go-minor-patch group with 8 updates (#1004)

Bumps the go-minor-patch group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.32.39` | `1.33.2` |
| [github.com/aws/aws-sdk-go-v2/service/sesv2](https://github.com/aws/aws-sdk-go-v2) | `1.67.1` | `1.71.0` |
| [github.com/aws/aws-sdk-go-v2/service/sts](https://github.com/aws/aws-sdk-go-v2) | `1.45.8` | `1.48.0` |
| [github.com/aws/smithy-go](https://github.com/aws/smithy-go) | `1.27.10` | `1.28.1` |
| [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) | `3.20.0` | `3.21.0` |
| [github.com/riverqueue/river](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |
| [github.com/riverqueue/river/riverdriver/riverpgxv5](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |
| [github.com/riverqueue/river/rivertype](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |


Updates `github.com/aws/aws-sdk-go-v2/config` from 1.32.39 to 1.33.2
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.39...config/v1.33.2)

Updates `github.com/aws/aws-sdk-go-v2/service/sesv2` from 1.67.1 to 1.71.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.67.1...service/s3/v1.71.0)

Updates `github.com/aws/aws-sdk-go-v2/service/sts` from 1.45.8 to 1.48.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/sts/v1.45.8...service/s3/v1.48.0)

Updates `github.com/aws/smithy-go` from 1.27.10 to 1.28.1
- [Release notes](https://github.com/aws/smithy-go/releases)
- [Changelog](https://github.com/aws/smithy-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/aws/smithy-go/compare/v1.27.10...v1.28.1)

Updates `github.com/coreos/go-oidc/v3` from 3.20.0 to 3.21.0
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.20.0...v3.21.0)

Updates `github.com/riverqueue/river` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

Updates `github.com/riverqueue/river/riverdriver/riverpgxv5` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

Updates `github.com/riverqueue/river/rivertype` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sesv2
  dependency-version: 1.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sts
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/smithy-go
  dependency-version: 1.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river/riverdriver/riverpgxv5
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river/rivertype
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* deps(web): bump the npm-minor-patch group in /web with 8 updates (#1003)

Bumps the npm-minor-patch group in /web with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.3.3` | `16.3.4` |
| [@next/mdx](https://github.com/vercel/next.js/tree/HEAD/packages/next-mdx) | `16.3.3` | `16.3.4` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.6` | `14.6.7` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.3.0` | `26.4.1` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.3` | `16.3.4` |
| [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) | `30.4.2` | `30.5.1` |
| [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) | `30.4.1` | `30.5.1` |


Updates `next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.4)

Updates `@next/mdx` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/next-mdx)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.6 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.6...v14.6.7)

Updates `@types/node` from 26.3.0 to 26.4.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-config-next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/eslint-config-next)

Updates `jest` from 30.4.2 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest)

Updates `jest-environment-jsdom` from 30.4.1 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@next/mdx"
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@types/node"
  dependency-version: 26.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: eslint-config-next
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: jest
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: jest-environment-jsdom
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* deps(python): bump pydantic in /sdks/python in the uv-minor-patch group (#1002)

Bumps the uv-minor-patch group in /sdks/python with 1 update: [pydantic](https://github.com/pydantic/pydantic).


Updates `pydantic` from 2.13.4 to 2.13.5
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/v2.13.5/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.13.4...v2.13.5)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.13.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: uv-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(blog): add 'Anyone in the world can put text in front of your agent's model for the price of an email' (#1009)

Co-authored-by: jiashuoz <jiashuoz@users.noreply.github.com>

* deps: bump the npm-minor-patch group with 3 updates (#1001)

Bumps the npm-minor-patch group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [zod](https://github.com/colinhacks/zod) and [@…
jiashuoz added a commit that referenced this pull request Sep 10, 2026
* fix(oauth): enforce max_agents on auto-provisioned agents

issueOAuthCodeWithNewAgent created agents via CreateAgentTx with no
max_agents check anywhere in the file, unlike POST /v1/agents, which
has always called EnforceAgentCreate first. Wire in the same check
before the transaction that creates the agent.

Fixes #951

* fix(oauth): enforce max_agents atomically on auto-provisioned agents

issueOAuthCodeWithNewAgent's cap check (CheckAgentCreate) ran before
BeginTx/CreateAgentTx as a separate step, so it had no per-user advisory
lock and no recheck inside the same transaction as the insert. Two
concurrent OAuth auto-provision requests, or one racing a REST create,
could both pass the check and exceed max_agents (reviewer-reproduced,
tokencanopy/e2a#952).

#942 already closed the equivalent race on the REST create path with
CreateAgentWithLimit (advisory lock + recheck + insert, one transaction).
That helper opens its own tx internally, which does not fit this path:
the OAuth flow needs the cap check and the insert to commit or roll back
together with the authorization-code write on its own already-open tx.
Split CreateAgentWithLimit into a tx-owning wrapper plus
CreateAgentWithLimitTx, which takes a caller-owned tx and does the lock,
count check and insert on it (same relationship CreateAgentTx already has
to CreateAgent). issueOAuthCodeWithNewAgent now calls the Tx variant on
its own transaction instead of the standalone check-then-act helper.

Added a concurrency regression test matching #942's REST-path one: 8
concurrent consent submissions against max_agents=1 must produce exactly
1 created agent and 7 rejections, with the auth-code count matching the
agent count. Confirmed it fails on the pre-fix check-then-act sequence
(non-deterministically over-admits, e.g. 6 created in one run) and passes
with the atomic version. Updated the existing single-request cap test to
seed a real agent under max_agents=1 rather than max_agents=0, since the
atomic path treats max_agents<=0 as unlimited (same convention
CreateAgentWithLimit already documents).

go build and go vet clean on touched files; gofmt clean.

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>

* fix(oauth): look up the agent cap before opening the auto-provision tx

issueOAuthCodeWithNewAgent's maxAgents lookup (a.enforcer.Get) ran after
BeginTx, so each in-flight request held two pool connections at once:
the transaction's own plus a second one for the lookup. Under this
package's own concurrency regression test (8 concurrent requests) that
needs up to 16 connections to make progress against pgxpool's default
MaxConns of 4, and the requests holding a connection while waiting on
a second one that will never free is a real deadlock, not flakiness.

Reproduced: capping pool_max_conns=4 and running
TestHTTP_Consent_ConcurrentCreateNewRespectsMaxAgents (added in this
PR) hangs to the test binary's timeout on every run. Moving the lookup
before BeginTx, the same order the REST create path already uses in
agents_write.go, means only one connection is ever held at a time; the
same test then passes in 3-4s across 20 consecutive runs.

go build, go vet and gofmt clean on the touched file. Full
internal/agent package suite green under -race (41/41).

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>

* fix(auth): cascade configured OIDC logout (#984)

* fix(auth): cascade configured OIDC logout

* fix(auth): harden OIDC logout handoff

* fix(auth): use canonical web origin for logout

* fix(auth): normalize callback origin for logout

* test(auth): cover logout provenance edges

* fix(auth): request Google's account chooser on legacy login (#987)

Pass prompt=select_account when generating the Google authorization URL in HandleLogin. Without this parameter, Google silently re-authenticates the single currently signed-in account if consent was previously granted, preventing users from switching accounts after signing out.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: pi (gemini-3.8-flash) <noreply@tokencanopy.com>

* chore(release): bump ts-sdk 5.9.0, cli 2.5.1, python 5.8.1 (#988)

Cuts the three client packages against the diff since each one's last
PUBLISHED tag (ts-sdk-v5.8.0, cli-v2.5.0, python-v5.8.0) rather than since
the last server release, per the #935 post-mortem.

@e2a/sdk 5.9.0 (minor). #936 moves the dot-segment path guard down to the
generated RequestContext chokepoint, so every generated request is covered
instead of the ten hand-written wrappers 5.8.0 enumerated. Minor rather than
patch for the same reason 5.8.0 was: a path parameter of exactly "." or ".."
now throws instead of issuing a misdirected request. Generated model and
operation docs also pick up the outbound recipient-delivery metering
semantics and the messages_day / auth_unavailable vocabulary.

@e2a/cli 2.5.1 (patch). #947 stops the CSV parser leaking a raw carriage
return out of a quoted multi-line field, plus the #937 --send-at / review-hold
documentation correction.

e2a 5.8.1 (patch). Generated pydantic Field descriptions refreshed from the
current OpenAPI document; no name, signature, type, validation, or runtime
behavior differs from 5.8.0.

cli/package.json keeps its "@e2a/sdk": "^5.7.0" range: check-sdk-version-sync
compares majors, ^5.7.0 already resolves to 5.9.0 on a fresh install, and
leaving it avoids creating a window where cli-v2.5.1 is unsatisfiable.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(sending): add durable provider authorization gate (#990)

B3 of the sending-protection plan: the budget ledger, the durable
submission attempt, and the final pre-I/O authorization that later slices
hang the provider paths off.

The module answers one question — may this operation open a socket right
now — and answers it with a single-use token bound to a durable attempt
rather than with a boolean. That shape removes the check-then-act window:
a pause, a plan downgrade, a policy activation, a midnight rollover, or a
competing worker all invalidate the token instead of racing it.

- types.go: the closed vocabulary. Every ref has unexported fields; the
  only serialization is OperationRef's versioned {v,id} form, which
  survives a River process replacement while granting no authority.
- budget.go: pool mapping, per-pool limits, and one ordered locking pass
  over every counter row a transaction may touch, with the arithmetic
  applied afterwards so an all-or-nothing decision needs no savepoint.
- operations.go: purpose, attribution, and reputation class derived once
  from a locked source row and persisted immutable.
- gate.go: Reserve allocates the durable ordinal, ConsumeAttempt
  re-derives everything under the normative lock order, RedeemProviderCall
  re-proves the chain immediately before the socket.

Enforcement is off: generation zero has budget_mode disabled, which
charges nothing and writes no counter rows while still exercising the
authorization seam. Nothing calls the Gate yet and the compiled contract
stays at 0.

Three review passes (two parallel, one mutation-tested verification pass)
found eleven defects, all fixed here with regression tests — including a
50x envelope amplifier, a Free account able to park the whole platform
pool in reservations, a stale token able to retire another worker's live
attempt, a reserve-delete-repeat strand against the shared pools, and a
hold that made a message permanently unsendable.

256 tests, green under -race.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat: onboarding acquisition survey (/welcome, users.acquisition_*, onboarding_survey flag) (#993)

* docs(plans): onboarding acquisition survey implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): acquisition survey option list and /me types

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(db): add users acquisition survey columns (migration 120)

Deviation from the plan: the plan specified migration slot 108, but
108-119 were already claimed by unrelated migrations landed since the
plan was written. Uses the next free slot (120) per
TestEmbeddedMigrationNumbersAreUniqueFrom108; behaviour and test
contract are otherwise unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): gate the app shell on the onboarding survey

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): /welcome onboarding survey page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(identity): write-once acquisition survey store method

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(config): onboarding_survey.enabled flag (default off)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(auth): onboarding survey on /api/auth/me (pending flag, write-once PATCH)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* docs(plans): onboarding survey migration landed as 120

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* fix(onboarding-survey): apply review findings

- PATCH /api/auth/me writes the survey before the name so a 409 never
  leaves a partial name update; maps a vanished user row to 401; caps the
  body at 64 KB; rejects control characters in detail (NUL was a 500)
- 404/409 bodies use the handler's plain-text http.Error style, not a
  one-off JSON envelope
- enum test reads the CHECK list out of migration 120 instead of a second
  hand-copied literal; every-value handler test uses one DB, not ten
- web: pathname null guard + trailing-slash normalization in the gate,
  visible 0/200 counter on the detail field, Skip clears a stale error,
  migration comment says 120

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp): build the image from the committed lockfile (#994)

The MCP image had been unbuildable since 05:15, and would have broken
again after every client publish.

mcp/Dockerfile ran `npm install --package-lock=false --workspaces` in both
stages, discarding the committed lockfile and re-resolving the whole
workspace graph from the live registry on every build. That works only
while the workspace's own @e2a/sdk version does not also exist upstream.
Once both are true, live resolution has two candidates for one edge and
arborist dies with `Cannot read properties of null (reading 'edgesOut')`.

Timeline: last successful build 05:14:03; @e2a/sdk@5.9.0 published to npm
05:15:45; every build since failed, reruns included, with no repo change
in between.

Both stages now use `npm ci`. The committed lockfile was never ambiguous:
lockfileVersion 3, all four workspaces, node_modules/@e2a/sdk linked to
sdks/typescript. Also copies design-system/package.json, listed in the
root workspaces array since #333 but never copied by any build stage.

release.env pins the server and MCP images to the same version, so a
release whose MCP image cannot build cannot be promoted at all. This
blocked v1.8.8.

Verified locally in both directions (the workflow does not run on PRs):
old Dockerfile reproduces the failure, new one builds, the container
answers /healthz 200, and @e2a/sdk resolves to the workspace symlink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(web): move protection save button to top right with dirty state tracking (#995)

* feat(blog): add 'Your approval gate shouldn't live in your agent's code' (#997)

Argues the HITL review checkpoint belongs in the email infrastructure
(enforced on the send path, pending_review + 202) rather than in the
agent's application code. Names the beta surface and the #989
hold-everything config riddle openly.

* fix(sendramp): make a disabled sending ramp a true no-op (#996)

The per-domain sending ramp is off by default (`sending_ramp.enabled`
defaults to false and prod sets no `sending_ramp` block), but its
disabled branch was not a no-op: `outboundRampGate.Reserve` called
`sendramp.Store.Exempt` on every eligible send, flipping the sending
domain from 'inactive' to 'exempt'. Eligibility is
`sent_as == "own_address" && message_type != "test"`, so every verified
custom domain that sends at all is permanently stamped.

That is a grandfathering decision taken silently, once per send, by the
send path. It matters more now that 'exempt' means "established sender"
beyond the ramp itself: an exempt domain both loses its daily stage cap
and stops consuming the shared probation pool that bounds Sybil abuse,
and re-registering a domain makes it exempt-eligible again — a reset
primitive. It also pre-empts the audited one-shot that exists for
exactly this decision: sendingpolicy's
ActivationRequest.GrandfatherCurrentSendingDomains, which is marked for
replay and takes SHARE ROW EXCLUSIVE on `domains`.

Disabled now means pass-through, matching the newer gate's semantics:
allow the send, reserve nothing, count nothing, stamp nothing. Domains
stay 'inactive' (and read as 'inactive' through
`GET /v1/domains/{domain}.sending_ramp.status`) so the operator can make
that call deliberately. Confirm/Release/Resolve keep delegating: a
reservation taken before the ramp was switched off still has to settle,
and with the ramp disabled there is no reservation row to touch.

`Store.Exempt` is kept — no automatic caller, documented as the
store-level primitive for an explicit single-domain operator exemption.

Two regression tests, both failing before this change with
`sending_ramp_status = "exempt"`: the gate directly, and a real
ramp-eligible send driven through the send worker. Each asserts the
domain stays 'inactive' and that no `sending_ramp_scopes`,
`domain_send_counters`, or `sending_ramp_reservations` rows appear.

Rows already stamped 'exempt' by a running deployment are NOT remediated
here; the runbook now says so and describes the choice.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sending): compose ramp with protection gate (#992)

* feat(sending): compose ramp with protection gate

B4 of the sending-protection plan. The custom-domain ramp and the sending
budget answer different questions — "has this domain earned this volume
yet?" versus "has this account or the platform exposed SES enough today?"
— and this slice makes one transaction answer both, most restrictive
winning.

One lock order for the ramp. The store previously used three: Reserve
took domain → scope → reservation → counter, Confirm took reservation →
counter → scope, Release took reservation → counter. Three orders over
four keys is a deadlock waiting for traffic, and it becomes unavoidable
once the gate composes both ledgers into one transaction, because that
transaction already holds highly contended global budget counters when it
reaches the ramp. internal/sendramp/tx.go now holds the single order
(domain identity → registrable-domain scope → message reservation → UTC
day counter) and the pool-owning methods are thin wrappers, so there is
no second implementation to drift.

Composition, in internal/sendingpolicy/ramp.go:

- Probation is now the ramp's answer rather than a stand-in. Shared-relay
  traffic stays probationary at every plan level and never graduates; a
  custom domain is probationary until its scope has one qualified day.
  That classification decides which budget counters the transaction must
  lock, and the budget counters come BEFORE the ramp keys in the
  normative order — so it is read unlocked. That is sound only because
  ramp progress is monotonic: a stale answer can be stale in the strict
  direction and no other.
- The ramp is authorized last, after the budget has been reacquired. A
  ramp hold therefore arrives with budget units already taken, and those
  are released before returning the hold — keeping them would charge an
  account for a send its own domain was not allowed to make.
- SettleProvider now moves the ramp: acceptance advances a qualified day,
  a definite permanent rejection releases the units, and retryable or
  ambiguous results leave the reservation standing, because a message
  that might have been delivered must not release capacity.
- CancelAttempt releases both ledgers; DeferAttempt still releases only
  the budget. A rate deferral was not rejected by anyone, and giving back
  its ramp claim would let the same message re-qualify a stage it already
  qualified.

Disabled mode is genuinely pass-through: no scope row, no counter, and
above all no `exempt` write. Writing `exempt` while the ramp is off would
permanently grandfather every domain that happened to send during the
disabled window, and the phase-3 activation would then find nothing left
to ramp. Production ships in exactly this state.

A fixture note worth keeping: the ramp ledger is keyed by REGISTRABLE
domain, so `ramp-1.example.test` and `ramp-2.example.test` are one scope.
The first version of these tests read the hostname key, found empty rows,
and would have passed for the wrong reason. Each fixture domain is now
its own eTLD+1, and the one test that is about sharing builds two
hostnames under a single registrable domain deliberately.

Tests: 264 in the package plus the existing ramp suite, green under
-race. Covers the 150/213/277 stage caps and their 75/107/139
qualification bars, every probation class, budget-allow/ramp-hold and its
mirror, settlement idempotency, permanent-rejection release,
defer-versus-cancel, subdomain scope sharing, and a Free account that can
qualify stage one but not stage two without losing its progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(sending): keep the ramp binding under retry, refusal, and rebind

Review of the ramp composition found seven ways the stage cap stopped being
a cap. All of them come from one shape: the sending budget is keyed by
submission ATTEMPT while the ramp reservation is keyed by MESSAGE, and code
that treats the two as interchangeable gives away capacity that was already
spent.

The refund. CancelAttempt released the ramp for any cancelled attempt.
Attempt one authorizes and hands 100 recipients to SES, the result is
ambiguous so nothing settles and the reservation correctly stands, River
allocates attempt two, a suppression cancels it — and the ramp counter goes
to zero for mail that is already in flight. It repeats, so the cap became
advisory. cancelRamp now asks about the OPERATION rather than the ordinal: if
any attempt was ever authorized, only SettleProvider may give those units
back. A reservation no attempt has authorized — the shape today's outbound
worker produces before this module is involved — is still refundable, which
is also where the mirror bug lived: the `released` early return meant a
cancel following a deferral never reached the ramp at all.

The stranding. A permanent sendramp error (`reservation already released` is
reachable by Reserve-after-Cancel on one operation) came back as a hard
error, rolling the transaction back with the attempt still `reserved`. Every
later execution failed identically, so nothing could ever release its units:
50 recipients pinned on global_all, global_probation, and account_daily
until midnight, three of them enough to close probationary sending for the
platform. Permanent refusals are now a terminal hold that releases, exactly
as the envelope path already answers the same class of loss.

The rebind. A domain with an unverified SENDING identity was ramp
pass-through AND, because InspectScopeTx never read the column, reported
established once its scope had a qualified day — no cap and no probation
charge. The wire identity is frozen at acceptance but the agent's registered
domain is not: verifying a child subdomain rebinds the account's agents onto
it, the child's SES identity stays pending while its DKIM is never
published, and the ramp resolves the domain live. An accepted backlog went
out uncapped under the parent's frozen From. It now holds, and classifies as
probationary. The two legacy states that mean "this domain already earned
its volume" — `exempt` and `complete` — are checked first and are untouched.

Three smaller ones. Reserve still classified probation as `op.Shared` with a
comment deferring to a task this commit is; the early hold therefore never
bounded the probation pool, the stored column disagreed with the class every
release targets, and each authorization paid a needless release-and-reacquire
on the platform's hottest counter rows. The ramp's own source read answered a
vanished message with a RETRYABLE hold, and because it runs before the
envelope resolution that answers it terminally, the wrong answer won whenever
the ramp was armed — a worker snoozing forever instead of failing once. And
in the ledger itself: a released-to-confirmed restoration errored instead of
no-opping when maintenance had reaped the day's counter (the reservation
outlives it by design), while ReleaseTx recorded `released` even when its
guarded decrement matched nothing, so a later restoration added back units
the counter never returned.

Tests: every fix above has a regression test that fails before it. The file
also gained the coverage Task 4 Step 2 asks for and did not have — racing
workers against one stage cap with the budget counters contended (exactly one
cap admitted, no deadlock), cross-midnight re-age of BOTH ledgers in one
authorization, settlement arriving days after its attempt, and a
FOR UPDATE NOWAIT probe that proves the named domain → scope → reservation →
day-counter suborder is actually taken rather than merely documented. Weak
assertions were tightened: the probation tests now assert the hold REASON,
the ramp-hold test checks all three pools it charged, and the Free-plan test
proves progression RESUMES after upgrade rather than merely surviving it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendramp): pin InspectScopeTx classification in its own package

InspectScopeTx decides which budget pool a send charges before the ramp
lock is taken, but every test of it lived in sendingpolicy, so the
per-package coverage gate saw it at 0% and failed the package at 76.6%.

Pin each branch where the code lives: missing domain, legacy exempt,
stamped complete, unverified identity with an old scope (must stay in
probation and ignore the scope), verified with no scope (day zero), a
completed scope behind a still-ramping domain row, the day-zero/day-one
boundary, and registrable-domain scope lookup with a hostname-keyed
decoy. Removing the unverified guard makes the fourth test fail.

Package coverage: 76.6% -> 84.9%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendingpolicy): redeem before settling in the ramp tests

Settlement reports what the provider did, so it is only meaningful for an
attempt that opened the socket. The adapter (B5, #998) redeems the token
immediately before it dials, and the gate there refuses to settle an
attempt whose call_state is not 'started'. These tests settled straight
after ConsumeAttempt; route them through a consumeAndRedeem helper so the
two slices can land in either order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(outbound): require single-use provider authorization (B5) (#998)

* test(testutil): extract the database helper into a leaf package

testutil bundles the contract server, which imports outbound. Once
outbound imports sendingpolicy (the provider seam requires the gate's
token), any INTERNAL test file in outbound or sendingpolicy that calls
testutil.TestDB closes an import cycle.

Move db.go into internal/testutil/testdb, a leaf that depends only on
identity and migrations, and keep testutil.TestDB / TestDBURL /
OpenPreparedTestDB / TruncateAll as forwarding wrappers so every
existing caller is unchanged. The contract server and its River test
use the two newly exported helpers (Truncate, BaseTestDBURL) instead of
package-private ones. The one internal sendingpolicy test imports
testdb directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): require single-use provider authorization

Add ProviderSubmitter, the one path from a composed message to an SMTP
transaction with SES. It requires a sendingpolicy.ProviderAuthorization
for every call, proves the envelope is the authorized one, derives
X-E2A-Provider-Attempt and X-SES-TENANT only from the token (and
X-SES-CONFIGURATION-SET from configuration), strips every occurrence of
those headers — mixed case and folded forms — from the composed MIME,
redeems the token, and only then dials. Every early exit is I/O-free
and leaves the token intact; a misconfigured relay is refused before
redemption so a retry does not burn an ordinal.

A definite permanent rejection is settled as such; an acceptance is
settled with the provider's message id, which SettleProvider now binds
to the attempt's feedback correlation exactly once (same id replays,
a different id is refused). A settlement that fails after acceptance
is reported on the result, never as a send error, so the caller retries
settlement rather than resubmitting.

The legacy tokenless Sender.SubmitOnce path stays for now; Task 7
migrates its callers and makes the relay's socket-opening methods
package-private.

Tests: zero-network on missing, mismatched, stale, and reused tokens
(asserted against a socket counter); exact and single attempt/tenant
headers with smuggled spellings removed and the body untouched; a
physical retry redeems a distinct ordinal; provider id binding and its
conflict rule. Removing the header strip, reordering redemption before
envelope validation, or dropping the id binding each fail a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the provider seam after review

Two review passes (correctness + adversarial) over the first cut. Every
finding below has a named test, and removing each fix fails it.

Gate:
- RedeemProviderCall re-proves the abuse pause. ConsumeAttempt linearized
  it, but that transaction has committed; a pause landing before the dial
  now invalidates the token. Unlocked read, because the account-control
  key precedes the operation key this transaction already holds.
- SettleProvider requires call_state = 'started', not just a confirmed
  reservation: a provider id bound to an attempt that never dialed is a
  ledger claim about a send that did not happen.
- The provider message id is normalized to SES's bare form on bind and on
  compare (NormalizeProviderMessageID). The relay returns it qualified
  (<id@region.amazonses.com>); SNS feedback carries it bare; the worker
  and the feedback finalizer must not refuse each other's spelling.
- A tenant name that cannot be a header value holds at mint with
  ses_tenant_unnamed instead of wedging silently in the adapter.

Adapter:
- X-E2A-Message-ID is derived from the token (a customer operation IS its
  message id), not trusted from the caller; Envelope.MessageID is gone.
- RCPT TO is issued from AuthorizedRecipients(), the canonical envelope
  the budget priced, never the caller's spelling of it.
- A bare CR anywhere in the header section, or a leading continuation
  line, is refused before redemption (ErrMalformedHeaderSection): a
  receiver that treats a lone CR as a line break would see a header this
  walker did not.
- Provider headers are emitted in the legacy path's order (configuration
  set first) so the worker swap is byte-identical for shared headers.
- The relay marks a failure after the terminating dot with
  ErrProviderAcceptanceUnknown; the adapter leaves it unsettled and no
  classifier calls it permanent.

Tests: pause between consume and submit; settle without redeem; bare CR
and leading continuation refused with zero sockets; canonical RCPT on the
wire; lost 250 unsettled and marked; 4xx unsettled; accepted-but-unsettled
reported on the result with a nil error; a positive control proving the
socket counter observes a dial; provider id normalization across the three
spellings. The socket counter now waits for the accept goroutine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): keep the acceptance-unknown marker honest

Re-review of the previous fix commit found the marker leaking in both
directions. The relay's deferred context remap replaced the error
wholesale, so a deadline or cancellation after the terminating dot — the
likeliest way to lose a 250 — dropped ErrProviderAcceptanceUnknown and
classified as a connection outage the worker would re-drive. And the
marker was joined to every post-DATA failure, including a coded 554
content rejection, which is the provider's definite answer and must
classify permanent. Now the marker survives the remap and is attached
only when no reply code came back.

Also pins the customer-only guard on the redeem-time pause re-check: the
notice telling an account it was paused is sourced from that paused
account and must still go out. Reviewer-authored test adopted. And the
compare-side normalization of the provider id now has a test that writes
a qualified spelling directly and replays bare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(outbound): enforce sending policy at fire time (B6) (#999)

* feat(messagelifecycle): add policy-budget and sending-setup expiry reasons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(sendingpolicy): settle and look up operations by id for evidence paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(identity): carry finite-hold state on the send claim

The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): enforce sending policy at fire time

The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* style(messagelifecycle): gofmt the reason catalog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(api): publish sending_paused and the two hold-expiry reasons

The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the worker cutover after review

Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): settle, wire, and mark precisely after re-review

Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(outbound): close the provider seam for every sender (B7) (#1000)

* feat(messagelifecycle): add policy-budget and sending-setup expiry reasons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(sendingpolicy): settle and look up operations by id for evidence paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(identity): carry finite-hold state on the send claim

The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): enforce sending policy at fire time

The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* style(messagelifecycle): gofmt the reason catalog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(api): publish sending_paused and the two hold-expiry reasons

The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the worker cutover after review

Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): settle, wire, and mark precisely after re-review

Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): close the provider seam for every sender

Slice B7 of the sending abuse prevention plan. The relay no longer exports
a send method; ProviderSubmitter.SubmitOnce with a gate token is the only
way to reach the provider, and a tracked-closure test parses every
production file to keep it that way.

- hitlnotify + webhooknotify: enqueue prepares a customer_notification
  operation in the source transaction and stamps it on the job; workers
  run Reserve -> early hold -> ConsumeAttempt -> authorized submit and
  snooze on a hold without provider I/O; pre-floor jobs resolve at fire
  time and are stamped once (jobs.StampJobArg).
- public feedback: server-keyed public_feedback_notification operation
  with a bounded per-attempt Reserve/Consume/Submit loop; a definite
  rejection or a lost acceptance stops it.
- e2a -reconcile-legacy-sending-jobs: stamps pending outbound_send /
  hitl_notify / webhook_notify jobs that carry no operation, cancels
  orphans, exits nonzero unless every scanned job was decided.
- main, TestServer and the contract server build the notifiers over the
  shared submitter and hand the API the submitter + gate.
- design addendum in docs/design/async-message-pipeline.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): key notification operations by source and charge last

Review round 1 of the provider-seam closure (B7).

- PrepareNotificationTx derives the operation id from its source
  (op_hitl_<message>, op_wh_<kind>_<webhook>_<episode>), so a repeat
  preparation yields one operation and the notify workers cancel a job
  whose reference names any other operation.
- The notify Deliverer is Compose + Submit; workers run compose ->
  Reserve -> hold -> ConsumeAttempt -> Submit, so a compose failure
  charges nothing and the token is consumed right before the socket.
- Reconcile command re-reads each job under FOR UPDATE and skips one a
  worker claimed or stamped meanwhile; counts separate paused/skipped.
- Feedback loop submits the token's canonical recipients, paces retries
  to fit the handler budget, keeps the SMTP error on a deadline, releases
  a reserved attempt when authorize errors, no panic on id mint.
- Closure guard never skips, matches method references, exempts the
  SubmitOnce symbol only, asserts its sentinel, fences the SES v2 import.
- Webhook health notices older than seven days are dropped.
- Wiring test for the notification bundles and the API seam; StampJobArg
  tests for the jobs coverage floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): re-key pre-derivation notification references

Re-review of the provider-seam closure (B7) found that migration 113
stamped adopted notify jobs with op_<md5> references, which the new
source binding would have cancelled on any upgrade crossing v1.8.7.

- A reference that is not a derived id is treated as pre-derivation: the
  notify workers re-resolve it through the Prepare path and replace it
  once (jobs.SetJobArg); a derived id for another source still cancels.
- The reconcile command scans and re-keys those references too.
- Bounded the feedback attempt release (2s); symmetric 7-day age guard on
  HITL notices; episode key in microseconds; nil-receiver guards on the
  compose path; doc and comment corrections; the closure guard states
  its residual scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(cmd): keep the reconcile scan two-valued and replaceable

Round-3 re-review nits: COALESCE the conforming-reference predicate so a
reference with no id cannot fall out of a NOT scan, decode only the
source fields so such a reference is replaced rather than failing to
decode, and state in the workers that any non-derived shape re-derives
from the job's own source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(blog): add 'Your agent's inbox is storage, not transport' (#1007)

Argues inbox polling is a transport problem, not a discipline
problem, and lays out e2a's four inbound delivery channels (signed
webhooks, WebSocket with no public URL, REST polling, MCP) plus
e2a listen for the laptop case.

* fix(sendingpolicy): lock the agent FOR NO KEY UPDATE in the accept path (#1006)

* fix(sendingpolicy): lock the agent FOR NO KEY UPDATE in the accept path

The v1.9.0 staging conformance gate failed on eight parallel HITL holds:
SQLSTATE 40P01. Each accept transaction inserts its message first, which
takes a FOR KEY SHARE lock on the agent row through the foreign key and a
row lock on account_usage through the storage trigger, then prepares its
operation, which locked the agent FOR UPDATE. FOR UPDATE conflicts with
KEY SHARE, so two concurrent sends waited on each other. The direct send
path (PrepareExternalTx) has the identical shape and deadlocks the same
way under parallel sends; staging simply never ran that case.

FOR NO KEY UPDATE keeps every ordering the gate needs (callers serialize
against each other and against any update or delete of the row) and does
not conflict with a foreign-key share. Same change for the webhook row.

Two regression tests reproduce the deadlock deterministically at the
gate and through the API, and both fail with the old lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendingpolicy): force the deadlock interleaving instead of timing it

Review of the lock-order fix: the gate test's 300ms sleep could let A
finish before B ever blocked, passing vacuously against the bug. B now
reports its backend pid and A waits until pg_stat_activity shows it
blocked on a lock. The e2e test no longer calls t.Fatal from worker
goroutines, and the PrepareExternalTx ordering comment now describes the
function rather than every caller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(conformance): cover parallel direct sends and record the lock rule

The staging gate only sent in parallel from a HITL agent; the direct
accept path has the same insert-then-lock shape and carries almost all
traffic. Add the eight-parallel-direct-sends case, write the accept
transaction's lock order into the pipeline design doc, and record the
FOR KEY SHARE / FOR UPDATE rule in AGENTS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* docs(readme): highlight hosted service and MCP setup (#1008)

* docs(readme): highlight hosted service and MCP setup

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* docs(readme): match Token Canopy brand colors

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

---------

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* docs(readme): use warm gold for logo and hosted button (#1010)

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* deps: bump the go-minor-patch group with 8 updates (#1004)

Bumps the go-minor-patch group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.32.39` | `1.33.2` |
| [github.com/aws/aws-sdk-go-v2/service/sesv2](https://github.com/aws/aws-sdk-go-v2) | `1.67.1` | `1.71.0` |
| [github.com/aws/aws-sdk-go-v2/service/sts](https://github.com/aws/aws-sdk-go-v2) | `1.45.8` | `1.48.0` |
| [github.com/aws/smithy-go](https://github.com/aws/smithy-go) | `1.27.10` | `1.28.1` |
| [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) | `3.20.0` | `3.21.0` |
| [github.com/riverqueue/river](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |
| [github.com/riverqueue/river/riverdriver/riverpgxv5](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |
| [github.com/riverqueue/river/rivertype](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |


Updates `github.com/aws/aws-sdk-go-v2/config` from 1.32.39 to 1.33.2
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.39...config/v1.33.2)

Updates `github.com/aws/aws-sdk-go-v2/service/sesv2` from 1.67.1 to 1.71.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.67.1...service/s3/v1.71.0)

Updates `github.com/aws/aws-sdk-go-v2/service/sts` from 1.45.8 to 1.48.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/sts/v1.45.8...service/s3/v1.48.0)

Updates `github.com/aws/smithy-go` from 1.27.10 to 1.28.1
- [Release notes](https://github.com/aws/smithy-go/releases)
- [Changelog](https://github.com/aws/smithy-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/aws/smithy-go/compare/v1.27.10...v1.28.1)

Updates `github.com/coreos/go-oidc/v3` from 3.20.0 to 3.21.0
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.20.0...v3.21.0)

Updates `github.com/riverqueue/river` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

Updates `github.com/riverqueue/river/riverdriver/riverpgxv5` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

Updates `github.com/riverqueue/river/rivertype` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sesv2
  dependency-version: 1.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sts
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/smithy-go
  dependency-version: 1.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river/riverdriver/riverpgxv5
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river/rivertype
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* deps(web): bump the npm-minor-patch group in /web with 8 updates (#1003)

Bumps the npm-minor-patch group in /web with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.3.3` | `16.3.4` |
| [@next/mdx](https://github.com/vercel/next.js/tree/HEAD/packages/next-mdx) | `16.3.3` | `16.3.4` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.6` | `14.6.7` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.3.0` | `26.4.1` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.3` | `16.3.4` |
| [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) | `30.4.2` | `30.5.1` |
| [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) | `30.4.1` | `30.5.1` |


Updates `next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.4)

Updates `@next/mdx` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/next-mdx)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.6 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.6...v14.6.7)

Updates `@types/node` from 26.3.0 to 26.4.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-config-next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/eslint-config-next)

Updates `jest` from 30.4.2 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest)

Updates `jest-environment-jsdom` from 30.4.1 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@next/mdx"
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development…
jiashuoz added a commit that referenced this pull request Sep 10, 2026
…r-cap account (#982)

* test(contract): prove the 402 envelope's current field against an over-cap account

account_limits_enforced asserts error.details.current, but at the moment
of refusal current == limit by construction of the count >= cap checks in
CheckAgentCreate/CheckDomainCreate, so a server that hardcoded Current to
the cap would pass every existing assertion.

Add a third seeded contract account (OverCapAPIKey) whose domains and
agents are created before a lower cap is applied, so it starts already
over both caps. A further create attempt is refused with current strictly
greater than limit, which only a server reading the real resource count
can produce. Verified by mutation: hardcoding Current to lim.MaxAgents /
lim.MaxDomains fails the new scenario while account_limits_enforced stays
green.

Fixes #828

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>

* fix(contract): wire the over-cap account key into the TS and Python runners

The standalone contract-server helper (cmd/e2a-contract-server) wrote
E2A_TEST_BASE_URL, E2A_TEST_API_KEY and E2A_TEST_CAPPED_API_KEY to its
env file, but never E2A_TEST_OVERCAP_API_KEY, even though
testutil.ContractServer already exposes OverCapAPIKey. The Go
integration suite runs the contract server in process and reads
cs.OverCapAPIKey directly, so it never depended on that env var and
kept passing.

The TS and Python contract runners never read an
E2A_TEST_OVERCAP_API_KEY env var and never wired an overcap_api_key
template variable, so the new account_limits_current_field_proven
scenario's auth_override reached the wire as the literal string
"Bearer {overcap_api_key}". The server rejected that as an invalid
key with 401 before ever reaching the over-cap check, which is the
failure both jobs reported.

This adds E2A_TEST_OVERCAP_API_KEY to the env file the helper writes,
and wires it into both runners the same way E2A_TEST_CAPPED_API_KEY
already is, including a skip gate for a deployed target that has no
over-cap account to offer.

Verified in a clean Docker container against current HEAD: the Go
integration suite (go test -tags integration ./tests/contract/...)
passes all 33 scenarios including account_limits_current_field_proven,
the TypeScript contract suite passes 50 tests with 0 failures, and the
Python contract suite passes 48 tests with 0 failures.

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>

* fix(auth): cascade configured OIDC logout (#984)

* fix(auth): cascade configured OIDC logout

* fix(auth): harden OIDC logout handoff

* fix(auth): use canonical web origin for logout

* fix(auth): normalize callback origin for logout

* test(auth): cover logout provenance edges

* fix(auth): request Google's account chooser on legacy login (#987)

Pass prompt=select_account when generating the Google authorization URL in HandleLogin. Without this parameter, Google silently re-authenticates the single currently signed-in account if consent was previously granted, preventing users from switching accounts after signing out.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: pi (gemini-3.8-flash) <noreply@tokencanopy.com>

* chore(release): bump ts-sdk 5.9.0, cli 2.5.1, python 5.8.1 (#988)

Cuts the three client packages against the diff since each one's last
PUBLISHED tag (ts-sdk-v5.8.0, cli-v2.5.0, python-v5.8.0) rather than since
the last server release, per the #935 post-mortem.

@e2a/sdk 5.9.0 (minor). #936 moves the dot-segment path guard down to the
generated RequestContext chokepoint, so every generated request is covered
instead of the ten hand-written wrappers 5.8.0 enumerated. Minor rather than
patch for the same reason 5.8.0 was: a path parameter of exactly "." or ".."
now throws instead of issuing a misdirected request. Generated model and
operation docs also pick up the outbound recipient-delivery metering
semantics and the messages_day / auth_unavailable vocabulary.

@e2a/cli 2.5.1 (patch). #947 stops the CSV parser leaking a raw carriage
return out of a quoted multi-line field, plus the #937 --send-at / review-hold
documentation correction.

e2a 5.8.1 (patch). Generated pydantic Field descriptions refreshed from the
current OpenAPI document; no name, signature, type, validation, or runtime
behavior differs from 5.8.0.

cli/package.json keeps its "@e2a/sdk": "^5.7.0" range: check-sdk-version-sync
compares majors, ^5.7.0 already resolves to 5.9.0 on a fresh install, and
leaving it avoids creating a window where cli-v2.5.1 is unsatisfiable.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(sending): add durable provider authorization gate (#990)

B3 of the sending-protection plan: the budget ledger, the durable
submission attempt, and the final pre-I/O authorization that later slices
hang the provider paths off.

The module answers one question — may this operation open a socket right
now — and answers it with a single-use token bound to a durable attempt
rather than with a boolean. That shape removes the check-then-act window:
a pause, a plan downgrade, a policy activation, a midnight rollover, or a
competing worker all invalidate the token instead of racing it.

- types.go: the closed vocabulary. Every ref has unexported fields; the
  only serialization is OperationRef's versioned {v,id} form, which
  survives a River process replacement while granting no authority.
- budget.go: pool mapping, per-pool limits, and one ordered locking pass
  over every counter row a transaction may touch, with the arithmetic
  applied afterwards so an all-or-nothing decision needs no savepoint.
- operations.go: purpose, attribution, and reputation class derived once
  from a locked source row and persisted immutable.
- gate.go: Reserve allocates the durable ordinal, ConsumeAttempt
  re-derives everything under the normative lock order, RedeemProviderCall
  re-proves the chain immediately before the socket.

Enforcement is off: generation zero has budget_mode disabled, which
charges nothing and writes no counter rows while still exercising the
authorization seam. Nothing calls the Gate yet and the compiled contract
stays at 0.

Three review passes (two parallel, one mutation-tested verification pass)
found eleven defects, all fixed here with regression tests — including a
50x envelope amplifier, a Free account able to park the whole platform
pool in reservations, a stale token able to retire another worker's live
attempt, a reserve-delete-repeat strand against the shared pools, and a
hold that made a message permanently unsendable.

256 tests, green under -race.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat: onboarding acquisition survey (/welcome, users.acquisition_*, onboarding_survey flag) (#993)

* docs(plans): onboarding acquisition survey implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): acquisition survey option list and /me types

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(db): add users acquisition survey columns (migration 120)

Deviation from the plan: the plan specified migration slot 108, but
108-119 were already claimed by unrelated migrations landed since the
plan was written. Uses the next free slot (120) per
TestEmbeddedMigrationNumbersAreUniqueFrom108; behaviour and test
contract are otherwise unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): gate the app shell on the onboarding survey

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(web): /welcome onboarding survey page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(identity): write-once acquisition survey store method

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(config): onboarding_survey.enabled flag (default off)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* feat(auth): onboarding survey on /api/auth/me (pending flag, write-once PATCH)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* docs(plans): onboarding survey migration landed as 120

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

* fix(onboarding-survey): apply review findings

- PATCH /api/auth/me writes the survey before the name so a 409 never
  leaves a partial name update; maps a vanished user row to 401; caps the
  body at 64 KB; rejects control characters in detail (NUL was a 500)
- 404/409 bodies use the handler's plain-text http.Error style, not a
  one-off JSON envelope
- enum test reads the CHECK list out of migration 120 instead of a second
  hand-copied literal; every-value handler test uses one DB, not ten
- web: pathname null guard + trailing-slash normalization in the gate,
  visible 0/200 counter on the detail field, Skip clears a stale error,
  migration comment says 120

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPerbtyZtci8gFA8kem8Cm

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp): build the image from the committed lockfile (#994)

The MCP image had been unbuildable since 05:15, and would have broken
again after every client publish.

mcp/Dockerfile ran `npm install --package-lock=false --workspaces` in both
stages, discarding the committed lockfile and re-resolving the whole
workspace graph from the live registry on every build. That works only
while the workspace's own @e2a/sdk version does not also exist upstream.
Once both are true, live resolution has two candidates for one edge and
arborist dies with `Cannot read properties of null (reading 'edgesOut')`.

Timeline: last successful build 05:14:03; @e2a/sdk@5.9.0 published to npm
05:15:45; every build since failed, reruns included, with no repo change
in between.

Both stages now use `npm ci`. The committed lockfile was never ambiguous:
lockfileVersion 3, all four workspaces, node_modules/@e2a/sdk linked to
sdks/typescript. Also copies design-system/package.json, listed in the
root workspaces array since #333 but never copied by any build stage.

release.env pins the server and MCP images to the same version, so a
release whose MCP image cannot build cannot be promoted at all. This
blocked v1.8.8.

Verified locally in both directions (the workflow does not run on PRs):
old Dockerfile reproduces the failure, new one builds, the container
answers /healthz 200, and @e2a/sdk resolves to the workspace symlink.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(web): move protection save button to top right with dirty state tracking (#995)

* feat(blog): add 'Your approval gate shouldn't live in your agent's code' (#997)

Argues the HITL review checkpoint belongs in the email infrastructure
(enforced on the send path, pending_review + 202) rather than in the
agent's application code. Names the beta surface and the #989
hold-everything config riddle openly.

* fix(sendramp): make a disabled sending ramp a true no-op (#996)

The per-domain sending ramp is off by default (`sending_ramp.enabled`
defaults to false and prod sets no `sending_ramp` block), but its
disabled branch was not a no-op: `outboundRampGate.Reserve` called
`sendramp.Store.Exempt` on every eligible send, flipping the sending
domain from 'inactive' to 'exempt'. Eligibility is
`sent_as == "own_address" && message_type != "test"`, so every verified
custom domain that sends at all is permanently stamped.

That is a grandfathering decision taken silently, once per send, by the
send path. It matters more now that 'exempt' means "established sender"
beyond the ramp itself: an exempt domain both loses its daily stage cap
and stops consuming the shared probation pool that bounds Sybil abuse,
and re-registering a domain makes it exempt-eligible again — a reset
primitive. It also pre-empts the audited one-shot that exists for
exactly this decision: sendingpolicy's
ActivationRequest.GrandfatherCurrentSendingDomains, which is marked for
replay and takes SHARE ROW EXCLUSIVE on `domains`.

Disabled now means pass-through, matching the newer gate's semantics:
allow the send, reserve nothing, count nothing, stamp nothing. Domains
stay 'inactive' (and read as 'inactive' through
`GET /v1/domains/{domain}.sending_ramp.status`) so the operator can make
that call deliberately. Confirm/Release/Resolve keep delegating: a
reservation taken before the ramp was switched off still has to settle,
and with the ramp disabled there is no reservation row to touch.

`Store.Exempt` is kept — no automatic caller, documented as the
store-level primitive for an explicit single-domain operator exemption.

Two regression tests, both failing before this change with
`sending_ramp_status = "exempt"`: the gate directly, and a real
ramp-eligible send driven through the send worker. Each asserts the
domain stays 'inactive' and that no `sending_ramp_scopes`,
`domain_send_counters`, or `sending_ramp_reservations` rows appear.

Rows already stamped 'exempt' by a running deployment are NOT remediated
here; the runbook now says so and describes the choice.


Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(sending): compose ramp with protection gate (#992)

* feat(sending): compose ramp with protection gate

B4 of the sending-protection plan. The custom-domain ramp and the sending
budget answer different questions — "has this domain earned this volume
yet?" versus "has this account or the platform exposed SES enough today?"
— and this slice makes one transaction answer both, most restrictive
winning.

One lock order for the ramp. The store previously used three: Reserve
took domain → scope → reservation → counter, Confirm took reservation →
counter → scope, Release took reservation → counter. Three orders over
four keys is a deadlock waiting for traffic, and it becomes unavoidable
once the gate composes both ledgers into one transaction, because that
transaction already holds highly contended global budget counters when it
reaches the ramp. internal/sendramp/tx.go now holds the single order
(domain identity → registrable-domain scope → message reservation → UTC
day counter) and the pool-owning methods are thin wrappers, so there is
no second implementation to drift.

Composition, in internal/sendingpolicy/ramp.go:

- Probation is now the ramp's answer rather than a stand-in. Shared-relay
  traffic stays probationary at every plan level and never graduates; a
  custom domain is probationary until its scope has one qualified day.
  That classification decides which budget counters the transaction must
  lock, and the budget counters come BEFORE the ramp keys in the
  normative order — so it is read unlocked. That is sound only because
  ramp progress is monotonic: a stale answer can be stale in the strict
  direction and no other.
- The ramp is authorized last, after the budget has been reacquired. A
  ramp hold therefore arrives with budget units already taken, and those
  are released before returning the hold — keeping them would charge an
  account for a send its own domain was not allowed to make.
- SettleProvider now moves the ramp: acceptance advances a qualified day,
  a definite permanent rejection releases the units, and retryable or
  ambiguous results leave the reservation standing, because a message
  that might have been delivered must not release capacity.
- CancelAttempt releases both ledgers; DeferAttempt still releases only
  the budget. A rate deferral was not rejected by anyone, and giving back
  its ramp claim would let the same message re-qualify a stage it already
  qualified.

Disabled mode is genuinely pass-through: no scope row, no counter, and
above all no `exempt` write. Writing `exempt` while the ramp is off would
permanently grandfather every domain that happened to send during the
disabled window, and the phase-3 activation would then find nothing left
to ramp. Production ships in exactly this state.

A fixture note worth keeping: the ramp ledger is keyed by REGISTRABLE
domain, so `ramp-1.example.test` and `ramp-2.example.test` are one scope.
The first version of these tests read the hostname key, found empty rows,
and would have passed for the wrong reason. Each fixture domain is now
its own eTLD+1, and the one test that is about sharing builds two
hostnames under a single registrable domain deliberately.

Tests: 264 in the package plus the existing ramp suite, green under
-race. Covers the 150/213/277 stage caps and their 75/107/139
qualification bars, every probation class, budget-allow/ramp-hold and its
mirror, settlement idempotency, permanent-rejection release,
defer-versus-cancel, subdomain scope sharing, and a Free account that can
qualify stage one but not stage two without losing its progress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(sending): keep the ramp binding under retry, refusal, and rebind

Review of the ramp composition found seven ways the stage cap stopped being
a cap. All of them come from one shape: the sending budget is keyed by
submission ATTEMPT while the ramp reservation is keyed by MESSAGE, and code
that treats the two as interchangeable gives away capacity that was already
spent.

The refund. CancelAttempt released the ramp for any cancelled attempt.
Attempt one authorizes and hands 100 recipients to SES, the result is
ambiguous so nothing settles and the reservation correctly stands, River
allocates attempt two, a suppression cancels it — and the ramp counter goes
to zero for mail that is already in flight. It repeats, so the cap became
advisory. cancelRamp now asks about the OPERATION rather than the ordinal: if
any attempt was ever authorized, only SettleProvider may give those units
back. A reservation no attempt has authorized — the shape today's outbound
worker produces before this module is involved — is still refundable, which
is also where the mirror bug lived: the `released` early return meant a
cancel following a deferral never reached the ramp at all.

The stranding. A permanent sendramp error (`reservation already released` is
reachable by Reserve-after-Cancel on one operation) came back as a hard
error, rolling the transaction back with the attempt still `reserved`. Every
later execution failed identically, so nothing could ever release its units:
50 recipients pinned on global_all, global_probation, and account_daily
until midnight, three of them enough to close probationary sending for the
platform. Permanent refusals are now a terminal hold that releases, exactly
as the envelope path already answers the same class of loss.

The rebind. A domain with an unverified SENDING identity was ramp
pass-through AND, because InspectScopeTx never read the column, reported
established once its scope had a qualified day — no cap and no probation
charge. The wire identity is frozen at acceptance but the agent's registered
domain is not: verifying a child subdomain rebinds the account's agents onto
it, the child's SES identity stays pending while its DKIM is never
published, and the ramp resolves the domain live. An accepted backlog went
out uncapped under the parent's frozen From. It now holds, and classifies as
probationary. The two legacy states that mean "this domain already earned
its volume" — `exempt` and `complete` — are checked first and are untouched.

Three smaller ones. Reserve still classified probation as `op.Shared` with a
comment deferring to a task this commit is; the early hold therefore never
bounded the probation pool, the stored column disagreed with the class every
release targets, and each authorization paid a needless release-and-reacquire
on the platform's hottest counter rows. The ramp's own source read answered a
vanished message with a RETRYABLE hold, and because it runs before the
envelope resolution that answers it terminally, the wrong answer won whenever
the ramp was armed — a worker snoozing forever instead of failing once. And
in the ledger itself: a released-to-confirmed restoration errored instead of
no-opping when maintenance had reaped the day's counter (the reservation
outlives it by design), while ReleaseTx recorded `released` even when its
guarded decrement matched nothing, so a later restoration added back units
the counter never returned.

Tests: every fix above has a regression test that fails before it. The file
also gained the coverage Task 4 Step 2 asks for and did not have — racing
workers against one stage cap with the budget counters contended (exactly one
cap admitted, no deadlock), cross-midnight re-age of BOTH ledgers in one
authorization, settlement arriving days after its attempt, and a
FOR UPDATE NOWAIT probe that proves the named domain → scope → reservation →
day-counter suborder is actually taken rather than merely documented. Weak
assertions were tightened: the probation tests now assert the hold REASON,
the ramp-hold test checks all three pools it charged, and the Free-plan test
proves progression RESUMES after upgrade rather than merely surviving it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendramp): pin InspectScopeTx classification in its own package

InspectScopeTx decides which budget pool a send charges before the ramp
lock is taken, but every test of it lived in sendingpolicy, so the
per-package coverage gate saw it at 0% and failed the package at 76.6%.

Pin each branch where the code lives: missing domain, legacy exempt,
stamped complete, unverified identity with an old scope (must stay in
probation and ignore the scope), verified with no scope (day zero), a
completed scope behind a still-ramping domain row, the day-zero/day-one
boundary, and registrable-domain scope lookup with a hostname-keyed
decoy. Removing the unverified guard makes the fourth test fail.

Package coverage: 76.6% -> 84.9%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendingpolicy): redeem before settling in the ramp tests

Settlement reports what the provider did, so it is only meaningful for an
attempt that opened the socket. The adapter (B5, #998) redeems the token
immediately before it dials, and the gate there refuses to settle an
attempt whose call_state is not 'started'. These tests settled straight
after ConsumeAttempt; route them through a consumeAndRedeem helper so the
two slices can land in either order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(outbound): require single-use provider authorization (B5) (#998)

* test(testutil): extract the database helper into a leaf package

testutil bundles the contract server, which imports outbound. Once
outbound imports sendingpolicy (the provider seam requires the gate's
token), any INTERNAL test file in outbound or sendingpolicy that calls
testutil.TestDB closes an import cycle.

Move db.go into internal/testutil/testdb, a leaf that depends only on
identity and migrations, and keep testutil.TestDB / TestDBURL /
OpenPreparedTestDB / TruncateAll as forwarding wrappers so every
existing caller is unchanged. The contract server and its River test
use the two newly exported helpers (Truncate, BaseTestDBURL) instead of
package-private ones. The one internal sendingpolicy test imports
testdb directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): require single-use provider authorization

Add ProviderSubmitter, the one path from a composed message to an SMTP
transaction with SES. It requires a sendingpolicy.ProviderAuthorization
for every call, proves the envelope is the authorized one, derives
X-E2A-Provider-Attempt and X-SES-TENANT only from the token (and
X-SES-CONFIGURATION-SET from configuration), strips every occurrence of
those headers — mixed case and folded forms — from the composed MIME,
redeems the token, and only then dials. Every early exit is I/O-free
and leaves the token intact; a misconfigured relay is refused before
redemption so a retry does not burn an ordinal.

A definite permanent rejection is settled as such; an acceptance is
settled with the provider's message id, which SettleProvider now binds
to the attempt's feedback correlation exactly once (same id replays,
a different id is refused). A settlement that fails after acceptance
is reported on the result, never as a send error, so the caller retries
settlement rather than resubmitting.

The legacy tokenless Sender.SubmitOnce path stays for now; Task 7
migrates its callers and makes the relay's socket-opening methods
package-private.

Tests: zero-network on missing, mismatched, stale, and reused tokens
(asserted against a socket counter); exact and single attempt/tenant
headers with smuggled spellings removed and the body untouched; a
physical retry redeems a distinct ordinal; provider id binding and its
conflict rule. Removing the header strip, reordering redemption before
envelope validation, or dropping the id binding each fail a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the provider seam after review

Two review passes (correctness + adversarial) over the first cut. Every
finding below has a named test, and removing each fix fails it.

Gate:
- RedeemProviderCall re-proves the abuse pause. ConsumeAttempt linearized
  it, but that transaction has committed; a pause landing before the dial
  now invalidates the token. Unlocked read, because the account-control
  key precedes the operation key this transaction already holds.
- SettleProvider requires call_state = 'started', not just a confirmed
  reservation: a provider id bound to an attempt that never dialed is a
  ledger claim about a send that did not happen.
- The provider message id is normalized to SES's bare form on bind and on
  compare (NormalizeProviderMessageID). The relay returns it qualified
  (<id@region.amazonses.com>); SNS feedback carries it bare; the worker
  and the feedback finalizer must not refuse each other's spelling.
- A tenant name that cannot be a header value holds at mint with
  ses_tenant_unnamed instead of wedging silently in the adapter.

Adapter:
- X-E2A-Message-ID is derived from the token (a customer operation IS its
  message id), not trusted from the caller; Envelope.MessageID is gone.
- RCPT TO is issued from AuthorizedRecipients(), the canonical envelope
  the budget priced, never the caller's spelling of it.
- A bare CR anywhere in the header section, or a leading continuation
  line, is refused before redemption (ErrMalformedHeaderSection): a
  receiver that treats a lone CR as a line break would see a header this
  walker did not.
- Provider headers are emitted in the legacy path's order (configuration
  set first) so the worker swap is byte-identical for shared headers.
- The relay marks a failure after the terminating dot with
  ErrProviderAcceptanceUnknown; the adapter leaves it unsettled and no
  classifier calls it permanent.

Tests: pause between consume and submit; settle without redeem; bare CR
and leading continuation refused with zero sockets; canonical RCPT on the
wire; lost 250 unsettled and marked; 4xx unsettled; accepted-but-unsettled
reported on the result with a nil error; a positive control proving the
socket counter observes a dial; provider id normalization across the three
spellings. The socket counter now waits for the accept goroutine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): keep the acceptance-unknown marker honest

Re-review of the previous fix commit found the marker leaking in both
directions. The relay's deferred context remap replaced the error
wholesale, so a deadline or cancellation after the terminating dot — the
likeliest way to lose a 250 — dropped ErrProviderAcceptanceUnknown and
classified as a connection outage the worker would re-drive. And the
marker was joined to every post-DATA failure, including a coded 554
content rejection, which is the provider's definite answer and must
classify permanent. Now the marker survives the remap and is attached
only when no reply code came back.

Also pins the customer-only guard on the redeem-time pause re-check: the
notice telling an account it was paused is sourced from that paused
account and must still go out. Reviewer-authored test adopted. And the
compare-side normalization of the provider id now has a test that writes
a qualified spelling directly and replays bare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(outbound): enforce sending policy at fire time (B6) (#999)

* feat(messagelifecycle): add policy-budget and sending-setup expiry reasons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(sendingpolicy): settle and look up operations by id for evidence paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(identity): carry finite-hold state on the send claim

The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): enforce sending policy at fire time

The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* style(messagelifecycle): gofmt the reason catalog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(api): publish sending_paused and the two hold-expiry reasons

The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the worker cutover after review

Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): settle, wire, and mark precisely after re-review

Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(outbound): close the provider seam for every sender (B7) (#1000)

* feat(messagelifecycle): add policy-budget and sending-setup expiry reasons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(sendingpolicy): settle and look up operations by id for evidence paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(identity): carry finite-hold state on the send claim

The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): enforce sending policy at fire time

The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* style(messagelifecycle): gofmt the reason catalog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(api): publish sending_paused and the two hold-expiry reasons

The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the worker cutover after review

Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): settle, wire, and mark precisely after re-review

Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): close the provider seam for every sender

Slice B7 of the sending abuse prevention plan. The relay no longer exports
a send method; ProviderSubmitter.SubmitOnce with a gate token is the only
way to reach the provider, and a tracked-closure test parses every
production file to keep it that way.

- hitlnotify + webhooknotify: enqueue prepares a customer_notification
  operation in the source transaction and stamps it on the job; workers
  run Reserve -> early hold -> ConsumeAttempt -> authorized submit and
  snooze on a hold without provider I/O; pre-floor jobs resolve at fire
  time and are stamped once (jobs.StampJobArg).
- public feedback: server-keyed public_feedback_notification operation
  with a bounded per-attempt Reserve/Consume/Submit loop; a definite
  rejection or a lost acceptance stops it.
- e2a -reconcile-legacy-sending-jobs: stamps pending outbound_send /
  hitl_notify / webhook_notify jobs that carry no operation, cancels
  orphans, exits nonzero unless every scanned job was decided.
- main, TestServer and the contract server build the notifiers over the
  shared submitter and hand the API the submitter + gate.
- design addendum in docs/design/async-message-pipeline.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): key notification operations by source and charge last

Review round 1 of the provider-seam closure (B7).

- PrepareNotificationTx derives the operation id from its source
  (op_hitl_<message>, op_wh_<kind>_<webhook>_<episode>), so a repeat
  preparation yields one operation and the notify workers cancel a job
  whose reference names any other operation.
- The notify Deliverer is Compose + Submit; workers run compose ->
  Reserve -> hold -> ConsumeAttempt -> Submit, so a compose failure
  charges nothing and the token is consumed right before the socket.
- Reconcile command re-reads each job under FOR UPDATE and skips one a
  worker claimed or stamped meanwhile; counts separate paused/skipped.
- Feedback loop submits the token's canonical recipients, paces retries
  to fit the handler budget, keeps the SMTP error on a deadline, releases
  a reserved attempt when authorize errors, no panic on id mint.
- Closure guard never skips, matches method references, exempts the
  SubmitOnce symbol only, asserts its sentinel, fences the SES v2 import.
- Webhook health notices older than seven days are dropped.
- Wiring test for the notification bundles and the API seam; StampJobArg
  tests for the jobs coverage floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): re-key pre-derivation notification references

Re-review of the provider-seam closure (B7) found that migration 113
stamped adopted notify jobs with op_<md5> references, which the new
source binding would have cancelled on any upgrade crossing v1.8.7.

- A reference that is not a derived id is treated as pre-derivation: the
  notify workers re-resolve it through the Prepare path and replace it
  once (jobs.SetJobArg); a derived id for another source still cancels.
- The reconcile command scans and re-keys those references too.
- Bounded the feedback attempt release (2s); symmetric 7-day age guard on
  HITL notices; episode key in microseconds; nil-receiver guards on the
  compose path; doc and comment corrections; the closure guard states
  its residual scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(cmd): keep the reconcile scan two-valued and replaceable

Round-3 re-review nits: COALESCE the conforming-reference predicate so a
reference with no id cannot fall out of a NOT scan, decode only the
source fields so such a reference is replaced rather than failing to
decode, and state in the workers that any non-derived shape re-derives
from the job's own source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(blog): add 'Your agent's inbox is storage, not transport' (#1007)

Argues inbox polling is a transport problem, not a discipline
problem, and lays out e2a's four inbound delivery channels (signed
webhooks, WebSocket with no public URL, REST polling, MCP) plus
e2a listen for the laptop case.

* fix(sendingpolicy): lock the agent FOR NO KEY UPDATE in the accept path (#1006)

* fix(sendingpolicy): lock the agent FOR NO KEY UPDATE in the accept path

The v1.9.0 staging conformance gate failed on eight parallel HITL holds:
SQLSTATE 40P01. Each accept transaction inserts its message first, which
takes a FOR KEY SHARE lock on the agent row through the foreign key and a
row lock on account_usage through the storage trigger, then prepares its
operation, which locked the agent FOR UPDATE. FOR UPDATE conflicts with
KEY SHARE, so two concurrent sends waited on each other. The direct send
path (PrepareExternalTx) has the identical shape and deadlocks the same
way under parallel sends; staging simply never ran that case.

FOR NO KEY UPDATE keeps every ordering the gate needs (callers serialize
against each other and against any update or delete of the row) and does
not conflict with a foreign-key share. Same change for the webhook row.

Two regression tests reproduce the deadlock deterministically at the
gate and through the API, and both fail with the old lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendingpolicy): force the deadlock interleaving instead of timing it

Review of the lock-order fix: the gate test's 300ms sleep could let A
finish before B ever blocked, passing vacuously against the bug. B now
reports its backend pid and A waits until pg_stat_activity shows it
blocked on a lock. The e2e test no longer calls t.Fatal from worker
goroutines, and the PrepareExternalTx ordering comment now describes the
function rather than every caller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(conformance): cover parallel direct sends and record the lock rule

The staging gate only sent in parallel from a HITL agent; the direct
accept path has the same insert-then-lock shape and carries almost all
traffic. Add the eight-parallel-direct-sends case, write the accept
transaction's lock order into the pipeline design doc, and record the
FOR KEY SHARE / FOR UPDATE rule in AGENTS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* docs(readme): highlight hosted service and MCP setup (#1008)

* docs(readme): highlight hosted service and MCP setup

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* docs(readme): match Token Canopy brand colors

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

---------

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* docs(readme): use warm gold for logo and hosted button (#1010)

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* deps: bump the go-minor-patch group with 8 updates (#1004)

Bumps the go-minor-patch group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.32.39` | `1.33.2` |
| [github.com/aws/aws-sdk-go-v2/service/sesv2](https://github.com/aws/aws-sdk-go-v2) | `1.67.1` | `1.71.0` |
| [github.com/aws/aws-sdk-go-v2/service/sts](https://github.com/aws/aws-sdk-go-v2) | `1.45.8` | `1.48.0` |
| [github.com/aws/smithy-go](https://github.com/aws/smithy-go) | `1.27.10` | `1.28.1` |
| [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) | `3.20.0` | `3.21.0` |
| [github.com/riverqueue/river](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |
| [github.com/riverqueue/river/riverdriver/riverpgxv5](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |
| [github.com/riverqueue/river/rivertype](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |


Updates `github.com/aws/aws-sdk-go-v2/config` from 1.32.39 to 1.33.2
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.39...config/v1.33.2)

Updates `github.com/aws/aws-sdk-go-v2/service/sesv2` from 1.67.1 to 1.71.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.67.1...service/s3/v1.71.0)

Updates `github.com/aws/aws-sdk-go-v2/service/sts` from 1.45.8 to 1.48.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/sts/v1.45.8...service/s3/v1.48.0)

Updates `github.com/aws/smithy-go` from 1.27.10 to 1.28.1
- [Release notes](https://github.com/aws/smithy-go/releases)
- [Changelog](https://github.com/aws/smithy-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/aws/smithy-go/compare/v1.27.10...v1.28.1)

Updates `github.com/coreos/go-oidc/v3` from 3.20.0 to 3.21.0
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.20.0...v3.21.0)

Updates `github.com/riverqueue/river` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

Updates `github.com/riverqueue/river/riverdriver/riverpgxv5` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

Updates `github.com/riverqueue/river/rivertype` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](https://github.com/riverqueue/river/compare/v0.45.0...v0.47.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sesv2
  dependency-version: 1.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sts
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/smithy-go
  dependency-version: 1.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river/riverdriver/riverpgxv5
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river/rivertype
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* deps(web): bump the npm-minor-patch group in /web with 8 updates (#1003)

Bumps the npm-minor-patch group in /web with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.3.3` | `16.3.4` |
| [@next/mdx](https://github.com/vercel/next.js/tree/HEAD/packages/next-mdx) | `16.3.3` | `16.3.4` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.6` | `14.6.7` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.3.0` | `26.4.1` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.3` | `16.3.4` |
| [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) | `30.4.2` | `30.5.1` |
| [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) | `30.4.1` | `30.5.1` |


Updates `next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.3...v16.3.4)

Updates `@next/mdx` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/next-mdx)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.6 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.6...v14.6.7)

Updates `@types/node` from 26.3.0 to 26.4.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-config-next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/eslint-config-next)

Updates `jest` from 30.4.2 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest)

Updates `jest-environment-jsdom` from 30.4.1 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@next/mdx"
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@types/node"
  dependency-version: 26.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: eslint-config-next
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: jest
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: jest-environment-jsdom
  dependency-version: 30.5.1
  dependency-type: direct:development…
jiashuoz added a commit that referenced this pull request Sep 10, 2026
…#983)

* fix(testutil): derive a per-workspace database for non-.test binaries

derivedDBSuffix() only derived the per-workspace, per-package database
suffix when os.Args[0] ended in ".test", so cmd/e2a-contract-server, the
one non-test binary sharing this harness, fell back to the base URL
verbatim. Two contract-server instances from different checkouts (two
agents, two worktrees) landed on the same e2a_test database and
truncated each other's rows on Close.

The suffix is now keyed on the running binary's own name regardless of
a ".test" suffix, so a compiled binary derives one too. testutil is
imported by exactly one other non-test file, cmd/e2a-contract-server;
every other consumer is already a `go test` binary and derives the
same suffix it did before.

Fixes #827

* ci: rerun to check Go e2e tests timeout reproducibility

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>

* feat(outbound): enforce sending policy at fire time (B6) (#999)

* feat(messagelifecycle): add policy-budget and sending-setup expiry reasons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(sendingpolicy): settle and look up operations by id for evidence paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(identity): carry finite-hold state on the send claim

The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): enforce sending policy at fire time

The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* style(messagelifecycle): gofmt the reason catalog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(api): publish sending_paused and the two hold-expiry reasons

The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the worker cutover after review

Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): settle, wire, and mark precisely after re-review

Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(outbound): close the provider seam for every sender (B7) (#1000)

* feat(messagelifecycle): add policy-budget and sending-setup expiry reasons

Two additive local failure reasons for the sending-protection holds:
submission.policy_budget_expired (a sending-budget hold reached its
seven-day deadline) and submission.sending_setup_expired (SES tenant
readiness did not land within the 72-hour setup deadline). Both are
local, correctable outcomes like submission.local_retries_exhausted, and
neither may ever be reported as a recipient rejection or a provider
outage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(sendingpolicy): settle and look up operations by id for evidence paths

Two callers hold provider evidence but no token: the worker that finds
provider-accept evidence already recorded on a row it is about to
re-drive, and the terminal reconciler settling a stranded row from that
same evidence. Neither can name an ordinal. SettleOperation applies the
outcome to the latest attempt whose provider call started — never a
later ordinal that was only reserved, and nothing when no attempt ever
dialed — through the same body SettleProvider uses. LookupOperation
recovers a reference for an operation that already exists; it is not a
constructor, and every Gate method still reloads the row under lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(identity): carry finite-hold state on the send claim

The send claim now returns the message's persisted hold class and
anchor (migration 116) plus the owning account's last_resumed_at and
ses_tenant_ready_at, so every worker execution can re-derive the same
deadline. RecordOutboundHold writes the pair only while the message is
pre-terminal; every terminal write — sent, failed, evidence-settled,
trash-cancelled — clears it, so a stale hold can never outlive its
message's outcome. The two new local expiry reasons are recognized as
complete terminal fallbacks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): enforce sending policy at fire time

The outbound send worker now authorizes every provider call through the
sending-protection Gate, in the fixed order the design names: Reserve
the durable attempt; snooze on an early hold without provider I/O;
DeferAttempt on a rate deferral and CancelAttempt on a final suppression
match; ConsumeAttempt as the last serialized decision; then the
authorized submitter, which redeems the token immediately before the
socket opens and settles the provider's answer. A later execution after
a confirmed attempt returns to Reserve, which allocates the next ordinal.

The worker-owned RampGate and agent.NewOutboundRampGate are removed: the
ramp is composed inside the gate and its progress moves only through
settlement. The Deliverer contract carries the token; the production
deliverer is outbound.ProviderSubmitter and refuses to dial without one.
A lost 250 (ErrProviderAcceptanceUnknown) is retried as a new ordinal and
never settled.

Enqueue prepares the operation in the accept transaction, between the
message insert and the River insert; a paused account is refused there
(ErrSendingPaused, HTTP 403 sending_paused) rather than queued. Jobs from
a pre-floor slot carry no reference and resolve at fire time through the
same Prepare path.

Finite holds persist a class and anchor on the message and derive the
deadline every execution: 72 hours for rate/ramp/provider and tenant
setup, seven days for policy budget. The first finite hold anchors at
the latest of accept, schedule, review, and last resume; a budget hold
promotes any class and keeps the anchor; policy_budget never changes
again; tenant readiness landing inside the setup deadline moves the
class to rate/ramp/provider exactly once; a pause has no clock but a
running deadline keeps running. Expiry emits the class's own reason.

Terminal reconciliation is settlement-only: an evidence-settled row also
settles the attempt that dialed through Gate.SettleOperation.

cmd/e2a gains one composition root (newOutboundSending) and a wiring
test that proves the registered send path holds the concrete gate and
the ProviderSubmitter-backed deliverer. The test servers build the same
composition with the disabled policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* style(messagelifecycle): gofmt the reason catalog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(api): publish sending_paused and the two hold-expiry reasons

The machine-checked contracts caught three vocabularies the worker
cutover widened without saying so: the error-code catalog and the
ErrorBody.Code documentation (sending_paused, 403, auth family), the
lifecycle reason table in docs/api.md (submission.policy_budget_expired,
submission.sending_setup_expired), and the OpenAPI description the two
generated SDK models embed. Both SDK error maps classify sending_paused
as a non-retryable permission error, with tests.

The email-eval integration runner's job-args parser insisted on exactly
one key; the accept transaction now stamps operation_ref beside
message_id, so the parser admits that key and still rejects any other.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): harden the worker cutover after review

Two parallel reviews (correctness + adversarial) over the first cut.
Every item has a named test.

Contract surfaces (the blocker in both): the two new lifecycle reasons
join the hand-maintained reason_code enum tag, both closed-vocabulary
tests, the regenerated spec, both generated SDK models (mirrored by
hand; the generator needs Docker, and the description now carries no
apostrophe so the two generators agree), the web lifecycle parser and
timeline, docs/api.md and docs/events.md. sending_paused is registered
in the error catalog, the ErrorBody.Code doc, docs/api.md, and both SDK
error maps.

Worker:
- A gate outage is a bounded rate/ramp/provider hold, not an unbounded
  snooze.
- A paused job evaluates no deadline; a persisted deadline is not
  extended and the first hold after resume applies it.
- A provider outage never emits the setup reason (expiryReasonFor).
- markFailed's evidence-settle branch settles the dialed attempt, as the
  reconciler already did; a failed post-acceptance settlement is retried
  before it is logged as critical; a provider-id conflict is surfaced as
  an invariant alarm.
- The job's operation reference must name its own message; a mismatch
  cancels before any ledger call. Enqueue refuses a zero reference.
- HoldClassFor maps reasons by name; the armed worker RegisterJobs
  builds is exposed (Jobs.SendWorker) so the wiring test can prove it
  carries the gate and the legacy resolver and the submitter carries the
  configuration set.

Gate: SettleOperation prefers the oldest dialed attempt with no provider
id yet, so evidence arriving in send order binds each attempt when
several dialed.

Paused accounts on every enqueue path: 403 sending_paused on the direct,
platform-test, and HITL-approve paths; the TTL auto-approve sweep defers
a paused account's expired review by an hour (DeferReviewExpiry) instead
of re-picking it first every cycle and starving the batch.

The email-eval integration runner admits the operation_ref args key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): settle, wire, and mark precisely after re-review

Mutation-tested re-review of the previous fix round: no blockers, six
should-fixes.

- SettleOperation without a token resolves the attempt as: one already
  bound to this exact provider id (a replay stays home), else the oldest
  dialed attempt with no id, else the latest dialed. The earlier
  oldest-unbound-first rule let a replay for attempt one bind attempt
  two; the test now covers that shape.
- snoozeOnGateError threads the live reservation into the bounded hold,
  so an expiry at final authorization gives the attempt back instead of
  stranding it under an enforcing policy.
- MarkFailed returns the evidence's provider id, and the worker's
  evidence settle under a terminal write carries it — the reconciler
  already did. The two evidence paths now agree.
- resettle logs at critical level when the context ends mid-retry; a
  dedicated test covers the retry itself.
- The wiring test registers workers exactly as main does and inspects
  the worker River received (Jobs.RegisteredSendWorker), so a
  RegisterJobs that bypassed the armed constructor fails it.
- sending_paused is marked experimental beside blocked_by_policy in the
  stability extension, the docs, and the description, since the pause
  control ships disabled and pre-GA. The Python forward-compat table
  gains the two lifecycle reasons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* feat(outbound): close the provider seam for every sender

Slice B7 of the sending abuse prevention plan. The relay no longer exports
a send method; ProviderSubmitter.SubmitOnce with a gate token is the only
way to reach the provider, and a tracked-closure test parses every
production file to keep it that way.

- hitlnotify + webhooknotify: enqueue prepares a customer_notification
  operation in the source transaction and stamps it on the job; workers
  run Reserve -> early hold -> ConsumeAttempt -> authorized submit and
  snooze on a hold without provider I/O; pre-floor jobs resolve at fire
  time and are stamped once (jobs.StampJobArg).
- public feedback: server-keyed public_feedback_notification operation
  with a bounded per-attempt Reserve/Consume/Submit loop; a definite
  rejection or a lost acceptance stops it.
- e2a -reconcile-legacy-sending-jobs: stamps pending outbound_send /
  hitl_notify / webhook_notify jobs that carry no operation, cancels
  orphans, exits nonzero unless every scanned job was decided.
- main, TestServer and the contract server build the notifiers over the
  shared submitter and hand the API the submitter + gate.
- design addendum in docs/design/async-message-pipeline.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): key notification operations by source and charge last

Review round 1 of the provider-seam closure (B7).

- PrepareNotificationTx derives the operation id from its source
  (op_hitl_<message>, op_wh_<kind>_<webhook>_<episode>), so a repeat
  preparation yields one operation and the notify workers cancel a job
  whose reference names any other operation.
- The notify Deliverer is Compose + Submit; workers run compose ->
  Reserve -> hold -> ConsumeAttempt -> Submit, so a compose failure
  charges nothing and the token is consumed right before the socket.
- Reconcile command re-reads each job under FOR UPDATE and skips one a
  worker claimed or stamped meanwhile; counts separate paused/skipped.
- Feedback loop submits the token's canonical recipients, paces retries
  to fit the handler budget, keeps the SMTP error on a deadline, releases
  a reserved attempt when authorize errors, no panic on id mint.
- Closure guard never skips, matches method references, exempts the
  SubmitOnce symbol only, asserts its sentinel, fences the SES v2 import.
- Webhook health notices older than seven days are dropped.
- Wiring test for the notification bundles and the API seam; StampJobArg
  tests for the jobs coverage floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(outbound): re-key pre-derivation notification references

Re-review of the provider-seam closure (B7) found that migration 113
stamped adopted notify jobs with op_<md5> references, which the new
source binding would have cancelled on any upgrade crossing v1.8.7.

- A reference that is not a derived id is treated as pre-derivation: the
  notify workers re-resolve it through the Prepare path and replace it
  once (jobs.SetJobArg); a derived id for another source still cancels.
- The reconcile command scans and re-keys those references too.
- Bounded the feedback attempt release (2s); symmetric 7-day age guard on
  HITL notices; episode key in microseconds; nil-receiver guards on the
  compose path; doc and comment corrections; the closure guard states
  its residual scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* fix(cmd): keep the reconcile scan two-valued and replaceable

Round-3 re-review nits: COALESCE the conforming-reference predicate so a
reference with no id cannot fall out of a NOT scan, decode only the
source fields so such a reference is replaced rather than failing to
decode, and state in the workers that any non-derived shape re-derives
from the job's own source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* feat(blog): add 'Your agent's inbox is storage, not transport' (#1007)

Argues inbox polling is a transport problem, not a discipline
problem, and lays out e2a's four inbound delivery channels (signed
webhooks, WebSocket with no public URL, REST polling, MCP) plus
e2a listen for the laptop case.

* fix(sendingpolicy): lock the agent FOR NO KEY UPDATE in the accept path (#1006)

* fix(sendingpolicy): lock the agent FOR NO KEY UPDATE in the accept path

The v1.9.0 staging conformance gate failed on eight parallel HITL holds:
SQLSTATE 40P01. Each accept transaction inserts its message first, which
takes a FOR KEY SHARE lock on the agent row through the foreign key and a
row lock on account_usage through the storage trigger, then prepares its
operation, which locked the agent FOR UPDATE. FOR UPDATE conflicts with
KEY SHARE, so two concurrent sends waited on each other. The direct send
path (PrepareExternalTx) has the identical shape and deadlocks the same
way under parallel sends; staging simply never ran that case.

FOR NO KEY UPDATE keeps every ordering the gate needs (callers serialize
against each other and against any update or delete of the row) and does
not conflict with a foreign-key share. Same change for the webhook row.

Two regression tests reproduce the deadlock deterministically at the
gate and through the API, and both fail with the old lock.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(sendingpolicy): force the deadlock interleaving instead of timing it

Review of the lock-order fix: the gate test's 300ms sleep could let A
finish before B ever blocked, passing vacuously against the bug. B now
reports its backend pid and A waits until pg_stat_activity shows it
blocked on a lock. The e2e test no longer calls t.Fatal from worker
goroutines, and the PrepareExternalTx ordering comment now describes the
function rather than every caller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

* test(conformance): cover parallel direct sends and record the lock rule

The staging gate only sent in parallel from a HITL agent; the direct
accept path has the same insert-then-lock shape and carries almost all
traffic. Add the eight-parallel-direct-sends case, write the accept
transaction's lock order into the pipeline design doc, and record the
FOR KEY SHARE / FOR UPDATE rule in AGENTS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

* docs(readme): highlight hosted service and MCP setup (#1008)

* docs(readme): highlight hosted service and MCP setup

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* docs(readme): match Token Canopy brand colors

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

---------

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* docs(readme): use warm gold for logo and hosted button (#1010)

Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>

* deps: bump the go-minor-patch group with 8 updates (#1004)

Bumps the go-minor-patch group with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.32.39` | `1.33.2` |
| [github.com/aws/aws-sdk-go-v2/service/sesv2](https://github.com/aws/aws-sdk-go-v2) | `1.67.1` | `1.71.0` |
| [github.com/aws/aws-sdk-go-v2/service/sts](https://github.com/aws/aws-sdk-go-v2) | `1.45.8` | `1.48.0` |
| [github.com/aws/smithy-go](https://github.com/aws/smithy-go) | `1.27.10` | `1.28.1` |
| [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) | `3.20.0` | `3.21.0` |
| [github.com/riverqueue/river](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |
| [github.com/riverqueue/river/riverdriver/riverpgxv5](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |
| [github.com/riverqueue/river/rivertype](https://github.com/riverqueue/river) | `0.45.0` | `0.47.0` |


Updates `github.com/aws/aws-sdk-go-v2/config` from 1.32.39 to 1.33.2
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](aws/aws-sdk-go-v2@config/v1.32.39...config/v1.33.2)

Updates `github.com/aws/aws-sdk-go-v2/service/sesv2` from 1.67.1 to 1.71.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](aws/aws-sdk-go-v2@service/s3/v1.67.1...service/s3/v1.71.0)

Updates `github.com/aws/aws-sdk-go-v2/service/sts` from 1.45.8 to 1.48.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](aws/aws-sdk-go-v2@service/sts/v1.45.8...service/s3/v1.48.0)

Updates `github.com/aws/smithy-go` from 1.27.10 to 1.28.1
- [Release notes](https://github.com/aws/smithy-go/releases)
- [Changelog](https://github.com/aws/smithy-go/blob/main/CHANGELOG.md)
- [Commits](aws/smithy-go@v1.27.10...v1.28.1)

Updates `github.com/coreos/go-oidc/v3` from 3.20.0 to 3.21.0
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](coreos/go-oidc@v3.20.0...v3.21.0)

Updates `github.com/riverqueue/river` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](riverqueue/river@v0.45.0...v0.47.0)

Updates `github.com/riverqueue/river/riverdriver/riverpgxv5` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](riverqueue/river@v0.45.0...v0.47.0)

Updates `github.com/riverqueue/river/rivertype` from 0.45.0 to 0.47.0
- [Release notes](https://github.com/riverqueue/river/releases)
- [Changelog](https://github.com/riverqueue/river/blob/master/CHANGELOG.md)
- [Commits](riverqueue/river@v0.45.0...v0.47.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sesv2
  dependency-version: 1.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sts
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/aws/smithy-go
  dependency-version: 1.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river/riverdriver/riverpgxv5
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
- dependency-name: github.com/riverqueue/river/rivertype
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* deps(web): bump the npm-minor-patch group in /web with 8 updates (#1003)

Bumps the npm-minor-patch group in /web with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [next](https://github.com/vercel/next.js) | `16.3.3` | `16.3.4` |
| [@next/mdx](https://github.com/vercel/next.js/tree/HEAD/packages/next-mdx) | `16.3.3` | `16.3.4` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.6` | `14.6.7` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.3.0` | `26.4.1` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.3` | `16.3.4` |
| [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) | `30.4.2` | `30.5.1` |
| [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) | `30.4.1` | `30.5.1` |


Updates `next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](vercel/next.js@v16.3.3...v16.3.4)

Updates `@next/mdx` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/next-mdx)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](testing-library/react-testing-library@v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.6 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](testing-library/user-event@v14.6.6...v14.6.7)

Updates `@types/node` from 26.3.0 to 26.4.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint-config-next` from 16.3.3 to 16.3.4
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.4/packages/eslint-config-next)

Updates `jest` from 30.4.2 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest)

Updates `jest-environment-jsdom` from 30.4.1 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest-environment-jsdom)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@next/mdx"
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@types/node"
  dependency-version: 26.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: eslint-config-next
  dependency-version: 16.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: jest
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: jest-environment-jsdom
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* deps(python): bump pydantic in /sdks/python in the uv-minor-patch group (#1002)

Bumps the uv-minor-patch group in /sdks/python with 1 update: [pydantic](https://github.com/pydantic/pydantic).


Updates `pydantic` from 2.13.4 to 2.13.5
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/v2.13.5/HISTORY.md)
- [Commits](pydantic/pydantic@v2.13.4...v2.13.5)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.13.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: uv-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(blog): add 'Anyone in the world can put text in front of your agent's model for the price of an email' (#1009)

Co-authored-by: jiashuoz <jiashuoz@users.noreply.github.com>

* deps: bump the npm-minor-patch group with 3 updates (#1001)

Bumps the npm-minor-patch group with 3 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [zod](https://github.com/colinhacks/zod) and [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react).


Updates `@types/node` from 26.3.0 to 26.4.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `zod` from 4.4.3 to 4.5.4
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](colinhacks/zod@v4.4.3...v4.5.4)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: zod
  dependency-version: 4.5.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* docs(testdb): describe non-test isolation

---------

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
Signed-off-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Josh Zhang <39790535+jiashuoz@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jiashuoz <jiashuoz@users.noreply.github.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