Every workbench @corbits/chat creates is minted as a native child tenant of
the bench it was created in, on Interchange's own parent/child tenant
hierarchy (tenant.parentId). This document describes the model, the seams
@corbits/chat owns because no native route covers them, and the gaps
still open upstream.
A workbench needs its own membership and permissions — who can post, who can
invite an agent, who can move it — distinct from the bench it lives in.
Rather than invent a parallel, workbench-scoped permission system alongside
Interchange's native grants, a workbench is minted as an ordinary tenant: it
gets its own owner/admin/member roles and grants, seeded exactly as a
bench is, so a workbench's membership is enforced with the same @intx/authz
machinery — evaluateGrants, requireGrant — every other tenant boundary
in the platform already uses. The tenant hierarchy (tenant.parentId) then
gives a workbench a bench of origin without needing a second, chat-specific
notion of "which bench owns this workbench."
POST /api/tenants/:tenantId/chat/workbenches (packages/chat/src/routes.ts):
-
Mints a new tenant via
WorkbenchTenancyStore.createWorkbenchTenant(packages/chat/src/workbench-tenancy.ts), parented under the calling bench (tenant.parentId = <bench id>). The child tenant is seeded exactly as the nativePOST /api/tenantsroute seeds one it creates directly — the sameowner/admin/membersystem roles, the same grant shapes — so it is indistinguishable from a tenant created by hand through that route. The mint (tenant, its three system roles, the creator's owner principal and role assignment, every system grant, and theworkbench_tenancylink) runs as one transaction, so it either lands complete or not at all — there is no partially-seeded tenant to observe. -
Seeds the child tenant's
ownerprincipal for the creator's own auth user id (principal.refId), so the creator is a first-class native member of the workbench's own tenant, not just of the parent bench. -
Records the parent↔child link in
workbench_tenancy(packages/chat/src/schema.ts), the table this package owns for exactly this purpose. -
Launches the workbench host and stores
workbench_settings/workbench_launchunchanged from before this feature: both remain scoped to the parent bench's tenant id, not the new child tenant. The mint and the launch are two separate steps against separate machinery, so ordering them alone does not prevent an orphaned tenant if the launch fails:POST .../chat/workbencheswraps the launch call and, on failure, callsWorkbenchTenancyStore.compensateWorkbenchTenantto delete the freshly-minted tenant (and everything cascaded onto it) before re-raising the error. Both the failure and the compensation are logged loudly.Compensation is itself a database write, and can itself fail — the same outage that failed the launch, for instance, can just as easily fail the cleanup. That double failure is caught separately: a compensation error is logged loudly on its own, tagged with the orphaned tenant id for an operator to clean up by hand, and the route always re-raises the original launch error, never the compensation error. A caller never sees "cleanup failed" in place of "your workbench failed to launch," and a double failure never produces a silently swallowed exception — the accepted cost of a double failure is one privileged orphan tenant sitting in the database with a loud log line pointing at it, not data loss or a hung request.
Point 4's tenant/launch split is a deliberate, documented limitation — see "What still lives in the parent bench" below.
No native hub-api route lists a tenant's children (tenant.parentId is
stored but never queried by any route in vendor/intx/hub-api). Rather
than leave "which workbenches are child tenancies of this bench" unanswerable,
@corbits/chat owns the answer from its own workbench_tenancy table via
WorkbenchTenancyStore.listChildWorkbenchTenancies.
GET .../chat/workbenches annotates every row from workbench_settings (still
queried by the same tenantId scope as before) with its workbench_tenancy
link, when one exists:
-
tenancy: { tenantId, parentTenantId, slug },legacy: false— a workbench created after this rollout, with a real child tenant behind it. -
tenancy: null,legacy: true— a legacy workbench: one created before workbench tenancy existed, with no tenant of its own. This branch and thelegacyfield are filed for removal once every workbench in a production database has been backfilled a tenancy; until then, dropping it silently would be exactly the kind of fallback AGENTS.md forbids, so it stays explicit in the route and in this document instead. The chat sidebar (packages/chat-ui/src/sidebar.tsx) surfaces it too: a legacy workbench's row carries a muted "Legacy" badge alongside its title, so the marker is visible to a human, not just present on the wire.Backfilling every legacy workbench a tenancy — so this branch, the
legacyfield, and the badge can all be deleted — is tracked as a follow-up item on CL-5647's list, not scheduled here.
POST .../chat/workbenches/:id/move re-parents a workbench's tenancy to a new
bench. There is no native PATCH for a tenant's parentId, so the move
updates two places, both owned by this package's own service code, in one
transaction (WorkbenchTenancyStore.moveWorkbenchTenancy):
- The
workbench_tenancylink row (parentTenantId) — the sourcelistChildWorkbenchTenanciesreads. - The native
tenant.parentIdcolumn itself, directly, through@intx/db's published schema (import { tenant } from "@intx/db/schema") — this is ordinary consumption of a published table export, not a fork ofvendor/intx/hub-api's tenant routes.
A workbench with no workbench_tenancy link (a legacy workbench) cannot be
moved; the route returns 409 conflict rather than silently no-op'ing.
The destination is verified and the move is written by the same call,
inside the same transaction — WorkbenchTenancyStore.moveWorkbenchTenancy
takes the caller's refId alongside the workbench and destination ids,
and re-checks the destination from inside its own transaction rather
than trusting a decision an earlier, separate read made:
newParentTenantIdmust name a tenant that actually exists — a bogus or fabricated id is rejected with404 not_foundrather than being written straight intoworkbench_tenancyandtenant.parentId.- The caller must hold an active principal in that destination tenant
carrying a manage-level grant, evaluated with the same
@intx/authzevaluateGrantslogicrequireGrantitself resolves against, against the destination tenant rather than the caller's own. A caller with only standing in the workbench's current bench — including the bench that currently owns the workbench — cannot move it into a tenant it has no authority over; that request is rejected with403 forbidden.
Both checks run under row locks (SELECT ... FOR UPDATE) taken on the
destination tenant row, the caller's principal row in that tenant, and
every grant row that could resolve the decision — all inside the
transaction that goes on to perform the two writes. This is not a
"check, then separately act" sequence: a grant revocation or a
destination-tenant deletion committed by another transaction blocks on
these locks until the move's transaction finishes, rather than landing
in a window between the check and the write and letting the move
proceed on since-revoked authority. createDrizzleWorkbenchTenancyStore
is the only implementation of this — the row locks, and the real
@intx/authz evaluateGrants call, only exist on the Postgres-backed
store, never on the in-memory test double — and test/isolation's
"chat workbench move" suite drives it to a real { kind: "moved" }
outcome end to end against Postgres, then re-reads both
workbench_tenancy.parent_tenant_id and tenant.parentId fresh to
confirm the writes actually landed, rather than trusting the response
body alone.
A third check, structural rather than authorization-based, runs
alongside these two: newParentTenantId cannot be the workbench's own
tenant, or descend from it. moveWorkbenchTenancy walks the
destination's ancestor chain — under the same row locks, inside the
same transaction — looking for the workbench's own tenant id; finding it
means completing the move would make the workbench its own ancestor, so
the move is rejected with { kind: "cycle" } (409 conflict over
HTTP) regardless of what grants the caller holds. tenant.parentId is
a plain self-referencing foreign key with no cycle constraint of its
own — Postgres will happily store a self-parent or a longer loop — so
this check is the only thing standing between a hierarchy this document
describes as a tree and one that, left unguarded, could become
cyclic. Nothing today walks parentId upward except this check itself,
so a cycle is not exploitable yet, but any future code that does
(breadcrumbs, an ancestor-scoped query, an admin tenant tree) would
infinite-loop on a cyclic tree with no way to detect how it got there.
workbench_tenancy carries a PRIMARY KEY (workbench_id) and a
UNIQUE (tenant_id), but listChildWorkbenchTenancies filters on
parent_tenant_id, a column neither constraint indexes. Migration
0006_workbench_tenancy_parent_index adds
workbench_tenancy_parent_tenant_id_idx on parent_tenant_id so that
read stays an index scan as the table grows, rather than a sequential
scan.
GET .../chat/workbenches itself reads tenancy by workbench_id (the
primary key) instead, one call per listed row: a bench's workbench
listing is scoped to workbench_settings, which keeps a workbench's row
forever in the bench it was created in even after a move, so annotating
those rows by "children of this bench" would go stale the moment a
workbench moved elsewhere and wrongly report it as legacy.
Minting a workbench tenant is not a single row. createWorkbenchTenant seeds a
tenant, its three system roles, one owner principal, one principal-role
assignment, and five system grants (one on owner, three on admin, one
on member), plus the workbench_tenancy link — a dozen native rows for
every workbench created, on top of the workbench_settings and
workbench_launch rows chat already wrote before this feature. A workspace
with heavy workbench churn multiplies its tenant/role/principal/
grant row counts accordingly; nothing about the mint amortizes this
across workbenches, since each one needs its own independent grant surface.
A tenant can be left behind with no workbench pointing at it in exactly one
window: the mint's transaction commits, then the process dies before the
workbench host launch call returns — there is no automated sweep for this
case, only the loud workbench-tenancy log line compensateWorkbenchTenant
already emits for the double-failure case (mint succeeded, launch failed,
and compensation itself failed). Either way, an orphaned tenant is a
tenant row with no matching workbench_tenancy.tenant_id and no
workbench_settings row referencing it — reachable by diffing
workbench_tenancy against tenant.parentId for the bench in question. It
carries no workbench state, only its own owner principal, roles, and grants,
so deleting it (cascading through role, principal, principal_role,
grant) is safe once confirmed orphaned; there is no data recovery
question, only cleanup.
A workbench's timeline (chat.workbench_messages, CL-6327) is no longer
mail addressed to a run, so its routes no longer gate on a workflow-run
grant either (CL-6346): GET/POST .../messages, the thread-read and
delivery-thread routes, block responses, reactions, pins, read-state,
typing, presence, and the SSE stream all check idResource("room", "id")
instead —
a plain rename in the grant grammar, not a new grammar feature, since
@intx/authz's pattern matching already treats a resource type as an
arbitrary "<type>:<id>" string; the system owner/admin/member
grants every tenant (bench or workbench) is seeded with already match it
via their resource: "*" wildcard. Route-level lifecycle actions —
settings, invite, participant removal, move, shares — are unaffected and
still gate on workflow-run, since those remain about the workbench host's
own run, not the room.
That coarse per-route grant check answers "can this principal act on
some room in this tenant at all"; it says nothing about which
workbench. resolveWorkbenchAccess (packages/chat/src/routes.ts) is
the finer-grained authz check every message route also runs, and as of
CL-6346 it folds in the workbench's own visibility setting
(chat/visibility, "bench" | "members", defaulting to "bench"):
"bench"(the default) — every principal already authenticated in the owning bench's tenant may open the workbench; reaching the check at all already proves that."members"— only a principal the workbench's own child tenant has minted may open it. Membership is native principals, never a bespokeworkbench_membertable (CL-6332): whoever is invited gets a real principal row in the workbench's own tenant (the creator already does, fromcreateWorkbenchTenant's owner mint), andWorkbenchTenancyStore.getTenantPrincipalByRefIdresolves whether the caller's own auth identity (principal.refId, stable across every tenant it holds a principal in) is one of them.
A legacy workbench with no workbench_tenancy link has no child tenant to
check membership against, so it stays bench-visible regardless of its
chat/visibility setting — the same "no fallback, stay honest" treatment
GET .../chat/workbenches's legacy field gives it elsewhere in this
document. Invite flow and chat/participants trimming onto this same
membership model are Phase 1.6's own slice (CL-6332), not this change's.
The workbench host's workflow run, its workbench_settings row, and its
workbench_launch row all remain scoped to the parent bench's tenant id,
even after the child tenant exists. Re-homing the launch itself into the
child tenant would mean passing the child tenant id through
@corbits/folded-runs' launch, session-resolution, and credential/catalog
resolution paths — machinery this rollout was explicitly scoped not to
reach into (packages/folded-runs internals are off-limits here). The
child tenant that exists today is therefore a real, native, correctly
parented tenant — with its own owner principal, roles, and grants — that
currently serves as the workbench's identity and listing anchor, while the
running instance continues to resolve against the parent bench's
inference/session/credential context.
Moving the launch itself into the child tenant is future work, gated on
@corbits/folded-runs exposing a tenant override for
launch/session/catalog resolution without requiring changes to its
internals.
Carried over from the platform tenancy inventory, and specifically relevant to this feature:
- No native child-tenant listing route.
tenant.parentIdis stored but no route ever queries it;WorkbenchTenancyStoreexists because of this gap. - No native
PATCHfor a tenant'sparentId. The move route updates the column directly through@intx/db's schema instead. CreateTenant.parentIdis unvalidated server-side on the nativePOST /api/tenantsroute — a caller can name any string as a parent, a gapworkbench-tenancy.tsdoes not introduce but also does not close, since it mints the child tenant directly rather than going through that HTTP route.- The move route does verify
newParentTenantId, unlike the native creation route.WorkbenchTenancyStore.moveWorkbenchTenancychecks the destination tenant exists and that the caller holds a manage-level grant there, inside the same transaction that performs the move (see "Movability" above) — a stricter contract thanPOST /api/tenants's unvalidatedparentIdenforces. Tightening the native route to match is future work upstream, not something this package can do from the outside.