feat(outbound): close the provider seam for every sender (B7) - #1000
Merged
Conversation
…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
…o feat/sending-provider-closure
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
…-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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-cutoverand will be retargeted tomainonce B6 merges.What changes
The relay can no longer send without a token.
outbound.SMTPRelay's exportedSend*methods andoutbound.Sender'sSend/SendOnce/SubmitOnce*are deleted. The only socket-opening entry point isProviderSubmitter.SubmitOnce(ctx, auth, envelope). A new tracked-closure test (internal/outbound/provider_authorization_guard_test.go) parses every production Go file and fails on anynet/smtpimport 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) andwebhooknotify(webhook health notices):customer_notificationoperation in the same transaction as the source row (PrepareNotificationTx) and stamps it on the job (operation_ref);jobs.StampJobArg, key-absent guard, existing fields preserved).Public feedback mail (
POST /api/feedback) is apublic_feedback_notificationoperation 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 pendingoutbound_send/hitl_notify/webhook_notifyjob 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 sharedProviderSubmitterand hand the API the submitter + gate (SetProviderSubmitter).Review round 1 (correctness + adversarial, Opus) → fixes in the second commit
op_hitl_<msg>,op_wh_<kind>_<webhook>_<episode>):PrepareNotificationTxis 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.Delivereris nowCompose+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.FOR UPDATEinside its own transaction and skips one a worker claimed or stamped meanwhile; output separatespaused/skippedfromremaining.go.mod, git optional), matches method values / method expressions, exception is theSubmitOncesymbol not the file, asserts its sentinel is live, and fences the SES v2 SDK import to sender-identity provisioning.StampJobArgtests for theinternal/jobscoverage 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: enforceis 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
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
-raceon the three notify/outbound packages green.internal/e2ehas known environmental failures on cleanmainlocally; CI is the authority.🤖 Generated with Claude Code
https://claude.ai/code/session_01AjfGxvXW6fNKWGFHuo68yX