The matter nobody can reach
Orphan means EMPTY, not "no lead"
The definition is strict and it matters: an orphan is a matter with
zero business.matter_assignments rows — no lead
and no members. A matter that has merely lost its lead still has
people on it who can act, and the accepted No-lead state (F3) is exactly that
situation. Widening the exception to "no lead" would hand every editor the
door to a matter that already has a team, which is Story-2677's containment
deleted rather than qualified.
Because the definition is load-bearing in two places, it is written once. The
policy in migration 0254 and the application gate both ask
business.matter_team_is_empty(firm_id, matter_id). A gate that
opened the door on one definition while the policy admitted writes on another
would be a door onto a wall.
Entry alone would have been a dead end
The ticket scoped this as an entry-gate change, with the adopt actions
already delivered by F3/F4. They are — for staffed users. Every
adopt action (claim lead, assign someone, add self) routes through the
team:staff capability, and that capability requires an
assignment row on the exact matter, which an orphan by definition has nobody
holding. Opening only the entry gate would have delivered a page whose every
button returns 403. So the same exception reaches three layers, and stops
there:
| Layer | What changed | What deliberately did not |
|---|---|---|
_require_matter_detail_entry |
An active firm_editor may ENTER a true orphan, and the
open is audited. |
System reads, the admin bypass, staffed users, and the matters LIST are all untouched. |
_require_matter_capability_access |
team:staff on a true orphan for an active editor. |
content:write stays shut — an editor cannot edit an
orphan's content until they have actually adopted it. |
matter_assignment_insert_policy (0254) |
INSERT admits the same case, via the shared helper. | UPDATE and DELETE keep the 0192 predicate verbatim — an orphan has no rows to update or delete. |
The grant that had to travel with the code
Migration 0192 made the team-write policies "pure lateral reach": firm admin, or an existing assignment row on that matter. That is the database twin of the application staffing floor, and it is why this could not be an application change alone. With the app layers opened and the policy untouched, the adopt path did not fail politely — it 500'd:
PUT /app/matters/2/lead → 500 asyncpg.exceptions.InsufficientPrivilegeError: new row violates row-level security policy for table "matter_assignments" [SQL: INSERT INTO business.matter_assignments (... team_role ...) VALUES (...)]
That is R2 as a measurement rather than a maxim, and it is this change's
observed-red evidence: the integration test below was written first, run
against the two application layers alone, and reported exactly this. The
migration is what turns it green. The clause is also
self-extinguishing — the helper is STABLE, so it
runs on the statement snapshot and answers "was this team empty before the
write". The first adopted row makes the matter non-empty, and every write
after it goes back through lateral reach.
Where DL28 narrows an existing guarantee, on the record
Story-3388 shipped an invariant that a removed editor cannot re-insert themselves — and its test built the removal by taking the only member off, which under DL28 is an orphan. The two decisions genuinely collide in exactly one case, and DL28 is the later and more specific one: once the roster is empty the removed editor is not a special principal, they are an active firm editor like any other, and the alternative is a matter no human below firm_admin can ever reach.
Rather than delete the guarantee or leave a stale assertion,
test_removed_opening_editor_cannot_reinsert_assignment now pins
both sides of the boundary: the matter keeps a colleague, the removed
editor's INSERT is still refused by RLS, and only after the last member is
taken off does the identical INSERT succeed.
Scenarios, and the one that is stronger than specified
| Scenario | Result | |
|---|---|---|
| S48 | admin opens an orphan | 200 — pre-existing bypass, unchanged |
| S49 | any active firm_editor opens an ORPHAN | 200 — the exception, audited |
| S50 | editor claims the vacant lead on an orphan | 200, roster = that editor as lead_counsel |
| S51 | editor (and admin) seats someone else on an orphan | 200, actor left off-roster and still gets the change back |
| S52 | editor adds self as member | 200, noLead: true — the accepted No-lead state |
| S53 | plain firm_member opens an orphan | 404 — see below |
| S55 | unstaffed editor opens a NON-orphan | 403 pramaan.matter.not_on_team |
S53 is nominally a 403 and the system gives a 404. That is
the pre-existing and stronger answer, not a miss: RLS
(business.can_user_read_matter) admits a firm_member
to a matter only through an assignment row, so an unstaffed member cannot see
that the matter exists and the read 404s before the entry gate is consulted at
all. pramaan.matter.not_on_team is by construction reachable only
by a firm_editor, who alone sees every matter in the firm and is
therefore the only principal the gate ever has to turn away. Asserting 403
there would have been asserting a weaker outcome than the one the system
gives.
S55 is the mandatory regression and it is asserted first in the test, before any orphan is opened — an implementation that passed by opening everything would fail on line one rather than at the end.
Why a plain read is not the orphan check
The count comes from a SECURITY DEFINER helper, never a direct
matter_assignments SELECT, and the reason is a trap rather than a
style preference. That table's SELECT policy shows a firm_member
only their OWN row. A direct count would therefore return zero on a fully
staffed matter — reading as an orphan to precisely the caller who must not
adopt one.
Green-signal sentence
This change asserts that a matter with an empty roster is enterable
and adoptable by an active firm editor, at all three layers that
would otherwise refuse it. It asserts nothing about a matter that still has
anyone on it — S55 and the rewritten 3388 invariant both pin that the
ordinary staffing floor is unchanged — and nothing about matter CONTENT,
which stays behind content:write until the editor has actually
adopted the matter.