All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
components/workspace/workspace-rail.tsx RESOURCE_GROUPS reshape: the prior three single-leaf coming-soon entries (Knowledge / Matters / Resources) are replaced with four product-domain groups (Knowledge / Workflows / Integrations / Help), each carrying a leaves: RailLeaf[] array. New shape per group: Knowledge has three leaves (Research / Vault / Sources, all coming-soon pending Session 32’s reshape); Workflows has two (My Workflows + Template Library); Integrations has two (Connections + Marketplace); Help has two (Guides + What’s New). The first leaf in each non-Knowledge group routes to a real placeholder page from 31b; second leaves and all Knowledge leaves fall back to /workspace/coming-soon/<slug> via the RailLeaf.href optional field. Each leaf’s href is the extension point — adding a real route in a future session is one line. Rail leaf labels follow Title Case across all groups; “All Workflows” renamed to “My Workflows” to honestly name what the list contains. Files: components/workspace/workspace-rail.tsx (RESOURCE_GROUPS shape change + renderer update for nested leaves). Smoke pass: rail renders four groups with the correct leaf counts and labels; clicking each leaf routes to the expected URL (real route for first leaves in W/I/H, coming-soon URLs for the rest); active-state highlighting works on real-route leaves./workspace/workflows, /workspace/integrations, /workspace/help. Each is a real designed page describing what the surface will become — h1 + subtitle + descriptive prose + an “In development — Session NN ships X” footer line that grounds the placeholder in a real roadmap rather than vaporware. Replaces the prior pattern (rail entries pointing at the generic /workspace/coming-soon/<slug> component) for these three surfaces, while keeping coming-soon as the fallback for sub-leaves and Knowledge. Real routes from day one means URLs are stable across the build-out — when Sessions 33/34/35 ship the real content, the URLs don’t change; only the page bodies fill in. Files: app/workspace/workflows/page.tsx (new), app/workspace/integrations/page.tsx (new), app/workspace/help/page.tsx (new). Route count went 19 → 22 (+3). Smoke pass: each new route renders with its own metadata title and forward-looking content; no auth gating added (inherits from app/workspace/layout.tsx).components/workspace/workspace-breadcrumb.tsx outer container gains a lowercase Tailwind class so every visible segment renders lowercase via CSS text-transform; underlying segment data (ROUTE_TABLE strings, STATIC_SEGMENT_HREFS keys, RESOURCE_AREA_LABELS values) preserves natural case so department names like “Commercial” stay “Commercial” in the DOM tree. Five new RESOURCE_AREA_LABELS entries added for the Session 31a sub-leaves (knowledge-vault → Vault, knowledge-sources → Sources, workflows-templates → Template Library, integrations-marketplace → Marketplace, help-whats-new → What’s New) so breadcrumbs on those coming-soon URLs render as polished labels rather than raw hyphenated slugs. Reverses Session 30’s “match the rail’s Title Case” choice on the breadcrumb specifically — lived experience showed Title Case breadcrumbs compete with page h1s (also Title Case) for typographic attention; lowercase reads as ambient chrome (the URL-bar mental model) and lets h1s carry the page’s voice. Rest of D-046’s Title Case decision stands for rail and admin tool names. Files: components/workspace/workspace-breadcrumb.tsx. Smoke pass: breadcrumbs on /workspace/coming-soon/knowledge-vault read “workspace / vault”; same pattern for the other four sub-leaves; rail and h1 case unchanged elsewhere in the app./workspace was promoted from the department-launcher hero to a five-card cards-grid dashboard surfacing the product’s full set of domains (Departments / Knowledge / Workflows / Integrations / Help). New files added: lib/workspace/dashboard.ts (source-of-truth array), components/workspace/workspace-dashboard.tsx + components/workspace/workspace-dashboard-card.tsx (the grid + card components), app/workspace/departments/page.tsx (the existing department-launcher body, moved off /workspace). A stagger-children entrance animation defined in app/globals.css drove the cards’ fade-in. Browser pass showed the dashboard was a downgrade — five cards with Preview pills on four of five surfaces announced the product’s emptiness rather than its capabilities; the pre-Session-31 department-launcher hero was a stronger front door. Reverted by deleting all dashboard-specific files from the working tree (they were never committed, so no git revert needed), stripping the dashboard-card-enter keyframes from app/globals.css, and restoring app/workspace/page.tsx + components/workspace/workspace-modules.tsx from HEAD. components/workspace/workspace-hero.tsx was restored byte-for-byte from commit 49b8d1e (Session 21) via git show 49b8d1e:components/workspace/workspace-hero.tsx > — the interpretive recreation Claude Chat had initially asked for baked in unintended typography changes from the Session 31 attempt; literal git restore was needed to land byte-identical with the pre-Session-31 hero. The /workspace landing surface in this commit is byte-identical to its pre-Session-31 state. The dashboard concept moves to README Future / Backlog for a future session when category surfaces have real content to surface (Session 36+). Files net of the revert: no dashboard files in the commit. Lesson learned and now encoded in CHATBOT_HANDOFF.md: when reverting a UI surface, restore from git literally rather than asking Claude Code to recreate the file from a description./workspace/admin/* routes the chrome renders AdminRail (a sibling component to WorkspaceRail) with an “Admin” top-line link and three captioned groups (Access, Insights, Value); elsewhere the existing workspace rail is unchanged. Switch happens client-side via a thin RailSwitcher that takes both server-rendered rails as ReactNode props and picks one based on usePathname(). Brand mark in both rails wraps in <Link href="/workspace"> to serve as a universal home gesture. The existing WorkspaceProfileBlock dropdown’s admin entry now flips label and href contextually — “Admin” → /workspace/admin from workspace routes, “Back to workspace” → /workspace from admin routes; isAdmin gating unchanged. Files: components/workspace/admin-rail.tsx (new), components/workspace/rail-switcher.tsx (new), lib/workspace/rail-styles.ts (new, shared className tokens), lib/workspace/profile.ts (new, shared display helpers), components/workspace/workspace-rail.tsx (brand mark → Link, consume shared modules), components/workspace/workspace-profile-block.tsx (pathname-aware label/href), app/workspace/layout.tsx (RailSwitcher wiring). Smoke pass: rail flips at the route boundary in both directions, avatar entry label matches the destination mode, brand-mark click returns to workspace from any admin sub-page./workspace/admin cards-grid landing reorganized into three captioned sections (Access, Insights, Value) matching the rail. New lib/admin/nav.ts exports ADMIN_NAV_GROUPS — a single typed array consumed by both the admin rail and the landing-page cards. Adding a new admin tool = one entry in that file; both surfaces update without further edits. ← Admin back-links removed from the three admin sub-pages (rail provides the affordance globally). Section headers on the landing reuse the rail’s captionLabel typographic token so caption vocabulary matches across surfaces; rail-specific margins (mx-2 mb-2) stay at the rail call sites while the landing applies its own (mb-3). Files: lib/admin/nav.ts (new), app/workspace/admin/page.tsx (consume ADMIN_NAV_GROUPS), app/workspace/admin/{users,metrics,calculator}/page.tsx (remove back-links, drop mt-4 from header). Smoke pass: landing renders three sections each with one card, rail and landing reference identical labels — both pull from ADMIN_NAV_GROUPS, no string duplication between surfaces.components/workspace/workspace-breadcrumb.tsx reworked so every non-last segment that resolves to a real route renders as a <Link>; scoping segments with no route (currently “Departments”) render as plain spans. Implementation: new STATIC_SEGMENT_HREFS map for static segment → href resolution, new resolveSegmentHref(seg, departments, agents) helper that falls back to dynamic department-name and agent-name lookups, renderer rewritten to branch on resolveSegmentHref returning null vs. an href. Admin tool labels normalized to Title Case (“User Access”, “Adoption Metrics”, “Productivity Calculator”) across ADMIN_NAV_GROUPS, breadcrumb leaves, and admin sub-page h1s — scoped deviation from the UX-writing skill’s sentence-case rule for visual parity with the Workspace rail’s department names (which are proper nouns and naturally Title Case). Scoping segments in ROUTE_TABLE are now Title-Cased (Workspace, Admin, Trash, Departments) so the breadcrumb reads with consistent case throughout. Caveat: agent-name resolution in resolveSegmentHref matches by name string and returns the first match — collision risk if two agents in an org share a name; tighten to id-keyed resolution if collisions surface. Files: components/workspace/workspace-breadcrumb.tsx, lib/admin/nav.ts (labels), app/workspace/admin/{users,metrics,calculator}/page.tsx (h1s). Smoke pass: breadcrumb on each admin sub-page reads “Workspace / Admin / LOCKED_DEPARTMENT_SLUGS constant) effectively retired in favor of a real access model driven by user_department_roles rows. Schema — migration 0020: new public.organization_default_departments table records the set of departments auto-granted to a new user at first provisioning. Columns id / organization_id / department_id / created_at + unique (organization_id, department_id) + index on organization_id. RLS: org-admin write (mirrors departments_org_admin_write from 0001), open-read inside the org. Backfilled with commercial + general-tools as the canonical defaults for the existing org via a generic slug-keyed INSERT. Migration 0020 also replaces 0015’s departments_read_accessible_or_admin policy with departments_read_same_org — every org member can now read every department’s metadata (id/slug/name/description) to support the locked-but-visible UX; agents inside departments remain RLS-gated via agents_read_accessible (has_department_access). Schema — migration 0021: extends ensure_user_provisioned() via CREATE OR REPLACE with a Stage 2 tail block. When the caller has zero existing user_department_roles rows, INSERT one row per default with role='user', guarded by ON CONFLICT (user_id, department_id) DO NOTHING against the unique constraint from 0001 and wrapped in an EXCEPTION WHEN OTHERS block matching the function’s prior best-effort error-swallowing posture. Defaults apply once at first provisioning, not on every authenticated request — any existing grant (manual, defaults-applied, or seed-inserted) blocks Stage 2 from re-running. CREATE OR REPLACE preserves the privilege grants from 0002 (revoke from public, grant to authenticated). Data helpers (lib/auth/access.ts): new getAllDepartmentsWithAccess(userId) returns DepartmentWithAccess[] with per-row hasAccess flag, replacing getAccessibleDepartments at the workspace landing + rail call sites. DepartmentWithAccess extends AccessibleDepartment so narrower-typed consumers (workspace top bar, breadcrumb) accept the wider shape via structural assignment. getAccessibleDepartments retained for any future caller needing the strict accessible set. New admin helpers getOrgUsers (returns OrgUser[]), getAllUserDepartmentRoles (returns UserDepartmentRoleRow[]), getOrganizationDefaults (returns department_id[]) — all org-admin gated at the app layer (mirror-RLS-tighter pattern matching D-042’s framing). Server actions (lib/actions/admin-users.ts, new): grantDepartmentAccessAction, revokeDepartmentAccessAction, addDefaultDepartmentAction, removeDefaultDepartmentAction. Centralized gateOrgAdmin (getUser + isOrgAdmin + organization_id resolve) + verifySameOrg (defense-in-depth user+dept org check) helpers keep each action body focused on its specific mutation. Zod-validated inputs at the trust boundary, discriminated-union { ok: true } | { ok: false, error: string } return, PG-error-code-only logging on failure. Grant + revoke also revalidatePath('/workspace') so the user’s rail + landing reflect on their next request. Admin UI — new /workspace/admin/users route: page-level gate isCurrentUserOrgAdmin → notFound (tighter than admin/layout.tsx’s requireAdminUser which admits dept_admin). Top section “Default access for new users” — chip-toggle row over the full department list, controlled by add/remove defaults actions. Main section “Users” — every user in the org as an expandable row with chevron disclosure; each user’s row reveals a chip-toggle group of the same department list controlled by grant/revoke actions. Four parallel server reads at page load, bucketed into a plain Record<user_id, department_id[]> for RSC-boundary serialization (Map/Set don’t cross). Chip toggle vocabulary: Aperture slate-blue filled chips (bg-chat-cite-bg + text-primary, matching the Session 27 Department Agent / Web search chip vocabulary) with a leading Check icon for the granted / default state. Muted outlined chips (border border-border + bg-card + text-muted-foreground) with a leading Plus icon for the click-to-grant / click-to-add state. Inline legend above each chip group: instructional sentence followed by two color-dot indicators with explicit labels (“Granted” / “Click to grant”; “Currently a default” / “Click to add as default”). Icons + legend added in a polish pass during smoke testing when color-alone differentiation read as ambiguous — WCAG 1.4.1 grounding. Surface consistency: LOCKED_DEPARTMENT_SLUGS constant retired from app/workspace/page.tsx. Department grid and rail now both render every department in the org and key locked-vs-accessible from department.hasAccess. Locked card variant (LockedDepartmentCard in department-card.tsx) preserved verbatim from prior visual treatment. New locked rail-entry render branch in workspace-rail.tsx mirrors the card’s mailto pattern — department-scoped subject + body, points at siteConfig.adminEmail. The empty-state guard in the rail still hides the DEPARTMENTS group only when the org has zero departments configured (degenerate state); a user with zero accessible departments now sees the group with all entries locked, matching the launchpad’s visibility-with-permissions principle. Files touched: supabase/migrations/0020_organization_default_departments.sql, supabase/migrations/0021_default_department_grants_on_provisioning.sql (new); lib/auth/access.ts, lib/actions/admin-users.ts (new), app/workspace/page.tsx, app/workspace/layout.tsx, app/workspace/admin/page.tsx, app/workspace/admin/users/page.tsx (new), components/workspace/department-card.tsx, components/workspace/department-grid.tsx, components/workspace/workspace-rail.tsx, components/admin/users/user-list.tsx (new), components/admin/users/user-access-row.tsx (new), components/admin/users/default-departments-section.tsx (new). 14 files total: 7 modified, 7 new. Build status: clean — 19/19 routes (the new /workspace/admin/users route is the +1 over Session 28’s 18), no new warnings, no TypeScript errors (compile 2.4s, TypeScript pass 2.1s). Migrations applied via Supabase dashboard SQL Editor per SETUP.md 3d convention; verification queries A–D for 0020 and A–C for 0021 all passed. Smoke status: in-browser verification across (a) admin sees all 8 departments fully accessible on launchpad + rail, (b) /workspace/admin/users renders with the defaults section + user list, (c) defaults toggle round-trips through the database (verified via SQL after page reload), (d) per-user grant/revoke persists and triggers locked rendering on /workspace + rail, (e) clicking a locked card or locked rail entry opens a department-scoped mailto. End-to-end fresh-user provisioning test skipped — function-level verification (verification B confirmed Stage 2 body contains both organization_default_departments and v_grant_count) considered sufficient. Operator note: a stray auth.users row (stevantini@gmail.com — missing the ‘e’ after ‘v’ in the admin email) surfaced during smoke prep; not provisioned in public.users so unaffected by either migration; left alone pending a future cleanup decision.forked_from_agent_id) but had never been activated. Session 21 deliberately retired the launchpad’s Templates section pending product clarity. Session 27’s framing surfaced the customer model: in-house legal departments need a curated set of approved agents per department with knowledge-management or org-admin lead curating; sole practitioners don’t need templates and create user-owned agents directly. Data migration: supabase/migrations/0019_activate_templates.sql (new) flips is_template = true on every created_by IS NULL row. Single UPDATE, transactional, idempotent. Applied via Supabase dashboard SQL Editor per SETUP.md 3d convention; verified template_count=15, unflipped_count=0 in dev. Six Commercial rows were already templates from migration 0006; this migration extends the flip to the remaining canonical rows across all eight departments. Launchpad activation: getAgentsForDepartmentLaunchpad helper pivots the Department Agents bucket from created_by IS NULL AND is_template = false to is_template = true. LaunchpadAgent type gains is_template: boolean. Department launchpad page fetches isCurrentUserOrgAdmin() in parallel with the agent buckets and threads canManageTemplates through AgentGrid to AgentCard. User-facing label preserved: cards still render under the “Department Agents” section heading — users think “the firm’s NDA review tool,” not “the NDA review template,” and the section title matches that vocabulary. Agent card refactor: isTemplate branch now chat-first (/workspace/agents/<id> — the chat surface, not the fork form). The fork-on-click pattern (Session 8f-A) is retired from card click; the chat-surface Customize button is the new entry point. canManageTemplates && agent.is_template renders a new EditableAgentCard (inline function in agent-card.tsx, shared with the existing My Agents bucket via a mode prop) in admin-template mode with overflow menu (Edit + Delete), confirmation dialog with org-stakes copy (“Other users will no longer see it on the department launchpad. Their forked copies are unaffected. You can restore it within 30 days.”), and 30-day Undo via toast — mirrors the MyAgentCard pattern from Session 8f-B with permission scope widened. Non-admin viewing a template gets primary chat click with no admin affordances. Three new server actions in lib/actions/agents.ts: createTemplateAgentAction (org-admin-gated via isCurrentUserOrgAdmin(); sets is_template = true and created_by = null — templates are org-canonical, not personal, so the NULL anchor places conceptual ownership at the organization rather than the individual admin); forkAgentFromConversationAction (three-step transactional: new agent INSERT → new conversation INSERT → bulk messages INSERT with source created_at preserved for timeline coherence; best-effort soft-delete rollback on conversation-copy failure since users have no DELETE policy on agents per migration 0010, so hard rollback via the user client isn’t available — orphan surfaces in the admin’s 30-day trash window instead of silent corruption). The result type for fork is { ok: true, newAgentId, newConversationId, departmentSlug } | { ok: false, error }. Three widened server actions: updateAgentAction, softDeleteAgentAction, restoreAgentAction. Gate logic flipped from “owner of non-template only” to “owner-of-non-template OR org-admin-of-template” via isCurrentUserOrgAdmin(). The 30-day restore window check is uniform across types — admins do not get extended restore for templates. Chat surface for templates: persistent “Department Agent” chip in agent-header.tsx meta-chip row using Aperture’s slate-blue mono-caps vocabulary with dot-prefix, matching the existing Web Search chip — renders whenever agent.is_template is true, regardless of viewer. No first-turn banner; passive persistent chip carries the load. Three-way top-right slot logic: owner-of-non-template → Edit link (Session 19 path, unchanged); template + canManageTemplates → Edit link (admin path); template + !canManageTemplates → “Customize” button (new); soft-deleted → no top-right action. The Customize button (components/chat/customize-template-button.tsx, new) calls forkAgentFromConversationAction with the current conversationId from ChatInterface state and routes to /workspace/agents/<new-agent-id>?c=<new-conversation-id> on success. Snapshot semantics on the copied conversation: re-snapshot from the new agent (not preserve the source’s snapshot) — at fork time the new agent’s prompt equals the template’s current prompt, so re-snapshotting produces identical content with cleaner downstream semantics (admin edits to the template don’t ghost-update the user’s prompt; user edits to their fork land on subsequent conversations as expected). Template edit page: gate widened to admit org-admin on templates. Warn-palette banner above the form using the Aperture --warn-fg-deep / --warn-fg / --warn-bg token trio: “Edits to this agent affect everyone in your organization. Existing conversations keep their original system prompt; new conversations will use your edits.” The second sentence is load-bearing — surfaces the snapshot semantics in plain language without the word “snapshot.” Trash visibility: getDeletedAgentsForUser widened with isOrgAdmin flag. Admins see the union of own user-owned deletions and all org template deletions; non-admins see only own. DeletedAgent type gains is_template. Trash row renders a “Department Agent” chip next to the name on template entries for scannability. ”+ New Agent” button: admin route → /workspace/agents/new?department=<slug>&as_template=true (binds to createTemplateAgentAction, heading “New Department Agent” with org-stakes subline); non-admin route → /workspace/agents/new?department=<slug> (binds to createAgentAction, unchanged user-owned path). The page reads the as_template URL param and admin-gates the template path with notFound() for non-admins so a typed-URL attempt falls through cleanly. Centerline regression fix (D-043): three wrappers in agent-header.tsx and message-bubble.tsx gain w-full to fix the Session 17b shrink-to-content latent bug. max-w-3xl is a ceiling, not a width; without w-full the container shrinks to content width and mx-auto then centers a shrunk element, producing content-length-dependent x-coordinate variance. The fix forces 768px width regardless of content. User bubble restructured: outer wrapper mx-auto flex w-full max-w-3xl justify-end; inner bubble naked (no inline-block), keeps max-w-full as the per-bubble width cap. Bubble’s right edge now flush against the column’s right edge — matches ChatGPT / Claude.ai / Cursor convention and the established messaging-app pattern (iMessage / WhatsApp / Slack all place user content on the right). Geometric measurement via browser getBoundingClientRect confirmed alignment: header content, user bubble outer wrapper, and composer card all at x=288 after the fixes. Schema/snapshot semantics confirmed in Step A.2 research, no schema work needed for the templates UX. Conversations are user-scoped via existing conversations_user_owns RLS — multiple users chatting with the same template each get their own thread automatically. system_prompt and model snapshotted per-conversation at creation time per migration 0004’s design intent and CLAUDE.md’s AI Integration Rules (“old conversations retain their original prompt for reproducibility”); admin edits don’t disturb in-flight conversations. tools_enabled and agent_attachments are NOT snapshotted — they read live from the agent row per turn. Admin edits to a template’s web-search toggle or attached files propagate to in-flight conversations on the next turn. Existing schema behavior, not a Session 27 introduction, but flagged in D-042 consequences as user-visible-now-that-templates-are-admin-mutable. Org-admin scope (not dept-admin) is deliberately tighter than RLS allows. Session 26’s D-041 established the mirror-RLS principle (don’t surface affordances that would 403); Session 27 inverts the framing — gates narrower than RLS for product-policy reasons (templates are org-wide artifacts; centralized curation matches the customer model). The opposite tightening direction is called out explicitly in D-042. Files touched: supabase/migrations/0019_activate_templates.sql (new), components/chat/customize-template-button.tsx (new); lib/actions/agents.ts, lib/auth/access.ts, app/workspace/departments/[slug]/page.tsx, app/workspace/agents/[id]/page.tsx, app/workspace/agents/[id]/edit/page.tsx, app/workspace/agents/new/page.tsx, app/workspace/agents/trash/page.tsx, components/chat/agent-header.tsx (Session 27 + D-043 centerline fix in the same file), components/chat/chat-interface.tsx, components/chat/message-bubble.tsx (D-043 only), components/workspace/agent-card.tsx (the EditableAgentCard is an inline function added in this file, not a separate component file), components/workspace/agent-grid.tsx (modified). Build status: clean — 18/18 routes, no new warnings, no TypeScript errors. Smoke status: in-browser verification across (a) launchpad surfaces templates as Department Agents; (b) admin overflow menu Edit + Delete works on Department Agent cards with org-stakes confirmation copy; (c) clicking a Department Agent routes to chat surface (not fork form); (d) chat surface renders Department Agent chip and Edit button for admin; (e) edit page renders warn-palette banner with snapshot-semantics copy; (f) soft-delete + Undo round-trips; (g) template entry visible in trash with Department Agent chip; (h) centerline alignment fix verified via getBoundingClientRect — header content, article wrapper, composer card all at x=288; (i) right-anchored user bubble visible. Non-admin Customize flow not verified end-to-end (no non-admin test account in this session); the action’s logic is covered by smoke of the admin paths and the auth gate. Deferred to Session 28: chat-surface containment — the chat column doesn’t visually read as a contained surface even with the geometry now correct; addressed separately as its own focused effort. Operator note: copy updates on Department Agents deferred to a production session per the D-041 pattern — operator edits live via the new affordance.opacity-0 group-hover:opacity-100 focus-visible:opacity-100 motion-reduce:opacity-100 convention. Click swaps the description text for a textarea + Save/Cancel buttons. ⌘/Ctrl+Return saves, Escape cancels, plain Enter is newline (matches Session 17b’s chat composer). The card’s <Link> swaps to a <div> during edit mode so accidental clicks on heading or foot don’t navigate away; the hover-lift treatment drops naturally in the div variant because the lift classes only apply to the Link’s className composition. Textarea uses Session 17a’s form-field convention (bg-card, hairline border, focus-within slate tint) and inherits text-[13px] leading-[1.45] from the description so visual rhythm doesn’t break in edit mode. Role gate: new isCurrentUserOrgAdmin() helper in lib/auth/access.ts returns true only for super_admin/org_admin, mirroring the RLS write policy on public.departments (departments_org_admin_write, migration 0001) exactly. dept_admin intentionally excluded — rationale in D-041. Existing isCurrentUserAdmin() (which includes dept_admin) preserved for admin route gating and the rail profile-dropdown visibility check, where dept_admin should still pass. Server action: new updateDepartmentDescriptionAction in lib/actions/departments.ts (new file). Pattern mirrors updateAgentModelAction (Session 17a): auth check → role check via isCurrentUserOrgAdmin() → Zod parse (UUID + string, max 280 chars after trim) → UPDATE with RLS as second line of defense → revalidatePath("/workspace") → discriminated-union result { ok: true } | { ok: false; error: string }. Trim-empty-to-null preserves the nullable column semantics (a blank-but-not-null value would render as an empty line on the card). PG error code logged without PII per backend-security.md. Optimistic update via useTransition matches Session 17a’s model picker; toast.error from sonner on failure (same import path as model picker — no new toast surface introduced). Accessibility: pencil button has aria-label="Edit description", textarea autofocuses on edit-mode entry, focus returns to pencil on exit (save success or cancel) via useEffect + ref, keyboard contract matches the chat composer. Seed file deliberately not updated — supabase/seed/0001_org_and_departments.sql retains the original (now inaccurate) descriptions. Production gets edited live via the new affordance once deployed; fresh local dev environments produce old copy until a future consolidation session. Files touched: lib/auth/access.ts, app/workspace/page.tsx, components/workspace/department-grid.tsx, components/workspace/department-card.tsx (modified — now a client component to hold the edit-mode state lifted from the editor); lib/actions/departments.ts, components/workspace/department-description-editor.tsx (new). Build status: clean — 18/18 routes, no new warnings, no TypeScript errors (compile 2.5s, TypeScript pass 2.0s). Smoke status: in-browser sign-off on admin account across (a) pencil hover-reveal works, (b) edit-mode entry/exit via pencil click + Escape + Cancel button, (c) ⌘/Ctrl+Return saves with optimistic update landing instantly, (d) page reload confirms server persistence, (e) navigation guard — clicks on card heading and foot during edit mode do not navigate. Non-admin view not verified in this session (no non-admin test account on hand); belt-and-suspenders coverage (server-side canEdit filter at page render + server-action role check) makes the worst-case failure a missing affordance, not a broken one. Copy updates for the eight existing departments deferred to a production session — operator will edit live once the deploy lands.?message=check-inbox querystring: form state OR confirmation state, never both. The previous behavior — form stayed visible with a status box appended below it on submit — read as a re-prompt rather than confirmation. The new behavior renders one of two mutually exclusive states. Confirmation echoes the submitted email (“We sent a sign-in link to email@example.com”) via an httpOnly cookie legalos_pending_email (path-scoped to /login, 10-minute TTL, set in the server action regardless of signInWithOtp outcome to preserve the no-leak posture). Confirmation also exposes Resend (a server action that reads the cookie, refreshes its maxAge, and re-fires the OTP) and Use-different-email (a Link to /login that lands the user back in form state). Visual polish ports the marketing landing vocabulary — left-anchored composition at the same px-6 min-[720px]:px-10 x-axis as LandingTopbar and LandingHero, Inter Tight type scale (h1 at text-[36px] min-[720px]:text-[48px], smaller than landing’s 64px to fit a one-line heading), masked-reveal entrance for heading and subline and content (landing-line-mask plus landing-line-up plus landing-el-up plus landing-el-in utilities reused verbatim from app/globals.css; mask-release delay tuned to 1200ms via inline override from landing’s hardcoded 3060ms because login’s one-line choreography ends at 1100ms vs landing’s four-line at 2960ms; stagger intervals 200ms / 1000ms / 1500ms — faster than landing’s 1500/2700/3300 because login is an action surface, not a marketing surface). LoginTopbar reuses the landing topbar’s brand-mark vocabulary verbatim (dot plus wordmark, identical typography and padding), wraps the wordmark in a Link to /, omits nav, CTA, and date label. Empty negative space on the right side at desktop widths — no glyph echo, no decoration; the landing establishes glyph identity, the login surface benefits from quietness. Primary CTA matches the landing hero’s “Enter workspace” button class string verbatim plus w-full and disabled: modifiers for form-button context. Authed-user bounce in proxy.ts — authenticated users hitting /login are redirected to /workspace, with refreshed Supabase session cookies copied from getSupabaseResponse() into the redirect per the file’s existing CRITICAL note (otherwise the redirect would drop the refreshed cookies and bounce-loop the user). /auth/callback stays reachable for authed users so old magic-link clicks still resolve. Server-action refactor. signInWithMagicLink and the new resendMagicLink action share private helpers resolveSiteUrl() and sendMagicLink(); cookie set via setPendingEmailCookie() helper. The siteUrl resolution chain (NEXT_PUBLIC_SITE_URL then https://${VERCEL_URL} then http://localhost:3000) extracted from inline to the helper. "use server" constraint accepted. The cookie name legalos_pending_email is duplicated as a string literal in actions.ts and app/(public)/login/page.tsx because "use server" files cannot export non-async-function constants; sync-warning comments at both locations. Files added: components/login/{login-topbar,login-form,login-confirmation}.tsx. Files modified: app/(public)/login/{page.tsx,actions.ts}, proxy.ts. No layout file — app/(public)/login/ has no other surface, so a layout wrapper would be ceremony. No agent-runtime changes. No DB schema changes. No /api/chat changes. Build status: clean — 18/18 routes, TypeScript pass green. Smoke status: in-browser sign-off across (a) form state renders with masked-reveal entrance on first paint at /login; (b) submit transitions to confirmation state at /login?message=check-inbox with form fully replaced and email echoed; (c) Resend stays on confirmation state and re-fires OTP; (d) Use-different-email returns to clean form state at /login; (e) authed user navigating to /login redirects instantly to /workspace with no flash of the form. Production smoke of the email-send paths gated on Supabase free-tier rate limit clearing. Out of scope, deferred: custom SMTP via Resend (next session, removes the 2/hour rate limit); ?next= preservation in the proxy (proxy.ts:24 deferral still active); invitation gate (sunsets D-035)./ with workspace relocation to /workspace (Session 22a — opens the public surface and shifts the entire authed app under a single prefix; spans four commits from relocate workspace through the landing build itself). The temporary one-line redirect at app/page.tsx is replaced with a real marketing page; every authed route under app/(workspace)/ moves to app/workspace/. Routing migration (app/workspace/..., proxy.ts, plus mechanical find-and-replace across Link href, redirect(), revalidatePath(), pathname matchers, breadcrumb route table literals and regex anchors, and WorkspaceNavLink prefix checks). The route-group (workspace) becomes a literal workspace segment; Next.js renders every authed surface at /workspace/... instead of bare-root. app/auth/callback/route.ts updated so a magic-link click without an explicit ?next= lands on /workspace, not on / (which now serves marketing). D-036 records the path-based-separation rationale (Linear/Vercel pattern over GitHub state-based or Stripe subdomain split — the latter remains the long-term answer once a custom domain is locked, but the cost is unjustified at the current vercel.app stage). Marketing landing components under components/landing/ (4 components, 1 elevated to client for the glyph scheduler): LandingTopbar with brand mark + weekday/month/day label + Sign in link; LandingHero with eyebrow (BETA · v0.1.0), three-line masked-reveal headline, subline, primary CTA to /workspace, secondary mailto Request access link; LandingFooter with brand block + three link columns (Product / Resources / Company) + subfooter row, all four columns flowing full-width to the viewport edge (no centered island on wide displays); LandingGlyph rendered inside the hero <section> so it centers vertically against the headline rather than against the entire <main> column. Hero typography. Inter Tight 64px desktop / 44px mobile at weight 400, tracking -0.03em, line-height 1.04. h1 inline maxWidth: "36ch" (lifted from the spec’s 22ch after smoke caught the 35-char third line wrapping into a fourth visual line), textWrap: "balance". Three lines: Welcome to legalOS, (legalOS in accent slate-blue) → your connected workspace and → legal department operating system. Per-line staggered animation delays at 1700ms / 1830ms / 1960ms via inline style, each line a typewriter landing-line-up slide inside a landing-line-mask clip-path wrapper that releases at 3060ms so descenders survive the choreography. The h1’s wrapper card moved from max-w-[980px] to max-w-[1140px]. Section flex top-anchored (no justify-center, no min-h-[calc(100vh-88px)]) with pt-[120px] desktop / pt-[80px] mobile so the hero starts a deliberate distance below the topbar without expanding to fill the viewport. Glyph two-phase choreography (components/landing/landing-glyph.tsx, client component for the scheduler). Opening phase (0 → ~19.6s after mount): three accent-stroked pulse rings at r=92, staggered with animationDelay 0s / 1.4s / 2.8s, each running landing-ring-pulse-opening (4 finite iterations of the 4.2s landing-ring-pulse cycle, eased cubic-bezier(0.2, 0.6, 0.2, 1)). Reads as continuous breathing; rings hold at the final keyframe (opacity 0, scale 1.05) once their iteration count exhausts. Settled phase (20s onward): a setTimeout(15000) arms a setInterval(5000) whose first tick at 20s sets hasFiredFirstPulse and bumps a pulseKey; each tick mounts a fresh <circle key={pulseKey}> that runs landing-ring-pulse-once (single 4s eased iteration of the same keyframes). Reads as a quiet heartbeat with ~1s of silence between pulses. Both phases coexist as siblings in the SVG — the opening rings self-terminate via finite iteration count rather than React unmount, so the transition between phases is seamless. Reduced-motion guard at app/globals.css zeroes all three variants (landing-ring-pulse, landing-ring-pulse-once, landing-ring-pulse-opening) so motion-sensitive users see only the static reference rings + center dot regardless of phase. Animation tokens (app/globals.css). New section “Session 22 — Landing keyframes”: seven @keyframes (landing-stage-in, landing-el-in, landing-el-up, landing-dot-in, landing-line-up, landing-ring-pulse, landing-line-unmask), seven matching @utility classes, plus landing-ring-pulse-opening (4 iterations) and landing-ring-pulse-once (1 iteration, 4s) variants that share the landing-ring-pulse keyframe at different iteration counts. The landing-ring-pulse keyframe choreography is 0% scale(0.18) opacity 0 → 12% opacity 0.55 → 70% opacity 0.05 → 100% scale(1.05) opacity 0 (front-loaded peak so each pulse reads as decisive then quiet). proxy.ts public-path allowlist (proxy.ts). PUBLIC_PATHS continues to cover /login and /auth; the proxy’s isPublicPath check now also matches / exactly (not as a prefix, which would trivially allowlist every path). Anonymous visitors see the marketing landing instead of being bounced to /login. The deferred ?next= preservation is documented in the proxy’s JSDoc (proxy.ts:24) and remains out of scope. Vercel observability (app/layout.tsx). @vercel/analytics/next and @vercel/speed-insights/next mounted at the bottom of <body> — no-ops in development, enabled in production once the deploy goes green. package.json and package-lock.json carry the two new deps; no other build config touches. PDF-extraction serverless-safe rebuild (lib/extract/, next.config.ts). The legacy pdf-parse (Node-only, hits fs.readFileSync('./test/data/05-versions-space.pdf') at import time, fatal on Vercel Functions) replaced by unpdf — pure-JS, serverless-safe, smaller bundle. The bundling fix that preceded it externalized pdfjs-dist and @napi-rs/canvas so they’re not pulled into the Function bundle. Files touched (commit summary): routing commit (adbf982) — every reference to the (workspace) route group; auth callback default; breadcrumb route table; pathname matchers in WorkspaceNavLink + WorkspaceBreadcrumb. Bundling commit (d54d501) — next.config.ts serverExternalPackages. Extraction commit (c986597) — lib/extract/pdf.ts, package.json, package-lock.json. Landing commit (1e65fe2) — components/landing/{landing-footer,landing-glyph,landing-hero,landing-topbar}.tsx (new), app/page.tsx, app/layout.tsx, proxy.ts, app/globals.css, package.json, package-lock.json. No agent-runtime changes. No /api/chat changes. No DB schema changes. Build status: clean — 18/18 routes. Smoke status: in-browser sign-off across (a) / lands on the marketing surface for both anonymous and authenticated visitors; (b) primary CTA navigates to /workspace; anonymous visitors then redirect to /login via the existing proxy gate; (c) hero choreography stages over ~3.7s with the three headline lines arriving on cadence and the line-mask releasing cleanly so descenders survive; (d) glyph opening rings render continuous breathing for 15s, then transition to single-pulse rhythm at 20s with no abrupt unmount; (e) reduced-motion mode resolves all landing animations to their end-states; (f) footer columns flow to the viewport edge on wide displays, no centered island; (g) every previously-working /workspace/* URL still resolves; (h) magic-link callback without ?next= lands on /workspace. Out of scope, deferred to Session 23 and later: ?next= preservation in the proxy (re-routing magic-link clicks to the deep page they came from); login / auth UX polish; invitation gate (which would sunset D-035 — see D-038 for the current threat-model accounting); custom SMTP via Resend (production prerequisite for wider URL sharing — Supabase free-tier email is rate-limited to 2/hour).background, secondary, accent, sidebar, chat-user-bubble-bg, card-divider, border, input, hairline, sidebar-border, hairline-strong, border-strong). Tokens with starting L at or above 0.97 bumped by +0.01 (card, popover, muted, paper-2, sidebar-accent — capped to avoid clipping near the sRGB ceiling at L=1.0; a flat +0.02 across the family would have collapsed --muted, --paper-2, and --sidebar-accent into indistinguishable pure white and eroded the warm cast at the brightest end). Card and form-field surface lift, additional pass. After the initial palette pass landed, cards read too close to the page background (--card at L 0.986 vs --background at L 0.9791 → tonal step of 0.007, below the threshold for distinct-surface perception). Lifted --card and --popover from oklch(0.986 0.0082 91.48) to oklch(0.992 0.0046 80.7209) (≈ #fdfaf4) — restoring the tonal step to 0.013 and bringing the chroma/hue family-aligned with --background. Form-field surfaces in the agent edit form, the chat composer, and other input-bearing call sites consume bg-card directly, so the lift propagates without per-component overrides. The admin calculator’s inputs use bg-background and stay there per the same shift logic. Foreground tracking. --primary-foreground and --sidebar-primary-foreground (the paper-on-primary tokens for cream text on slate-blue surfaces) lifted from L 0.9591 to L 0.9791 to track the new --background L value, preserving the semantic pairing between paper-bg and paper-on-accent. Foreground tokens themselves (ink, ink-2, mute, caption) and the accent family (primary, accent-hover) are untouched — only the surface layer shifted. Inline hex comments updated in app/globals.css to reflect the new computed values per token (e.g. paper #f4f1ec → paper #fbf8f3; stone #efeae1 → stone #f6f1e8; stone-2 #e8e2d4 → stone-2 #efe9db; stone-3 #e3ddd1 → stone-3 #eae4d8; card-border #ebe6dc → card-border #f2ede3; card-divider #f0ebdf → card-divider #f7f2e6). Aperture handoff README annotated. docs/design/aperture/README.md gains a header banner directing readers to app/globals.css as the canonical source for current values; the original hex tables in the README are preserved as historical reference. Files touched. app/globals.css (palette tokens + card/popover lift), docs/design/aperture/README.md (header banner only). No component overrides. No agent-runtime changes. No DB schema changes. Build status: clean — 18/18 routes. Smoke status: in-browser sign-off across (a) /, /workspace, agent pages, agent edit form, chat composer, login — every surface inherits the lighter palette automatically; (b) cards read as distinct surfaces against the lighter page bg (the 0.013 tonal step is perceptible without being heavy); (c) hairlines remain visible against both background and card surfaces; (d) foreground contrast preserved against the lighter backgrounds (no readability regression for ink-on-paper or ink-2-on-stone); (e) chat code blocks and inverted surfaces unchanged.components/workspace/workspace-rail.tsx). Inbox link dropped entirely — the affordance promised notifications/messages infrastructure that doesn’t exist and reading as a placeholder eroded the rail’s restraint. Knowledge, Matters, and Resources convert from flat resource-link rows to captioned groups, each with placeholder leaf links to /coming-soon/<slug>. The captioned-group pattern previews the rail’s eventual hierarchy when those areas ship without forcing the user to click into emptiness today. Workspace landing modules section (components/workspace/workspace-modules.tsx, new). New “MORE” section beneath <DepartmentGrid /> rendering the same three areas (Knowledge / Matters / Resources) as quiet rows — mono-caps section header, single line per row with arrow-circle right, subdued visual weight relative to the department cards. Sets up the eventual surface where these modules become real without needing a layout change when they do. Locked department cards (components/workspace/department-card.tsx). Product and Compliance render a locked variant: muted typography, lock icon in the foot replacing the arrow circle, “Request access” mailto CTA pointing at siteConfig.adminEmail, click handler stripped. This is a demo placeholder for future per-user RBAC, not actual access enforcement — RLS already enforces real access at the query layer and these cards stay visible to admins regardless. Once per-user RBAC ships (D-035 sunset adjacent), the locked variant flips on/off based on the user’s user_department_roles scope rather than a hardcoded slug list. The hardcoded LOCKED_DEPARTMENT_SLUGS = ["product", "compliance"] constant is the single point of removal when that work lands. Migration 0016 — six Commercial agents flipped from external to native. supabase/migrations/0016_commercial_agents_to_native.sql runs six atomic per-row UPDATEs on the Commercial-department agents (enterprise-agreement-review, mutual-nda-review, order-form-sow-review, vendor-agreement-review, dpa-review, ai-addendum-review), flipping type = 'native', populating system_prompt with placeholder copy (real prompts deferred to a separate “8f-Promote” content session per D-025’s phasing list — this migration is the structural flip, not the prompt-quality session), setting model = 'anthropic/claude-sonnet-4-6', is_template = false, and external_url = NULL. The agents_native_requires_prompt CHECK constraint from migration 0001 (type = 'native' → system_prompt AND model NOT NULL) is the schema-layer enforcement that the UPDATE order respects. Pattern B — canonical departmental agents. is_template = false is the load-bearing flip: it surfaces the six agents in the new Department Agents launchpad bucket (see below) rather than the Templates bucket. These are not forkable templates — they’re canonical, departmentally-blessed agents the user invokes directly. The forkable-template path (Personal AI Workflow Builder — click-to-fork-and-customize a department agent) is the right Phase 3 product move and is recorded in PROJECT_OUTLINE.md’s Phase 3 candidates list, not shipped here. Companion code change (lib/auth/access.ts). getAgentsForDepartmentLaunchpad rewrites from the prior two-bucket {templates, myAgents} shape to a transitional three-bucket {departmentAgents, templates, myAgents} shape, then loses the templates bucket entirely later in the same commit (Templates section removed — see below). Final two-bucket shape: {departmentAgents, myAgents} predicated on is_template = false and created_by IS NULL for department agents (canonical, no user owner) vs. created_by = userId for user-owned agents. Applying migration 0016 without the helper rewrite would leave the six rows orphaned by the launchpad query — they exist in DB but don’t surface in UI. Migration 0017 — soft-archive every Blank Agent template. supabase/migrations/0017_archive_blank_agent.sql runs UPDATE public.agents SET is_active = false WHERE slug LIKE 'blank-agent-%' AND is_active = true to take the eight per-department Blank Agents (one per dept, seeded by 0004_blank_agents.sql) out of circulation. Soft-archive via is_active, not the deleted_at 30-day-undo column — these aren’t user soft-deletes that need restoration windows. Hard delete avoided because existing user forks of Blank Agents (different agent ids, independent rows in public.agents) are unaffected by this migration and remain accessible; only the seeded template rows are archived. Idempotent — re-running on already-archived rows is a no-op via the is_active = true predicate. Companion seed change: supabase/seed/0004_blank_agents.sql updated to set is_active = false on insert/conflict so a fresh dev re-seed reproduces post-migration state. Three-bucket → two-bucket launchpad (app/(workspace)/departments/[slug]/page.tsx). Department Agents section added above the prior Templates + My Agents pair (intermediate three-bucket shape), then the Templates section dropped entirely later in the same commit once 0017 took the Blank Agents out of circulation — leaving Department Agents (canonical, dept-level) and My Agents (user-owned). ”+ New Agent” button moved from the My Agents section heading to the page header so it stays visible regardless of which section the user is reading; the page-header position is also where the eventual Personal AI Workflow Builder fork affordance will live. Known surface-edge bug: the page-header “+ New Agent” button works on locked departments if URL-typed (no client-side gate); noted in known-issues, fix folds into the per-user RBAC work. Migration 0018 — welcomed_at column on public.users. supabase/migrations/0018_users_welcomed_at.sql runs ALTER TABLE public.users ADD COLUMN IF NOT EXISTS welcomed_at timestamptz (nullable, no default). Semantics: NULL = first-login welcome variant, NOT NULL = returning-user variant. One-shot — not reset per session, not backfilled (existing users see the welcome variant on their next visit, by design — landing as a re-introduction after the rail restructure rather than skipping them straight to the returning state). Idempotent via IF NOT EXISTS. Reversible — comment block in the migration file documents the ALTER TABLE … DROP COLUMN rollback shape. Adaptive WorkspaceHero (components/workspace/workspace-hero.tsx). New variant branching on welcomed_at: NULL renders the welcome variant — Welcome to legalOS, the operating system for legal departments. (Inter Tight 52px, masked-reveal entrance, the product framing from D-026 carried verbatim into the hero copy). NOT NULL renders the returning-user variant — legalOS, the operating system for legal departments. (drops “Welcome to” prefix; subtle, conveys the user has been here before without inventing greeting phrasing). User-name greeting and time-of-day prefix removed (the Good morning, Steven. pattern from S9e). Removed deliberately — pending dynamic info (recent activity summary, last conversation, next scheduled task) that would make a personalized hero surface feel earned rather than placeholder. Recorded in PROJECT_OUTLINE.md’s Phase 3 candidates as “Dynamic info on returning hero.” Agent-count caption relocated (components/workspace/department-grid.tsx). The ${totalAgents} ${agentVerb} working across ${deptCount} ${deptNoun} line moves from the hero subline (where it competed with the welcome/returning copy) to a caption in the DepartmentGrid section heading — same data, more appropriate position. Files touched (15): DECISION_LOG.md (D-035 rewritten from S20’s 32-line long form to current 27-line shorter form — no new D-entry, D-numbering jumps D-031 → D-035 with D-032/033/034 intentionally unused), app/(workspace)/departments/[slug]/page.tsx, app/(workspace)/page.tsx, components/workspace/{department-card,department-grid,department-header,workspace-hero,workspace-rail}.tsx, lib/auth/access.ts, supabase/seed/{0002_commercial_agents,0004_blank_agents}.sql (modified); components/workspace/workspace-modules.tsx, supabase/migrations/{0016_commercial_agents_to_native,0017_archive_blank_agent,0018_users_welcomed_at}.sql (new). No agent-runtime changes. No proxy.ts changes. No /api/chat changes. Build status: clean. Smoke status: in-browser sign-off across (a) rail renders without Inbox, with the three captioned groups; (b) workspace landing shows MORE section beneath DepartmentGrid; (c) Product and Compliance cards render locked, mailto CTA opens default email client; (d) Commercial launchpad shows six Department Agents as the first section, no Templates section, My Agents section unchanged; (e) “+ New Agent” button position on page header; (f) first-login user (welcomed_at IS NULL) sees Welcome variant; (g) re-test via update public.users set welcomed_at = null; returns the user to first-login state for repeat verification (recorded in known-issues for future reference). Out of scope, deferred: real Commercial agent prompts (separate “8f-Promote” content session); Personal AI Workflow Builder (Phase 3); per-user RBAC for departments — replaces LOCKED_DEPARTMENT_SLUGS (Phase 3); per-agent RBAC (Phase 3); dynamic info on returning hero (Phase 3); canonical agents for the other 7 departments (Phase 3); page-header “+ New Agent” lock-respect bug fix (folds into per-user RBAC); invitation gate (D-035 sunset condition).px-* py-* from five inner pages (app/(workspace)/agents/[id]/edit/page.tsx, app/(workspace)/agents/new/page.tsx, app/(workspace)/agents/trash/page.tsx, app/(workspace)/admin/layout.tsx, components/coming-soon/coming-soon.tsx). The workspace body wrapper’s px-14 pt-14 pb-8 plus gap-9 becomes the single source of truth for page-content rhythm — no more per-page additive padding stacking on top of the chrome’s intrinsic spacing. Static RLS recon (Step B, no code) — audited the post-auth flow for a stranger completing magic-link auth at /login. Found proxy.ts:51 calls ensure_user_provisioned() (migration 0002) on every authed request, auto-inserting a public.users row as role='user' with zero user_department_roles — no invitation gate, no allowlist, no pending-approval state. Combined with two over-permissive RLS policies from migration 0001 (users_read_same_org, departments_read_same_org) gated only on organization_id = public.current_org_id(), any authenticated stranger could enumerate every member’s id/email/full_name/role and every department’s slug/name/description from the browser console via the anon key. Recon was static analysis only — no separate “live verification code” exists despite handoff-breadcrumb implications. Migration 0015 — RLS tightening (Step C). supabase/migrations/0015_tighten_org_read_policies.sql replaces both policies with department-scoped variants: users_read_self_or_dept_peer_or_admin (self always, full org for org_admin/super_admin, plus users sharing at least one department membership for peer visibility within shared departments) and departments_read_accessible_or_admin (full org for admins, plus departments where the user holds any role). Admin paths preserved — admin pages and user-management UIs depend on org-wide visibility for org_admin/super_admin and that survives. RLS-only migration, no data change, no backfill, reversible by re-applying the original policy SQL. Empty-state polish (Step C, same commit). app/(workspace)/page.tsx gains an empty-departments branch — when a user has zero user_department_roles, the landing hides <DepartmentGrid />, the rail’s Departments group disappears via components/workspace/workspace-rail.tsx, and the hero subline composes a request-access mailto CTA pointing at siteConfig.adminEmail. components/workspace/workspace-hero.tsx’s subline prop widens from string to ReactNode to accept the composed JSX. The combination — gracefully inert UI for strangers plus RLS that refuses metadata enumeration — is the soft-fence posture D-035 deliberately accepts as the Phase-2 alternative to a full invitation gate. D-035 added to DECISION_LOG.md in long-form (32 lines) — context, three sunset options (allowlist by email domain, auth_invitations table, manual approval via is_active=false default), and consequences. (D-035 was rewritten to a shorter 27-line form in Session 21; both versions describe the same posture, the rewrite trims redundant prose.) Files touched (11): DECISION_LOG.md, app/(workspace)/admin/layout.tsx, app/(workspace)/agents/[id]/edit/page.tsx, app/(workspace)/agents/new/page.tsx, app/(workspace)/agents/trash/page.tsx, app/(workspace)/page.tsx, components/coming-soon/coming-soon.tsx, components/workspace/workspace-hero.tsx, components/workspace/workspace-rail.tsx, config/site.ts (modified); supabase/migrations/0015_tighten_org_read_policies.sql (new). No agent-runtime changes. No proxy.ts changes. No /api/chat changes. Build status: clean. Smoke status: in-browser sign-off across (a) five inner pages render with consistent rhythm against workspace chrome (no double-padded surfaces); (b) authenticated user with department memberships sees DepartmentGrid plus populated rail Departments group; (c) authenticated user with zero user_department_roles sees empty-state landing — no DepartmentGrid, no rail Departments group, mailto Request access CTA in hero subline; (d) RLS regression check via the Supabase SQL editor — anon-key SELECT on public.users from a stranger session returns only the stranger’s own row; anon-key SELECT on public.departments returns rows only for departments the stranger has roles in (zero rows for the empty-state stranger). Out of scope, deferred: invitation gate (D-035 sunset condition — gated on Session 24 SMTP work as a related prerequisite for outbound email at scale); per-user RBAC for departments (Phase 3, replaces LOCKED_DEPARTMENT_SLUGS from Session 21); per-agent RBAC (Phase 3).GET /api/attachments/[id]/download (Session 19, Step B prerequisite). Mirrors the docx-export route’s ownership pattern: validates auth, RLS-scoped SELECT on agent_attachments via agent_attachments_user_owns (migration 0007), explicit attachment.user_id === user.id belt-and-suspenders, soft-delete check (deleted_at IS NULL), and a parent-agent created_by === user.id re-check; all failure modes — missing, foreign user, soft-deleted, signing failure — collapse to a single 404 { ok: false, error: "not_found" } so the route never leaks attachment existence outside the user’s access. The response is a 302 redirect to a supabase.storage.from("agent-attachments").createSignedUrl(storage_path, 60, { download: original_filename }) URL — the route does not proxy bytes through, so Vercel Function bandwidth stays at zero and the storage object streams directly to the browser. The 60-second TTL is well within “user clicked the link” timing while keeping shared-link leak windows tight. Used by the Session 19 chat empty-state file list (spec §2.8) for re-downloading agent reference attachments; future surfaces (attachment preview on the edit page, conversation export including attachments) consume the same endpoint. Files touched: app/api/attachments/[id]/download/route.ts (new). No schema changes./agents/<id> (Session 18 — three-step arc: B = data layer + streaming pipeline, C = visual polish, addendum = trace card grouping + citation positioning fix). Closes spec §2.4 (citations), §2.5 (tool-use trace), and §2.6 (streaming states), plus the long-deferred citation-persistence carry-over from Session 8j. Schema migration supabase/migrations/0014_message_sources_and_tool_calls.sql. Two jsonb columns added to public.messages: sources (default '[]'::jsonb, NOT NULL — array of { id, title, url, domain, fetched_at? } records, one per unique citation URL within the message) and tool_calls (default '[]'::jsonb, NOT NULL — array of { id, name, input, output, status, started_at, finished_at?, error?, position } records, one per Anthropic tool_use_id). Both columns inherit message-level RLS from migration 0004; column comments document the shape; idempotent via add column if not exists. Applied to project ref knlnchvfjxchpbkuwtpp. SSE event vocabulary expansion (lib/llm/anthropic/stream.ts, lib/chat/sse-parser.ts). The Session 8a contract (meta | token | done | error) plus the 8j additions (tool_use_start | tool_use_end | citations) is replaced by a richer per-call vocabulary: meta, token (text deltas, including inline <sup data-source-id="..."> citation markers as part of the same token stream — no separate citation_marker event), tool_trace_start { id, name, input, started_at, position } (one per server tool invocation, fired at content_block_stop of the server_tool_use block so input is fully accumulated from input_json_delta chunks), tool_trace_done { id, output, finished_at } (one per matching web_search_tool_result block landing successfully), tool_trace_error { id, error, finished_at } (one per tool-result error code: unavailable | max_uses_exceeded | too_many_requests | query_too_long | request_too_large | invalid_tool_input), source_added { id, title, url, domain, fetched_at? } (one per new citation URL, deduped within message — the same URL cited multiple times in different sentences produces one source record), done, error. The tool_use_* and end-of-stream citations events from 8j are removed; the <WebSearchIndicator> ribbon in MessageList is removed (trace cards are their own indicator). Adapter rewrite (lib/llm/anthropic/chat.ts). Per-block bookkeeping replaces the contiguous-tool-use-interval tracker. The adapter walks Anthropic’s content_block_* events: server_tool_use blocks accumulate input_json_delta chunks into partialJson keyed by block index, then emit tool_trace_start at content_block_stop with parsed input; web_search_tool_result blocks emit tool_trace_done (or tool_trace_error if the content surfaces a web_search_tool_result_error); citations stream as citation events from citations_delta deltas on text blocks (each carrying { url, title, citedText }) — the route turns those into source records and inline <sup> markers. Citation-marker pipeline (app/api/chat/route.ts). Each unique citation URL within a message gets a stable src_<12-char hex> id generated server-side from crypto.randomUUID() (no new dependency); deduped by URL via a per-stream Map<url, sourceId>. The marker is <sup data-source-id="src_xxx"></sup> — explicit open+close (not self-closing <sup ... />, because HTML5 ignores the trailing / on non-void elements and leaves an unclosed sup that wraps every subsequent token as nested children — the load-bearing bug the Step C smoke caught). Citation positioning is server-side deferred-and-injected (Step C addendum). Anthropic emits citations_delta BEFORE the cited text rather than after; injecting at the stream position lands pills in front of claims ([1] The FTC banned…). The route now buffers markers in a per-stream pendingCitations: string[] queue and drains at the next CITATION_DRAIN_RE match within a text chunk: [.!?](?=\s|$) (sentence-ending punctuation followed by space or end-of-chunk) plus zero-width lookaheads for \n\n (paragraph break), \n[*\-+] (bullet list-item start), \n#{1,6}\s (heading), \n>\s (blockquote), \n\d+\. (ordered list-item). Tool events (tool_trace_*) and end-of-stream force-drain — citations belong to text already emitted, never to text that hasn’t arrived yet. Within-drain dedup by sourceId collapses repeated pills (Anthropic emitting three citations to the same source for one cited claim renders one pill, not three). The persisted assistantText is the source of truth — markers land in their final position by INSERT time, no render-time post-processor. Trace card grouping is render-time only (Step C addendum). The DB stores one record per Anthropic tool_use_id; buildBlocks() in components/chat/message-bubble.tsx walks sorted tool calls and buckets adjacent same-name calls (where message.content.slice(prev, curr).trim() === "") into a tool_trace_group block — four parallel web_searches with no prose between them render as one card with header Web search · Searched · 4 queries. A search → sentence of prose → another search produces two cards. Singleton groups render exactly as ungrouped did (no count segment, single Query + Result expanded panel). D-031 captures the rationale (stable IDs survive edits + regenerations + DB roundtrip; HTML <sup> is semantic and plugin-free; render-time grouping preserves position-in-prose semantics with zero schema cost). Persistence on stream completion AND on stream error (app/api/chat/route.ts). The assistant message INSERT now writes content (full body including embedded sup markers), sources, tool_calls (with attribution: source_ids accumulated between tool_trace_done(N) and tool_trace_done(N+1) are rolled into call N’s output.source_ids at end-of-stream; live SSE events ship output.source_ids: [] per the attribution-timing decision in Step B). On stream error mid-flight, the route now persists what it has rather than dropping accumulated state — partial text, partial sources, tool_calls with running-status calls left at running (honest about termination state). Reload reflects the partial state via the conversation-reload path. Conversation reload via ?c=<conv_id> (app/(workspace)/agents/[id]/page.tsx, lib/auth/access.ts, components/chat/chat-interface.tsx). New helper getConversationForChatSurface(conversationId, agentId, userId) selects the conversation if it belongs to the current user AND to the given agent (RLS belt-and-suspenders; single null contract on any failure mode), then loads messages with id, role, content, sources, tool_calls. The agent page reads searchParams.c, validates the UUID shape, calls the helper, and passes initialMessages + initialConversationId props to ChatInterface. The chat surface seeds state from props on mount and writes history.replaceState on the first SSE meta event for a brand-new conversation so subsequent hard reloads preserve the thread. Bad / foreign / wrong-agent ids fall through to a fresh conversation silently — no leakage, no error banner. No conversation listing UI / sidebar in scope (Session 19+). Markdown pipeline extension (components/chat/markdown-renderer.tsx). Adds rehype-raw@^7.0.0 (sole new dependency) upstream of rehype-sanitize so inline HTML (<sup data-source-id="...">) survives the AST round-trip. The sanitize schema extends GitHub defaults to whitelist exactly one new attribute: bare-string 'dataSourceId' on sup (the camelCase form hast uses internally; the tuple form ["dataSourceId"] was the silent-strip bug Step C smoke caught — it interprets a 1-element tuple as [name, undefined] and never matches a real value). The sup component override resolves data-source-id (checking both kebab-case and camelCase prop names for resilience) against passed-in sources and routes to a new <CitationMarker /> chip — slate-blue mono pill, 0.7em, weight 500, padding 1px 5px, radius 4px, hover bg darkens via --chat-cite-bg-hover, click smooth-scrolls to #source-<id> and triggers the chat-source-flash 600ms keyframe on the target row. A legacy normalizer at the top of the renderer rewrites the old self-closing <sup ... /> form to <sup ...></sup> at render time, so messages persisted before Step B’s marker syntax fix display correctly without a backfill migration. Every other layer of XSS defense remains intact — only the explicit sup[data-source-id] whitelist is added; raw HTML otherwise still drops <script>, <style>, on*, javascript: URIs, etc. Visual polish layer (Step C) — five new components / files plus globals.css extensions. components/chat/tool-trace-card.tsx is a full polish per spec §2.5: collapsed by default, click-to-expand toggle (role=button, tabIndex=0, Enter/Space, aria-expanded), chevron rotation 220ms cubic-bezier, three-state header (Searching running with pulsing slate dot to the left / Searched done / Failed (N of M) mixed-error), warn-fg/30 card border on error. Singleton expanded panel: small mono-caps Query / Result labels, query in the chat-code-bg surface with mono 12.5px, result in prose Cited N source(s) or warn-fg error message. Multi-call expanded panel: ordered list of per-call rows separated by divide-border-strong/40 hairlines, each row carrying its own Query box + per-call Result line. components/chat/citation-marker.tsx is the inline pill with <a href="#source-<id>" data-source-id={...} aria-label="Citation N, source: <title>"> — click handler handleClick does preventDefault, smooth scrollIntoView({ behavior: "smooth", block: "center" }), removes/re-adds the flash class with a forced reflow so consecutive clicks restart the animation, and removes itself on animationend. components/chat/sources-list.tsx renders the section per spec §2.4 footnote list: mono-caps Sources header with --border top divider, 3-column grid (sm:grid-cols-[22px_minmax(0,1fr)_180px] with mobile single-column collapse), each row carrying id="source-<src_id>" for the citation chip’s anchor link. Number column is mono slate-blue tabular-nums right-aligned; title is a target-blank link with truncate and min-w-0; domain is mono caption right-aligned. Fold logic: constants FOLD_THRESHOLD = 15, FOLDED_COUNT = 5 at the top of the file; sources.length > 15 shows first 5 plus a show {N-5} more → mono link that expands in place via local React state (resets to folded on every render including reload — fold state is per-message UX, not persisted). components/chat/typing-indicator.tsx tuned to spec §4: 1.4s ease-in-out, 0/180/360ms stagger, opacity 0.3 → 1.0 via the new chat-typing-dot keyframe. Streaming caret per spec §2.6: 1px-wide block at the end of the last text block when the message is the actively streaming assistant turn, 1s steps(2) blink via chat-caret-blink keyframe (discrete blink, not soft fade — fits Aperture’s typewriter tone). MessageList computes showCaret (isStreaming && isLast && role === "assistant" && !isWaitingForFirstToken) and threads to MessageBubble. app/globals.css gains four new tokens (--chat-cite-border, --chat-cite-bg-hover, --chat-cite-flash-bg — slate-blue at ascending alpha 0.18 / 0.18 / 0.24; --chat-trace-dot — slate-blue at 0.55 alpha for the running indicator) and four CSS keyframes + utility classes (chat-typing-dot, chat-trace-dot, chat-caret-blink, chat-source-flash). All four animations gate on a single global @media (prefers-reduced-motion: reduce) rule that resolves them to animation: none — users with motion sensitivity get static end-states rather than pulse / blink / flash. Docx export hardening (lib/exports/docx.ts). Adds CITATION_MARKER_RE (matches both self-closing and explicit-close sup forms) and strips citation markers before lexing — docx export doesn’t render numeric superscripts in v1 (the source URL is dropped per Decision 3 from Session 8k), so leaving the tag in would surface as literal text. Step C polish may revisit by either rendering numeric superscripts or appending a Sources section to the docx. D-031 added to DECISION_LOG.md: documents stable per-source-id markers as HTML <sup> (vs. index-based or remark-plugin alternatives) and render-time trace card grouping (vs. DB-level grouping). Files touched: supabase/migrations/0014_message_sources_and_tool_calls.sql (new); app/api/chat/route.ts, app/(workspace)/agents/[id]/page.tsx, app/globals.css, components/chat/{chat-interface,markdown-renderer,message-bubble,message-list,tool-trace-card,typing-indicator}.tsx, lib/auth/access.ts, lib/chat/sse-parser.ts, lib/exports/docx.ts, lib/llm/anthropic/{chat,stream}.ts, package.json, DECISION_LOG.md (modified); components/chat/{citation-marker,sources-list}.tsx (new). No proxy.ts changes. No agents-table or conversations-table schema changes — only the new columns on messages. Build status: clean — 16/16 routes. Smoke status: in-browser sign-off across (a) trace card collapsed by default, click expands, chevron rotates 220ms, query string in chat-code-bg surface; (b) parallel calls collapse into one grouped card with · N queries header and per-call expanded rows; (c) citation pills render as styled mono pills inline (not raw [N](url) autolinked text — the Step C iteration that caught the schema-syntax bug + self-closing-sup bug); (d) citation pills land AFTER cited text (after the period, paragraph break, list start, etc.); (e) within-sentence duplicate citations to the same source collapse to one pill; (f) sources list renders as the 3-column grid with truncate on title and domain — no “smushing” at the column boundary (the iteration that caught the minmax(120px,180px) Tailwind extractor failure); (g) sources fold-at-15 surfaces show N more → and expands on click; (h) clicking a citation pill smooth-scrolls to and briefly highlights the matching source row; (i) ?c=<conv_id> round-trips through hard reload — same trace card, same superscripts, same sources list, same positions; (j) three-dot pulse before first content (1.4s, 180ms stagger) and blinking caret during text streaming both work; (k) prefers-reduced-motion: reduce resolves all four animations to static states; (l) DB row counts match UI exactly (assistant: source_count and tool_call_count). Out of scope, deferred to Session 19: empty-state identity panel polish, error banner restraint pass, sticky right-side mini-index for long sessions, hover-reveal turn metadata gutter (spec §2.10), padding-nesting cleanup across edit / new / trash / admin / coming-soon, conversation listing / sidebar UI, per-call focus within an expanded grouped trace card.create-next-app@16.2.4 (App Router, TypeScript, Tailwind CSS v4, ESLint 9, Turbopack; no src/; import alias @/*).--defaults preset: base-nova, baseColor: neutral, Lucide icons). cn() helper in lib/utils.ts; pure-neutral OKLCH CSS variables in app/globals.css leave room for theme presets.claude-templates into .claude/skills/: nextjs.md, react-patterns.md, tailwind.md, ui-patterns.md, responsive-design.md, ux-writing.md, web-accessibility.md, environment-management.md, vercel-deployment.md, frontend-security.md, infra-security.md.nextjs.md (Next.js 16 warning, absorbing the scaffold’s now-deleted AGENTS.md), tailwind.md (v4 @theme directive + CSS-variable pattern), ux-writing.md (legal-audience tone).config/ directory with three stub files: site.ts (branding + active theme preset), departments.ts (seed list + shape for the five starting departments), theme.ts (preset registry + metadata). TypeScript types and TODOs only; not yet wired into any component.README.md, CHANGELOG.md (this file), .env.example (with server-only vs client-exposed annotations for Supabase and Anthropic keys).lib/actions/, lib/hooks/, top-level styles/), D-017 (Next.js 16 proxy file convention, formerly middleware).@supabase/ssr (Session 3a): /login form + server action, /auth/callback code exchange, authenticated landing at /, and lib/supabase/{server,browser}.ts clients.proxy.ts + first-login user provisioning (ensure_user_provisioned() SECURITY DEFINER RPC), server-side access helpers in lib/auth/access.ts, and a gated /departments/[slug] route (Commercial wired up; invalid-or-inaccessible slugs return 404 to avoid leaking existence) — Session 3b. Includes migration 0002_user_provisioning.sql and idempotent seed supabase/seed/0001_org_and_departments.sql./departments/commercial now renders category-grouped external agent cards (3 sell-side, 3 buy-side), a session-scoped welcome modal, a floating support button with a mailto: contact, and a legal-specific tips section (Privilege matters / Playbook first). Includes migration 0003_agents_category.sql (adds category text column on agents), seed supabase/seed/0002_commercial_agents.sql, shadcn Dialog primitive, getAgentsForDepartment helper, and localStorage-backed agent-click analytics in lib/analytics/events.ts (D-010 Phase 1 fallback). Analytics fires on onPointerDown to avoid new-tab-open teardown races. Base UI primitives from D-015; no theme-preset port (deferred)./admin (Session 5 backfill — written in prior Session 5, never committed; surfaced and committed during the Session 5 fix audit, tagged [BACKFILL] in git log): authenticated-routes layout with main nav (app/(app)/layout.tsx, components/nav/main-nav.tsx), admin-only sub-layout calling requireAdminUser() with 404-on-non-admin to avoid leaking section existence (app/(app)/admin/layout.tsx), admin landing page with link cards (app/(app)/admin/page.tsx, components/admin/admin-card.tsx), admin auth helpers (isCurrentUserAdmin, requireAdminUser in lib/auth/access.ts), and sign-out server action at its CLAUDE.md-correct path (lib/actions/auth.ts). Includes a placeholder AdoptionMetrics view at /admin/metrics slated for Session 6 rebuild under Constraint C — see D-020./admin/calculator (Session 5 fix): replaces the prior placeholder 4-input calculator with a faithful port of the multi-associate, multi-task workspace from agent-launchpad-template/admin.html (lines ~1115–1968). Per-associate name + salary inputs with computed fully-loaded hourly rate (salary / 2080) × 1.3; per-task description / tasks-per-year / time-w/o / time-w with derived hours-saved and savings; per-associate footer totals across all five numeric columns; grand totals (hours, savings, platform cost, ROI) with ROI styled green when ≥0 and red when <0; three shadcn Dialog info modals with verbatim source copy. CSV export wired to a real download (productivity_savings_data.csv) — improving on the original’s alert('Report export functionality coming soon!') placeholder, documented as a Constraint C exception in D-019. localStorage persistence under launchpad_calculator_data. First reference port shipped under Constraint C (D-019); shadcn Card and Table primitives added to support it./admin/metrics (Session 6): replaces the prior paraphrased view (committed as a [BACKFILL] placeholder under D-020) with a faithful port of the original agent-launchpad-template/admin.html metrics surface (lines ~932–1112, 1182–1252, 1972–2261). Five-card metric grid (Total Interactions / Daily Active Users / Weekly Active Users / Daily Repeat Users / Weekly Repeat Users) with optional ↑/↓ trend pills; Top Users table with rank badges, week/month/year period selector, and clickable user emails that open a User Detail Modal; Clicks per Agent bar chart using the source’s (value / max) * 100% width formula with clickable agent labels that open an Agent Detail Modal; both detail modals reset their internal period selector to week on each open and cap their row table at 50 with an italic “Showing 50 of N…” overflow row. Demo-data toggle (D-021) at the top of the surface switches between sample data (matches source’s visible behavior verbatim) and real data (aggregates AgentClickEvent[] from lib/analytics/events.ts per D-010); selection persists in localStorage.launchpad_metrics_data_source. User-dependent surfaces in real mode render explicit “tracked in Phase 2 when events move to Supabase” empty-state copy (Q1 of the Session 6 plan — AgentClickEvent does not currently include user identity). Two new CSV exports — Top Users (top_users_<period>_<mode>.csv) and Clicks per Agent (clicks_per_agent_<period>_<mode>.csv) — wire the source’s two remaining alert('Report export functionality coming soon!') placeholder buttons to real downloads, extending D-019’s existing exception. Sample-data fixtures at lib/metrics/sample-data.ts adapt the source’s 10 agent names to six seeded Commercial agents plus four invented placeholders representing plausible future agents in other departments; click magnitudes, user emails, per-period structure, and rank order verbatim. Procedural fixtures (userInteractionData, agentUsageData) seeded with mulberry32(42) for bit-for-bit reproducibility across page reloads. shadcn select, tabs, badge primitives added to support the rebuild. Floating “Back to Top” button mirrors source lines 1972–1988.user_department_roles SQL block. Vercel project configured with four env vars at first import (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, and — added Stage 2 — NEXT_PUBLIC_SITE_URL); ANTHROPIC_API_KEY deferred to Phase 2. Two-stage Supabase URL Configuration applied: Stage 1 (localhost) at project creation, Stage 2 (Site URL switched to prod, prod + preview wildcard added to Redirect URLs) after Vercel assigned the production URL. Ten-step end-to-end smoke test passed against the live deploy.public/robots.txt with User-agent: * / Disallow: / (Session 7). The app is auth-gated; nothing useful is reachable without login, so indexing the login page or any future public marketing copy serves no purpose for a private legal-department deployment. Two lines, no dependencies.VERCEL_URL fallback in signInWithMagicLink server action (Session 7) — extends the magic-link redirect resolution from a binary NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000" chain to a three-step chain: NEXT_PUBLIC_SITE_URL (Production, set explicitly) → VERCEL_URL (auto-injected by Vercel on every runtime, unique per deploy, server-only) → http://localhost:3000 (local dev). Solves the chicken-and-egg problem where setting NEXT_PUBLIC_SITE_URL to the prod URL satisfies Production but breaks magic-link on Preview deploys, since each preview has a unique URL that a static env var can’t statically point to. Preview branches now self-test magic-link login without per-branch env var configuration. See app/(public)/login/actions.ts./ (Session 9e — final session of the UI/UX overhaul arc 9a–9e). Replaces the prior department picker that lived at app/(app)/page.tsx with a 232px left-rail + main-column layout matching the docs/design/aperture/README.md spec pixel-faithfully. Route-group split. The new landing lives in its own route group app/(workspace)/ (alongside (app) and (public)) so it can render WITHOUT the global MainNav top bar that (app)/layout.tsx adds — the Aperture rail IS the navigation on this surface; a top bar would clash visually and break the 1440×900 design. app/(workspace)/layout.tsx is a no-op pass-through (no MainNav, no Toaster); app/(app)/layout.tsx is untouched, so every other authenticated route (/departments/<slug>, /agents/<id>, /admin, /coming-soon/...) continues to render with MainNav exactly as before. Auth gating remains global via proxy.ts (path is not in PUBLIC_PATHS). Decision recorded: Decision 1 = (a) — custom layout for / only, MainNav stays everywhere else; chosen over global MainNav replacement (option c) because surfaces not yet renovated for Aperture (department launchpad, agent chat, admin) would inherit a polished rail next to their old layouts and the renovation cost was out of scope for 9e. Components, all server components, located at components/workspace/: workspace-rail.tsx (brand mark with slate-blue dot, Workspace active link, Departments group with all 8 dept names, Resource links group → /coming-soon/<slug> from 9d, Profile block at bottom with initials avatar + full name + role label), workspace-top-bar.tsx (breadcrumb workspace / **departments** + date string Saturday · May 2 via Intl.DateTimeFormat server-clock), workspace-hero.tsx (“WORKSPACE” mono caption + h1 greeting in Inter Tight 52px + subline in mute), department-card.tsx (whole-card <Link> to /departments/<slug>, name 19px / description / foot with {N} agent(s) left + arrow circle right; hover lifts -2px, shadow grows from 3-layer idle to 3-layer hover, border darkens to --hairline-strong, arrow circle inverts paper-bg → ink-bg with translateX(2px) — all 220ms cubic-bezier(.2,.7,.2,1)), department-grid.tsx (section heading + 3-col grid wrapping department-cards), workspace-footer.tsx (mono caption left “⌘K to command ⌘1 workspace · ⌘M matters · ⌘I inbox” + right “privilege enforced v0.1.0” — version read from package.json at build time per Decision 3). Data layer. New getAgentCountsByDepartment() helper in lib/auth/access.ts issues a single PostgREST read against public.agents filtered by is_active = true and deleted_at IS NULL, RLS-scoped to the user, then aggregates Record<string, number> keyed by department UUID in JS — chosen over a single nested PostgREST query (option 1) because three-level nested filtering is fiddly and ~50–100 row JS aggregation is negligible (Decision 4). Page composes via Promise.all over getAccessibleDepartments + getAgentCountsByDepartment after requireAuthUser and getCurrentUserProfile resolve. Phantom-data scope rules honored throughout (the load-bearing constraint of 9e): hero stats column (Open / SLA at risk / Saved · MTD) hidden; live-agents pulse in top bar hidden; per-department counts in the rail hidden (department names alone); per-card stats replaced with real {N} agent(s) count from the DB (templates + user-owned both count, plural-correct copy); greeting simplified to Good {morning|afternoon|evening}, {firstName}. with no bolded phrase (the design’s “Two redlines” implied live data we don’t have); the bolded-phrase rendering mechanic stays in workspace-hero.tsx as a markdown **phrase** parser so future content can opt in without a component change. Subline uses real counts: ${totalAgents} ${agentVerb} working across ${deptCount} ${deptNoun}. Pick a department to pivot in. (e.g., 14 agents are working across 8 departments.); empty-departments branch (defensive — every seeded user has all 8) renders a contact-admin message instead. Glyphs: none. No icons in the rail, no icons on the cards. The brand-mark dot is the only visual ornament (CSS-styled <span aria-hidden>). The Aperture spec’s .glyph mini-icon and .live pulsing indicator on cards are dropped per the no-glyphs + phantom-data rules — README spec for card body is name → description → foot, which the implementation matches verbatim. Spec → Tailwind mapping: exact pixel values from the README via [] arbitrary values (text-[52px], tracking-[-0.03em], leading-[1.02], max-w-[22ch] for h1; gap-[14px] and grid-cols-3 for grid; rounded-[14px] and p-[22px] for cards; h-[56px] top bar / h-[36px] footer; etc.). Multi-layer card shadows ship as Tailwind arbitrary shadow-[0_1px_0_rgba(...),0_1px_3px_rgba(...),0_8px_24px_-8px_rgba(...)] for both idle and hover states — Lightning CSS handles the comma-separated layers cleanly. Hover border #d8d2c7 from spec is approximated with --hairline-strong (#e3ddd1) since the difference at card scale is imperceptible — avoids introducing a fourth stone variant token for one usage. Inter Tight stylistic alternates (font-feature-settings: "ss01", "cv11") applied via inline style on the workspace page root only (Decision 6) — scoped to this surface, doesn’t leak to others. Accessibility: rail is <nav aria-label="Workspace">; cards are <Link> elements with aria-label="Open {department.name} workspace"; brand dot, avatar, and arrow circle are all aria-hidden; “Workspace” rail link carries aria-current="page"; focus-visible outline rings on every interactive element via the existing focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring utility chain. Build status: clean, 13/13 routes, no new warnings. Smoke status: in-browser visual sign-off across rail, top bar, hero, card grid, hover transitions, footer, navigation into /departments/<slug> (MainNav reappears as expected), all four /coming-soon/<slug> rail links, and the back-link from coming-soon. Closes the UI/UX overhaul arc (9a–9e). Future work (separate session, briefed separately): extending the Aperture chrome — rail + paper palette + typography — to the department launchpad and agent chat surfaces, which currently retain the old MainNav + 8h-era layouts./coming-soon placeholder route for forthcoming workspace areas (Session 9d). Single dynamic route at app/(app)/coming-soon/[area]/page.tsx plus a bare fallback at app/(app)/coming-soon/page.tsx, both inside the (app) route group so they inherit MainNav + Toaster from (app)/layout.tsx and auth gating from proxy.ts (path is not in PUBLIC_PATHS, so unauthenticated visitors get redirected to /login with no extra per-route code). Both page.tsx files are thin shims that delegate to a shared server component at components/coming-soon/coming-soon.tsx. The component carries a small Record<string, { label, copy }> lookup at the top covering four recognized slugs — knowledge, matters, inbox, resources — and falls through to a generic “We’re building this part of the app. Check back soon.” message for any other slug (including the bare /coming-soon URL with no slug at all). No 404 on unrecognized slugs by design — the route handles arbitrary input gracefully because the Workspace landing’s secondary nav (Session 9e) might link to slugs that haven’t been wired into the lookup yet. Visual styling consumes the new Aperture tokens from 9c: the area label uses text-caption (the new --caption token resolved through @theme inline to oklch(0.6074 0.0221 77.2148) / #8a8174) at font-mono text-[11px] uppercase tracking-[0.16em]; the heading “Coming soon.” renders in Inter Tight via text-foreground (--foreground → ink); body copy uses text-muted-foreground (mute); the back link is a plain <Link href="/">← Back to workspace</Link> with hover:text-primary (slate-blue accent) — no shadcn primitive needed for a single anchor. Layout centers content vertically + horizontally in min-h-[calc(100vh-4rem)] (matching the existing empty-state pattern at (app)/page.tsx). No new shadcn primitives, no new design tokens, no schema changes — purely additive. Both page.tsx files declare export const metadata = { title: "Coming soon" } so the browser tab reads Coming soon · legalOS per the root metadata template from Session 8m. Build status: 13/13 routes (was 12), clean. Smoke status: unauthenticated visits to all 6 test URLs (4 recognized slugs, 1 unrecognized garbage, 1 bare) correctly redirect to /login via proxy.ts; in-browser authenticated pass confirmed each recognized slug renders its label + copy, generic fallback renders without a label, and the back link returns to /. Groundwork for the Aperture Workspace landing’s secondary nav links (Session 9e) — those will point at /coming-soon/<slug> instead of 404’ing.0013_grra_to_public_sector_and_general_tools.sql applied to prod as a one-shot backfill — plain SQL inside a single BEGIN; … COMMIT; transaction, no do $$ ... end $$ block, no declared variables, no PL/pgSQL guards (pre-flight inspection is the verification, not runtime checks). Slug-based subqueries ((select id from departments where slug = 'grra')) replace variable-based ID lookups. Steps in order: (1) Public Sector description updated to absorb GRRA scope: "Government relations, regulatory affairs, public-sector contracts, and policy advocacy."; (2) DELETE FROM agents WHERE department_id = (...grra...) removes GRRA’s only agent — the Blank Agent template blank-agent-grra from 0004 — deleted, not moved, because Public Sector already has its own Blank Agent template (blank-agent-public-sector) and moving the GRRA copy would have created two functionally identical templates in the same department, defeating the merge; required before step 4 because agents.department_id’s on delete restrict FK would otherwise block the department DELETE; (3) DELETE FROM user_department_roles for GRRA explicitly (the on delete cascade on the FK would also handle this — explicit retained for audit clarity); (4) hard-delete the GRRA department row; (5) shift sort_order down by 1 in a single UPDATE for M&A, Privacy, Product, Compliance, Operations (4→3, 5→4, 6→5, 7→6, 8→7) so the final state is contiguous 1–8 with no gaps; (6) INSERT INTO departments for General Tools at sort_order 8 with ON CONFLICT (organization_id, slug) DO NOTHING — description is the user-specified exact string "general purpose agentic tools" (lowercase, no period — deliberate divergence from the sentence-case + period convention used by the other seven department descriptions; recorded in D-029 so future copy reviews don’t “fix” it); (7) grant existing org_admin / super_admin users dept_admin on General Tools via an org-scoped JOIN (d.organization_id = u.organization_id) — same role-based pattern as 0012 but the join is org-scoped rather than CROSS JOIN, so the grant is correct under the multi-tenant-ready schema even though today there is one organization; (8) INSERT INTO agents for the General Tools Blank Agent template via INSERT ... SELECT FROM departments WHERE slug = 'general-tools' so organization_id and department_id derive from the just-inserted row — column list, value shape, system_prompt text, description text, model (anthropic/claude-sonnet-4-6), and constants (is_template = true, tools_enabled = '[]'::jsonb, default_output_format = 'markdown') mirror the canonical Blank Agent insert from supabase/seed/0004_blank_agents.sql / 0012_department_changes.sql exactly; no new prompt copy invented. Hard-delete chosen over soft-delete via departments.is_active = false because app code (lib/auth/access.ts’s getAccessibleDepartments and getDepartmentIfAccessible) does not filter on is_active, and neither does the RLS policy departments_read_same_org — soft-deleted GRRA would have leaked through slug navigation and any inner join on user_department_roles → departments. Reverse-block comments at the bottom of the migration document the rollback shape (with an explicit note that rollback does NOT auto-restore the deleted blank-agent-grra — recreate manually via the canonical Blank Agent shape from supabase/seed/0004_blank_agents.sql if needed). Companion files updated: supabase/seed/0004_blank_agents.sql header comment (“five starting departments” → “eight starting departments” listing the post-9b 8 — the loop body is generic and required no logic change), config/departments.ts (data-only mirror of the canonical 8-department list, no runtime consumer). Stale current-state references updated: CLAUDE.md Project Overview “It ships with eight departments —” line drops GRRA and adds General Tools; SETUP.md manual-SQL fallback in 3f rewritten to the canonical 8 departments with current sort_order, plus surrounding prose (“all five departments” / “the admin seed block in 3f grants access to all five”) flipped to “all eight”; lib/metrics/sample-data.ts header comment updated (“M&A / Privacy / GR&RA / Public Sector” → “M&A / Privacy / Public Sector / General Tools”). Historical phase content preserved verbatim per the same rule that preserved migration headers in D-026: PROJECT_OUTLINE.md Phase 1 Shipped table, Phase 4 plan; docs/AGENT_ARCHITECTURE.md “Five starter departments, no AI department” section. Migrations 0001–0012 are NOT touched. Groundwork for the Aperture Workspace landing (Session 9e). New ADR: D-029 — Department restructure: merge GRRA into Public Sector, add General Tools.docs/design/aperture/ (Session 9a). Six files total — README.md, atrium-aperture-final.html, atrium-aperture-tweakable.jsx, atrium-hybrid-departments.jsx, design-canvas.jsx, tweaks-panel.jsx — delivered by Claude Design as the “Aperture” handoff. Status: reference-only. These files are not imported by app/ or components/, are not part of the Next.js build, and define no runtime behavior; they exist as a design specification only. The folder’s README.md is the canonical spec and reading order. This session is groundwork for the UI/UX overhaul (Sessions 9a–9e); the three architectural decisions surfaced by Aperture (D-027 fonts, D-028 Constraint B relaxation, D-029 department restructure) will land in their respective implementation sessions, not in 9a.legal-department-launchpad-template. Through Sessions 8a–8l the surface grew well past “launchpad” — native chat with prompt caching, configurable per-agent web search, attached references with text extraction, per-message Word export, soft-delete with 30-day undo, agent CRUD, an 8-department launchpad with role-based access, productivity-calculator and adoption-metrics admin surfaces. The “launchpad” framing reads as one entry point to other tools; the actual product is the operating layer in-house legal teams work inside. Casing convention: display name legalOS (camelCase, lowercase ‘l’) used in UI chrome, browser tab titles, and prose; slug / package name / repo name / cloud-project names legalos (all lowercase) used in URLs, command-line contexts, and CI identifiers. Follows the loose nodeJS / iOS / macOS precedent. Three commits. Commit 1 (refactor: rename to legalOS — code, config, browser metadata): package.json name field flipped to legalos plus a real description; config/site.ts siteTitle flipped from "Legal AI Launchpad" to "legalOS" (consumed by main-nav.tsx as the brand text on every page); app/layout.tsx replaces the original create-next-app placeholder metadata with a real metadata.title.template ({ default: "legalOS", template: "%s · legalOS" }) so per-page titles compose as <page> · legalOS and the default tab title is legalOS alone; .env.example header comment updated. Commit 2 (docs: reframe identity to legalOS; D-026 records the rename): README, PROJECT_OUTLINE, CLAUDE.md, DECISION_LOG header all reframed to the “operating system for legal departments” identity; SETUP intro / clone command / siteTitle snippet updated; docs/AGENT_ARCHITECTURE.md opening line updated; CLAUDE.md directory-tree label legal-department-launchpad-template/ → legalos/; new D-026 — Rename to legalOS ADR appended capturing the rationale, casing convention, alternatives considered, and consequences (D-007’s slug commitment is explicitly superseded). Existing decision-log entries D-001 through D-025 stay verbatim per the immutable-history rule. Commit 3: this changelog entry. Manual rename steps completed by the project owner between commits 2 and 3: GitHub repo renamed legal-department-launchpad-template → legalos (web UI Settings → Repository name); local git remote updated via git remote set-url origin https://github.com/steveantini/legalos.git; Vercel project renamed legal-department-launchpad-template → legalos (deployment URL flipped from legal-department-launchpad-template-*.vercel.app to legalos-*.vercel.app); Supabase project renamed (project ref knlnchvfjxchpbkuwtpp is immutable per D-024 — only the display name changed). Stale-content updates riding alongside the rename. README “Current phase” line and CLAUDE.md “Current Phase” line updated from Phase 0 — Foundation (months out of date) to Phase 2 — Agent product surface reflecting the current reality through 8l. CLAUDE.md Project Overview “five departments” → “eight departments” matching 8l’s department-list expansion. These updates are scope creep relative to a strict identity-only rename, taken deliberately because the docs that get the most reader attention shouldn’t carry stale framing alongside the new identity. Migration and seed-file headers preserved as historical run artifacts — supabase/migrations/0001_*.sql through 0012_*.sql and supabase/seed/0003_*.sql retain their legal-department-launchpad-template headers because they document the project name when each migration was authored and applied. Future migrations use the new name. The PROJECT_OUTLINE.md Phase 0 bullet (“Create repo legal-department-launchpad-template”) is preserved on the same logic — phase descriptions stay as-is per the rename plan, and the action that was taken referenced the old slug. Smoke test passed all 7 steps — browser tab title shows legalOS, main nav brand text shows legalOS on picker / launchpad / chat surfaces, login page is clean of legacy branding, functional regression spot-check confirmed chat streaming / agent form / trash all work normally, zero remaining “Legal AI Launchpad” / “Legal Department Launchpad” / “Launchpad Template” matches in user-visible UI. Out of scope, explicitly deferred: white-labeling per deployment (an organization-level brand_name column overriding siteConfig.siteTitle for forks that want their own branding) — flagged in D-026; not in scope until multi-tenancy actually ships. The local working-directory rename (legal-department-launchpad-template/ → legalos/ on the project owner’s filesystem) is a post-session cleanup the owner will do separately and doesn’t affect git or anything functional.grra, ma) stay stable so existing URLs and agents.department_id foreign keys continue to resolve; only the display names get canonicalized to the long forms (the abbreviated forms M&A and GR&RA had drifted into prod at some point and 8l flips them back to the seed’s intended values). Migration 0012 is wrapped in a do-block that early-returns when no organization is seeded — fresh forks see a no-op migration and rely on the updated supabase/seed/0001_org_and_departments.sql to land all 8 departments in canonical state on first run. Five idempotent sections inside the do-block: (1) UPDATE each existing slug’s name + sort_order to canonical values (no-op when already correct); (2) INSERT the 3 new departments with ON CONFLICT (organization_id, slug) DO NOTHING; (3) grant dept_admin on the 3 new departments to every org_admin / super_admin user via a role-based generic clause (no hardcoded user UUID — better repo hygiene; future admin users auto-grant on similar migrations); (4) loop over the 3 new department rows and INSERT Blank Agent templates with ON CONFLICT DO NOTHING, mirroring the shape from supabase/seed/0004_blank_agents.sql. Seed file updates. supabase/seed/0001_org_and_departments.sql extends its bulk-insert block to cover all 8 departments in canonical order; the user_department_roles loop already grants dept_admin per department so no change there. Header comment “five departments” → “eight departments”. supabase/seed/0004_blank_agents.sql untouched: its existing loop iterates every department in the org and creates a Blank Agent template per row, so re-running it picks up new departments without code changes. config/departments.ts updated for type-safety / forker-scaffolding consistency with seed 0001 — sortOrder shifts on existing 5 entries, 3 new entries appended. The file is data-only (no component reads it), so the change is repo-hygiene rather than runtime-behavior. First-pass description copy for the three new departments — Operations: “Internal operations, vendor management, procurement, and corporate transactions.”; Product: “Product launches, feature reviews, terms updates, and product-counsel partnerships.”; Compliance: “Compliance program management, regulatory monitoring, and audit support.” Marked as placeholders to be refined during the upcoming UI/UX overhaul session. Smoke status: passed all 8 steps — DB verification confirmed 8 departments + 8 Blank Agent templates + 8 dept_admin grants for the project owner, picker page renders 8 cards in correct order with canonical full names, tab bar reflects the 8 departments, the new Operations launchpad renders with its Blank Agent template, the Commercial regression check showed all 6 original templates + Blank Agent + existing my-agents intact, and slug stability confirmed via direct URL paste of /departments/ma and /departments/grra.formatted_outputs table from 0007 with a real production user surface; ships the first agent-output export path per architecture §4. Three commits. (1) Markdown-to-docx renderer at lib/exports/docx.ts. renderMessageAsDocx(markdown: string): Promise<Buffer> parses with marked.lexer then walks the flat token stream to build docx Paragraph + TextRun objects. v1 feature set per Decision 4: headings (H1–H6 mapped 1:1 to HeadingLevel), paragraphs, bold (**), italic (*), inline code in Courier New (per Q2), unordered + ordered lists with proper docx numbering config. Hyperlinks render as visible text only — the URL is dropped per Q3. Tables, code blocks, blockquotes, images, and HTML fall through to plain text via the token’s raw source so edge-case markdown produced by the model never bricks the export — graceful degradation rather than 500. Adds docx@^9.6.1 (Word document generation, pure JS, no native deps) and marked@^18.0.2 (markdown lexer with a flat tokens array, simpler than remark for v1’s feature set). marked and react-markdown coexist: react-markdown renders the chat (produces JSX), marked drives the export (produces a server-walkable AST). (2) GET route handler at app/api/exports/messages/[id]/docx. Authenticates via the user-scoped Supabase server client, RLS-loads the message via messages_user_via_conversation (0004) — cross-user requests collapse to a clean 404 not_found response so the route never leaks which condition tripped (missing message vs. another user’s). Verifies role === 'assistant' (user/system messages aren’t exportable; returns 400 invalid_message). Follows the conversation FK to the agent for the filename slug. Renders markdown to docx, inserts the formatted_outputs audit row (format = 'docx', size_bytes = buffer.length, storage_path = null per the architecture-§4 generate-on-demand model), and returns the Buffer with Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document, Content-Disposition: attachment; filename="<agent-slug>-<YYYY-MM-DD>.docx", explicit Content-Length (avoids “unknown size” download dialogs in some browsers per Note 3), and Cache-Control: no-store. Audit-row insert failures log but don’t fail the response — the user gets the file regardless and the ledger reconciles later. The existing formatted_outputs_user_via_conversation policy from 0007 covers the INSERT in full; no new migration in 8k. (3) Hover-revealed DownloadMessageButton client component on assistant messages. Calls the export route via fetch, parses the response Content-Disposition for the filename, converts to a Blob, and triggers the native browser download through a virtual-anchor click + URL.createObjectURL. Loading spinner during the ~500ms server render; Sonner toast on failure with a code-mapped message (unauthenticated / not_found / invalid_message / generic fallback). MessageBubble’s assistant branch gains a named group/message class (named so it doesn’t collide with future group classes higher up the tree) and renders the button as a sibling of the bubble. The opacity-0 group-hover/message:opacity-100 focus-visible:opacity-100 pattern keeps the button hidden until intent — hover OR keyboard tab focus into the button itself. Gated on !message.id.startsWith("tmp-") so a still-streaming message can’t be exported (streaming uses a temp placeholder that becomes the server UUID on the SSE done event; before that point the row hasn’t landed in the DB and exporting would produce truncated .docx output and a junk audit row — the temp-id check is documented in a code comment per Note 4). Smoke status: passed all 8 steps. Hover reveal works, download triggers cleanly, file opens in Word/Pages/LibreOffice without errors, headings + lists + bold/italic + monospace render as actual Word formatting (not literal markdown syntax), formatted_outputs row lands with correct shape, cross-user paste of a foreign message id returns 404 not 500/403, multi-section response with multiple headings exports with proper hierarchy. No regressions in 8j flows (chat surface, tool-use indicator, Sources footer, caching pipeline). Known limitation. Web search citations don’t survive the export — citations live in ephemeral React state per the 8j deferral and never enter messages.content, so the markdown the export reads doesn’t include them. When conversation-resumption work persists citations to a messages.citations column (deferred per the 8j changelog entry), the export should be extended to append a Sources section to the docx. No new ADR. D-009 (RLS-from-creation) covers the audit-row insert. Architecture §4 commits to per-message Word export as the first output format; 8k ships the implementation.web_search_20250305 server tool as the first per-agent configurable tool. Five commits. (1) Migration 0011 adds usage_events.web_search_count integer not null default 0 so every assistant message records how many searches Claude ran on that turn. (2) Web search wired into chat infrastructure. lib/llm/pricing.ts extends to a five-component cost model — regular input + cache write + cache read + output + web search — and exposes WEB_SEARCH_PER_SEARCH_MICRO_USD = 10000 (i.e. $10/1000 searches, model-agnostic per Anthropic’s published rate). computeCostMicroUsd takes webSearchCount as the new fifth positional argument. The Anthropic adapter at lib/llm/anthropic/chat.ts switches its stream contract from a text-only AsyncIterable<string> to a discriminated AsyncIterable<AnthropicStreamEvent> with four variants (text, tool_use_start, tool_use_end, citations). Tool-use start/end track a single contiguous “in tool-use” interval so back-to-back searches don’t flicker the indicator, and a message_stop while still in tool-use emits a fallback tool_use_end so the UI can never get stuck on the “Searching…” state. Citations emit once at end-of-stream from finalMessage(), pulled out of every text block’s web_search_result_location entries; degenerate citations with no url are dropped, missing title falls back to the url. The chat route at app/api/chat/route.ts builds the tools array from the agent’s tools_enabled JSONB column with a whitelist guard (“web_search” is the only catalog entry today; unknown ids silently dropped rather than passed through to a 400 from Anthropic), passes the web_search_20250305 tool with max_uses: 5 (a conservative cap — five searches per turn × $0.01 = $0.05 max search fee per turn), reads usage.server_tool_use.web_search_requests from the response, and persists it into the new column. SSE protocol at lib/llm/anthropic/stream.ts extends with tool_use_start, tool_use_end, citations event types alongside the existing meta / token / done / error. (3) Agent actions accept tool_web_search. Both createAgentSchema and updateAgentSchema gain a tool_web_search Zod string→boolean transform that maps the form switch’s “on” payload to true and absent to false. Each action shapes tools_enabled as either ["web_search"] or [] from the boolean. The schema is the trust boundary — there’s no free-form tools input, only the named tool field — so unknown tool ids cannot reach the database. Future tools add a sibling tool_<name> field plus an extra branch in the array build. (4) Web search toggle in agent form Advanced section. Replaces the Tools “Coming soon” placeholder with a labeled bordered row containing a shadcn Switch primitive and a one-line description that includes the per-search cost ($0.01) so the user knows the trade-off before flipping it. AgentFormDefaults gains toolsEnabled: string[]; the create page reads it from a forked template’s tools_enabled (forks inherit per architecture §2; the user can flip toggles before saving) or initializes [] for from-scratch agents; the edit page reads it from the existing agent. (5) Chat UI for tool-use indicator + Sources footer. ChatInterface tracks a toolUseLabel state — tool_use_start sets “Searching the web…”; tool_use_end clears it; the stream-end finally block clears defensively. The label takes precedence over the typing indicator in MessageList — the user sees the “Searching…” row while the model is in tool-use, then the typing dots when it resumes generating text, then the streamed text replaces both. Citations event accumulates into the active assistant message’s citations array; MessageBubble’s assistant branch renders a “Sources” footer below the markdown when citations are present (numbered list of links, target=_blank, falls back to URL when title is missing). ChatStreamEvent in lib/chat/sse-parser.ts mirrors the server-side stream contract with a ChatCitation type kept in step on both sides. Smoke status: partial. Steps 1–7 ran end-to-end. Step 7 (multi-search single turn) confirmed the indicator stays continuously on across back-to-back searches without flicker, citations from multiple sources rendered in the Sources footer, and web_search_count > 1 was persisted. Steps 8–10 (fork inheritance, cache + web search interaction, regression chat) skipped — see known issues below. Known issues / follow-ups. (a) Citations don’t persist across page reloads. Citations live only in React state for the lifetime of the message; they don’t survive a refresh. The deferral was made deliberately during 8j planning because the chat surface today already loses conversation state on refresh (“refresh = new conversation” per Session 8b). When the conversation-resumption surface lands in a future session, citations move to a messages.citations column and the bubble reads from there. Tracked separately. (b) Cache round-trip not validated end-to-end. Smoke step 7 surfaced a real architectural finding: turn 2 of a multi-turn conversation reported cache_creation_tokens > 0 (in fact larger than turn 1’s) and cache_read_tokens = 0, instead of the expected cache hit. The grew-not-hit pattern suggests something about the request shape is changing turn-over-turn, defeating Anthropic’s prefix-based caching. Diagnostic logging was added to streamAnthropicChat and a reproduction was attempted, but a stale browser tab held a connection to a previous dev server process so the diagnostic output never reached the new server’s log. The cleanup PR removed the instrumentation; the bug remains. Investigation deferred to a future session before further chat infrastructure changes land on top. Possible candidates documented during diagnosis: (i) cache_control marker placement when tools are present (Anthropic’s docs recommend the marker on the last tool when both tools and system content exist; we place it on the last system block — may need to extend), (ii) tool-definition or tool-result content invalidating the cache prefix, (iii) something in the SDK request shape changing turn-over-turn that’s not visible from the call-site code. Carried forward as the 8h-vintage caching-validation gap that 8h’s known-issue line predicted (“Risk: silent cache misses at scale would cost 10× without surfacing”); 8j has now observed that exact failure mode, just without the diagnostic data to fix it. No new ADR. D-009 (RLS-from-creation) covers the migration. D-023 (caching as required architecture) and D-025 (Phase 2 scope expansion) cover the surface. The architectural commitments this session implements are already on record./departments/<slug>. Hand-rolled horizontal <nav> containing one <Link> per department the current user has access to, ordered by sort_order. Active tab marked via aria-current="page" and a border-b-2 border-primary underline; inactive tabs are muted-foreground with a hover transition. Container is overflow-x-auto so narrow viewports scroll horizontally instead of wrapping to a second row. Underline pattern stays inside the existing token system — no new design tokens. New getAccessibleDepartments helper in lib/auth/access.ts joins user_department_roles to departments via PostgREST inner select, scoped by RLS to the user’s organization. The department page loads agents and accessible departments in parallel via Promise.all so the second query doesn’t add latency, and tightens top padding py-10 → py-6 now that the tab bar sits above the page header. Tab bar is not sticky (pages aren’t long enough to justify it; sticky would also need to coordinate z-indexing with the main nav above). Hand-rolled rather than coerced from shadcn’s tabs primitive, which is for in-page controlled tabs with content panes — a different shape than route-based navigation. (2) Picker page at /. Replaces the prior placeholder landing that hard-coded a “Go to Commercial” button (Session 3b). Renders one card per department the user has access to via the same getAccessibleDepartments helper; cards link to /departments/<slug>. Visual treatment matches the existing agent-card pattern (border, rounded-lg, hover lift) so no new design tokens land. Defensive empty state (“No departments yet — Contact your admin”) covers future deployments where access can be restricted; in v1 every user gets all five departments via the seed and the branch never triggers. (3) Agent form Advanced section flattened. Drops the <details>/<summary> collapse mechanic. Attached references, Tools placeholder, and Output format placeholder render always-visible below an “Advanced settings” section heading — honest about what’s there per the locked-in decision. The auto-expand-in-edit-mode-when-attachments-exist computation from 8h is no longer relevant and goes with it. Same flat layout applies to both create and edit modes (single component handles both). Smoke status. All eight steps passed: picker renders, tab bar active-state and click-through across all five departments, narrow-viewport horizontal scroll, form save in both create and edit, regression check on 8f-A/8f-B/8h chat + delete + undo + attachments. No regressions surfaced. No new ADR. All decisions land inside the existing surface; no architectural commitments touched.lib/llm/pricing.ts extends ModelPricing with cacheWritePerMillion (1.25× input, per Anthropic’s published 5-minute ephemeral cache rates) and cacheReadPerMillion (0.1× input). computeCostMicroUsd takes the four token counts (input, output, cache_creation, cache_read) and computes the four-component cost — break-even is one cache-read turn after the write, so any conversation longer than two turns saves money. The Anthropic chat adapter (lib/llm/anthropic/chat.ts) switches the SDK’s system parameter from a string to an array of { type: "text", text, cache_control? } blocks; the adapter is a dumb pass-through (caller composes blocks and places markers), and finalUsage surfaces cache_creation_input_tokens / cache_read_input_tokens from the SDK’s response usage. The chat route builds a single-block system array (preamble + agent prompt) with cache_control: { type: "ephemeral" } on it, reads all four token counts from finalUsage, persists them into the cache_creation_tokens / cache_read_tokens columns added by 0006, and computes cost through the four-component math. Below Anthropic’s caching threshold (~1024 tokens for Sonnet/Opus, ~2048 for Haiku) the marker is a no-op and both cache token counts come back as 0 — that’s correct behavior, not a bug, and the math still works (0 × any rate = 0). (2) Text-extraction utilities. Five files under lib/extract/ — one per supported format (pdf.ts, docx.ts, text.ts, xlsx.ts) plus a dispatcher (extract.ts). PDF uses pdf-parse v2’s PDFParse class with getText() and a destroy() finally for cleanup; DOCX uses mammoth’s extractRawText; TXT/MD pass through as UTF-8 buffer reads; XLSX uses SheetJS’s sheet_to_csv per sheet so the model sees cell-position context. The dispatcher returns a discriminated union { ok: true, text, truncated } | { ok: false, reason } so failures (thrown errors from any of the libs; empty or whitespace-only extractions) surface to the caller and never produce silent zero-content rows. 100K character cap applied at extraction time — truncated extractions append [This document was truncated to its first 100,000 characters.] so the model has explicit context that more existed. ALLOWED_MIME_TYPES const matches the agent-attachments storage bucket allowlist (0008) so the application-layer pre-check produces a clearer error than the storage layer’s generic rejection. (3) Server actions for attachments. lib/actions/attachments.ts ships four actions covering create-mode drafts and edit-mode bound rows: uploadAttachmentDraftAction uploads to storage + extracts text without inserting an agent_attachments row (used by the form’s create flow where the agent UUID is pre-allocated client-side and the row inserts atomically inside createAgentAction at form save); addAttachmentAction uploads + extracts + inserts the row in one round trip (edit flow), enforcing the 5-attachment cap server-side; removeAttachmentDraftAction deletes only the storage object; removeAttachmentAction soft-deletes the row + deletes the storage object (no undo for attachments — uploads replace, no history per architecture §3). All four actions enforce the 20MB / 5-attachment / MIME-allowlist limits client-side AND server-side; the bucket itself enforces file_size_limit and allowed_mime_types as the last line of defense. Failed extractions return success with extractedText: null and an extractionWarning so the user sees the file in the list and removes it manually — per architecture §3 / 8h plan Q3. (4) Attachment UI + atomic create + chat-route attachment loading. components/agents/agent-attachments-section.tsx activates the previously-placeholder “Attached references” surface inside the form’s Advanced collapsible: list-of-files (not drag-and-drop per locked-in decision), upload button opens the native file picker, rows show filename + size + remove-X with a confirmation dialog. The Advanced section auto-expands when editing an agent that already has attachments. Create flow uses a pre-allocated agent UUID generated server-side in /agents/new via node:crypto.randomUUID() and threaded through the form: drafts upload to <user_id>/<agent_id>/... before the agent row exists, and createAgentAction takes the array of pending attachment metadata as a hidden JSON field, inserting the agent row + all attachment rows atomically. Storage path prefix is verified at the action layer to reject malformed metadata (defense in depth above the bucket-layer policy). Edit flow inserts attachments immediately via addAttachmentAction. The chat route loads live attachments per Decision B — transcripts are immutable history per architecture §3, but the configuration that produces them (system prompt, attachments) reflects the agent’s current state at send time — ordered by created_at ASC per Decision A so the prompt cache prefix stays stable across turns. Failed-extraction rows where extracted_text IS NULL are filtered out and never reach the model. Each attachment becomes a system text block wrapped in <attachment filename="..."> tags; the cache_control marker sits on the last block. (5) serverExternalPackages: ['pdf-parse'] in next.config.ts. pdf-parse v2 delegates to pdfjs-dist, which dynamically imports a sibling pdf.worker.mjs at runtime. Turbopack bundles the main pdf.mjs into a server chunk but does not place the worker file alongside it, so every PDF parse threw “Setting up fake worker failed: Cannot find module …pdf.worker.mjs” — text-extractable PDFs of all sizes failed identically before reaching any parsing code. Externalizing pdf-parse lets Node’s native module resolver find the worker file in node_modules where pdf-parse expects it. Single-line config change. The experimental.serverActions.bodySizeLimit was raised to 25mb in the same file to clear 20MB attachment uploads with multipart-overhead headroom; other server actions are small (text-only FormData) and unaffected. No new migration in 8h. Existing 0006 (cache columns), 0007 (agent_attachments table + RLS), and 0008 (storage bucket + policies) cover the schema. Verified by re-reading 0007’s agent_attachments_user_owns policy — using (user_id = auth.uid()) covers SELECT / UPDATE / DELETE on owned rows; with check (user_id = auth.uid() AND organization_id = current_org_id() AND exists (...agents.created_by = auth.uid())) covers INSERT / UPDATE escalation guards. Smoke status. Steps 1–4 passed: regression on 8f-A/B flows, UI activation, TXT upload + extraction + DB verification, PDF upload + extraction (Steven_Antini_CV.pdf, 5751 chars extracted, preview content correct after the serverExternalPackages fix). Steps 5–12 (DOCX, XLSX, size/count/MIME rejections, end-to-end chat with attachments referenced, two-turn cache-hit verification, edit-and-remove) skipped per the project owner’s call to close 8h on the partial verification rather than continue. Known issues / follow-ups. (a) IBM_401_k_SPD_2025_SMM.pdf (142 pages, ~1.9MB, valid text-extractable PDF) was the original failure case before the serverExternalPackages fix and was not re-tested after the fix — may or may not work on the larger document; if it doesn’t, the fallback path is library swap (likely unpdf, no worker dependency) per the 8h diagnostic conversation. (b) Prompt caching is wired in code but not validated end-to-end — the cache_creation_tokens / cache_read_tokens columns weren’t exercised in smoke. The code may be correct; not proven. Risk: silent cache misses at scale would cost 10× without surfacing. Both items want a follow-up smoke session before promotion claims about caching efficacy or large-PDF support are made externally.0010_agents_user_updates.sql. Single UPDATE policy agents_user_updates_own covers edit, soft-delete, and restore — Postgres doesn’t distinguish UPDATEs by which columns changed, so three policies would have been redundant copies of the same using / with check clauses. The using filter scopes to user-owned native non-template rows in the user’s org; the with check enforces escalation guards (cannot flip is_template, cannot change created_by, cannot change organization_id, cannot move to a department the user lacks access to). The 30-day undo window is intentionally application-layer, not RLS — RLS is about who, not when. Hard delete remains service-role only; soft-deleted rows beyond 30 days stay in the DB until a future cron job hard-deletes them (deferred Phase 2 cleanup commitment). (2) Server actions in lib/actions/agents.ts. Three new actions plus a unified AgentFormResult type covering both create and edit form shapes. updateAgentAction (useActionState-compatible): validates with Zod, verifies ownership at app layer (RLS is the backstop), updates name / description / system_prompt / model only — department, slug, fork provenance, and template status are immutable across edits. Slug stays stable across renames because URLs use UUIDs and a public-facing identifier shouldn’t churn from a rename even when it isn’t actually public. softDeleteAgentAction (imperative): sets deleted_at = now(), revalidates the department page and trash page, returns the agent name and department slug so the client can fire an Undo toast. restoreAgentAction (imperative): clears deleted_at, gated by a 30-day window check at the application layer, returns past-window attempts with a specific error. (3) Form component three-mode branching + Zod-only validation. AgentForm extends to accept mode: 'create' | 'edit', agentId, and an explicit action prop — the page passes the right server action; the form binds it via useActionState. Submit button label and Cancel link target both branch by mode (edit returns to /agents/<id>; create returns to /departments/<slug>). The validation fix drops the HTML5 required attribute from the name and system-prompt inputs so browser-native tooltips no longer fire; Zod-driven inline errors below each field — already wired in the form, just no longer suppressed by browser pre-validation — become the sole validation surface. Resolves the known issue from 8f-A’s close-out. (4) Card overflow menu + delete dialog + toast. The user-owned AgentCard branch (a new isMyAgent prop alongside the existing isTemplate) refactors to the stretched-link pattern: relative card container, absolute-positioned navigation <Link> filling the area at z-10, body content with pointer-events-none so clicks pass through to the link, and a three-dots DropdownMenu button positioned in the top-right corner with pointer-events-auto and z-20 so its clicks register without bubbling. The link’s aria-label uses the agent’s actual name. Menu items: Edit (links to /agents/<id>/edit) and Delete (opens a Dialog confirmation with Cancel + destructive Delete buttons; initial focus on Cancel). Confirming Delete calls softDeleteAgentAction via useTransition; on success a Sonner toast surfaces (“next/font/google (Geist + Geist_Mono) from app/layout.tsx and replaces the font tokens in app/globals.css’s @theme inline block with explicit system stacks: --font-sans is -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-mono is ui-monospace, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --font-heading continues to chain through var(--font-sans). Aligns every surface (login, launchpad, chat, admin, calculator, metrics, agent-form) with Next.js’s default 404 styling and removes the partially-wired Geist import (the prior config self-referenced --font-sans: var(--font-sans);, so the sans surface was already inheriting browser defaults — this commit makes that intent explicit). No design-token, sizing, or layout changes; pure font-family swap. Two-file change, single commit.0009_agents_user_writes.sql. A pre-flight RLS gap surfaced during planning: 0001’s agents_admin_write is the only write policy on public.agents, gated on is_department_admin() — regular users can read agents but cannot insert. Every createAgent() call would have been silently rejected by RLS otherwise. Migration 0009 adds agents_user_creates_own (INSERT-only) with a tight with check: organization_id = current_org_id() AND created_by = auth.uid() AND is_template = false AND type = 'native' AND is_active = true AND deleted_at IS NULL AND has_department_access(department_id). Users can mint native agents they own in departments they have access to; templates and external agents remain admin/service-role only. UPDATE / DELETE policies for the user’s own agents land in 8f-B alongside edit and soft-delete. Drop-and-recreate idiom for idempotence. (2) createAgentAction server action at lib/actions/agents.ts. useActionState-compatible signature returning { ok: true } | { ok: false; formError?; fieldErrors? }. Zod-validated against MODEL_PRICING.keys (the bounded model field cannot drift from supported models — misaligned ids fail at validation, not at the SDK call). Validates auth, resolves the user’s profile org, resolves the department by slug + verifies access, optionally validates the fork target (template must be readable, native, and in the same department; any failure collapses to a generic error to avoid leaking which condition tripped), generates a collision-safe slug (kebab-case + 6-char random suffix because (organization_id, slug) is unique and two users naming agents identically would collide), inserts with sensible Phase 2 defaults (type = 'native', is_template = false, tools_enabled = [], default_output_format = 'markdown', sort_order = 0, forked_from_agent_id set on fork, null otherwise), and redirect()s to /departments/<slug> on success. RLS rejection surfaces a generic “Could not create agent. Try again.” form-level error and logs the underlying error server-side. (3) getAgentsForDepartmentSplit in lib/auth/access.ts. Replaces the flat getAgentsForDepartment helper with a two-bucket loader returning { templates, myAgents }. Two parallel queries: templates (is_template = true AND is_active = true AND deleted_at IS NULL, ordered by sort_order ASC so the Blank Agent at sort_order = 0 leads) and my-agents (is_template = false AND is_active = true AND deleted_at IS NULL AND created_by = auth.uid(), ordered by created_at DESC so newest creation lands first). Both queries respect RLS — agents_read_accessible and agents_admin_read_all from 0001 scope results to the user’s organization and accessible departments. The old flat helper is removed in the same commit; LaunchpadGrid and CategorySection (the prior category-grouped renderers) are deleted as dead code. category-labels.ts survives for future per-section categorization. (4) components/agents/agent-form.tsx. Single-form progressive-disclosure agent creation surface, client component using useActionState(createAgentAction, { ok: true }). Required fields (name, description, system prompt, model) visible by default. Advanced section in a native HTML <details> element renders disabled placeholders for attached references, tools, and output format with “coming soon” copy — users see the shape of what’s coming without being able to act on it (each is deferred per the architecture’s Phase 2 phasing items 5–9). Model dropdown is bounded to MODEL_PRICING keys with a one-line helper per option (“Claude Sonnet 4.6 — fast, cost-effective. Good default for most tasks.” etc). When the page passes a forkedFromAgent prop, the form renders a “Forked from 0006_agents_extensions.sql adds five columns to public.agents — is_template boolean not null default false, forked_from_agent_id uuid references public.agents(id) on delete set null, tools_enabled jsonb not null default '[]'::jsonb, default_output_format text not null default 'markdown' with a CHECK (default_output_format in ('markdown','docx')) constraint, and deleted_at timestamptz (null = active) — plus two columns to public.usage_events for prompt-caching cost tracking (cache_creation_tokens integer not null default 0, cache_read_tokens integer not null default 0; existing rows from 8a/8b record 0 until the prompt-caching wiring session populates real values). New indexes on agents: agents_is_template_idx (templates section query) and partial composite agents_active_idx (organization_id, department_id, sort_order) where deleted_at is null (active-agents launchpad query). A targeted UPDATE flips the six seeded Commercial agents (enterprise-agreement-review, mutual-nda-review, order-form-sow-review, vendor-agreement-review, dpa-review, ai-addendum-review) to is_template = true; the Test Smoke Agent retains is_template = false from the column default. Idempotence via add column if not exists, a pg_constraint lookup wrapping the add constraint (Postgres has no add constraint if not exists), create index if not exists, and is distinct from on the targeted UPDATE. (2) 0007_attachment_and_output_tables.sql creates three RLS-enabled tables in the user-owns + admin-read idiom from 0001/0004. agent_attachments (permanent attachments scoped to a user-owned agent; delivery_mode CHECK accepts 'text_extracted' (v1, Path B) plus the deferred 'native_pdf' and 'hybrid' (Path C); source_type CHECK accepts 'upload' (v1) plus the deferred 'gdrive_link'; extracted_text cache column so chat turns don’t re-parse on every request; user_id and organization_id denormalized so RLS can enforce ownership without joining through agents; soft-delete via deleted_at; partial index on agent_id where deleted_at is null). message_attachments (turn-scoped per-message uploads for §5a’s chat-input paperclip; cascades on parent message delete). formatted_outputs (audit + dedup of exports; format CHECK narrow to 'docx' in v1, widens when XLSX/PPTX/Workspace land; storage_path nullable so the renderer may persist or regenerate-on-demand; on delete restrict on user_id mirrors the usage_events pattern from 0004 — deleting a user must not silently erase their export history). RLS policies: agent_attachments_user_owns uses denormalized user_id for SELECT (no join) and verifies parent-agent ownership in the with check so a write cannot point at another user’s agent_id even if the denormalized fields are spoofed; message_attachments_user_via_conversation mirrors messages_user_via_conversation from 0004 (access flows through messages → conversations → user); formatted_outputs_user_via_conversation uses denormalized user_id for SELECT and verifies parent-conversation ownership in the with check; each table also gets an org-scoped _admin_read SELECT-only policy. (3) 0008_attachment_storage_buckets.sql creates two private Supabase Storage buckets (agent-attachments, message-attachments) with file_size_limit = 20971520 (20MB) and a MIME allowlist matching architecture §3 (PDF, DOCX, TXT, MD, XLSX) — both enforced at the storage layer so a misbehaving client cannot bypass them via direct API call. Path conventions: <user_id>/<agent_id>/<filename> for agent-attachments, <user_id>/<conversation_id>/<message_id>/<filename> for message-attachments; the first path segment is auth.uid() in both, so storage policies use (storage.foldername(name))[1] = auth.uid()::text for ownership checks. Five policies per bucket on storage.objects: owner SELECT, INSERT, UPDATE, DELETE plus an admin SELECT that joins to public.users to verify the path-owner is in the same organization as the admin. Bucket inserts use on conflict (id) do nothing; once a bucket exists, re-running the migration does not overwrite its file_size_limit / allowed_mime_types. All policies are drop policy if exists then create policy for idempotence. Seed updates. New supabase/seed/0004_blank_agents.sql seeds one Blank Agent per department (5 rows, slugs blank-agent-<dept-slug> to satisfy agents.slug’s org-wide unique constraint without clashing across departments; display name “Blank Agent” on every row; is_template = true, created_by = null, model = 'anthropic/claude-sonnet-4-6', tools_enabled = '[]', default_output_format = 'markdown', sort_order = 0); the seed loops over public.departments ordered by sort_order, so adding a new department later automatically gets a Blank Agent on next run. The Blank Agent system prompt — "You are a helpful assistant. Respond clearly and concisely to whatever the user asks. If you're uncertain about the user's intent, ask a clarifying question rather than guessing." — produces sensible default behavior even if the user never edits it, and intentionally does not reference the configuration architecture so the model is unaware it is a “to-be-edited template.” supabase/seed/0002_commercial_agents.sql updated to set is_template = true on the six Commercial rows so a fresh fork lands the same prod state (existing prod rows were flipped by 0006’s UPDATE; this edit is for forkers). supabase/seed/0003_test_native_agent.sql adds an explicit is_template = false on the Test Smoke Agent row — same as the column default, but stated for clarity. Verification. All three migrations applied to prod between commits; npx tsc --noEmit and npm run build clean after each application; chat smoke against the Test Smoke Agent (one turn, npm run dev) confirmed the runtime unchanged — no application code reads any of the new columns/tables/buckets yet, but verifying the schema additions did not perturb existing SELECT paths catches the failure mode where a column rename or a CHECK constraint inadvertently breaks a code generator. Open questions resolved during planning (recorded here so future sessions don’t re-ask): forked_from_agent_id for Blank-Agent-derived user agents will be set to the Blank Agent’s id rather than null (provenance is the column’s purpose; recording it always when known is cleaner than null-as-default — a future session implements the fork action). Indexes added: plain is_template, partial composite (organization_id, department_id, sort_order) where deleted_at is null. usage_events cache columns are NOT NULL DEFAULT 0 (not nullable). Out of scope (deferred to later sessions per architecture phasing). Application code reading the new columns/tables (item 3 — agent CRUD UI), attachment upload subsystem (item 5), Word export (item 9), web search tool (item 8), prompt caching wiring (item 6 — populates cache_creation_tokens / cache_read_tokens), Test Smoke Agent retirement (item 4), six Commercial templates’ system prompts (item 10). No new ADR — D-025 already covered the architectural commitments this migration implements.docs/AGENT_ARCHITECTURE.md §6 and the first item on PROJECT_OUTLINE.md’s Phase 2 phasing list — the foundation that lets Phase 6 add OpenAI / Google adapters by dropping a sibling file alongside lib/llm/anthropic/ rather than refactoring the chat route. Three commits land it. (1) Schema + seed. Migration supabase/migrations/0005_vendor_prefixed_model_ids.sql rewrites every model id in the runtime tables from the bare Anthropic form (claude-sonnet-4-6) to the vendor-prefixed form (anthropic/claude-sonnet-4-6) inside a single BEGIN/COMMIT transaction across agents.model, conversations.model_snapshot, and usage_events.model. Idempotence comes from a slash-based predicate (NOT LIKE '%/%') which is forward-compatible — once future vendors land, openai/... and google/... rows are caught by the same check, so re-running 0005 after a multi-vendor cutover doesn’t double-prefix. A commented inverse block at the bottom of the file documents the rollback shape (this repo doesn’t use a CLI that applies down-migrations, so the SQL is ready-to-paste rather than scripted). supabase/seed/0003_test_native_agent.sql updated to insert the prefixed form so re-running the seed after 0005 stays consistent. (2) Structural move (zero behavior change). lib/anthropic/{client,prompt-defense,stream,types}.ts move under lib/llm/anthropic/ (vendor-scoped); lib/anthropic/pricing.ts → lib/llm/pricing.ts and lib/anthropic/rate-limit.ts → lib/llm/rate-limit.ts (vendor-agnostic — neither file contains Anthropic-specific code, both belong at the lib/llm/ root where future vendors can add rows to the same MODEL_PRICING table and reuse the same per-user rate limiter). MODEL_PRICING is re-keyed on vendor-prefixed ids so cost lookup post-migration is MODEL_PRICING['anthropic/claude-sonnet-4-6']. lib/chat/sse-parser.ts JSDoc cross-reference updated; app/api/chat/route.ts import paths rewritten. No API surface changes, no runtime changes — a pure file move that pre-stages the dispatcher. (3) Dispatcher + adapter. lib/llm/parse-model-id.ts parses prefixed ids into { vendor, model }; throws on malformed input (multiple slashes, empty model segment, non-conforming vendor segment matching /^[a-z0-9_-]+$/). The bare-id fallback (raw.indexOf('/') === -1 → { vendor: 'anthropic', model: raw }) is a transitional shim for the migration 0005 cutover window — new code can read pre-migration data without an outage if the deploy lands before the SQL migration is applied. Marked for removal in a future cleanup session once no bare-id rows can exist. lib/llm/anthropic/chat.ts extracts the Anthropic streaming surface into streamAnthropicChat({ model, systemPrompt, messages, maxTokens }) returning a uniform { textDeltas, finalUsage } shape; future vendor adapters implement the same contract so app/api/chat/route.ts reads from any of them with no per-vendor branching outside the dispatcher. The dispatcher is a single switch (vendor) inside start() with a default: that throws on unsupported vendor and trips the existing upstream_error SSE path; parseModelId(conversation.model_snapshot) runs before the SSE stream opens, so a parse failure becomes a JSON 500 rather than a mid-stream error event. The route’s generic “model stream failed” log replaces the prior Anthropic-specific message. Local smoke (cutover-window verification). Two-turn chat against the Test Smoke Agent — chat streams normally; dev server logs computeCostMicroUsd threw "Unknown model for cost calculation: claude-sonnet-4-6" (with stack trace), the route catches it and logs "computeCostMicroUsd failed — recording cost 0"; conversations.model_snapshot and usage_events.model both record the bare claude-sonnet-4-6 (because the local DB hadn’t received 0005 yet at smoke time and the local seed pre-dates the prefix update); usage_events.cost_micro_usd = 0. This is the documented graceful-degradation path — bare-id rows flow through parseModelId’s fallback, the chat completes successfully, and only cost tracking briefly records 0 (recoverable post-migration since usage_events.tokens_in / tokens_out are the source of truth and cost can be backfilled). Once 0005 is applied to prod and the seed update lands, MODEL_PRICING lookups hit and cost_micro_usd > 0 resumes. What does NOT change in 8d: no schema additions beyond the model id rewrite (the agent extensions, attachment tables, formatted_outputs, and usage_events cache columns are work item 2); no agent CRUD UI (item 3); no second vendor adapter (Phase 6); no test runner (tsc --noEmit clean and the chat smoke test are the 8d gate, with a TODO(test) in parse-model-id.ts for a future Vitest setup). No new ADR — D-025 already covered the architectural commitment to the prefixed format and the dispatcher shape.docs/AGENT_ARCHITECTURE.md (Session 8c — Phase 2 scope expansion). 404-line living design specification for the agent product surface; the spec subsequent Phase 2 sessions implement. Six sections covering what an agent is (user-owned, user-configurable workspaces with template forking; versioning / sharing / Drive integration deferred with explicit upgrade paths; prompt caching as required architecture from day one), the user-tunable surface (eight fields split between freeform and bounded; templates strictly read-only at RLS + UI layers; single-form progressive-disclosure agent creation; soft delete with 30-day undo; per-department Templates + My Agents sections; user-private categories within department; five fixed starter departments with no separate AI department), data inputs (Supabase Storage with RLS-policied bucket; Path B text extraction in v1 with delivery_mode preserving Path C as deferred; PDF / DOCX / TXT / MD / XLSX format support; 20MB / 5-attachment limits; transcript-as-record lifecycle with audit-grade preservation deferred to Phase 7-ish), outputs (markdown shipped in 8b; Word .docx via server-side renderer with formatted_outputs audit + dedup; XLSX / Google Workspace / PowerPoint deferred), tools (the load-bearing 5a/5b distinction — per-message file upload as a core chat capability available on every agent vs. configurable tools as the per-agent tools_enabled list; web search as the v1 configurable tool with Anthropic’s built-in search and inline source rendering for provenance), and model abstraction (lib/anthropic/ → lib/llm/anthropic/ move; vendor-prefixed model ids anthropic/claude-sonnet-4-6; single-case dispatcher in app/api/chat/route.ts; per-vendor caching strategy with multi-vendor implementation deferred). Closes with a non-binding schema sketch (existing agents.created_by from 0001 reused unchanged; net-new is_template, forked_from_agent_id, tools_enabled JSONB, default_output_format, deleted_at columns on agents; new agent_attachments, message_attachments, formatted_outputs tables; cache_creation_tokens / cache_read_tokens extensions on usage_events; two new Storage buckets), a 10-item dependency-ordered implementation phasing list (sessions sequenced when picked up, not pre-numbered), and a consolidated deferred-items list with explicit re-evaluation triggers per item. Companion ADR D-025 records the scope expansion. PROJECT_OUTLINE.md Phase 2 is reorganized to mirror the implementation phasing list; Phase 5 (Agent Admin UI) and Phase 6 (Multi-Vendor Model Adapters) are reframed because user-level agent CRUD and the multi-vendor directory structuring both move into Phase 2. No code, no schema changes, no migrations land in 8c — pure documentation per the session’s defined scope./agents/[id] route at app/(app)/agents/[id]/page.tsx loads the native agent server-side (404 on non-native, non-active, or department-inaccessible per D-009 — never leak existence) and mounts the client ChatInterface. components/chat/ adds six pieces: chat-interface.tsx (top-level container, message list state, conversationId reuse across turns, error and empty-state branches, scroll-to-bottom on new tokens), message-list.tsx + message-bubble.tsx (role-aware rendering, assistant bubbles host the markdown renderer, user bubbles are plain text), message-input.tsx (auto-growing Textarea, Enter-to-send / Shift+Enter-newline, disabled while a turn is streaming), markdown.tsx (react-markdown + remark-gfm + rehype-sanitize — model output is treated as untrusted per CLAUDE.md AI Integration Rules and sanitized at render-time; raw HTML injection is never used), and sse-parser.ts (consumes the four meta / token / done / error event types Session 8a’s app/api/chat/route.ts emits, surfaces upstream errors as a “regenerate” affordance per the 8a mid-stream-failure contract). components/launchpad/agent-card.tsx branches on agent.type — native agents render an in-tab <Link href="/agents/[id]"> while external agents continue to open agent.url in a new tab with the existing onPointerDown analytics fire (D-010); both card variants share the same visual layout. shadcn textarea and scroll-area primitives added to support the chat surface. Dependencies: react-markdown, remark-gfm, rehype-sanitize. End-to-end smoke test executed manually against the live runtime — Test Smoke Agent appears on /departments/commercial, click navigates to /agents/<uuid>, two-turn conversation streams in 1–2 seconds per turn, conversations row carries the model_snapshot and snapshotted prompt, four messages rows alternate user/assistant with token counts on assistant rows only, two usage_events rows record claude-sonnet-4-6 model and non-zero cost_micro_usd. This test served as the deferred Session 8a end-to-end verification (per the 8a session-close pivot) and validated the Phase 2 runtime end-to-end for the first time.supabase/migrations/0004_native_agents.sql adds three Phase 2 tables — conversations (snapshots system_prompt and model at conversation creation per CLAUDE.md AI Integration Rules so old conversations retain their original prompt for reproducibility; full agent_prompt_versions table deferred), messages (immutable in practice; composite index on (conversation_id, created_at) covers list-by-conversation + ordering), usage_events (append-only cost ledger; cost_micro_usd as bigint to avoid float drift, on delete restrict on user_id and agent_id preserves billing history while on delete set null on conversation_id and message_id lets old chats be cleaned up without orphaning the ledger) — plus the message_role enum (user | assistant | system). Full RLS on all three tables in the paired user-owns + admin-read idiom 0001 established for the agents table; usage_events has no UPDATE / DELETE policies, making the ledger immutable from any user session (service-role still bypasses for administrative cleanup if ever needed). lib/anthropic/ adds six runtime primitives: types.ts (hand-written narrow types for Conversation, Message, UsageEvent, MessageRole, and the NativeAgent subset of agents — CLI-driven type generation deferred to a future scheduled session per D-023’s note), pricing.ts (MODEL_PRICING table for Opus 4.7 / Opus 4.6 at $5/$25, Sonnet 4.6 at $3/$15, Haiku 4.5 at $1/$5, current as of April 2026; header note documents that Opus 4.7’s new tokenizer can produce up to ~35% more tokens for the same source text, so usage_events.tokens_in / tokens_out are the source of truth and cost should never be estimated from character counts), prompt-defense.ts (two-layer injection defense — PROMPT_INJECTION_PREAMBLE prepended to every native agent’s stored system prompt + <user_input> delimiter wrapping on the user message; no deny-list of suspect phrases per the structural-defense recommendation in prompt-engineering.md), client.ts (createAnthropicClient() factory behind import "server-only" so client components fail the build if they ever pull in the SDK; throws a clear error on missing ANTHROPIC_API_KEY instead of letting Anthropic surface a confusing 401), stream.ts (SSE encoder for the four event types — meta, token, done, error — and response headers including Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, and X-Accel-Buffering: no so token-by-token streaming actually appears token-by-token through Vercel’s edge), rate-limit.ts (per-user 20 messages/minute via count(*) on messages joined to conversations.user_id; explicit user_id filter is required because the messages table has two SELECT policies — messages_user_via_conversation and messages_admin_read — and without filtering an admin’s quota would be wrongly counted against other users’ messages; fails open on Supabase infrastructure errors so a transient blip can’t lock out chat). app/api/chat/route.ts composes them into the Phase 2 chat endpoint: getUser() → Zod-validate body (length 1..10000, control-char strip) → load + verify agent (active, native, has prompt + model) → has_department_access RPC → rate limit → resolve or create conversation (snapshotting system_prompt and model on creation) → insert user message → emit SSE meta event → stream Anthropic with <user_input>-wrapped multi-turn history → emit SSE token events → persist assistant message + usage_events row with computeCostMicroUsd math → emit SSE done. User-scoped Supabase server client throughout (not service-role) so RLS remains the last line of defense per D-009. Discriminated-union JSON error responses ({ ok: false, error: <code> }) per CLAUDE.md conventions; internal details log server-side and never leak (PII per backend-security.md). Mid-stream Anthropic failures emit event: error with "upstream_error" and close cleanly — the user message stays persisted, the assistant message is not inserted, leaving the conversation in a “user spoke, no reply” state that 8b’s UI can present as a regenerate affordance. Conversations whose user_id does not match the caller collapse to forbidden so the route does not leak existence. runtime: "nodejs" (Vercel default per April-2026 platform notes — Edge functions are not recommended) and maxDuration: 300 (Vercel default; chat calls close inside it). supabase/seed/0003_test_native_agent.sql seeds one minimal native agent (test-smoke-agent, model claude-sonnet-4-6, Commercial department) per D-023’s “in-scope-for-8a, out-of-scope-after-8c” plan — Session 8c will replace it by promoting one of the six existing Commercial external agents to native. .claude/skills/ gains anthropic-api.md and prompt-engineering.md (chore commit, straight copies from claude-templates/skills/ai-integration/), satisfying the CLAUDE.md routing-rule prerequisite for AI/prompt work and completing the Phase 2 skill batch per D-011. @anthropic-ai/sdk@^0.91.1 added to dependencies (server-only — every import lives behind import "server-only" in lib/anthropic/). End-to-end smoke test deferred to Session 8b’s UI-based verification per the session-close pivot — tsc --noEmit clean and code review are the 8a gate. ANTHROPIC_API_KEY configured in .env.local (local dev) and Vercel Production + Preview (Sensitive); migration 0004 applied to both dev and prod Supabase projects; test agent seeded.agent-header.tsx border token upgraded from border-border to border-hairline-strong (same 1px width, stronger tone — #eae4d8, the documented Aperture “card outer chrome” token from the Session 22b surface ramp). Breathing room below the header increased from mb-4 (16px) to mb-7 (28px) so the chat surface starts with a deliberate gap above the first message rather than rolling immediately into content. Composer-as-anchor: message-input.tsx card shadow stack gains an upward-pointing layer (0_-8px_24px_-12px_rgba(0,0,0,0.08) prepended to both the default and focus-within shadow strings) signaling that scrollable content disappears beneath the card. Outer wrapper’s bottom padding reduced from pb-4 (16px) to pb-2 (8px) so the composer sits closer to the chat surface’s bottom edge. The composer reads as a literal anchor rather than a card floating in vertical space. Empty-state elimination: components/chat/chat-empty-state.tsx deleted (the §2.8 identity panel from Session 19, 211 lines). MessageList’s empty-state early-return branch removed; ChatInterface no longer derives isEmpty or passes empty-state props. AgentHeader’s emptyState prop and the conditional that hid model + web-search chips during empty state are removed — those chips now render unconditionally as part of the meta-chip row. Net visual on a fresh agent: header at top (name + description + Department Agent chip + model chip + web-search chip), empty middle, composer at bottom — the ChatGPT pattern. Accessibility cleanup: double-<main> landmark violation resolved. app/workspace/layout.tsx outer <main> demoted to <div> (structural chrome wrapping the rail-plus-content grid, not the page’s main content). app/workspace/page.tsx and app/workspace/departments/[slug]/page.tsx — previously returning fragments and inheriting the layout’s <main> — gain their own <main className="flex flex-col gap-9"> wrappers to preserve the gap spacing previously inherited from the layout’s body wrapper. Each page now has exactly one <main> landmark. Files touched: components/chat/agent-header.tsx, components/chat/message-input.tsx, components/chat/chat-interface.tsx, components/chat/message-list.tsx, app/workspace/layout.tsx, app/workspace/page.tsx, app/workspace/departments/[slug]/page.tsx, app/workspace/agents/[id]/page.tsx (modified); components/chat/chat-empty-state.tsx (deleted, 211 lines). Build status: clean — 18/18 routes, no new warnings, no TypeScript errors (compile 2.4s, TypeScript pass 1993ms). Smoke status: in-browser verification across (a) chat surface reads as contained — top hairline visible against page background, composer with upward shadow reads as a scroll anchor; (b) fresh agent renders header + empty middle + composer with no orientation panel; (c) model + web-search chips visible on the header for all agents; (d) non-chat workspace pages (landing, departments, admin, trash) render visually unchanged after the <main> cleanup. Composer-width alignment verified via getBoundingClientRect — header content and composer card at identical x-coordinates (left=288, right=886, width=598), confirming the earlier perceived asymmetry was the visual difference between bordered card chrome and unbordered text edges, not a structural offset. Deferred to future polish: if A + B containment treatment proves insufficient over time, Step B’s option C (reduce empty visual frame via workspace padding or chat-column width adjustment, touching D-030) is a focused follow-up session — not currently planned. Operator note: the agent_attachments query on the chat page still selects full rows but only .length is consumed after empty-state removal; a future cleanup could downgrade to a count-only query.?next= proxy preservation + Phase-0/1 config TODO cleanup (Session 25 — completes the deferral called out at proxy.ts:24 in D-036, plus retires three vestigial Phase-0/1 config-file TODOs surfaced during the pre-UI/UX-arc audit; no new D-entry since neither thread is a fresh decision). ?next= mechanics: the requested path is preserved through the unauthed redirect from proxy.ts (request.nextUrl.pathname + request.nextUrl.search), threaded through /login page searchParams → <LoginForm/> hidden input → server-action FormData → signInWithOtp’s emailRedirectTo querystring. /auth/callback (which already handled ?next= from Session 22a) is the consumer; the callback swaps its inline validation for the shared helper but is otherwise unchanged. The resend flow preserves next via a hidden input in the confirmation state, and the “Use a different email” link preserves it as well so a user who mistypes their email and clicks back doesn’t lose their original destination. Defense-in-depth: same-origin validation runs at three points — proxy entry, server-action post-Zod-parse, and callback exit. Extracted to lib/url/safe-next.ts after the second call site materialized. Validation rule unchanged from Session 22a’s inline pattern: startsWith("/") and !startsWith("//"), defaulting to /workspace on any failure. Blocks protocol-relative (//evil.com) and absolute (https://evil.com) values; passes any same-origin relative path including those with embedded querystrings (the encoded ? survives the round-trip cleanly). Clean-URL default: next is only appended to emailRedirectTo and the post-submit redirect when it differs from /workspace. Default flow’s URLs stay clean — /login?message=check-inbox not /login?message=check-inbox&next=%2Fworkspace. Incidental bug fix: the unauthed redirect in proxy.ts now carries refreshed session cookies via the same for (const cookie of getSupabaseResponse().cookies.getAll()) loop the authed-user bounce uses (per the file’s CRITICAL note about cookie carry on redirects). Was a latent issue from Session 23 — the loop was added there for the authed bounce but the older unauthed redirect predated the pattern and never got it. Surfaced and fixed while in the file. Config cleanup: config/theme.ts and config/departments.ts deleted — zero consumers in either case; the theme-registry approach was replaced by Aperture per D-027/D-037 (light-mode only, tokens in app/globals.css), and department seed data lives in SQL per CLAUDE.md’s post-Phase-1 prohibition on hardcoded department lists in frontend code. config/site.ts TODO block removed; stale JSDoc reference to the deleted theme file replaced with a one-liner naming the actually-wired preset. Surfaced-but-deferred: siteConfig is not wired into app/layout.tsx’s <title> / description metadata — those values are hardcoded directly in layout.tsx, which means the companyName / siteTitle fields in siteConfig are template-shaped but not load-bearing for this project. Either wire siteConfig into metadata for fork-template consistency, or accept the hardcoded values as canonical and trim siteConfig accordingly. Logged for a future small session, not this one. Files touched: proxy.ts, app/auth/callback/route.ts, app/(public)/login/{page,actions}.ts, components/login/{login-form,login-confirmation}.tsx, config/site.ts (modified); lib/url/safe-next.ts (new); config/theme.ts, config/departments.ts (deleted). Build status: clean — 18/18 routes, no new warnings, no TypeScript errors (compile 2.5s, TypeScript pass 1969ms). Smoke status: deferred to operator trust per session close (no live in-browser verification this session); behavior contract verified via skill cross-check — api-security.md’s whitelist-validation principle (validate redirect_uri against strict allowlist, line 213) and backend-security.md’s fail-closed default (line 60) are the safeNextPath shape, supabase.md’s getUser() pattern (gotcha #7) preserved in proxy.ts.smtp.resend.com:465, username resend, password is a Resend API key from resend.com/api-keys); the Next.js app keeps calling signInWithOtp unchanged. Sender is legalOS <onboarding@resend.dev>, the conventional Resend sandbox sender — deliberately sandbox mode rather than verified custom domain because the custom-domain decision from D-036 remains deferred and provisioning a domain solely to lift the rate limit would force premature naming/infrastructure decisions. Sandbox mode constrains delivery to the operator’s Resend account email (sole verified recipient, no separate verification surface exists — recipient verification happens once at signup); any other recipient fails with a 403 in Resend’s email log at resend.com/emails, which becomes the first diagnostic surface for delivery issues. Supabase imposes a separate 30/hour rate limit on newly configured custom SMTP, adjustable at /dashboard/project/_/auth/rate-limits — well above smoke-test volume but worth knowing for future load-testing. Resend free-tier headroom is 3,000/month and 100/day, comfortable for Phase 2 through trusted-cohort rollout. Files touched: SETUP.md (new subsection 3g “Configure custom SMTP via Resend (sandbox mode)” between 3f and Part 4 — context, Resend account setup, sandbox-mode constraint, Supabase dashboard config with field table, 4-step smoke test with 30/hour caveat, forward-pointer to custom-domain work; existing “Magic link emails aren’t arriving” troubleshooting entry rewritten as a 4-stop diagnostic ladder leading with the sandbox-recipient footgun and Resend’s email log). Smoke verified: 4 magic-link sends to the operator email within one hour, all delivered via onboarding@resend.dev, “via resend.com” line visible in client, all four logged delivered at resend.com/emails — proves the 2/hour cap is gone. Broader-cohort delivery and per-environment credential separation (dev/preview/prod sharing one Resend account vs. splitting) both remain blocked on the custom-domain decision; DNS-level sender authentication (SPF/DKIM/DMARC) is sidestepped because Resend has those records configured on resend.dev, but becomes required work when a custom domain replaces the sandbox sender.components/chat/message-input.tsx). ⌘+Return (Mac) / Ctrl+Return (Win/Linux) sends; plain Enter is newline; Esc during streaming stops generation. Reverses the prior Enter-sends behavior per spec §2.7 — legal text is long and deliberate, accidental sends are worse than the small friction of a modifier key. Hint copy: {modKey} Return to send · Return for newline · Esc to stop where modKey is "⌘" on Mac/iOS, "Ctrl" elsewhere. Detection via /Mac|iPhone|iPad/i.test(navigator.platform), defaulting to "Ctrl" for SSR. The handler accepts e.metaKey || e.ctrlKey on either platform — no platform branching at the keyboard layer, only at the hint-copy layer. Draft autosave (components/chat/chat-interface.tsx). localStorage.setItem('legalos.draft.<agentId>', value) on every change, debounced 200ms via useRef + setTimeout. Restore-on-mount useEffect keyed on agentId reads localStorage, seeds the draft state, schedules requestAnimationFrame to set the textarea selection range to end-of-text. Clear-on-send: inline localStorage.removeItem alongside setDraft("") (belt-and-suspenders against fast users re-typing before the debounce flushes). Multi-agent persistence — each agent’s composer carries its own draft, switching between agents preserves both drafts independently. SSR-guarded with typeof window === "undefined" early-returns. Stop button + AbortController (components/chat/chat-interface.tsx, components/chat/message-input.tsx). New onStop?: () => void prop on MessageInput. When disabled (= isStreaming) and onStop is present, the send button is replaced with a Stop button — icon-only filled square at size-9 (12×12 inner via size-3 bg-current), variant="outline" with bg-paper-2 to differentiate from the filled-ink send. ChatInterface owns useRef<AbortController | null>(null); each handleSend creates a fresh controller, threads controller.signal into the /api/chat fetch, and clears the ref in finally. handleStop calls abortRef.current?.abort(). Window-level Esc listener gated on isStreaming (because <textarea disabled> doesn’t dispatch keyboard events per the WHATWG spec). Unmount cleanup useEffect aborts any in-flight request when ChatInterface unmounts. New isAbortError(err) type guard reads err.name === "AbortError" defensively; both catch blocks (initial fetch + SSE drain loop) check it before showing error banners or appending system bubbles. On user-initiated stop: silent — partial assistant message stays as-is, no banner, no system bubble. Server-side cost-bleed acknowledged out of scope: the /api/chat route’s start(controller) keeps iterating the Anthropic SDK after the client closes; the full assistant message + usage_events row will land in the DB, and reloading the page shows the FULL message rather than the partial. Pattern B (server-side cancel acknowledgment) deferred to a future session if cost surfaces as a problem. Empty-state alignment (components/chat/chat-empty-state.tsx). Outer wrapper widens from mx-auto max-w-2xl to w-full edge-to-edge fill; inner welcome panel adds mx-auto and stays at max-w-2xl for welcome-copy line-length, centered within the chat surface. Without this, the first message arriving made the wrapper jump from 672px to 4xl, shifting all content ~120px on each side. Empty-state-to-populated transition is now invisible. Chat surface single-centerline alignment (multiple files). Every element of the chat surface — agent header content, user message cards, assistant prose+download unit, composer, error banner, soft-delete banner, empty-state inner panel — now centers at the same x-axis. Page main caps at max-w-4xl (896px), centers via mx-auto, owns the chat-surface frame. Agent header (components/chat/agent-header.tsx): active state’s content gains mx-auto max-w-3xl inside the full-width <header> so the border-bottom acts as a chat-surface separator at the full 4xl width while content sits at the conversation column’s 3xl; soft-deleted variant’s card narrows to max-w-3xl mx-auto entirely (it’s a card block, not a separator). MessageList <ul> drops redundant mx-auto max-w-4xl; becomes flex w-full flex-col gap-7 py-6. MessageBubble user branch: mx-auto added to the max-w-3xl tinted card so the user card centers within its <li>. MessageBubble assistant branch: prose + citations + download button restructured into a single <div className="mx-auto flex max-w-3xl items-start gap-2"> wrapper. Without this, the download button as a flex sibling on the <li> directly would asymmetrically narrow the prose’s available width, offsetting the assistant centerline ~16px left of the user-card centerline. The 44px prose-right-edge shrink at stream completion (when isExportable flips and the download button mounts) is acceptable: button is opacity-0 until hover and only mounts at a real state boundary. MarkdownRenderer’s outer max-w-3xl now redundant inside the new wrapper but kept for defensive symmetry. Composer outer wrapper drops px-4; becomes mx-auto w-full max-w-3xl pt-3 pb-4. Without this, the px-4 inset narrowed the visible white card by 32px relative to its 3xl-max-width siblings. ChatErrorBanner wrapper and soft-delete banner same treatment — drop px-4, keep mx-auto max-w-3xl. Scrollbar gutter architecture (chat surface frame). Page main gains scrollbar-stable (reserves a 15px gutter on its right edge — empty space, page main never uses it, but pulls page-main content area in to 881px). MessageList scroll container retains scrollbar-stable AND adds -mr-[15px] (negative right margin extends the scroll container’s outer 15px back into the page main’s reserved gutter; overflow-hidden on page main accommodates this since clipping happens at the padding-box edge). Net: scroll container outer = full 896px, with its own scrollbar gutter reserving 15px, leaving visible content = 881px — matching the page main’s content area where header / composer / banners live directly. All three pieces are load-bearing. Drop page-main’s stable: header/composer center 7.5px right of messages. Drop MessageList’s stable: scrollbar appearance jitters message content (the bug session 15 / D-030 fixed). Drop -mr-[15px]: the message column narrows to 866px and centers 7.5px left. The 15px hardcoded value is a browser-default approximation (12–17px actual depending on OS/browser); 2–3px variance is invisible at chat-surface scale. Files touched: app/(workspace)/agents/[id]/page.tsx, components/chat/{agent-header,chat-empty-state,chat-interface,message-bubble,message-input,message-list}.tsx. No agent runtime changes. No proxy.ts changes. No /api/chat changes. No DB schema changes. Build status: clean — 16/16 routes. Smoke status: in-browser verification across (a) ⌘+Return / Ctrl+Return sends, plain Return is newline; (b) hint copy renders correct platform glyph; (c) draft autosave restores on reload, clears on send; (d) drafts persist independently per agent; (e) stop button replaces send during streaming, click stops generation silently with partial message preserved; (f) Esc during streaming triggers same stop; (g) chat surface elements all align at the same vertical centerline (header content, messages, composer, banners share x=440.5 at full 4xl width); (h) no width jitter when scrollbar appears; (i) empty state seamlessly transitions to populated without horizontal shift. Out of scope, deferred to remaining sessions in the chat-redesign arc: session 18 — tool-trace state machine, citation superscript + footnote rendering, citation persistence to message records (carries over the 8j bug); session 19 — empty-state identity panel polish, error banner restraint pass, sticky right-side mini-index for long sessions, hover-reveal turn metadata gutter (spec §2.10).components/chat/message-input.tsx). Three-row vertical stack inside the existing white card surface (session 15): textarea (top) → tools row (middle) → hint row (bottom). Tools row layout: left <WebSearchIndicator/>, right <ModelPicker/> + send button. Send button shrinks from h-10 to size-9 p-0 to balance the new tool-button heights. Hint copy moves out from under the textarea into a centered footer inside the card (sentence-case retained per session-15 voice; mono-caps deferred). Web search indicator (components/chat/web-search-indicator.tsx, new). Read-only chip — renders ONLY when agent.tools_enabled includes "web_search"; the slot is empty when the agent doesn’t have web search enabled. Click navigates to /agents/<id>/edit#web-search where the actual toggle lives. Slate-blue active treatment (text-primary, border-primary/20, bg-chat-cite-bg, mono caps, dot prefix) — visual continuity with the prior toggle’s “on” appearance preserved. No client state, no useTransition, no server action; the agent edit form owns the toggle. The companion updateAgentWebSearchAction was added in the polish-1 build and dropped in the polish-2 iteration along with the toggle component itself — consolidating toggling on a single surface. Model picker (components/chat/model-picker.tsx, new). 3-model dropdown (claude-sonnet-4-6, claude-opus-4-7, claude-haiku-4-5) — common-case picks; the niche claude-opus-4-6 entry stays reachable through the full edit form’s Select (4-model list). Trigger label is read-through via modelLabel() — shows whatever model is on the agent record, even if outside the 3-option list, so a power user who picked opus-4-6 via the form sees their actual current model in the trigger. shadcn DropdownMenu styled to Aperture tokens: mono-caps, bg-paper-2 trigger, hairline border, chevron rotates 180° on open via group-data-[popup-open]:rotate-180. useTransition + optimistic local state; failure path reverts the trigger label and surfaces a toast.error. Server action (lib/actions/agents.ts). New updateAgentModelAction(formData) → AgentSettingResult. Owner-only at the application layer (created_by === user.id && !is_template && type === 'native' plus a fresh deleted_at !== null check), matching updateAgentAction. RLS enforces the same scope at the DB layer regardless. Zod-validates agent_id (UUID) and model (SUPPORTED_MODELS enum from MODEL_PRICING). Writes agents.model. revalidatePath('/agents/<id>') so the AgentHeader model chip reflects the change without a full reload. The companion updateAgentWebSearchAction is intentionally absent — web search is read-only in the composer. The AgentSettingResult result-tuple type is retained for future composer field actions. Shared utility (lib/llm/model-label.ts, new). Extracted modelLabel(model: string | null): string from agent-header.tsx:43. Two consumers: <AgentHeader> meta chip (existing, now imports from this file) and <ModelPicker> trigger (new). Pure string transform — strips the vendor prefix (anthropic/claude-sonnet-4-6 → claude-sonnet-4-6). Agent edit form flatten (components/agents/agent-form.tsx). Removed the “Advanced settings” section wrapper: the <h2>Advanced settings</h2> heading, the border-t border-border pt-6 divider, the inner Tools card around the Web-search Switch (web search is now a top-level field with its own <Label> + helper text, identical to every other field), and the static “Default output format” note (replaced — see Document export below). New field order: Name → Description → Model → Web search → System prompt → Attachments → submit row. Quick-config (model + web-search) sits above deep-config (system prompt + attachments) for better progressive disclosure. Web-search field gains id="web-search" + scroll-mt-8 so the composer’s <WebSearchIndicator/> link target scrolls cleanly to it. Document export note (components/agents/agent-form.tsx). New muted helper note above the submit row: Export to Word, Google Docs, and more — coming soon. Caption-tone, no interactive element. Replaces the deleted Default-output-format note with forward-looking framing that doesn’t lock to a single format. Card tone refinement (app/globals.css). --card and --popover shift from oklch(1 0 0) (pure white #ffffff) to oklch(0.976 0.0082 91.48) (warm off-white #f9f7f1). Cards still visibly elevated above the warm paper background (#f4f1ec) but no longer stark — sits in the user-stated target range #f7f5ef..#faf8f2. Propagates automatically to every bg-card consumer (department cards, agent cards, composer card, attachment list rows, agent edit form fields after the override below) and every bg-popover consumer (profile dropdown, model picker dropdown, agent card overflow menu, Select dropdown content, Dialog surfaces). Hex annotations in :root updated. Dark-mode block untouched (Aperture is light-mode only per D-027). The two-character OKLCH-validity repair from D-030 is preserved; this round shifts the value within the now-valid syntax. Form field surfaces (components/agents/agent-form.tsx). Four bg-card className overrides at the agent edit form’s input call sites: Name <Input>, Description <Input>, System prompt <Textarea>, Model <SelectTrigger>. Inputs are where the user does work and should read as elevated containers, not blend into the page background. Call-site overrides keep the change scoped to the form context that prompted the request — login form, search inputs elsewhere, etc. continue to use the default bg-transparent treatment unless promoted to a global pattern later. agent-attachments-section.tsx untouched — its visible card-style attachment rows already use bg-card, so they pick up the new --card value automatically. Workspace greeting accent (app/(workspace)/page.tsx). Wraps the user’s first name in **…** markers in the greeting string. <WorkspaceHero>’s existing emphasis parser (from session 9e, designed for the Aperture spec’s bold-highlighted-phrase pattern) renders the wrapped portion as font-medium text-primary — slate-blue, weight 500. Hero component itself unchanged — page-level wrapping keeps the parser content-driven so future copy can highlight any phrase, not just the username. Subtle accent in the right place: the username is the personal-load-bearing word in “Good morning, Steven.” Roadmap (README.md). New Future / Backlog entry: Document export. Export agent conversations and individual responses to Word (.docx), Google Docs, and additional document formats. Currently messages can be downloaded one-at-a-time as markdown via the per-message DownloadMessageButton; full conversation export and rich document formats are not yet implemented. Files touched: app/(workspace)/agents/[id]/page.tsx, app/(workspace)/page.tsx, app/globals.css, components/agents/agent-form.tsx, components/chat/{agent-header,chat-interface,message-input}.tsx, lib/actions/agents.ts, README.md (modified); components/chat/{model-picker,web-search-indicator}.tsx, lib/llm/model-label.ts (new). No agent-runtime changes. No proxy.ts changes. No /api/chat changes. No DB schema changes. Build status: clean — 16/16 routes. Smoke status: in-browser visual sign-off across (a) composer renders the model picker dropdown with consistent off-white surface; (b) composer hides the web-search chip when tools_enabled doesn’t include "web_search" and shows it when it does; (c) clicking the web-search chip navigates to the edit form’s web-search field; (d) model picker dropdown opens, selecting a different model persists across reload; (e) opus-4-6 selected via the edit form is shown in the picker trigger but isn’t a dropdown option (read-through behavior); (f) agent edit form’s input fields render visibly off-white against the warm paper background; (g) workspace greeting shows the username in slate-blue weight-500, distinct from the surrounding text; (h) all card and popover surfaces across the product render at the consistent off-white tone — no stark white anywhere. Out of scope, deferred to remaining sessions in the chat-redesign arc: session 17b — keyboard contract flip (⌘+Return to send, Return for newline, Esc during streaming), draft autosave to localStorage, stop button during streaming with SSE abort; session 18 — tool-trace state machine, citation superscript + footnote rendering, citation persistence to message records (carries over the 8j bug); session 19 — empty-state identity panel, error banner restraint pass, sticky right-side mini-index for long sessions, hover-reveal turn metadata gutter (spec §2.10)./agents/<id> (Session 15 — implements docs/design/aperture/chat-aperture-spec.md plus polish iterations from in-browser smoke; pulls forward session 16’s turn-layout work after smoke surfaced a width-jitter bug whose fix coupled with the bubble-paradigm replacement). Tokens. Nine net-new tokens land in app/globals.css: five chat-surface tokens (--chat-user-bubble-bg, --chat-prose-fg, --chat-code-bg, --chat-code-fg, --chat-cite-bg), --border-strong (#d8d2c7) for composer + archived-header borders, and the warn trio (--warn-fg #8a3a3a, --warn-fg-deep #6e2e2e, --warn-bg #f9f0ec) for error / archive treatment. Values pinned directly (no var() chains) so chat-* call sites tune independently per spec §3.2. OKLCH bug fix. --card and --popover were declared as oklch(1 0) — invalid syntax (CSS Color Module Level 4 requires three components, missing hue). Browsers fell back to transparent for background-color, so every bg-card element rendered transparent and showed the page background through. Repaired to oklch(1 0 0). Cards now render visibly white (#fff) on warm-paper. Agent header (components/chat/agent-header.tsx, new). Rich variant: name in Inter Tight 28 / 400 / -0.025em / 1.05, description at 14 / 1.55 / muted-fg with 60ch cap, meta-chip row (model in mono caps; web-search-on indicator with slate dot; attachment count). Soft-deleted variant: --card-divider background, --border-strong border, archived banner copy in --warn-fg. Replaces the legacy thin border-bottom strip at the top of the chat page. Markdown renderer (components/chat/markdown-renderer.tsx). Drops the Tailwind Typography plugin; an explicit type ramp per spec §2.3 lives in ReactMarkdown’s components prop. Fourteen element overrides — p, h1–h4, strong, em, inline + block code, pre, blockquote, ul/li (5px slate dots via ::before), ol/li (tabular-nums marker), table/th/td (small-caps mono headers, hairline grid). Code blocks invert to --chat-code-bg / --chat-code-fg (the only inverted surface in chat). Outer div carries max-w-3xl so prose stays in the 56–60ch reading measure regardless of the surrounding 4xl wrapper. Turn layout (components/chat/message-bubble.tsx, components/chat/message-list.tsx). Bubble paradigm replaced with single-column turns per spec §1.4 (polished iteration: speaker-label gutter dropped after smoke — the tinted-card-vs-bare-prose pattern carries the speaker distinction without the labels reading as redundant noise in legal-domain product). User messages render as a tinted --chat-user-bubble-bg card at max-w-3xl with hairline border, 10px radius, whitespace-pre-wrap break-words so pasted contracts survive intact. Assistant messages render as bare prose, max-w-3xl from the renderer, no card. System messages keep their centered-italic-caption treatment. The <ul> wraps everything at mx-auto max-w-4xl with gap-7 (28px between turns, matching the visual reference’s .col { gap: 28px }). The waiting-for-first-token and tool-use placeholders render flat (typing dots / spinner-row) without speaker labels. The legacy max-w-[80%] per-bubble cap is gone; flex children that hold message content carry min-w-0 so unbreakable tokens (long URLs, wide inline code) can’t defeat the max-w-3xl cap. Width architecture (D-030). Outer chat wrapper at max-w-4xl, prose column at max-w-3xl inside. Deliberate deviation from spec §1.4’s max-w-3xl-everywhere reading — user feedback during smoke pushed back on the resulting narrowness; the compromise widens the chrome while preserving the prose line-length argument. Documented in DECISION_LOG. Scrollbar gutter. New @utility scrollbar-stable { scrollbar-gutter: stable; } rule in app/globals.css, applied to the MessageList scroll container. The diagnosed root cause of “chat width changes with content”: when the first message overflowed the scroll container, a vertical scrollbar (~15px) appeared, narrowed the available content area, and the centered <ul mx-auto> re-centered leftward — visibly shifting every message. Stable gutter reserves the scrollbar track in the layout box from initial paint, so first overflow no longer jitters. Sizing fix. ChatInterface’s legacy h-[calc(100vh-4rem)] replaced with min-h-0 flex-1; chat <main> gains min-h-0 flex-1 overflow-hidden so the chat surface contains its own scroll inside MessageList. Without this fix the composer fell ~92px below the viewport fold (top bar 56 + agent header ~92 + body padding 88 > 100vh) and the visible width fix would have landed on top of a still-broken sizing story. The workspace body wrapper at app/(workspace)/layout.tsx is unchanged — other workspace routes still need its overflow-auto behavior. Composer (components/chat/message-input.tsx). White card surface per spec §2.7: bg-card, border border-border-strong, rounded-[14px], layered soft shadow (0 1px 2px rgba(0,0,0,0.04), 0 12px 28px -14px rgba(0,0,0,0.10)). Focus-within state: border tints --primary at 0.45α, layered shadow gains a 3px slate ring at --primary 0.08α; 200ms transition-[border-color,box-shadow] ease-out. Textarea inherits transparency (bg-transparent border-0 shadow-none focus-visible:border-0 focus-visible:ring-0) so the card surface is clean and the focus signal is owned by the card, not the input. Outer wrapper changes from py-4 to pt-3 pb-4 to match the visual reference’s 12px breathing above the card. Out of session-15 scope, deferred to session 17: attachment chips, web-search toggle, model picker, ⌘+Return contract flip, draft autosave, stop button. Workspace card hover state (components/workspace/department-card.tsx, components/workspace/agent-card.tsx). Subtle slate-blue accent layered on the existing Aperture lift+shadow hover treatment. Border tints --primary at 0.35α (replaces the prior --hairline-strong); shadow gains a slate-tinted layer at 0_8px_24px_-8px_rgba(59,86,128,0.12) appended to the existing dark-layer stack. Lift, transform, and 220ms cubic-bezier(.2,.7,.2,1) timing all preserved. Restraint is intentional — slate stays as accent, never becomes the card surface. Prompt formatting rules (lib/llm/anthropic/prompt-defense.ts). New exported OUTPUT_FORMATTING_RULES constant appended to every native agent’s assembled system prompt by buildSystemPrompt, after the agent’s stored prompt for recency-bias weight. Forbids decorative Unicode glyphs (✅ ❌ ✓ ✗ 🟢 🔴 ⚠️ ⭐ 💡 📌 and similar) which Apple Color Emoji renders as colored emoji regardless of CSS color — out-of-character for legal-domain product. Document-meaningful symbols (§ ¶ © ®) explicitly allowed. Applies uniformly to all native agents (blank-template forks AND user-customized) because it’s part of assembly, not stored in agents.system_prompt. Design references (docs/design/aperture/, new). chat-aperture-spec.md (authoritative, ~470 lines), chat-aperture.html and chat-aperture.jsx (visual reference, four scenes). Stashed alongside the existing atrium-* siblings — same precedent as the original Aperture handoff stash from session 9a. Roadmap (README.md). New Future / Backlog entry: card access-rights treatment — flagging the future need for inactive variants of department and agent cards (visually disappear into bg-background, no shadow, no hover, click target removed) once role-based hiding is wired. D-030 added to DECISION_LOG.md: documents the max-w-4xl wrapper / max-w-3xl prose width architecture, the OKLCH repair, the speaker-gutter pattern from spec §1.4 (with the smoke iteration that dropped the speaker labels), the scrollbar-gutter and min-w-0 fixes, the sizing fix pulled into the same session, and the rationale for pulling session 16’s turn-layout work forward. Files touched: app/(workspace)/agents/[id]/page.tsx, app/globals.css, components/chat/{agent-header,chat-interface,markdown-renderer,message-bubble,message-input,message-list}.tsx, components/workspace/{agent-card,department-card}.tsx, lib/llm/anthropic/prompt-defense.ts, DECISION_LOG.md, README.md, plus the three design-reference files. No agent-runtime changes. No proxy.ts changes. No /api/chat changes. No DB schema changes. Build status: clean — 16/16 routes. Smoke status: in-browser visual sign-off across (a) cards on / and /departments/<slug> render visibly white with the slate-blue hover accent; (b) chat surface on /agents/<id> has static left/right margins regardless of message volume — no width jitter; (c) composer renders as a white card with hairline border and soft shadow, focus-within tints the border slate and shows the 3px ring; (d) speaker distinction reads clearly via card-vs-no-card alone (no YOU/AGENT labels needed); (e) waiting-for-first-token and tool-use placeholders render flat without gutters; (f) prose constrained to max-w-3xl for legal-readable line length while the chrome reads at the wider max-w-4xl; (g) composer always visible at the bottom of the viewport — no falling below the fold. Out of scope, deferred to remaining sessions in the chat-redesign arc: session 17 — composer attachment chips, web-search toggle, model picker, ⌘+Return contract flip, draft autosave, stop button; session 18 — tool-trace state machine, citation superscript + footnote rendering, citation persistence to message records (carries over the 8j bug); session 19 — empty-state identity panel, error banner restraint pass, sticky right-side mini-index for long sessions, hover-reveal turn metadata gutter (spec §2.10).(app) route group retired; breadcrumb refactored to a declarative route table (Session 14 — closes the workspace-chrome consolidation arc that began in 9a). Closes 9a–14. Every product-facing AND operator-facing route (/, /departments/<slug>, /agents/<id>, /agents/<id>/edit, /agents/new, /agents/trash, /coming-soon, /coming-soon/<area>, /admin, /admin/calculator, /admin/metrics) now renders inside the unified Aperture workspace chrome. (app) route group entirely removed — app/(app)/layout.tsx deleted, components/nav/main-nav.tsx deleted, components/nav/ directory removed, app/(app)/ directory removed (was the last subtree). app/ now contains only (public), (workspace), api, auth, favicon.ico, fonts, globals.css, layout.tsx. Routes moved. Three admin pages + the admin layout: (app)/admin/{layout, page, calculator/page, metrics/page}.tsx → (workspace)/admin/{layout, page, calculator/page, metrics/page}.tsx. Two coming-soon pages: (app)/coming-soon/{page, [area]/page}.tsx → (workspace)/coming-soon/{page, [area]/page}.tsx. All translations are 1:1 — no narrowing changes, no logic changes, no prop changes to component imports. The admin layout still calls requireAdminUser() (gates every admin sub-route with notFound() on non-admin to avoid leaking section existence) and wraps content in <main className="mx-auto max-w-5xl px-6 py-10"> (max-w-5xl preserved — wider than agent surfaces’ max-w-3xl since the metrics tables benefit from the extra width). Coming-soon sizing fix. components/coming-soon/coming-soon.tsx’s <main> className changed from min-h-[calc(100vh-4rem)] to flex-1 min-h-0 — same fix as the chat page in session 11. The legacy 4rem was sized to MainNav’s height; under workspace chrome the body wrapper is the flex column and flex-1 min-h-0 makes the component fill the available height naturally without creating artificial scroll inside the wrapper. Profile block becomes a dropdown. New client island components/workspace/workspace-profile-block.tsx wraps the existing avatar + truncated-name + role-label markup in a <DropdownMenuTrigger> (using shadcn/ui’s existing primitive — no new dependency). The trigger uses render={<button type="button" className="..." aria-label={\Account menu for ${displayName}`} />} to make the entire block keyboard-clickable while preserving the legacy mt-auto flex-bottom-pin. Adds hover:bg-hairline and focus-visible ring for the new interactive state. Menu opens with side=”top” (rail bottom is at viewport bottom) and align=”start”. Two items: **Admin** — conditional on isAdmin, renders as <DropdownMenuItem render={}> (mirrors the /agents/new and /agents/trash moved into the workspace chrome (Sessions 12 + 13 — continues the structural consolidation that 10a/10b/11 began; closes the agent-surface family of routes into (workspace)). Route move. app/(app)/agents/new/page.tsx → app/(workspace)/agents/new/page.tsx; app/(app)/agents/trash/page.tsx → app/(workspace)/agents/trash/page.tsx. Both URLs (/agents/new and /agents/trash) keep the same shape — only the underlying route group changes, swapping the global MainNav top bar for the persistent Aperture rail + workspace top bar from 10a’s layout. Atomic move. Both legacy pages and both new pages staged in the same commit so main’s history never has two page.tsx files at the same URL (Next.js route collision). The transient working-tree collision during file ops was deliberately allowed (no build runs between writes and deletes); the final build verifies the resolved state. No narrowing changes. Both pages are 1:1 translations from the legacy versions: same imports, same data fetches (requireAuthUser cached from 10a; getDepartmentIfAccessible for the new page; getDeletedAgentsForUser for trash; inline agents query for the new page’s fork_from template lookup), same notFound conditions (5-condition single-collapse on the fork_from template; trash has no notFound, just an empty-state branch), same form / list composition. The mx-auto max-w-3xl px-6 py-10 outer wrapper is preserved on both pages; visual deduplication of the nested padding (workspace body wrapper’s px-14 pb-8 pt-14 plus the page’s own px-6 py-10) belongs to the chat-redesign session, not 12/13. Breadcrumb extension. <WorkspaceBreadcrumb> (already a client component) gains useSearchParams() alongside the existing usePathname() so the /agents/new branch can read ?department=<slug> and resolve it to a department name via the existing departments prop. The useSearchParams() import doesn’t force a Suspense boundary in practice — workspace surfaces are already fully dynamic via the layout’s data fetches. Two new pathname branches inserted BEFORE the generic /^\/agents\/([^/]+)/ regex — the load-bearing ordering without which “new” and “trash” would be interpreted as agent ids and fall through to the defensive "Agent" placeholder: (1) /agents/new resolves to workspace / departments / <Department.name> / <last> where <last> is "Fork template" when ?fork_from= is present and "New agent" otherwise — department resolved from ?department=<slug> against the departments prop; defensive fallback when slug is absent or unresolvable renders workspace / departments / <last> (no department segment), which shouldn’t trigger in practice since the page redirects to / on missing slug and notFound()s on unresolvable; (2) /agents/trash resolves to workspace / trash, mirroring the workspace / <area> pattern from /coming-soon/<area> — trash is user-scoped, not department-scoped, so a single labeled segment matches the Aperture vocabulary best (alternatives workspace / agents / trash adds a synthetic intermediate segment that doesn’t correspond to any actual route). Rail active state intentionally NOT extended for either route. Neither /agents/new nor /agents/trash is a navigation target the rail should track — they’re action surfaces, not destinations. The rail’s department links don’t highlight on /agents/new even though the URL carries a department slug; the breadcrumb already conveys department context, and rail highlighting on action surfaces would be misleading (the user isn’t “in” Commercial when filling out a fork form scoped to it; they’re creating something to put there). createAgentAction redirect target unchanged — the action body in lib/actions/agents.ts:line ~290 redirects to /departments/<slug> post-creation. Both URLs are now in (workspace), so the redirect lands cleanly on the renovated launchpad with the new agent visible in My Agents. revalidatePath("/agents/trash") unchanged — the two callers in lib/actions/agents.ts (softDeleteAgentAction, restoreAgentAction) operate on the URL, not the file path; URL is identical post-move. Inbound link references unchanged — MainNav’s conditional Trash link at components/nav/main-nav.tsx:54 (href="/agents/trash"), <AgentCard>’s native-template fork link at components/workspace/agent-card.tsx:137 (/agents/new?department=...&fork_from=...), and the launchpad’s “Create new agent” CTAs at (workspace)/departments/[slug]/page.tsx:64 (/agents/new?department=<slug>) all use literal URL strings; URLs preserved. (app)/agents/ directory entirely removed after the move — only [id]/ remained from 11 (already deleted), then new/ and trash/ deleted this session, leaving the parent empty and git rm auto-cleaning the entire subtree. (app)/ now contains only admin/, coming-soon/, layout.tsx. createAgentAction flow when forking: form submits with forked_from_agent_id set (passed through from the page’s URL ?fork_from=); action validates the template (4 conditions inline), inserts the new agent row with forked_from_agent_id populated, then redirects to /departments/<slug>. Net behavior identical to the legacy flow; only the rendering chrome changed. Auth gating unchanged — both routes auth-gated by proxy.ts (paths not in PUBLIC_PATHS, URL-based not file-based). Toaster sink already mounted in (workspace)/layout.tsx (10b); RestoreButton’s success / error toasts and AgentForm/AgentAttachments upload toasts surface correctly without a layout change in 12/13. No agent-runtime changes. No proxy.ts changes. No /api/chat changes. Build status: clean — 13/13 routes, no new warnings. Smoke status: in-browser visual sign-off across rail-stays-static-on-navigation, breadcrumb shapes for blank-create vs fork-template vs trash, AgentForm composition inside workspace chrome, RestoreButton firing toasts, MainNav-from-/admin Trash link transitioning chrome to workspace, missing-department redirect("/") recovery, defensive breadcrumb fallback paths. Out of scope, deferred to separate sessions: moving (app)/admin/* (admin dashboard, calculator, metrics) into workspace chrome — those surfaces have their own (app)/admin/layout.tsx and a different chrome design pattern decision; not part of the agent-surface consolidation arc. Visual redesign of the chat / new-agent / trash surfaces (bubbles, composer, streaming states, form layout, list rows) deferred to the chat-redesign Design brief. Sessions 9a–13 now have every product-facing user surface (/, /departments/<slug>, /agents/<id>, /agents/<id>/edit, /agents/new, /agents/trash, /coming-soon/<area>) on the unified Aperture chrome; only admin tooling remains under MainNav.app/(app)/agents/[id]/page.tsx and app/(app)/agents/[id]/edit/page.tsx deleted; app/(workspace)/agents/[id]/page.tsx and app/(workspace)/agents/[id]/edit/page.tsx created in their place. Both URLs (/agents/<id> and /agents/<id>/edit) keep the same shape — only the underlying route group changes, swapping the global MainNav top bar for the persistent Aperture rail + workspace top bar from 10a’s layout. (app)/agents/new/ and (app)/agents/trash/ stay where they are (out of scope; their Aperture renovation is a future session). Atomic move. Both legacy pages and both new pages staged in the same commit so no point in main’s history has two page.tsx files at the same URL (Next.js route collision). The transient working-tree collision during file ops was deliberately allowed (no build runs between writes and deletes); the final build verifies the resolved state. New nested layout. app/(workspace)/agents/[id]/layout.tsx calls getAgent(id) (cached, see below) and notFound() on null — gating missing / RLS-hidden / unreadable agents at the layout level so /agents/<id> 404s cleanly via the layout for those cases. generateMetadata resolves the document title to <Agent.name>; the edit page’s own generateMetadata overrides to Edit <Agent.name>. The layout itself is a <>{children}</> pass-through — chrome is inherited from (workspace)/layout.tsx, not added here. Two new helpers in lib/auth/access.ts. getAgent(id) (cached) returns the unified AccessibleAgent shape — every column the chat surface, edit form, and breadcrumb collectively need (id, name, description, type, is_active, is_template, system_prompt, model, tools_enabled, created_by, deleted_at) plus the nested department: {slug, name} projected from departments!inner PostgREST join. Single null contract on any failure mode (missing, RLS-hidden) — same idiom as getDepartmentIfAccessible. Callers narrow further with their own notFound() clauses. getAccessibleAgentsForBreadcrumb(userId) (cached) returns a slim AgentBreadcrumbContext[] (id, name, department_slug, department_name) — the workspace layout fetches this on every render to feed the breadcrumb’s slug-to-name resolution and the rail’s parent-department highlight when on /agents/<id>. Filtered to is_active = true AND deleted_at IS NULL so soft-deleted agents (whose chat surfaces ARE still reachable per architecture §3) don’t appear in rail active-state lookups; the breadcrumb falls through to a defensive "Agent" placeholder for those edge cases. Both helpers wrapped in React cache() so layout + page calls within a request resolve to a single Supabase round-trip per helper. Workspace layout threading. (workspace)/layout.tsx now calls getAccessibleAgentsForBreadcrumb in parallel with getAccessibleDepartments (Promise.all), threads the agents array to both <WorkspaceRail> and <WorkspaceTopBar>. Top bar forwards it to <WorkspaceBreadcrumb>; rail forwards it as agentsLookup to each department <WorkspaceNavLink>. Breadcrumb extension. <WorkspaceBreadcrumb> gains two new pathname branches between the existing /departments/<slug> and /coming-soon/<area> branches: /agents/<id> → workspace / departments / <Department.name> / <Agent.name>; /agents/<id>/edit → ... / Edit. The edit branch matches first because the edit pathname also satisfies the simpler agent regex. Defensive "Agent" fallback when an agent isn’t in the lookup list — RLS shouldn’t permit this branch (the layout’s notFound() runs first) but the breadcrumb stays readable rather than crashing. Rail active-state extension. <WorkspaceNavLink> gains an optional agentsLookup?: AgentBreadcrumbContext[] prop. When set AND the link is a department link (href starts with /departments/) AND pathname matches /agents/<id> (or /agents/<id>/edit), the agent is looked up in the list and the link is also active when the agent’s department_slug matches this link’s slug. Lets the rail’s parent-department highlight follow navigation into a chat surface, even though chat URLs aren’t structurally nested under /departments/. Workspace and Resource links don’t receive agentsLookup — only department links do. Chat page sizing fix. Replaces the legacy h-[calc(100vh-4rem)] (which was sized to MainNav’s height) with flex-1 min-h-0. The workspace body wrapper is a flex column with min-h-0 overflow-auto, so flex-1 makes the chat’s <main> fill the column’s remaining height while min-h-0 lets the chat shrink below its intrinsic content height — letting <ChatInterface>’s internal scroll activate as designed without spilling into the body wrapper’s overflow-auto. Visual chat redesign (bubbles, composer, streaming states, citations, model picker, context strip, error states) deferred to a separate Design brief — this is structural sizing only. Narrowing-mechanism shift, net behavior identical. Legacy chat page’s inline supabase query filtered with .eq("is_active", true).eq("type", "native") so the query returned null when those failed and a single notFound() covered the rest. New page calls getAgent(id) (returns the row regardless) and narrows with if (!agent || agent.type !== "native" || !agent.is_active) notFound(). Same effective contract — single rendered 404 covering all the same cases — mechanism shifts from query filter to code branch. Edit page’s 7-condition collapse stays as-is, plus an 8th !agent.department defensive guard (the inner-join helper guarantees it for visible agents, but the existing legacy page’s secondary if (!department) notFound() check is preserved by folding into the main clause). Department field shape rename. Legacy edit page read agent.departments (PostgREST nested-object name with the trailing s); new edit page reads agent.department (helper-projected, singular). One-touch change inside the new edit page; doesn’t propagate to <AgentForm> props (which take departmentSlug as a string). Soft-delete UX preserved. Soft-deleted agents (deleted_at IS NOT NULL) keep their chat surface accessible per architecture §3 (transcript-as-record); <ChatInterface>’s message-input branches into a disabled state via the same isDeleted prop. Toaster sink already mounted in (workspace)/layout.tsx from 10b, so attachment-upload toasts and any future chat-surface toasts have a sink without needing a layout change in 11. <AgentForm> is shared between (app)/agents/new/ (still under MainNav) and (workspace)/agents/[id]/edit/ (now under workspace chrome). Form’s cancelHref resolves to /agents/<id> for edit mode and /departments/<slug> for create — both URLs stay valid post-move (chat lives in workspace; launchpad is the workspace launchpad from 10b). updateAgentAction redirects to /agents/<id> post-update — URL unchanged, redirect lands on the workspace chat surface as intended. No D-entries. Implementation, not architecture; the architectural decisions for the workspace-chrome consolidation already live in 9c/9e/10a. Build status: clean. Smoke status: in-browser visual sign-off across rail-stays-static-on-navigation, breadcrumb updates with route on /agents/<id> and /agents/<id>/edit, parent department highlights in the rail when on a chat surface, chat fills the body wrapper vertically with <ChatInterface> scrolling internally, soft-deleted agent renders with disabled input, edit form composes inside the workspace chrome, <AgentForm> cancel returns to chat, updateAgentAction redirects to chat, attachment toasts fire correctly. Out of scope for this session, deferred to a separate Design brief: chat surface visual redesign — bubbles, composer, streaming states, citations, model picker, context strip, error states. The chat surface composes byte-for-byte the same content as before in terms of colors / typography / message-list shape; only the surrounding chrome changed. Sub-renovations flagged for future sessions: moving /agents/new and /agents/trash into (workspace)/agents/... to inherit the same chrome (and visual redesign of those forms in line with Aperture); admin surfaces (/admin, /admin/calculator, /admin/metrics) potentially moving too once a chrome design pattern for non-product-surface admin tooling is decided.app/(app)/departments/[slug]/page.tsx (top-bar MainNav, in-page tabs, Templates / My Agents grids, TipsSection, WelcomeModal, floating SupportButton) is deleted; its replacement at app/(workspace)/departments/[slug]/page.tsx is content-only and inherits the workspace rail + top bar from (workspace)/layout.tsx (10a). Both routes resolve to /departments/<slug> so the move was atomic — old deleted in the same commit as the new file landing. The new page renders <DepartmentHeader> (h1 + optional muted subline matching the landing’s hero typography) + Templates section + My Agents section (with empty-state tile and Create-new-agent CTA). Auth path unchanged: requireAuthUser (cached via 10a) + getDepartmentIfAccessible(slug) with notFound() on null to preserve the existence-leak guarantee from D-009. Route-aware chrome. Two new client islands added so the layout’s rail and breadcrumb update with navigation: <WorkspaceNavLink> wraps <Link> and reads usePathname() to apply active styling — match="exact" for the Workspace link and Resource links, match="prefix" for Department links so /departments/commercial activates the Commercial entry; <WorkspaceBreadcrumb> reads the same hook and renders workspace / departments on /, workspace / departments / <Department.name> on /departments/<slug>, and workspace / <Area Label> on /coming-soon/<area> (resource-link slugs mapped via a small lookup table that mirrors the rail’s RESOURCE_LINKS array). The rest of the rail and top bar stay server-rendered; only the path-derived bits become client islands. The departments array is passed from the layout into both islands so they can resolve slug → name without re-fetching. Aperture agent card. New components/workspace/agent-card.tsx (and matching agent-grid.tsx) replace the legacy components/launchpad/agent-card.tsx. Same three render branches (external opens new tab + analytics fire on onPointerDown, native template links to /agents/new?department=<slug>&fork_from=<id>, native my-agent uses the stretched-link pattern with overflow menu — Edit links to /agents/<id>/edit, Delete fires softDeleteAgentAction with a Sonner Undo toast wired to restoreAgentAction). Visual vocabulary matches <DepartmentCard> from 9e: white card surface, border-card-border, hairline border darkening to --hairline-strong on hover, 220ms cubic-bezier(.2,.7,.2,1) transition on transform / box-shadow / border-color, multi-layer shadow growth idle → hover, focus-visible outline ring on every branch. Body shape is simpler than DepartmentCard — name + description only, no foot or arrow circle (agents are leaves, not navigational containers). Toaster mount. (workspace)/layout.tsx now imports Toaster from @/components/ui/sonner and mounts it once at the bottom of the outer container — sink for MyAgentCard’s Undo toast. Without this mount the toast would degrade silently. Footer removed. <WorkspaceFooter /> and components/workspace/workspace-footer.tsx are deleted; the inner <main> grid drops from grid-rows-[56px_1fr_36px] to grid-rows-[56px_1fr] so the body wrapper reclaims the 36px footer row. The footer’s keyboard-shortcut hints (⌘K to command ⌘1 workspace · ⌘M matters · ⌘I inbox) and “privilege enforced” copy implied features that don’t exist (no command palette, no privilege-enforcement code) — better to surface those when they ship rather than as visual placeholders. Version label was the only remaining substantive element and didn’t justify its own row. Rail-jump fix. Outer container’s className gains grid-rows-[100vh] so the implicit single grid row is hard-pinned at viewport height. Without this, <main>’s 1fr row was free to grow with body content (no height ceiling), the implicit outer row stretched to fit, and the rail cell stretched with it — pushing the mt-auto profile block out of view as the user navigated to a tall page. With the explicit row pin, the rail cell stays at exactly 100vh regardless of body content; <main>’s 1fr row resolves to 100vh - 56px reliably; body wrapper’s overflow-auto activates as designed. Helper rename. getAgentsForDepartmentSplit in lib/auth/access.ts renamed to getAgentsForDepartmentLaunchpad — same {templates, myAgents} return shape, same two-query partition logic (is_template = true for templates; is_template = false AND created_by = userId for myAgents; same RLS scoping; same sort orders), Aperture-vocabulary naming. Not wrapped in cache() — single caller per request. Components dropped. Entire components/launchpad/ directory deleted: agent-card.tsx, agent-grid.tsx, category-labels.ts (already orphaned per 10b recon), department-tabs.tsx (rail is the navigation now, no in-page tabs), support-button.tsx (SupportButton intent preserved as a “Future / Backlog” entry in README.md — “In-app support chat — replaces the legacy floating support button removed in 10b”), tips-section.tsx (privilege-and-playbook copy block — surfaceable later as part of an admin/onboarding flow if needed), welcome-modal.tsx (sessionStorage-gated welcome dialog — replaceable with a proper onboarding flow if user research justifies one). All seven had exactly one consumer each per the 10b recon, verified via grep before deletion. No D-entries. This is the implementation half of the 9–10 UI/UX overhaul arc; architectural decisions (palette, Constraint B relaxation, department restructure) all already landed in D-027 / D-028 / D-029. Build status: clean across all incremental builds (helper rename, new components, layout edits, footer removal, rail fix). Smoke status: in-browser visual sign-off across rail-stays-static-on-navigation, breadcrumb-updates-with-route, active-state-tracks-path, every card branch (external new-tab + native-fork + native-my-agent overflow + delete + Undo), /coming-soon/<area> breadcrumb shape, and the rail-no-longer-jumps fix. Sets the workspace surface up for any further (workspace)-group routes to inherit the chrome unchanged. Sub-renovation flagged for a future session: the agent chat surface at /agents/<id> still lives under (app)/ with MainNav — moving it into (workspace)/agents/[id]/ to inherit the rail is the natural next chunk.app/(workspace)/page.tsx into app/(workspace)/layout.tsx; wrap shared auth/data helpers in React’s cache() (Session 10a — preparation for the department launchpad renovation in 10b). Chrome lifted. The outer two-column grid shell (grid h-screen grid-cols-[232px_1fr] + bg-background text-foreground + inline fontFeatureSettings: '"ss01", "cv11"'), <WorkspaceRail />, the inner three-row main grid (grid-rows-[56px_1fr_36px]), <WorkspaceTopBar />, the body wrapper (flex min-h-0 flex-col gap-9 overflow-auto px-14 pb-8 pt-14), and <WorkspaceFooter /> all move from the page into the layout. The page becomes content-only — a fragment containing <WorkspaceHero /> and <DepartmentGrid /> as siblings, picking up the layout’s gap-9 between them via the body wrapper’s flex column. The layout was a no-op pass-through prior to 10a (introduced in 9e); now it’s a real async server component that fetches what the chrome needs (auth user, profile, accessible departments) and slots {children} into the body wrapper. Chrome persists across navigation as more workspace-group routes land, which is the load-bearing reason for the lift — 10b’s department launchpad will live at app/(workspace)/departments/[slug]/page.tsx and inherit this chrome unchanged. cache() wrap. Three helpers in lib/auth/access.ts — requireAuthUser, getCurrentUserProfile, getAccessibleDepartments — converted from plain async functions to cache()-wrapped consts. Per-request memoization keyed on arguments (or no-arg for the first two): layout + child page calls within the same request resolve to a single Supabase round-trip per helper. Same call-site signatures, same return types, same JSDoc carry-over — no consumer changes needed. The other helpers in the file (getDepartmentIfAccessible, getAgentCountsByDepartment, isCurrentUserAdmin, requireAdminUser, getAgentsForDepartmentSplit, getDeletedAgentsForUser, userHasDeletedAgents) are not wrapped in 10a — only the three with cross-component duplication get cache() today; the rest can be wrapped opportunistically when a similar duplication surfaces. No behavioral change to the workspace landing — visual parity confirmed in-browser before commit. Out of scope for 10a, flagged for 10b: dynamic breadcrumb in <WorkspaceTopBar /> (currently hardcoded workspace / **departments**), route-aware “Workspace” active state in <WorkspaceRail /> (currently permanent via inline aria-current="page"), and the /departments/<slug> route-handler shift from (app)/ to (workspace)/ (currently the rail’s department links resolve to the old top-bar-MainNav launchpad).@theme color tokens (Session 9c — UI/UX overhaul groundwork). Fonts. D-022’s system-ui-only font stack is replaced via next/font/local. Two variable woff2 files committed under app/fonts/ alongside their SIL OFL 1.1 LICENSE files: inter-tight-variable-latin.woff2 (display + UI surfaces, weights 100–900 via the variable axis — covers the non-standard 450 weight Aperture’s spec calls for) and geist-mono-variable-latin.woff2 (mono surfaces, same variable-axis approach). Sourced from @fontsource-variable/inter-tight / @fontsource-variable/geist-mono (npm), copied into the repo, then the npm packages were uninstalled — the fonts are committed assets, not runtime dependencies. app/layout.tsx declares two localFont calls exposing --font-display and --font-mono CSS variables on <html>; app/globals.css’s @theme inline block references those variables in --font-sans, --font-mono, --font-heading with system-ui, sans-serif / ui-monospace, monospace fallbacks. next/font/local auto-generates metric-overridden Arial fallbacks (size-adjust: 97.21% for Inter Tight, 131.49% for Geist Mono) so the swap-in produces no layout shift. Total payload: ~76KB across both variable fonts. Geist Mono is substituted for Aperture’s spec’d IBM Plex Mono — same humanist-mono neighborhood, pairs naturally with Inter Tight, no IBM-branded assets in the repo. Recorded in D-027. Palette. :root block in app/globals.css rewritten with OKLCH values converted from the Aperture palette via culori (rounded to 4 decimal places). Existing shadcn token names preserved where they map cleanly to Aperture roles so existing bg-background / text-foreground / border-border / text-muted-foreground / bg-primary / etc. className references keep working but resolve to the new warmer palette: --background → paper #f4f1ec, --foreground → ink #1a1816, --card → white #ffffff, --primary → accent slate-blue #3b5680, --primary-foreground → paper, --secondary → stone #efeae1, --muted → paper-2 #fbf9f4, --muted-foreground → mute #6b6358, --accent (shadcn hover surface, not brand) → stone, --border / --input → card-border #ebe6dc, --ring → accent. Sidebar tokens remapped to the Aperture rail spec: --sidebar → stone, --sidebar-foreground → ink-2 #2c2925, --sidebar-primary → ink (active nav bg), --sidebar-primary-foreground → paper, --sidebar-accent → paper-2 (hover), --sidebar-border → stone-2 #e8e2d4. Seven new tokens added for Aperture roles without a clean shadcn analog, exposed via @theme inline so Tailwind generates bg-paper-2 / border-hairline / text-caption / etc. utilities for future component renovation: --paper-2 (hover row tint, alias of --muted), --hairline (stone-2, top-bar / footer hairlines), --hairline-strong (stone-3 #e3ddd1, card outer chrome), --card-divider (#f0ebdf, card foot top-border), --ink-2 (nav link text), --caption (mute-2 #8a8174, caption / mono text), --accent-hover (#4a679a). --destructive left unchanged (Aperture has no destructive palette). --chart-1 through --chart-5 left unchanged for 9c — flagged for a future “chart palette retune for warm-neutral compatibility” session. The .dark block is not touched — Aperture is light-mode only by spec; dark mode retune is later work. Lightning CSS auto-generates lab() fallbacks alongside each OKLCH for older-Safari compatibility. Constraint shift. Constraint B from D-014 (let shadcn defaults drive the visual style) is relaxed; new constraint replaces it: shadcn primitives for interaction correctness, design tokens for visual styling. The token-rebinding mechanism above is the lowest-touch path from “shadcn neutral” to “Aperture warm-paper” — every existing screen visually shifts in one CSS edit without touching any component or page file. Recorded in D-028. Files touched: app/layout.tsx, app/globals.css, four new files under app/fonts/. No component or page file was modified — that is the explicit scope boundary of 9c. Dev dep added: culori (kept installed for future palette work). Build status: clean. Visual sanity: dev server smoke-checked on the unauthenticated /login surface; authenticated surfaces (Workspace, department launchpad, agent chat) verified in-browser before commit. Some existing screens designed against the old neutral palette will look slightly off in the warm-paper palette — those get renovated in 9d / 9e or ad-hoc later. Groundwork for the Aperture Workspace landing (Session 9e). New ADRs: D-027 — Font reversal (D-022 → Inter Tight + Geist Mono, self-hosted) and D-028 — Constraint B relaxed; new constraint: shadcn primitives for interaction, design tokens for visual.CLAUDE.md, README.md, and PROJECT_OUTLINE.md updated from Next.js 15 → Next.js 16 and Tailwind CSS → Tailwind CSS v4 to match the scaffold.skills-checklist.md Tailwind adaptation note rewritten to describe the v4 pattern (@import "tailwindcss", @theme directive, CSS-first tokens) instead of v3’s theme.extend..gitignore merged: scaffold’s Next.js / Yarn-PnP entries combined with project-specific entries (Supabase local dev, Claude local settings, !.env.example allowlist, editor/OS files).agent-launchpad-template reads the source verbatim and replicates field-for-field, formula-for-formula, interaction-for-interaction. Visual style continues to follow shadcn defaults (Constraint B). D-020 scopes the immediate paraphrase debt: components/admin/adoption-metrics.tsx is a placeholder covering ~15% of the source’s metrics surface and is deferred to a Session 6 rebuild under Constraint C. lib/analytics/events.ts (the data sink) and the localStorage-disclosure intro paragraph in app/(app)/admin/metrics/page.tsx survive the rebuild.admin.html’s intended real-data path rather than introducing a new feature. The source had sample-data and live-API code paths but isApiConnected was hardcoded false (line 1308); this project’s lib/analytics/events.ts (D-010) plays the role the source’s Apps Script integration was meant to fill. D-019’s Consequences extended in lock-step to cite the two new metrics CSV builders (Top Users, Clicks per Agent) as additional applications of the existing “Create Report wired to real download” exception (originally established for the calculator’s CSV export).SETUP.md and .env.example reconciled with the actual production deploy procedure (Session 7, Step 7). SETUP.md 3a clarifies dev vs. prod project naming; 3e is now explicitly framed as Stage 1 of a two-stage URL Configuration; 3f gains an “Alternative: production-only setup” sub-section for the dashboard add-user path; 4c narrows NEXT_PUBLIC_SITE_URL’s Vercel scope to Production only (Preview/Development intentionally fall through to VERCEL_URL / localhost) and adds the chicken-and-egg note, the VERCEL_URL fallback documentation, and the “preview deploys share the production Supabase project” caveat as the Phase 0/1 default; 4d is renamed to “Stage 2 of URL Configuration” and now also instructs updating Site URL (not just Redirect URLs); 4e/4f split the prior single deploy step into “First deploy” (which produces the prod URL) and “Set NEXT_PUBLIC_SITE_URL and redeploy”. .env.example’s NEXT_PUBLIC_SITE_URL block expanded to document the chicken-and-egg pattern, the Production-only Vercel scope, the three-step fallback chain, and a “DO NOT set VERCEL_URL manually” reminder.docs/AGENT_ARCHITECTURE.md plus this ADR plus the PROJECT_OUTLINE.md reorganization; subsequent sessions implement against the spec in dependency order. The deferred-to-roadmap list at the end of the architecture doc is the source of truth for what Phase 2 does NOT include.lib/llm/<vendor>/), vendor-prefixed model ids, single-case dispatcher, and bounded model picker all land in Phase 2 work item 1, so Phase 6 is sibling adapter implementation against an existing structure. Data Model section restructured into “Phase 1 shipped / Phase 2 shipped / Phase 2 remaining” buckets. Current status updated from “Phase 0 — Foundation” to mid-Phase 2.ebhhqndkitgiwunrgjyb and a prod project at ref knlnchvfjxchpbkuwtpp) by accident rather than by design; SETUP.md 4c documents single-project as the Phase 0/1 default. The dev project is retired and .env.local (gitignored) now points at the prod project. SETUP.md 4c’s “use a separate Supabase project for production if you want isolation” sentence remains valid as advice for future forkers; this project’s owner has chosen single-project explicitly.app/api/chat/route.ts (not server action — route handlers handle streaming bodies more cleanly and give us a curl-testable surface), Anthropic SDK on the server only with ANTHROPIC_API_KEY server-only per D-008, cost tracking on every call to a usage_events table per the CLAUDE.md AI Integration Rules non-negotiable, per-user rate limiting at ~20 messages/minute (Phase 7 will replace with a proper distributed rate-limit service if/when scale warrants), two-layer prompt-injection defense (system-prompt preamble + user-input <user_input> delimiter wrapping; no deny-list of phrases — structural defense is what every modern provider recommends), per-agent model id in agents.model (multi-provider abstraction deferred to Phase 6 per PROJECT_OUTLINE.md). 8a creates foundations only — chat UI is 8b, first native agent conversion is 8c, analytics promotion to Supabase remains downstream.AGENTS.md scaffolder file. Its Next.js 16 warning content was folded into the project-local note at the top of .claude/skills/nextjs.md per D-013.components/ui/button.tsx — shadcn components will be added deliberately via shadcn add <name> when actually needed, per D-015’s consequences.signInWithPassword server action (Session 7) — D-018 (2026-04-23) amended D-006 to “magic link is the sole auth method for Phase 1” but left the password form code in the tree as deferred-removal work. Session 7’s production deploy was the right time to follow through, since shipping deferred-removal code to a real URL would carry forward dead-on-arrival code. Surgical removal: drops passwordLoginSchema and signInWithPassword from app/(public)/login/actions.ts; drops the password input, label, “Sign in with password” submit button, the “(only if signing in with password)” hint, the orphaned error === invalid-credentials rendering branch, and the “If you have a password, use it instead” intro copy from app/(public)/login/page.tsx. Magic-link path untouched.CLAUDE.md, PROJECT_OUTLINE.md, DECISION_LOG.md, SETUP.md, skills-checklist.md.supabase/migrations/0001_initial_schema.sql.