Skip to content

Publish completed investigations as child documents - #506

Merged
MaggieAppleton merged 9 commits into
run-on-clickerfrom
design-investigate-child-doc
Oct 12, 2026
Merged

MaggieAppleton merged 9 commits into
run-on-clickerfrom
design-investigate-child-doc

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Slice 3 of /investigate (stacked on #500). A completed investigation is now published as a research-shaped child document, and its inline card becomes a compact link to it instead of rendering the long result inline.

What changes

  • Publication — complete_experiment promotes the candidate and creates the child channel in one fenced commit (ExperimentStore.publish, memory + PostgreSQL). The child id is deterministic per investigation; a repeated or concurrent completion returns the same child. The card can never link a child that is not persisted.
  • Record link — childId on the investigation record plus a unique experiments.child_channel_id column (migration 019_investigation_children, FK like research_workspaces.published_channel_id).
  • Result → document (apps/server/src/experiments/document.ts) — title from the report's # heading (else the brief), the report MDX, ## Views with one <Experiment> embed per view, ## Method, ## Limitations, and as much raw evidence as fits the 256 KB source limit. Validated with the dialect + Lexical round trip at submit time, so completion cannot fail on content.
  • Views in the child — embeds reference the investigation id; experiment routes accept a published investigation from its child (detail, list, state, decision, placement, export) and experiment:changed is broadcast to both rooms, so selections stay one shared state.
  • Card — completed card shows Investigation · 1 view, the child title and first report paragraph; the whole card opens the child (research ready-card pattern). Recorded decisions from Propose investigations from the slash menu as inline cards #500 stay on the card above the open control.
  • Child surface — provenance line now handles investigations (Investigation from <parent> · by … · N views, Brief toggle, return to card). No /investigate in children (V1 rule, follows research). Record decision moved onto each embed; Insert view in document is gone (views are already in the child).
  • Security — a submitted report may not contain Investigation (or any record-backed component); checked in the connector route and again in the document builder.

Screenshots

Ready card (with a decision recorded from the child):

Ready card

Child document (desktop) and its embedded view with Record decision:

Child document
Child views

Phone:

Child on phone

Tests

  • document.test.ts — conversion round-trips the dialect, inert markup, tight lists, evidence clipped under the source limit, protected components refused.
  • Storage contract (memory + PostgreSQL) — concurrent completions publish one child, published() lookup, stale publish returns the existing record.
  • routes.test.ts — child reachable from the child id, list, provenance, idempotent complete, smuggled <Investigation> refused.
  • card.test.tsx — compact ready card, no inline result, decisions on the card.
  • experiments.e2e.ts — completed card → open child, shared selections and Record decision in the child, survives disconnect. Local e2e couldn't run (ports held by the slice-2 agent); relying on CI.

Notes

  • Embed and decision-record styling are unchanged from before (heavy borders); worth a follow-up polish pass.
  • Found while testing: list items separated by blank lines fail the Lexical round trip; the builder emits tight lists.

Review fixes

  • Authority: decision and placement from a child now also check the parent's build lock (the record belongs to the parent). Published records report locked when their owning document is locked, so the child disables Record decision. Route test covers both refusals.
  • Deletion: permanently deleting an investigation's child is a typed conflict (still published by an investigation) in both adapters, mirroring research; contract test on memory and PostgreSQL. PostgreSQL published() joins the parent channel, like memory.
  • Missing child: a completed card whose document is gone says "Its published document is no longer available."
  • Rollback: investigationSchema is strict, so rolling back past 019_investigation_children cannot decode completed rows that carry childId; strip the field (and the column) before downgrading.
  • Polish: evidence headings use interface type (scoped .plan-evidence reset over prose sizes); the embed uses border-edge; the filter uses the shared field styling; Record decision and a ghost Remove evidence from document share one row; recorded decisions are a quiet inset row with a ghost Insert decision evidence in document.

Before → after (ready card, child view):

Before ready card
After ready card
Before child views
After child views
After phone view

Follow-up (not in this PR): on a phone the bar chart opens at 1:1 and is cropped until fit is chosen; that is packages/diagrams behaviour, not the view component. The table is a contained horizontal scroll lane, and the chart toolbar no longer wraps.

Design-contract review

Renewed (Maggie approved for this pass) in a dedicated commit: apps/web/src/navigation-shell.tsx dynamic owner. Its class data flow (sidebarMotion.className, sidebarPresence.className) is unchanged; only the child navigation callbacks now take Pick<Research.ReadyChild, "slug"> so investigation children reuse research child navigation. #507 also renews this file; whichever lands second rebases.

🤖 Generated with Claude Code

@MaggieAppleton
MaggieAppleton force-pushed the design-investigate-child-doc branch from 7024941 to d55c5d1 Compare October 12, 2026 09:05
@MaggieAppleton
MaggieAppleton added this pull request to stack #509 October 12, 2026 09:17
@MaggieAppleton
MaggieAppleton force-pushed the design-investigate-child-doc branch from 0b03c8e to c107477 Compare October 12, 2026 09:44
@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Looks good (one authority gap worth fixing before merge, rest are follow-ups)

Checked against the base (#500) in a local build on a fake-GitHub server. I paired a connector, ran /investigate, then went through submit → complete → ready card → child → Record decision → back to the parent, on desktop at 1440×900 and phone at 390×844. Server and web experiment tests pass locally (17/17).

Storage and publication: correct

  • complete → ExperimentStore.publish runs the fence, the child channel insert and the record update (with child_channel_id) in one transaction. Rows are locked in the same order as save (channel row, then experiment row), so there's no deadlock between the two. Because the child id is deterministic and if (current.childId) return current runs before the revision check, a concurrent or repeated completion returns the same child. The contract test covers this in memory and in PostgreSQL.
  • Persistence comes before publication: changed() fires only after the commit, and the card only gets child from a stored childId plus a real channel row. The ready card can't link to a missing child.
  • Plan.initial() and the dialect/Lexical validation run outside the transaction, and the document is checked at submit time. Completion can't fail on content. Good.
  • Migration 019 is additive and nullable, and it uses UNIQUE + ON DELETE RESTRICT, the same as research_workspaces.published_channel_id. It needs no backfill.

Result → document: safe

  • The report is parsed and passes through canonical() (dialect allowlist + Lexical round trip). Nothing is evaluated. Checks, limitations and the environment line are escaped with reportText. Evidence is put in fences that can't be closed early (longest backtick run + 1). Evidence is clipped to the remaining budget, so the total stays under 256 KB.
  • I verified live that a report smuggling <Investigation …/> is refused by the connector with protected-report. The builder repeats the same check.

Authorization

  • Run, retry and cancel still require value.documentId === params.id, so they work only from the parent. Good.
  • The repository role is rechecked: access() runs against the child channel, and the child shares the parent's repository. A child reader gets nothing a parent reader wouldn't, and writes still need push/admin.
  • Gap (should fix): routes.ts:350 (decision) and :356 (placement) call mutationAllowed(params.id). From the child, that checks the child's implementation lock, and in V1 a child never has one. A writer can therefore record an investigation decision through the child while the parent has an active implementation/build, which the parent route refuses. The client has the same hole: room-workspace.tsx:937 uses the child room's implementation.locked. Suggested fix: in owned(), or in these two routes, also call mutationAllowed(value.documentId) when value.childId === params.id. The decision belongs to the parent's record, so the parent's lock should govern it. Add a routes test for it. Placement from the child edits the child document, so checking the child lock there is right. Decision is the route that matters.

Smaller follow-ups

  • postgres/adapter.ts hard delete (~line 906) checks research_workspaces.published_channel_id before DELETE FROM channels, but has no matching check for experiments.child_channel_id. Deleting an archived investigation child will therefore surface a raw FK 23503 error instead of the typed conflict, and the memory adapter will allow it. Mirror the research check in both adapters.
  • postgres/experiments.ts:31 published() doesn't filter on the parent existing or being archived, but the memory version does (options.exists). Small parity difference.
  • Rollback: investigationSchema is .strict(), so once childId is written, older code fails to decode completed rows. Fine going forward. Just note in the PR that rolling back past this PR breaks reads of experiment lists.
  • If the child channel is gone, a completed card loses its inline result and offers no link. Unlikely, but the card could say "Result unavailable" rather than showing just the brief.

UI

  • Ready card: good. It's compact, matches the research ready card, Investigation · 1 view + title + first paragraph, and the whole card opens the child. It's the same on phone.
  • Child provenance line: good. Investigation from <parent> · by … · 1 view with the Brief toggle. It wraps acceptably on phone.
  • Heavy borders (agree with the implementer's flag): the <Experiment> embed and the recorded-decision block each use a 1px near-black border with a large radius, and so does the native <select>. They are the darkest objects on the page and are boxed inside a soft card, which breaks the app's quiet surfaces. Suggest:
    • Embed: border-border-subtle (same token as SidecarCard), the card radius, and drop the inner serif Startup heading to the card-title size. The section already says "Views".
    • Recorded decision on the ready card: no border. Make it a quiet inset row (bg-surface-subtle, radius-sm) with conclusion · "by ana-rev", rationale and evidence behind disclosure. "Insert decision evidence in document" should become a btn-ghost btn-sm instead of a full-width outline button.
    • "Remove evidence from document" is a centred, unstyled text button at the bottom of the embed. Make it a btn-ghost btn-sm, left-aligned with Record decision, or move it to an overflow menu.
    • <select>: use the app's select styling.
    • Phone: the 4-column table clips its last column (Median), and the chart toolbar wraps onto two rows. Both are probably pre-existing in ExperimentView, but they're more visible now that the view is a document's main content.

CI

See the orchestrator report. The container job passes. Validation and e2e were running when I posted this.

Base automatically changed from design-investigate-slash to run-on-clicker October 12, 2026 10:19
MaggieAppleton and others added 9 commits October 12, 2026 11:19
Completion now promotes the candidate and creates a research-shaped child in one
fenced commit: the report, an Experiment embed per view, method, limitations and
evidence. The record links the child (child_channel_id), the inline card becomes
a compact link to it, and the child's embeds read the parent's record by id so
selections and decisions stay shared. Reports may no longer embed Investigation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Other tests publish the same fixture into one repository, so a child's title
may gain a suffix. The second proposal anchors on its own passage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The navigation-shell.tsx dynamic owner's class data flow (sidebarMotion and
sidebarPresence classNames) is unchanged; only the child navigation callbacks
now take Pick<Research.ReadyChild, "slug">. Maggie approved renewing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A child's evidence decisions belong to the parent's record. The record now
reports when its owning document is locked so the child disables Record decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…parent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Evidence headings use interface type instead of prose sizes, decisions sit in an
inset row with a ghost action, and the filter uses the shared field styling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant