structurefeat(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 AMBlockCalloutNode.tsxfeat: add Deals Playbook document type with #trigger and @ references
Introduces a playbook document type: a stage-organized deal playbook where
#trigger callouts (event/cron/field-change configs) and @ reference chips
(resources, knowledge-base, org, subagents, crm-updater/next-steps tokens)
replace prose trigger notation. Reference chips open real, content-seeded
docs via a new playbook_resource doc type. Renders richly in /brain and at
/agents/playbook.
Co-Authored-By: Claude Opus 4.8 <<email>>Jun 12, 2026, 8:48 AMcompiled-playbook-types.tsfeat: Post API component compile + prompt (design: playbook-webhooks-and-post-api phase 4)
Post API is a doc-only block (config in <post-api> XML) the agent fires via the
existing external-system-integration / run-code-executor path — no tool/tRPC/row:
- CompiledPostApiBlock/Field types (server + mail mirror); <post-api> serialize
(name/url + header/field/instructions children); compile extractSectionPostApi
→ section.postApiBlocks.
- get-playbook-section renderPostApiSection() emits a <post_api_endpoints> prompt
directive (always included) telling the agent to load external-system-integration
and POST; static values/headers inlined (doc is the trust boundary), ai fields
described. Skill access surfaced via the directive (playbook agents have empty
allowedSkills = all skills).
- Round-trip serialize→compile + render unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jun 25, 2026, 3:16 PMcomposite-merge.tsfix: composite playbook renders empty due to invalid parsed content
The composite playbook editor loaded blank even though the server data
was intact. Root cause: empty markdown leaves (e.g. an empty ``` fence in
a cross-cutting block) parse to a codeBlock holding an empty text node
({type:'text',text:''}), which ProseMirror forbids — so setContent throws
"Empty text nodes are not allowed" and the whole doc fails to render.
Strip empty text nodes at the source (parsePlaybookXmlToJson) and again in
mergeComposite. Also hardens the open→render→save path so this class of
failure can't recur silently or destroy data:
- always fetch the playbook fresh (blacklist from IndexedDB persistence +
staleTime 0 / refetchOnMount 'always') so a stale/empty cached copy is
never rendered without a network call
- defer setContent to a microtask so TipTap's React NodeViews render
(React refuses flushSync inside a lifecycle)
- re-seed on fresh data without clobbering unsaved edits; surface a visible
error instead of a silent blank on hydration failure
- refuse to overwrite a populated playbook with an empty doc (server guard
in saveCompositePlaybook + client guard when unhydrated)
- getChatAgentPlaybook fetches its documentId fresh too
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 6, 2026, 9:32 PMCompositePlaybookDocument.tsxfix(auth): close chat-thread cross-user IDOR, restore admin scope on Brain playbook nav
chat.getThreads/getMessages/listThreadsFor* accepted a client-supplied userId
with no authorization check at all — any authenticated caller could read any
other user's chat threads and messages by naming their id. Gate every
cross-user read on the new chat_thread:user policy (SELF_OR_ORG_ADMIN, same
authority agent.* and documents.getDoc already use), and close the matching
write-side gap in createThread. Deduplicate the four call sites onto the
existing assertActor helper instead of reimplementing its self-path
short-circuit inline four times.
Separately: an org admin clicking an @resources/... reference or subagent
chip inside a teammate's playbook document lost the admin's targetUserId on
navigation (brainDocumentPath called without it), even though the query that
fetched the doc already had it in scope. The Knowledge explorer then re-fetched
by bare documentId with no scope and hit the per-user ownership check as the
admin, not the teammate — the same failure mode PlaybookAopSection's tree view
already handles correctly. Thread targetUserId/ownerUserId through the four
affected navigate(brainDocumentPath(...)) call sites.
Co-Authored-By: Claude Sonnet 5 <<email>>
Claude-Session: https://claude.ai/code/session_0187itFj1uakga2xd7kPk4qpSep 12, 2026, 11:36 AM1 defectHashMention.tsfeat: add Deals Playbook document type with #trigger and @ references
Introduces a playbook document type: a stage-organized deal playbook where
#trigger callouts (event/cron/field-change configs) and @ reference chips
(resources, knowledge-base, org, subagents, crm-updater/next-steps tokens)
replace prose trigger notation. Reference chips open real, content-seeded
docs via a new playbook_resource doc type. Renders richly in /brain and at
/agents/playbook.
Co-Authored-By: Claude Opus 4.8 <<email>>Jun 12, 2026, 8:48 AMHashMentionList.tsxfeat(playbook): make a board, a table or a doc from inside the playbook
A playbook could already REFERENCE any document. What it could not do was make one —
so the only thing it has ever created inline is a subagent, and an agent that needs a
board beside it was a two-surface job: leave the playbook, find the Files tree, make
the thing, come back, remember what you called it.
`#board` / `#table` / `#doc` (and the same three in `/`) now create the document and
drop a `<ref>` at the cursor, via `aop.createPlaybookDocument`.
Two things it deliberately does not do:
- The ref is NOT wrapped in a trigger. `insertSubagentRef` wraps its ref because a
subagent outside a trigger is an orphan that shows in the roster and never fires; a
BOARD inside one is a resource the playbook would try to run. Opposite defaults, so
they are two functions and two hooks rather than one with a flag.
- A board created here does not go under the AOP. Boards and tables are org/user-scoped
by convention, because the point of a board being a file is that a SECOND agent can be
granted the same one — filing it under whichever playbook happened to make it would
turn "grant both agents the roadmap" into a path naming one of them. `ORG_TABLES_ROOT`
/ `USER_TABLES_ROOT` join the board roots as the default home for a table with no path.
Board creation goes straight through `board-ops.createBoard`, the same implementation
the CLI, the kanban and the agent tool call, so a board made from a playbook cannot
arrive with a schema the other three would refuse. The template picker's options come
from the server for the same reason — it cannot offer an id `createBoard` would reject.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 10:44 AMIntegrationNode.tsxfeat(agents): frontend for agent-workspace phases 2-14
One pass over every UI surface the design describes:
- Config §2 Connections (ceiling ∩ grant, three tool states not two) + Files grants
- Memory tab: editable panes, char cap, accept/decline proposal cards, reflect
- Home: agents as rows grouped by folder, under a collapsed agenda
- Share menu with publish preview, folder picker, debug rail, Run now
- MCP permissions captured at connection setup, with New/Review badges
Backend gaps the UI exposed, closed rather than papered over:
- accepting a proposal now actually patches the instruction body (resolveProposal
only recorded the decision, so the card claimed a change that never happened)
- agent.replaceMemoryFile: a pane save was clear-then-append, so a failure between
them left the file at its bare heading
- agent.getFileGrants/setFileGrants; the frontmatter patcher learned list values,
written INLINE because its line parser mangles a YAML block list
- userSettings.homeSectionsCollapsed: the blob strips undeclared keys, so the
collapse preference was writing to nowhere. Hook is optimistic — deriving it
from the server value alone made every fold wait for a round trip.
Also fixed: normalizeAuthorizationHeader matched \s not \s, so pasting a full
'Bearer sk-…' produced 'Bearer Bearer sk-…'.
148 frontend tests, 1193 backend tests, both typechecks clean.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 25, 2026, 8:56 PMPlaybookDocument.tsxfix(auth): close chat-thread cross-user IDOR, restore admin scope on Brain playbook nav
chat.getThreads/getMessages/listThreadsFor* accepted a client-supplied userId
with no authorization check at all — any authenticated caller could read any
other user's chat threads and messages by naming their id. Gate every
cross-user read on the new chat_thread:user policy (SELF_OR_ORG_ADMIN, same
authority agent.* and documents.getDoc already use), and close the matching
write-side gap in createThread. Deduplicate the four call sites onto the
existing assertActor helper instead of reimplementing its self-path
short-circuit inline four times.
Separately: an org admin clicking an @resources/... reference or subagent
chip inside a teammate's playbook document lost the admin's targetUserId on
navigation (brainDocumentPath called without it), even though the query that
fetched the doc already had it in scope. The Knowledge explorer then re-fetched
by bare documentId with no scope and hit the per-user ownership check as the
admin, not the teammate — the same failure mode PlaybookAopSection's tree view
already handles correctly. Thread targetUserId/ownerUserId through the four
affected navigate(brainDocumentPath(...)) call sites.
Co-Authored-By: Claude Sonnet 5 <<email>>
Claude-Session: https://claude.ai/code/session_0187itFj1uakga2xd7kPk4qpSep 12, 2026, 11:36 AM3 defectsPlaybookDocumentCreateDialog.tsxfix(playbook): created resources belong to the document's owner
Review finding. Creating a board, table or document from a teammate's playbook
created it under the SIGNED-IN user, then inserted a reference to it into the
teammate's playbook, leaving them pointing at someone else's resource.
`PlaybookDocument` passed `ownerUserId` to `useSubagentCreation` and nothing
equivalent to `useDocumentCreation` one line below. But threading a prop was not
the fix: `aop.createPlaybookDocument` had no `targetUserId` in its input at all
and built its scope from `ctx.sessionUser.id`, so a client-supplied owner would
have been stripped by zod and the call would have succeeded with the wrong owner.
That silent-strip shape is the same one `listAopsForUser` had.
Server: `targetUserId` added, gated through `assertActorAllowed`, with BOTH the
actor and the org taken from the verdict. The org half matters as much as the
owner: five sibling routes in this file were fixed during review for pairing a
cross-org actor with the caller's organization, which writes rows invisible to
the customer and into Cedar's own org. Also adds the org-scope gate on the
`scope: 'org'` path, which previously had none.
Frontend: `ownerUserId` threaded through useDocumentCreation and the create
dialog. `CompositePlaybookDocument` had the identical defect, one line below its
own correct `useSubagentCreation({ ownerUserId })`, and is fixed the same way.
Tests assert the owner OF THE CREATED ROW for all three kinds, not that the call
succeeded, since a call succeeding with the wrong owner is the entire bug.
Reverting both owner slots to `ctx.sessionUser.id` fails 4 of 7.
Note for whoever extends the guards: this route was invisible to
target-user-id-reaches-the-authority because that guard flags files which NAME
`targetUserId` without reaching the authority, and this route named no user field
at all. The blind spot is "no identifier, so it silently uses the session user on
a surface that is not the session user's".
Verified: server types clean, 966 tests across routes and auth; mail tsc clean,
396 suites / 3768 passing.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 9:22 PMplaybookExtensions.tsfix(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 PM1 defectPlaybookStageNode.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 AMPlaybookTriggerNode.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 AMPostApiNode.tsxfeat(forms): the settings row layout, and a select that admits it opens
Every field is one row now — icon and name left, the sentence under it,
the control right — because a form with eight fields laid out
label-over-control gives the eye nothing to run down. Fields read the
layout from context (`FieldRows`), so a form declares it once instead of
per field.
The select rendered no chevron at all, and no call site had noticed: it
was a bordered box with a word in it. It now owns its chevron, comes in
two sizes instead of the four that had accumulated across 62 call sites,
and has a `ghost` variant for the ones that sit inside a sentence.
Two bugs found by reading computed styles rather than screenshots:
FormControl is a Slot that hardcoded `bg-popover`, so it was overriding
the background of every control in every react-hook-form in the app; and
the menu's `shadow-[...]` compiled to three fully transparent layers.
Geometry is measured off Linear's settings surface; the colours are
Cedar's own tokens, since our light theme is warm and theirs is grey.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>
Claude-Session: https://claude.ai/code/session_01DFkAfWRuLsdTkpxrFhTKYWAug 30, 2026, 11:37 PMReferenceMention.tsfeat: add Deals Playbook document type with #trigger and @ references
Introduces a playbook document type: a stage-organized deal playbook where
#trigger callouts (event/cron/field-change configs) and @ reference chips
(resources, knowledge-base, org, subagents, crm-updater/next-steps tokens)
replace prose trigger notation. Reference chips open real, content-seeded
docs via a new playbook_resource doc type. Renders richly in /brain and at
/agents/playbook.
Co-Authored-By: Claude Opus 4.8 <<email>>Jun 12, 2026, 8:48 AMReferenceMentionList.tsxfeat: add Deals Playbook document type with #trigger and @ references
Introduces a playbook document type: a stage-organized deal playbook where
#trigger callouts (event/cron/field-change configs) and @ reference chips
(resources, knowledge-base, org, subagents, crm-updater/next-steps tokens)
replace prose trigger notation. Reference chips open real, content-seeded
docs via a new playbook_resource doc type. Renders richly in /brain and at
/agents/playbook.
Co-Authored-By: Claude Opus 4.8 <<email>>Jun 12, 2026, 8:48 AMReferenceNode.tsxfeat: add Deals Playbook document type with #trigger and @ references
Introduces a playbook document type: a stage-organized deal playbook where
#trigger callouts (event/cron/field-change configs) and @ reference chips
(resources, knowledge-base, org, subagents, crm-updater/next-steps tokens)
replace prose trigger notation. Reference chips open real, content-seeded
docs via a new playbook_resource doc type. Renders richly in /brain and at
/agents/playbook.
Co-Authored-By: Claude Opus 4.8 <<email>>Jun 12, 2026, 8:48 AMreferences.tsfeat(playbook): the agent panel — a ref stops being a chip (design: per-trigger-instructions phase 6)
A ref inside a trigger was a 12px FileText glyph and a document title at py-0.5 —
the smallest thing in the block, indistinguishable from a link to a resource doc,
and the most important thing in the block. It becomes a block-level panel: the
real AgentAvatar and the agent's name, then its instruction underneath.
The instruction area is ALWAYS rendered. No toggle, no disclosure. An instruction
you have to click to see is one nobody audits, and an empty slot that shows itself
is what teaches the feature exists.
The empty state is a CSS ::before, never seeded text. Seeded text would round-trip
into the XML and every ref would ship with "Instructions for this trigger…" as its
real instruction, delivered to the agent. The test mutation-checks this: seeding
the placeholder as content makes both assertions fail.
Two keyboard rules the panel needs and a plain block node does not:
- Enter inserts a hardBreak (which the serializer writes as \n) rather than
splitting the panel into two refs pointing at the same agent.
- isolating, plus Backspace-deletes-empty-panel: without isolating, an
instructed panel merges its text upward and takes its documentId with it.
`triggerRef` is registered in ID_NODE_TYPES. Omitting it makes the schema strip the
node's nodeId on every save and re-seed the whole Y.Doc — the bug documented in
that file.
Also fixes a PRE-EXISTING round-trip bug found on the way: compile-playbook reads
`section`/`when` off a ref, but the editor emitted and parsed only `id`, so any
playbook edited in the UI silently lost them. Both ref shapes now carry them, in
both directions — fixing only one shape would have relocated the loss rather than
removed it.
@-insertion could not produce a panel as the design assumed: the @ menu is
PATH-addressed and a <ref> needs an id. ReferenceOption gained an optional
documentId, so an option that knows its document produces a panel and the system
tokens (@crm-updater, @next-steps), which have no document behind them, correctly
stay chips.
8 new panel tests + 5 attribute round-trip cases; 55 playbook and 190
document-saving tests green.
Co-Authored-By: Claude Opus 5 <<email>>Sep 7, 2026, 10:25 PMSubagentCreateDialog.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 AMTriggerNode.tsxfix(agents): drop the building glyph, green the calendar, unlabel the instruction box
THE GLYPH ROW. `external_crm` loses its mark entirely rather than gaining a better
one. Email and Slack wear real vendor logos and a meeting is a calendar, but "the
external CRM" is whichever of HubSpot, Salesforce, Attio or Copper this user
connected — so any single glyph either names the wrong vendor or names nothing. A
building says "a company" and invites the question "which one?", to which there is
no answer; sync arrows say "refresh", which is an action rather than an event. An
honest gap beats a mark that needs explaining.
So `Icon` is now optional on TriggerEventType, and the absence is the documented
point. `external_crm` stays a first-class event everywhere it matters — the picker
offers it, the row's accessible name lists it, the `?` explainer names it in prose.
Only the compact glyph row skips it, and that row was always a summary rather than
a census. The trigger badge falls back to the same generic mark `any` wears.
The meeting calendar is green, matching what the conversation timeline gives a
meeting. No tinted discs: the two vendor logos hardcode their brand fills, so a
text colour cannot reach them, and the calendar was the one grey mark in a row of
colour — it was the only one that needed anything.
THE INSTRUCTION BOX. "Instructions" as a visible label made every source row read
as a section heading over a box; stacked six deep that is six headings for one
idea. The label is gone and the sentence under the box does the naming instead —
which it had to do anyway, because "Instructions" alone never said WHICH
instructions or how they differ from the agent's own:
"Trigger-specific instructions (optional): if you want special instructions for
only this trigger to be given to the agent."
That needed a real kit change, not a workaround: `Field` now accepts `label={null}`
and renders no label element. It is `null` rather than an omitted prop because a
label is the default and dropping one should be a decision somebody typed — and the
control still carries an `aria-label`, since "obvious from context" is only true
for the reader who can see the context.
330 agent + playbook tests green, including new ones pinning that external_crm is
named but not drawn, and that only the meeting carries a tint.
Co-Authored-By: Claude Opus 5 <<email>>Sep 8, 2026, 11:59 AMTriggerRefNode.tsxfeat(playbook): the agent panel — a ref stops being a chip (design: per-trigger-instructions phase 6)
A ref inside a trigger was a 12px FileText glyph and a document title at py-0.5 —
the smallest thing in the block, indistinguishable from a link to a resource doc,
and the most important thing in the block. It becomes a block-level panel: the
real AgentAvatar and the agent's name, then its instruction underneath.
The instruction area is ALWAYS rendered. No toggle, no disclosure. An instruction
you have to click to see is one nobody audits, and an empty slot that shows itself
is what teaches the feature exists.
The empty state is a CSS ::before, never seeded text. Seeded text would round-trip
into the XML and every ref would ship with "Instructions for this trigger…" as its
real instruction, delivered to the agent. The test mutation-checks this: seeding
the placeholder as content makes both assertions fail.
Two keyboard rules the panel needs and a plain block node does not:
- Enter inserts a hardBreak (which the serializer writes as \n) rather than
splitting the panel into two refs pointing at the same agent.
- isolating, plus Backspace-deletes-empty-panel: without isolating, an
instructed panel merges its text upward and takes its documentId with it.
`triggerRef` is registered in ID_NODE_TYPES. Omitting it makes the schema strip the
node's nodeId on every save and re-seed the whole Y.Doc — the bug documented in
that file.
Also fixes a PRE-EXISTING round-trip bug found on the way: compile-playbook reads
`section`/`when` off a ref, but the editor emitted and parsed only `id`, so any
playbook edited in the UI silently lost them. Both ref shapes now carry them, in
both directions — fixing only one shape would have relocated the loss rather than
removed it.
@-insertion could not produce a panel as the design assumed: the @ menu is
PATH-addressed and a <ref> needs an id. ReferenceOption gained an optional
documentId, so an option that knows its document produces a panel and the system
tokens (@crm-updater, @next-steps), which have no document behind them, correctly
stay chips.
8 new panel tests + 5 attribute round-trip cases; 55 playbook and 190
document-saving tests green.
Co-Authored-By: Claude Opus 5 <<email>>Sep 7, 2026, 10:25 PMuseDocumentCreation.tsxfix(playbook): created resources belong to the document's owner
Review finding. Creating a board, table or document from a teammate's playbook
created it under the SIGNED-IN user, then inserted a reference to it into the
teammate's playbook, leaving them pointing at someone else's resource.
`PlaybookDocument` passed `ownerUserId` to `useSubagentCreation` and nothing
equivalent to `useDocumentCreation` one line below. But threading a prop was not
the fix: `aop.createPlaybookDocument` had no `targetUserId` in its input at all
and built its scope from `ctx.sessionUser.id`, so a client-supplied owner would
have been stripped by zod and the call would have succeeded with the wrong owner.
That silent-strip shape is the same one `listAopsForUser` had.
Server: `targetUserId` added, gated through `assertActorAllowed`, with BOTH the
actor and the org taken from the verdict. The org half matters as much as the
owner: five sibling routes in this file were fixed during review for pairing a
cross-org actor with the caller's organization, which writes rows invisible to
the customer and into Cedar's own org. Also adds the org-scope gate on the
`scope: 'org'` path, which previously had none.
Frontend: `ownerUserId` threaded through useDocumentCreation and the create
dialog. `CompositePlaybookDocument` had the identical defect, one line below its
own correct `useSubagentCreation({ ownerUserId })`, and is fixed the same way.
Tests assert the owner OF THE CREATED ROW for all three kinds, not that the call
succeeded, since a call succeeding with the wrong owner is the entire bug.
Reverting both owner slots to `ctx.sessionUser.id` fails 4 of 7.
Note for whoever extends the guards: this route was invisible to
target-user-id-reaches-the-authority because that guard flags files which NAME
`targetUserId` without reaching the authority, and this route named no user field
at all. The blind spot is "no identifier, so it silently uses the session user on
a surface that is not the session user's".
Verified: server types clean, 966 tests across routes and auth; mail tsc clean,
396 suites / 3768 passing.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 9:22 PMusePlaybookAop.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 AMuseSubagentCreation.tsxfix(auth): close chat-thread cross-user IDOR, restore admin scope on Brain playbook nav
chat.getThreads/getMessages/listThreadsFor* accepted a client-supplied userId
with no authorization check at all — any authenticated caller could read any
other user's chat threads and messages by naming their id. Gate every
cross-user read on the new chat_thread:user policy (SELF_OR_ORG_ADMIN, same
authority agent.* and documents.getDoc already use), and close the matching
write-side gap in createThread. Deduplicate the four call sites onto the
existing assertActor helper instead of reimplementing its self-path
short-circuit inline four times.
Separately: an org admin clicking an @resources/... reference or subagent
chip inside a teammate's playbook document lost the admin's targetUserId on
navigation (brainDocumentPath called without it), even though the query that
fetched the doc already had it in scope. The Knowledge explorer then re-fetched
by bare documentId with no scope and hit the per-user ownership check as the
admin, not the teammate — the same failure mode PlaybookAopSection's tree view
already handles correctly. Thread targetUserId/ownerUserId through the four
affected navigate(brainDocumentPath(...)) call sites.
Co-Authored-By: Claude Sonnet 5 <<email>>
Claude-Session: https://claude.ai/code/session_0187itFj1uakga2xd7kPk4qpSep 12, 2026, 11:36 AM1 defectWebhookTriggerPanel.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 AM