Transfer engine with project move
## Epic requirement (verbatim)
> Allow Operator to change ownership of everything. If he changes the ownership of a project - the project and all tasks and everything related moves to another org.
>
> Operator can see everything. He has a global org filter. Make it a state of the art method of managing - do a deep web research
Full settled specs: the PRD (task #5681) and the Architecture (task #5682) of epic #5680 — read them with `task_read` / `task_read_artifact`. This body carries the parts that bind THIS child verbatim.
## Requirements this child serves (PRD, verbatim)
- R1.2 (server half) "Every move follows the same flow (state-of-the-art pattern, §7): 1. **Target picker** — searchable list of orgs by name+slug; the current org is excluded; the operator-plane org is not a target for tenant data (refused with a reason). 2. **Pre-flight manifest** — computed by the server before anything changes, grouped as: *Moves* (counts per child type, expandable list), *Released* (worktree slots, R2.4/R3.2), *Left behind* (shared dependencies, R2.5), *Dropped* (bindings that cannot cross, R2.6), *Blockers* (if any — move button disabled with the reason). Names, not ids. 3. **Confirmation** — the operator types the entity's name; the dialog states \"from <org A> to <org B>\" in words. 4. **Result** — success shows what moved and links to the entity, now showing org B; failure shows the server's reason and changes NOTHING (all-or-nothing)."
- R1.3 "Plan limits of the target org are NOT checked (Q7: ignore limits). The manifest may display the target's counts, but never blocks."
- R1.4 "Every move writes one audit record (actor, entity, from-org, to-org, manifest summary, timestamp) visible in Audit to the operator and in BOTH orgs' activity for their owners/admins. No email."
- R1.5 "Identity and URLs are stable: the moved entity keeps its id, slug (if unique in the target; otherwise the move is blocked with \"rename first\") and every deep link to it keeps working."
- R1.6 "Moving back is the same action. Things released/left behind/dropped are not restored by moving back (stated in the manifest)."
- R2.1 "Moves with the project, atomically: all its tasks (epics, children, any state), their comments, mentions, reactions, attachments, questions/Q&A, body versions, dependencies, ratings, tags, PR stats, environments, agent sessions & transcripts, run telemetry, artifacts; features; project env vars; project memory; project-scoped role bindings to groups/users that exist in the target org (else R2.6); project placements/node-group links (re-pointed per R2.4); crons, automations and triggers that target ONLY this project; credentials bound ONLY to this project. After the move no row reachable from the project names org A — including the unkeyed references `rehome.py` names (`tasks.project_id`, `task_tags.tag_id`): a tag used only by this project's tasks moves; a tag shared with org-A tasks is left behind and its link dropped from the moved tasks (listed)."
- R2.2 "Billing/usage ledger rows (token usage, ledger entries, invoices, billing notices) stay with org A — usage already billed belongs to whoever paid. Usage after the move accrues to org B."
- R2.3 "Live tasks (active / awaiting_response / paused) do NOT block the move (Q5: allow live). A running runner keeps running to completion on its current old-org node; its task already belongs to org B; its settle, artifacts, telemetry and transcript land in org B. Every dispatch AFTER the move targets org-B capacity only."
- R2.4 "Worktrees on old-org nodes (Q2: release + re-place): every slot of the project on an org-A node that has no live runner is released by the move (committed work stays on origin; uncommitted work in those trees is discarded and the manifest says so per slot). A slot with a live runner is released when that runner ends. The project's org-A node placements/node-group links are removed. The project then needs placing on org-B nodes; until it is, its Start shows the existing \"no node\" reason. Placement is the operator's normal project/node edit, not part of the move."
- R2.5 "Shared dependencies (Q3: leave behind): a credential, cron, automation, trigger, tag, node group, playbook or account that the project uses AND that anything staying in org A also uses stays in org A. The moved project's link to it is removed; the manifest lists each one under *Left behind* with what still uses it, and the moved project shows the missing binding afterwards."
- R2.6 "Bindings that cannot cross (a project role binding to an org-A user or group) are dropped and listed under *Dropped*."
## Approach (Architecture C-B + C-F + audit; read §3 C-B in the Architecture artifact for the full project rule)
- New `apps/api/app/tenancy/transfer.py`: rule table (`register(kind, KindRule)`), policies `Follow | Exclusive | Repoint(fallback) | Detach | Drop | Erase(intent) | Block(code,message) | Stays(reason)`, `plan()` (read-only → Manifest), `execute()` in ONE transaction: 1 lock (`FOR UPDATE` root, `FOR SHARE` project tasks); 2 re-plan under lock (fingerprint differs → 409 `plan_changed`; blockers → 409 `blocked`); 3 Repoint/Detach/Drop/Erase-row deletes; 4 `rehome_set`; 5 `port_block` re-allocated via `routes/projects.py::_next_free_port_block`; 6 carry-over grants (`carryover.grant` per live task); 7 erase intents (`slot_destroy.enqueue_erase`); 8 audit; 9 commit, then notify nodes. `lock_timeout` 5s, `statement_timeout` 120s; deadlock `40P01` and lock timeout → 409 `busy` (D24, with a test).
- **Project rule** exactly as Architecture C-B "Project rule (R2)" lists it (Follow / Exclusive / Repoint / Detach / Erase / Block / Stays), incl. D14 (system project → Block), D16 (task edges to staying tasks → Block listing each pair), D2 (paused slots just released), D23 (group bindings always dropped), D9 (`features.lead_id` → target owner, listed "reassigned").
- **Satisfiability + totality test** `apps/api/test_transfer_rules.py`: every `Follow` checked against `all_org_keys()`; totality = equality both ways with the tenant-owned FKs reachable from each root, read off `Base.metadata` the way `test_the_unkeyed_tenant_references_are_the_ones_the_gap_register_declares` (`apps/api/test_tenancy_composite_keys.py`) reads it.
- **Fingerprint (D17)**: hash of blockers, left_behind, dropped, reassigned, released slot identities, ids of top-level moved objects — NOT task children/telemetry/comments/activity.
- **Name clashes**: per-org unique names among moving top-level objects → `Block("rename first: <name> exists in <org B>")` (D8). Operator plane as target → Block (except cron/automation).
- **API** `apps/api/app/routes/org_transfer.py` (new): `GET /api/operator/transfers/kinds`, `POST /api/operator/transfers/plan`, `POST /api/operator/transfers`; `Access.SUPERADMIN` + `Tenancy.OPERATOR` (is_operator only); `confirm_name` checked server-side.
- **Audit**: two `activity_events` rows of kind `org_transfer` (`activity_kinds.py`), one in A and one in B, sharing `data.transfer_id`; the operator Audit list (`routes/audit.py`) collapses rows sharing a `transfer_id` into ONE entry ("<entity> moved A → B") (D10).
- **ADR-0158** (new register entry in `docs/adrs/README.md`): operator org transfer — rule-table engine, single transaction + durable intents, carry-over.
Interface contracts (Architecture §4, verbatim — the node, object and dialog children build on these; do not change them):
```
GET /api/operator/transfers/kinds -> {kinds: Kind[]} # registered kinds only
POST /api/operator/transfers/plan in {kind, id, target_org_id} -> Manifest
POST /api/operator/transfers in {kind, id, target_org_id, fingerprint, confirm_name}
-> 200 {transfer_id, manifest} | 409 {reason: "plan_changed"|"blocked"|"busy", manifest?}
| 403 (not operator) | 422 {reason: "confirm_name"|"operator_plane"|"same_org"}
Manifest = {kind, id, name, from_org:{id,name,slug}, to_org:{id,name,slug},
moves:[{type,label,count,names:string[<=50]}],
released:[{node,slot,task_id?,task_title?,uncommitted: bool|null}],
left_behind:[{type,name,still_used_by:string[]}],
dropped:[{type,name,reason}], reassigned:[{type,name,from_person,to_person}],
blockers:[{code,message,link?}], notes:string[], fingerprint}
Kind = project|node|host|credential|cloud_connection|account|node_group|cron|automation|trigger|playbook
Activity kind "org_transfer", data {transfer_id, kind, entity, from_org, to_org, counts}
@dataclass KindRule: root: type[Base]; refs: Mapping[str, Policy] # "<table>.<col> -> <table>"
label: Callable[[row], str]; name_of: Callable[[row], str]
blockers: Sequence[Callable[[Session, object, UUID], list[Blocker]]] = ()
@dataclass Blocker: code: str; message: str; link: str | None = None
Policy = Follow() | Exclusive() | Repoint(fallback: "drop"|"null"|"target_owner") | Detach() | Drop()
| Erase(intent: str) | Block(code: str, message: str) | Stays(reason: str)
transfer.register(kind: str, rule: KindRule) -> None # children 4/5 add kinds
transfer.plan(session, kind, id, target_org: UUID) -> Manifest # pydantic, = the JSON above
transfer.execute(session, kind, id, target_org, fingerprint: str, actor: AdminUser) -> Result
@dataclass Result: transfer_id: UUID; manifest: Manifest
```
`GET /api/operator/transfers/kinds` lists `project` after this child; the node/host and object children register theirs.
## Acceptance criteria (PRD, verbatim; this child proves them at the API level — the browser walk of each is the "Change-org dialog" child's)
- AC2 (server) "The dialog shows target picker (no current org, operator plane refused), a manifest with all five groups and counts that match the DB, typed-name confirm, and \"from A to B\" in words. The button is disabled while the name differs or a blocker exists." → server: manifest groups+counts match the DB; operator plane/same org → 422; wrong `confirm_name` → 422.
- AC3 "Project move: after it, the project, every one of its tasks (each state: draft, ready, active, awaiting_response, paused, system_failed, done), features, comments, attachments, sessions/transcripts, artifacts, env vars, memory and project-only crons/automations/triggers/credentials show org B; filtering by A shows none of them, filtering by B shows all; deep links still open."
- AC4 "No row left naming org A reachable from the moved project (unkeyed refs included)." Verify: "API test over every table linked to the project asserts `org_id = B` or row listed as left behind/dropped."
- AC5 "A live task during the move: its runner finishes; settle, report and transcript appear on the task in org B; the next dispatch of any task of that project goes only to an org-B node."
- AC6 "Released slots: the project's idle slots on org-A nodes are gone from Worktrees; committed branches remain on origin; the manifest named each slot and its uncommitted-loss status. A live slot is released after its runner ends."
- AC7 "Shared credential/automation/tag used by another A project stays in A, still works for that project, is listed as *Left behind*, and the moved project no longer lists it."
- AC8 "Billing ledger rows before the move stay in A's billing; new usage after the move is B's."
- AC12 "Target-limit overflow never blocks (B at its project limit still receives the project)."
- AC13 "Every move appears once in Audit with actor, entity, from, to, summary, and in both A's and B's activity for their admins."
- AC14 (project) "Failure is all-or-nothing: a blocked/failed move leaves every row in A and the UI states why." (Force a blocker, e.g. slug clash in B → nothing moved.)
- AC13 detail (critic): the activity middleware already writes its own `activity_events` row for every mutation — fold that row into the `transfer_id` collapse (or exclude it) so the operator Audit shows the move exactly ONCE; add the hand-written `org_transfer` audit site to `KEPT_RECORD_WRITE_SITES` (enforced by `tests/test_activity_kinds.py`).
- R1.6: the manifest `notes` always state that moving back does not restore released, left-behind or dropped items.
- Plus: a tenant superadmin / org owner calling any transfer route → 403; `plan_changed` and `busy` (incl. deadlock mapping) tested; the rule satisfiability/totality test passes.
- Quality: manifest entries carry human names (never bare ids) and an operator can read each group's meaning without the code.
Credentials & reach (Architect §8, verbatim rows: oracle | credential/identity | status):
- AC4 and unit/integration tests | worktree only (`./dc check full`) | n/a
- AC5 (next dispatch on an org-B node), AC8 (new usage accrues to B) | Org B runner capacity on dev, via the operator login | **verified**: Org B owns 2 nodes, one online with 1 runner account and runner CLIs, one offline
- AC6 committed branches remain on origin | `jaah-forge` (this node's authenticated forge CLI) on `jaah-ai/jaah` | **verified**: `jaah-forge api get repos/jaah-ai/jaah/branches` returned branch data
Out of scope:
- Do NOT add node/host rules or touch the `nodes.org_id` trigger (the node child). Do NOT add the 8 standalone kinds (the objects child).
- Do NOT build any web UI (the dialog child).
- Do NOT change the tenant-facing cron org re-file (D13) or `ON UPDATE CASCADE`.
- Do NOT move billing/usage ledgers (`Stays`).
- Do NOT change `seam.py` (the filter child owns it) or the carry-over internals (the carry-over child) — call `carryover.grant` only.
PRD §5 (verbatim, binds every child):
- Do NOT add user/membership moves (Q1 not selected) or change `admin_users.org_id` writes.
- Do NOT change the account-menu org switcher (`components/account/OrgSwitcherList.tsx`) or `/api/auth/switch` semantics, or the stale-tab guard (`lib/activeOrg.ts`, `stale_tab_guard.py`).
- Do NOT add impersonation / "act as org".
- Do NOT add plan-limit checks to moves; do NOT touch billing ledgers' org (R2.2) or Stripe objects.
- Do NOT weaken tenant read narrowing for non-operators (`authz.py::_bind_read_scope`); the filter only narrows an operator further.
- Do NOT allow any path other than the operator move to change `nodes.org_id`.
- Do NOT carry a shared dependency or a secret silently into org B; do NOT copy credentials.
- Do NOT add email notifications, bulk move, or scheduled moves.
- Do NOT move offboarding/deletion logic (`tenancy/offboard.py`, `org_delete.py`) — reuse, don't rewrite.
- Nothing ships behind a flag or setting (R6/AC20: "Everything above is ON by default for operators on the dev control plane (dev.jaah.ai) with no flag; no \"ship disabled\".")
Done means:
- AC2 (server), AC3, AC5 (state transfer + carry-over grant written + next dispatch refused on org-A capacity), AC6 (slots erased + intents written; live slot deferred), AC7, AC8, AC12, AC13, AC14 — `apps/api/test_org_transfer_project.py` (seeded two-org DB, one test per AC) and `apps/api/test_org_transfer_api.py` (routes, status codes, R1.6 note) pass.
- AC4 — `apps/api/test_transfer_rules.py` (satisfiability + totality) and an every-linked-table org assertion test pass.
- 403 for non-operators, 409 `plan_changed`/`blocked`/`busy`, 422 `confirm_name`/`operator_plane`/`same_org` — API tests pass.
- ADR-0158 present in `docs/adrs/README.md`; `python3 tools/check_context.py` green.
- `./dc check full` green.
Outcome
Org transfer engine + project move + operator API + audit (ADR-0158) shipped; full gate green, PR #4977 merged, Jaah Deploy success. Deferred: refused moves unaudited; non-live dynamic envs keep their old node.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)