AccessRequests.tsxfeat: approving a request is a review, so the server stops guessing
The share panel approves by staging the requester into the same invite field
everyone else is added through. That shape asked three things the queue-only
version never had to.
The request now carries the requester's address and photo. The panel had been
filling that gap from the org directory, which holds only while every requester
is an org member — a guest staged with no address at all.
`decideAccessRequest` takes the share ladder, not the three rungs a request may
ask for. The approver decides with the full role menu open, so "Full access" was
a role they could pick and then not send; the route fell back to what was asked
for. The ceiling is unchanged — `decideRequest` still caps at the decider's own.
And `settleOnly`, for "the grant is already written, just close the row." The
panel grants first, so recording the answer meant attempting an insert that
already existed. That was safe by accident: a partial unique index made the
second write a no-op. It is not a thing to rest a permission on.
The CLI's one role ladder splits into the two that exist — four rungs for a
person, three for a link, because a link hands its role to whoever holds the URL.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>
Claude-Session: https://claude.ai/code/session_014LBvyeyqQ8U7J2aRYpD9mLSep 9, 2026, 2:34 PMAudienceAvatar.tsxfeat: the panel says "everyone with this agent" (design: agent-scoped-access phase 5)
Inside an agent's namespace the General access row's subject is the AGENT, not
the organisation — a document under an agent cannot be reached without it, so the
organisation is not the boundary there and its member count is a different set of
people.
Three things the plan did not survive.
NO ACCESS REVOKES, IT DOES NOT FLIP THE BARRIER. The barrier cuts what an
ancestor's grants reach; an agent grant sits on the document itself. Flipping it
would have left every holder exactly where they were while appearing to remove
them. No count precedes it either: `visibilityPreview` counts organisation
members crossing a barrier, and a number answering a different question is worse
than none.
THE PRINCIPAL IS READ OFF `via`, NOT GUESSED FROM SHAPE. `removePerson` and
`setRole` derived it as `userId ? user : email ? email : null` — right for a
person, silently wrong for the org line and any agent row, both of which carry
their subject's id in `userId`. A revoke would have returned ok and changed
nothing. Found by testing a grant naming some OTHER agent, which stays in the
people list because a standing the panel will not draw is one nobody can take
away.
And `files.grantAccess` reported phase 3's `agent_not_administered` as "Document
not found", so a member picking a role was told the document does not exist.
Also indexes agent-scoped-access.md in sharing-overview.md, which still said
"The four documents" and did not list it — a fifth doc unreachable from the map
that exists to prevent exactly that.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>
Claude-Session: https://claude.ai/code/session_014LBvyeyqQ8U7J2aRYpD9mLSep 9, 2026, 10:36 AMGeneralAccess.tsxfix(sharing): close the gates a review found open, and make one predicate mean one thing
Six holes, and they fall into two shapes.
SURFACES NEVER BROUGHT ONTO THE GATE. `documents.createShareLink` and its four
siblings were written before the access model and check tenancy alone, so any
member of an org could mint a permanent anonymous public link to any document in
it — a teammate's file, a deal they are not on, a folder explicitly made private
— and `/share/:token` honours those tokens. `agentSharing.list`/`resend` asked
only whether the caller could RESOLVE the agent, which for an org-published one
is true of everybody, so an agent's whole recipient list (names and addresses)
was readable by the tenant and its invite notifications were a primitive anyone
could aim at anyone. And `getFolderVisibility` took an id and no viewer at all,
which made `visibilityPreview` an existence oracle over every organisation.
TWO RENDERINGS THAT HAD DRIFTED. The floor was a chain guarded on `floor ===
null` where `accessibleFilter` renders independent OR arms — so the first arm to
match suppressed the rest, and a read-only seat on a deal capped you on your own
file inside it. `agentIdFromPath` scanned every segment while the SQL tests two,
so a folder named `agent-{id}` four deep was reachable by id and invisible in
every listing. Both are the same failure: a listing that admits a row the
mutation then refuses, which is the split this subsystem exists to prevent.
Also: `grantAccess` took a client-supplied user id with no membership check, and
`namesFor` looks users up by id alone — so the share control doubled as a
cross-tenant directory. `viewerFor` trusted its `orgId`, which the filter renders
and the resolver does not. Private→public silently dropped the role you picked,
because it read the org's id off an entry a barrier had cut. Plus a capped
password on the unauthenticated unlock route (it reaches a blocking scryptSync),
beacons bound to their token, two disjoint cache invalidations, a hardcoded
"Owner" on every viewer of an agent panel, and a deterministic grant tie-break.
`refusals.test.ts` pins all six; the two floor cases were checked against the old
chain to be sure they fail for the right reason.
Rides along: the `autoExpandDepth` work already in the tree. Two of its five
files carry review fixes as well and cannot be split, so the test and doc that
prove it come with them rather than landing a behaviour change without either.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:14 PMInviteField.tsxfix(sharing): close the gates a review found open, and make one predicate mean one thing
Six holes, and they fall into two shapes.
SURFACES NEVER BROUGHT ONTO THE GATE. `documents.createShareLink` and its four
siblings were written before the access model and check tenancy alone, so any
member of an org could mint a permanent anonymous public link to any document in
it — a teammate's file, a deal they are not on, a folder explicitly made private
— and `/share/:token` honours those tokens. `agentSharing.list`/`resend` asked
only whether the caller could RESOLVE the agent, which for an org-published one
is true of everybody, so an agent's whole recipient list (names and addresses)
was readable by the tenant and its invite notifications were a primitive anyone
could aim at anyone. And `getFolderVisibility` took an id and no viewer at all,
which made `visibilityPreview` an existence oracle over every organisation.
TWO RENDERINGS THAT HAD DRIFTED. The floor was a chain guarded on `floor ===
null` where `accessibleFilter` renders independent OR arms — so the first arm to
match suppressed the rest, and a read-only seat on a deal capped you on your own
file inside it. `agentIdFromPath` scanned every segment while the SQL tests two,
so a folder named `agent-{id}` four deep was reachable by id and invisible in
every listing. Both are the same failure: a listing that admits a row the
mutation then refuses, which is the split this subsystem exists to prevent.
Also: `grantAccess` took a client-supplied user id with no membership check, and
`namesFor` looks users up by id alone — so the share control doubled as a
cross-tenant directory. `viewerFor` trusted its `orgId`, which the filter renders
and the resolver does not. Private→public silently dropped the role you picked,
because it read the org's id off an entry a barrier had cut. Plus a capped
password on the unauthenticated unlock route (it reaches a blocking scryptSync),
beacons bound to their token, two disjoint cache invalidations, a hardcoded
"Owner" on every viewer of an agent panel, and a deterministic grant tie-break.
`refusals.test.ts` pins all six; the two floor cases were checked against the old
chain to be sure they fail for the right reason.
Rides along: the `autoExpandDepth` work already in the tree. Two of its five
files carry review fixes as well and cannot be split, so the test and doc that
prove it come with them rather than landing a behaviour change without either.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:14 PMLinkSection.tsxfix(sharing): close the gates a review found open, and make one predicate mean one thing
Six holes, and they fall into two shapes.
SURFACES NEVER BROUGHT ONTO THE GATE. `documents.createShareLink` and its four
siblings were written before the access model and check tenancy alone, so any
member of an org could mint a permanent anonymous public link to any document in
it — a teammate's file, a deal they are not on, a folder explicitly made private
— and `/share/:token` honours those tokens. `agentSharing.list`/`resend` asked
only whether the caller could RESOLVE the agent, which for an org-published one
is true of everybody, so an agent's whole recipient list (names and addresses)
was readable by the tenant and its invite notifications were a primitive anyone
could aim at anyone. And `getFolderVisibility` took an id and no viewer at all,
which made `visibilityPreview` an existence oracle over every organisation.
TWO RENDERINGS THAT HAD DRIFTED. The floor was a chain guarded on `floor ===
null` where `accessibleFilter` renders independent OR arms — so the first arm to
match suppressed the rest, and a read-only seat on a deal capped you on your own
file inside it. `agentIdFromPath` scanned every segment while the SQL tests two,
so a folder named `agent-{id}` four deep was reachable by id and invisible in
every listing. Both are the same failure: a listing that admits a row the
mutation then refuses, which is the split this subsystem exists to prevent.
Also: `grantAccess` took a client-supplied user id with no membership check, and
`namesFor` looks users up by id alone — so the share control doubled as a
cross-tenant directory. `viewerFor` trusted its `orgId`, which the filter renders
and the resolver does not. Private→public silently dropped the role you picked,
because it read the org's id off an entry a barrier had cut. Plus a capped
password on the unauthenticated unlock route (it reaches a blocking scryptSync),
beacons bound to their token, two disjoint cache invalidations, a hardcoded
"Owner" on every viewer of an agent panel, and a deterministic grant tie-break.
`refusals.test.ts` pins all six; the two floor cases were checked against the old
chain to be sure they fail for the right reason.
Rides along: the `autoExpandDepth` work already in the tree. Two of its five
files carry review fixes as well and cannot be split, so the test and doc that
prove it come with them rather than landing a behaviour change without either.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:14 PMMoveAudienceGate.tsxfix(tests): complete the fixtures the workspace types grew (types:test, 4/n)
`AgentSummary` gained `namespace`/`defaultFile`/`chatEnabled`, `ResolvedAgent`
gained `frontmatter`/`content`, `ResolvedFileGrant` gained `namespace`, and
`SubagentSummary` gained `instructions` — none of which the fixtures said.
The access harness re-declared the grant principal union by hand, which is how
`agent` could be added to `PrincipalType` without the harness ever knowing; it
derives from the column's own type now, so the next principal cannot silently
skip it.
73 -> 64 errors.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 9, 2026, 10:48 AMPersonRow.tsxfeat: the panel says "everyone with this agent" (design: agent-scoped-access phase 5)
Inside an agent's namespace the General access row's subject is the AGENT, not
the organisation — a document under an agent cannot be reached without it, so the
organisation is not the boundary there and its member count is a different set of
people.
Three things the plan did not survive.
NO ACCESS REVOKES, IT DOES NOT FLIP THE BARRIER. The barrier cuts what an
ancestor's grants reach; an agent grant sits on the document itself. Flipping it
would have left every holder exactly where they were while appearing to remove
them. No count precedes it either: `visibilityPreview` counts organisation
members crossing a barrier, and a number answering a different question is worse
than none.
THE PRINCIPAL IS READ OFF `via`, NOT GUESSED FROM SHAPE. `removePerson` and
`setRole` derived it as `userId ? user : email ? email : null` — right for a
person, silently wrong for the org line and any agent row, both of which carry
their subject's id in `userId`. A revoke would have returned ok and changed
nothing. Found by testing a grant naming some OTHER agent, which stays in the
people list because a standing the panel will not draw is one nobody can take
away.
And `files.grantAccess` reported phase 3's `agent_not_administered` as "Document
not found", so a member picking a role was told the document does not exist.
Also indexes agent-scoped-access.md in sharing-overview.md, which still said
"The four documents" and did not list it — a fifth doc unreachable from the map
that exists to prevent exactly that.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>
Claude-Session: https://claude.ai/code/session_014LBvyeyqQ8U7J2aRYpD9mLSep 9, 2026, 10:36 AMPillMenu.tsxrefactor(sharing): one share panel, in Notion's shape, wherever people are added
Documents, agents and deals each answered "who else is on this" in a different
visual language, and the document panel answered it three times over — a
General access row, a Private switch and an Anyone-with-the-link row, stacked,
so no reader could say what a document's audience actually was.
One panel now, one field, one kind of row:
- General access is a row in the list of people, because that is what it is.
Its ladder ends in No access, where Remove sits on a person's row, and that
is what writes the barrier — the Private switch is gone.
- Publishing is not a value of that ladder. It is its own tab, opening on one
button rather than a hairline row reading Off.
- Agents get a View as tab, with the sentence a chevron had no room for: their
copy has their own triggers, files and runs.
- Deal membership is the same panel, with ownership as a real standing —
Make owner, wired to the reassignment service that was already there.
- manager reads as Full access. "Can manage" beside "Can edit" named a fourth
verb where the difference is who may re-share.
Every row is one control, the whole row is the target, and the second line is
the person's address, never a sentence about why they are there. Faces come
from user.image with the initials fallback, so a share list is people rather
than a column of tinted discs.
Adds the first frontend tests for conversation membership, which had none.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>
Claude-Session: https://claude.ai/code/session_014LBvyeyqQ8U7J2aRYpD9mLSep 8, 2026, 12:14 PMRequestAccessScreen.tsxfeat: a refused agent says so, and offers the way in
The agent page rendered "This agent could not be loaded" with the raw error
underneath — which is what a refusal, a deleted agent and a network drop all look
like. A refusal is not a failure and must not render as one; documents learned
that in Phase 6 and agents had not.
It now shows the request-access screen, saying "agent" rather than "document".
Only on NOT_FOUND, which is what agent.get answers both for an agent you may not
open and for one that is not there — deliberately the same answer, since telling
them apart tells you an agent exists. Everything else keeps the error rendering.
The hard part is that the caller has an agent id from a URL and cannot turn it
into a document id: the resolver that would is the one refusing them.
files.agentRefusalContext does it server-side, finding the subagents row by
metadata->>'agent_id' org-wide and keeping the ANSWER narrow — a document id only
for a row in the caller's own org, the same empty context for "no such agent" as
for "an agent in another organisation", and never the agent's name. It prefers the
org-level row when a rep also holds their own copy, because a grant on somebody's
personal copy is not an audience any approver administers.
The route is on files rather than agent: every procedure on the agent router goes
through resolveAgentForCaller, whose documented invariant is that it never returns
another user's agent — which is every agent this answers about.
One screen, not two. An agent is a document, and a second screen saying the same
sentence is a second screen to keep in step.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>
Claude-Session: https://claude.ai/code/session_014LBvyeyqQ8U7J2aRYpD9mLSep 12, 2026, 10:13 AMRoleMenu.tsxfix(tests): complete the fixtures the workspace types grew (types:test, 4/n)
`AgentSummary` gained `namespace`/`defaultFile`/`chatEnabled`, `ResolvedAgent`
gained `frontmatter`/`content`, `ResolvedFileGrant` gained `namespace`, and
`SubagentSummary` gained `instructions` — none of which the fixtures said.
The access harness re-declared the grant principal union by hand, which is how
`agent` could be added to `PrincipalType` without the harness ever knowing; it
derives from the column's own type now, so the next principal cannot silently
skip it.
73 -> 64 errors.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 9, 2026, 10:48 AMSharedWithMeRoot.tsxfix(sharing): close the gates a review found open, and make one predicate mean one thing
Six holes, and they fall into two shapes.
SURFACES NEVER BROUGHT ONTO THE GATE. `documents.createShareLink` and its four
siblings were written before the access model and check tenancy alone, so any
member of an org could mint a permanent anonymous public link to any document in
it — a teammate's file, a deal they are not on, a folder explicitly made private
— and `/share/:token` honours those tokens. `agentSharing.list`/`resend` asked
only whether the caller could RESOLVE the agent, which for an org-published one
is true of everybody, so an agent's whole recipient list (names and addresses)
was readable by the tenant and its invite notifications were a primitive anyone
could aim at anyone. And `getFolderVisibility` took an id and no viewer at all,
which made `visibilityPreview` an existence oracle over every organisation.
TWO RENDERINGS THAT HAD DRIFTED. The floor was a chain guarded on `floor ===
null` where `accessibleFilter` renders independent OR arms — so the first arm to
match suppressed the rest, and a read-only seat on a deal capped you on your own
file inside it. `agentIdFromPath` scanned every segment while the SQL tests two,
so a folder named `agent-{id}` four deep was reachable by id and invisible in
every listing. Both are the same failure: a listing that admits a row the
mutation then refuses, which is the split this subsystem exists to prevent.
Also: `grantAccess` took a client-supplied user id with no membership check, and
`namesFor` looks users up by id alone — so the share control doubled as a
cross-tenant directory. `viewerFor` trusted its `orgId`, which the filter renders
and the resolver does not. Private→public silently dropped the role you picked,
because it read the org's id off an entry a barrier had cut. Plus a capped
password on the unauthenticated unlock route (it reaches a blocking scryptSync),
beacons bound to their token, two disjoint cache invalidations, a hardcoded
"Owner" on every viewer of an agent panel, and a deterministic grant tie-break.
`refusals.test.ts` pins all six; the two floor cases were checked against the old
chain to be sure they fail for the right reason.
Rides along: the `autoExpandDepth` work already in the tree. Two of its five
files carry review fixes as well and cannot be split, so the test and doc that
prove it come with them rather than landing a behaviour change without either.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:14 PMSharePanel.tsxfix(sharing): close the gates a review found open, and make one predicate mean one thing
Six holes, and they fall into two shapes.
SURFACES NEVER BROUGHT ONTO THE GATE. `documents.createShareLink` and its four
siblings were written before the access model and check tenancy alone, so any
member of an org could mint a permanent anonymous public link to any document in
it — a teammate's file, a deal they are not on, a folder explicitly made private
— and `/share/:token` honours those tokens. `agentSharing.list`/`resend` asked
only whether the caller could RESOLVE the agent, which for an org-published one
is true of everybody, so an agent's whole recipient list (names and addresses)
was readable by the tenant and its invite notifications were a primitive anyone
could aim at anyone. And `getFolderVisibility` took an id and no viewer at all,
which made `visibilityPreview` an existence oracle over every organisation.
TWO RENDERINGS THAT HAD DRIFTED. The floor was a chain guarded on `floor ===
null` where `accessibleFilter` renders independent OR arms — so the first arm to
match suppressed the rest, and a read-only seat on a deal capped you on your own
file inside it. `agentIdFromPath` scanned every segment while the SQL tests two,
so a folder named `agent-{id}` four deep was reachable by id and invisible in
every listing. Both are the same failure: a listing that admits a row the
mutation then refuses, which is the split this subsystem exists to prevent.
Also: `grantAccess` took a client-supplied user id with no membership check, and
`namesFor` looks users up by id alone — so the share control doubled as a
cross-tenant directory. `viewerFor` trusted its `orgId`, which the filter renders
and the resolver does not. Private→public silently dropped the role you picked,
because it read the org's id off an entry a barrier had cut. Plus a capped
password on the unauthenticated unlock route (it reaches a blocking scryptSync),
beacons bound to their token, two disjoint cache invalidations, a hardcoded
"Owner" on every viewer of an agent panel, and a deterministic grant tie-break.
`refusals.test.ts` pins all six; the two floor cases were checked against the old
chain to be sure they fail for the right reason.
Rides along: the `autoExpandDepth` work already in the tree. Two of its five
files carry review fixes as well and cannot be split, so the test and doc that
prove it come with them rather than landing a behaviour change without either.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 8:14 PMShareTrigger.tsxrefactor(sharing): one share panel, in Notion's shape, wherever people are added
Documents, agents and deals each answered "who else is on this" in a different
visual language, and the document panel answered it three times over — a
General access row, a Private switch and an Anyone-with-the-link row, stacked,
so no reader could say what a document's audience actually was.
One panel now, one field, one kind of row:
- General access is a row in the list of people, because that is what it is.
Its ladder ends in No access, where Remove sits on a person's row, and that
is what writes the barrier — the Private switch is gone.
- Publishing is not a value of that ladder. It is its own tab, opening on one
button rather than a hairline row reading Off.
- Agents get a View as tab, with the sentence a chevron had no room for: their
copy has their own triggers, files and runs.
- Deal membership is the same panel, with ownership as a real standing —
Make owner, wired to the reassignment service that was already there.
- manager reads as Full access. "Can manage" beside "Can edit" named a fourth
verb where the difference is who may re-share.
Every row is one control, the whole row is the target, and the second line is
the person's address, never a sentence about why they are there. Faces come
from user.image with the initials fallback, so a share list is people rather
than a column of tinted discs.
Adds the first frontend tests for conversation membership, which had none.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>
Claude-Session: https://claude.ai/code/session_014LBvyeyqQ8U7J2aRYpD9mLSep 8, 2026, 12:14 PM