autoLayout.test.tsfeat: node, edge and layout operations (design: graph-documents phase 2) Every graph mutation is one Y.Doc transaction over a delta, not a document rewrite. A drag is one `graphLayout` key; two people dragging two nodes merge. The load-bearing divergence from the plan: `board-ops` persists by handing `writeDocument` the board's markdown mirror and letting `writeFileAsYjs` parse it back out. A graph cannot do that. Its mirror is deliberately lossy — no layout, no `derived` caches, and nodes and edges rendered as incident-encoded prose rather than as data — so the round-trip would discard both on every write and re-parse a rendering as truth. So this adds `writeGraphAsYjs`, the table's delta-applying shape for the table's reason, and `createGraph` inserts its row directly rather than carving an exception into the phase-1 write guard (an exception has to be expressible as a parameter, and a parameter is reachable). What the tests pin, beyond the obvious: - A graph ADOPTS NOTHING. An undeclared key is stored verbatim and the schema comes back byte-identical — the inverse of the board's adoption assertion. Because fields are per-graph, a key one graph invents cannot collide with another graph's typing of it, so there is nothing to reconcile. - `rm_node` removes the node, every incident edge and the layout key with no observable intermediate state, and no second document lock to order. - The caps are refused as a WHOLE. A call adding two nodes to a graph at the limit lands neither, rather than the first. - Churn is demonstrated, not assumed: 1,600 interleaved field rewrites grow the blob, and compaction reclaims it with byte-identical content across all four maps. Also here: `graphs:` grants (exact by name, never prefix-covering — a grant on `organisation/graphs` must not silently cover every graph in the org), the `graph` stats hook, the org-chart and buying-committee templates, and a shared `autoLayout` for the canvas. And `deriveTableSaveScope` turned out never to have been table-specific — it is now `deriveSaveScopeFromPath` in document-saving, rather than the graph layer importing from the table layer. 132 graph tests; 1,575 passing across the touched suites, no regressions. Triggers are the one phase-2 item still open. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 14, 2026, 9:36 PMfloating-edge.test.tsfeat(mail): one properties rail, and a tree that reads as a tree **The properties rail is now literally the task ticket's.** It was a lookalike — a `grid-cols-[5.5rem_1fr]` with a fixed label column, its own caption constant, its own row height — which is why it read as a different UI for the same job. The task rail is the one built against Linear and the one that reads right, so it was extracted rather than averaged: `components/ui/property-rows.tsx` owns the row geometry, the hover chrome, the caption type and the section rhythm, and both surfaces compose it. `TaskTicketProperties` keeps only what is a fact about a task. One deliberate difference, and it is not cosmetic: a document's keys are ARBITRARY. `stance` and `seniority` are both selects, so `propertyIcon` gives them the same glyph, and a task rail's icon-only row would leave a column of identical dots beside three bare values. So the field's name rides in the row, muted, ahead of the value — same height, same type, same hover. It does not get a column of its own, which was the thing that made the old rail read as a form. **Siblings stopped disagreeing.** Floating anchors were picked by comparing card CENTRES, and two people on one row of a chart have different centres whenever their cards differ in height. When their shared boss's centre fell between the two, one child connected upward and the other downward, and a line looped around a card for no reason a reader could see. A rank is a shared TOP edge — exactly equal for siblings however tall either card grows — so tops are what is compared now. **The axis relation draws orthogonally.** `getSmoothStepPath` with `offset` at half the layout's own rank gap, so every child of one parent turns on the same y and they share one horizontal run: the screenshot's shape, and what makes a tree read as a tree rather than as a fan of curves that happen to converge. Every other relation stays a bezier, so an `influences` overlay can never be mistaken for the reporting line it crosses. The `hierarchy` flag comes off the same predicate that chose `layoutEdges`, so what ranks the chart and what draws as its skeleton cannot be two different sets. **The legend moved to the bottom.** It is read once on arrival and then never again, which is the whole lifecycle a legend should have — and that is not a reason to put it across the top of the drawing it explains. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 5:50 PMgraph-to-flow.test.tsfix(graph): the org chart was upside down, and the nodes were see-through **The chart rendered inverted.** `autoLayout` places an edge's SOURCE above its target — correct for the outbound flow canvas it is shared with, since a flow runs downward. A hierarchy runs the other way: `reports_to` is written junior→senior ("nim reports to brian"), so feeding it unreversed drew the most junior person at the top and the VP at the bottom. Reversed at the graph layer, not in `autoLayout`, because the flow canvas is not wrong — whether an edge means "above" or "below" is a property of the RELATION, and only the graph layer knows the relation. Two tests asserted the old order; both were encoding the bug. **Unconnected people were ranked as though they had reporting lines.** Dagre got all 7 Campfire nodes and 2 edges, ranked the 3 connected ones, then scattered the other 4 across those same ranks to balance the drawing — so two people nobody had placed rendered as peers of the VP who decides, and two more as peers of the champion three levels down. Dagre was not wrong; it was asked the wrong question. A node with no reporting line has no rank, and giving it one asserts a hierarchy nobody stated — the same failure as inventing an edge, reached through layout instead of data. So there is now one layout, `hierarchyLayout`: the reporting TREE on top, laid out by dagre, and everyone else tiered by seniority in a block below it, centred under the tree. A node moves between the blocks only when a real edge is drawn or removed. With no edges it is pure tiers; with every node connected it is pure dagre; the mixed case — the normal one — gets both. **`Auto-format` now calls that same function.** It used to call `tierLayout` directly, which threw away the reporting structure the automatic pass had just drawn, so the button either did nothing useful or made things worse. One layout, two callers, and they cannot diverge. **The node tints are opaque.** They were a 12% wash, which on a canvas is what it sounds like: the dot grid showed through and the category read as a suggestion. Each is now a solid pale surface in light mode and a deep one in dark. Surface and border are separate fields so a selected node takes the primary border outright rather than fighting `dark:border-green-800`, which tailwind-merge cannot reconcile against it. 86 graph tests; 4,674 passing across apps/mail. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 11:31 AMGraphInspector.test.tsxfeat(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 PMGraphNodeCard.test.tsxfeat(mail): the graph canvas (design: graph-documents phase 5) A graph document renders as an editable node/edge canvas whose nodes open as the documents they point at. ONE tRPC read returns the schema, the nodes, the edges and the layout, because all four live in one Y.Doc and come out of one decode. That is the payoff of keeping nodes inside the document: a board reads its schema from the Y.Doc and its cards from SQL and the two can disagree; a graph cannot. ONE node component, for every graph there will ever be. It renders from the SCHEMA — one row per `displayOnCard` field, typed by the same `TableColumnType` the board and table use, drawn by the same `CardFieldDisplay`. There is no node component per domain, and the day there is, "an org chart is configuration" stops being true. Three things the tests pin that are easy to undo: - The layout runs over ONLY the edges matching `view.layoutEdgeKind`, so which relation is the skeleton is a VIEW property: point it at `influences` and the same people re-rank with nothing written. Adding a non-layout edge moves nobody. - `hydrated ?? derived ?? fields`, in that order. An authored value shows as authored, a bound value shows live once hydration lands, and the cache only fills the gap between open and arrival — so the three can disagree in exactly one direction, toward the document. - Only TWO gestures write: a drag is one `graphLayout` key, a drawn connection is one `connect`. There is deliberately no whole-canvas save, because that is how a derived annotation becomes a persisted field and how one open tab overwrites another's concurrent edit. A computed layout is persisted ONCE, so the next open is stable and any later re-layout is a deliberate act rather than something that happens because someone opened the file. `graph` is registered in all five places a document type has to be — the full-bleed set, FileBrowser, FileArtifactPanel, FilesTab and CompanyExplorer — plus its icon and label. 36 graph tests; 4,588 passing across apps/mail, no regressions. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 14, 2026, 10:04 PMGraphSchemaEditor.test.tsxfeat(mail): an icon rail down the canvas, and the schema as a form The "Graph" button sat in the top-left corner — the first place a reader's eye lands on a drawing meant to be looked at — and hid the two things people actually do (add somebody, tidy the layout) two clicks deep behind a word that names the document rather than any action on it. It also carried a read-only "Conventions" list, which is a legend, and there is already a legend. It is a vertically centred rail of three glyphs now: Add node, Auto-format, Schema. Icon-only, so every button carries an aria-label and its tooltip renders that same string — one label, two renderings, so they cannot drift. The tooltip provider is LOCAL: a graph also renders inside the home rail's artifact panel and the company explorer, neither of which descends from the app's provider, and a tooltip without one silently never opens. Schema was previously a JSON edit. It is a form: - **Background** is an OptionPicker, not a select, because "which field paints the cards" is the one question you cannot answer from a list of words — each row carries that field's own palette, built from its `optionColors` in declared option order. A field with no colours shows the dashed "no tint" ring rather than borrowing someone else's. - **Tiers** and **Hierarchy** are short lists of words, so SelectField. - **On the node face** toggles `displayOnCard`, sending the whole field spread with one property flipped: `addFields` merges by key, so an unknown property this form never rendered survives instead of being re-asserted from defaults. A view key pointing at a field or relation the schema has since dropped is offered back as `stance · no longer a field`. Without that the control matches nothing, renders blank, and the first click silently repoints a graph that was tiered by something the form could not name. `FieldPopover` grows a `side` passthrough. Radix defaults to `bottom`, which is right for a row control and wrong for one in a vertical rail: the schema panel dropped over the drawing it edits, and flipped upward whenever the rail sat low on screen. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 3:35 PMGraphSidebar.test.tsxfeat(mail): an icon rail down the canvas, and the schema as a form The "Graph" button sat in the top-left corner — the first place a reader's eye lands on a drawing meant to be looked at — and hid the two things people actually do (add somebody, tidy the layout) two clicks deep behind a word that names the document rather than any action on it. It also carried a read-only "Conventions" list, which is a legend, and there is already a legend. It is a vertically centred rail of three glyphs now: Add node, Auto-format, Schema. Icon-only, so every button carries an aria-label and its tooltip renders that same string — one label, two renderings, so they cannot drift. The tooltip provider is LOCAL: a graph also renders inside the home rail's artifact panel and the company explorer, neither of which descends from the app's provider, and a tooltip without one silently never opens. Schema was previously a JSON edit. It is a form: - **Background** is an OptionPicker, not a select, because "which field paints the cards" is the one question you cannot answer from a list of words — each row carries that field's own palette, built from its `optionColors` in declared option order. A field with no colours shows the dashed "no tint" ring rather than borrowing someone else's. - **Tiers** and **Hierarchy** are short lists of words, so SelectField. - **On the node face** toggles `displayOnCard`, sending the whole field spread with one property flipped: `addFields` merges by key, so an unknown property this form never rendered survives instead of being re-asserted from defaults. A view key pointing at a field or relation the schema has since dropped is offered back as `stance · no longer a field`. Without that the control matches nothing, renders blank, and the first click silently repoints a graph that was tiered by something the form could not name. `FieldPopover` grows a `side` passthrough. Radix defaults to `bottom`, which is right for a row control and wrong for one in a vertical rail: the schema panel dropped over the drawing it edits, and flipped upward whenever the rail sat low on screen. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 3:35 PMhierarchyLayout.test.tsfix(graph): give the auto-format real air between cards The gaps were right on paper and wrong on screen, for one reason: dagre spaces ranks by `ranksep + nodeHeight`, and it was being told a card is 72px. That is the card's `minHeight` and the flow canvas's real node size — but a graph card stacks a name and up to four `displayOnCard` field rows and renders around 150px. So `ranksep: 80` bought roughly nothing, and rows came out nearly touching. Telling dagre the real size is most of the fix; the rest is more air. Graph metrics now live in one file: a 200×150 card, 120px between neighbours, 110px between rows, and 200px between the reporting tree and the loose block below it — wider so the split reads as two different things rather than one more rank. `autoLayout` takes the sizes and gaps as options and defaults every one of them to the flow canvas's current numbers, so that caller is byte-for-byte unaffected. Sizing belongs to the caller: only it knows what it is drawing. The two overlap tests were checking against 72px, which is the same assumption that caused this — they measure the real card height now, and two new tests assert the actual clear space between rows and between neighbours, so a future change to the card cannot quietly close the gaps again. 95 graph tests; 4,683 passing across apps/mail. Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 1:18 PMtierLayout.test.tsfeat(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 PMtree-edge.test.tsfix(graph): one drop, one bus, one riser — the family-tree line `getSmoothStepPath` turns at the midpoint between ITS OWN two endpoints. That is right for a flow diagram, where each edge is its own object, and wrong for a tree, where the four lines leaving one manager are one drawing. Cards differ in height, so four children produced four horizontal runs a few pixels apart — the zigzag. The turn is now anchored to the PARENT: the bus sits a fixed distance below the upper card's edge, so every child of one parent shares one horizontal line whatever their own cards do. That is the shape every org chart on paper has ever had, and it needs no flag for direction — the upper anchor is always the smaller y, so `min(sy, ty) + offset` is the parent's side whether the edge was drawn junior→senior or a node has since been dragged above the person it reports to. A child sitting directly under its parent now draws ONE straight segment. It was routing two rounded corners around a horizontal run of half a pixel — card centres come off measured DOM boxes and land on fractions — and that kink was the most visible defect on an otherwise tidy chart. One pixel of tolerance decides it. Two degenerate cases the tests pin, because both draw a line THROUGH a card rather than merely looking wrong: a pair dragged almost on top of each other clamps the bus to the lower anchor instead of routing past it, and a short horizontal run shrinks its corners rather than bulging outside the two cards it joins. Co-Authored-By: Claude Opus 5 <<email>>Sep 20, 2026, 11:41 PM