componentsfix(playbook): the four things wrong with the trigger UI
1. TWO GLYPHS THAT SAID NOTHING.
The meeting glyph was invisible. It asked `connectionLogo('meetings')`, which
resolves to the hand-drawn icons.tsx Calendar — an `<svg fill="none">` whose
path sets no fill of its own, so it inherits `none` and paints NOTHING unless
the call site passes a `fill-*` class. That row passes only a size. Fixed at
both ends: the glyph row now uses lucide's Calendar (which carries its own
stroke), and the shared icon gets `fill="currentColor"` on its path so every
other call site that passes only a size renders too. It is a presentation
attribute, which a CSS class beats, so date-picker-dialog's `fill-[#9D9D9D]`
is unaffected.
The external-CRM glyph was sync arrows, which read as "refresh" — an action,
not an event — and named neither the event nor the system. Both marks now come
from the conversation timeline (Calendar, Building2), which is where a user has
already learned what these events look like. The trigger badge follows suit.
2. THE MEMBER PICKER MOVES INTO THE BREADCRUMB ROW.
It answers the same question the crumbs do — WHOSE playbook is this — so on its
own row underneath it read as a filter over the content rather than as part of
the address, and cost a row of height on every load for a control most sessions
never touch. Only the picker moves: the banner and the permission notice stay in
AdministeredUserBar, because they are full-width messages and squeezing either
into a title row would truncate it or shove the crumbs sideways as it appears.
The bar now collapses to nothing when it holds neither.
3. A REF WITH NO INSTRUCTION HAD NO WAY TO GET ONE.
Every ref authored before this feature is self-closing, so it parses to the
inline chip — and the chip had nowhere to put an instruction. That is not a
migration to wait out; it is every ref in every playbook today. The chip now
carries a second half, `+ Instructions`, which converts it into the panel with
the caret already in the instruction area.
Offered ONLY inside a trigger: a ref in <always-loaded> is a document the agent
reads, not an agent a trigger fires, so there is no trigger for it to have an
instruction for. The shared FileLinkNode does not learn what a trigger is — it
asks its host "does anyone want a segment on this chip", and the playbook is the
only host that answers.
The conversion carries `section`/`when` across (dropping them would turn "add an
instruction" into "quietly widen this ref's scope") and has two shapes: a chip
alone in its paragraph replaces the paragraph, leaving no empty one behind; a
chip among prose leaves the prose as the block's own note.
4. THE DISPATCH EXPLAINER MOVES INTO A `?`.
It was a line under the pill. But it is REFERENCE, not status — identical for
every trigger of a kind, never changing — and a sentence that never changes on a
row you read daily stops being read within a week while still costing a line of
height on every block in the document. Behind a `?` it is one click away on the
day you need it and invisible on the days you do not.
The popover answers two things in the order they are needed: what wakes it
(with the four event glyphs when the trigger is `any`, since "every event" is
the one label that names no thing), and what happens then — whether an
orchestrator reads this block and CHOOSES, or every agent in it simply runs.
4,103 mail tests green; tsc -b clean.
Co-Authored-By: Claude Opus 5 <<email>>Sep 7, 2026, 11:00 PMhooksfeat(permissions): one authority for every access decision
Consolidates every "may this caller act on this data" decision onto a single
function, and closes eleven instances of one bug class found along the way.
## The authority
services/auth/authorize.ts returns a VERDICT, never a role. There is
deliberately no isOrgAdmin on the return type, so no call site can branch on
one, and changing the permission model is a change to the DEFAULT_POLICY table
rather than a sweep through the codebase. Policy is a frozen constant keyed
(resource, scope); a missing key fails closed. resolvePolicy(orgId) is the seam
for per-org policy later, so this ships with ZERO schema changes.
Replaces canActOnTarget, resolveOrgMembership, userIsOrgAdmin, an inline
same-org check in listTasksTool, and eleven `if (!isOrgAdmin)` sites that each
re-derived the rule. One of them computed it on the wrong user.
## Holes closed
All the same shape: a caller-supplied identifier resolving to another user's
data below the gate.
applyConfigChangeTool userId input, write
readConnectionSettings userId input, read
writeDocumentTool org-linked playbook path redirect
listDocumentsTool same, ungated, plus displayDocumentTool
configureStrategistTool bare aopId, no check at all
getSentEmailsTool userId input, verbatim sent/drafted bodies
aopAgents.listForAop subagent docs to any authenticated caller
agent-action-queue 5 routes, incl. delete and replay (destructive)
custom-field-management ownerId resolved from the verdict, then ignored
overview-instance org-scope write with no tenant comparison
admin router ~45 procedures + 14 sub-routers, cross-tenant
Two were destructive, three were cross-tenant writes, one was introduced by an
earlier fix in this same change set and caught by a later sweep.
## Feature
Org admins can administer a teammate's playbook and configuration. targetUserId
is uniform across the tool layer, the tRPC layer gained the org-admin path, and
the frontend gained a scoped provider and member picker that never renders one
person's data labelled as another's.
## Enforcement
Five guards keyed on the LIVE REGISTRY, not hand-written lists, each with an
anti-vacuity floor and each proven by planting the violation:
no bare userId inputs on any registered tool
every targetUserId file reaches the authority
every mutating action is administrable or documented self-only
role reads confined to the authority (depcruise rule + source scan)
redirect sites consult the authority about the RESOLVED target
## Notes
- agentExecutionEnabled is Cedar-staff-only, enforced in the single writer.
Stronger than SELF, so deliberately not a policy row.
- playbook:org and document:org are ANY_MEMBER, matching today's behaviour.
57 of 73 orgs have no admin; tightening is one cell once they do.
- Fixes two org-role gates that read undefined on every request and so refused
everyone, including statistics.getOrgOverview.
- config-write split: apply-change moved to connection-write. 17,615 -> 10,142
bytes against the connector's silent-drop ceiling.
Verified: server 799 files / 9,310 tests; mail 316 suites / 2,959 tests; types
and deps:check clean. End-to-end against the production Cedar org with a
temporary non-staff account, 42 assertions across member/admin/viewer, since
staff accounts cannot exercise the org-grant branch. Account deleted and org
state verified identical to its pre-test backup.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 8:01 AMcontext.tsxfeat(permissions): one authority for every access decision
Consolidates every "may this caller act on this data" decision onto a single
function, and closes eleven instances of one bug class found along the way.
## The authority
services/auth/authorize.ts returns a VERDICT, never a role. There is
deliberately no isOrgAdmin on the return type, so no call site can branch on
one, and changing the permission model is a change to the DEFAULT_POLICY table
rather than a sweep through the codebase. Policy is a frozen constant keyed
(resource, scope); a missing key fails closed. resolvePolicy(orgId) is the seam
for per-org policy later, so this ships with ZERO schema changes.
Replaces canActOnTarget, resolveOrgMembership, userIsOrgAdmin, an inline
same-org check in listTasksTool, and eleven `if (!isOrgAdmin)` sites that each
re-derived the rule. One of them computed it on the wrong user.
## Holes closed
All the same shape: a caller-supplied identifier resolving to another user's
data below the gate.
applyConfigChangeTool userId input, write
readConnectionSettings userId input, read
writeDocumentTool org-linked playbook path redirect
listDocumentsTool same, ungated, plus displayDocumentTool
configureStrategistTool bare aopId, no check at all
getSentEmailsTool userId input, verbatim sent/drafted bodies
aopAgents.listForAop subagent docs to any authenticated caller
agent-action-queue 5 routes, incl. delete and replay (destructive)
custom-field-management ownerId resolved from the verdict, then ignored
overview-instance org-scope write with no tenant comparison
admin router ~45 procedures + 14 sub-routers, cross-tenant
Two were destructive, three were cross-tenant writes, one was introduced by an
earlier fix in this same change set and caught by a later sweep.
## Feature
Org admins can administer a teammate's playbook and configuration. targetUserId
is uniform across the tool layer, the tRPC layer gained the org-admin path, and
the frontend gained a scoped provider and member picker that never renders one
person's data labelled as another's.
## Enforcement
Five guards keyed on the LIVE REGISTRY, not hand-written lists, each with an
anti-vacuity floor and each proven by planting the violation:
no bare userId inputs on any registered tool
every targetUserId file reaches the authority
every mutating action is administrable or documented self-only
role reads confined to the authority (depcruise rule + source scan)
redirect sites consult the authority about the RESOLVED target
## Notes
- agentExecutionEnabled is Cedar-staff-only, enforced in the single writer.
Stronger than SELF, so deliberately not a policy row.
- playbook:org and document:org are ANY_MEMBER, matching today's behaviour.
57 of 73 orgs have no admin; tightening is one cell once they do.
- Fixes two org-role gates that read undefined on every request and so refused
everyone, including statistics.getOrgOverview.
- config-write split: apply-change moved to connection-write. 17,615 -> 10,142
bytes against the connector's silent-drop ceiling.
Verified: server 799 files / 9,310 tests; mail 316 suites / 2,959 tests; types
and deps:check clean. End-to-end against the production Cedar org with a
temporary non-staff account, 42 assertions across member/admin/viewer, since
staff accounts cannot exercise the org-grant branch. Account deleted and org
state verified identical to its pre-test backup.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 8:01 AMcontract.tsfeat(agents): org admin/owner can view a teammate's agent workspace
Backend (apps/server): agent.ts's 31-procedure Agent Workspace router only
ever resolved an agentId inside the CALLER's own AOP set, so an org admin
opening a teammate's saved agent got a generic NOT_FOUND regardless of role —
the gap behind Karim's (Adapt) report that Jacob's execution logs and Deal
Risk agent were unreachable even after run-sql was fixed to allow admin reads.
requireAgent now accepts an optional targetUserId and routes through
resolveAgentForAdmin (already built for debugTool.ts's test-run, PR #2749)
instead of resolveAgentForCaller when it names someone else, gated by
authorize()'s existing subagent_execution:user policy (SELF_OR_ORG_ADMIN).
Every procedure's downstream reads/writes now scope to the resolved
actorUserId instead of ctx.sessionUser.id — critically for runNow and
createWebhookSource, which must execute/register AS the target (their own
connections/credentials), not the calling admin's. duplicate stays self-only:
its single-userId design has no clean cross-user answer and that's a separate
product decision.
Frontend (apps/mail): even with the backend fixed, nothing could reach it —
CompanyExplorer already resolves a document's real owner (docOwnerUserId, off
documents.getDoc) but never passed it to AgentDocumentView, which calls
agent.list unscoped to map documentId -> agentId. That's the exact "document
access error" Karim hit clicking into Jacob's Deal Risk agent from Brain ->
Playbooks. targetUserId now threads CompanyExplorer -> AgentDocumentView ->
AgentView as an explicit prop (never read ambiently via useTargetUserId(),
since AgentView/AgentDocumentView also mount on surfaces with no admin
picker) into every trpc.agent.* call, with contract.ts asserting each route
actually accepts the field.
7 new regression tests (org-admin/plain-member/cross-org matrix, plus
runSingleSubagent identity-correctness) alongside the 3 administered-user
contract-coverage tests, all passing. Server + mail typecheck clean.
Co-Authored-By: Claude Sonnet 5 <<email>>
Claude-Session: https://claude.ai/code/session_01SfYKPYHXdMgrPLuKhXvWedSep 10, 2026, 9:04 PMindex.tsfeat(permissions): one authority for every access decision
Consolidates every "may this caller act on this data" decision onto a single
function, and closes eleven instances of one bug class found along the way.
## The authority
services/auth/authorize.ts returns a VERDICT, never a role. There is
deliberately no isOrgAdmin on the return type, so no call site can branch on
one, and changing the permission model is a change to the DEFAULT_POLICY table
rather than a sweep through the codebase. Policy is a frozen constant keyed
(resource, scope); a missing key fails closed. resolvePolicy(orgId) is the seam
for per-org policy later, so this ships with ZERO schema changes.
Replaces canActOnTarget, resolveOrgMembership, userIsOrgAdmin, an inline
same-org check in listTasksTool, and eleven `if (!isOrgAdmin)` sites that each
re-derived the rule. One of them computed it on the wrong user.
## Holes closed
All the same shape: a caller-supplied identifier resolving to another user's
data below the gate.
applyConfigChangeTool userId input, write
readConnectionSettings userId input, read
writeDocumentTool org-linked playbook path redirect
listDocumentsTool same, ungated, plus displayDocumentTool
configureStrategistTool bare aopId, no check at all
getSentEmailsTool userId input, verbatim sent/drafted bodies
aopAgents.listForAop subagent docs to any authenticated caller
agent-action-queue 5 routes, incl. delete and replay (destructive)
custom-field-management ownerId resolved from the verdict, then ignored
overview-instance org-scope write with no tenant comparison
admin router ~45 procedures + 14 sub-routers, cross-tenant
Two were destructive, three were cross-tenant writes, one was introduced by an
earlier fix in this same change set and caught by a later sweep.
## Feature
Org admins can administer a teammate's playbook and configuration. targetUserId
is uniform across the tool layer, the tRPC layer gained the org-admin path, and
the frontend gained a scoped provider and member picker that never renders one
person's data labelled as another's.
## Enforcement
Five guards keyed on the LIVE REGISTRY, not hand-written lists, each with an
anti-vacuity floor and each proven by planting the violation:
no bare userId inputs on any registered tool
every targetUserId file reaches the authority
every mutating action is administrable or documented self-only
role reads confined to the authority (depcruise rule + source scan)
redirect sites consult the authority about the RESOLVED target
## Notes
- agentExecutionEnabled is Cedar-staff-only, enforced in the single writer.
Stronger than SELF, so deliberately not a policy row.
- playbook:org and document:org are ANY_MEMBER, matching today's behaviour.
57 of 73 orgs have no admin; tightening is one cell once they do.
- Fixes two org-role gates that read undefined on every request and so refused
everyone, including statistics.getOrgOverview.
- config-write split: apply-change moved to connection-write. 17,615 -> 10,142
bytes against the connector's silent-drop ceiling.
Verified: server 799 files / 9,310 tests; mail 316 suites / 2,959 tests; types
and deps:check clean. End-to-end against the production Cedar org with a
temporary non-staff account, 42 assertions across member/admin/viewer, since
staff accounts cannot exercise the org-grant branch. Account deleted and org
state verified identical to its pre-test backup.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 8:01 AMpermission-error.tsfeat(permissions): one authority for every access decision
Consolidates every "may this caller act on this data" decision onto a single
function, and closes eleven instances of one bug class found along the way.
## The authority
services/auth/authorize.ts returns a VERDICT, never a role. There is
deliberately no isOrgAdmin on the return type, so no call site can branch on
one, and changing the permission model is a change to the DEFAULT_POLICY table
rather than a sweep through the codebase. Policy is a frozen constant keyed
(resource, scope); a missing key fails closed. resolvePolicy(orgId) is the seam
for per-org policy later, so this ships with ZERO schema changes.
Replaces canActOnTarget, resolveOrgMembership, userIsOrgAdmin, an inline
same-org check in listTasksTool, and eleven `if (!isOrgAdmin)` sites that each
re-derived the rule. One of them computed it on the wrong user.
## Holes closed
All the same shape: a caller-supplied identifier resolving to another user's
data below the gate.
applyConfigChangeTool userId input, write
readConnectionSettings userId input, read
writeDocumentTool org-linked playbook path redirect
listDocumentsTool same, ungated, plus displayDocumentTool
configureStrategistTool bare aopId, no check at all
getSentEmailsTool userId input, verbatim sent/drafted bodies
aopAgents.listForAop subagent docs to any authenticated caller
agent-action-queue 5 routes, incl. delete and replay (destructive)
custom-field-management ownerId resolved from the verdict, then ignored
overview-instance org-scope write with no tenant comparison
admin router ~45 procedures + 14 sub-routers, cross-tenant
Two were destructive, three were cross-tenant writes, one was introduced by an
earlier fix in this same change set and caught by a later sweep.
## Feature
Org admins can administer a teammate's playbook and configuration. targetUserId
is uniform across the tool layer, the tRPC layer gained the org-admin path, and
the frontend gained a scoped provider and member picker that never renders one
person's data labelled as another's.
## Enforcement
Five guards keyed on the LIVE REGISTRY, not hand-written lists, each with an
anti-vacuity floor and each proven by planting the violation:
no bare userId inputs on any registered tool
every targetUserId file reaches the authority
every mutating action is administrable or documented self-only
role reads confined to the authority (depcruise rule + source scan)
redirect sites consult the authority about the RESOLVED target
## Notes
- agentExecutionEnabled is Cedar-staff-only, enforced in the single writer.
Stronger than SELF, so deliberately not a policy row.
- playbook:org and document:org are ANY_MEMBER, matching today's behaviour.
57 of 73 orgs have no admin; tightening is one cell once they do.
- Fixes two org-role gates that read undefined on every request and so refused
everyone, including statistics.getOrgOverview.
- config-write split: apply-change moved to connection-write. 17,615 -> 10,142
bytes against the connector's silent-drop ceiling.
Verified: server 799 files / 9,310 tests; mail 316 suites / 2,959 tests; types
and deps:check clean. End-to-end against the production Cedar org with a
temporary non-staff account, 42 assertions across member/admin/viewer, since
staff accounts cannot exercise the org-grant branch. Account deleted and org
state verified identical to its pre-test backup.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 8:01 AM