fix(subscriptions): registering a subscription or deleting the instance key adopts the credential-less fleet (#2572) - #2741
Merged
Merged
Conversation
…bscription SUB-003 only ever moves an agent that ALREADY has a subscription (its precondition 2), and registering a subscription assigned nobody. So on an instance with no ANTHROPIC_API_KEY every pre-existing agent sat in api_key mode with nothing behind it. Neither feature is wrong; nothing covered the gap. `subscription_service.adopt_for_credentialless_agents` is that cover: one predicate, one loop, two exclusions. Phase A decides and persists inside the request (every blocking call in asyncio.to_thread), so the panel's immediate `GET /api/subscriptions` refetch is already correct; Phase B applies to containers in the background under the #799 per-agent lock, re-verifying the assignment before each restart because Phase A itself creates SUB-003 eligibility. Three things are load-bearing and easy to undo by accident: - Condition 1 resolves through get_anthropic_api_key() — encrypted row, legacy row, then os.getenv. Never has_secret_setting(), which is DB-only and presence-only and would adopt a whole fleet off a working env key. - The runtime gate is LABEL-STRICT. `agent_container_runtimes` defaults a missing label to "claude-code" and `is_claude_runtime(None)` is True, so the new `agent_container_runtime_labels` sibling preserves absence: a container with no runtime label (trinity-system has none) is not evidence of a Claude runtime. An unreadable Docker adopts nobody — the opposite resolution to ent#403's documented fail-open, because that call site decides a UI affordance and this one writes a credential assignment. - Phase B skips the system agent (_restart_agent stops first, so #1816's "never recreate a running trinity-system" guard is bypassed by construction), and ephemeral ghosts are skipped by the sweep entirely — the AUTH recreate predicate carries no ghost exemption, so restarting an adopted ghost destroys its workspace mid-budget. Auth-MODE change, so the apply is _restart_agent; _hot_reload_subscription_token keeps its producer invariant. SUB-003's preconditions are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…less fleet Trigger A1. The hook sits beside the #1089 key-rollover fan-out and copies its swallow-everything contract: the sweep must never fail a registration whose credential is already stored. Only Phase A is awaited, so the Settings panel's refetch on the next line already lists the adopted agents. The body parameter is renamed `request` -> `payload` because the handler now also takes the injected Request. Two things called `request` in one handler is how a log line inside an `except` raises AttributeError, which would escape to the outer handler and return 500 with the credential stored. All six body-param sites move, including the two inside logger calls, and the three positional calls in the existing hot-reload suite are updated. Also adds the two manual-assignment audit rows. AC5 asks for adoption to be recorded "alongside manual assignment", which is only literally true if the manual move is recorded too — #2421 found this router carried no platform_audit_service call at all, so "how did agent X get onto this subscription?" answered with a false negative for every manual move. This takes `subscription_assign` / `subscription_clear` only; #2421's register / delete / settings actions stay unclaimed. Subscription id and name in details, never a token (Invariant #12). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…ss fleet
Trigger A2. The canonical migration off a metered key is *register the
subscription, then delete the key*. In that order Trigger A1 does nothing —
the key still resolves, so the sweep short-circuits — and the deletion then
strands the whole fleet in exactly the reported state with no trigger left.
Both reachable clear paths are hooked. The dedicated
DELETE /api/settings/api-keys/anthropic is the one the UI uses; the generic
DELETE /api/settings/{key} reaches the same rows through db.delete_setting,
which — unlike db.set_setting — carries no ent#435 sink guard, so it is a real
second door rather than a theoretical one. Both are gated on the route's
existing `deleted` truthiness, so a no-op DELETE stays inert, and both are
already assert_admin-gated, so the hook inherits an identical human-only
boundary and adds no reachable principal.
The hook deliberately sits on the routes, not on clear_secret_setting: that
leaf also serves github_pat and the Slack keys. A third clear path exists at
set_secret_setting's blank-write branch and is unreachable for this key —
update_anthropic_key 400s anything not starting `sk-ant-`, and the generic PUT
is refused by ent#435's sink guard.
Noted, not fixed here: db.delete_setting has no delete-side twin of that sink
guard. Closing it would change DELETE of any credential-shaped key from 200 to
422 — ent#435's scope, not this bug's.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…router boundary Every test executes the path against a real schema (db_harness) rather than asserting on source text (#2659). The only stubs are the two edges a unit test cannot have: the batch Docker runtime read and the container restart. The ones that earn their keep are the negatives. A working instance key adopts nobody and never even asks Docker; an agent already on a subscription and one opted out of the platform key are untouched; a codex agent, a container-less agent and a container with no runtime label are each skipped for a different reason, and an unreadable Docker adopts nobody at all. T16 drives register-then-delete end to end at both routers — the ordering the key-deletion trigger exists for — and T17 deletes the settings row while ANTHROPIC_API_KEY is still in the environment and asserts zero adoptions, which is the test that fails if condition 1 is ever "simplified" to has_secret_setting(). T11b/T11c pin the two destructive exclusions, T11d pins the under-lock re-verify, T12 drives the REAL headroom ranker rather than asserting a mock's return value, and T13 pins the already-shipped #74 create path so that half of the decision survives by behaviour rather than by luck. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
… their guards Requirements (security.md): 20.3's workflow gains the automatic step; 20.3a is retitled to cover all three triggers and now carries the five-condition predicate, the two never-move guards, the phase split, the two Phase-B exclusions, the container-less known bound, the fail-closed Docker rule and the owner-blind decision; 20.5a's "Cut: Tier 4 bulk auto-assign" is amended rather than silently contradicted — the credential-less subset is delivered, bulk migration of agents with a working credential stays cut; SEC-001's CREDENTIALS category lists the four new event actions. Feature flows: subscription-management.md owns the delta (the #74 section becomes the three-trigger section, Flow 1's overview / sequence diagram / endpoint block show the awaited decide phase and the backgrounded apply, both POST entry-point rows note the sweep, revision history row added). platform-settings.md gains the two Anthropic-key DELETE rows and a downstream pointer, because that flow owns Settings -> API Keys. subscription-auto-switch.md gets one cross-reference under "Not covered" so nothing there contradicts it — SUB-003 stays failure-driven and its precondition 2 is untouched. Architecture: backend.md's subscriptions / settings / subscription_service / subscription_auto_switch bullets; agent-lifecycle.md's #1089 section gains the fourth producer, the one that deliberately takes the recreate branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…ither A2 route The sweep GRANTS a credential, so it stays human-admin-only by inheriting each delete route's existing assert_admin rather than adding a gate of its own. Asserted on BOTH routes, because "a guard applied to one of two sibling codepaths" is the repo's most recurrent escape class and this change has exactly that shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…read The sparse-`attrs["Labels"]` trap this flow documents now has two call sites with opposite correct answers: the model control fails OPEN to "claude-code", and #2572's credential-less subscription sweep must fail CLOSED because a missing label is not evidence of a Claude runtime (trinity-system carries none). Recorded here so a future change to the sparse shape moves both. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
The sweep-summary row exists so that "adopted 0 of 40 because Docker was unreadable" is distinguishable in `audit_log` from "nothing to do" — that is its stated rationale in the plan and in the shipped test's own docstring. But the unreadable-Docker arm returned before writing one, so the single case the row was designed for left the trail saying nothing happened, with an ERROR log as the operator's only signal and `agent_count` staying 0 either way. Write the summary row on that abort, carrying a `skipped.docker_unreadable` count so the reason is in the record rather than only in the log. The other early returns stay silent on purpose: "no credential-less agents" and "no assignable subscription" are genuinely nothing-to-do, not a failed decision. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
vybe
marked this pull request as ready for review
September 12, 2026 20:00
# Conflicts: # docs/memory/feature-flows.md
Contributor
|
merge-train: pushed |
dolho
added a commit
that referenced
this pull request
Sep 15, 2026
… 903 dev lines into the split packages The modify/delete conflicts on `routers/settings.py` and `services/git_service.py` are resolved by DELETING dev's monolith copies and re-porting every hunk dev added to them since the fork into the file that now owns it, symbol by symbol, with each function's body checked equal to dev's modulo package qualification: git_service (one dev commit, ent#615 / #2757 — the fleet-PAT fix): - `_AUTH_PATTERNS` marker -> conflicts.py - `_git_remote_url` removed, `_remote_seturl_subcommand` docstring, `_credentialless_remote_url`, `rebind_origin_and_push` (root push + credential in the exec env), `update_remote_pat` (env write, not URL) -> remotes.py - the credential-helper install + embedded-token sweep block (`write_container_github_pat`, both alarms, `scrub_git_remote_tokens`, the fleet sweep, `spawn_git_remote_token_scrub`, all `_SCRUB_*`) -> NEW token_scrub.py (remotes.py would otherwise sit at 821 lines, over the threshold the split exists for) - `_agent_can_push`, `_agent_has_write_credentials` docstring, `sync_to_github`, `reset_to_main_preserve_state` -> sync.py - `initialize_git_in_container` (seeds before writing a remote) -> provisioning.py Package `__init__` re-exports every new name; the duplicate `REBIND_PUSH_TIMEOUT_S` the hunk would have introduced is dropped. settings (five dev commits — #2715, #2619, #2707, #2741, #2739): - 11 changed routes replaced in place across flags/credentials/ integrations/generic - 11 new symbols placed beside their dev-order predecessors; the #2715 Resend/Gemini routes + their two helpers go to NEW provider_keys.py (credentials.py would otherwise reach 1,045 lines), included on the package router right after `credentials` and before `generic` - `_ANTHROPIC_KEY_ALIASES` / `_adopt_after_instance_key_removed` reached from generic.py through the sibling module object, per the package rule ops: `_format_model_name`'s #2739 `claude-fable-5-1` entry lands in `ops_costs_service.py`, where the split moved the function; the #2726 test imports from there. Dev's tests that patch monolith attributes are re-pointed the way the split re-pointed every earlier one: the ent#615 exec recorder is installed on each execing sibling and `_detect_git_dir` on `gitignore`; #2572's `db` fake on `credentials` and `generic`; ent#553's source read on `flags`; #1677's emitter allowlist and the ent#615 source reads on `token_scrub`. `_PRE_SPLIT_ROUTES`' post-split allowlist records the six #2715 routes; the git_service import-surface pin drops `_git_remote_url` (gone by design) for its ent#615 replacements. Content conflicts: `backend.md` (dev's facts under the package names), `test_ent123_tokenless_clone.py` (dev's helper patch, on `gs.sync`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpvcfgWkmQPD7DrDLmATTf
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.
Summary
On an instance with no Anthropic API key, registering a subscription — the one action the product tells the operator to take — assigned nobody. Every pre-existing agent stayed in
api_keyauth mode with nothing behind it, and the fleet kept reading "Not logged in".The hole sits between two things that were each individually correct. #442 made "the platform has no usable credential" a precondition of subscription auth, and SUB-003 only ever moves an agent that already has a subscription (its precondition 2 is literally where the gap was written down).
POST /api/subscriptionsassigned none. So nothing in the system closed the distance between "an operator just supplied a credential" and "the agents that have none can use it".Three triggers now cover it, with no periodic sweep:
POST /api/subscriptions, which the Settings form and the MCPregister_subscriptiontool both reach. A re-register (upsert) re-runs it; the sweep is idempotent.DELETE /api/settings/api-keys/anthropicand the genericDELETE /api/settings/{key}— becausedb.delete_settingcarries no delete-side twin of ent#435's write sink guard, so the second route is reachable rather than theoretical.The predicate is no usable credential, not a preference between two working ones — five conditions, all of which must hold: no key resolvable at instance level via
settings_service.get_anthropic_api_key()(env fallback included, never the DB-onlyhas_secret_setting());subscription_id IS NULL;use_platform_api_keytrue; atrinity.agent-runtimelabel that is present AND Claude; not ephemeral. An unreadable Docker adopts nobody.Phase split. Phase A (decide + persist) is awaited inside the triggering request, so
GET /api/subscriptionsand the auth badge are correct on the panel's immediate refetch. Phase B (container apply) is backgrounded, per-agent, under the #799 switch lock, and re-verifies the assignment before restarting — SUB-003 may legitimately have moved the agent in between. Credential-less → subscription is an auth-mode change, so it recreates rather than hot-reloading. Target selection reusesselect_subscription_for_new_agent— the same #2409 headroom ranker all three triggers already share.Fixes #2572
What changed
Backend (5 files)
services/subscription_service.py—adopt_for_credentialless_agents(), the sweep itself: one fleet-wide DB read, one batch Docker read, per-agent select/assign under the bug: SUB-003 auto-switch has no per-agent lock — concurrent 429s race the restart #799 lock, every blocking call inasyncio.to_thread. Module-level_SWEEP_LOCK; a concurrent sweep skips, never queues. Keyword-onlytriggerparameter feedsdetails.trigger— the sweep itself is trigger-agnostic and is not forked per trigger.routers/subscriptions.py— Trigger A1 onPOST /api/subscriptions, swallow-everything so it can never fail a registration whose credential is already stored. The body parameter is renamedrequest→payloadbecause the handler now also takes the injectedRequest; two things namedrequestin one handler is how a log line inside anexceptraisesAttributeErrorand returns a 500 with the credential already stored.routers/settings.py— Trigger A2 on both clear paths, each gated on the route's existingdeletedtruthiness. The hook sits on the routes, not onclear_secret_setting— that leaf also servesgithub_patand the Slack keys, and hooking it would run a Claude-subscription sweep on unrelated credential deletions.services/docker_service.py—agent_container_runtime_labels(), a label-strict sibling ofagent_container_runtimes(). Same tri-state, same keying, samesparse=Truecost bound; the one difference is the whole reason it exists.agent_container_runtimes()resolves a missing label to"claude-code"— right for a UI affordance, wrong for deciding whether to hand an agent a Claude subscription, andtrinity-systemcarries no runtime label at all. Absence is read as no evidence, never as Claude.services/docker_utils.py— the async form, following the existing function-local-import rule so a suite that stubsservices.docker_servicestill resolves throughsys.modules.Audit (the one deliberate scope expansion, for AC 5)
AC 5 asks that adoption be recorded "alongside manual assignment" — which is only literally true if the manual move is recorded too. #2421 (still open) found this router carried no
platform_audit_service.log(...)call at all, so an auditor asking "how did agent X get onto this subscription?" got a false negative for every manual move. This PR addssubscription_assign/subscription_clearalongside the newsubscription_auto_adopt/subscription_auto_adopt_sweep. #2421's remaining actions (register / delete / auto-switch settings) stay unclaimed and cannot collide. No token enters any audit sink — subscription id and name only (Invariant #12).Tests (2 files) —
tests/unit/test_2572_credentialless_adoption.py(new, 38 tests) andtests/unit/test_subscription_reassign_hotreload.py(updated for thepayloadrename).Docs (8 files) —
requirements/security.md(§20.3 workflow, §20.3a rewritten to own both triggers, §20.5a's "Cut" sentence amended rather than silently contradicted, SEC-001 event-action list);feature-flows/subscription-management.md(primary owner);feature-flows/subscription-auto-switch.md(one-line cross-reference, so SUB-003's failure-scoped overview is not contradicted);feature-flows/platform-settings.md;feature-flows/workspace-model-choice.md;feature-flows.md(Recent Updates row);architecture/backend.md;architecture/agent-lifecycle.md.No schema change — dual-track migration Rule #9 N/A. Mechanically confirmed: the changed-path set hits neither
src/backend/db/{schema,migrations,tables}.pynorsrc/backend/migrations/versions/. No new table, no new column, no Alembic revision, nomigrations.pyentry.No frontend change. Zero files under
src/frontend/.Acceptance criteria
Trigger A1 on
POST /api/subscriptions, Phase A awaited. Evidence:test_2572_credentialless_adoption.pydrives the service and the router boundary with a stubbed Docker read and asserts the assignment is persisted before the POST returns. Mutation-tested — swapping the predicate to a DB-only check fails 2 tests.Predicate condition 1 short-circuits the whole sweep on any instance where
get_anthropic_api_key()resolves anything — encrypted row → legacy row →ANTHROPIC_API_KEYenv fallback. This is the structural guarantee, not a per-agent filter. Proven on the isolated stack by a negative control: withANTHROPIC_API_KEYstill exported, deleting the settings row returned{"deleted":true,"fallback_configured":true}and produced 0 adoption rows.Condition 2 (
subscription_id IS NULL), re-checked inside the per-agent lock so a concurrent SUB-003 switch cannot be overwritten. Covered in the unit suite.GET /api/subscriptionsreports the agents on it.Reuse only — no new surface.
GET /api/subscriptionsbuildsagents/agent_countfromagent_ownership.subscription_id, and the badge readsGET /api/subscriptions/agents/{name}/auth→derive_auth_mode(), both purely DB. Because Phase A is awaited, both are correct on the panel's very next refetch.One
subscription_auto_adoptrow per adopted agent, onesubscription_auto_adopt_sweepsummary row, plus the previously-missing manualsubscription_assign/subscription_clear. See the audit note above.Decisions made by the issue owner
These were put to the issue owner during planning and decided; they are recorded here so a reviewer reads them as settled, not as proposals.
services/subscription_recovery_service.pyis deliberately not extended — that would be the periodic sweep that was declined.use_platform_api_key = false) are excluded. The operator explicitly opted them out of platform credentials; the agent-sidearm_subscription_auth_guard()(bug: .env-resident ANTHROPIC_API_KEY shadows subscription auth in the per-spawn execution env (#1999), defeating SUB-003 auto-switch #2114) would force-unset a key the backend structurally cannot see.derive_auth_modestill readsuse_platform_api_keyrather than key existence, so an agent the sweep skips still renders "API Key" on a keyless instance. That is bug: agent chat shows Claude Code's raw "run /login" error when no model credential is configured #2570's to fix, not this one's.trinity-systemis adopted in the DB but never restarted by the sweep (bug: trinity-system never adopts a rebuilt base image — ensure_deployed short-circuits on 'already running' #1816), and ephemeral ghosts are excluded from the sweep entirely (a recreate destroys their workspace).Verification
/verify-local --skip-agenton68a2bc55→ status = pass (projecttrinity-verify-0b7f263a):Affected neighbourhood, re-run on the final tree:
Isolated-stack end-to-end proof — PARTIAL, stated honestly. Plan §13 lists 11 steps. 4 ran, plus 4 extra negative controls; 7 did not run, for two structural reasons rather than for convenience:
trinity-agent-networkowned by the live dev stack.verify-localexportsTRINITY_AGENT_NETWORK, but no backend code reads it — all three agent-create sites hard-code the global name. Creating an agent there would have reached into live infrastructure.ANTHROPIC_API_KEY, so predicate condition 1 short-circuits by design — which is correct behaviour, and also means the positive adoption path cannot be driven from that stack without removing a real credential from the environment.What did run, and what it showed:
POST /api/subscriptionswith a fabricatedsk-ant-oat01-…200— registration never probes Anthropic, so a syntactic token is enough to exercise the triggerDELETE /api/settings/api-keys/anthropic(dedicated route, key present){"success":true,"deleted":true,"fallback_configured":true}— row gone,settings_changeaudit row written, 0 adoption rows (env-fallback negative holds)DELETE /api/settings/anthropic_api_key_encrypted(generic route, key present){"success":true,"deleted":true}— same, 0 sweep rowsDELETEon both routes{"deleted":false}— no rows of any kind; thedeletedgate holdsPUTthenDELETE{"deleted":true}with asettings_changerow only — no adoption, no sweepPOST /api/subscriptions401on all three — the gate is liveThe positive adoption path, Phase B restart, and the "system agent is never restarted" guarantee are proven by the 38 unit tests, which execute the real service and router paths against a stubbed Docker read. Each guard was mutation-tested rather than merely asserted:
trinity-systemexclusion → 1 test failsasyncio.wait_for, so absence of the skip manifests as a hang rather than a failure — see follow-ups)Known conditions / pre-existing reds
All three reproduce on base (
origin/dev@7a40408b4) and none is introduced here.test_2703_skill_assign_delivery.py— 5 failures in the full unit island at--randomly-seed=12345(branch: 15,336 passed / 35 skipped / 5 failed). Reproduced on a pristine_ref-devcheckout at7a40408b4: 5 failed / 15,298 passed. Minimal reproducer is 2 files — that file plustest_skill_service_user_agent.py. Order-dependent state leakage in bug(skills): a library skill assigned to an agent is missing from the Workspace and every playbook list until a manual Sync or restart — and the lists never refresh on assign/unassign #2703's neighbourhood, unrelated to subscriptions.test_2638_subscription_switch_on_turn.py— 3 failures in the 8-file set under-p no:randomlyin one specific fixed order. The file alone is 54/54 green, every pairwise combination is green, and the default (randomized, i.e. what CI runs) mode is green. Reproduced on base.test_subscription_auto_switch_pingpong.py— 10 setup ERRORS at--randomly-seed=7. Reproduced on base.A zero-adoption sweep writes no audit row on the
not names/not candidates/not assignableearly returns — the summary row is written on the Docker-unreadable abort and after a real loop. So on a keyed install neither trigger leaves a trace. This is by design (the docstring's stated purpose is to distinguish "0 of 40 because Docker was unreadable" from "nothing to do"), and it is why the isolated-stack probes above correctly show 0 rows. A DEBUG line for the silent returns is a follow-up.Follow-ups (not filed — for the maintainer)
Product, shown to the issue owner with the shipped option and not overridden:
Engineering, found during this work:
system_agent_service._create_system_agentnever callsselect_subscription_for_new_agent, so the create-time trigger does not covertrinity-system(A1/A2 now do).db.delete_settinghas no twin of theset_settingsink guard, soDELETE /api/settings/{credential-shaped key}bypasses the secret-settings policy generally. This PR's second hook makes it harmless for this one key; the general gap remains.uvicorn --workers 2, so_SWEEP_LOCKand bug: SUB-003 auto-switch has no per-agent lock — concurrent 429s race the restart #799's lock are per-worker. This contradicts the comment atsubscription_auto_switch.py:64-70; the RedisSETNXpath is already documented there. Worth closing as its own issue.agent_container_runtime_labels()is a ~45-line near-duplicate ofagent_container_runtimes()— deliberate (their fail-open/fail-strict resolutions differ), but it deserves a parity test.subscription_clearwrites an audit row on a no-op DELETE (no subscription was assigned).asyncio.wait_for); a hang is a worse failure mode than an assertion.TRINITY_AGENT_NETWORKat the three agent-create sites. That single change would make plan §13's full 11-step proof runnable beside a live stack — the reason 7 steps are unrun above.subscription_assign/subscription_clearas landed, so its remaining scope is unambiguous..claude/agents/test-runner.mdcatalog row fortest_2572_credentialless_adoption.pyis owed..claudeis a private submodule and read-only from this worktree, so it is not in this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB