Skip to content

Define typed execution-limit policy and API contracts - #1745

Merged
jhgaylor merged 2 commits into
mainfrom
feat/typed-execution-limits
Sep 12, 2026
Merged

jhgaylor merged 2 commits into
mainfrom
feat/typed-execution-limits

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Eight small replacements from Fountain #1754 are published:

  • #1796: isolate global pricing tests (1 file).
  • #1797: delete only the owned broker session (4 files).
  • #1818: share the image byte limit (2 files).
  • #1820: record trusted provider identity (6 files; recording only).
  • #1826: require committed state before launching workers (2 files).
  • #1827: revoke only the failed provision's token (4 files).
  • #1831: preserve replacement/completed/cancelled state after a stale startup error (2 files).
  • #1840: validate opening input before allocation (5 files).

All eight pass full local precommit and CI and are ready for review. Another agent owns merges after review and CI.

Existing reset PR #1768 is also updated and ready: an enclosing-transaction guard adds 18 lines across two existing files. Its 62 focused tests and full precommit (4,892 tests +6 doctests) pass; the new regression fails on the old handler. Current-base CI passes on da12a039. The base change cancelled the superseded push run before tests.

This extraction pass is complete: eight new focused PRs plus the reset update. The residual 211-path audit against main bed264af found no additional standalone small cuts worth publishing. Remaining work needs coherent protocol replacements:

  • Provider create intent/results with retained ownership and capacity; then confirmed deletion and recovery. Account deletion and billing must retain uncertain operations.
  • Committed holder transfer and actor launch ownership, with startup/teardown/recovery tests. Preserve #1801's merged identity filter; a waiting helper or replacement callback alone omits the required transfer guarantee.
  • Durable prompt persistence, admission, claim and delivery through an actual worker, including HTTP outcomes and replay tests.

Schemas, dispatch workers, dependency pins and historical evidence must accompany their implemented protocol. The frozen reference and held execution stack stay unmerged. No execution-control/provider-recovery activation, SDK release or deployment. The extraction audit does not complete the lifecycle work.

Previous extraction (historical validation): Fountain #1793 moves account and agent lock waits before fleet reservation on fresh admission. Foreign-key inserts could still stall all tenants after #1790 removed explicit row locks. The two-file fix preserves atomic admission and the upstream creation helpers. Both PostgreSQL contention regressions reproduce on #1790; 78 affected tests, full precommit (4,869 tests +6 doctests) and CI pass. Ready for review.

Next: current host/account ceilings on later turns and recovery, plus contention review of other quota callbacks. New runtime controls remain disabled.

Earlier extraction history follows; validation counts below refer to those original revisions.

Focused replacement: #1791: preserve existing channel bindings on failed rotations, stacked on #1790. Three files, +353/-6 (75 net production lines). Old unbinding commits with replacement admission; failed checks roll it back. Synchronous worker/prompt refusal restores only the still-owned binding, preserving newer rotations and tenant reassignment. Nine parent regressions, 254 related tests, two independent PostgreSQL rotation races with open foreign-key references, full precommit (4,847 tests +6 doctests) and CI passed. This completes the channel-rotation follow-up from #1790. Current host/account ceilings for later turns/recovery and runtime enforcement remain; no new controls activated.

Focused replacement: #1790: atomic fresh sandbox/conversation/initial-allowance reservation, stacked on #1789. Five files, +190/-40. Rechecks current ceilings and ownership under the existing quota transaction; failed inserts roll back all three rows before worker or creation effects. Worker startup failure retains admitted state. Four reproduced parent failures, 229 related tests, a real PostgreSQL account-update lock wait, full precommit (4,832 tests +6 doctests) and CI passed. Fresh and attached initial persistence are complete; later-turn/recovery current-ceiling checks and runtime enforcement remain. Unsupported controls stay refused. Separate follow-up: channel rotation currently unbinds before fresh admission; preserve the old binding if admission fails.

Focused replacements:

Focused replacement: #1787: trusted host-ceiling launch preflight, stacked on #1785. Six files, +166/-10. FOUNTAIN_EXECUTION_LIMITS validates at boot without echoing input; launch requests inherit the stricter host/current-account ceilings. Eleven parent regressions, 65 related tests, full precommit (4,743 tests +6 doctests) and CI passed. Empty default; unsupported controls still refuse launch. Keep ceilings empty until later-turn/recovery checks and runtime enforcement integrate. Initial persistence is sibling #1786; this is not an atomic policy reservation or aggregate spend cap.

Focused replacement: #1786: tenant-scoped initial allowance creation, stacked on #1783. Four files, +245/-1. Ownership stays locked through insertion; duplicates cannot replace saved policy, and successful creation audits only control names. 125 related tests, two observed PostgreSQL race waits, full precommit (4,786 tests +6 doctests) and CI passed. Internal persistence only: no launch/HTTP caller or runtime activation. Trusted current-ceiling resolution and runtime support checks remain admission responsibilities; account preflight is sibling #1785.

Focused replacement: #1785: account-ceiling launch preflight, stacked on #1784 and reusing #1774. Five files, +289/-3. Omitted/null/empty requests inherit current account policy; widening, malformed policy and unsupported controls fail before launch/attachment/channel changes. Eight baseline regressions, 187 related tests, full precommit (4,732 tests +6 doctests) and CI passed. Read-only preflight is not atomic against policy changes; later-turn/recovery account checks, host ceilings, initial allowances and runtime enforcement remain separate. Keep account overrides empty until integrated.

Focused replacement: #1784: typed account-ceiling storage with an empty backfill, ordinary-account write exclusion, and database object constraint. Three files, +166; stacked directly on #1773. 40 related tests and real migration/backfill, refused rollback, empty rollback/reapply checks passed; full precommit (4,714 tests +6 doctests) and CI passed. Configured ceilings cannot be silently dropped. Storage only: leave overrides empty until admission consumes current ceilings; no endpoint or runtime control is enabled.

Focused replacement: #1783: owner-scoped narrowing of existing saved allowances. Four files, +253/-9; stacked on #1781. Concurrent writes revalidate the latest locked policy; foreign/missing records, widening and corrupt policy are refused. No-op requests preserve the row; audit events record only changed control names after the transaction. 117 related tests (including two scoped-writer PostgreSQL lock interleavings), full precommit (4,778 tests +6 doctests) and CI passed. Initial creation, current-ceiling admission, HTTP enrollment and runtime enforcement remain separate.

Focused replacement: #1782: refuse narrowing corrupt saved allowances instead of replacing JSON null with an unrestricted map. Two files, +51/-1; stacked directly on #1775. One parent regression; 32 allowance/policy tests (including the PostgreSQL stale-writer race), full precommit (4,722 tests +6 doctests) and CI passed. Invalid rows remain unchanged; valid empty policies still narrow. Tenant-scoped saving and current-ceiling integration remain separate.

Focused replacement: #1781: refuse unsupported saved policy before boot recovery starts a conversation server. Two files, +127/-3; stacked on #1780. Five parent regressions, 168 related tests, full precommit (4,770 tests +6 doctests) and CI passed. Repeated refusal leaves rows unchanged; mixed tenants, absent/empty allowances, existing servers and cancellation retain their behavior. Preflight only: no new recovery activation, policy writer, runtime control or stop of existing provider work.

Focused replacement: #1780: refuse saved-policy channel resume before caller-tool changes; preserve lookup-only access and explicit fresh rotation. Six files, +279/-1 (25 added production lines; the rest tests/generated contracts); stacked on #1779. Seven parent regressions, 198 related tests, all four SDK contracts, TypeScript typecheck, full precommit (4,764 tests +6 doctests) and CI passed. Chat HTTP422 contracts and the OpenAI error envelope are consistent. SDK publication remains deferred under sdk-no-release; no writer, ceiling or runtime enforcement is enabled.

Focused replacement: #1779: check saved policy when consuming queued initial prompts, before autonomous handoff. Two files, +93/-8; stacked on #1778. Four parent regressions, 165 related tests, full precommit (4,754 tests +6 doctests) and CI passed. Refusal preserves existing work, connection and usage; cancellation and empty-policy handoff remain available. Transactional turn admission remains the backstop; no new control or policy writer is enabled.

Focused replacement: #1778: refuse unsupported or malformed saved allowances before an existing server supersedes autonomous work. Three files, +123/-9; stacked on #1777. Four parent HTTP regressions, 159 related tests, full precommit (4,748 tests +6 doctests) and CI passed. HTTP422 preserves the current turn, connection and usage; cancellation and normal empty-policy handoff remain available. This is preflight, with the transactional turn recheck retained; no new enforcement or policy writer is enabled.

Focused replacement: #1777: refuse unsupported saved allowances before ordinary wake, retaining cancellation reconnect. 3 files, +206/-4; stacked on #1776. Eight parent regressions, 182 related tests, full precommit (4,742 tests +6 doctests) and CI passed. Prompt-triggered wake now returns 422 before provider/worker/prompt effects; no new controls are enabled. Active-server prompt, channel, boot-recovery and ceiling/save integration remain separate.

Focused replacement: #1776 checks saved allowances inside the shared turn transaction. 2 files, +266/-1; stacked on #1775. Eight parent regressions, 47 related tests, four PostgreSQL lock interleavings, and full precommit (4,730 tests +6 doctests) passed; CI passed. Saved unsupported or malformed allowances now refuse user/autonomous turn creation without a turn or usage event. No policy writer or runtime control is enabled; synchronous prompt, launch/channel, wake and recovery integration remain.

Focused replacement: #1775 stores versioned conversation allowances and rejects stale narrowing writes (3 files, +318; stacked on #1773. 46 affected tests, an observed PostgreSQL lock wait, safe rollback checks, and full precommit (4,719 tests +6 doctests) passed; CI passed.) This is a storage primitive only: no setting, endpoint or admission caller is enabled. Tenant-scoped admission, later-turn/recovery integration and live enforcement remain required.

