Provider grid term and built-in cutover

Task 9925 · Done · Coder · created · updated

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.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)