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