Enforce local account and agent caps
## Original requirement (verbatim, epic body)
> Build new deployment type for Jaah: local. Do a web research and copy the model of OpenClaw, N8N and other similar products:
> - Open source in a public repo (for not private), mirrored some of the components from main repo
> - A package of node + CP that user runs on his machine, no multiple machines, single user, no advanced functionality, no networking, no nodes
> - Should be a very easy start - one line install and/or build from repo and/or docker compose
> - Limited number of accounts
>
> Analyze and propose options
Settled upstream (do not reopen): PRD (Task 10080) decisions D1–D16 + assumptions S1–S9; Architecture (Task 10081) decisions D17–D29 — the **1+3 hybrid**: one codebase with a build-time edition (three literal constants rewritten only by the mirror build), an allowlist manifest + tree builder + per-PR mirror-build gate, caps enforced in the CP (route + DB trigger) and the node (independent refusals), public repo `jaah-ai/jaah-local` (fair-code, read-only mirror, images on GHCR), published only on `master` promotion and fail-closed until the licence is approved (D24). Binding numbers: 1 user, ≤3 AI provider accounts in total (any state/provider, S4), ≤10 concurrent agent sessions (over-cap work queues, never fails, S5), no pgvector, no telemetry, loopback only, Docker required.
## Scope of this child
**C4. CP caps (Arch §2.5).** Owns AC-15, AC-16 (CP side), AC-17 (CP layer). This child carries this wave's ONLY new migration revision (the local-only accounts trigger).
## Approach (from the settled architecture — realize it, do not redesign)
- **Accounts ≤ 3.** `routes/accounts.py:create_account` (≈`:684`) has no count check. When `IS_LOCAL`, call `local_caps.require_account_headroom(session)` (new module `apps/api/app/local_caps.py`; keep `edition.py` import-free) before the insert: lock the org row (`org_limits.lock_org`), count ALL `accounts` rows (any state or provider, S4), raise 409 with the cap payload.
- Second CP check: a new migration creating a `BEFORE INSERT` trigger on `accounts`, created only when local (use C1's `migrations/_edition.py`; call its function at migration time), with the literal 3 in its body — catches seed/portfolio/any other insert path. Pick an off-round revision minute and re-check `apps/api/migrations/versions` for id collisions at final push (parallel runners collide on round ids).
- **Agents ≤ 10.** When `IS_LOCAL`, `org_limits._limit` for `runner_slots` yields a `Limit` with cap `LOCAL_MAX_AGENTS`; `effective_limit` (`org_limits.py:260`) returns it. The existing `DispatchBudget` (`org_limits.py:342`, used by the placer in `routes/fleet.py` ≈`:819-821`) already holds over-cap ready tasks back as `ready` — the S5 queue. `runner_slots` already counts advisor and console sessions.
- **Cap payload** (one shape, web renders it): refusal `409 {"detail": "<copy per ui-copy-standards>", "kind": "accounts"|"agents", "limit": int, "upgrade_url": str}`.
- **Status:** `GET /api/local/limits` → `{"accounts": {"used": int, "cap": 3}, "agents": {"running": int, "cap": 10, "queued": int}, "node": {"online": bool, "last_seen": str|null}, "upgrade_url": str}`; `queued` = DispatchBudget's held-back count from the last pass; `node` = the local node heartbeat (AC-9's "online" surface). Pinned contract — C6 consumes it.
- **Console terminal at cap:** a user-started console session at the cap is refused with the 409 cap payload (`kind: "agents"`); advisor chats are tasks and queue.
- Copy follows `docs/standards/ui-copy-standards.md` (states the limit, names Jaah Cloud).
Full documents: the epic's settled `prd` and `architecture` artifacts (Tasks 10080/10081).
Credentials & reach:
- AC-3, AC-17 grep, AC-8 migrations, C1–C6 unit and gate checks — nothing beyond the worktree (plus GitHub Actions for `local-edition-check.yml`) — **verified**: the workflow runs in this repo's existing CI
Out of scope:
- Do NOT touch the node (jaahd) layer or the lease-return/`capacity` verdict path (C5 owns both sides of it).
- Do NOT edit the manifest `include` (C2's glob picks `local_caps.py` up).
- Do NOT add bootstrap, pool or per-account runner settings (C3), the router split (C2) or UI (C6).
- The caps must not be settable by any setting, env var, flag, CLI or UI-editable DB row (AC-17). Cloud plan caps and behaviour unchanged when `IS_LOCAL` is False.
PRD §7 (verbatim) — the local edition, and the work to build it, MUST NOT:
- Change the behaviour, UI or data model of the cloud deployments (dev, `dev.jaah.ai`; prod, `app.jaah.ai`). Any edition switch must default to today's behaviour there.
- Ship, in the local edition or in the public repo: Stripe or billing; SES or system mail; Slack or Telegram; headscale or tailnet; cloud node launch (`apps/api/app/cloud_node_launch.py`); node groups (`apps/api/app/node_group_reconcile.py`); Multi-Node Hosts (`apps/api/app/vnodes.py`); NodeLink (`apps/api/app/nodelinks.py`); MicroVM (`docs/policies/microvm-nodes.md`); Jarvis (`apps/site-widget/`); the support knowledge base (`apps/api/app/support_kb/`); project memory (`apps/api/app/project_memory/`); SSO, SCIM or multi-org tenancy; the mobile app (`apps/mobile/`); GitHub Actions self-hosted runners (`infra/host/gha-runners/`); `infra/jaah/` (Terraform) and `infra/host/`.
- Require pgvector, Redis, S3/MinIO, or any cloud account.
- Phone home: no telemetry, no version check, no licence or activation server.
- Accept community PRs into the public repo, or require a CLA.
- Offer data export from local to Jaah Cloud, or attaching the local node to a cloud org.
- Rewrite the control plane for SQLite, or install the control plane natively without Docker.
- Expose any port beyond loopback, or support more than one machine.
- Make the caps (3 accounts, 10 concurrent sessions) configurable by any setting, env var, flag or UI.
Done means:
- **AC-15 (R7). At most 3 accounts in total.** "Accounts 1–3 succeed in any mix of providers and auth kinds. The 4th is refused with copy that states the limit (3) and links to Jaah Cloud. States, each of which must hold: (i) 3 enabled accounts → the 4th is refused; (ii) 2 enabled and 1 disabled → the 4th is still refused (S4); (iii) delete one of 3 → a new one succeeds; (iv) the API path (`POST` directly) is refused the same way as the UI path." — verify: local-mode API tests for (i)–(iv) with a mix of providers/auth kinds; payload carries `kind`, `limit: 3`, `upgrade_url`; plus a test that a direct SQL insert of a 4th row is refused by the trigger; plus a concurrency test (two simultaneous creates at 2 → exactly one succeeds).
- **AC-16 (R8), CP side. At most 10 concurrent agent sessions.** "queue 12 independent tasks (and, separately, an epic whose plan fans out to more than 10 coders). Observe at most 10 running sessions at any moment… the rest show as queued, then start as slots free… no task fails because of the cap. States: idle; exactly 10 running; 10 running plus queued; a session ending frees a slot." — verify: placer test in local mode with 12 ready tasks → 10 placed, 2 stay `ready`, `/api/local/limits` reports `queued: 2`; ending one session places one more; none `system_failed`. Console session at 10 → 409 `kind: "agents"`. (End-to-end on a real install: C7b.)
- **AC-17 (R9), CP layer:** "Each cap is enforced in at least two independent layers, the control plane and the node." — CP layer = route check + DB trigger; verify both refuse independently (disable the route check in a test → trigger still refuses). The cross-layer code-patch oracle runs in C7b.
- `GET /api/local/limits` contract test matches the pinned shape exactly; with `IS_LOCAL` False it returns 404 and account creation has no count check (cloud unchanged) — test.
- Single new alembic head (`alembic heads` shows one); cloud tests unchanged; `./dc check quick` green.
Outcome
Local CP caps: ≤3 accounts (route check + local-only AFTER INSERT trigger), ≤10 agents (literal runner_slots cap, over-cap tasks queue, console 409 cap payload), GET /api/local/limits. Quick gate green; review lane pass; PR #6940 queued in merge lane. No UI surface. Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)