agentfeat(agents): four namespaces, a default file, and an agent doc that opens as the agent
An agent's folder now reads as four namespaces — playbook, config/, memory/ and
everything else as output — by CLASSIFICATION rather than migration, so not one
document row moves. The thing that actually writes these paths is prompt text, some
of it stored in seeded agent bodies, and `overview` is addressed by exact-path
equality while `archives/` is matched by a metadata-stamping regex; re-pointing the
helpers would have been a silent-failure change. This generalises the split the
coaching agent already ships.
`default_file` frontmatter says which file the Files tab opens. Relative to the
agent's folder so it survives duplicate and publish, resolved once on the server so
no client re-derives the default, absent meaning `overview` so nothing changes on
deploy. Set from the agent's menu, a file row, `agent.create` or the CLI. The
frontmatter patcher learned to REMOVE a key, which is what makes "back to the
default" expressible at all.
A document whose type is `agent` renders as the agent. Five surfaces rendered one as
prose and only one mounted the structured header, so the raw YAML showed with no
header at all on the rest — and the hider was looking for a horizontal-rule run when
the server emits a `frontmatter` sentinel code block. Both are fixed: the render is
keyed on documentType, and the settings are a collapsed form above the editor
carrying every key, with unknown ones kept in their own block rather than dropped.
Brain documents share one measure and one left edge: the reading column drops to
80ch with no horizontal padding, its back gutter is resized to match, and a stale
`ml-4` on the playbook crumb is gone.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 2, 2026, 11:51 PMboardfeat(mail): the inspector is the document, at the reader's width
Six things, all of them the panel arguing with the person reading it.
**It is the real document now.** The body was a read-only markdown render of
`doc.content`. It is `<Document />` bound to the same Y.Doc the document page
binds, through the same provider registry — so it is not a copy that could
drift, it is the document, and typing in the panel and typing on the page are
the same keystrokes. No merge to worry about: two views of one CRDT is the
case Y.js exists for.
**The width is the reader's.** A profile is sometimes the thing you came for
and sometimes an aside, and no fixed width is right for both. The left edge
is a drag handle, 320–900px, remembered per viewer. Default 480, up from 416.
**The title is a heading.** It was `text-sm font-medium` — the same type as
the rows under it — so the panel read as a list whose first line happened to
be a name. The breadcrumb above it is gone.
**No fill behind the properties.** One surface per object: the panel is the
surface, and a filled block inside it was a second box drawn around one thing.
**Every declared field is a row, value or not.** A schema declares a field
because somebody wants that fact recorded, so an empty one is a prompt, not
an absence — hiding it means the only way to discover the graph wants a title
is to already know.
**An array is its values, not its JSON.** Agents write `buying_role` both as
`['economic_buyer','finance_approver']` (a tool call) and as
`'economic_buyer, finance_approver'` (a markdown round-trip). `toFieldValue`
JSON-stringified the first, so the same field rendered as a literal
`["economic_buyer","finance_approver"]` in one graph and as two chips in
another. Arrays of scalars now take the comma-joined form `splitMultiSelect`
already parses, so both shapes render as chips.
**A select opens in a popover.** The inline version spliced every option into
the form, so opening one field pushed every field below it down the panel and
a five-option field was the tallest thing on screen. It is an `OptionPicker`
now — filter, ordinals, right-hand tick, keyboard containment, all of it
already built. A multi-select stays open across toggles, since picking two
values should not be two openings.
Also drops the `as any` in the floating-edge test: `getFloatingEdgeParams`
takes the two fields it actually reads (`NodeBox`) instead of the whole
20-field `InternalNode`, which a test could only fabricate with a cast — and
a cast is what would stop the compiler noticing the day React Flow renames
`positionAbsolute`.
Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 4:13 PMcoachingfeat(documents): play the call inside the document, at the moment being discussed
A coaching note the rep cannot check against the tape is an assertion, and until now
checking it meant leaving the document for a provider page that opened at 0:00. A
```moment card now embeds the call positioned at `at:`, every timestamp in its
transcript is a button that seeks the player to that line, and a new ```recording
fence embeds a whole call anywhere in a document.
Playback resolves from `externalId`, never a stored URL. That is not a preference:
Circleback's `recording_url` is a signed link that expired 24h after ingest, so every
Circleback moment's "mandatory recording link" was already a 400, and Fathom's is a
web page rather than media. The id is stable, and the server mints from it per view.
Cedar's own <video> rather than a provider embed, because only an element we own can
be seeked — verified against the live providers rather than assumed. Fathom's
/embed/<token> IS frameable (no X-Frame-Options, no frame-ancestors, unlike /share),
but its component declares only {call, autoplay, shareUrl, displayMode} and nine
probed parameter names changed nothing, so it cannot be positioned; it is kept as the
last rung of the fallback ladder. Fathom's share page does deep-link — ?timestamp=125
comes back as currentTime 125.0 — so links out carry the moment. Gong's iframe takes
exact from/to but demands the viewer be signed into Gong, in an iframe, not in Safari.
Gong gains a media path it never had: /v2/calls/extensive with exposedFields.media,
and the tenant's real call URL read off metaData.url instead of the hardcoded
us-4796 subdomain. Its 403 returns `permission_required`, deliberately not
`unavailable` — the recording exists and Cedar may not fetch it, which is a problem
with a named owner and a one-minute fix, so the player names the
api:calls:read:media-url scope and where a Gong admin grants it. The connection form
now asks for it up front. This lives in gong-recording.ts, not gong.ts, because
gong.ts reaches crm/meeting-events -> meetings/index.ts -> back to gong.ts, and
importing the minter from there crashes on load with a TDZ error.
Nothing mints until the reader presses play: Fathom allows 30 download requests per
60s per key and renders a cold recording in ~34s, so a document with ten moments that
minted eagerly would spend a third of that budget before anyone pressed anything.
Not verified, and called out in the design doc: no live mint ran (this machine's
ORG_CREDENTIALS_ENCRYPTION_KEY does not match those connection rows), the Gong path
has never run against Gong since no org has a connection, and whether a seek lands on
the right words is still unproven — Fathom's call started_at and recording.started_at
differ by 62s on the one sample inspected.
Also carries a pre-existing working-tree change to fathom.ts that could not be
separated from the same file: transcript turns keep their timestamp, so the coaching
rubric's discovery-length row is scoreable for webhook-ingested calls. Its test is
included rather than left orphaned.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 29, 2026, 3:02 PMfile-linkfix(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 PMgraphfix(graphs): a deal's org chart is readable by the people on that deal
Greptile's remaining open finding on this PR. A conversation chart lives at
`conversation/{id}/org-chart` and is stored ORG-OWNED on purpose — `userId`
is null so that one `graphs: org-chart: rw` grant covers every deal. That
makes ownership the wrong question to ask about it: `findGraph`'s
`ownerScope` matches a null owner for any member of the org, so every
teammate could read and mutate the buying-committee chart of a deal they
were not on, by path, by name or by id.
Membership is now asked at RESOLUTION, for the same reason ownership is:
all three addressing forms end at that one row, and a check bolted onto a
single caller would leave the other two open. `findGraph` runs every
resolved row through one gate, which is a no-op for anything that is not a
conversation chart.
- `GraphScope.canReachConversation` is optional because it is a property of
the SURFACE, not of the graph. `graphs.*` supplies it (session user, must
be on the deal); the admin router stays deliberately org-wide and the
agent tool stays gated by its playbook grant. A scope that omits it is
byte-for-byte unchanged.
- `graphs.*` answers it with `assertConversationMembership` — the helper
`files.*`, `drive.*` and `agent.*` already guard conversation-scoped reads
with, so the graph canvas and the file tree beside it agree about who may
see a deal. Fails closed on any throw, as `agent.ts` does with it.
- `conversationIdFromOrgChartPath` is the inverse of the path builder, and
is deliberately exact: a looser match would hand the gate the wrong id for
`conversation/{id}/agent-7/notes`, a different document with a different
rule. Tested both ways.
Also drops a stale duplicated comment above `activeKind` left by an earlier
edit — two comments for one constant, the first contradicting the second.
Not changed, and worth a look separately: `listGraphs` applies no ownership
filter at all, so it still lists other teammates' `user/graphs/*` and every
deal's chart. The ids it hands out no longer open anything that check would
refuse, so this is an enumeration gap rather than an access one — but it is
shared with the agent and admin surfaces, so narrowing it is its own change.
Verified: @zero/server and @zero/mail typecheck; 1803 tests pass across
services/documents, trpc and the document tools; deps:check clean.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 11:50 PMhistoryfix: close the findings from reviewing the fixes themselves
A second review pass, this time over the ten fix commits — which were unreviewed
code, and which is where the worst finding was.
Caps enforced in one place each, missing the path their own docstring cites. The
cell-length limit lived only in `applyCellWrites`, so an agent bypassed it by
choosing `add_rows` over `set` — those rows go through `materializeRow`, which
checked the column name and not the value. Both now call one `assertCellLength`.
The column cap lived only in `applyTableOps`, which the import path never reaches:
it goes `writeDocument` → `writeFileAsYjs` → `writeTable`, so the "1,000x300 import"
the docstring describes was still uncapped. Now checked in `table-import.ts`
alongside the row cap.
`rename_column` was the one route left to give a column a reserved label — the
guard was added to `create` and `add_columns` in the same commit and not there, so
the invariant its error message asserts was not actually held.
The invalidation hook in `next-step-sync` fires inside `applyUpdate`'s transaction,
and it detaches a chain that opens its own connection — so it could read the
pre-commit row and cache the stale value back into every bound cell with nothing
left to re-fire. Queued as a `postCommitEffect` instead. The five sibling call
sites added in the same commit are all on auto-commit connections and were already
right.
`focusCellAbove`/`focusCellLeft` were added by the keyboard/ARIA commit and never
wired to anything — arrow-key cell navigation does not exist, so they were dead
code claiming otherwise. Deleted rather than left as a promise. The gutter also
needed `role="gridcell"`: `aria-colcount` counts it as column 1 and the data cells
start at 2, so without it the row declared a cell that did not exist.
And two `_`-prefixed unused bindings in test files I wrote, which the repo forbids
outright.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 15, 2026, 2:24 PMmentionfeat: typed cells, live ref chips, and tables as first-class files (design: table-documents phases 6, 7)
Phase 6 — a cell chip and a prose chip are literally the same component. ConversationNode
and EventNode were split the way FileLinkChip already was (Content + the NodeViewProps
wrapper that mounts it), so behaviour stays identical by construction: same data hooks,
same click handler, same setSelectedArtifact path. The only PM-specific parts were
NodeViewWrapper and reading node.attrs, which is why the extraction is small.
Editing mounts a one-line TipTap instance for the focused cell ONLY, configured with the
EXISTING suggestion extensions — so typing `@` in a table cell runs the same extension,
the same search, and inserts the same node as typing `@` in a prose document, rather than
a parallel implementation that drifts. Exactly one editor is alive at a time, so the
descriptor-tree costs that rule out a ProseMirror grid never apply here.
Phase 7 — "New table" in the file-creation menu, a table graph-node kind, "N rows × M
columns" from the metadata the server hook already writes, the grid in the artifact panel
(discriminated inside FileArtifact on the FETCHED documentType, since the outer switch is
on ContextKind where a table is just a `file`), a read-only grid on the public share page,
resolveOpenDoc, and Download as Excel/CSV wired to documents.exportTable.
Also completes Phase 10's agent-facing half: the `table` tool's `create` now takes a
`from` clause, so an agent materializes rows from a real query instead of transcribing
them. The query runs BEFORE the document is provisioned, so an over-cap or malformed
filter leaves no half-built table behind, and truncation plus any binding error is
surfaced as a tool warning rather than silently yielding a short or empty column.
Three fixes found by running the suites rather than by reading:
- The tool description had grown to 2134 chars, past the 2048 limit Claude Code truncates
at — text past the cutoff is silently dropped for every caller. Trimmed to 2030 while
ADDING the `from` guidance, which is the part most worth having early.
- tableTool.test.ts enumerated its mock of the documents barrel, so every new import in
the tool broke the file with a mock error rather than a real failure. It now spreads the
real module.
- A cell-ref offset expectation was hand-counted one short (`[[conversation: 9f2e]]` is 22
chars, so end is 27). The implementation was right; the test now asserts by slicing the
value, since these offsets exist precisely so the grid can interleave text and chips.
Tests: 1787 server, 55 mail across the table/home/files suites. Both apps typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 10, 2026, 5:04 PMplaybookMerge pull request #2766 from CedarCopilot/fix/chat-thread-cross-user-idor
fix(auth): close chat-thread cross-user IDOR, restore admin scope on Brain playbook navSep 12, 2026, 11:54 AMpropertiesfeat(graph): a node is a person — face, name, title, link
The panel was drawing a person as a list of key/value rows, including the
rows that ARE the person: `Title Director of Sales` under a heading that
said their name, `LinkedIn https://…` as raw text, and `Person
<email>` — plumbing — as a property. That is the same information
the contact panel beside a conversation already renders as a profile, and
there is no reason a reader should meet two renderings of one human.
So a node now draws as a profile header: avatar, name, LinkedIn mark, title,
company — the shape `PersonProfile` uses, in the inspector at `lg` and on
the canvas card at `sm`. The headline sits under it. A person nobody has
enriched is a circle with two initials, which is exactly as much as is known.
**It is keyed off `from`, not off field keys.** A field bound to
`person.photoUrl` IS the avatar, whatever it is called. `from` is a closed
catalog vocabulary the server already validates; a key is free text an agent
minted, so keying on `photo` would mean a graph that called it `avatar` drew
no face with nothing on screen to say why. It also means no new schema
vocabulary: a graph gets a header by binding fields it would have bound
anyway, and a graph of COMPANIES binds none of them and draws as before.
Whatever the header drew is then SUBTRACTED from the rows — including from
the undeclared-key rule, via `hiddenKeys`, because the node's bag really
does still carry `person` and it would otherwise reappear directly under the
header that replaced it.
**The org chart declares `photo` and `headline`**, bound to `person.photoUrl`
and `person.headline`.
**And the agent can finally fill them.** `graphEnrich` has existed in the
service and on both tRPC routes since phase 7, but `GRAPH_ACTIONS` never
listed it — so the one agent whose whole job is this chart could not enrich
anybody on it. `graph.enrich` is now a verb, behind a WRITE grant even for
the free report: the report and the spend are one act from the agent's point
of view, and a grant that let it price something it could not buy would only
teach it to try.
The prompt tells it to use **fiber**: $0.04 a person against crustdata's
$0.10, and the only provider that returns `person.photoUrl` and
`person.headline` — the two fields the faces are made of. Two calls, the
first free, always with a `limit`, and skip the second when the report says
everyone is already enriched.
Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 11:15 PMrecordingfix(meetings): a Circleback recording that plays, on open, with a link that goes to Circleback
Three things stood between a Circleback meeting and its recording.
The recording could not be fetched at all. credentials-encryption derives its
AES key from ORG_CREDENTIALS_ENCRYPTION_KEY || BETTER_AUTH_SECRET, so which
secret a credential was written under is a property of the environment at WRITE
time. Every Circleback MCP token set was written under BETTER_AUTH_SECRET; once
the org key was set, every decrypt died with "Cipher job failed", the call sites
swallowed it into "no credentials", and the player said "No playable recording
for this meeting" with the real cause visible nowhere. Decryption now tries each
configured secret; encryption still writes with the first.
The meeting dialog waited for a press before fetching. That poster exists so a
coaching document with ten moments does not fire ten mints on open against a
Fathom budget of 30/min — an argument that does not apply to one recording the
reader opened to watch. `autoLoad` skips the press without autoplaying.
"Open in Circleback" went to storage.googleapis.com. What ingest stored is a
signed GCS object whose signature dies 24h later; unsigned it answers 403
forever. The page is built from the meeting id instead, and no surface will link
to a raw media file any more.
Webhook-era rows (numeric external_id, all before 2026-07-29) still cannot mint
or link — neither ReadMeetings nor the page route takes a numeric id.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 5, 2026, 12:21 PMtablefeat: the transcript names what it did, and the table it made is reachable
A chat that spent twenty calls building a LinkedIn tracker recorded almost
none of it: four different people got connection requests under four
identical "Executing linkedin-write" lines, the table itself never became a
context item, and re-entering the chat reopened the deal instead of the
table. Traced end to end; this is the set of causes.
The transcript:
- Tool labels resolve from the ARGUMENTS, not just the name. Every tool is an
action family now, so the name alone collapses a dozen verbs into one row.
Rows expand to who / where / whether a message went with it.
- A select column's declared option order IS its sort ladder; the tool
description now says so, and the transcript shows the ladder.
LinkedIn caps:
- A send's provider-id lookup gets its own budget (connect/dm/inmail_lookup),
mirroring the send it serves. Charged to profile_view, a day of browsing
refused invitations that had 80/week of connect headroom left.
- Connection requests go out with NO note unless the user asked for one —
enforced at execute time, not just described.
Getting back to what the agent made:
- The table tool files a context chip on create/import, as write-document has
always done; delete pulls it back off.
- A thread remembers what it was displaying (chat_threads.context), so
re-entry restores the table rather than re-deriving the deal.
- A fan-out opens a conversation-scoped table inside its deal's Files tab.
- 'table' is a valid write type on the document family — the tool was telling
the agent to pass a value its own schema rejected, which is what drove a
delete-and-rebuild that orphaned the open document.
The grid:
- Title, then description, then the commands — the order the questions arrive
in; a URL or address in a cell is a real link; a deleted table says so
instead of rendering on from the local Y.Doc cache.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 11:43 PMyjsMerge remote-tracking branch 'origin/staging' into refactor/agent-files-sections
# Conflicts:
# apps/mail/modules/agents/components/AgentConversationFilesFolder.tsx
# apps/mail/modules/conversations/components/files/ConversationFileTree.tsx
# apps/mail/modules/files/components/list/FileListColumns.tsx
# apps/mail/modules/files/components/list/index.ts
# apps/mail/modules/files/components/list/types.ts
# apps/mail/tests/modules/files/brain-explorer.test.tsx
# apps/mail/tests/modules/files/list/FileListRow.test.tsx
# apps/server/src/mastra/tools/debug/debugTool.ts
# apps/server/src/services/playbook/playbook-execution-triggers.ts
# apps/server/src/services/playbook/playbook-renderers.ts
# apps/server/src/services/playbook/resolve-playbook-context.ts
# apps/server/src/services/playbook/write-document-guarded.ts
# apps/server/src/trpc/routes/documents.tsSep 9, 2026, 9:26 AMattachment-file-view.tsxfeat(files): every file surface renders one row (design: unified-files-ui phases 3-6)
/brain, a conversation's Files tab and an agent's Output/Memory tabs were ten
row renderers that had each re-decided indentation, icon chrome, type size,
selected colour and separators. The same file looked like a different object
depending on where you found it. They now all render FileListRow.
Shape is Google Drive's, reworked from review:
- the column header IS the top row; no count strip above it
- the right edge is one fixed cell holding a ⋮ menu, so a row with four
affordances and a row with one still end at the same x
- no "Edited by" column — the actor rides inside Modified ("3 days ago by
Jesse Li", "by you" for the viewer). The two read as one fact, and splitting
them spent a column's width to say something the reader reassembles anyway
- a folder's count sits beside its title, "Archives (50)", not as a lone
number at the far right where it reads as a column only some rows have
- "New file"/"New folder" live in the header's + and in each folder's own ⋮,
never a strip above the list that pushes the files down the page
- a scope root is a ROW, not a section header. It is a folder — you open it,
drop into it, create inside it — so it gets the folder's row, just a
prominent one
Selection and keyboard, matching the thread list where it exists and diverging
only where the divergence is the point:
- hovering cross-fades the tile into a square checkbox in the same footprint;
pure CSS, because a tree re-rendering every row on mouse-move is unusable
- hover reaches the keyboard by CustomEvent into a ref, never state
- keys act on hover FIRST, then selection — the cursor is the more specific
statement of intent
- the letters are the task list's (w delete, x select, ⇧X range), but w
CONFIRMS here: mail can delete instantly because its undo stack reverses a
label change, and a folder takes its whole subtree with it
Two bugs the migration surfaced, both from Radix restoring focus to a menu
trigger a tick after close: a popover opened from a ⋮ item dismissed itself,
and Rename's autofocused input blurred and cancelled in the same frame.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 31, 2026, 11:21 PMchip-styles.tsfeat: typed cells, live ref chips, and tables as first-class files (design: table-documents phases 6, 7)
Phase 6 — a cell chip and a prose chip are literally the same component. ConversationNode
and EventNode were split the way FileLinkChip already was (Content + the NodeViewProps
wrapper that mounts it), so behaviour stays identical by construction: same data hooks,
same click handler, same setSelectedArtifact path. The only PM-specific parts were
NodeViewWrapper and reading node.attrs, which is why the extraction is small.
Editing mounts a one-line TipTap instance for the focused cell ONLY, configured with the
EXISTING suggestion extensions — so typing `@` in a table cell runs the same extension,
the same search, and inserts the same node as typing `@` in a prose document, rather than
a parallel implementation that drifts. Exactly one editor is alive at a time, so the
descriptor-tree costs that rule out a ProseMirror grid never apply here.
Phase 7 — "New table" in the file-creation menu, a table graph-node kind, "N rows × M
columns" from the metadata the server hook already writes, the grid in the artifact panel
(discriminated inside FileArtifact on the FETCHED documentType, since the outer switch is
on ContextKind where a table is just a `file`), a read-only grid on the public share page,
resolveOpenDoc, and Download as Excel/CSV wired to documents.exportTable.
Also completes Phase 10's agent-facing half: the `table` tool's `create` now takes a
`from` clause, so an agent materializes rows from a real query instead of transcribing
them. The query runs BEFORE the document is provisioned, so an over-cap or malformed
filter leaves no half-built table behind, and truncation plus any binding error is
surfaced as a tool warning rather than silently yielding a short or empty column.
Three fixes found by running the suites rather than by reading:
- The tool description had grown to 2134 chars, past the 2048 limit Claude Code truncates
at — text past the cutoff is silently dropped for every caller. Trimmed to 2030 while
ADDING the `from` guidance, which is the part most worth having early.
- tableTool.test.ts enumerated its mock of the documents barrel, so every new import in
the tool broke the file with a mock error rather than a real failure. It now spreads the
real module.
- A cell-ref offset expectation was hand-counted one short (`[[conversation: 9f2e]]` is 22
chars, so end is 27). The implementation was right; the test now asserts by slicing the
value, since these offsets exist precisely so the grid can interleave text and chips.
Tests: 1787 server, 55 mail across the table/home/files suites. Both apps typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 10, 2026, 5:04 PMdeferred-auto-convert.tsfix: playbook trigger/stage parsing and CRM updater doc editor
Preserve event trigger if: clauses on edit, stop splitting kebab-case stage
ids on hyphens, and surface the crm-updater subagent doc in the CRM settings tab.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <<email>>
Co-authored-by: Cursor <<email>>Jun 16, 2026, 1:07 AMdocument.tsxfix(agents): thread targetUserId into the instructions doc editor + doc-events SSE
Karim (Adapt Insurance, org admin) reported Jacob's agent instructions not
showing when viewing his agent — the admin-view targetUserId prop reached
every trpc.agent.* call but never the shared <Document> Y.js editor that
AgentInstructionsSection hands the actual instructions content to. Document
had no targetUserId concept at all, so its documents.getDoc fetch ran under
the admin's own session; the server correctly rejected it as a cross-user
read (row.userId !== userId on a user/-scoped doc), and the failure was
swallowed into a console.error with no onProviderError handler wired up —
the editor just rendered a permanently empty Y.Doc.
- Document/DocumentProps gains targetUserId, forwarded to both the initial
getDoc seed and refreshFromServer.
- AgentInstructionsSection forwards its own targetUserId prop down into
<Document> instead of dropping it at the door.
- /api/doc-events (the live-update SSE channel) had the same shape of gap:
a user/-scoped doc's live stream only allowed the doc's own owner or
Cedar staff, so even a fixed initial read would 403 on remote updates.
Routed through assertActorAllowed — the same authority documents.getDoc
already uses — instead of a flat isCedarStaff check.
files.applyUpdate (the write path) needed no change: it already derives
the document's scope from the row itself via assertAuthorizedScope, not
from a client-supplied id, so org-admin writes were already authorized
correctly.
Co-Authored-By: Claude Sonnet 5 <<email>>
Claude-Session: https://claude.ai/code/session_01SfYKPYHXdMgrPLuKhXvWedSep 11, 2026, 12:29 PMDocumentBackGutter.tsxfeat(tasks): a CRM updates lane, meeting-aware routing, and narrow-panel polish
field-approval was the single largest Misc population (374 of a 677-task
backlog) with nowhere to go — work that finishes inside the CRM rather than
in someone's inbox now has its own default lane. The router additionally
counts meetings already held on a deal, which is what makes "first meeting"
vs "subsequent meeting" lanes decidable; countMeetingsSoFar is shared with
the opt-in post-meeting split script so a task lands in the same place
whichever path filed it. Backfill window/limit/confidence are env-overridable
because a newly-added lane leaves a deeper backlog than the routine 100/user.
Also in this commit (whole-tree — the session had no edit history to scope by):
- Back gutters collapsed a breakpoint too early; ~760px/75ch columns keep
usable margin past @max-4xl, so the swap moved to @max-3xl (wide-column
hosts 5xl -> 4xl) across threads, conversations, playbooks and Brain.
- Calendar event popover: larger title, single-line date row, and locations
and descriptions run through LinkifiedEventText so meeting links are
clickable.
- Snippets shrinks to an icon-only Braces button; the inbox shortcut legend
drops the channel switch and folds range/multi-select into one "s" hint.
- AOP custom-field column building extracted to crm/utils/aop-columns, and
InboxLinkedDealsSection split out (its test already imported the path).
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 4, 2026, 7:45 PMfull-bleed-doc-types.tsfeat: the org-chart agent, and the graph wiki (design: graph-documents phase 7)
An org chart exists with NO org-chart-specific code anywhere in the repo. It is
a graph whose schema happens to describe people and whose view happens to rank
by reporting — and `graph-templates.test.ts` enforces that by asserting every
template is expressible entirely in `nodeSchema` + `edgeKinds` + `view`.
The subagent is instructed to create from the template rather than hand-author a
schema, point each node at the person's EXISTING contact profile, draw an edge
only with named evidence recorded in the edge's `meta`, call `field_values`
before writing a value so it does not invent a fifth stance, read its lint delta
after every write — and NEVER delete a node. Someone who has gone quiet gets
`stance: unknown`, because going quiet is itself the signal.
Two things worth knowing:
- `org-chart-seed.test.ts` extracts every `graph.<action>` the prompt names and
runs each through the tool's OWN input schema, plus a negative case so the
check is not vacuous. It is the only thing that catches the prompt drifting
from the tool, and it fails in CI rather than at 3am.
- The document and the `<ref>` ship together. A subagent only runs when the
playbook references it, so seeding the instructions alone would ship an agent
that never runs. It takes a cron block of its own rather than joining the
07:00 chain — a long graph pass in front of the morning agenda buys it nothing.
Also: `docs/wiki/graph-documents.md`, covering what this run learned the hard
way — why nodes live inside the Y.Doc when a board's cards live outside, why the
lossy mirror is what forced `writeGraphAsYjs` into existence, the key-order bug
that would have churned `content_yjs` on every save while every deep-equality
test stayed green, and the fact that the frontend has FIVE document-type dispatch
sites rather than a registry. That last one is also indexed inline, in
`full-bleed-doc-types.ts`, which is the closest thing to the registry it is not.
1,887 passing across playbook, aop, graph and tools.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 14, 2026, 10:09 PMhtml-document-view.tsxfeat(sharing): typed links, and the count you can check (design: sharing-ui-actions phase 4)
The anonymous eternal token is retired from the UI. `html-share-dialog.tsx` — 188
lines, four `toast.error` sites, a Dialog that dimmed the very document it was
describing — is deleted, and its call site mounts the real control.
A link now names its audience, expires, and can carry a password. Defaults are the
narrowest useful thing rather than the widest: Anyone at Cedar · Can view · 30 days.
`manager` is absent by construction — only manage may re-share, and a bearer
credential that confers the right to hand out more of itself is a credential with no
floor. `editor` IS offered, because the role is now enforced: `accessFor` folds a
redeemed link's standing through the same `max` as a grant.
Copy works with sharing OFF, which is the case the old dialog could not express at
all and the common one: most of the time you are sending a colleague a pointer, not
publishing. It copies the in-app URL and says "Only people with access can open this"
underneath, so the reader knows which of the two they just did.
Revoking states the count first — "opened 34 times. Anyone using it loses access
immediately" — which is what `viewCount` was added for.
A live link's settings are deliberately NOT editable. The URL is already out there:
editing in place either silently changes what a sent link does, or rotates the token
and breaks it invisibly. Settings are chosen in a composer before the mint, and that
Create is the one Save-shaped button in the panel — R1 is about ambient state, and a
half-configured bearer credential must not exist between two clicks.
And the private-folder confirm gains its disclosure. The count had no way to be
checked; now the same server walk that produced the number returns the names behind
it, so "9 people lose access" is a fact somebody can open and read.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 7, 2026, 4:11 PMuse-active-doc-context.tsfeat(chat): the agent you are in, and the file you are reading, are context
Opening a file from a file browser — the agent workspace's Output tab, a
deal's Files tab, the Brain explorer — published nothing at all, and an agent
workspace published nothing anywhere. So a question asked while looking at
both arrived with neither: "this file" and "this agent" named things the chat
had never been told about.
Both now ride the wire as badges beside the others, dismissable the same way,
and the file carries its documentId — which is what makes the ambient file
attachable at all, since manage-context takes an id and a path is not one.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 9, 2026, 1:07 PMuse-rich-text-extensions.tschore: snapshot in-progress working tree before layout-slice refactor
Pre-existing WIP across chat input, file-link mentions, nav buttons, agenda,
and server file-system, committed to clear the runway for the unified layout
slice implementation (design: layout-slice).
Co-Authored-By: Claude Opus 4.8 (1M context) <<email>>Jul 11, 2026, 8:20 PM