refactor(rooms): move multi-agent rooms into OSS core (ent#443) - #2355
Conversation
Rooms were the entitled `shared_sessions` module, 404ing in community builds — while the frontend that drives them (`components/rooms/`, `stores/rooms.js`, the ent#392 composer typeahead) and the MCP tools (`tools/rooms.ts`) shipped in EVERY build and self-disabled. Three of four surfaces were already public, so gating only the backend left an OSS install rendering an affordance it then refused. Workspace itself moved for the same adoption reason (ent#356); rooms are the half that makes it the place people work with agents rather than a second 1:1 chat. The public docs had already drifted ahead of the code: the Workspace guide presents `@`-mention → room as the ordinary continuation of a 1:1, while the FAQ called multi-agent chat "an enterprise capability". Both are live on docs.ability.ai; this makes the guide true and corrects the FAQ. Engine ported VERBATIM — mention-wake turn-taking, per-room budgets, chain depth, wake caps, the ent#218 overshoot rule and the ent#220 cancellation shield are unchanged. A port that also fixes things is a port nobody can review. Adoption, not creation: * DDL to `db/schema.py` + `db/tables.py`, versioned on the OSS two-track runner (`shared_sessions_tables_to_oss` + Alembic `0042_shared_sessions_oss`). Both tracks are CREATE TABLE IF NOT EXISTS, so the migration is a NO-OP on every entitled install — no data migration, no lost transcripts. * Table names keep the `enterprise_` prefix. Renaming them IS the data migration this forbids; the prefix is provenance, not a licensing claim (ent#356). * The enterprise Alembic `0011_shared_sessions` stays on its own line — `0012` revises it, and deleting an applied revision is a PG boot failure (ent#431). One deliberate behaviour change, and it fixes a latent bug the move surfaced: `participants.identity` / `messages.sender_identity` are now in AGENT_REFS, so a rename re-keys and a purge cascades. Before this, rooms sat outside the registry (it covers OSS tables), so a renamed agent silently stopped being woken — its participant row still named the old agent, which is what mention resolution matches against — and a purge orphaned participants and transcript. Both columns are POLYMORPHIC, so each ref carries a `kind` predicate: unscoped, a rename would rewrite (and a purge DELETE) a human participant whose user id or verified email happened to equal the agent's name. The forward parity regex cannot see either column, so `test_agent_cleanup_parity` gains a documented `_POLYMORPHIC_AGENT_COLUMNS` set for the backward direction and `test_ent443_rooms_oss_core` pins the predicates explicitly. Transition safety: the OSS routers are included BEFORE `register_enterprise(app)`, so on an install whose submodule is not yet bumped both mount and the ungated OSS one wins the match order. Pinned by test, source-asserted so a reorder fails loudly. `multi_agent_chat_available` is now unconditionally true but STAYS on the roster: it is the portal's only capability channel (#2128) — a portal principal cannot read `/api/settings/feature-flags` — and the shipped bundle gates the picker, five room store actions and `/workspace/r/:roomId` on it, so deleting it would hide the feature this move exposes. Tests: five suites ported verbatim (94 pass), `test_2128` rewritten around the new unconditional answer, new `test_ent443_rooms_oss_core.py` (16) covering mount, ungated first-match, no feature-id registration, both migration tracks, and the kind-scoped rename contract. Companion PR in trinity-enterprise deletes the module; the submodule bump follows its merge. Related to ent#443. Blocks ent#442. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ec9f98f to
a2ed79f
Compare
|
Rebased onto The Alembic chain had to move with the base. The SQLite conflict resolved the same way: this branch no longer carries
Re-verified on this base (nothing below was inherited from the dev-based run):
One regression this surfaced and fixed: Known cosmetic during the transition: with a not-yet-bumped submodule both routers mount; route matching is first-wins (OSS serves, proven at runtime under |
`enterprise-docs-guard` failed on the architecture.md section documenting the move: the pattern forbids `enterprise_[a-z_]+` in public docs, and naming `enterprise_rooms` / `enterprise_room_participants` / `enterprise_room_messages` reads as a private-schema disclosure. It isn't one any more, and the guard already carries this exact carve-out for ent#356's portal tables: those names moved onto the OSS track and their `enterprise_` prefix is retained history, because renaming them would be a data migration on every existing install. The room tables are the same case one module later, so they join the same allowlist with the same stated reason. Listed one by one rather than as a `room` prefix. The exemption should cover the three tables that actually moved — a future private `enterprise_room_*` table must still trip the guard, and it does (verified: `enterprise_room_secrets` still matches, `enterprise_rooms` no longer does). Related to ent#443. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe
left a comment
There was a problem hiding this comment.
The port itself reads well — the verbatim-port discipline, the CREATE TABLE IF NOT EXISTS adoption on both tracks, keeping enterprise 0011 on its own line, and the polymorphic AGENT_REFS scoping with colliding-human-row tests are all the right calls, and the FAQ/guide correction is overdue. Sending it back for four things, two of which are landing blockers.
1. Blocker — the prod image does not boot: shared_sessions/ is not in the backend Dockerfile
prod-image-smoke is red, and it is a real failure, not flake:
File "/app/main.py", line 128, in <module>
from shared_sessions.router import budget_router as room_budget_router
ModuleNotFoundError: No module named 'shared_sessions'
docker/backend/Dockerfile copies backend packages via an explicit per-directory list (routers, services, adapters, utils, db, canary, client_portal, migrations); the COPY src/backend/*.py glob above it catches loose top-level modules only, not packages. shared_sessions/ is a new package and is not on that list — and this PR does not touch docker/ at all.
main.py imports it unconditionally at module scope, so this is a crash loop on boot in production, not a degraded surface.
The precedent is already in the file, four lines above where the fix goes — written for client_portal during ent#356, the very move this PR follows:
# Workspace / client portal (ent#356). A new top-level backend package must be
# listed here or the image builds fine and the app dies at import — `main.py`
# does `from client_portal.router import ...` and the container has no such
# module (#1033's class). `prod-image-smoke` is what catches it; source-only CI
# cannot, because the source tree always has the directory.
COPY ../../src/backend/client_portal /app/client_portal/One line beside it:
COPY ../../src/backend/shared_sessions /app/shared_sessions/Worth checking whether anything else needs the same treatment before re-running.
2. Blocker — Alembic branch point: this merges two heads into dev
0042_shared_sessions_oss declares down_revision = "0041_secret_settings_encryption". But dev already carries 0042_agent_ownership_operator_resume off that same parent, with 0043_subscription_headroom_history chained onto it. Both revisions become heads the moment this lands.
I reproduced it by dropping this PR's revision file into a copy of dev's versions/ and running the repo's own guard:
alembic-heads: FAIL — resolves to 2 heads across 45 revision(s); exactly 1 is required.
• 0042_shared_sessions_oss (0042_shared_sessions_oss.py)
• 0043_subscription_headroom_history (0043_subscription_headroom_history.py)
They fork at: 0041_secret_settings_encryption
upgrade head is singular and resolves its target before applying anything, so the graph applies zero revisions — 0042_agent_ownership_operator_resume and 0043_subscription_headroom_history stop arriving too, not just this one. Git reports no conflict, because each file is individually valid.
The PR body says check_alembic_heads is green, and it is — but only against main, where 0042/0043 do not exist yet. That is finding 3 hiding this one.
Fix: rebase onto dev and renumber to 0044_shared_sessions_oss with down_revision = "0043_subscription_headroom_history". Since nothing has applied 0042_shared_sessions_oss anywhere, a plain renumber is correct here — no merge revision needed.
Related, and much less serious: the SQLite list in db/migrations.py was also cut from main, so its tail is missing agent_ownership_operator_resume and subscription_headroom_history_table. That one will conflict loudly on rebase and is name-tracked rather than order-critical, so it should resolve cleanly — just flagging it so it does not get resolved in the wrong direction.
3. Base is main; it should be dev
Per the SDLC, feature work targets dev and only a release PR targets main. As it stands this PR would land 100 files into main outside the release process — its real change set against dev is 25 files; the other ~75 are just everything already sitting on dev.
The more consequential effect is on CI: schema-parity is passing because the base is main. Retarget and it turns red on finding 2, which is the guard doing its job. Retargeting first also means the re-run after the Dockerfile fix is testing the thing that will actually merge.
4. Title is still wip:
Presumably intentional while the above is open — flagging it so it does not survive to the merge.
Merge order
Your body has it right and I want to confirm it rather than have it re-derived later: this PR → trinity-enterprise#444 → the submodule pointer bump. #444 is green and clean and will sit and wait; nothing there needs changing.
Happy to re-review as soon as 1 and 2 are in — the substance of the port is not what is holding this up.
…PY list (ent#443)
`prod-image-smoke` failed:
File "/app/main.py", line 128, in <module>
from shared_sessions.router import budget_router as room_budget_router
ModuleNotFoundError: No module named 'shared_sessions'
The backend Dockerfile globs top-level MODULES (`COPY src/backend/*.py` — #1033's
fix, after redis_breaker_util.py crash-looped the backend) but enumerates
PACKAGES one COPY line at a time. So a new package builds a clean image that
dies at import. The `client_portal` COPY line carries a comment predicting
exactly this; ent#443 is the prediction coming true one module later.
Two changes:
* the missing `COPY ../../src/backend/shared_sessions /app/shared_sessions/`;
* a source-level parity guard, because the only thing standing between this
class of bug and production was a job that builds the entire image and reports
a runtime crash. `test_ent443_backend_package_copy_parity` parses main.py's
module-scope imports and the Dockerfile's COPY lines and fails in
milliseconds, naming the exact line to add. Verified by deleting the COPY and
watching it go red — a guard that has never been seen failing is a guess.
It does NOT replace prod-image-smoke: that job proves the image boots, which no
static check can. This only proves the COPY list has not fallen behind the
import list. `enterprise` is exempt (optional submodule, guarded import).
Related to ent#443.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t#443) `regression diff` caught this: `test_rename_rekeys_every_registered_table` failed under HEAD and not under BASE, and it is a HARNESS gap, not a rename bug. The test seeds one row per AGENT_REFS entry, renames, and asserts nothing is left behind. ent#443 registered the room tables with a predicate — the `identity` / `sender_identity` columns are POLYMORPHIC, so each ref carries `kind = 'agent'` to keep a rename off a human participant whose user id or verified email happens to equal the agent's name. A seeded row that does not satisfy that predicate is skipped BY DESIGN, and then reported as a strand the seeder itself caused. The harness already knew about this shape: it hard-coded `scope = "agent"` for the scope-filtered `mcp_api_keys` refs, with a comment saying a synthetic value "matches no filter, which would look like a strand when it is really this seeder's fault". That special case is now generalized — `_filter_values` parses any ref's `extra_filter` and seeds the columns it requires — so the next filtered ref is covered without touching this file again. Behaviour is unchanged and was never wrong: `test_ent443_rooms_oss_core` proves a rename re-keys the agent participant and leaves the `user` and `workspace_user` rows (seeded with colliding identities) untouched. Related to ent#443. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…x line (ent#443)
Review finding 2: `0039_shared_sessions_oss` shared a numeric prefix with
`dev`'s `0039_operator_queue_addressed_to`, and both chain off
`0038_portal_chat_state`.
The fork itself is not the defect — it is forced. This PR is a hotfix onto
`main`, whose Alembic head IS `0038_portal_chat_state`; 0039-0043 exist only on
`dev`, so chaining off `dev`'s head would name a revision `main` does not have
and fail boot on the line this ships to. `main` must resolve to one head, and it
does: verified with the repo's own guard (40 revisions, 1 head) and with a real
`alembic upgrade head` against PostgreSQL 16 from a narrow (VARCHAR(32))
`alembic_version`, reaching `0044_shared_sessions_oss` with the column widened
to 255. A downgrade leaves all three room tables in place and the re-upgrade
over pre-existing tables is a clean no-op — the adoption contract, exercised.
So what is fixed here is the part that was avoidable: the duplicated numeric
prefix, and the fact that the back-merge obligation lived nowhere.
- Renumbered to `0044_shared_sessions_oss` (parent unchanged, still 0038) so the
filename does not collide with `dev`'s 0039 after the back-merge. Revision ids
are strings, but the numeric prefix is the graph's only human ordering cue,
and a duplicate would be the first in the repo — on exactly the graph whose
failure mode is invisible to git.
- Documented in the revision docstring, architecture.md and a new test: the
back-merge MUST add an `alembic merge` revision with
`down_revision = ("0043_subscription_headroom_history",
"0044_shared_sessions_oss")`. It cannot be added here — it would name
revisions `main` does not carry. `check_alembic_heads` fails that PR loudly
until it exists; the fix there is the merge revision, never renumbering a
revision already applied wherever the hotfix went.
- `test_the_room_revision_chains_off_the_hotfix_line_and_numbers_uniquely`
asserts the parent is 0038 and that no two revision files share a prefix.
Simulated the back-merge to prove both halves rather than assert them: dev's
`versions/` plus this file fails the guard at exactly `0038_portal_chat_state`,
and adding the prescribed merge revision collapses it to one head.
Suites: test_ent443_rooms_oss_core (17 pass), and the
`-k "schema or alembic or parity or migration"` sweep (562 pass, 1 skip).
Related to ent#443.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ic-fork classes (ent#443) Two entries from the #2355 re-review, both third-or-later occurrences. The COPY class has now bitten three times — #1033 (a module, fixed by the `*.py` glob), ent#356 (`client_portal`), ent#443 (`shared_sessions`) — and is invisible to every source-only check by construction, because the source tree always has the directory. #2355 added the static parity guard; the entry records why the guard needs an anti-vacuity anchor and where it is still blind (transitive imports). The Alembic entry records the shape a hotfix migration takes: the single-head guard is green on BOTH branches while the graph is forked, because the fork only exists in the relationship between them. It names the four things that make it safe — chain off the branch you ship to, number past the other branch's head, simulate the merge with the repo's own guard, and exercise an adoption revision by downgrading and re-upgrading over existing tables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-review — all four findings addressed; no blockers remainFindings 1 and 4 are fixed as asked. Finding 2 is fixed, but not the way the review prescribed, and the difference matters — details below. Finding 3 is a deliberate decision rather than a mistake.
1 — Dockerfile (blocker, fixed)
You asked "worth checking whether anything else needs the same treatment before re-running". It does not today, and I made that answer durable rather than a one-time grep — Third occurrence of this shape (#1033 module → ent#356 → ent#443), so it is now in 2 — Alembic (blocker, fixed — but the prescribed fix would have broken
|
…absence (ent#443) Four review findings from the #2355 re-review, none blocking, all cheap. 1. The docs half of the move was incomplete. The PR corrected three lines and left nine, including the feature's own page opening with "**Enterprise feature.**" and a sentence in `sharing-access-and-monetization.md` eight lines below the one it fixed. Since the stated motivation was that the published guide had drifted ahead of the code, a half-corrected guide is the same defect with a smaller radius. Six files updated; the only surviving mentions of "enterprise" beside rooms now say it *used to be* one. Two of those lines were stale twice over: `faq/collaboration.md` and `guides/using-trinity.md` both described reaching rooms through a **Sessions** view that ent#381 retired. Rooms open from the Workspace by @mentioning a second agent, which is what they now say. 2. `rooms.ts` treated any 403 or 404 as "shared sessions are not enabled on this instance". That was true while the module was entitled — an unentitled 403 really did mean absent — and ent#443 makes it false: the rooms module authors 403 for an agent the caller may not reach, and a deliberately uniform 404 for a room it is not a member of. Both were being reported to the agent as a switched-off feature. The status alone cannot answer the question, so the discriminator is now the shape of `detail`: the serving module authors `{code, message}`, absence is a plain string (FastAPI's own "Not Found", or an older build's entitlement sentence). That is the rule the Workspace already follows for the same distinction (#2128) — this is the MCP surface catching up. Everything unrecognised still degrades to the friendly result, so the default is unchanged. `ApiError` retains the raw body for it rather than having callers regex the message, and a non-ApiError keeps the old string test. Seven cases pinned in `rooms-availability.test.ts`, including the coded-404 one, which is the case a status-only rule gets wrong most often. 3. The new COPY parity guard keyed on `main.py`'s imports, so a package pulled in only transitively would still die at import. Every non-exempt package under `src/backend/` is copied today, so asserting that directly is free and closes it; `_EXEMPT` stays as the escape hatch. 4. `router.py`'s gate comment, which this PR rewrote from "Triple-gated" to "Double-gated", kept claiming `require_admin` alone would admit an agent. #1890 / ent#297 moved that rejection into the gate itself. The code is right — `reject_agent_principal` is belt-and-braces — but a rationale this repo treats as load-bearing should not assert something the gate stopped doing. Tests: 511 passed, 1 skipped (`-k "ent443 or 2128 or parity or schema or alembic"`); MCP server 161/161. Related to ent#443. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything about the Workspace lives in OSS. This surface was built as the entitled `workspace_asks` module in the private repo, and that was the wrong edition for exactly the reason ent#356 (the Workspace itself) and ent#443 (multi-agent rooms) were: the frontend that drives it — `PortalAsks.vue`, `store.fetchAsks()`, `store.asksAvailable` — ships in EVERY build and self-disables on a 404. So a community install rendered the ask affordance and then refused it: an advert for a missing feature rather than a clean absence. Under `client_portal/`, not a new top-level package. That is what it is — a client-portal surface, on the client-portal prefix, owning no table (an ask IS an `operator_queue` row with an addressee, which is what makes "answering anywhere clears it everywhere" true by construction). It also means the module inherits `client_portal`'s Dockerfile COPY line instead of needing its own: a new top-level package that `main.py` imports at module scope builds a clean image that dies at import, and that has now been #1033, ent#356 and ent#443. Verbatim port. The service and models are unchanged; the router loses its `requires_entitlement` dependency and the service its `FEATURE_ID`. Nothing else moved, so the diff reads as a move. RETAINED, deliberately: the `/api/enterprise/client-portal/asks` prefix. ent#83 published it as the headless integration surface and the shipped Vue bundle already calls it — renaming breaks a client for nothing. Provenance, not a licensing claim, exactly like the `enterprise_`-prefixed portal tables it reads beside. Transition: the OSS router is included BEFORE `register_enterprise(app)`, so on an install whose submodule still registers the gated module both mount and the ungated OSS one wins the match order. Verified live on a pure-OSS build with an un-bumped submodule — OSS route first, `entitled features: []`. The suite moves with it (`tests/unit/test_ent428_workspace_asks.py`), unchanged beyond import paths and one path calculation, plus three cases the ported ones structurally cannot see: the routes answer with no entitlement, nothing calls `requires_entitlement`/`register_module` (parsed, not grepped — a docstring may still SAY it used to be gated), and the mount precedes the seam. That last assertion is anchored on the CALL, not on `str.index` of the name: `main.py` mentions `register_enterprise(app)` in prose above it, so a substring match finds the comment. It false-failed here on the first run. #2355 carries the same weaker form — it can only false-fail there, never false-pass, and is worth tightening separately. Local: 22 in the ported suite; `-k "portal or agent_page or ent364 or ent428 or operator or queue or asks or entitle or version"` → 808 passed, 1 skipped. Related to ent#428. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything about the Workspace lives in OSS. This surface was built as the entitled `workspace_asks` module in the private repo, and that was the wrong edition for exactly the reason ent#356 (the Workspace itself) and ent#443 (multi-agent rooms) were: the frontend that drives it — `PortalAsks.vue`, `store.fetchAsks()`, `store.asksAvailable` — ships in EVERY build and self-disables on a 404. So a community install rendered the ask affordance and then refused it: an advert for a missing feature rather than a clean absence. Under `client_portal/`, not a new top-level package. That is what it is — a client-portal surface, on the client-portal prefix, owning no table (an ask IS an `operator_queue` row with an addressee, which is what makes "answering anywhere clears it everywhere" true by construction). It also means the module inherits `client_portal`'s Dockerfile COPY line instead of needing its own: a new top-level package that `main.py` imports at module scope builds a clean image that dies at import, and that has now been #1033, ent#356 and ent#443. Verbatim port. The service and models are unchanged; the router loses its `requires_entitlement` dependency and the service its `FEATURE_ID`. Nothing else moved, so the diff reads as a move. RETAINED, deliberately: the `/api/enterprise/client-portal/asks` prefix. ent#83 published it as the headless integration surface and the shipped Vue bundle already calls it — renaming breaks a client for nothing. Provenance, not a licensing claim, exactly like the `enterprise_`-prefixed portal tables it reads beside. Transition: the OSS router is included BEFORE `register_enterprise(app)`, so on an install whose submodule still registers the gated module both mount and the ungated OSS one wins the match order. Verified live on a pure-OSS build with an un-bumped submodule — OSS route first, `entitled features: []`. The suite moves with it (`tests/unit/test_ent428_workspace_asks.py`), unchanged beyond import paths and one path calculation, plus three cases the ported ones structurally cannot see: the routes answer with no entitlement, nothing calls `requires_entitlement`/`register_module` (parsed, not grepped — a docstring may still SAY it used to be gated), and the mount precedes the seam. That last assertion is anchored on the CALL, not on `str.index` of the name: `main.py` mentions `register_enterprise(app)` in prose above it, so a substring match finds the comment. It false-failed here on the first run. #2355 carries the same weaker form — it can only false-fail there, never false-pass, and is worth tightening separately. Local: 22 in the ported suite; `-k "portal or agent_page or ent364 or ent428 or operator or queue or asks or entitle or version"` → 808 passed, 1 skipped. Related to ent#428. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vybe
left a comment
There was a problem hiding this comment.
Re-review after the 5 commits pushed since my CHANGES_REQUESTED. Both blockers are closed and verified:
1. Dockerfile — COPY ../../src/backend/shared_sessions /app/shared_sessions/ is present beside the client_portal precedent, and prod-image-smoke is green. The added test_ent443_backend_package_copy_parity.py guard is a better answer than the one-time grep I asked for.
2. Alembic — resolved to 0044_shared_sessions_oss with down_revision = 0038_portal_chat_state. I verified the graph directly: main tails at 0038, so this is single-head on this base and check_alembic_heads is green on the thing that actually merges. pg-migrations green.
4. Title — wip: dropped.
3 — base branch: accepting main, with the cost stated. Confirmed with the author. The consequence is real and I want it on the record rather than rediscovered when CI goes red: dev carries 0039…0043 off the same 0038 parent, so the moment the two branches meet, that version line has two heads and alembic upgrade head — being singular — applies zero revisions. check_alembic_heads is a required context via schema-parity, so the next release PR will block loudly rather than ship silently; it owes an alembic merge revision at that point. Your PR comment already says this; approving on that basis.
All 21 non-skipped checks pass. Security scan clean, no new env var, no host paths or mode flips.
Merge order confirmed and being executed now: this PR → trinity-enterprise#444 → submodule pin bump on main.
Not blocking, but I I1'd it and it is still open: 9 live doc lines still describe rooms as enterprise-gated (docs/user-docs/collaboration/rooms.md:5,53,68, faq/sharing-access-and-monetization.md:67, faq/collaboration.md:51, README.md:57, guides/using-trinity.md:21, integrations/mcp-server.md:82,140) — all on docs.ability.ai, and sync-docs-to-vertex fires on this push. Filing as a follow-up.
|
Correction to my approval, so nobody chases this: the I1 docs finding is already fixed — I quoted it from the review thread instead of checking the merged tree, and it was closed by the later commits. Verified against Every surviving
Merge order completed: this PR ( |
Everything about the Workspace lives in OSS. This surface was built as the entitled `workspace_asks` module in the private repo, and that was the wrong edition for exactly the reason ent#356 (the Workspace itself) and ent#443 (multi-agent rooms) were: the frontend that drives it — `PortalAsks.vue`, `store.fetchAsks()`, `store.asksAvailable` — ships in EVERY build and self-disables on a 404. So a community install rendered the ask affordance and then refused it: an advert for a missing feature rather than a clean absence. Under `client_portal/`, not a new top-level package. That is what it is — a client-portal surface, on the client-portal prefix, owning no table (an ask IS an `operator_queue` row with an addressee, which is what makes "answering anywhere clears it everywhere" true by construction). It also means the module inherits `client_portal`'s Dockerfile COPY line instead of needing its own: a new top-level package that `main.py` imports at module scope builds a clean image that dies at import, and that has now been #1033, ent#356 and ent#443. Verbatim port. The service and models are unchanged; the router loses its `requires_entitlement` dependency and the service its `FEATURE_ID`. Nothing else moved, so the diff reads as a move. RETAINED, deliberately: the `/api/enterprise/client-portal/asks` prefix. ent#83 published it as the headless integration surface and the shipped Vue bundle already calls it — renaming breaks a client for nothing. Provenance, not a licensing claim, exactly like the `enterprise_`-prefixed portal tables it reads beside. Transition: the OSS router is included BEFORE `register_enterprise(app)`, so on an install whose submodule still registers the gated module both mount and the ungated OSS one wins the match order. Verified live on a pure-OSS build with an un-bumped submodule — OSS route first, `entitled features: []`. The suite moves with it (`tests/unit/test_ent428_workspace_asks.py`), unchanged beyond import paths and one path calculation, plus three cases the ported ones structurally cannot see: the routes answer with no entitlement, nothing calls `requires_entitlement`/`register_module` (parsed, not grepped — a docstring may still SAY it used to be gated), and the mount precedes the seam. That last assertion is anchored on the CALL, not on `str.index` of the name: `main.py` mentions `register_enterprise(app)` in prose above it, so a substring match finds the comment. It false-failed here on the first run. #2355 carries the same weaker form — it can only false-fail there, never false-pass, and is worth tightening separately. Local: 22 in the ported suite; `-k "portal or agent_page or ent364 or ent428 or operator or queue or asks or entitle or version"` → 808 passed, 1 skipped. Related to ent#428. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e agent-page leak, and the module itself (ent#428) (#2356) * feat(operator-queue): filter the queue listing by addressee in SQL (ent#428) `operator_queue.addressed_to_email` shipped in #2300, but nothing could ask the DB for "the asks addressed to this person" — the only consumer read a fleet-wide page of pending items and filtered the addressee out of the result. That is wrong at the layer, not just inefficient. `list_items` orders by status, then priority, then age, and applies `limit` before the caller ever sees a row, so a post-hoc filter really means "the newest N pending items in the FLEET, of which some happen to be yours". One person's low-priority ask falls out of the window as soon as the fleet is busy — it disappears from the surface that addressed it while still sitting pending in the queue, and by construction nobody else is permitted to answer it. So `list_items` takes an optional `addressed_to_email`, compared case-insensitively: the ingestion boundary lowercases before it stores (`_validated_addressee`), but `create_item` is a public writer and the read must not silently depend on every future caller having remembered that. Also annotates `respond_to_item(responded_by_id=...)` as Optional. It is a `users` id, and an ask answered by someone with no platform account has no row there — NULL id plus the answering email is what distinguishes that from an answer by an operator whose account was since deleted, which keeps its id. The annotation records the intent so it is not later "tidied" back to `str`. Additive: no migration, no route change, no default behaviour moved — omitting the argument returns exactly what it returned before, pinned by a test. Related to ent#428. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workspace): the agent page listed every client's asks, not the viewer's (ent#428) Found by /review on the primitive commit, and reproduced before it was believed: two clients co-shared on one agent, an ask addressed to the first, and the second reads its title and question verbatim. BOB SEES: [('Invoice Acme at the agreed discount?', 'Alice, confirm the Q3 discount before I send it.')] `client_portal/agent_page.py::_asks` is the client-facing "Waiting on you" block, and it was scoped by `agent_name` alone. One agent is routinely shared with several people, so that scope was never the audience — it was the only scope available before ent#364 added the addressee. The file already withheld `context` as a known leak surface, which is what makes it deceptive: it looks like a reader that has thought about disclosure. `title`, `question` and `options` are agent-authored free text and are exactly where an ask meant for someone else says something not meant for this reader. The section heading was "Waiting on you" the whole time. Pre-existing on `dev` since ent#364: that change added the column, its ingestion-time validation and three new renderings that honour it, and did not teach this older reader. Fixed here rather than filed, because this is the PR that adds the primitive it needs. `viewer_email` is REQUIRED, not defaulted — a default is how the next caller silently gets the unfiltered list back. Unaddressed operator asks stop rendering here, and that narrowing is intended: this is a client-facing page, an operator ask is agent-authored text written for the operator, and a client cannot act on one from here anyway ("reply in chat" is the only affordance). Operators keep the full queue in Operations, and the page now agrees with what the Workspace's other ask surfaces show that person. Also makes the filter fail CLOSED. `list_items` uses truthiness for every other filter, so a falsy value means "don't filter"; for this argument that would turn a caller which lost its email into a caller that sees everyone's asks. `None` still means no filter (the operator listing); `""` now matches nothing. The two `test_ent360` failures were signature-only — both stub the query out, so their subjects (alert-type exclusion, `context` never forwarded) are untouched; they just pass the viewer now. Three new tests, all three failing without this change. Slice re-run: 655 passed, 1 skipped. Related to ent#428. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(workspace): move agent-initiated asks into OSS core (ent#428) Everything about the Workspace lives in OSS. This surface was built as the entitled `workspace_asks` module in the private repo, and that was the wrong edition for exactly the reason ent#356 (the Workspace itself) and ent#443 (multi-agent rooms) were: the frontend that drives it — `PortalAsks.vue`, `store.fetchAsks()`, `store.asksAvailable` — ships in EVERY build and self-disables on a 404. So a community install rendered the ask affordance and then refused it: an advert for a missing feature rather than a clean absence. Under `client_portal/`, not a new top-level package. That is what it is — a client-portal surface, on the client-portal prefix, owning no table (an ask IS an `operator_queue` row with an addressee, which is what makes "answering anywhere clears it everywhere" true by construction). It also means the module inherits `client_portal`'s Dockerfile COPY line instead of needing its own: a new top-level package that `main.py` imports at module scope builds a clean image that dies at import, and that has now been #1033, ent#356 and ent#443. Verbatim port. The service and models are unchanged; the router loses its `requires_entitlement` dependency and the service its `FEATURE_ID`. Nothing else moved, so the diff reads as a move. RETAINED, deliberately: the `/api/enterprise/client-portal/asks` prefix. ent#83 published it as the headless integration surface and the shipped Vue bundle already calls it — renaming breaks a client for nothing. Provenance, not a licensing claim, exactly like the `enterprise_`-prefixed portal tables it reads beside. Transition: the OSS router is included BEFORE `register_enterprise(app)`, so on an install whose submodule still registers the gated module both mount and the ungated OSS one wins the match order. Verified live on a pure-OSS build with an un-bumped submodule — OSS route first, `entitled features: []`. The suite moves with it (`tests/unit/test_ent428_workspace_asks.py`), unchanged beyond import paths and one path calculation, plus three cases the ported ones structurally cannot see: the routes answer with no entitlement, nothing calls `requires_entitlement`/`register_module` (parsed, not grepped — a docstring may still SAY it used to be gated), and the mount precedes the seam. That last assertion is anchored on the CALL, not on `str.index` of the name: `main.py` mentions `register_enterprise(app)` in prose above it, so a substring match finds the comment. It false-failed here on the first run. #2355 carries the same weaker form — it can only false-fail there, never false-pass, and is worth tightening separately. Local: 22 in the ported suite; `-k "portal or agent_page or ent364 or ent428 or operator or queue or asks or entitle or version"` → 808 passed, 1 skipped. Related to ent#428. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(workspace): review follow-ups on the ask move — stale edition claims, and an N+1 (ent#428) Four findings from /review on the move commit. None in the ported logic; all four are things the move itself created or exposed. **Three now-false edition claims.** Moving the module made public prose lie: * `docs/memory/architecture.md:1241` said "the read/answer surface is the ENTITLED `workspace_asks` module". A public doc calling a public module paid is the wrong direction of wrong — it is the sentence a reader would trust. * `stores/clientPortal.js` explained its own 404/403 guard as "the module is enterprise-gated, so an OSS or unentitled build answers 404/403". The guard still belongs — an OLDER backend either predates the surface or still gates it — but the stated reason would tell a reader asks are a paid feature. Comments only; the logic is untouched and still correct. * `asks/models.py` still called itself `Private.` This does mean the PR now touches one frontend file, so the "no frontend" claim in the body is corrected rather than quietly left standing. **An N+1 the move promoted to an OSS polled endpoint.** `list_asks` called `_on_roster` per ITEM. That resolves to `agent_name in roster_agent_names(...)`, one-to-two DB reads, for an answer that cannot change inside a single request — on an endpoint the Workspace polls every 20s, per signed-in client, per open tab. A client's asks cluster on one or two agents, so this is now 1-2 reads instead of N. The FIRST attempt at that fix was wrong and the tests said so: hoisting `roster_agent_names` out of the loop turned six of them red, because the suite's seam is `agent_on_roster`. That was correct feedback rather than an awkward fixture — `agent_on_roster` IS the access predicate ("the scope of what a caller can DO must equal the scope of what they can SEE"), and re-implementing membership beside it is exactly how the two drift apart. Redone as a per-REQUEST memo over that same predicate: same win, seam intact, fail-closed preserved per agent. Pinned on the CALL COUNT, not on timing — a duration assertion would be flaky and would not say what it means. Proven non-vacuous: AssertionError: roster consulted 8 times for 8 asks across 2 agents Slice: 678 passed, 1 skipped. Guard sweep (`centralized|parity|invariant| architecture|wiring|1310|186|293|926|enumeration`): 816 passed, 3 skipped. Related to ent#428. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(workspace): attach an ask to a chat at raise time, and revive the surface that never rendered (ent#429) Slices 3+4 of ent#364. Most of the rendering shipped in #2300; auditing the acceptance criteria against the code left three things genuinely undone, one of them a promise the feature has been making falsely since it landed. **AC #2 — nothing homeless.** `workspace_session_id` was READ by the ask projection and written by nothing, so `chat_id` was always null and an ask raised by a scheduled run belonged nowhere. It is now resolved at RAISE time (`client_portal.service.ensure_thread_for_ask`), reusing the client's existing thread with that agent and opening one only if they have never chatted — the same `_resolve_session_id(..., None)` a first client turn takes, so an ask lands IN the conversation rather than beside it. Render-time resolution would not be an attachment: it is a guess repeated per view, with nothing durable to audit. **And it is platform-written, which nothing enforced.** The projection's docstring already promised "platform-written context only" while the clamp happily passed an agent-authored `workspace_session_id` straight through — `chat_id` is where the Workspace SENDS a reader, so an agent could choose that destination. Stripped unconditionally now, before the real one is written, and stripped whether or not an addressee resolves. Three ordering details, each load-bearing: * the addressee is resolved BEFORE the context block, because the thread id is written INTO context and doing it after would add bytes the size cap had already signed off on; * the context dict is REBUILT, not popped — `out = dict(req)` is a shallow copy, so popping would reach into the caller's own dict and the clamp's contract is that it never mutates the request; * the thread id survives the oversize-context marker, or an agent-controlled oversize context would be the one way to produce a homeless ask. Fail-SOFT, deliberately the opposite direction to `_validated_addressee` beside it: that fails closed because it is an authorization decision, this one only decides where a link points, and losing the whole question over a missing link is the worse trade. **AC #1 — the inline-in-chat surface has never rendered.** `PortalConversation` passes `props.agent`, the agent OBJECT, to `asksForAgent`, which compares it against the string `agent_name`. It matched nothing, and the wrapper is `v-if="agentAsks.length"`, so the third of ent#364's three surfaces silently did not exist. Verified with a throwaway probe before believing it: asksForAgent('scout') -> 1 ask asksForAgent({name: 'scout'}) -> 0 asks Pinned against the SOURCE, because this repo's vitest runs in `node` with no component-mount harness — crude, but it is the difference between catching this class again and not. **AC #3 — expiry now says WHEN.** The row said "This expired before it was answered" with no time, and the #1142 sweep DELETES terminal rows, so between lapsing and being swept this is the only evidence the question was ever asked. An hour ago and last March are different situations. `expiredLabel` lives in `portalUtils` with `now` injected, per this file's rule that a sentence composed inside a component is a sentence no test can reach; it degrades to the bare wording on a missing, garbled or future timestamp, because a wrong time is worse than no time — the reader would act on it. Plus `askThreadLink`: "Open the conversation", shown only when the ask names a thread the reader is not already in. Additive — it never hides an ask from the thread being read. Narrowing the inline surface to only its own thread is the open question, not this. All Workspace work in the OSS repo, per the standing rule. Backend slice: 687 passed, 1 skipped. Frontend: 52 files / 1097 tests, tokens OK, build OK. 6 of the 9 new backend cases fail without this change; the other 3 are regression guards for properties it could have broken. Related to ent#429. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(operator-queue): the ingestion clamp is not pure, and its docstring said it was (ent#429) Review follow-up. `_clamp_ingested_item` is named and documented as a field-hygiene clamp — "Returns a NEW dict; never mutates the caller's request" — and a reader reasonably concludes it is safe to call speculatively, just to see what a clamped item would look like. It is not, and has not been since ent#364: it resolves `addressed_to_email` against the agent's roster, which is a DB READ and an authorization decision. ent#429 went further and gave it a DB WRITE — `_workspace_thread_for` may create a portal session. That is fine where it is. The write has to precede the context size check, because the thread id is written INTO context and adding it afterwards would append bytes the cap had already signed off on. What is not fine is a docstring that describes the opposite, so it now names both effects before the field list, and says what the second caller would cost: an empty client thread left behind by a clamp whose result was never created. No behaviour change. Slice: 687 passed, 1 skipped. Related to ent#429. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves multi-agent rooms from the entitled
shared_sessionsmodule into OSS core, in the shape ent#356 used for the Workspace itself. PR 1 of 2 — the companion PR intrinity-enterprisedeletes the module there; the submodule bump follows its merge.Related to ent#443 (private tracker — it carries the edition/monetization rationale, which
enterprise-docs-guardkeeps out of public docs). Blocks ent#442.Why
Three of the four surfaces were already public.
components/rooms/,stores/rooms.js, the ent#392 composer typeahead andsrc/mcp-server/src/tools/rooms.tsship in every build and self-disable; only the backend was gated. So an OSS install rendered the room affordance and then refused it — an advert for a missing feature rather than a clean absence.The docs had already drifted ahead of the code, publicly:
docs/user-docs/sharing-and-access/workspace.md:60presents@-mention → room as the ordinary continuation of a 1:1, with no edition qualifierdocs/user-docs/faq/sharing-access-and-monetization.md:59said flatly "Multi-agent chat is an enterprise capability"Both live on docs.ability.ai. This PR makes the guide true and corrects the FAQ.
Verbatim port
The engine is unchanged: mention-wake turn-taking, per-room budgets, chain depth, the per-participant wake cap, the ent#218 overshoot rule and the ent#220 cancellation shield. A port that also fixes things is a port nobody can review — the five ported suites are the evidence, and they were not edited beyond their import paths.
Adoption, not creation
db/schema.py+db/tables.py, versioned on the OSS two-track runner: SQLiteshared_sessions_tables_to_oss, Alembic0042_shared_sessions_oss(single head;check_alembic_headsgreen).CREATE TABLE IF NOT EXISTS, so the migration is a no-op on every entitled install. No data migration, no lost transcripts.downgradedrops nothing — this revision adopted the tables, it did not create them.enterprise_prefix. Renaming them is the data migration this forbids; the prefix is provenance, not a licensing claim (the ent#356 precedent, quoted in the code).0011_shared_sessionsstays on its own line —0012_slack_agent_bindingsrevises it, and deleting an applied revision is a PostgreSQL boot failure that takes the whole paid tier with it (ent#431).shared_sessions/schema.pyis now a test/bootstrap applier over the canonical OSS statements (theclient_portal/schema.pyshape) and owns no DDL of its own.One deliberate behaviour change — it fixes a latent bug the move surfaced
enterprise_room_participants.identityandenterprise_room_messages.sender_identityare now registered inAGENT_REFS. Before this, rooms sat outside the registry (it covers OSS tables), so:Both columns are polymorphic —
kind/sender_kinddecides whether the value is an agent name, a platform user id, or a workspace client's verified email — so each ref carries akind = 'agent'predicate. Unscoped, a rename would rewrite (and a purge DELETE) a human participant whose id or email happened to equal the agent's name.test_ent443_rooms_oss_core.pyproves both directions with colliding human rows.The forward parity regex structurally cannot see either column (
identityis far too generic to add to_AGENT_ID_COLUMNS), sotest_agent_cleanup_parity.pygains a documented_POLYMORPHIC_AGENT_COLUMNSset for the backward direction — still verified against the real DDL, so a drop or rename still fails.This is the same fix ent#356 made for the portal tables, and it is the answer to open question 1 on the issue.
Transition safety
The OSS routers are included in
main.pybeforeregister_enterprise(app). On an install whose submodule has not yet been bumped, both routers mount and the ungated OSS one wins the match order, so an entitled install keeps serving rooms throughout the window. Pinned behaviourally and source-asserted, so moving the mounts below the seam fails loudly instead of silently handing the paths back to a 403.Capability bit
multi_agent_chat_availableis unconditionally true now but stays on the roster payload. It is the portal's only capability channel (#2128) — a portal principal cannot read/api/settings/feature-flags— and the shipped bundle gates the picker, five room store actions and/workspace/r/:roomIdon it. Deleting the field would make all of those readundefinedand hide the very feature this move exposes.Tests
test_ent169/220/361/362/387_*)test_ent443_rooms_oss_core.py(new)test_2128_workspace_rooms_capability.py(rewritten)test_agent_cleanup_parity.py-k "schema or alembic or parity"sweepThe new suite covers what the ported ones structurally cannot see (they import the module directly and would pass even if it were never mounted): both routers mounted, the first-matching route is the OSS one and carries no entitlement dependency, no feature id registered, DDL on both tracks under the historical names, the bootstrap helper owns no DDL, and the kind-scoped rename contract.
Pre-existing, unrelated:
tests/unit/test_ent399_ipv6_origin.pyhas 2 failures on pristineorigin/dev(verified in a throwaway worktree) — environmental IPv6 formatting, untouched by this PR.Open questions from the issue
AGENT_REFS— answered above: it was a live bug, fixed here, kind-scoped.docs/planning/SHARED_SESSIONS_DESIGN.md— still in the private repo; not moved by this PR. Reviewer call whether it follows the code.Reviewer notes
shared_sessions/__init__.pyfirst — it carries the why, the prefix rationale and the two-router split.main.py,client_portal/service.py, the two migration tracks,db/agent_cleanup.py, and the new test file.🤖 Generated with Claude Code