Provider grid term and built-in cutover
Original work item (verbatim): "Providers and their auth type is a standard plan feature. Refactor current implementations"
Operator decisions (verbatim, PRD §1):
| # | Question (short) | Answer (verbatim) |
|---|---|---|
| D1 | What should the system default + per-org Providers tab become? | "Plan grid + org override (Recommended) — operator comment: Also must be universal with custom agents" |
| D2 | Fate of the plan switch `non_claude_engines_allowed` | "Retire it, grid decides (Recommended)" |
| D3 | Operator's own orgs (ungoverned today) | "Governed by its plan/override" |
| D4 | Pairs a later build adds | "OFF until enabled (Recommended)" |
| D5 | What "universal with custom agents" means | "Agent kinds as grid rows, Each registry agent as a provider" |
| D6 | Where the grid sits | "New 'Providers' tab (Recommended)" |
| D7 | Data migration | "Preserve effective access (Recommended)" |
| D8 | Existing accounts on a now-disallowed pair | "Stop bullshit". Read as: no change from today, so keep current behaviour (never edited or deleted, refused at dispatch). |
## Architecture decisions (verbatim)
| # | Question | Answer (verbatim) | What it changes |
|---|---|---|---|
| Q1 | What does the grid's "SDK agent" row replace? `agents_enabled` gates more than running SDK agents: the Agents pages, agent CRUD, connectors, the project default agent and the task agent pick. | "All of it, retire switch" | `agents_enabled` is retired completely (it overrides PRD A2's partial reading). The `sdk` row gates every manifest agent, connectors included. The Agents pages show when any agent row (`sdk` or any `acp:*`) is ON. |
| Q2 | How does the migration keep registry access where `acp_registry_allowed` was ON? | "All OFF, operator enables" | Every `acp:*` cell starts OFF on `(default)`, on every plan and in every override. **This overrides D7/R7 for registry rows only.** Built-in pairs and the `sdk` row still keep exactly the access they had. |
| Q3 | Which registry agents appear as grid rows? | "Installable ones + stored" | A registry entry is a row when the install route would accept it, or when any plan, `(default)` or override already stores it. |
| Q4 | Delivery order | "Yes, 3 children" | Three children: C1 builds the term and the built-in cutover. C2 (agent rows) and C3 (Providers tab UI) both depend on C1 and build in parallel. |
## Requirements (PRD §3, verbatim — this child delivers the parts its scope names)
**R1 — One plan term, the provider grid.**
- What an org may run is ONE plan term in `app/plan_terms.py`: a set of allowed rows × auth types.
- It resolves the standard way: org override → plan → `(default)` → code constant.
- A plan may inherit it from `(default)`: a new `INHERITABLE` entry, e.g. `providers`.
- The code constant is today's `SEED` (Claude + API key).
- `plan_terms.py` is the single home of the resolution. `provider_auth_policy.py` may remain as the door-facing API (`is_allowed`, `require_allowed`), but it reads only the plan term.
**R2 — Rows of the grid are universal (D1, D5).**
The grid has one row per runnable engine:
- (a) each built-in provider in `agent_providers.PROVIDERS`, with that provider's `auth_kinds`;
- (b) the agent kind "SDK agent" (manifest agents), as one row;
- (c) each ACP-registry agent, as a provider row of its own, keyed by its registry id. Its auth type is the key route registry agents use: API key via ACP `authenticate`.
One resolver answers "may org O run row R with auth type K" for all three.
**R3 — Retire the old mechanisms (D1, D2, D5).** Removed, migrated first (R7):
- the system default (`system_state` `provider_auth_defaults`, Settings › Providers › Default providers, its API routes);
- the org Providers tab and `orgs.provider_auth_override`;
- the plan switch `non_claude_engines_allowed` (SWITCHES row, plan column, org override column, console twin `planSwitches.tsx`, Features-grid entry, `CONSOLE_SWITCHES`, dev seed);
- the switches the grid's agent rows replace: `agents_enabled`'s gate on running/installing SDK agents and `acp_registry_allowed`.
Columns are dropped only per the additive-migration rule: they stay until no release reads them (`docs/standards/coding-standards.md`).
**R4 — Every door reads the grid.** Every place that today reads tiers 1–4 reads the grid term instead:
- account create;
- dispatch candidate pick (`auth_pair_disallowed`);
- sign-in;
- task create/update provider pin;
- automations;
- project default provider;
- console provider pick;
- agent picker built-ins;
- SDK-agent run/install;
- ACP-registry browse/install/bind/dispatch, including the registry agent's bound key account at bind time and at dispatch.
A provider is "on the plan" iff at least one of its pairs is allowed (D2). Refusals name the row and the auth type, and point to Billing for a tenant: "<Row> with <auth type> is not on your plan. Upgrade in Billing."
**R5 — Operator orgs governed (D3).** Operator-plane orgs resolve the grid like tenants:
- an operator-plane org has no plan, so it reads `(default)` plus its own org override;
- the "not governed" carve-out in `provider_auth_policy.py` / `tenancy` is removed for this term;
- the term is overridable on operator-plane orgs (`OPERATOR_PLANE_SWITCHES` equivalent).
**R6 — New rows OFF (D4).** A pair, provider, or registry agent that a later build or a registry refresh adds is OFF on every plan, `(default)` and org until an operator turns it on. It is never ON by inference.
**R7 — Migration preserves effective access (D7).** After the migration every org — tenant, operator-plane, staging (via its parent) — can use exactly the set of built-in pairs it could use before. Computed per org as `(org override ?? system default, or all supported pairs for operator-plane) ∩ non_claude switch`. The rest of the migration:
- `(default)` = today's system default;
- each plan = that list filtered by the plan's effective Non-Claude switch, or inherits `(default)` when the filter changes nothing;
- an org whose effective set differs from its plan's gets an org override equal to its effective set today;
- SDK-agent and registry rows: ON exactly where `agents_enabled` / `acp_registry_allowed` were effectively ON;
- the migration is idempotent and reversible per the repo's migration rules.
**R8 — Existing accounts (D8).** Unchanged from today: an account whose pair the terms disallow is never edited or deleted. It is shown off-plan and never leased.
**R9 — UI, Providers tab (D6).** A new **Providers** tab beside Price / Limits / Features / Products, in both:
- the plan editor (Products › plan), with "Inherit from (default)" per the standard inherit control;
- the org's Overrides, with "Use plan" / "Override" and "Overridden — plan: X" like every other term.
It shows one grid: rows = built-ins, SDK agent, registry agents; columns = auth types. Not-applicable cells are absent, not disabled checkboxes.
On the org Overrides it is offered for operator-plane orgs too (R5). The tenant-facing account page (`AccountEditPage.tsx`) names hidden rows via the plan, not "policy", and its remedy points to the Providers override tab.
Follow `docs/standards/ui-style-guide.md` and `docs/standards/ui-copy-standards.md`.
**R10 — Docs single-homed.**
- `plan_terms.py`'s header lists the new term and drops "non-Claude engines";
- `provider_auth_policy.py`'s header is rewritten or deleted to match;
- reader docs (`apps/docs`) and router rows that mention Default providers / Providers tab / Non-Claude engines describe the present;
- no copy of the resolution rule outside its home.
## This child's scope and approach (Architecture §2, verbatim)
### C1: Grid term and built-in provider cutover (backend core)
*Depends on: nothing.*
**Scope:**
- §1.1–§1.4: the term, resolver, `provider_auth_policy` rewrite, plans/org-override/identity fields, and `GET /api/provider-grid` with the `builtin` group.
- §1.5 rows marked C1.
- §1.6 C1 removals, web included: the Settings section, the org ProvidersTab, the Non-Claude switch and its console readers moved to `lib/providerGrid.ts`.
- Migration M1 and its test.
- Docs (R10):
- the `plan_terms.py` header bullet "providers", replacing "non-Claude engines";
- the `provider_auth_policy.py` header;
- every `apps/docs` page and router row that mentions Default providers, the org Providers tab or Non-Claude engines;
- `docs/policies/fleet-task-lifecycle.md` and `docs/architecture.md` if they name the old tiers (`rg` decides).
**Acceptance it owns:**
- AC4: built-in surfaces.
- AC5.
- AC6: built-in.
- AC7.
- AC9: API part (operator-org override through `PUT /orgs/{id}/plan`; Accounts › Add offering).
- AC10.
- AC11: built-in pairs, plus the staging amendment in §1.7.
- AC12.
- AC13.
- AC14: provider prose.
**Before merge,** record the Accounts › Add offerings of three dev orgs (a tenant with an old override, a tenant without one, and the operator org) in the PR body. These are the "before" half of AC11's dev oracle, re-observed after deploy.
**`AccountEditPage` remedy.** In C1 the operator remedy links to the org's Overrides tab, since Providers does not exist there until C3. C3 retargets the link to the Providers tab.
**Tests:**
- Rewrite `test_provider_auth_policy.py` with every state in the PRD's states table.
- Update `test_accounts_route.py`, `test_console_provider_pick.py`, `test_agents_plan_switch.py` (built-in part), `test_automation_fire.py`, `test_org_plan_admin.py`, `test_plan_terms.py`, `test_plan_matrix.py`, `billing/test_plan_routes.py`, `test_settings.py` and `test_auth.py`.
- Update the web tests: `AgentPicker.test.tsx`, `WorktreePool.test.tsx`, `AccountEditPage.add.test.tsx`, `Settings.roles.test.tsx`, `OverridesTab.test.tsx`. Delete `ProvidersTab.test.tsx`.
## Pinned design (Architecture §1, verbatim — build against it, do not re-decide)
### 1.1 The term: a set of cells
The term is a **cell**: `(row, auth)`, stored as `[row, auth]`.
| Row key | Rows | `auth` values |
|---|---|---|
| built-in provider value (`claude`, `codex`, `deepseek`, `antigravity`, `pi`) | `agent_providers.PROVIDERS` | that provider's `auth_kinds` (`oauth`, `api_key`) |
| `sdk` | the "SDK agent" row: manifest agents and catalog connectors | `own` (own credentials; the agent leases no account) |
| `acp:<registry id>` | one row per ACP-registry agent | `api_key` (the key reaches the agent through ACP `authenticate`) |
Registry rows get the `acp:` prefix so a registry id can never collide with a built-in value. For example, the registry has `pi-acp` and `codex-acp` beside the built-ins `pi` and `codex`.
**Storage** is two nullable JSONB columns:
- `billing_plans.providers`: the list of cells, in catalogue order (`encoded`).
- `orgs.providers_override`: NULL means "use plan"; a list replaces the plan's value outright.
`INHERITABLE` gains `"providers": ("providers",)`. The code constant is `SEED = {("claude", "api_key")}`.
*Chose JSONB columns over a `products`-style child table.* A cell carries no attributes, so a child table would add a model, a CRUD layer and per-row history for a plain set. The existing `provider_auth_override` already proves the JSONB encoding.
### 1.2 Resolution: single-homed in `plan_terms.py` (R1, R5)
- `PlanTerms` gains `providers: Term[frozenset[Cell]]`. It is built in `effective()` exactly like `products`:
- org override, else
- `field(plan, dflt, "providers")`, else
- `SEED`.
- Staging orgs resolve through `terms_org` with nothing extra: they get the parent's plan value and the parent's override.
- **An operator-plane org reads `(default)` plus its own override, never its `plan_key`'s plan.**
- `orgs.plan_key` is NOT NULL (`models/orgs.py:259`), so the resolver must pass `plan=None` for `not is_tenant_org(org.id)`, mirroring `_switch` (`plan_terms.py:656-675`).
- Test: an operator org whose `plan_key` names a real plan with a different grid still resolves to `(default)`'s.
- The "not governed / every pair" carve-out is removed. The operator org is governed through `(default)` plus its override.
- **`org_id is None`** (no org: system callers) resolves to the `(default)` row's value, else `SEED`. This is pinned and tested.
- **Plan includes.** `includes_plan_key` does not apply to this term. The term follows the standard inheritable path (plan → `(default)`), like `products`. M1 freezes the old switch's OR over included plans into stored values, so after migration an edit to an included plan no longer flows to the including plan. The `plan_terms.py` header bullet says so.
- A fast reader `providers_of(session, org_id, *, memo=None) -> frozenset[Cell]` returns only the value, for hot paths.
- **Decode rule (AC10, R6).** A stored cell is kept when:
- its row is built-in and the pair is in `supported_pairs()`, or
- its row is `sdk` with `own`, or
- its row matches `acp:<_ID>` with `api_key`.
Anything else is dropped on read. The resolver **never fetches the registry**: dispatch has no CDN dependency. A cell is ON only if it is stored, so a new pair, provider or registry agent reads OFF everywhere until an operator stores it.
- `_overrides()` gains a "Providers" entry, so a plan move with `keep_overrides=false` clears it, like products.
### 1.3 The door-facing API: `app/provider_auth_policy.py`, rewritten
The module keeps its name so ~15 callers need no rename churn. It reads **only** `plan_terms.providers_of`. Its header becomes a pointer: "resolution: `plan_terms.py`; this module: catalogue, verdict and refusal sentence".
The functions below are pinned. C2 and C3 build against them.
```python
Cell = tuple[str, str]
SDK_ROW: Final = "sdk"; OWN: Final = "own"; REGISTRY_PREFIX: Final = "acp:"
def builtin_cells() -> tuple[Cell, ...] # renamed supported_pairs(); acp_runtime.py keeps calling supported_pairs (alias kept)
def validated(cells) -> frozenset[Cell] # 422 naming the first malformed/unsupported cell; shape-only for acp:* (no CDN)
def encoded(cells) -> list[list[str]] # stable order: built-ins (catalogue), sdk, acp:* sorted
def allowed(session, org_id, *, memo=None) -> frozenset[Cell]
def is_allowed(session, org_id, row, auth, *, memo=None) -> bool
def provider_on_plan(session, org_id, row, *, memo=None) -> bool # any cell of `row` allowed (D2); replaces engine_on_plan
def refusal(session, org_id, row, auth=None) -> str
def require_allowed(session, org_id, row, auth) -> None # 422 refusal(...) + " Nothing was created."
```
`refusal` returns:
- tenant: `"<Row label> with <OAuth|API key|own credentials> is not on your plan. Upgrade in Billing."`
- operator-plane org: `"… is off in this organization's Providers override."`
- with no `auth`: `"<Row label> is not on your plan. …"`
Row labels come from the provider label, "SDK agent", or the registry entry's name (falling back to its id).
The following are deleted: `STATE_KEY`, `system_default`, `set_system_default`, `org_override`, `engine_on_plan`, `engine_off`.
### 1.4 API seams
These seams are pinned. C3 builds against them, and C2 extends the catalogue they return.
1. **Plans** (`routes/billing_plans.py`):
- `PlanAdminOut.providers: list[list[str]]` is the plan's own stored value.
- `PlanCreate`/`PlanPatch.providers: Optional[list[list[str]]]`. `inherits` may contain `"providers"`.
- `_snapshot` and the `(default)` change preview include `providers`.
- `dry_run` works unchanged.
2. **Org overrides** (`routes/orgs.py`):
- `OverridesOut.providers: {value: list[list[str]], overridden: bool, plan_value: list[list[str]]}`.
- `OrgPlanIn.providers: Optional[list[list[str]]]` has the **same tri-state semantics as `products`**: absent means unchanged, `null` resets to the plan, a list sets the override.
- It is accepted for operator-plane orgs too, so it joins the `OPERATOR_PLANE_SWITCHES` analogue in `put_org_plan`.
- Writes go through `org_plan_admin` with the existing override-changed activity (`OVERRIDE_CHANGED`) and customer history.
3. **Grid catalogue**: `GET /api/provider-grid`, readable by whoever may read plans.
```json
{"columns": [{"key":"oauth","label":"OAuth"},{"key":"api_key","label":"API key"},{"key":"own","label":"Own credentials"}],
"groups": [{"key":"builtin","label":"Providers","rows":[{"key":"claude","label":"Claude","auth":["oauth","api_key"]}, ...]},
{"key":"agents","label":"Agents","rows":[{"key":"sdk","label":"SDK agent","auth":["own"]}]},
{"key":"registry","label":"ACP registry","rows":[{"key":"acp:cline","label":"Cline","auth":["api_key"],"note":null}, ...]}],
"registry_unavailable": false}
```
- C1 serves the `builtin` group only. C2 adds `agents` and `registry`.
- Registry rows are the entries that pass the **same predicate the install route uses** (extracted from `acp_registry.py:223-302` into one function, `install_refusal(entry) -> Optional[str]`), plus every `acp:*` id stored anywhere (`note: "Not in the registry"` when the live list lacks it).
- If the CDN is down, the response carries the stored ids plus `registry_unavailable: true`, and the UI says so.
4. **Identity payload** (`routes/auth.py`):
- `providers: list[list[str]]` is the principal's org's allowed cells.
- It replaces `non_claude_engines_allowed` (C1) and `agents_enabled` and `acp_registry_allowed` (C2).
- Web helper `apps/web/src/lib/providerGrid.ts`:
- `cellOn(p, row, auth)`
- `providerOn(p, row)`
- `agentsOn(p)`, which is true when `sdk` or any `acp:*` is ON
- `registryOn(p)`
- These helpers become the only console readers.
5. **Org roster `plan_switches`**: the act-as narrowing on the Agents pages now keys on the roster's `providers` (per org), read through the same `agentsOn`.
### 1.5 Every door reads the grid (R4)
| Door | File (today) | New call | Child |
|---|---|---|---|
| Account create | `routes/accounts.py:716` | `require_allowed(org, provider, auth_kind)` | C1 |
| Account list "allowed" | `routes/accounts.py:198` | `allowed()` | C1 |
| Dispatch candidate pick | `routes/fleet.py:1546,1617` `auth_pair_disallowed` | `allowed(memo=)` | C1 |
| Sign-in | `routes/commands.py:1934-1947` | `require_allowed` / `provider_on_plan` + `refusal` | C1 |
| Task create/update pin | `routes/tasks/create.py:149-153`, `update.py:646,664` | `provider_on_plan` + `refusal` | C1 |
| Automations | `automation_actions.py:311`, `automation_fire.py:449` | same | C1 |
| Project default provider | `routes/projects.py:1925-1928` | same | C1 |
| Console provider pick | `routes/console.py:484-488` | same | C1 |
| Agent picker built-ins | `routes/agents.py:598-606` | `provider_on_plan` | C1 |
| SDK agent run | `agent_resolution.py:131-134` (`AGENTS_OFF`) | `is_allowed(sdk, own)` for v1 / manifest-2 / connector rows | C2 |
| SDK agent CRUD, catalog, connectors | `routes/agents.py:301-310` `_ON_PLAN` | create/install needs `sdk/own`; listing/page needs `agentsOn` | C2 |
| Project default agent, task agent pick | `routes/projects.py:1934`, `routes/tasks/create.py:166` | the picked agent's cell | C2 |
| Registry browse | `routes/acp_registry.py:37-49` | any `acp:*` allowed; entries off-plan hidden for a tenant, shown as "Off" to an operator | C2 |
| Registry install | `routes/acp_registry.py:143-179` | `require_allowed(acp:<id>, api_key)` | C2 |
| Registry key bind | `acp_registry.key_account_refusal` (`:405`), via `routes/agents.py:394-415` | plus `is_allowed(account.provider, account.auth_kind)` → refusal names the pair | C2 |
| Registry dispatch | `agent_resolution._unrunnable` (`:131-154`), `lease_drain.py:1493` | `is_allowed(acp:<slug>, api_key)` **and** the bound account's pair (a new check: today dispatch re-checks neither) | C2 |
| Console places tag | `support_agent/console_places.py:186-188` | `agentsOn` equivalent | C2 |
| Web readers | `AgentPicker.tsx:126-182`, `WorktreePool.tsx:910`, `AccountEditPage.tsx:537-559`, `providerCatalogue.ts:40-61`, `App.tsx:336-339`, `directoryTabs.ts:62`, `agentsTabs.tsx:43`, `RegistryPage.tsx:112` | `lib/providerGrid.ts` | C1 (provider ones), C2 (agent ones) |
`AccountEditPage` names a hidden pair "not on your plan".
- Its remedy for a tenant is Billing.
- For an operator it links to Orgs › org › Overrides › Providers (R9).
### 1.6 Retired mechanisms (R3)
**C1 removes:**
- `system_state` `provider_auth_defaults` readers and writers: `routes/settings.py:378-496`, `ProviderAuthDefaultsSection.tsx`, the `Settings.tsx:113` panel and its `provider_auth` anchor. The `settings/providerAuth/` directory is deleted once nothing imports it.
- `orgs.provider_auth_override` readers and writers: `routes/orgs.py:2164-2221`, `pages/orgs/ProvidersTab.tsx` and its mount in `customers/SecurityTab.tsx:37,118`, and `pages/orgs/api.ts:344-358`.
- `non_claude_engines_allowed` everywhere: the `SWITCHES` row, `CONSOLE_SWITCHES`, the `plan_terms` header bullet and reader `:783`, the `models/billing.py:202` and `models/orgs.py:283` mappings, `seed_dev_profile.py:1288`, the `org_plan_admin.py:296` label, `planSwitches.tsx:80`, the `PlanFields.tsx:152` Features entry, and the census row in `tools/tests/test_vendor_name_census.py:564`.
- The `provider_auth_override` ORM mapping in `models/orgs.py:227`.
**C2 removes** `agents_enabled` and `acp_registry_allowed` the same way: SWITCHES, CONSOLE_SWITCHES, models, identity, planSwitches, Features grid, the dev seed, and their `plan_terms` header bullets.
**Deep links.** `/settings#provider_auth` falls back to the Settings top. The org Security tab's `providers` tab param falls back to its first tab, using the existing tab-param fallback. There is never a blank page.
**Columns are dropped later.** The DB columns stay, unmapped and read by nothing, until the contract step (§3, follow-up F1). The removed ORM mappings are nullable columns, so inserts keep working.
`apps/jaahd/agent_providers.py`'s `capabilities.plan_gate` field is **left untouched** because the PRD forbids changing the node daemon. The API and web simply stop reading it: drop `plan_gate` from `apps/web/src/types.ts:387`. Removing the field from the node catalogue is follow-up F2.
### 1.7 Migrations
Each child migrates what it cuts over, in the same PR as the cutover, so no window lets the two sources drift. Each is a plain Alembic revision `YYYYMMDDHHMM_<slug>` in `apps/api/migrations/versions/` with `SET lock_timeout = '3s'`. Each is **expand-only** (additive rule, `docs/standards/coding-standards.md`) and **frozen**: no `app.*` import (`test_frozen_migration_imports.py`), so the built-in pair list and the switch-chain logic are copied into the revision as constants.
**M1 (C1), `<ts>_provider_grid`:**
1. `ADD COLUMN IF NOT EXISTS billing_plans.providers JSONB NULL` and `orgs.providers_override JSONB NULL`.
2. `old_default` = the `system_state['provider_auth_defaults'].allowed` decoded and filtered to the frozen supported pairs, else SEED.
3. Define `switch_eff(org)`, today's `_switch` logic for `non_claude_engines_allowed`:
- the org override when set, else
- OR over the plan's `includes_plan_key` chain when any plan in it is ON, else
- the plan's own value, else
- `(default)`'s value, else False.
A missing plan reads `(default)`. An operator-plane org reads `(default)` plus its own override.
4. `(default).providers = old_default`. This is R7's "(default) = today's system default". The per-org overrides below absorb any switch effect.
5. For each non-default plan: `v = old_default ∩ {claude cells ∪ (non-Claude cells if switch_eff(plan) ON)}`.
- If `v == old_default`, append `"providers"` to `inherits`.
- Otherwise store `v`.
6. **Staging orgs** get no override: they resolve through the parent (PRD state table: "staging (parent's)").
- Today a staging org reads its *own* `provider_auth_override` (else the system default), and only the switch goes through the parent.
- M1 logs, as a count with org ids at INFO, every staging org whose old set differs from its parent's new set.
- This is an **accepted behaviour change**: a staging org mirrors its parent for every other term already. AC11's staging case is amended to "staging org resolves to its parent's effective set".
7. For each non-staging org:
- `old = (tenant ? (provider_auth_override ?? old_default) : all frozen supported pairs) ∩ switch filter(org)`.
- `new` = what the org would now resolve to (its plan's value or inherited `(default)`; `(default)` for operator-plane orgs).
- If `old != new`, set `providers_override = old`.
8. Idempotent: steps 4–7 write only where the target is NULL (and step 5 only appends `"providers"` to `inherits` when it is absent). Re-running changes nothing.
9. Downgrade: strip `"providers"` from every `inherits` and drop both columns. The old sources (the `system_state` row, `provider_auth_override`, the switch columns) were never touched, so the previous release reads exactly what it read before.
**Migration tests** follow the pattern in `tests/test_migration_202610171200_project_memory_switch.py`. They seed the PRD AC11 matrix and assert that effective sets are equal before and after, that downgrade restores them, and that a second upgrade is a no-op. The matrix covers:
- a tenant with an override and a tenant without one,
- the plan switch on and off, including through `includes_plan_key`,
- an org switch override,
- an operator org,
- a staging org,
- a dangling `plan_key`,
- a NULL plan switch reading `(default)`.
Risks (verbatim, Architecture §4):
| Risk | Mitigation |
|---|---|
| **Q2 consequence.** Every registry agent an org has **installed** stops dispatching after C2 deploys, until an operator turns its `acp:<id>` cell on. Today dispatch never re-checks the switch. The operator chose this. | C2's release note and PR body name it. The Providers tab shows installed ids (stored or not) via the catalogue `note`, so they are easy to find. |
| A naive migration copies the system default into `(default)` and stops there. A simple test passes, but orgs with a switch override or an `includes_plan_key` chain lose access. | The M1 algorithm computes per-org effective sets. The AC11 matrix explicitly includes the include-chain and org-switch-override cases. |
| A naive UI renders a wide table that scrolls horizontally or clips names at 390px (AC1 floor). | The stacked-card layout below 640px (C3). |
| A generic refusal ("not allowed") passes the status-code tests but does not tell the user the remedy. | A single `refusal()` sentence. Tests assert that the row label and auth label appear in the detail. |
| Registry CDN outage breaks the plan editor or dispatch. | The resolver never fetches. The catalogue falls back to stored ids with `registry_unavailable`. |
| Operator orgs lose an "open vocabulary" provider (an unknown provider value used to be allowed). | Intended (R5, R6). M1 gives operator orgs every frozen supported pair ∩ their switch, which is all a current account can be. |
| A stale console bundle reads the removed identity flags. | Web and API deploy together. A missing flag reads as off, so the failure is fail-closed. |
| AC4's `rg` oracle matches migration **tests**, which must name the old columns. | Amend the oracle to `--glob '!**/migrations/**' --glob '!**/test_migration_*'`. |
Credentials & reach:
| Oracle | Credential / identity / environment | Status |
|---|---|---|
| All `./dc test`, `./dc check quick`, migration tests, `check_context.py` (AC5–AC8, AC10–AC14 API parts) | nothing beyond the worktree | verified (worktree-only) |
| Browser flows AC1–AC4, AC8, AC9, AC11-dev | dev control plane at `https://app.dev.jaah.ai` (the PRD's "dev.jaah.ai" does not resolve from this node; `app.dev.jaah.ai` returns 200 on `/` and `/api/health` from node h5-3); credential `me/jaah/debug/main/operator-login` (operator, can switch to tenants) | verified: on the roster, `credential_get` succeeded, read-only HTTP probe from node h5-3 |
| Tenant T browser views (AC5, AC7) | the same credential, switching to a tenant org (per its description) | verified (same probe) |
| AC11 dev "before" capture (C1) | The same credential. C1 records the offerings before merge (§2 C1). | verified (same probe) |
| Merge and deploy watch | `jaah-forge` (pre-authenticated per task) | verified by design (runner charter) |
Out of scope:
PRD §5 (verbatim) — do NOT do any of these:
- MUST NOT change the ACP-vs-legacy runtime switch (`app/acp_runtime.py`, Settings acp-runtime). It is an operator cutover control, not a plan term.
- MUST NOT change `jaah_ai_enabled` / the Jaah AI account lane.
- MUST NOT change other plan switches, caps, quotas, products or pricing.
- MUST NOT edit, disable or delete any existing account (D8).
- MUST NOT add new providers, auth kinds, or registry distribution rules (`acp_registry.pick_distribution`, key routes).
- MUST NOT change OAuth-first spillover or account picking beyond the pair verdict.
- MUST NOT touch the node daemon (`apps/jaahd`) except, if needed, reading `agent_providers` metadata. No node wire change.
- MUST NOT keep any of the retired mechanisms as a parallel source of truth (no "system default" fallback beside `(default)`).
- Non-runnable parts of the agent catalog (e.g. connectors, if `agents_enabled` gates them) stay behind `agents_enabled` unless the Architect shows the switch then decides nothing. See A2.
- Architecture boundary: do NOT build the `sdk`/`acp:*` rows, the `agents`/`registry` catalogue groups, M2, or the `agents_enabled`/`acp_registry_allowed` retirement (child C2). Do NOT build the Providers tab UI in PlanEditor/OverridesTab or `components/plans/ProviderGrid.tsx` (child C3).
- Do NOT drop any DB column or the `system_state['provider_auth_defaults']` row (deferred contract follow-up F1); do NOT edit `capabilities.plan_gate` in `apps/jaahd/agent_providers.py` (deferred F2).
States to cover in tests (PRD, verbatim):
| Subject | States |
|---|---|
| Term | plan own / plan inherits `(default)` / no `(default)` row (code constant) / org override / staging (parent's) / operator-plane org (no plan) |
| Pair | known+on / known+off / unknown to stored terms (off) / no longer supported by build (dropped) |
| Account | on allowed pair / on disallowed pair |
Done means:
| # | Criterion | Verify |
|---|---|---|
| AC4 | The old surfaces are gone: no Settings › Providers › Default providers, no org Providers tab, no "Non-Claude engines" in any Features grid or override, no ACP-registry or Agent catalog switch where the grid replaced it (R3). Old deep links redirect or 404 cleanly, never a blank page. | Browser: search Settings for "Default providers" → none. Open org T tabs → no "Providers" tab outside Overrides. Plan Features → no "Non-Claude engines". `rg -n "non_claude_engines\|provider_auth_override\|provider_auth_defaults" apps/ --glob '!**/migrations/**'` → no live reader. |
| AC5 | Account create obeys the grid, for every state: allowed / disallowed by plan / disallowed by override / inherited from `(default)`. A disallowed pair is refused with the row and auth type named and nothing created. | Browser as tenant T: Accounts › Add → only allowed providers/auth types are offered. Hidden ones are named with the plan remedy. API test: `./dc test tests/test_provider_auth_policy.py tests/test_accounts_route.py` green, covering all four states. |
| AC6 | Dispatch never leases an account on a disallowed pair, including a registry agent's bound key account. | `./dc test` on the fleet candidate and registry-binding tests, with a new case "bound key on disallowed pair → refused, named". Browser: bind a registry agent to an account whose pair is off → bind refused with the reason. |
| AC7 | Every provider-pin door refuses a provider with no allowed pair: task create/update, automation, project default provider, console pick, sign-in. The agent picker omits it. | API tests per door, e.g. `tests/test_console_provider_pick.py`, `tests/test_agents_plan_switch.py`, renamed or rewritten, all green. Browser: as T with Codex off, the task form provider picker has no Codex. |
| AC9 | Operator-plane org is governed: turning Codex OFF in its override hides Codex for operator users exactly as for a tenant. | Browser as operator: Orgs › Operator › Overrides › Providers → Codex off → Accounts › Add offers no Codex. |
| AC10 | A pair or registry agent unknown to the stored terms reads OFF everywhere. | Unit test: a pair added to the catalogue and absent from stored terms → `is_allowed` False for plan, `(default)`, override, operator org. |
| AC11 | Migration preserves effective access: every org's allowed built-in pair set, and its SDK/registry agent access, is identical before and after. | Migration test: seed orgs covering tenant with override, tenant without, plan switch on/off, org switch override, operator org, staging org. Snapshot effective sets → upgrade → equal. Downgrade → restores. Also on dev: before deploy, record 3 orgs' Accounts › Add offerings; after deploy → same. |
| AC12 | Existing accounts on a disallowed pair are untouched: same row, not deleted, shown off-plan, never leased. | API test: account exists, then its pair is turned off → row unchanged, candidate pick excludes it with `auth_pair_disallowed`. |
| AC13 | Gates green: quick gate and full gate, context checker. | `./dc check quick`; merge lane `./dc check full`; `python3 tools/check_context.py` → pass. |
| AC14 | Docs describe the present (R10). | `rg -n "Default providers\|Non-Claude engines" docs apps/docs apps/api/app apps/web/src` → no stale prose. `check_context.py` green. |
Ownership in this child (Architecture §2 C1, verbatim): AC4: built-in surfaces. AC5. AC6: built-in. AC7. AC9: API part (operator-org override through `PUT /orgs/{id}/plan`; Accounts › Add offering). AC10. AC11: built-in pairs, plus the staging amendment in §1.7 ("staging org resolves to its parent's effective set"). AC12. AC13. AC14: provider prose.
- AC4 amended oracle (Architecture §4, replaces the rg in the AC4 row): `rg -n "non_claude_engines|provider_auth_override|provider_auth_defaults" apps/ --glob '!**/migrations/**' --glob '!**/test_migration_*'` → no live reader.
- AC7 browser step: C1 deletes the old org Providers tab and the new Providers tab arrives in C3, so use a dev tenant whose plan already has Codex off after M1 (no edits). If no such tenant exists, mark the browser half `(deferred — Finish verifies after C3 deploys)`; the API tests are mandatory.
- AC9 here is the API part only and is proved by `./dc test` (operator-org override through the org-plan route, then account create refuses Codex); its browser half belongs to C3. Do NOT call the dev API directly.
- AC5 browser: observe Accounts › Add as tenant T on its current plan (no edits); the four states are proved by the API tests.
- Dev-state rule: the hub rule is "Operate Jaah only through the Jaah UI" — never change dev plans/overrides through raw API calls. Any dev value a browser oracle changes is restored to its prior value afterwards, and the restore is cited. This rule overrides the architecture's line that the grid is "editable only through the plans/org-plan API on dev" between C1 and C3: such checks wait for the Providers tab or defer to Finish.
- AC11 dev oracle: record the Accounts › Add offerings of three dev orgs (a tenant with an old override, a tenant without one, and the operator org) in the PR body BEFORE merge; re-observe after deploy and cite both.
- Quality floor (planner tightening): every refusal detail asserted in tests names the row label and the auth label and the remedy sentence from `refusal()`; a bare "not allowed"/403 without the named pair FAILS AC5/AC7.
Outcome
Provider grid plan term (`providers`) replaces the system default, the org Providers tab and the Non-Claude switch. Every built-in door reads it. M1 preserves each org's pairs; dev Accounts › Add for 4 orgs is identical before and after deploy. Merge lane full gate green, PR #6880 merged, dev deploy green. Deferred: F1 (column drop), F2 (jaahd plan_gate). Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)