Skip to content

refactor(conversations): Launch, part c: attach_conversation and its checks - #2245

Merged
jhgaylor merged 1 commit into
mainfrom
stack/2175-7c-launch-attach
Sep 15, 2026
Merged

jhgaylor merged 1 commit into
mainfrom
stack/2175-7c-launch-attach

Conversation

@jhgaylor

Copy link
Copy Markdown
Collaborator

Stage 7c of #2175 (one owner per conversation lifecycle verb), stacked on stage 7b (#2243, since merged to main).

What moved

Into Fountain.Conversations.Launch:

  • attach_conversation/3 — the @doc false door stage 7b opened in Conversations. It had no caller outside Launch (the three call sites are start_conversation/2's sandbox_id clause and both {:home, _} branches), so it moves in as a private defp with no delegate left behind.
  • check_attachable/4 (all three clauses) and check_attach_capacity/3 (both clauses) — already defp in Conversations, called by nothing outside it.
  • Private helpers used only by those three: create_attached_conversation/3, deliver_attach_prompt/3, check_sandbox_api_attach/2.
  • _unsafe_sandbox_runtime/1 — its only caller was check_attachable/4. Moving it with its caller keeps that _unsafe_* call local instead of opening a cross-module call credo's unsafe_call_ownership check would have required a comment for.

Attach still inserts the conversation idle, reads the sandbox through the tenant-scoped Conversations.get_sandbox/2 (kept as the first fetch), reserves the source inside the insert transaction, and gates spend with Fountain.Billing.check_spend/1 — moved unchanged, none added (ADR 0030 decision 6).

What stayed, and why

  • claim_sandbox, sandbox_retired? and prevent_sandbox_revival stay in Conversations' update_sandbox; attach keeps calling Conversations.update_sandbox (retirement rejection at admission, Make sandbox reset and retirement atomic with turn admission #1767).
  • check_attach_capacity/3's remaining cross-module _unsafe_sandbox_at_capacity?/3 call stays in Conversations (shared with turn_machine.ex), so it gets an # ownership: ... comment naming the scoped sandbox read instead of moving.
  • resolve_vault_id, resolve_environment_id, resolve_inference_credential_id, resolve_permission_policy, resolve_parent_id, check_execution_limits, insert_conversation_row, reserve_inference, after_conversation_created, record_execution_allowance_created, broadcast_sidebar_update and require_provider_commit_boundary all stay @doc false doors in Conversations: each still has at least one caller inside Conversations or another module besides Launch (checked by grep), so none is Launch-only yet.
  • unbind_rotated_channel/2 and restore_rotated_channel/2 (opened public in Launch in stage 7b, for Conversations' admission and fail_initial_start) now have every caller inside Launch, since create_attached_conversation/3 and deliver_attach_prompt/3 moved in. Their doc comments are updated to say so, but they stay def — tightening their visibility is outside this stage's scope.

Left for stage 8 or a follow-up

Nothing else stage 7b opened in Conversations became Launch-only as a result of this move (checked each caller above); no candidates to flag.

Delegates

None added: attach_conversation/3, check_attachable/4 and check_attach_capacity/3 were never called from outside Launch to begin with.

Doc accuracy

Updated stale cross-module references to the moved functions' old location: conversations.ex's comment above check_sandbox_api_name/2, reapply.ex's three prose references to check_attachable/4, and Launch.resolve_admission_inference/5's doc (it now serves two callers inside the same module instead of one in Conversations).

Proves unchanged

Run individually from apps/fountain against fountain_test_2175_7c:

File Tests
test/fountain/conversations/attach_test.exs included in the 354 below
test/fountain/conversations/sandbox_mode_test.exs included
test/fountain/conversations/orphaned_home_test.exs included
test/fountain/conversations/termination_attach_order_test.exs included
test/fountain/conversations/retirement_admission_order_test.exs included
test/fountain/conversations/reset_admission_order_test.exs included
test/fountain/conversations_start_test.exs included
test/fountain/team_test.exs included
test/fountain_web/controllers/conversation_controller_test.exs included
test/fountain/audit_guardrail_test.exs included

Run together: 354 tests, 0 failures. ../../ee/test/fountain/credits_enforcement_test.exs: 11 tests, 0 failures.

Gates

  • Full mix precommit --full (10 stages, 417s): PASSED, run before rebasing onto the merged 7b.
  • After rebasing onto origin/main (72e753d, 7b's squash-merge): mix precommit compile format credo (3 stages, 8s): PASSED. The rebase replayed only this stage's own commit (diffstat unchanged), so the full suite from before the rebase still applies.

Lengths

conversations.ex: 4972 → 4397 (against the pre-stage base; the file also picked up unrelated shrinkage from Wake stages 3/3b merging underneath this branch). launch.ex: 783 → 1051.

No server change, no behaviour change, no changelog fragment.

Closes #2218

🤖 Generated with Claude Code

…checks

Moves the attach arm of the launch into `Fountain.Conversations.Launch`:
`attach_conversation/3` (the `@doc false` door stage 7b opened), the two
checks `check_attachable/4` and `check_attach_capacity/3`, and the private
helpers used only by them: `create_attached_conversation/3`,
`deliver_attach_prompt/3`, `check_sandbox_api_attach/2` and
`_unsafe_sandbox_runtime/1`.

`attach_conversation/3` had no caller outside `Launch` (it was already
`@doc false`, not part of `Conversations`' public surface), so it moves
in as a private `defp` with no delegate left behind. The three call sites
in `Launch.start_conversation/2` (the `sandbox_id` clause and both
`{:home, _}` branches) become local calls.

`check_attachable/4` and `check_attach_capacity/3` were already `defp` in
`Conversations`, called by nothing outside it; they move the same way.
`_unsafe_sandbox_runtime/1` had exactly one caller, `check_attachable/4`,
so it moves too, closing the cross-module `_unsafe_` call the move would
otherwise have opened (credo's unsafe_call_ownership check would have
required an ownership comment there; moving the caller and callee
together keeps the call local instead). `check_attach_capacity/3`'s
remaining cross-module `_unsafe_sandbox_at_capacity?/3` call (that
function stays in `Conversations`, shared with `turn_machine.ex`) gets
an ownership comment naming the scoped sandbox read.

`unbind_rotated_channel/2` and `restore_rotated_channel/2` (opened public
in `Launch` in stage 7b for `Conversations`' admission and
`fail_initial_start`) now have every caller inside `Launch`, since
`create_attached_conversation/3` and `deliver_attach_prompt/3` moved in;
their doc comments are updated to say so, but they stay `def` since
tightening their visibility is outside this stage's scope.

Doc-comment references to the moved functions' old location are updated
in `conversations.ex` and `reapply.ex`; no code path outside the moved
functions changes.

`start_conversation/2` (both the fresh path from stage 7b and the attach
path here) is now fully owned by `Launch`; `Conversations.start_conversation/2`
is a one-line delegate. Wake, interrupt, terminate and release stay in
`Conversations`/`ConversationServer` for their own stages.

No server change, no behaviour change, no changelog fragment.

Closes #2218

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jake Gaylor <jhgaylor@gmail.com>
@jhgaylor jhgaylor added stack:root The base PR of a stack; merge before its descendants. stack:2175 labels Sep 15, 2026
@jhgaylor
jhgaylor enabled auto-merge September 15, 2026 07:19

@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.

APPROVED — independent adversarial review

Reviewed head 144b3d6019daf8e9ba0f6295be9cd0de4f1873fe against base 72e753d292ceec9b6539da9710966c081bb6ea51. No actionable regressions found.

Covered all changed paths:

  • apps/fountain/lib/fountain/conversations.ex
  • apps/fountain/lib/fountain/conversations/launch.ex
  • apps/fountain/lib/fountain/conversations/reapply.ex

I traced explicit sandbox attachment, persistent-home reuse and its creation-race fallback through the context delegates and API caller. Checked tenant-scoped agent/sandbox lookup; environment, vault and inference allowlists; sandbox identity/runtime/reset/capacity checks; callback-credential isolation; suspension/billing/platform-inference gates; source and machine lock order; transactional conversation/allowance insertion; channel restoration and row removal on prompt refusal; and audit attribution after commit. The existing attach, controller, sandbox API access and execution-limit admission tests cover the relevant denial and rollback paths.

Local validation: an exact-revision Elixir AST comparison passed for all seven moved functions (10 clauses), normalizing metadata, def/defp, and the intentional Conversations/Launch call qualification changes. Existing Launch and remaining Conversations definitions were equivalent, and Reapply definitions were unchanged. I separately checked alias targets, the shared advisory-lock namespace, public helper exports, and repository-wide callers of both removed context names; no callers were left behind. git diff --check passed.

Verified current-head GitHub evidence: CI required and Detect secrets passed. Application tests and the full verification recipe were not rerun locally. This is an independent source review, not a Review Loop service verification attestation.

@jhgaylor
jhgaylor added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit 21d2cc1 Sep 15, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stack:root The base PR of a stack; merge before its descendants.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(conversations): Launch, part c: attach_conversation and its checks

2 participants