fix(settings): render the Room budget defaults panel — it gates on an entitlement ent#443 removed (#2620) - #2621
Conversation
…t is gone
The panel that sets a room's message, cost and TTL budgets was hidden on every
install. It gated on `isEntitled('shared_sessions')`, correct while rooms were
an enterprise module — but ent#443 moved multi-agent rooms into OSS core:
`main.py` mounts both routers unconditionally, `shared_sessions/router.py`
records that its routes "used to carry requires_entitlement", and nothing
registers that feature id any more. So the predicate was False on every build,
OSS and enterprise alike.
The endpoint behind it was fine the whole time — `budget_router` is
`require_admin` plus `reject_agent_principal` on the write. Only the UI was
unreachable, which is why this went unnoticed: rooms worked, and just their
dial was missing. Found by an operator asking why a room stopped at 59/60
messages and had closed permanently, with no way to raise the default.
Removing the gate changes no authorization. The routes enforce `require_admin`
themselves, and the panel only mounts on Settings' Retention tab, which is
`adminOnly`.
This is the second instance of one class. ent#356 did the same to
`client_portal`, caught in `NavBar.vue`, whose comment even named the mechanism
that would eventually break it. So the fix is not just this panel:
* `retiredEntitlementGates.spec.js` lists the ids that moved to OSS
(`client_portal`, `shared_sessions`) and fails on any `isEntitled(...)` gate
naming one. Adding an id to that list is the second half of moving a module
to OSS. Mutation-tested: restoring the gate fails two of its three cases.
* `shared_sessions.service.FEATURE_ID` is documented as retired rather than
deleted — the private submodule is not visible from here and may still
import the name. It is not an entitlement id and must not be gated on; a
surviving constant is what makes this class read like a live gate to the
next person.
Docs: `learnings.md` records the class — an OSS move is two edits, and the
failure mode is a control that silently disappears while the capability works.
Related to #2620
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
…y does Operator feedback on #2620: 60 messages is too low as a default, and the UI should say something before a room dies rather than after. **Defaults** (operator ruling 2026-09-08): 60 → 200 messages, 24h → 1 week TTL. 60 turned out to bound a working session, not a runaway, and reaching either limit CLOSES the room permanently — `close_room` is a one-way CAS with no reopen path — so both were ending real conversations. The TTL in particular was a room's second irreversible death and the one that fires with nobody present. Deliberately no default cost cap, which is worth stating because it changes how these read: `max_messages` is now the ONLY hard spend bound a room has, and a message is a poor proxy for spend — one @mention of three agents costs three turns. `0` remains a supported "never expires" (`_expiry` returns None for `hours <= 0`), settable per install in Settings → Retention. **The notice.** The old signal was the ratio alone — a small amber `59/60 messages` in the header. That is the fact, not the CONSEQUENCE, and the consequence is the part nobody can guess: the room closes for good and takes the thread with it. `budgetNotice()` now names what remains, says what happens, and — only in the last five — says what to do instead. It renders twice: the header stays ambient, and a banner appears above the composer at critical, which is where someone is about to spend one. Nothing renders below 80%, because a permanent gauge is how a warning gets ignored. The rule is pure in `utils/roomBudgets.js`: vitest runs `environment: 'node'` with no mount harness, so a rule inside the SFC is one no test can reach. Two ent#387 tests asserted the literal `60`. They are bound to `service.DEFAULT_MAX_MESSAGES` instead of being re-pinned to `200` — their property is "a client's supplied budget is discarded and the operator default applies", and a hard-coded number turns every future defaults change into a false failure while saying nothing extra when it passes. `db/schema.py`'s column default stays 60 and is annotated as the vestigial fallback it is: every insert supplies the value explicitly, and the applied Alembic revision 0044 carries the same literal — rewriting an applied migration is worse than a fallback nobody reaches. Known and deliberate: `MAX_TTL_HOURS` is also 168, so the default now sits at its own ceiling and the panel can only move it down (or to 0 = never). Raising the ceiling is a separate operator decision and is not taken here. Related to #2620 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
|
Operator feedback handled — pushed as Defaults raised (your call): 60 → 200 messages, 24h → 1 week TTL, no cost cap. Both limits close a room permanently ( The warning is now proactive and says what happens. Before: a small amber
Rule is pure in Two ent#387 tests asserted the literal Suites: backend room/settings 488 passed, full frontend 2314 passed. One open decision for the reviewer: |
Caught by /review on this branch, not by any test: the banner added in the
previous commit sat between the attachments block and the composer —
<div v-if="attachments.length"> …chips… </div>
<form v-else> ← the composer
— and `v-else` binds to the immediately preceding element. So the composer
chained to the BANNER's condition instead, and disappeared at exactly the
moment the banner fired: the last few messages of a room. The warning removed
the ability to act on it, which is the opposite of what this issue is for.
Invisible to everything that ran: the SFC compiles (a `v-else` after any
`v-if` is valid) and the suite has no mount harness, so no test renders the
template. The banner moves above the attachments block, restoring adjacency.
`roomComposerChain.spec.js` pins the relationship. The FIRST version of that
guard matched text — "is the gap after the last </div> empty" — and was
vacuous: the intruder's own closing tag becomes the last one, so the gap reads
clean and the mutation passed. It now walks the template AST and asserts the
form's preceding element SIBLING carries the `attachments.length` v-if, which
is the relationship that actually decides whether the composer renders.
Mutation-tested: re-inserting the banner fails it with a message naming the
cause.
Related to #2620
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
/review report —
|
Second review pass. No behaviour change. Related to #2620 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ
/review report — second pass (post-fix)Re-reviewed the full branch against merge-base Critical: none. [C1] from the first pass (the banner stealing the composer's What this pass actually didRather than re-read
Findings (all informational)[I4] Both budgets critical → messages wins (confidence 9/10). [I5] [I6] One assertion is now coincidentally vacuous-adjacent (confidence 7/10). [I1]–[I3] from the first pass stand: TTL default equals its own ceiling; Fixed in this pass
CleanSQL · auth · concurrency · credentials · enum completeness · migrations (none needed) · no dead code in the diff (the panel's SuitesFull frontend 2317 passed · backend room/settings 488 passed. |
|
merge-train: ejected — required check red. CodeQL (required on |
CodeQL flagged the comment stripper in the retired-entitlement guard
(js/incomplete-multi-character-sanitization). It is not an XSS sink — the
output is only regex-matched against `isEntitled('<id>')` — but the rule
points at a real hole in the guard: one pass over `<!--…-->` can leave a
fresh `<!--` behind (`<!-<!---->->` strips to `<!-->`), so a live gate
written inside a comment-shaped string could survive stripping and read as
a comment the guard was told to ignore.
Loops the three replaces until the source stops changing, and says in the
comment which of the two problems this is about.
Related to #2620
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bd71qsYbFodvofba8P69eP
…anel-gate # Conflicts: # docs/memory/learnings.md
|
merge-train (2026-09-09): pushed |
|
merge-train (2026-09-09, follow-up): the fresh run closed alert 349 but CodeQL raised 350 on the fixed-point loop itself ( |
|
CodeQL finding addressed in
The three replaces now run to a fixed point, and the comment says which of the two problems this is about. 3 specs pass. |
…anel-gate # Conflicts: # docs/memory/learnings.md
|
merge-train (2026-09-09): pushed a merge of |
Fixes #2620.
The bug
The Room budget defaults panel (Settings → Retention) was hidden on every install. It gated on
isEntitled('shared_sessions')— correct while rooms were an enterprise module — butent#443moved multi-agent rooms into OSS core:main.pymounts both routers unconditionally,shared_sessions/router.pyrecords in its own header that its routes "used to carryrequires_entitlement", and nothing registers that feature id any more.So the predicate was
Falseon every build, OSS and enterprise alike, while the endpoint behind it worked for any admin. Rooms functioned; only their dial was missing. Found by an operator asking why a room stopped at 59/60 messages and closed permanently, with no UI to raise the default.The fix
Remove the gate. No authorization changes:
budget_routerisrequire_admin(plusreject_agent_principalon the write), and the panel only mounts on Settings' Retention tab, which isadminOnly: true. The panel'sload()also had anif (!entitled.value) return, so even had it rendered it would have shown empty — that goes too.Why this is more than one panel
Second instance of one class.
ent#356did exactly this toclient_portal, caught inNavBar.vue— whose comment even named the mechanism that would eventually break it:The failure is invisible in the worst way: nothing errors, no route 404s, the capability works, and only a control disappears — so it surfaces as a user asking where a setting went, not as a bug report.
Two things to stop a third:
retiredEntitlementGates.spec.jslists the ids that moved to OSS (client_portal,shared_sessions) and fails on anyisEntitled(...)gate naming one, anywhere undersrc/frontend/src. Adding an id to that list is the second half of moving a module to OSS. Comments are stripped before matching, so the two files that document this history don't read as committing it.shared_sessions.service.FEATURE_IDis documented as retired rather than deleted. The issue suggested removing it, but the private enterprise submodule is not visible from here and may still import the name — deleting a module-level constant a hidden consumer might import is a break I could not test. It is now explicitly marked as not-an-entitlement-id, since a surviving constant is precisely what makes this read like a live gate to the next person.Verification
v-if="entitled"gate and its computed fails 2 of the guard's 3 cases (no gate references a feature id that moved to OSS core,the room budget panel renders without an entitlement check). Panel restored byte-exact afterwards.isEntitled('shared_sessions')had exactly one occurrence, and the other live gates (slack_per_agent_bots,cross_model_validation,user_management,retention) all name modules that are still entitled.Docs:
learnings.mdrecords the class.Related to #2620
🤖 Generated with Claude Code
https://claude.ai/code/session_01CdxGmvuKuqaWJZ6pKUgaUJ