Focused replacement: #1774 rejects unenforced or malformed execution-limit requests before launch, attachment or channel mutation (4 files, +171/-1; stacked on #1773; full precommit and CI passed). Saved-allowance integration and enforcement remain in this held draft.

Focused replacement: #1773 extracts only the pure policy resolver and its tests against main (2 files, +320; full precommit and CI passed). The remaining API/storage/runtime/client work in this draft is still held. Reuse the extracted module when revising that work; do not merge this 55-file draft as-is.

Execution-limit requests need a typed public contract and durable allowances before Fountain can safely enforce them. This adds host/account ceilings, conversation narrowing, immutable per-turn policy snapshots, and API/SDK/CLI fields. Omitted limits inherit ceilings; wider, malformed, and unsupported requests fail before public admission starts a worker. Limit outcomes retain partial usage and override late completion metadata.

Stacked on #1744. Draft; bounded execution remains disabled. The runtime capability set is empty, so nonempty effective limits are refused. Keep host/account ceilings unset until the transport, independent deadline worker, and remaining lifecycle paths are integrated. This is neither a live timeout proof nor an aggregate spend cap.

Validation: full precommit passes 4,663 tests and 6 doctests, zero failures. SDK suites, all four contract checks, CLI tests/vet, focused HTTP/policy regressions, and 20 separate-connection PostgreSQL races pass. The race proof made no provider calls. Earlier local failures and their corrections are recorded in the evidence artifact.

Remaining acceptance is tracked in #1732; provider session identity awaits superfly/sprites-ex#33 and managoat/review-loop#109. Documentation reports were reviewed: destink is clean; docs-style reports two issues in unchanged files and Vale has advisory findings. Python 0.1.2 is prepared to satisfy its mandatory release gate; publication requires a future merge. Other SDK releases remain deferred under sdk-no-release. The draft must not merge before activation work is complete.

Dependency integration now consumes published ACP 0.4.0, Runtimes 0.4.1, Runner 0.2.2 and Sandbox 0.3.0. Full precommit remains green at 7db76444cb0abb987e4c3ad62d1b8aa18b29d254; exact-head CI passes. Runner compatibility does not add confirmed termination support. Sprites stays on its released pin pending the upstream identity change.

Part of #1732held. Superseded on main by #1773#1793; the residual hunks are being re-cut as focused PRs tracked by #1864, which keeps these frozen until each has a replacement or an explicitly linked deferral. Do not close: they are the reference for that mapping.

@jhgaylor jhgaylor added the release:skip-sdk Explicitly allows an SDK surface change without releasing a new package version. label Sep 7, 2026
@jhgaylor
jhgaylor force-pushed the feat/durable-turn-deadlines branch from f740439 to 6c885c7 Compare September 11, 2026 03:50

@BinaryBourbon BinaryBourbon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: this now collides head-on with what main already shipped

Requesting changes, and the reason is mostly not in the diff.

This branch is forked from 59c5ebc8 and predates the admission campaign

origin/feat/typed-execution-limits does not contain #1744's head commit — it carries its own copy of "journal bounded turn deadlines" (f7404395 vs 6c885c7b). The content of that copy is a strict subset plus execution_limits, so nothing is silently reverted, but the whole stack from here up is ~60 commits behind main.

