Skip to content

refactor(rooms): move multi-agent rooms into OSS core (ent#443) - #2355

Merged
vybe merged 7 commits into
mainfrom
refactor/443-rooms-to-oss-core
Aug 21, 2026
Merged

refactor(rooms): move multi-agent rooms into OSS core (ent#443)#2355
vybe merged 7 commits into
mainfrom
refactor/443-rooms-to-oss-core

Conversation

@dolho

@dolho dolho commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Moves multi-agent rooms from the entitled shared_sessions module into OSS core, in the shape ent#356 used for the Workspace itself. PR 1 of 2 — the companion PR in trinity-enterprise deletes the module there; the submodule bump follows its merge.

Related to ent#443 (private tracker — it carries the edition/monetization rationale, which enterprise-docs-guard keeps 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 and src/mcp-server/src/tools/rooms.ts ship 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:60 presents @-mention → room as the ordinary continuation of a 1:1, with no edition qualifier
  • docs/user-docs/faq/sharing-access-and-monetization.md:59 said 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

  • DDL → db/schema.py + db/tables.py, versioned on the OSS two-track runner: SQLite shared_sessions_tables_to_oss, Alembic 0042_shared_sessions_oss (single head; check_alembic_heads green).
  • 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. downgrade drops nothing — this revision adopted the tables, it did not create them.
  • Table names keep the 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).
  • Enterprise Alembic 0011_shared_sessions stays on its own line — 0012_slack_agent_bindings revises it, and deleting an applied revision is a PostgreSQL boot failure that takes the whole paid tier with it (ent#431).
  • shared_sessions/schema.py is now a test/bootstrap applier over the canonical OSS statements (the client_portal/schema.py shape) and owns no DDL of its own.

One deliberate behaviour change — it fixes a latent bug the move surfaced

enterprise_room_participants.identity and enterprise_room_messages.sender_identity are now registered in AGENT_REFS. Before this, rooms sat outside the registry (it covers OSS tables), so:

  • an agent rename silently stopped it being woken — its participant row still named the old agent, and that row is what mention resolution matches against;
  • an agent purge orphaned participants and transcript.

Both columns are polymorphickind / sender_kind decides whether the value is an agent name, a platform user id, or a workspace client's verified email — so each ref carries a kind = '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.py proves both directions with colliding human rows.

The forward parity regex structurally cannot see either column (identity is far too generic to add to _AGENT_ID_COLUMNS), so test_agent_cleanup_parity.py gains a documented _POLYMORPHIC_AGENT_COLUMNS set 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.py before register_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_available is 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/:roomId on it. Deleting the field would make all of those read undefined and hide the very feature this move exposes.

Tests

Suite Result
5 ported suites (test_ent169/220/361/362/387_*) 94 pass, 1 skip
test_ent443_rooms_oss_core.py (new) 16 pass
test_2128_workspace_rooms_capability.py (rewritten) 9 pass
test_agent_cleanup_parity.py 4 pass
-k "schema or alembic or parity" sweep 488 pass

The 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.py has 2 failures on pristine origin/dev (verified in a throwaway worktree) — environmental IPv6 formatting, untouched by this PR.

Open questions from the issue

  1. AGENT_REFS — answered above: it was a live bug, fixed here, kind-scoped.
  2. Does any install break by rooms appearing unentitled? No — additive. Nothing is withdrawn, no route changes shape, and an entitled install keeps its existing tables and transcripts untouched.
  3. 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

  • Read shared_sessions/__init__.py first — it carries the why, the prefix rationale and the two-router split.
  • The diff looks large but is dominated by the verbatim module and the ported tests; the reviewable surface is main.py, client_portal/service.py, the two migration tracks, db/agent_cleanup.py, and the new test file.

🤖 Generated with Claude Code

@dolho dolho changed the title refactor(rooms): move multi-agent rooms into OSS core (ent#443) wip: refactor(rooms): move multi-agent rooms into OSS core (ent#443) Aug 20, 2026
@dolho
dolho changed the base branch from dev to main August 20, 2026 14:32
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>
@dolho
dolho force-pushed the refactor/443-rooms-to-oss-core branch from ec9f98f to a2ed79f Compare August 20, 2026 14:44
@dolho

dolho commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (single commit) and force-pushed.

The Alembic chain had to move with the base. main's head is 0038_portal_chat_state; the revision was written against dev's 0041_secret_settings_encryption, which does not exist on main — an unresolvable down_revision is a PostgreSQL boot failure, not a migration warning. Renamed to 0039_shared_sessions_oss with down_revision = "0038_portal_chat_state"; check_alembic_heads reports 40 revisions, 1 head.

The SQLite conflict resolved the same way: this branch no longer carries secret_settings_encryption (dev-only), just shared_sessions_tables_to_oss at the end of MIGRATIONS.

⚠️ Reconciliation will fork the version line. dev has 0039_operator_queue_addressed_to0043_subscription_headroom_history, all chained from 0038. This revision is now a second child of 0038, so whenever dev and main meet, that line has two heads — and alembic upgrade head is singular, so it resolves its target first and applies nothing: every revision merged since the fork stops arriving, silently, with no git conflict. It needs an alembic merge revision at the reconciliation point. Flagging rather than guessing which direction that merge should run.

Re-verified on this base (nothing below was inherited from the dev-based run):

  • room suites + capability + cleanup parity: 123 passed, 1 skipped
  • guard families (schema|alembic|parity|migration|cleanup|rename): 682 passed, 1 skipped
  • SQLite entitled-install adoption: seeded 0011-era DDL + live rows → init_database() → migration recorded, data identical, module reads the old room, cached_session_id and last_read_seq intact
  • PostgreSQL entitled-install adoption (real PG, scratch DB, dropped after): upgrade_to_head()alembic_version = 0039_shared_sessions_oss, room + 4 participants + 3 messages intact, agent cursors preserved

One regression this surfaced and fixed: test_1819_rename_cascade_parity failed after the AGENT_REFS addition — its seeder inserts one row per ref and could not satisfy a kind = 'agent' predicate, so both room refs looked stranded. It already had a hard-coded scope special case for the scope-filtered key refs; that is now generalized to parse any ref's extra_filter, so a future filtered ref is covered without touching the harness again.

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 TRINITY_OSS_ONLY=1 where the gated router would 403) but the OpenAPI path map is last-wins, so /docs shows tags: [enterprise:rooms] for /api/rooms until #444 merges.

`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 vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

dolho and others added 3 commits August 20, 2026 17:54
…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>
@dolho dolho changed the title wip: refactor(rooms): move multi-agent rooms into OSS core (ent#443) refactor(rooms): move multi-agent rooms into OSS core (ent#443) Aug 20, 2026
…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>
@dolho

dolho commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Re-review — all four findings addressed; no blockers remain

Findings 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.

# Finding State
1 shared_sessions missing from the backend Dockerfile 7c55bd46 — plus a static guard so the class stops recurring
2 Alembic branch point 34813911 — renumbered to 0044, parent deliberately unchanged
3 Base is main ⚖️ intentional: this is a hotfix to main
4 wip: title ✅ dropped

1 — Dockerfile (blocker, fixed)

COPY ../../src/backend/shared_sessions /app/shared_sessions/, one line below the client_portal precedent you quoted. prod-image-smoke is green.

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 — tests/unit/test_ent443_backend_package_copy_parity.py asserts the COPY list has not fallen behind main.py's module-scope imports, and fails in milliseconds naming the exact line to add. It carries an anti-vacuity anchor (both real incidents, client_portal and shared_sessions, as fixtures) because a parser that quietly stops matching makes both assertions pass on nothing. Deliberately not a replacement for prod-image-smoke — that job proves the image boots, which no static check can.

Third occurrence of this shape (#1033 module → ent#356 → ent#443), so it is now in docs/memory/learnings.md.

2 — Alembic (blocker, fixed — but the prescribed fix would have broken main)

The suggested fix was "renumber to 0044 with down_revision = "0043_subscription_headroom_history"". That is right for a dev-based PR and wrong for this one: main's head is 0038_portal_chat_state, and 00390043 exist only on dev, so a down_revision naming 0043 is a revision the map cannot resolve — a boot failure on the very line this ships to.

So the fork at 0038 is forced, not accidental, and main resolves to exactly one head as-is. What was avoidable — and is fixed — is the duplicated numeric prefix and the fact that the back-merge obligation lived nowhere:

  • Renumbered 0039_shared_sessions_oss0044_shared_sessions_oss, parent still 0038, so the filename does not collide with dev's 0039_operator_queue_addressed_to after the back-merge. Ids are strings, but the prefix is the graph's only human ordering cue, and a duplicate would be the first in the repo — on exactly the failure git shows no conflict for.
  • The back-merge must add an alembic merge revision with down_revision = ("0043_subscription_headroom_history", "0044_shared_sessions_oss"). It cannot ship 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. Written into the revision docstring, architecture.md, and a test.
  • test_the_room_revision_chains_off_the_hotfix_line_and_numbers_uniquely pins parent = 0038 and prefix uniqueness.

Simulated rather than asserted — dev's versions/ + this file fails the repo's own guard at exactly the fork point, and adding the prescribed merge revision collapses it:

alembic-heads: FAIL — resolves to 2 heads across 45 revision(s)
  They fork at: 0038_portal_chat_state
# + the merge revision:
alembic-heads: 46 revision(s), 1 head (0045_merge_shared_sessions_oss) — PASS

Verified against a real PostgreSQL 16 from a narrow VARCHAR(32) alembic_version (the pg-migrations shape): reaches 0044_shared_sessions_oss, column widened to 255, three room tables created. Then the part that actually exercises an adoption revision — downgrade -1 leaves all three tables in place, and re-upgrade over the pre-existing tables is a clean no-op.

The SQLite list you flagged conflicts loudly on rebase: not applicable while the base is main; it will be a name-tracked keep-both resolution at the back-merge.

3 — Base branch

Confirmed with the author: this is intentionally a hotfix into main, not ordinary feature work that drifted. That resolves the CI concern too, but from the other direction than the review assumed — schema-parity and check_alembic_heads are green because main genuinely is single-head with this revision applied, not because the base is hiding finding 2.

4 — Title

wip: dropped.


What I verified independently

Since the port's substance was not what held this up, I checked the claims rather than re-reading the engine:

  • Verbatim portservice.py and db.py differ from the enterprise originals by docstrings only; models.py is byte-identical. router.py's delta is exactly the entitlement removal, schema.py is the intended rewrite to a bootstrap applier over db/schema.py (owns no DDL).
  • Adoption contract on an entitled PG install — the enterprise 0011_shared_sessions DDL is byte-identical to the OSS 0044 DDL (statement order aside): same columns, same defaults, same UNIQUE (room_id, kind, identity) / UNIQUE (room_id, seq), same four indexes. So the IF NOT EXISTS no-op leaves the correct shape, not merely a shape — which is the thing that would silently break a paying install if it were false.
  • DDL agreement across all four sourcesdb/schema.py, db/tables.py, db/migrations.py, Alembic 0044: identical column sets on all three tables, correct per-dialect types (SERIAL/DOUBLE PRECISION vs AUTOINCREMENT/REAL).
  • extra_filter reaches every consumer — the kind-scoping is applied in all three: cascade_delete, cascade_rename, and find_orphan_agent_names. A predicate honoured on two of three would have left the polymorphic hazard open on the third.
  • Companion side is consistent — enterprise 0011 kept, 0012_slack_agent_bindings still chains off it, _register_module("shared_sessions") gone, no stale reference to the OSS revision id.
  • Neither submodule pointer is committed (both are dirty working-tree only), so the merge order in the body still holds.
  • The parity guards were widened, not weakened_POLYMORPHIC_AGENT_COLUMNS is still validated against the real DDL, and the _seed_row change replaces a hard-coded scope special case with a parsed predicate, which is what stops the harness reporting its own unsatisfied filter as a strand.
  • The ent399_ipv6 failures are pre-existing — reproduced identically on a pristine origin/main worktree.
  • Rooms suites: 131 passed, 1 skipped. -k "schema or alembic or parity or migration": 562 passed, 1 skipped.

Non-blocking findings

[I1] The docs half of this PR is incomplete — 9 live lines still say rooms are enterprise-gated (confidence 10/10)

The PR's stated motivation is that the guide had drifted publicly. Three lines were corrected; the feature's own page was not:

  • docs/user-docs/collaboration/rooms.md:5 — opens > **Enterprise feature.** Shared sessions are available on the enterprise tier…; also :53 and :68
  • docs/user-docs/faq/sharing-access-and-monetization.md:67"only multi-agent rooms within it are enterprise-gated"eight lines below the line this PR fixed, in the same file
  • docs/user-docs/faq/collaboration.md:51"This is an enterprise-tier capability… In a community build the Sessions view is hidden and the feature is unavailable"
  • docs/user-docs/README.md:57, docs/user-docs/guides/using-trinity.md:21, docs/user-docs/integrations/mcp-server.md:82 and :140

All live on docs.ability.ai. Point-in-time docs (whats-new/, dev-announcements/) are correctly out of scope. Not a blocker for the code, but it leaves the FAQ contradicting itself in one file.

[I2] The MCP room tools now mis-diagnose a genuine 403 (confidence 8/10)

src/mcp-server/src/tools/rooms.ts:27-30:

function unavailable(e: unknown): boolean {
  const msg = e instanceof Error ? e.message : String(e);
  return msg.includes("404") || msg.includes("403");
}

Unchanged by this PR, but its meaning changed. Before, a 403 genuinely meant mounted-but-unentitled. Now the entitlement gate is gone, so 403 can only be a real refusal — non-member, agent principal on the budget setter, "you cannot reach that agent" — and the agent is told shared_sessions_not_enabled: Shared sessions are not enabled on this Trinity instance, which sends whoever reads it after the wrong thing entirely.

The frontend got exactly this distinction (coded detail = refusal, plain string = absence, per the #2128 note in architecture.md); the MCP surface did not. Narrowing unavailable() to 404-only would be the equivalent fix. Fine as a follow-up.

[I3] The new COPY guard is blind to transitive imports (confidence 7/10)

test_ent443_backend_package_copy_parity.py keys on main.py's module-scope imports, so a package pulled in only by a router or service is invisible — the image would still die at import. Today _top_level_packages() - _EXEMPT and the COPY list are exactly equal (verified: 8 packages, all copied), so tightening the assertion to "every non-exempt top-level package is copied" is free and closes the hole; _EXEMPT already exists for anything that legitimately should not ship. Left alone rather than widened mid-review.

[I4] A rationale comment this PR edited is now false (confidence 8/10)

src/backend/shared_sessions/router.py:111-116, rewritten from "Triple-gated" to "Double-gated", keeps:

require_admin alone would admit an agent to widen the budget that bounds it.

That stopped being true at #1890/ent#297 — require_admin rejects agent principals itself (dependencies.py:882, "ent#293 — an ADMIN gate is never agent-callable"). The code is right and reject_agent_principal is correct belt-and-braces; only the stated reason is stale, in a sentence the PR touched. Worth a one-line fix since this repo treats these rationales as load-bearing.


Verdict: approve. Both blockers are closed with evidence, and finding 2's resolution is stronger than the one prescribed — chaining off main's real head, with the back-merge's merge revision written down and tested instead of left to be rediscovered when CI goes red. I1 is the only thing I would genuinely like landed before this ships, and it is docs-only: it leaves the feature's own guide page announcing the opposite of what merged.

Merge order unchanged and confirmed: this PR → trinity-enterprise#444 → submodule bump. The back-merge to dev owes the alembic merge revision.

🤖 Generated with Claude Code

…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>
@dolho
dolho requested a review from vybe August 20, 2026 18:25
dolho added a commit that referenced this pull request Aug 21, 2026
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>
trinity-ability pushed a commit that referenced this pull request Aug 21, 2026
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 vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after the 5 commits pushed since my CHANGES_REQUESTED. Both blockers are closed and verified:

1. DockerfileCOPY ../../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. Titlewip: 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.

@vybe
vybe merged commit 366cf50 into main Aug 21, 2026
25 checks passed
@vybe

vybe commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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 origin/main at 366cf505; no follow-up issue filed.

Every surviving enterprise-adjacent line about rooms now reads correctly:

  • docs/user-docs/collaboration/rooms.md:5"Shared sessions ship in every Trinity build. They used to be an enterprise capability…"
  • docs/user-docs/faq/sharing-access-and-monetization.md:59 — no edition claim at all
  • docs/user-docs/guides/using-trinity.md:21 — scopes "entitled" to the Enterprise nav entry and states rooms are not a nav entry

faq/collaboration.md, README.md and integrations/mcp-server.md carry no stale claim. Nothing to file.

Merge order completed: this PR (366cf505) → trinity-enterprise#444 (2a5def35) → submodule pin bump #2360.

trinity-ability pushed a commit that referenced this pull request Aug 21, 2026
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 pushed a commit that referenced this pull request Aug 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants