Files
smart-org/docs/plan/reports/agent-1-phase-3.md
T

7.0 KiB

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.