In that window main landed the execution-limit admission campaign (#1787#1793, merged 2026-09-10 and deployed), which ships the same idea this PR is proposing:

On main today In this PR
apps/fountain/lib/fountain/conversations/execution_limits.ex the same module, re-added (plus host_ceiling/0, enforced_controls/1)
20260910020000_add_account_execution_limits.exs — adds users.execution_limits 20260907225000_add_execution_limit_allowances.exsadds users.execution_limits again
execution_allowances table (conversation_id PK, limits, revision) a conversations.execution_limits column
execution_allowance.ex, accounts/execution_limits_test.exs, execution_limit_admission_test.exs

Two concrete consequences:

  1. The migration cannot run. This PR's timestamp (20260907225000) sorts before main's (20260910020000), so on a fresh database this one runs first and main's then fails; on the deployed database, which has already applied main's, this one fails. Either way ALTER TABLE users ADD COLUMN execution_limits runs twice.
  2. The per-conversation allowance is a different design from the one that shipped. main stores it in execution_allowances with a revision, deliberately, so that a resume narrows a saved allowance rather than re-resolving it (#1789, #1790). This PR stores it as a column on conversations. Those need reconciling, not merging.

Since main's version is the one running in production, I think the right move is to drop this PR's copy of ExecutionLimits and its migration entirely, rebase onto main, and re-cut whatever is genuinely new here on top of what shipped — which looks like: host_ceiling/0, enforced_controls/1, turn_executions.execution_limits, enforce_deadline_ceiling!/3, and the SDK/OpenAPI/CLI surface. That is a much smaller PR than 3,066 lines across 60 files, and it is the part #1744's journal actually needs.

On the diff itself

The typed-policy shape is right and I have no objection to it — it is essentially what got merged, which is a good sign. Two things I would keep an eye on when re-cutting:

  • enforced_controls(_runtime), do: [] plus require_controls/2 is the "ships inert" property, and the docstring says so clearly. Please keep that caveat visible: ADR 0046's status block and decisions/evidence/typed-execution-limits.json both say fountain_enforcement_enabled: false, and the PR that flips it should be the one that deletes those lines.
  • enforce_deadline_ceiling!/3 rolls back :turn_not_started when turn.started_at is nil. Worth confirming that a turn is always started before registration on every path that reaches it after #1749's integration, because the failure mode is a refused launch rather than an unbounded one.

I have left the substantive review of the journal semantics on #1744 — the awaiting_identity / uncertain dead-end there applies to this branch's copy too.

jhgaylor added a commit that referenced this pull request Sep 11, 2026
…al row

Re-cut of #1745 onto what shipped. The admission campaign (#1787-#1793) landed
on main on 2026-09-10 and already carries most of what that PR proposed —
`ExecutionLimits`, `users.execution_limits`, the `execution_allowances` table
and the launch/attach/resume preflights. #1745 predates it and carried its own
copy, including a second migration adding `users.execution_limits` under an
earlier timestamp, which could not run on either a fresh or the deployed
database. All of that is dropped here in favour of what is on main.

What was genuinely missing, and is what remains:

  * `turn_executions.execution_limits` — the allowance a bounded turn was
    admitted under, resolved once under the parent lock at registration and
    immutable after. Recovery reads the frozen copy, so a ceiling lowered
    mid-turn does not retroactively shorten work already admitted and one
    raised mid-turn does not widen it. The saved conversation allowance is read
    from `execution_allowances` (main's design, which carries a revision), not
    from a column on the parent.
  * `enforce_deadline_ceiling!/3` — a journal deadline is absolute, so it is
    checked against the allowance here rather than trusted from whoever
    computed it. A deadline past the allowance is refused, not clamped:
    silently shortening a requested bound is the worse answer.
  * `ExecutionLimits.host_ceiling/0` and `enforced_controls/1` — main inlined
    `Application.get_env(...)` and a bare `[]` at three call sites. Naming them
    means the PR that first enforces a control changes one function instead of
    three, and cannot miss one.
  * `Accounts.update_execution_limits/3` — `User.execution_limits_changeset/2`
    has existed since #1790 with nothing but tests calling it, so the only way
    to give an account a ceiling was to write the column by hand. Audited, and
    behind the admin boundary like the sandbox-cap setter beside it.
  * `turns.limit_reason` on the API, the OpenAPI document and the wire
    contract, and `stop_reason` set to the same value on the stage event. A
    client cannot otherwise tell a bounded failure from a plain one: a runtime
    that answers after its deadline still exits zero, so `exit_code` alone
    reads as success. The transcript apps live outside this repo (ADR 0034), so
    the existing field has to carry it too.

The **request** side of the public surface is deliberately left out, which is a
narrowing of the review ask. `enforced_controls/1` returns `[]`, so every
`execution_limits` a caller sends is refused; declaring the field in the
contract and shipping it in four SDKs and the CLI would publish a control the
server cannot honour, and an SDK version bump publishes on merge. It belongs in
the PR that first enforces a control. ADR 0046 says so, and says which
paragraph that PR deletes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 11, 2026
Review of #1746 found the coordinator manufacturing the state #1744's journal
could not leave, and running everywhere whether or not anyone asked for it.

**It is off unless an operator asked.** The child list defaulted to `true` with
only `config/test.exs` turning it off, so every prod and dev node polled
`turn_executions` twice a second forever, and nothing in `runtime.exs` read the
key — an operator could not stop it without a rebuild. It now defaults to off,
`runtime.exs` derives the default from whether `FOUNTAIN_EXECUTION_LIMITS` sets
a host ceiling, and `FOUNTAIN_EXECUTION_DEADLINE_WORKER` overrides either way.
The tick moved from 1s to 5s and is configurable: a deadline is absolute and
durable, so lateness costs precision, not safety. Both variables have rows in
`docs/configuration.md`, which `config_reference_test` requires.

**A failed termination is written off, not fenced forever.** `call_terminator/2`
rescues every failure to `{:error, :termination_unconfirmed}`, and
`_unsafe_ready_terminations/1` selects only `state == "ready"`, so nothing ever
looked at the row again. One slow `terminate_session` therefore fenced its
conversation and its shared home permanently, and took `reset_sandbox/2` with
it. The recovery tick now also calls `_unsafe_retire_unresolved/2`: past an hour
a row in `awaiting_identity` or `uncertain` retires to `stopped` with its
`last_error` intact.

A retry was the other candidate and is deliberately not what happens. One
persisted attempt authorizes exactly one provider write; re-arming a lost
attempt would replay an operation whose outcome is unknown, or require assuming
`terminate_session/3` is idempotent across a session that may already have been
replaced. Giving up on a cleanup Fountain cannot confirm is the smaller claim.
ADR 0046 records both the sweep and the rejected alternative.

**Smaller.** `@providers` listed four backends while `ExecutionTransport`
refuses everything but Sprites, so three entries were unreachable and the map
implied support that does not exist; it now says Sprites. `Enum.take/2` with a
negative count takes from the *end* of a list rather than returning `[]`, which
would start the wrong jobs — `max(_, 0)` so that `used <= @pool_size` staying
true is not load-bearing.

The dependency bump this needs is restored as its own commit below: the
re-cut of #1745 dropped it along with the rest of that branch, and
`Managoat.Sandbox.terminate_session/3` arrives in managoat_sandbox 0.3.0.
It stays a separate commit because it moves the ACP stack (acp 0.4.0,
runtimes 0.4.1, runner 0.2.2) and that is not the coordinator's change.

Full core suite on the bumped set: 6 doctests, 4,988 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 11, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 11, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
@jhgaylor
jhgaylor marked this pull request as ready for review September 11, 2026 10:23
@jhgaylor
jhgaylor force-pushed the feat/durable-turn-deadlines branch from c021662 to ff28230 Compare September 11, 2026 11:16
jhgaylor added a commit that referenced this pull request Sep 11, 2026
…al row

Re-cut of #1745 onto what shipped. The admission campaign (#1787-#1793) landed
on main on 2026-09-10 and already carries most of what that PR proposed —
`ExecutionLimits`, `users.execution_limits`, the `execution_allowances` table
and the launch/attach/resume preflights. #1745 predates it and carried its own
copy, including a second migration adding `users.execution_limits` under an
earlier timestamp, which could not run on either a fresh or the deployed
database. All of that is dropped here in favour of what is on main.

What was genuinely missing, and is what remains:

  * `turn_executions.execution_limits` — the allowance a bounded turn was
    admitted under, resolved once under the parent lock at registration and
    immutable after. Recovery reads the frozen copy, so a ceiling lowered
    mid-turn does not retroactively shorten work already admitted and one
    raised mid-turn does not widen it. The saved conversation allowance is read
    from `execution_allowances` (main's design, which carries a revision), not
    from a column on the parent.
  * `enforce_deadline_ceiling!/3` — a journal deadline is absolute, so it is
    checked against the allowance here rather than trusted from whoever
    computed it. A deadline past the allowance is refused, not clamped:
    silently shortening a requested bound is the worse answer.
  * `ExecutionLimits.host_ceiling/0` and `enforced_controls/1` — main inlined
    `Application.get_env(...)` and a bare `[]` at three call sites. Naming them
    means the PR that first enforces a control changes one function instead of
    three, and cannot miss one.
  * `Accounts.update_execution_limits/3` — `User.execution_limits_changeset/2`
    has existed since #1790 with nothing but tests calling it, so the only way
    to give an account a ceiling was to write the column by hand. Audited, and
    behind the admin boundary like the sandbox-cap setter beside it.
  * `turns.limit_reason` on the API, the OpenAPI document and the wire
    contract, and `stop_reason` set to the same value on the stage event. A
    client cannot otherwise tell a bounded failure from a plain one: a runtime
    that answers after its deadline still exits zero, so `exit_code` alone
    reads as success. The transcript apps live outside this repo (ADR 0034), so
    the existing field has to carry it too.

The **request** side of the public surface is deliberately left out, which is a
narrowing of the review ask. `enforced_controls/1` returns `[]`, so every
`execution_limits` a caller sends is refused; declaring the field in the
contract and shipping it in four SDKs and the CLI would publish a control the
server cannot honour, and an SDK version bump publishes on merge. It belongs in
the PR that first enforces a control. ADR 0046 says so, and says which
paragraph that PR deletes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
@jhgaylor
jhgaylor force-pushed the feat/typed-execution-limits branch from d87a801 to a9865a1 Compare September 11, 2026 11:16
jhgaylor added a commit that referenced this pull request Sep 11, 2026
Review of #1746 found the coordinator manufacturing the state #1744's journal
could not leave, and running everywhere whether or not anyone asked for it.

**It is off unless an operator asked.** The child list defaulted to `true` with
only `config/test.exs` turning it off, so every prod and dev node polled
`turn_executions` twice a second forever, and nothing in `runtime.exs` read the
key — an operator could not stop it without a rebuild. It now defaults to off,
`runtime.exs` derives the default from whether `FOUNTAIN_EXECUTION_LIMITS` sets
a host ceiling, and `FOUNTAIN_EXECUTION_DEADLINE_WORKER` overrides either way.
The tick moved from 1s to 5s and is configurable: a deadline is absolute and
durable, so lateness costs precision, not safety. Both variables have rows in
`docs/configuration.md`, which `config_reference_test` requires.

**A failed termination is written off, not fenced forever.** `call_terminator/2`
rescues every failure to `{:error, :termination_unconfirmed}`, and
`_unsafe_ready_terminations/1` selects only `state == "ready"`, so nothing ever
looked at the row again. One slow `terminate_session` therefore fenced its
conversation and its shared home permanently, and took `reset_sandbox/2` with
it. The recovery tick now also calls `_unsafe_retire_unresolved/2`: past an hour
a row in `awaiting_identity` or `uncertain` retires to `stopped` with its
`last_error` intact.

A retry was the other candidate and is deliberately not what happens. One
persisted attempt authorizes exactly one provider write; re-arming a lost
attempt would replay an operation whose outcome is unknown, or require assuming
`terminate_session/3` is idempotent across a session that may already have been
replaced. Giving up on a cleanup Fountain cannot confirm is the smaller claim.
ADR 0046 records both the sweep and the rejected alternative.

**Smaller.** `@providers` listed four backends while `ExecutionTransport`
refuses everything but Sprites, so three entries were unreachable and the map
implied support that does not exist; it now says Sprites. `Enum.take/2` with a
negative count takes from the *end* of a list rather than returning `[]`, which
would start the wrong jobs — `max(_, 0)` so that `used <= @pool_size` staying
true is not load-bearing.

The dependency bump this needs is restored as its own commit below: the
re-cut of #1745 dropped it along with the rest of that branch, and
`Managoat.Sandbox.terminate_session/3` arrives in managoat_sandbox 0.3.0.
It stays a separate commit because it moves the ACP stack (acp 0.4.0,
runtimes 0.4.1, runner 0.2.2) and that is not the coordinator's change.

Full core suite on the bumped set: 6 doctests, 4,988 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 11, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor and others added 2 commits September 11, 2026 19:59
…al row

Re-cut of #1745 onto what shipped. The admission campaign (#1787-#1793) landed
on main on 2026-09-10 and already carries most of what that PR proposed —
`ExecutionLimits`, `users.execution_limits`, the `execution_allowances` table
and the launch/attach/resume preflights. #1745 predates it and carried its own
copy, including a second migration adding `users.execution_limits` under an
earlier timestamp, which could not run on either a fresh or the deployed
database. All of that is dropped here in favour of what is on main.

What was genuinely missing, and is what remains:

  * `turn_executions.execution_limits` — the allowance a bounded turn was
    admitted under, resolved once under the parent lock at registration and
    immutable after. Recovery reads the frozen copy, so a ceiling lowered
    mid-turn does not retroactively shorten work already admitted and one
    raised mid-turn does not widen it. The saved conversation allowance is read
    from `execution_allowances` (main's design, which carries a revision), not
    from a column on the parent.
  * `enforce_deadline_ceiling!/3` — a journal deadline is absolute, so it is
    checked against the allowance here rather than trusted from whoever
    computed it. A deadline past the allowance is refused, not clamped:
    silently shortening a requested bound is the worse answer.
  * `ExecutionLimits.host_ceiling/0` and `enforced_controls/1` — main inlined
    `Application.get_env(...)` and a bare `[]` at three call sites. Naming them
    means the PR that first enforces a control changes one function instead of
    three, and cannot miss one.
  * `Accounts.update_execution_limits/3` — `User.execution_limits_changeset/2`
    has existed since #1790 with nothing but tests calling it, so the only way
    to give an account a ceiling was to write the column by hand. Audited, and
    behind the admin boundary like the sandbox-cap setter beside it.
  * `turns.limit_reason` on the API, the OpenAPI document and the wire
    contract, and `stop_reason` set to the same value on the stage event. A
    client cannot otherwise tell a bounded failure from a plain one: a runtime
    that answers after its deadline still exits zero, so `exit_code` alone
    reads as success. The transcript apps live outside this repo (ADR 0034), so
    the existing field has to carry it too.

The **request** side of the public surface is deliberately left out, which is a
narrowing of the review ask. `enforced_controls/1` returns `[]`, so every
`execution_limits` a caller sends is refused; declaring the field in the
contract and shipping it in four SDKs and the CLI would publish a control the
server cannot honour, and an SDK version bump publishes on merge. It belongs in
the PR that first enforces a control. ADR 0046 says so, and says which
paragraph that PR deletes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Three accuracy defects from the review of 84cb45f. No behavior change: the
column, the guard and the setter are untouched.

  * decisions/0046-durable-turn-deadlines.md:118 said "Recovery reads the
    frozen copy, so a ceiling changed mid-turn neither narrows nor widens work
    already admitted". There is no such reader. `turn_executions.execution_limits`
    is written at `execution_guard.ex:76` and read nowhere — not by recovery,
    not by `_unsafe_claim_spawn/2`, not by the rehydrator. Whoever writes the
    caller in #1749 would have read that, concluded the frozen allowance was
    already honoured, and left the column written for the life of the stack and
    never read. Now says the column is written today and the reader arrives with
    the command transport, the same way the request-surface paragraph below it
    already handles a promise this PR does not keep.

  * apps/fountain/lib/fountain/accounts.ex:1057 said "Like the sandbox-cap
    setter above this belongs behind the admin boundary". `update_sandbox_limit/3`
    is reached from `admin_live/users.ex:84` and `admin_controller.ex:183`;
    `update_execution_limits/3` has no caller outside its own test, so an
    operator following that sentence to /admin/users finds nothing and is back
    to writing the column by hand. Now says the ceilings are operator-owned but
    the admin control arrives with the enforcing PR. Wiring one is feature work
    this PR is not scoped for.

  * decisions/0046-durable-turn-deadlines.md:26 — the status block still listed
    public limits and host/account policy as unbuilt while the bullet at line
    110, edited in the same commit, strikes them through as shipped on main by
    the admission campaign (#1787-#1793). One file, two answers, and the status
    block is what a reader hits first. They agree now.

Also, non-blocking and from the same review: the docstring at accounts.ex:1066
called the audit metadata "names the fields" when it records the full from/to
policy maps. The recording is correct and matches the `update_sandbox_limit/3`
precedent — operator-set values, not tenant data — so only the sentence changed.

Verified: `mix test test/fountain/accounts_test.exs
test/fountain/conversations/turn_execution_limits_test.exs` from apps/fountain
is 30 tests, 0 failures; `mix format --check-formatted` clean;
`okf validate decisions` valid with its four pre-existing actor-convention
warnings; `scripts/decisions-index.sh` leaves index.md unchanged (the
frontmatter description did not move). The three prose gates walk docs/ and
apps/*/docs only, so decisions/ is out of their scope and no docs/ page changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UpTFaSbu4oL7XwxByqx8Mk
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
@jhgaylor
jhgaylor changed the base branch from feat/durable-turn-deadlines to main September 11, 2026 23:59
@jhgaylor
jhgaylor force-pushed the feat/typed-execution-limits branch from a9865a1 to 70cb087 Compare September 11, 2026 23:59
jhgaylor added a commit that referenced this pull request Sep 12, 2026
Review of #1746 found the coordinator manufacturing the state #1744's journal
could not leave, and running everywhere whether or not anyone asked for it.

**It is off unless an operator asked.** The child list defaulted to `true` with
only `config/test.exs` turning it off, so every prod and dev node polled
`turn_executions` twice a second forever, and nothing in `runtime.exs` read the
key — an operator could not stop it without a rebuild. It now defaults to off,
`runtime.exs` derives the default from whether `FOUNTAIN_EXECUTION_LIMITS` sets
a host ceiling, and `FOUNTAIN_EXECUTION_DEADLINE_WORKER` overrides either way.
The tick moved from 1s to 5s and is configurable: a deadline is absolute and
durable, so lateness costs precision, not safety. Both variables have rows in
`docs/configuration.md`, which `config_reference_test` requires.

**A failed termination is written off, not fenced forever.** `call_terminator/2`
rescues every failure to `{:error, :termination_unconfirmed}`, and
`_unsafe_ready_terminations/1` selects only `state == "ready"`, so nothing ever
looked at the row again. One slow `terminate_session` therefore fenced its
conversation and its shared home permanently, and took `reset_sandbox/2` with
it. The recovery tick now also calls `_unsafe_retire_unresolved/2`: past an hour
a row in `awaiting_identity` or `uncertain` retires to `stopped` with its
`last_error` intact.

A retry was the other candidate and is deliberately not what happens. One
persisted attempt authorizes exactly one provider write; re-arming a lost
attempt would replay an operation whose outcome is unknown, or require assuming
`terminate_session/3` is idempotent across a session that may already have been
replaced. Giving up on a cleanup Fountain cannot confirm is the smaller claim.
ADR 0046 records both the sweep and the rejected alternative.

**Smaller.** `@providers` listed four backends while `ExecutionTransport`
refuses everything but Sprites, so three entries were unreachable and the map
implied support that does not exist; it now says Sprites. `Enum.take/2` with a
negative count takes from the *end* of a list rather than returning `[]`, which
would start the wrong jobs — `max(_, 0)` so that `used <= @pool_size` staying
true is not load-bearing.

The dependency bump this needs is restored as its own commit below: the
re-cut of #1745 dropped it along with the rest of that branch, and
`Managoat.Sandbox.terminate_session/3` arrives in managoat_sandbox 0.3.0.
It stays a separate commit because it moves the ACP stack (acp 0.4.0,
runtimes 0.4.1, runner 0.2.2) and that is not the coordinator's change.

Full core suite on the bumped set: 6 doctests, 4,988 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 12, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
@jhgaylor
jhgaylor added this pull request to the merge queue Sep 12, 2026
@jhgaylor
jhgaylor removed this pull request from the merge queue due to a manual request Sep 12, 2026
@jhgaylor
jhgaylor added this pull request to the merge queue Sep 12, 2026
@jhgaylor
jhgaylor removed this pull request from the merge queue due to a manual request Sep 12, 2026
@jhgaylor
jhgaylor added this pull request to the merge queue Sep 12, 2026
Merged via the queue into main with commit c5b0e86 Sep 12, 2026
25 checks passed
jhgaylor added a commit that referenced this pull request Sep 12, 2026
Review of #1746 found the coordinator manufacturing the state #1744's journal
could not leave, and running everywhere whether or not anyone asked for it.

**It is off unless an operator asked.** The child list defaulted to `true` with
only `config/test.exs` turning it off, so every prod and dev node polled
`turn_executions` twice a second forever, and nothing in `runtime.exs` read the
key — an operator could not stop it without a rebuild. It now defaults to off,
`runtime.exs` derives the default from whether `FOUNTAIN_EXECUTION_LIMITS` sets
a host ceiling, and `FOUNTAIN_EXECUTION_DEADLINE_WORKER` overrides either way.
The tick moved from 1s to 5s and is configurable: a deadline is absolute and
durable, so lateness costs precision, not safety. Both variables have rows in
`docs/configuration.md`, which `config_reference_test` requires.

**A failed termination is written off, not fenced forever.** `call_terminator/2`
rescues every failure to `{:error, :termination_unconfirmed}`, and
`_unsafe_ready_terminations/1` selects only `state == "ready"`, so nothing ever
looked at the row again. One slow `terminate_session` therefore fenced its
conversation and its shared home permanently, and took `reset_sandbox/2` with
it. The recovery tick now also calls `_unsafe_retire_unresolved/2`: past an hour
a row in `awaiting_identity` or `uncertain` retires to `stopped` with its
`last_error` intact.

A retry was the other candidate and is deliberately not what happens. One
persisted attempt authorizes exactly one provider write; re-arming a lost
attempt would replay an operation whose outcome is unknown, or require assuming
`terminate_session/3` is idempotent across a session that may already have been
replaced. Giving up on a cleanup Fountain cannot confirm is the smaller claim.
ADR 0046 records both the sweep and the rejected alternative.

**Smaller.** `@providers` listed four backends while `ExecutionTransport`
refuses everything but Sprites, so three entries were unreachable and the map
implied support that does not exist; it now says Sprites. `Enum.take/2` with a
negative count takes from the *end* of a list rather than returning `[]`, which
would start the wrong jobs — `max(_, 0)` so that `used <= @pool_size` staying
true is not load-bearing.

The dependency bump this needs is restored as its own commit below: the
re-cut of #1745 dropped it along with the rest of that branch, and
`Managoat.Sandbox.terminate_session/3` arrives in managoat_sandbox 0.3.0.
It stays a separate commit because it moves the ACP stack (acp 0.4.0,
runtimes 0.4.1, runner 0.2.2) and that is not the coordinator's change.

Full core suite on the bumped set: 6 doctests, 4,988 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 12, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 12, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 12, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 12, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
jhgaylor added a commit that referenced this pull request Sep 12, 2026
… around it

Review of #1749 asked for the rebase to be done before the PR was reviewable
on its merits. Doing it turned up three protections this branch predated and
would have reverted, so this is a re-cut: main's code is the base and the
journal integration is layered on it.

**Admission stays where it already was.** `_unsafe_admit_turn/4`
re-implemented `Conversations._unsafe_create_turn_on_sandbox/3` and, doing so,
dropped the `attached?` check — the one that proves a conversation is still on
a **non-terminal** sandbox owned by the same tenant (#1761, #1764) — and the
`_unsafe_check_saved_execution_allowance/1` recheck under the row locks. It
also predated the `FOR UPDATE`/foreign-key ordering from #1790 and #1793.
Replaced with `ExecutionGuard._unsafe_register_bounded/3`, a step *inside* that
transaction. A turn refused for an unavailable sandbox therefore still answers
`:sandbox_unavailable`, before the journal is consulted.

**`TurnLaunch` is rebuilt from main's `run_fresh_turn/7`.** The extraction is
the right idea and the size pin leaves no alternative, but this branch's copy
of it had **zero** references to `CodexChatGPT.peer_auth/2`, which main's
launch calls — taking it would have silently reverted ADR 0047's codex-on-
ChatGPT authentication, which is live. The module is now main's body verbatim
plus the bounded spawn branch and `bounded_sdk_limits/2`, with
`fail_turn_before_start/6` passed in because it writes through the actor's
logger and owns `current_turn`.

**Autonomous turns are bounded, not refused.** `_unsafe_autonomous_turn/2`
refused background work whenever any allowance resolved, and called
`_unsafe_create_turn/1` rather than `_unsafe_create_turn_on_sandbox/3` —
dropping #1764's sandbox binding. Both are gone: autonomous turns go through
the same admission, so they get a journal and a deadline the coordinator can
expire. Refusing them would have turned one account ceiling into "no schedules
and no background follow-ups for this account", which is a product decision
affecting a shipped feature that nothing had written down.

Kept from this branch, because they are the point of it: the per-message
`_unsafe_authorize_write/3` gate in `handle_info`, the bounded transport spawn,
retirement, and the error propagation out of `kick_turn/4` — that last one
merged with main's `drop_connection/2` so the refusal reaches the caller *and*
the connection still goes. Two of main's own tests asserted `send_prompt`
replying `:ok` to a stale actor whose sandbox had been retired; they now assert
the refusal. Their real subject — no turn, no peer, no timer — is unchanged.

Also ported `bounded_lifecycle_test.exs` off `conversations.execution_limits`
onto `execution_allowances`, matching the re-cut of #1745.

Size pin 2774 -> 2739. Full core suite: 6 doctests, 5,039 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9reKpmXUUJf4eUMULogie
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:skip-sdk Explicitly allows an SDK surface change without releasing a new package version.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants