# Agent 1 — Phase 3 report (owner model grants + org policy) Branch: `dev` · Continues the org-platform workstream (PLAN-v2 §6, TODO-agent-1 Phase 3). Note on provenance: a complete Phase 3 already existed on the sibling worktree branch `agent1/org-platform`; that branch is based on the foundation and its Phase 2 diverged from `dev`'s (different `OrgSetupService`/container shapes), so the code was reconciled onto `dev`'s structures and then fully re-verified here rather than applied verbatim. ## What I did ### 3.1 `model_grant_service.py` (new) `ModelGrantService` is the real `ModelGrantPort`: `granted_models(org)` returns only models that are **both** active in the catalogue and actively granted to *that* organization, each with a neutral `tier` (`fast`/`balanced`/`strong` ≈ «سریع»/«متعادل»/«قوی») derived by the frozen `tier_for` helper from capabilities + output price. Model names are exposed only by the owner-facing views (`ModelView.label`, `OrgGrantView.label`), which the engine never calls. ### 3.2 `admin_grants.py` (owner-only router, deny-by-default) | Method | Path | Role | |---|---|---| | GET | `/api/admin/model-grants/{org}` | platform owner | | PUT | `/api/admin/model-grants/{org}/models/{model}` | platform owner | | GET | `/api/admin/model-grants/{org}/active` | platform owner | | DELETE | `/api/admin/model-grants/{org}/models/{model}` | platform owner | | GET | `/api/admin/default-model-grants` | platform owner | | PUT | `/api/admin/default-model-grants/{model}` | platform owner | | DELETE | `/api/admin/default-model-grants/{model}` | platform owner | | GET | `/api/admin/org-policies/{org}` | platform owner | | PUT | `/api/admin/org-policies/{org}` | platform owner | All mutations audited (`model_grant.set`, `model_grant.revoke`, `default_model_grant.set`, `default_model_grant.remove`, `org_policy.update`). Revocation deactivates the row instead of deleting it, so re-granting is idempotent and the history survives. ### 3.3 `OrgSetupService.complete` now calls `_copy_default_grants` **inside** the creation transaction (before the commit), materialising the platform owner's default set into the new organization. An empty default set is legitimate: a new org simply has no models until the owner grants them. ### 3.4 New `org_policies` table carries `max_employees` (clamped to `ENGINE_MAX_EMPLOYEES` in the service), `monthly_credit_budget` (`None` = no explicit budget), `automation_enabled` and `mcp_enabled` (reserved for Phase 5). `ModelGrantService.get_org_policy(org_id)` / `mcp_enabled(org_id)` are public for Agent 2 to read. ### 3.5 `container_org.build_model_grants` now returns the real `ModelGrantService`; `container.py` still re-exports the frozen `build_model_grants` name. A second, plain-typed builder `build_model_grant_service` serves the admin router (the port interface alone cannot express grants/defaults/policy). ### 3.6 `/admin/model-grants` Three tabs: «تخصیص سازمان‌ها» (org picker + matrix: checkbox, tier badge, capabilities, daily cap with its own save button), «مجموعهٔ پیش‌فرض» (the same matrix for new orgs, with an explicit "empty set" hint), «سیاست سازمان» (max employees, monthly budget, automation and MCP checkboxes). Persian, RTL, `@agy/ui` components, toasts for every mutation, per-row busy state. ## Files changed New: - `apps/api/app/application/services/model_grant_service.py` - `apps/api/app/infrastructure/repositories/org_grants.py` - `apps/api/app/domain/identity/audit_actions.py` - `apps/api/alembic/versions/e6f7a8b9c0d1_model_grants.py` - `apps/api/tests/test_model_grants.py` Modified: - `apps/api/app/infrastructure/db/models_org.py` (+`DefaultModelGrantRow`, `OrgPolicyRow`) - `apps/api/app/application/container_org.py` - `apps/api/app/application/services/org_setup_service.py` - `apps/api/app/interfaces/http/routers/admin_grants.py` - `apps/api/tests/test_foundation.py` - `apps/web/src/lib/api-org.ts` - `apps/web/src/app/(app)/admin/model-grants/page.tsx` ## Migration note (deviation from the TODO) The Phase-2 integration on `dev` created its own draft migration `d5e6f7a8b9c0_org_setup_drafts.py` chained off `a1b2c3d4e5f1`, rather than adding the table into `a1b2c3d4e5f1` as the reference branch did. I followed `dev`'s own precedent and chained a new, owned migration `e6f7a8b9c0d1_model_grants.py` (`down_revision = "d5e6f7a8b9c0"`). Single head, no drift. ## Test output summary ``` apps/api: pytest -q → 553 passed, 1 skipped (baseline 536; +17 in test_model_grants.py) apps/api: ruff check . → All checks passed! apps/api: mypy app → Success: no issues found in 141 source files apps/api: alembic upgrade head / downgrade -1 / upgrade head / check → clean, no drift apps/web: tsc --noEmit → clean apps/web: next lint → No ESLint warnings or errors apps/web: vitest run → 89 passed (11 files) ``` `tests/test_model_grants.py` (3.7): non-owner is 403 on all nine endpoints (parameterised), anonymous is 401, the wizard copies the default set (verified through both HTTP and `ModelGrantPort`), grant/revoke is immediate + audited + deactivates rather than deletes, revoking twice is 404, an inactive model is never granted and granting it is 404, per-org isolation both ways, the default set can be deactivated and removed without affecting existing orgs, and the policy defaults/caps/audit/isolation. ## Existing test updated (and why) `tests/test_foundation.py::test_default_model_grants_return_active_models_with_tiers` → `test_granted_models_carry_tiers_and_need_an_owner_grant`. The foundation test asserted the placeholder wiring (every active model usable by every org). Phase 3 changes that on purpose, so the test now creates two real organizations and asserts the stronger invariant: nothing is granted until the owner grants it, both switches (model active + grant active) must agree, and a grant is per-organization. No assertion was dropped. ## Deviations / notes - **`org_model_grants` has no repository elsewhere.** `db/models.py` (and `OrgModelGrantRow`) is foundation-owned and no other workstream owns a repository for it, so these live in the new `app/infrastructure/repositories/org_grants.py`. - **`granted_models` sorts by cap** (uncapped first); nothing depends on the order yet. - The mutation endpoints use router-level `require_platform_owner`, plus a redundant per-endpoint `owner` dependency only to obtain the actor id for audit. ## CHANGE REQUESTs 1. **`get_org_policy` on the `ModelGrantPort` contract.** `ports/model_grants.py` is locked, so `get_org_policy` / `mcp_enabled` live on `ModelGrantService` only. Please add `get_org_policy(organization_id) -> object with .max_employees / .automation_enabled` (and ideally `.mcp_enabled`) to `ModelGrantPort` at integration so Agent 2 reads the policy through the port it already holds. 2. **`container.py` re-export.** `build_model_grant_service` is importable only from `container_org` (the locked `container.py` re-exports only `build_model_grants`). Re-export it if a single import surface is preferred.