feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -0,0 +1,140 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user