Foundry on Autri — Wave 2 (M4 Authoring/Review UI) Build Epic
Status: Build epic — storified session 28 (2026-06-29), after the char-offset renderer was de-risked by a working spike. Grounds the build of milestone M4 in the requirements contract projects/autri/requirements/foundry-integration (authoritative on scope) and follows Wave 1 (projects/autri/epics/foundry-v1, S0–S4 shipped to the held foundry-v1 branch). Consumes the shipped @autri/authoring domain (annotations + reviews + read adapters + M3 access). Authored as a standalone doc — Form-A input for /hl:ship.
Grounding & spike outcome
A Wave-2 renderer spike (session 28) built a working vertical slice — /foundry surface → getSection → offset-faithful renderer → select → @autri/authoring create → persist → rehydrate — against a real ingested markdown doc on the local dev DB (migration 029 applied additively). Proven: rendered markdown carries exact source offsets per text run (domTextLen === end − start, syntax chars excluded); a selection maps to the exact source range; the annotation persists with char_start/char_end/quoted_text and the correct server-derived user_id/author_type; a fresh page load re-resolves the highlight at the exact span. Dark-themed, zero console errors. The spike code lives uncommitted in the foundry-wave2 worktree and is the foundation these stories harden — not throwaway.
Load-bearing decisions
- Anchor offset space = section-local offsets into
getSection().content(validated by the spike). Create-time and display-time use the identical domain-returned string, so the round-trip is exact by construction, and deterministic chunking reproduces it byte-for-byte on re-ingest of unchanged content. - Data layer = route handlers (not server actions): durable across redeploys (server-action ids are per-build hashed → stale tabs 404), and they fit the lifted Foundry components' fetch shape.
- Renderer = a rehype pass over react-markdown's hast (positions preserved) that wraps each text run in an offset-tagged element, splitting at highlight boundaries into
<mark>. Dependency-free. Replaces Foundry's brittle quoted-text DOM search — the structural fix for the orphaning cluster (#163/#161/#127).
Open question owed to Wave 3 (flag, not a Wave-2 blocker)
M2's "survives a re-ingest of unchanged content" bar depends on section-identity stability, which the renderer does not own: the shipped anchor keys on sections.id (a UUID). The offset within a section is proven stable; whether the anchor survives re-ingest hinges on whether that section UUID (or the M5 unit_index) is reused for unchanged content — an ingestion/versioning property. Resolve in the Wave-3 (M5) storify: confirm sections.id stability across re-ingest, or re-key the anchor on (unit_index, char range). Wave 2's own bar is M4's: highlights map to the correct spans.
Isolation — parallel-track discipline
All Wave-2 work branches off the held foundry-v1 branch (HEAD at integration time), not main — same discipline as Wave 1. PRs merge into foundry-v1; integration to main stays deliberate (alongside Track 1's FIA work). The /hl:ship orchestration must override its default main base to foundry-v1 for both worktree creation and merge target. The spike foundation is committed to the Wave-2 base branch first so the renderer story builds on it rather than rebuilding from scratch.
QA backend
Headless-preview + dev-auth (backend (b)). The dev-auth bypass (AUTRI_DEV_AUTH=1 + the dev Credentials provider) is already present and was validated end-to-end in the spike. Headless-login recipe for every QA-agent prompt: GET /api/auth/csrf → POST /api/auth/callback/dev (form-encoded csrfToken + json=true) sets the session cookie; then drive the worktree dev server. Watch the two known traps: the dev server must run from the worktree (a foundry-wave2-pinned .claude/launch.json config exists), and headless tabs report visibilityState='hidden' — don't gate verified behavior on visibility. Crucible is the post-deploy prod regression, not the pre-merge gate.
Story Summary
| Story | Title | Scope | Deps | Visual | Phase |
|---|---|---|---|---|---|
| W2-S0 | Surface foundation + Shell mount + auth adapter | The /foundry route section inside <Shell> (new nav key), the foundryScope() session→domain adapter, doc picker + section nav, loading/error states. Hardens the spike's bare mount. | — | yes | M4 |
| W2-S1 | Char-offset renderer (hardened) + mechanized test | Harden the spike renderer: multi-chunk-section join fidelity, selection edge cases (element boundaries, triple-click, keyboard), the entity/value-mismatch guard on code/tables; add the deterministic offset round-trip test across ≥2 doc shapes. | W2-S0 | yes | M4 |
| W2-S2 | Annotate + thread (reviewer flow 1) | Create a comment from a selection; threaded replies (parent_id); resolve/reopen; highlight↔comment interaction (click-to-scroll, hover); the annotation list. | W2-S1 | yes | M4 |
| W2-S3 | Review batch + submit (reviewer flow 2) | Review composer: batch draft annotations → the domain's atomic submit; view a review; review status lifecycle. | W2-S2 | yes | M4 |
| W2-S4 | Author shell | Edit-mode UI shell (editor view + edit toggle) that the M5 keystone plugs into in Wave 3. NO versioned save this wave — the save action is stubbed/disabled. | W2-S0 | yes | M4 |
Stories
W2-S0 — Surface foundation + Shell mount + auth adapter [foundation]
Depends on: none. Visual: yes.
Scope. Promote the spike's bare /foundry surface to the real surface: mount inside the existing <Shell> chrome with a new nav key (sidebar entry), keep the foundryScope() adapter (Cognito session → AuthContext + read/write scope; done in the spike), the document picker (org-scoped markdown docs), the section nav, and real loading/error/empty states. Autri-themed, dark mode default (inherits au-* primitives + token palette; no new theme work).
Acceptance.
- A signed-in user opens
/foundry, sees their org's documents, opens one, and navigates sections — all within the Autri<Shell>. 🧑 (headless-preview visual QA) - Dark mode renders correctly; no console errors. 🤖 (
preview_console_logsin QA) - A cross-org document id 404-hides (the domain's
assertCanReadDocumentis the boundary; the route surfaces it as not-found). 🤖 (route test)
Owned surfaces. app/app/foundry/{layout,page}.tsx, app/app/foundry/[docId]/page.tsx, app/lib/foundry/scope.ts, app/components/foundry/* (shell/nav/picker), nav registration in the Shell nav config.
W2-S1 — Char-offset renderer (hardened) + mechanized test [substrate — the crux]
Depends on: W2-S0 (mounts in the surface). Visual: yes.
Scope. Harden the spike's rehype-offsets + source-markdown into the production renderer:
- Multi-chunk-section fidelity.
getSection().contentjoins chunks with\n, normalizing the original paragraph gaps. Decide + implement the join/render fidelity (render per-chunk, or join on the true separator) so multi-chunk sections render faithfully while offsets stay exact. Single-chunk sections already proven. - Selection edge cases. Selections that start/end on element boundaries (not text nodes), triple-click, keyboard selection, selections spanning block elements — all map to correct source offsets.
- The value/offset guard. Validate the
value.length !== end − startfallback on docs with HTML entities, code blocks, and tables (where rendered text ≠ source slice); confirm it degrades to node granularity and never lands a highlight wrong.
Acceptance.
- Mechanized offset round-trip test across ≥2 doc shapes (single-chunk prose + a multi-chunk and/or code/table doc): for a known range, select → offset →
quoted === content.slice(start,end); persisted offsets re-resolve to the same span. 🤖 (the M4 "highlights map to correct spans" bar; validate ≥2 cases per[[feedback_validate_across_cases_not_n1]]) - A highlight renders on a multi-chunk section at the correct span, visually. 🧑 (visual QA)
- No console errors on render. 🤖
Owned surfaces. app/lib/foundry/rehype-offsets.ts, app/components/foundry/source-markdown.tsx, their *.test.ts.
W2-S2 — Annotate + thread (reviewer flow, part 1) [app]
Depends on: W2-S1. Visual: yes.
Scope. The reviewer's core loop, lifting Foundry's AnnotationThread/CommentDraft UI rewired to the new anchor + route handlers (not the old REST/localStorage): create a comment from a selection; threaded replies (parent_id, inheriting review_id); resolve/reopen; the highlight↔comment interaction (click a highlight → scroll to the comment; hover); the annotation list/thread panel. Honor the shipped domain's behaviors (30s create-dedup, one-level reply cascade on delete).
Acceptance.
- A user highlights source text, leaves a comment, replies to it, and resolves it — all persisted via
@autri/authoring, all re-resolving on reload. 🧑 (visual QA, headless recipe) - Clicking a highlight scrolls to its comment; the comment shows author_type + status + char range. 🧑
- Route handlers map
ServiceError→ correct HTTP status (404 hide, 400 validation). 🤖 (route test)
Owned surfaces. app/components/foundry/section-reader.tsx (+ thread components), app/app/foundry/api/annotations/route.ts (extend: edit/resolve/reopen/delete + list).
W2-S3 — Review batch + submit (reviewer flow, part 2) [app]
Depends on: W2-S2. Visual: yes.
Scope. The review aggregate: a review composer that batches draft annotations and submits them via the domain's atomic submit (draft → submitted, stamps review_id, all in one transaction); viewing a submitted review and its annotations; the review status lifecycle (draft → submitted → complete).
Acceptance.
- A user batches several draft comments into a review and submits; all targeted annotations flip to
submittedand carry thereview_id; the review showssubmitted_at. 🧑 (visual QA) - The submit is atomic (the domain guarantees it; verify the UI reflects all-or-nothing). 🤖 (route/domain test already covers atomicity; UI integration verified visually)
Owned surfaces. review components under app/components/foundry/, app/app/foundry/api/reviews/route.ts.
W2-S4 — Author shell [app]
Depends on: W2-S0. Visual: yes. (Co-waves with W2-S1; lives in its own files/route to avoid the section-reader collision.)
Scope. The author-side surface shell only — the editing affordances the M5 versioning keystone plugs into in Wave 3, with no real save this wave. An edit-mode toggle, a markdown editor view of a section/doc, and a save action that is present but stubbed/disabled (wired to the M5 atomic section-flip in Wave 3). Reviewer-first, author-shell-only per the contract's M4 framing.
Acceptance.
- A user toggles to author/edit mode on a doc and sees the editor view; the save control is visibly present but inert (clearly "coming in versioning"). 🧑 (visual QA)
- No write path is reachable from the shell yet (no annotations/version mutation) — the shell is inert by construction. 🤖 (no write route added)
Owned surfaces. author components under app/components/foundry/author/, an author view/route distinct from the reviewer surface.
Wave plan
Topo sort of the deps:
- Wave A: W2-S0 (foundation).
- Wave B: W2-S1 (renderer) + W2-S4 (author shell) — both depend only on S0; S4 in its own files → parallel-safe.
- Wave C: W2-S2 (annotate + thread — needs S1).
- Wave D: W2-S3 (review batch + submit — needs S2).
5 stories, 4 waves, all visual → headless-preview + dev-auth QA; W2-S1 also carries the mechanized renderer test. Base off foundry-v1; merge into foundry-v1.