A running record of architectural decisions for legalOS, with context, the decision, and the reasoning. New decisions are appended; old decisions are not edited — if a decision is reversed, add a new entry referencing the old one.
Entries D-001 through D-025 reference the project by its previous name, “Legal Department Launchpad Template” / legal-department-launchpad-template. Those are preserved verbatim per the standing rule that decision-log history is immutable. The rename to legalOS is recorded in D-026.
Format for each entry:
## D-NNN — Short title
Date: YYYY-MM-DD
Status: Accepted | Superseded by D-NNN | Reversed
Context: What situation forced the decision.
Decision: What we chose.
Reasoning: Why.
Alternatives considered: What else we looked at.
Consequences: What this commits us to.
Date: 2026-04-21 Status: Accepted
Context: The prior agent-launchpad-template was framed around a single use case (Commercial contract review). The goal is a holistic in-house legal department AI entry point covering multiple departments over time.
Decision: Build a single app that supports multiple legal departments — Commercial, M&A, Public Sector, GR&RA, Privacy at launch, with Products, Compliance, Litigation, IP on the roadmap.
Reasoning: Legal departments are multi-disciplinary. A single unified launchpad drives far higher adoption than five separate micro-apps. Shared infrastructure (auth, analytics, admin, productivity calculator) is built once. A new department is a row in a table, not a new repo.
Alternatives considered:
Consequences: Departments must be data-driven from Phase 1. Routing, nav, RLS policies, and admin must all understand “which department.”
Date: 2026-04-21 Status: Accepted
Context: The immediate use case is a single in-house legal department. The long-term possibility is a SaaS offering for multiple legal departments.
Decision: Build as a single-tenant app today, but carry organization_id on every relevant table from day one. Do not do full multi-tenant (signup flow, billing, tenant isolation tests) until/unless a SaaS decision is made.
Reasoning: Adding organization_id on day one is a two-line change per table; adding it later is a painful migration. Full multi-tenancy (Stripe, signup, isolation testing) is expensive and premature.
Alternatives considered:
organization_id entirely. Rejected — makes a future SaaS move a rewrite.Consequences: Every table that holds org-scoped data has organization_id. RLS policies check it even though there’s one organization today. Adding the signup/billing layer later is scoped, not architectural.
Date: 2026-04-21 Status: Accepted
Context: Agent configs (name, description, system prompt, model, department) could live in config files (YAML/JSON) or in the database.
Decision: Agents are database rows. Their metadata, system prompts, and version history live in Supabase.
Reasoning: A config-file approach means every new agent or prompt tweak is a PR + deploy. A database approach unlocks the Phase 5 agent admin UI where legal ops staff can create and edit agents themselves. That is a major adoption lever — when the people closest to the work can tune the tools without filing a ticket, adoption compounds.
Alternatives considered:
Consequences: System prompts are loaded at request time. They must be versioned so in-flight conversations don’t break mid-stream when a prompt changes. The admin UI (Phase 5) is now a required feature, not optional.
Date: 2026-04-21 Status: Accepted
Context: Three options for user access: (a) all users see all departments, (b) one department per user, (c) role-based with per-department permissions.
Decision: Option (c). Users have roles (super_admin, org_admin, dept_admin, user) and independent per-department access rows in a user_department_roles join table. A user can be a dept_admin for Commercial and a user for M&A.
Reasoning: Legal departments have real access patterns — M&A data is often walled off from the rest of the department; privacy data sometimes is; public sector work may be sensitive. Option (a) fails a basic legal-industry smell test. Option (b) is too restrictive — many lawyers legitimately work across departments.
Alternatives considered:
Consequences: Every query for department-scoped data filters by user_department_roles. Every RLS policy on a department-scoped table joins to that table. Navigation is filtered at the UI level for UX, and re-enforced at the DB level for security.
Date: 2026-04-21 Status: Accepted (after two reversals — see D-005a and D-005b below)
Context: Hosting, backend language, and framework choice.
Decision: Host on Vercel. Use Next.js 15 (App Router) for both frontend and backend (API routes + server actions). Use Supabase for database and auth. TypeScript end-to-end.
Reasoning: Vercel + Next.js + Supabase is the most paved-path stack for this shape of app. The user’s claude-templates library is already optimized for this exact stack, meaning ~24 of 25 skills apply directly. Server-side secrets are straightforward (Vercel env vars); streaming LLM responses are a first-class feature; preview deploys per branch are automatic; Supabase RLS provides a real security layer without running a separate auth service.
Alternatives considered:
Consequences: One deploy target, one language, one set of conventions. vercel-deployment.md and nextjs.md skills apply directly. Server actions are the default for mutations; route handlers for anything that needs a stable HTTP contract.
Briefly considered for the backend. Reversed in favor of Next.js API routes because a separate Python service adds complexity without current payoff, and because TypeScript everywhere reduces context-switching for solo development.
Briefly adopted when GitHub Pages was the planned host. Reversed when hosting moved to Vercel — Vite on static hosting was a workaround for not having a server, and the server is back.
Date: 2026-04-21 Status: Amended by D-018 (2026-04-23) — magic link only
Context: Initial authentication method for Phase 1.
Decision: Supabase Auth with email/password and magic link.
Reasoning: Fastest path to a working login for a single-org demo. Magic link reduces password-management friction for demos and internal pilots. Google and Microsoft SSO can be added later without changing the underlying auth layer.
Alternatives considered:
Consequences: SSO providers (Google, Microsoft) are added in a later phase. Auth flow, session handling, and the proxy (proxy.ts, per D-017) are designed so adding a provider is drop-in.
Amendment (2026-04-23 — see D-018): After shipping magic link in Session 3a and the email/password form in Session 3c, we re-evaluated whether to keep the password form in Phase 1 scope. Decision: drop it. Magic link alone is a complete auth solution and avoids the password-set / password-reset / strength-rules / rate-limiting sub-features a password form implies. See D-018 for the full reasoning.
legal-department-launchpad-templateDate: 2026-04-21 Status: Accepted (amended 2026-04-21)
Context: The project needs a repo name. Codename discussion floated Atrium, Aegis, Keystone, Chambers, Docket; working titles included legal-department-launchpad-demo and in-house-legal-department-template.
Decision: legal-department-launchpad-template.
Reasoning: “Legal department” scopes it to the target user. “Launchpad” inherits the mental model from the prior agent-launchpad-template. “Template” signals fork-ability. The AI-native positioning is conveyed in README and marketing copy rather than in the repo slug — a shorter name is easier to type, remember, and read in CLIs.
Alternatives considered: Atrium (memorable but opaque), legal-department-launchpad-demo (reads as throwaway), in-house-legal-department-template (too generic about “what” the template is), legal-department-ai-launchpad-template (originally accepted; see amendment).
Consequences: README and docs lean into “template” and “fork this to your org” framing. AI-native positioning is communicated in prose, not the slug.
Amendment (2026-04-21): Originally accepted as legal-department-ai-launchpad-template. Renamed to legal-department-launchpad-template to shorten the slug and avoid “AI” becoming dated vocabulary in the repo name itself. The GitHub repo was renamed accordingly; the old URL continues to redirect.
Date: 2026-04-21 Status: Accepted
Context: Native agents require calling the Anthropic API. There are three ways to do it: from the browser with a user-supplied key (BYOK), from the browser with a bundled key (insecure), or server-side only.
Decision: All Anthropic API calls happen server-side. The ANTHROPIC_API_KEY environment variable is server-only and never prefixed with NEXT_PUBLIC_.
Reasoning: With Vercel hosting, we have a real server, so the temporary workaround of BYOK for static hosting no longer applies. Server-side calls give us rate limiting, cost tracking, prompt-injection defense centralized in one place, and a single place to rotate keys.
Alternatives considered:
Consequences: All chat routes are route handlers or server actions. Client components call the server, not Anthropic directly. Cost tracking, rate limiting, and prompt-injection defense happen in one place.
Date: 2026-04-21 Status: Accepted
Context: RLS is optional in Postgres/Supabase. Many teams turn it on late or partially.
Decision: Every table in this project has RLS enabled from the moment it is created. Every table has explicit policies before any data is inserted. A table with no policies has zero access — which is the safe default.
Reasoning: Defense in depth. If the proxy (proxy.ts) is misconfigured, if a server action forgets a role check, if a client is compromised — RLS is the last line that stands. Retrofitting RLS onto a table that already has data is a minefield.
Alternatives considered:
Consequences: Every migration that creates a table is paired with RLS policies. RLS policies are tested with positive and negative integration tests.
Date: 2026-04-21 Status: Accepted
Context: The prior agent-launchpad-template uses localStorage for analytics. The new project could start with real backend analytics from Phase 1.
Decision: Phase 1 keeps localStorage analytics (inherited from the prior template’s pattern). Phase 2 promotes to a Supabase analytics_events table when the real shape of events is clearer from a working system.
Reasoning: Premature schema design for analytics tends to produce tables you regret. Running Phase 1 with localStorage gives us 1-2 weeks of real-world event shapes to observe before committing to a schema. The migration from localStorage to Supabase is small and localized.
Alternatives considered:
Consequences: An analytics migration is on the Phase 2 checklist. The abstraction in lib/analytics/ is designed so the storage backend is swappable without touching the call sites.
Date: 2026-04-21 Status: Accepted
Context: The claude-templates library has 25 skill files. Not all apply immediately.
Decision: Copy 11 skills in Phase 0 (nextjs, react-patterns, tailwind, ui-patterns, responsive-design, ux-writing, web-accessibility, environment-management, vercel-deployment, frontend-security, infra-security). Add 5 more in Phase 1 (supabase, database-patterns, database-security, backend-security, api-security). Add 2 more in Phase 2 (anthropic-api, prompt-engineering). Add the remaining analytics, model-abstraction, eval, observability, cost-tracking, ci-cd, and possibly mcp-development skills in Phases 6 and 7.
Reasoning: Copying all 25 on day one buries the signal. Copying them as they become relevant keeps .claude/skills/ focused and encourages re-reading when they matter.
Alternatives considered: Copy all 25 at once. Rejected — noise over signal.
Consequences: Every phase has an explicit “skills to add this phase” line item. Skill template sync (the convention from the claude-templates repo) runs at the end of every phase.
Date: 2026-04-21 Status: Accepted
Context: The prior template already established this convention; restating it here for clarity.
Decision: Six docs are kept current throughout the project: README.md, CLAUDE.md, PROJECT_OUTLINE.md, DECISION_LOG.md, SETUP.md, CHANGELOG.md. A change is not “done” until affected docs are updated.
Reasoning: Documentation debt compounds faster than code debt on solo-developer projects with AI assistance. The cost of keeping docs current is small per-change; the cost of reconstructing intent three months later is enormous.
Alternatives considered: Less doc discipline. Rejected — already learned this lesson on prior projects.
Consequences: Every PR/commit that changes product behavior updates relevant docs. Every phase ends with a sync back to the claude-templates library.
Date: 2026-04-22 Status: Accepted
Context: Phase 0 Session 2’s create-next-app@latest invocation installed Next.js 16.2.4 and React 19.2.4. CLAUDE.md, PROJECT_OUTLINE.md, and README.md had previously stated “Next.js 15” based on the last version the author had been using elsewhere.
Decision: Accept Next.js 16. Update CLAUDE.md, PROJECT_OUTLINE.md, and README.md so the docs match the scaffold. D-005 (full-stack Next.js on Vercel) stands; only the version detail is superseded here.
Reasoning: Starting a brand-new template on a lagging major is debt we don’t want to accrue. Next.js 16 is current stable, the scaffolder’s default, and has a supported upgrade path. We have no production dependency requiring a pin to 15, and the marginal breaking changes are survivable in Phase 0.
Alternatives considered: Downgrade by pinning next@^15 and reinstalling — preserves the doc claim but starts us one major behind immediately. Rejected.
Consequences: AI-assisted code may need to consult Next.js 16 docs for APIs that differ from 15 (Cache Components, updated PPR and caching conventions, etc.). The nextjs.md skill gets a “Next.js 16 specifics” section when copied in Step 5, absorbing the warning content from the scaffold’s AGENTS.md (which is then deleted).
Date: 2026-04-22 Status: Accepted
Context: The same Session 2 scaffold installed Tailwind CSS v4. v4 is architecturally different from v3: CSS-first configuration via @import "tailwindcss" and the @theme directive, rather than a JS tailwind.config.ts with theme.extend. This project’s theme-preset approach (Carbon / Modern / Minimal / Custom), ported from the prior agent-launchpad-template, was designed against v3.
Decision: Accept Tailwind v4.
Reasoning: v4 is the ecosystem direction. Its CSS-variable-first model and @theme directive map more cleanly onto our theme-preset approach than v3’s JS config did — CSS variables were always the underlying mechanism, and v4 makes them first-class. Downgrading to v3 to preserve compatibility with a claude-templates skill file written against v3 is backwards.
Alternatives considered: Downgrade to Tailwind v3 for direct carry-over of the prior template’s theme tokens. Rejected — v3 is a dead end for new work.
Consequences:
.claude/skills/tailwind.md (applied in Step 5) is rewritten to describe v4’s @theme directive and CSS-variable pattern. skills-checklist.md is updated in lock-step so the checklist’s adaptation guidance matches what Step 5 will actually write.claude-templates/skills/frontend/tailwind.md is written against v3. Flagged as an explicit Phase 0 end-of-phase skill-sync item: generalize the v4 pattern back to the portable template.Date: 2026-04-22 Status: Accepted
Context: shadcn/ui 4.x (4.4.0 at the time of Phase 0 Session 2) restructured its CLI and default component model. The --defaults preset for init expands to --template=next --preset=base-nova, which targets shadcn’s own Base UI primitive library (@base-ui/react) rather than the Radix primitives shadcn used for years. Session 2’s scaffold accepted --defaults and landed on Base UI.
Decision: Accept Base UI.
Reasoning: Same logic as D-013 (Next.js 16) and D-014 (Tailwind v4) — for a template that will be long-lived, ecosystem direction matters more than the comfort of the previous default. shadcn’s own docs and future components target Base UI; Radix is being framed as a legacy option. Starting a new template on the legacy option is debt we don’t want to accrue. Base UI and Radix have comparable consumer APIs, so the replatform cost later would be higher than the adaptation cost now.
Alternatives considered: Re-init with -b radix to stay on Radix primitives. Rejected — Radix was shadcn 3.x’s default; shadcn 4.x is moving to Base UI.
Consequences: components.json records "style": "base-nova" and Base UI is installed as @base-ui/react. Any subsequent shadcn add <component> pulls the Base-UI-flavored variant. If a specific primitive we need is not yet matched in Base UI, we can consume Radix directly as a peer dep for that one component rather than replatforming.
lib/ as home for actions and hooksDate: 2026-04-23 Status: Accepted
Context: The nextjs.md skill recommended a broader top-level layout with actions/, hooks/, types/, and styles/ all at the repo root. CLAUDE.md’s original directory structure was narrower — app/, components/, lib/, config/, supabase/, .claude/, public/ — and did not specify where server actions, custom hooks, or TypeScript types should live.
Decision: Keep CLAUDE.md’s narrow set. Server actions live in lib/actions/. Custom hooks live in lib/hooks/. Types live inline next to the code that uses them (no top-level types/). Add styles/ at the top level, because global CSS grows quickly in a template.
Reasoning: Single source of truth wins over anticipated discovery. For AI-assisted solo development, a doc that accurately describes the repo is more valuable than a layout that self-announces. Promote lib/actions/ or lib/hooks/ to top-level the first time either exceeds ~8 files.
Alternatives considered: Pre-create actions/, hooks/, types/ at root now with .gitkeep files — rejected, empty dirs are noise. Accept the full nextjs.md recommendation wholesale — rejected, CLAUDE.md is the project’s authority, not the skill.
Consequences: CLAUDE.md remains the authoritative directory map. The nextjs.md skill differs from this project’s choice; that is acceptable because the skill is a portable template and this is a project. CLAUDE.md’s directory structure is updated in lock-step with this entry to list styles/, lib/actions/, and lib/hooks/.
Date: 2026-04-23 Status: Accepted
Context: Session 3b’s first npm run build produced a Next.js 16 deprecation warning: “The ‘middleware’ file convention is deprecated. Please use ‘proxy’ instead.” Per node_modules/next/dist/docs/01-app/01-getting-started/16-proxy.md, Next.js 16 renamed the feature — API unchanged, only the filename (middleware.ts → proxy.ts) and the exported function name (middleware → proxy) change.
Decision: Use proxy.ts at repo root. Rename the exported function to proxy. Keep internal helper filenames like lib/supabase/middleware.ts for filename stability; update docstrings and prose to say “proxy” where the reference is to the Next.js file convention.
Reasoning: Template-repo discipline. The deprecation is current as of Next 16.2.4 and may become a hard removal in a future major. Shipping a fork-able template on the deprecated convention would be a quiet gift of tech debt to every forker. Per the project-local adaptation note at the top of .claude/skills/nextjs.md: “Heed deprecation notices.”
Alternatives considered: Ship on the deprecated middleware.ts. Build still works with only a warning. Rejected — template expected to live multiple Next.js majors, and forkers who upgrade will hit the removal first.
Consequences:
middleware.ts → proxy.ts at repo root; exported function renamed.lib/supabase/middleware.ts and createSupabaseMiddlewareClient keep their names; their docstrings clarify the intended use site is proxy.ts.PHASE_0_SYNCBACK_TODO.md now tracks an additional update to the upstream skills/frontend/nextjs.md: rename its “Middleware” section to “Proxy” (and keep a cross-reference for Next.js ≤15 users). Bundled with D-016’s layout-flexibility note under the same sync-back item.Date: 2026-04-23 Status: Accepted
Context: D-006 called for email/password + magic link. After shipping magic link in Session 3a, we evaluated whether to add the password form.
Decision: Ship magic link only. Drop email/password from Phase 1 scope.
Reasoning: Magic link alone is a complete auth solution. It handles first-touch signup, returning users, and password-forgetting users in a single flow — no password reset plumbing ever needed. Shipping password auth would add a password-set flow, password reset flow, password strength rules, and rate limiting considerations. Scope discipline wins; every phase that skips a feature is a phase that ships faster. Forkers who need SSO or password auth can add it against the existing @supabase/ssr foundation.
Alternatives considered:
Consequences: D-006 is amended. SETUP.md, README.md, CLAUDE.md, and PROJECT_OUTLINE.md references to email/password have been updated or removed in the same commit. SSO providers (Google, Microsoft) remain on the roadmap for a later phase and will be added against the same @supabase/ssr foundation. The email/password form code shipped in Session 3c (app/(public)/login/page.tsx, app/(public)/login/actions.ts) remains in the tree at the time of this ADR; a follow-up commit to remove it is a reasonable next action but is not bundled here — this commit is docs-only.
Date: 2026-04-25 Status: Accepted
Context: Session 5 shipped a productivity calculator at /admin/calculator that did not match the upstream agent-launchpad-template/admin.html original. The port was built against a paraphrased description of the feature (a four-input form: team size, hours/person/week, hourly rate, platform cost) rather than the original (a multi-associate, multi-task workspace with per-row derived values, per-associate totals, grand totals, ROI, info modals, and CSV export). The result was major functional drift — the new calculator was a different feature wearing the same name. The fix session rebuilt the calculator to match the original; this ADR codifies the rule that prevents the failure mode.
Decision: Adopt Constraint C — Functional parity with originals. When a feature is being ported from an upstream reference, read the original first and replicate field-for-field, formula-for-formula, interaction-for-interaction. Visual style follows Constraint B (shadcn defaults). Behavior follows the originals exactly unless an explicit exception is documented in this log.
The full rule, including how to apply it on a per-session basis, is recorded in CLAUDE.md under the “Reference Ports (Constraint C)” section. This entry is the authoritative record of why the rule exists.
Reasoning: Paraphrased descriptions of UX leak content. The reference is the source of truth for behavior; even careful paraphrases of multi-component, formula-driven features tend to omit fields, smooth over edge cases, or “improve” interactions. The originals were authored, tested, and shipped with intent — replicating them faithfully is cheaper than reinventing them, and produces a port a forker can recognize as “the same feature, in the new stack.”
The corollary is that visual style is the only axis where deviation is encouraged: the originals are styled with project-specific tokens that this template does not aspire to inherit. Constraint B already governs that axis. Constraint C governs the behavioral axis.
Alternatives considered:
Consequences:
../agent-launchpad-template/) begins with a verbatim read of the source. The plan presented to the user names the specific source files and line ranges that informed it.loginOverlay + sessionStorage admin_authenticated pattern is replaced), and (b) the “Create Report” button is wired to a real CSV download rather than the original’s alert('Report export functionality coming soon!') placeholder. The Session 6 metrics rebuild extends exception (b) to the two additional “Create Report” buttons in the original (Top Users footer, line 1022; Clicks per Agent footer, line 1110) — both wired to CSV downloads (top_users_<period>_<mode>.csv, clicks_per_agent_<period>_<mode>.csv) under the same rationale.Date: 2026-04-25 Status: Accepted
Context: The audit conducted during the Session 5 fix (the same fix that established Constraint C in D-019) revealed that components/admin/adoption-metrics.tsx was built against a paraphrased description of the original admin.html metrics surface, rather than against the original verbatim. The paraphrased view covers roughly 15% of the source’s functionality: it shows top-5 agents by all-time click count and a 7-day daily bucket of clicks. The source (agent-launchpad-template/admin.html lines ~925–1112) provides far more — Active Users and Total Interactions metric cards with trend pills, a Top Users table with rank badges, week/month/year time-period selectors throughout, clickable user and agent names that open detail modals showing per-period interaction history, and a bar chart of activity over time with gradient fills. The placeholder also lacks a Create Report button. This is the same failure mode that produced the broken 4-input productivity calculator addressed in the Session 5 fix.
Decision: Commit the paraphrased adoption-metrics view as a placeholder (the BACKFILL commit immediately preceding this entry). Rebuild it in Session 6 against the original verbatim under Constraint C. Preserve lib/analytics/events.ts (the data sink is correctly factored — committed in 6375d76, shaped close to the eventual Phase 2 analytics_events schema per D-010) and the localStorage-disclosure intro paragraph in app/(app)/admin/metrics/page.tsx (it accurately discloses the Phase 1 limitation and was written to survive the rebuild). Replace everything else inside components/admin/adoption-metrics.tsx.
Reasoning: Same failure mode as the calculator, same remedy. Constraint C (D-019) exists precisely to prevent this drift, and the rebuild pattern is now established. Making D-020 explicit ahead of Session 6 — rather than folding the metrics rebuild into D-019 — gives Session 6 a self-contained scope statement it can reference at plan-review time without re-deriving what’s in scope. It also creates a clear precedent for how future paraphrase debt gets recorded: one ADR per affected surface, scoped to that surface, with explicit lists of what survives and what gets replaced.
Alternatives considered:
adoption-metrics.tsx from the working tree and ship the route with a “coming soon” placeholder. Rejected — the paraphrased version is at least functional against real localStorage events and serves a useful purpose for forkers exploring the template. Holding the tree empty until Session 6 trades a small amount of present utility for nothing in return.Consequences:
components/admin/adoption-metrics.tsx field-for-field against agent-launchpad-template/admin.html lines ~925–1112, in accordance with Constraint C. The Session 6 plan-review must enumerate the original’s fields, tables, modals, time-period selectors, and chart contents, and present them to the user before any code is written — exactly as the Session 5 fix did for the calculator.lib/analytics/events.ts is preserved unchanged across the rebuild. If Session 6 needs to extend the event shape (e.g., to support per-user interaction history for the user detail modal), the changes go there, not in a parallel data layer. D-010’s Phase 2 plan still applies.app/(app)/admin/metrics/page.tsx (the localStorage disclosure referencing D-010) is preserved across the rebuild.app/(app)/admin/page.tsx) is currently aligned with the paraphrased view. After the Session 6 rebuild it should be updated to match the rebuilt surface; bundle that update into the Session 6 fix commit, not as a separate commit.Date: 2026-04-26 Status: Accepted
Context: The original agent-launchpad-template/admin.html was architected to support both sample data and a live analytics integration via an Apps Script backend (APPS_SCRIPT_URL, isApiConnected, loadMetrics / loadTopUsers / loadClicksByAgent / loadUserDetails / loadAgentDetails). The integration was never completed — isApiConnected is hardcoded false at line 1308, and every loadXxx API function falls through to its updateXxx sample-data sibling on if (!isApiConnected). In effect, the original always renders sample data, but not because sample-data-only was the design intent; it’s an artifact of an incomplete integration.
This project, in contrast, has a real analytics data path: lib/analytics/events.ts writes AgentClickEvents to localStorage on every external-agent click (D-010). That data is real, present, and matches the shape the original’s API path was designed to consume.
Decision: Implement the real-data path the original was architected for, using our existing localStorage analytics events (D-010) as the real-data source. Sample data remains available as a demo mode. The Session 6 adoption metrics rebuild ships with a single user-visible toggle that switches between the two sources.
Reasoning: This is not a Constraint C deviation. It is the completion of the original’s stated architecture, adapted to this project’s data path. Constraint C requires field-for-field parity of behavior; the original’s behavior includes a real-data mode that simply was never wired. Wiring it honors the original’s intent more faithfully than copying the unfinished state. The toggle gives forkers both surfaces — a demo mode that matches the original’s visible behavior verbatim, and a real-data mode that actually exercises their localStorage events.
Alternatives considered:
Consequences:
dataSourceText), real mode says something like “Showing your agent click events from this browser’s localStorage.”app/(app)/admin/metrics/page.tsx, preserved per D-020) and the inline mode-status copy serve different purposes: the page-level paragraph documents the Phase 1 limitation of localStorage-only events; the inline mode-status documents which data source is currently displayed. Both stay.lib/analytics/events.ts. The bucketing helpers live alongside the metrics components; the data sink itself is unchanged per D-020.Date: 2026-04-27 Status: Accepted
Context: D-014 and D-015 stated that the sync-back from this project’s lessons to the portable claude-templates library should happen before Phase 2 begins. PHASE_0_SYNCBACK_TODO.md plus Constraint C (D-019), the commit-consistency rule, and the session-close triple-check protocol — all developed across Sessions 4–7 — would target that sync. Phase 2 (native agent runtime) is now the next priority over the sync.
Decision: Proceed with Phase 2 first; defer the sync-back to a dedicated session after Phase 2 ships.
Reasoning: Project momentum is on Phase 2; native agents are the headline feature that makes the project demonstrable. Sync work, while genuinely valuable, can be done after Phase 2 without blocking any Phase 2 needs. Trade-off accepted: the templates library lags this project by Phase 2’s worth of lessons; the next project using the templates inherits pre-Phase-2 patterns until the sync session lands.
Alternatives considered:
Consequences:
PHASE_0_SYNCBACK_TODO.md remains as a tracked open item. It is not closed by this ADR.PHASE_0_SYNCBACK_TODO.md items AND the Constraint C / commit-consistency / session-close protocol additions developed during Sessions 4–7.claude-templates/skills/* and claude-templates/CLAUDE.template.md.Date: 2026-04-27 Status: Accepted
Context: Phase 2 begins; native agents need a runtime. Decisions about streaming, route handler vs. server action, cost tracking, rate limiting, and prompt-injection defense were locked in by the project owner before Session 8a. This ADR records them as a bundle so reviewers can see the architectural shape of the native agent runtime in one place rather than reconstructing it from individual commits.
Decision: Bundled commitments for the native-agent runtime, all locked in for Session 8a:
app/api/chat/route.ts. Route handlers handle streaming bodies more cleanly than server actions and give us a stable HTTP contract that smoke tests (curl) can hit directly.@anthropic-ai/sdk. The API key lives only in ANTHROPIC_API_KEY (server-only, never NEXT_PUBLIC_) per D-008. Client components never call Anthropic.usage_events capturing tokens_in, tokens_out, model, user_id, agent_id, conversation_id, and created_at. Non-optional per the CLAUDE.md “AI Integration Rules” non-negotiables.system_prompt is wrapped at request time with a standard preamble that tells the model user content is data, not instructions, and that it must not reveal the system prompt. (b) User input is validated server-side: max length (e.g., 10,000 chars), Zod-typed, and structurally delimited inside the message sent to the model.agents.model (the column already exists in the 0001 schema). Multi-provider abstraction is deferred per PROJECT_OUTLINE.md Phase 6.Reasoning: Streaming is the modern UX expectation for chat — deferring it to Phase 7+ would force a rewrite when the chat UI lands in 8b. Route handlers are a more natural home for streaming responses than server actions; they also give 8a a curl-testable surface so the runtime can be smoke-tested without a UI. Cost tracking from day one is a CLAUDE.md non-negotiable, and the cheapest moment to wire it is when the very first Anthropic call ships. Rate limiting prevents both abuse and accidental cost spikes during demos and pilots; absence of a rate limit on a route that calls a paid API is irresponsible. Prompt-injection defense is non-optional given the legal-domain context — attorney work product is the worst possible substrate for a leakable system prompt or a tool-use exfiltration. The bundled-commitments framing matches the pattern D-020 and D-021 established for Session 6.
Alternatives considered:
useActionState-style returns but the contract is less standard than a route handler with a streaming Response, and they’re harder to smoke-test from curl. Route handlers win for a runtime that needs an HTTP contract.Consequences:
conversations, messages, usage_events with full RLS), lib/anthropic/ helpers (client wrapper, streaming, cost calc, prompt-injection preamble), per-user rate limiter, and the app/api/chat/route.ts handler. No UI lands in 8a — chat UI is 8b. Converting an existing Commercial agent to native is 8c.department_id before any Anthropic call.agents.system_prompt becomes the stored prompt; the runtime wraps it with the prompt-injection preamble at request time. No DB column changes are required to the existing agents table — 0001 already provisions system_prompt and model columns and the agents_native_requires_prompt check constraint.lib/anthropic/pricing.ts as a hardcoded table keyed on model id. Updating rates is a code change. Multi-provider normalized cost (Phase 6) replaces this with a per-provider rate registry.curl smoke test can run end-to-end. That seed (one row, minimal system prompt) is in scope for 8a; 8c will replace it by promoting one of the six existing Commercial external agents to native.Date: 2026-04-28 Status: Accepted
Context: The project initially set up two Supabase projects — a dev project (ref ebhhqndkitgiwunrgjyb, named legal-department-launchpad-template) for local development, and a prod project (ref knlnchvfjxchpbkuwtpp, named legal-launchpad-prod, now renamed legal-department-launchpad-template) wired up to Vercel. SETUP.md 4c documents single-project as the Phase 0/1 default and a separate prod project as optional isolation. The two-project setup happened by accident rather than by design, and during Session 8b’s smoke test the seed data, migrations, and user provisioning had drifted between the two — surfacing as friction the project’s solo-developer scale does not justify.
Decision: Retire the dev project. Local development and Vercel both point at the prod project (knlnchvfjxchpbkuwtpp). The single-tenant deployment now uses one Supabase project across all environments.
Reasoning: For a solo demo at Phase 2 scale, the operational overhead of maintaining two synchronized projects exceeds the “test against real data” risk of using one. SETUP.md 4c already documents single-project as the default; this ADR aligns actual practice with documented default rather than introducing a new pattern.
Alternatives considered:
Consequences:
.env.local was updated to point at the prod project (gitignored, not committed); the dev project’s keys are no longer in active use anywhere in this repo.PHASE_0_SYNCBACK_TODO.md and any future templates sync session should reflect that the template can document either path; neither is the canonical right answer.ebhhqndkitgiwunrgjyb should not be reused — re-provision rather than resurrect.legal-launchpad-prod → legal-department-launchpad-template so the Supabase project name matches the GitHub repo name (D-007). Project ref knlnchvfjxchpbkuwtpp is unchanged — Supabase refs are immutable.Date: 2026-04-28 Status: Accepted
Context: Phase 2 was originally scoped as “native agent runtime” — Sessions 8a (foundations) and 8b (chat UI) landed cleanly and validated end-to-end against a single Test Smoke Agent. Session 8c was originally scoped to promote one of the six Commercial external agents to native, with a hardcoded system prompt and a single migration path from external card to native chat. During Session 8c the project owner surfaced a substantially larger product vision: native agents should be user-owned, user-configurable, extensible workspaces with attached references, configurable tools, multi-format output (markdown + Word in v1), and a forward path to multi-vendor model support. The architecture document at docs/AGENT_ARCHITECTURE.md is the design specification produced in 8c and is the spec subsequent Phase 2 sessions implement.
Decision: Accept the scope expansion. Phase 2 becomes a multi-session arc that implements docs/AGENT_ARCHITECTURE.md in dependency order. Session 8c lands the architecture document, this ADR, the PROJECT_OUTLINE.md reorganization, and the changelog entry — no code, no schema changes, no migrations land in 8c. Subsequent sessions implement, sequenced when picked up rather than pre-numbered.
Reasoning: The original Phase 2 framing produced a real working surface (Sessions 8a/8b smoke-tested end-to-end through Session 8b’s verification against the live runtime) and validated that the runtime substrate is sound. The scope expansion reflects requirements surfaced through detailed design conversation: the product is genuinely more useful as a configurable workspace than as a launchpad with a static native chat tab. Doing the architecture work first means subsequent sessions have a target spec to implement against, avoiding the failure mode where individual sessions make ad-hoc architectural decisions that compound into rework. The architecture document is deliberately long and prose-heavy because every Phase 2 session after 8c will read it as their spec; that one-time investment in writing a real design document amortizes across many implementation sessions.
Alternatives considered:
docs/AGENT_ARCHITECTURE.md is the right home; PROJECT_OUTLINE.md gets the Phase 2 reorganization that points at the architecture doc as the spec.Consequences:
docs/AGENT_ARCHITECTURE.md is the spec subsequent Phase 2 sessions implement. Treat it as a living document — refinements during implementation update the doc, not just the code, so the spec and the codebase stay aligned.PROJECT_OUTLINE.md Phase 2 is reorganized in this session to mirror the architecture’s implementation phasing list. The original “Phase 2 — Native Agent Runtime (1–2 weeks)” framing is replaced with a multi-session arc shape. Phase 5 (Agent Admin UI) and Phase 6 (Model Abstraction) are also touched: Phase 5 is reframed because user-level agent CRUD now lands in Phase 2 and Phase 5 becomes the residual admin-only surface; Phase 6 is reframed because the directory structuring and dispatcher land in Phase 2’s first work item, so Phase 6 is “ship sibling adapters” against an existing structure.docs/AGENT_ARCHITECTURE.md is the source of truth for what Phase 2 does NOT include. Items there have explicit re-evaluation triggers; no item is “deferred forever,” only “deferred until X surfaces.”usage_events will gain cache_creation_tokens and cache_read_tokens columns when the relevant migration session lands; cost analytics that ignored the cache layer would systematically misreport spend on subsequent turns of every conversation.Date: 2026-04-30 Status: Accepted (supersedes the slug commitment in D-007)
Context: The project has carried “Legal Department Launchpad Template” / legal-department-launchpad-template as its name since D-007 (2026-04-21). Through Sessions 8a–8l the product surface has grown well past “launchpad” — native chat with prompt caching, configurable per-agent tools (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.
Decision: Rename to legalOS. Display name “legalOS” (camelCase, lowercase ‘l’); slug / package name / repo name “legalos” (all lowercase). Browser tab title pattern: <page> · legalOS. Header on all surfaces: legalOS alone, no tagline.
Reasoning: The new framing — “an operating system for legal departments” — is a more accurate description of what’s been built. “Launchpad” describes a single capability; “operating system” describes the substrate. Choosing the substrate framing now sets the right reader expectation for the docs, the README, and the marketing surface that this project will eventually need.
The casing choice (camelCase legalOS / lowercase legalos) follows the loose convention of nodeJS, iOS, macOS — domain-name and command-line cases use lowercase, brand cases use the camel form. Consistent with how those names are written in user-visible chrome (tab title, headers) versus identifiers (package names, URLs, slugs).
Alternatives considered:
legalOS form reads as one identifier.iPhone, iPad, nodeJS).legal) plus categorical (OS).Consequences:
siteConfig.siteTitle and the layout’s metadata title template carry the brand text.legalOS (default) and <page> · legalOS (per-page) via app/layout.tsx’s metadata.title.template.package.json name field flips to legalos.supabase/migrations/0001_*.sql through 0012_*.sql, supabase/seed/0003_*.sql) preserve their original legal-department-launchpad-template headers as historical run artifacts — they document what the project was called when each migration was authored and applied. Future migrations use the new name.legal-department-launchpad-template) is superseded by this entry. The GitHub repo, Vercel project, and Supabase project (ref knlnchvfjxchpbkuwtpp, immutable per D-024) all rename to legalos. GitHub keeps a redirect from the old URL.brand_name column overriding siteConfig.siteTitle) is deferred — not in scope here. Forks today inherit the legalOS branding by default; the option to override per-tenant lands when multi-tenancy actually ships.Date: 2026-05-02 Status: Accepted
Context: Sessions 9a–9e implement a UI/UX overhaul (“Aperture”). The Aperture design’s department model has Public Sector absorb the scope previously carved off into Government Relations & Regulatory Affairs (GRRA), and adds a new General Tools department as the home for utility / general-purpose agents that don’t belong to a specific practice area. Session 9b is the data-layer half of that restructure — schema/UI alignment in 9c–9e depends on the database already being in the new shape. The DB pre-9b: 8 departments in order Commercial, Public Sector, GRRA, M&A, Privacy, Product, Compliance, Operations. The DB post-9b: 8 departments in order Commercial, Public Sector, M&A, Privacy, Product, Compliance, Operations, General Tools.
Decision:
"Government relations, regulatory affairs, public-sector contracts, and policy advocacy.". Delete (do not move) GRRA’s only agent — the Blank Agent template blank-agent-grra seeded by 0004 — because Public Sector already has its own Blank Agent template (blank-agent-public-sector); moving GRRA’s would have created two functionally identical templates in the same department, defeating the merge. Pre-flight inspection (run before applying the migration) is the verification that GRRA contains exactly one row with the expected slug; the migration itself is plain SQL and trusts that pre-flight rather than re-checking with PL/pgSQL guards."general purpose agentic tools" (lowercase, no period — user-specified exact string, deliberate divergence from the sentence-case + period convention used by the other seven department descriptions). Slug general-tools. Blank Agent template seeded for it like every other department. Existing org_admin / super_admin users auto-grant dept_admin via the role-based generic clause established in 0012.departments.is_active exists, app code does not filter on it (getAccessibleDepartments, getDepartmentIfAccessible, the RLS policy departments_read_same_org) — soft-deleting GRRA would leak it through slug navigation and any inner join on user_department_roles → departments. Hard delete is enforced safely by the FK design: agents.department_id ... on delete restrict blocks the DELETE until agents are moved out, and user_department_roles.department_id ... on delete cascade removes membership rows automatically.supabase/migrations/0013_grra_to_public_sector_and_general_tools.sql as plain SQL inside a single BEGIN; … COMMIT; transaction — no do $$ ... end $$ block, no declared variables, no defensive RAISE EXCEPTION guards (pre-flight is the verification). Slug-based subqueries ((select id from departments where slug = 'grra')) replace ID-into-variable lookups. One-shot prod backfill, not a re-runnable seed; ON CONFLICT DO NOTHING is used where it naturally fits (department insert, role grant, Blank Agent insert) but no extra branching for “already-applied” cases.Reasoning:
GRRA’s original scope — “lobbying, regulatory monitoring, policy advocacy” — overlaps heavily with Public Sector’s existing mandate. The Aperture design treats this as one practice area and folds the GRRA flavor into Public Sector’s description. Maintaining a separate GRRA department adds a column to the launchpad without a meaningfully different agent set behind it.
General Tools fills a real gap: utility agents (a generic Blank Agent, a “summarize this” assistant, a translation helper) don’t belong to any specific practice area and were previously parked under whichever department happened to be open. Naming the bucket explicitly makes the launchpad’s IA honest about where these live.
The lowercase “general purpose agentic tools” description is a user-chosen deviation from the sentence-case + period convention. Captured here so future copy reviews don’t flag it as a typo and “fix” it.
Alternatives considered:
is_active = false. Rejected — would leak through slug navigation and inner-join queries because neither app code nor the RLS policy filters on departments.is_active. Soft-delete would require app-code and policy changes to be safe; bigger scope than 9b warrants.Consequences:
0013_grra_to_public_sector_and_general_tools.sql is the prod backfill. The reverse-block at the bottom of the file documents the rollback shape (manual restoration of GRRA + Public Sector description + sort_order shifts), but does not auto-restore agents that were moved from GRRA → Public Sector — those would need a manual decision on whether the Public-Sector copy has since been used.supabase/seed/0004_blank_agents.sql (header comment refreshed to list the post-9b 8 departments — the file’s for v_dept in select … from departments loop is generic and picks up General Tools without code changes) and config/departments.ts (data-only mirror of the canonical 8-department list, no runtime consumer).CLAUDE.md (Project Overview line listing the eight departments), SETUP.md (the manual SQL fallback in 3f, plus the surrounding “all five” prose), lib/metrics/sample-data.ts (header comment listing the four departments the invented agents belong to). Historical phase-description content in PROJECT_OUTLINE.md (Phase 1 Shipped table; Phase 4 plan) and docs/AGENT_ARCHITECTURE.md (the “five starter departments, no AI department” section describing Phase 2’s design-time scope) is preserved verbatim per the same rule that preserved migration headers in D-026 — phase descriptions stay as-is, decision-log entries record the changes.Date: 2026-05-02 Status: Accepted (supersedes D-022)
Context: Session 8g (D-022) dropped next/font/google (Geist + Geist_Mono) and replaced the font tokens with system-ui stacks (-apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, … for sans; ui-monospace, "SF Mono", Menlo, … for mono). The motivation at the time was avoiding the Google Fonts runtime dependency and matching whatever the user’s OS already provided. With Sessions 9a–9e (Aperture UI/UX overhaul) underway, the Aperture design’s typography is load-bearing — a 52px hero headline, 10–11px mono caption labels, and a tight-tracking + variable-weight rhythm that depends on Inter Tight specifically. System-ui fonts render meaningfully differently across operating systems (San Francisco on macOS, Segoe UI on Windows, Roboto on Android), and at the design’s small sizes the differences become legibility-affecting, not just stylistic.
Decision: Reverse D-022. Self-host two fonts via next/font/local:
@fontsource-variable/inter-tight (npm), copied into app/fonts/inter-tight-variable-latin.woff2 plus the SIL OFL 1.1 LICENSE.@fontsource-variable/geist-mono (npm), copied into app/fonts/geist-mono-variable-latin.woff2 plus the SIL OFL 1.1 LICENSE.Aperture’s spec calls for IBM Plex Mono; Geist Mono is substituted for three reasons: (a) we don’t want IBM-branded font assets in this project, (b) Geist Mono lives in the same humanist-mono neighborhood as Plex (similar x-height and weight rhythm, designed for UI density), and (c) Geist Mono pairs naturally with Inter Tight — both have clean lowercase forms designed for small sizes.
localFont declarations in app/layout.tsx expose two CSS variables (--font-display for Inter Tight, --font-mono for Geist Mono). app/globals.css’s @theme inline block references those variables in --font-sans, --font-mono, and --font-heading with system-ui, sans-serif / ui-monospace, monospace fallbacks.
Reasoning:
The pivot is that the project now has a commissioned design (Aperture, Session 9a) with specific typographic intent. Deferring to system fonts was a Phase-1 speed call when no specific design was in play; with a design in hand, type rendering needs to be consistent across platforms and weights — including the non-standard weight 450 that Inter Tight’s variable axis makes available.
Self-hosting via next/font/local rather than next/font/google keeps the runtime dependency-free (no Google Fonts CDN call), commits the fonts into the repo (version-pinned to whatever was in @fontsource-variable/* at copy time), and lets next/font apply its automatic metric-overridden fallback (size-adjusted Arial during loading, eliminating layout shift). Variable woff2 files (1 file per family — ~45KB Inter Tight + ~31KB Geist Mono = ~76KB total) cover all weights including 450 from a single asset; static-instance distributions per weight would balloon to 6 + 2 = 8 woff2 files and still wouldn’t cover the 450 axis point.
Alternatives considered:
next/font/google. Rejected — re-introduces a runtime dependency on Google Fonts and the privacy / perf concerns that originally motivated D-022.geist npm package directly. Rejected — the package wraps next/font internally; self-hosting via next/font/local is the cleaner pattern matching how Inter Tight is set up.Consequences:
app/globals.css. The @theme inline font tokens (--font-sans, --font-mono, --font-heading) now resolve through the localFont CSS variables.app/fonts/. License compliance is in-repo, not deferred.@fontsource-variable/inter-tight and @fontsource-variable/geist-mono were installed as dev deps for the file copy and then uninstalled. They are not runtime dependencies. The fonts live in app/fonts/, not node_modules/.ss01 / cv11 from the Aperture spec) happen by editing app/layout.tsx’s localFont declarations.Date: 2026-05-02 Status: Accepted (relaxes Constraint B from D-014)
Context: Constraint B (D-014, the Tailwind-v4 + shadcn decision) was: let shadcn defaults drive the visual style. No theme port from the prior agent-launchpad-template, no custom palette, just shadcn’s neutral OKLCH defaults. The motivation was Phase-1 speed — shipping a working app without spending days picking colors that would change anyway when a real design landed. With Sessions 9a–9e (Aperture UI/UX overhaul) underway, a real design HAS landed, and the discipline shifts.
Decision: Relax Constraint B. Replace it with this new constraint:
Use shadcn primitives for interaction; override visual styling via Aperture design tokens.
Concretely:
--background, --foreground, --primary, --muted, etc.) to Aperture roles so existing className references like bg-background and text-muted-foreground keep working but resolve to the new palette. New tokens that don’t have a shadcn analog (--paper-2, --hairline, --hairline-strong, --card-divider, --ink-2, --caption, --accent-hover) are added alongside and exposed via @theme inline so Tailwind generates first-class utilities.cn() layer in the consuming component — not by forking the primitive. If the override grows past a few utility classes, that’s a signal to extract a project-level wrapper component.Reasoning:
This is what most teams do once they have a real visual identity. The shadcn-defaults posture is right for the first weeks of a project; it’s not right once a designer has shipped a palette, type system, and spacing rhythm. Treating shadcn as an interaction library (not a visual library) gives us interaction correctness for free and visual sovereignty where it matters.
The token-rebinding approach (Session 9c’s mechanism) is specifically chosen to avoid component-by-component visual rewrites. By rebinding what --background, --primary, etc. resolve to, every existing screen visually shifts in one CSS edit — without touching any component or page file. This is the lowest-touch path from “shadcn neutral” to “Aperture warm-paper” and the right shape for a design-system change of this scale.
Alternatives considered:
Consequences:
Card (shadcn or near-equivalent) styled with bg-card, border-border, custom radius and shadow utilities, and the ArrowRight icon — not a hand-built <div> with inline styles.--paper-2, --hairline, etc.) are available via standard Tailwind utilities (bg-paper-2, border-hairline, text-caption). Component code consumes these the same way it consumes bg-background — no special syntax..dark block in app/globals.css) are deliberately untouched in 9c. Aperture is light-mode only by spec; dark mode retune is a future session.Date: 2026-05-04 Status: Accepted
Context: Session 15 ports the Aperture chat surface (/agents/<id>) toward spec docs/design/aperture/chat-aperture-spec.md. The spec’s §1.4 visual reference puts the entire turn — 64px speaker-label gutter + gap + flexible content — inside a single max-w-3xl (768px) column. After the initial 15a smoke pass landed the surface at the spec’s max-w-4xl-then-3xl arrangement (heading + lists at 4xl, message body at 3xl, with bubble-style user-right / assistant-left layout), user feedback during smoke surfaced two distinct problems: (a) the chat width visibly jittered when the first message overflowed the scroll container and a vertical scrollbar appeared, narrowing the content area and re-centering the inner column; (b) the surface read as narrow at rest, with margins that didn’t communicate “this is the work area” the way Claude’s own chat surface does. The fixes for (a) and (b) couple: replacing the bubble paradigm with the speaker-gutter pattern from spec §1.4 unifies turn shape and removes the per-bubble width-cap escape hatches that contributed to (a); pulling session 16’s turn-layout work forward into 15 lets both fixes ship together rather than landing the diagnosed jitter fix on top of a layout that’s about to be replaced anyway.
Decision:
max-w-4xl (896px). The page <main> and the message-list <ul> both cap at 4xl, mx-auto centered. This is the “chat surface” width the user sees as the chrome boundary of the conversation.max-w-3xl (768px). The MarkdownRenderer’s outer div applies max-w-3xl, and the user-message tinted card matches at max-w-3xl. Both speakers’ content visually align with the composer (also max-w-3xl mx-auto), giving a single consistent prose column for the whole conversation. Legal reading at 14.5–15px stays in the 56–60ch sweet spot per spec §1.<li> is a flex row: [64px gutter][gap-4][flex-1 content with max-w-3xl][optional download button]. The gutter holds mono-caps YOU (slate-blue, the only place YOU shows in slate per spec §2.2) or AGENT (caption tone). At full 4xl width, the prose left edge sits at gutter-end ≈ 80px from the wrapper’s left, exactly aligned with the composer’s left edge underneath. The right side of each turn carries ~64px of breathing room where the eventual hover-reveal metadata gutter (spec §2.10) will live in a later session.scrollbar-gutter: stable on the message-list scroll container, applied via a Tailwind v4 @utility scrollbar-stable rule in app/globals.css (not an arbitrary value at the call site). Reserves the scrollbar track’s width in the layout box so the first overflow does not shift centered content leftward.min-w-0 on the flex-1 content column in every turn shape. Defeats the min-width: auto (= min-content) default that lets unbreakable tokens (long URLs, wide inline code) push past max-w-3xl.ChatInterface’s legacy h-[calc(100vh-4rem)] is replaced with min-h-0 flex-1, and the page <main> gains min-h-0 flex-1 overflow-hidden, so the chat surface contains its own scroll inside MessageList. Without this, the composer falls below the visible fold (~92px on a typical viewport) and the visible width fix would land on top of a still-broken sizing story. The workspace body wrapper at app/(workspace)/layout.tsx is unchanged — other workspace routes (departments, agents listings, admin) need its overflow-auto behavior.Reasoning:
The user’s “just like Claude” requirement is about the felt presence of the chat surface, not just line-length. Claude’s own chat sits at a wider chrome with prose constrained tighter inside. The spec’s literal max-w-3xl-everywhere reading optimizes line-length but produces a chat surface that reads as narrow at rest, especially on the 1440–1920px viewports the product targets. Splitting the two layers (wrapper at 4xl, prose at 3xl) keeps the legal-reading argument intact while giving the chrome the presence the user is asking for.
The speaker-gutter pattern from spec §1.4 was already going to land in session 16; pulling it forward into 15 avoids shipping a layout (Session 15’s max-w-[80%] bubbles) that the very next session was going to throw out. The width-jitter bug and the bubble paradigm share root causes — three nested width layers (page main 4xl, message ul 3xl, per-bubble 80% with no min-w-0) all recomputing independently against parents whose effective width changed when a scrollbar appeared. Collapsing to one canonical width per layer (4xl wrapper, 3xl prose, no per-message cap) plus stable scrollbar gutter plus min-w-0 fixes the jitter directly rather than papering over it.
The sizing fix is in scope because shipping a width-correct layout where the input falls below the fold defeats the visible improvement. The two are not separable from a “does this surface feel like a real chat” perspective.
Alternatives considered:
max-w-[80%] per-bubble caps without min-w-0 — separately a contributor to width jitter. Both fixes belong together.max-w-3xl) to a per-message wrapper, not into MarkdownRenderer. Rejected for now — single consumer, no propagation concern. If a future MarkdownRenderer consumer wants unconstrained prose, the override is a className prop or a sibling renderer at the call site. Revisit if a second consumer appears.h-[calc(100vh-4rem)] → flex sizing) to a separate session. Rejected — composer below the fold would undercut the visible width win. Pulling it forward keeps the session’s deliverable coherent.Consequences:
MarkdownRenderer’s outer div now applies max-w-3xl. Currently a single consumer (assistant message bubble); no propagation surface.overflow-auto. This split is intentional — chat needs a fixed input at the bottom, the others don’t.gap-7 (28px) between turns matches the visual reference’s .col { gap: 28px }, replacing Session 15a’s gap-4 (16px). If the rhythm reads too airy in long sessions, tighten to gap-6 in a follow-up — not blocking sign-off.Date: 2026-05-04 Status: Accepted
Context: Session 18 lands tool traces and citations end-to-end — streaming pipeline (Step B), visual polish (Step C), and a same-session addendum that reshaped two UX patterns based on live-streaming smoke. Two design choices made this session deserve their own ADR rather than being buried in the CHANGELOG, because both shape downstream constraints (renderer plugin surface, tool-call schema migration, regen / edit flows) and a future reader of the code will rightly ask “why this shape and not the obvious alternative.”
Decision:
Citation markers persist as stable per-source IDs in HTML <sup> tags inside the message body. Each unique citation URL within a single message gets a src_<12-char-hex> id generated server-side at first encounter (deduped by URL within the message; the same URL cited three times in a sentence produces one source record but the within-drain dedup also collapses repeated pills to one). The id is embedded as <sup data-source-id="src_xxx"></sup> directly inside the persisted messages.content body. The numeric superscript label the user sees ([1], [2], …) is computed at render time as sources.findIndex(s => s.id === markerId) + 1, never stored. The rendering pipeline is react-markdown → remark-gfm → rehype-raw (promotes raw HTML in markdown into hast) → rehype-sanitize with a schema that whitelists sup[data-source-id] (bare-string allow, not a tuple — see Step-C-addendum smoke for the silent-strip bug the tuple form caused) → react-markdown component override that routes the sup element to a <CitationMarker /> chip. The marker syntax uses explicit open+close (<sup ...></sup>), not self-closing (<sup ... />) — HTML5 ignores the trailing / on non-void elements, leaving an unclosed <sup> that wraps every subsequent token (text, lists, even later sup tags) as nested children, which was the load-bearing bug the Step C smoke caught. A legacy normalizer in markdown-renderer.tsx rewrites the old self-closing form at render time so messages persisted before the fix display correctly without a backfill migration.
Trace card grouping happens at render time only. The DB schema (messages.tool_calls JSONB, added in migration 0014) stores one record per Anthropic tool_use_id — every individual call carries its own { id, name, input, output, status, started_at, finished_at?, position, error? } entry. The grouping into a single visual card is computed by buildBlocks() in components/chat/message-bubble.tsx: after sorting tool calls by their captured position offset, the function walks the sorted list and buckets adjacent same-name calls into a tool_trace_group block when the body slice between the previous call’s position and the next call’s position trims to empty (i.e. no actual prose between them). A search → sentence of prose → another search produces two cards; four parallel searches with no prose between them produce one grouped card with header Web search · Searched · 4 queries. Singleton groups render exactly as ungrouped Step C did — no count segment, single Query + Result expanded panel.
Reasoning:
Citation marker stability. Stable IDs survive the surface area of operations that would invalidate index-based markers: edits to the body, regenerations that change sentence ordering, future merge / split / branch operations, DB roundtrip across migrations. Rendering numbers from findIndex keeps the visible label correct even if sources are reordered after persistence (e.g. a future “consolidate duplicate sources” pass). HTML <sup> is semantic, standards-track, and survives any markdown processor or sanitizer with a one-line allowlist — it requires no remark/rehype plugin we’d need to ship and maintain. Plugin surfaces rot: a custom [^src_xxx] syntax would lock us to a remark plugin’s API across upgrades, where <sup> is HTML and continues to work for as long as HTML continues to exist. The cost of the choice is that the persisted body is no longer “pure markdown” — it carries inline HTML — but we already render through rehype-raw for other reasons and the security posture is preserved by the explicit attribute whitelist.
Trace card grouping at render time. The persisted shape is the truth — every Anthropic tool_use_id is its own record, with its own input, output, timing, and source attribution. Grouping is a presentation concern: how the user perceives parallel work. Rendering grouping from the same data preserves position-in-prose semantics (Decision A from the Step B prompt — tool traces slot into the body at the position where the model invoked them), keeps schema changes to zero, and means a future product decision to ungroup, regroup, or change adjacency rules requires no migration and no historical-data risk. The cost is that the grouping logic lives in two places — the buildBlocks walker and the ToolTraceCard multi-call branch — but both are colocated in components/chat/ and fully tested by the smoke flow. The alternative (storing a group_id or a parent-child schema in tool_calls) would have given us the same UX at the cost of a schema migration, a backfill for historical messages, and a permanent constraint on future grouping changes. Render-time wins on every axis except code locality, which isn’t a meaningful loss here.
Alternatives considered:
[1], [2], …). Rejected — any edit, regeneration, or source-list reorder would corrupt the link between marker and source. Numbers must be derived, not stored.[^src_xxx] citation syntax. Rejected — adds a permanent plugin dependency to maintain across remark upgrades; HTML <sup> does the same work with no plugin surface.tool_call_group table or a group_id column on messages.tool_calls. Rejected — locks the grouping rules into a schema. Adjacent + same-name is the right rule today; a future product call (e.g. “group by query intent across non-adjacent calls”, “always show parallel calls collapsed but sequential calls separately”) would require a migration. Render-time computation absorbs the change cost-free.Consequences:
messages.content body is no longer pure markdown — it carries inline <sup data-source-id="..."> HTML. Any future renderer that consumes content must either (a) accept HTML, (b) strip the markers (the docx export does this — lib/exports/docx.ts:CITATION_MARKER_RE), or (c) translate them to its own citation format.rehype-raw is a permanent dependency in the markdown pipeline. The sanitize schema’s sup[data-source-id] whitelist is the only authorized HTML-attribute-bearing tag the pipeline accepts; adding more attributes or tags requires extending citationSchema in markdown-renderer.tsx and is a security-review-worthy change.tool_calls JSONB stays at one-record-per-tool-use-id forever. A future change to grouping rules edits buildBlocks only — no migration, no backfill, no historical-data risk. The persisted shape carries the fine-grained truth; presentation reshapes it.position offset (parallel calls fired without writing prose between) always group regardless of the trim check, because slice(N, N) === "" trivially. Future tools that write meaningful position-distance metadata (e.g. “this tool ran on retrieved doc X, this one on doc Y, in conceptually different parts of the response”) would need a different signal — a group_hint field on the tool call record, or a name-based grouping override. Not a problem today; flagged for if/when a non-search server tool lands.Date: 2026-05-05 Status: Accepted (with sunset)
Context: Session 20 Step B recon identified that ensure_user_provisioned (RPC called by proxy.ts on every authenticated request) auto-provisions any authenticated email into the single-tenant org as role='user' with zero department roles. There is no invitation gate, no email allowlist, no pending-approval state. Combined with Supabase magic-link auth defaults (no domain restriction), the practical posture is: anyone who knows the production URL and has any email account can become an inert org member.
Decision: Defer the invitation gate. Accept the open signup posture for the remainder of Phase 2.
Reasoning: For the small group of trusted feedback users in Phase 2, strangers becoming inert role='user' rows with no department access is harmless — they see the empty-state landing, can’t navigate into anything (everything 404s or 403s), and the metadata leaks were closed by migration 0015. The cost of a proper invitation gate (auth_invitations table, admin UI to send invitations, pending-approval state, gating ensure_user_provisioned on invitation existence) is real engineering work that doesn’t earn its place against the actual threat model right now.
Alternatives considered:
auth_invitations table + admin invite UI. Strongest, but full-session scope.is_active=false default + admin-flip workflow. Medium scope, requires a “pending approval” branch in the workspace landing.allowed_email_domains column + domain-match check in ensure_user_provisioned. Lightest scope, but breaks for legitimate users with non-matching emails (gmail accounts of trusted reviewers).Consequences:
public.users (cosmetic, not exploitable).Sunset condition: This entry expires when EITHER (a) the production URL is publicized to non-trusted users (e.g., a public landing page goes live with a sign-up flow), OR (b) an invitation gate is implemented. Whichever comes first.
Date: 2026-05-07 Status: Accepted
Context: The existing app structure had every authenticated route living at the root, with no provision for a public marketing surface. The roadmap (PROJECT_OUTLINE.md, Phase 2 work) called for a public landing page that doubles as the auth entry point, routing visitors to /workspace where the auth gate lives.
Decision: Move the entire authenticated surface to /workspace/* via a route-group rename (app/(workspace) → app/workspace). Replace the temporary one-line redirect at app/page.tsx with a real marketing landing. The CTA always routes to /workspace; the existing proxy.ts middleware gates the auth check from there. proxy.ts updated to allowlist "/" as a public path.
Reasoning: Path-based separation (Linear/Vercel pattern, / marketing + /workspace app) was preferred over state-based rendering (GitHub pattern, single / serving different content by auth state) because shareable URLs stay deterministic, SEO behavior is unambiguous, and the mental model scales as the app grows. Subdomain split (Stripe pattern, marketing.legalos + app.legalos) was the better long-term answer but requires a custom domain that’s deferred until product naming is locked. Path-based is the right call at the current vercel.app stage. The migration cost was paid once via mechanical find-and-replace across Link href, redirect(), revalidatePath(), pathname matchers, breadcrumb route table literals and regex anchors, and WorkspaceNavLink prefix checks. Auth callback’s default fallback shifted from / to /workspace so a magic-link click without an explicit ?next= lands on the workspace, not on marketing.
Alternatives considered:
marketing.* + app.*). Correct long-term answer; deferred until a custom domain replaces the current vercel.app URL./landing or /home. Rejected — marketing should be the canonical public URL.Consequences:
app/, components/, and lib/ now lives under /workspace.workspace segment is now a Link to /workspace itself.proxy.ts allowlists "/" as public; everything else outside /login and /auth/callback continues to require auth.?next= preservation in the proxy is still deferred (called out at proxy.ts:24). When implemented, it becomes a small follow-up.vercel.app site URL no longer redirects from / to a workspace landing; it serves the marketing surface.Date: 2026-05-07 Status: Accepted
Context: The Aperture palette as originally specified produced a noticeably warm-grey page surface that read as heavier than intended once the marketing landing went up alongside the workspace. The user requested a lighter overall surface treatment across all pages, with proportional shifts to the related warm-tan tokens to preserve family coherence.
Decision: Lift every warm-tan family token in light mode by an OKLCH lightness delta proportional to its sRGB headroom. Tokens with starting L below 0.97 bumped by +0.02. Tokens with starting L at or above 0.97 bumped by +0.01 (capped to avoid sRGB clipping at L=1.0). Hue and chroma preserved across all tokens. Foreground tokens (ink, ink-2, mute, caption) and accent tokens (primary, accent-hover, primary-hover) untouched — only the surface family shifted.
Reasoning: A flat +0.02 across the family produced clipping on --muted, --paper-2, and --sidebar-accent (all at starting L 0.9823, which clamps to white at L=1.0023). Clipping collapses three distinct hover/wash tokens into indistinguishable pure white, which would have eroded the warm cast at the brightest end of the family. Proportional deltas preserve the relative spread between tokens while still achieving the overall lift. Cards and form-field surfaces additionally received a follow-up lift from L 0.986 to L 0.992 to restore the card-vs-background tonal step from 0.007 to 0.013, after the initial pass left cards reading too close to the page bg. --primary-foreground and --sidebar-primary-foreground (the paper-on-primary tokens for cream text on slate-blue surfaces) lifted to track the new --background L value, preserving their semantic pairing.
Alternatives considered:
border, hairline, secondary) had headroom and benefitted from the larger bump; capping them at +0.01 under-delivered on the requested lift.Consequences:
docs/design/aperture/README.md hex references no longer match production CSS. The README is annotated with a header banner pointing to app/globals.css as canonical.app/globals.css updated to reflect the new computed values per token.Date: 2026-05-07 Status: Annotates D-035
Context: D-035 (open signup posture deferred to post-Phase-2) listed two sunset triggers: (a) the production URL is publicized to non-trusted users via a public landing with a sign-up flow, OR (b) an invitation gate is implemented. Session 22 Step B made / publicly accessible by allowlisting "/" in proxy.ts’s PUBLIC_PATHS, but the landing’s only CTA routes to /workspace which remains auth-gated. No signup flow exists.
Decision: D-035 is not yet sunset. Trigger (a) is half-met (public landing exists), trigger (b) is unmet (no invitation gate). The “Request access” link on the landing is a mailto, not a sign-up form, which preserves the original threat model — visitors must email before being added.
Reasoning: Documenting the current state explicitly so future sessions reading D-035 don’t assume the trigger fired silently. When the invitation gate eventually ships, a fresh D-entry will sunset D-035 with the explicit transition.
Consequences:
mailto Request access link is the de facto invitation gate at the current stage.Date: 2026-05-09 Status: Accepted
Context: The Phase 1 login surface was a bare HTML form (text-2xl heading, raw <input> and <button> with utility classes, vertically and horizontally centered) that did not match the marketing landing’s quality bar. Two functional issues compounded the visual gap. First: on submit, the page redirected to /login?message=check-inbox and re-rendered with the form still visible PLUS a status box appended below it — to a user, this read as a re-prompt, not as confirmation. Second: authenticated users hitting /login saw the form and could submit again, with no bounce to /workspace. Session 23 addressed all three in one commit.
Decision: Replace the bare form with a polished surface that mirrors the marketing landing’s typography, x-axis anchor, masked-reveal motion vocabulary, and primary CTA treatment. Drive a two-state UI off the existing ?message=check-inbox querystring: form state OR confirmation state, never both. The confirmation replaces the form rather than annotating it. Echo the submitted email in the confirmation 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. Provide explicit Resend and Use-different-email actions. Add an authed-user bounce in proxy.ts: authenticated users hitting /login get redirected to /workspace, with refreshed Supabase session cookies copied across the redirect per the file’s existing CRITICAL note. /auth/callback remains reachable for authed users so old magic-link clicks still resolve.
Reasoning: The pattern shipped (dedicated page, full state transition on submit, email echo, explicit resend and change-email actions) matches what Linear, Vercel, Notion, Cursor, Stripe, and Raycast all use for magic-link flows. Modal auth was rejected — easier to dismiss accidentally, less serious tone, accessibility footguns. Persistent-form-with-status-box was rejected as the bug it was, not a design choice — users read it as a re-prompt. Email echo via httpOnly cookie was preferred over URL-querystring carry (would expose email in browser history and Vercel logs) and over client-state carry (form-action redirects break client state). Cookie path-scoped to /login so it never travels to /workspace or any other surface. Cookie set on both success and signInWithOtp failure to avoid signaling delivery failure via cookie absence — preserves the principle codified in the original actions.ts comment. The authed-user bounce was lifted in alongside the polish because it is a five-line proxy.ts change adjacent to the work; shipping login polish without it would leave an obvious rough edge. The visual polish was batched into one prompt per the Session 22 lesson on serial visual edits.
Alternatives considered:
LandingGlyph echo on the right side at desktop widths. Rejected — empty negative space is the more confident move on a functional surface; the landing already establishes glyph identity.Consequences:
legalos_pending_email cookie is the only piece of cross-request state introduced by the login surface. Path-scoped, httpOnly, 10-minute TTL./login are redirected to /workspace instantly; old magic-link clicks via /auth/callback continue to work."use server" files cannot export non-async-function constants, so the cookie name is duplicated as a literal in actions.ts and app/(public)/login/page.tsx with sync-warning comments. Acceptable — the literal is short and the two locations are adjacent.app/(public)/login/ — there is no other surface in that group, so a layout wrapper would be ceremony.Date: 2026-05-11 Status: Accepted
Context: Supabase Auth’s default email provider caps at 2 messages per hour on the free tier. That cap is the binding constraint on smoke-testing the magic-link flow end-to-end — a single login attempt followed by a resend exhausts the quota for the hour, making it impossible to verify the surface shipped in Session 23 (D-039) under realistic conditions. The 2/hour cap also blocks the prerequisite work for the invitation gate that sunsets D-035: any cohort larger than the operator cannot be onboarded for trusted-reviewer feedback without first lifting the email rate limit.
Decision: Configure Supabase Auth to route magic-link emails through Resend’s SMTP server in sandbox mode. The Next.js application code is unchanged — signInWithOtp continues to call Supabase Auth, which now delegates the SMTP transport to Resend instead of its own default sender. Configuration lives entirely in Supabase’s hosted dashboard (Authentication → SMTP Settings) and Resend’s dashboard; no env vars, no migrations, no application changes.
Sandbox mode uses the onboarding@resend.dev sender address with display name legalOS. Resend’s sandbox constraint limits delivery to a single recipient: the email address used to create the Resend account. Sends to any other recipient fail with a 403 in Resend’s email log. This is sufficient for solo operator smoke-testing of the magic-link surface and the rate-limit lift, but does not unblock broader cohort delivery.
Reasoning: Resend was chosen over Postmark, SendGrid, and AWS SES because it’s the modern developer-focused option with the cleanest Supabase integration story, a generous free tier (3,000/month, 100/day), and a documented sandbox path that requires zero DNS work. The 3,000/month cap is well above any plausible smoke-testing or trusted-cohort volume through Phase 2. Native Resend-Supabase integration via the Supabase Integrations marketplace was rejected in favor of manual SMTP credential paste — the SMTP path is more portable across providers should Resend ever need to be swapped, and the integration’s value-add is dashboard convenience rather than a different transport mechanism.
Sandbox mode (rather than verified custom domain) was chosen because the custom-domain question from D-036 is still deferred. Provisioning a domain solely to lift the 2/hour cap for solo smoke-testing would force premature naming and infrastructure decisions. Sandbox mode lifts the binding constraint immediately and defers the domain decision to its natural place: when broader-cohort delivery is actually required, i.e., when the invitation gate that sunsets D-035 is ready to ship.
Alternatives considered:
Consequences:
legalOS <onboarding@resend.dev> via Resend’s SMTP. The “via resend.com” line in email clients makes the routing visible./dashboard/project/_/auth/rate-limits. Sufficient for smoke-testing; a future load-test session would need to lift it..env.example or Vercel env. No env-var noise added.resend.dev has those records configured by Resend. These become required work when a custom domain replaces the sandbox sender.Sunset condition: This entry is superseded when a verified custom domain replaces onboarding@resend.dev as the sender. At that point, broader-cohort delivery unblocks, the invitation gate’s email-delivery prerequisite is satisfied, and the per-environment credential question (one Resend account vs. split) is decided.
Date: 2026-05-12 Status: Accepted
Context: Department descriptions in production had drifted from accurate copy — the Commercial card’s "Contract review, vendor agreements, commercial operations." surfaced the issue, and the other seven descriptions had likely drifted similarly across migrations 0012 (Product / Compliance / Operations added) and 0013 (GRRA merged into Public Sector, General Tools added). These descriptions are stable copy, not dynamic agent summaries: they describe the scope of a department’s work, which is independent of which specific agents currently exist in it. Two design questions arose. Where should the editing surface live — a separate /workspace/admin/departments route, or inline on the workspace landing where the descriptions already render? And which admin tier should be able to edit — any admin role (super_admin, org_admin, dept_admin), or only the org-level admins whose authority maps to cross-department concerns?
Decision: Inline edit on the department card, super_admin / org_admin only. The card grows a hover-revealed pencil affordance at top-right (opacity-0 default, fades in on hover or focus-visible, motion-reduce:opacity-100 fallback). Click swaps the description text for a textarea plus Save / Cancel buttons. ⌘+Return / Ctrl+Return saves (matching Session 17b’s chat-composer keyboard contract), Escape cancels, plain Enter is newline. Optimistic update via useTransition (matching Session 17a’s model-picker pattern). dept_admin is intentionally excluded from the role gate to match the existing RLS write policy on public.departments — departments_org_admin_write from migration 0001 — which restricts writes to super_admin / org_admin.
The card’s surrounding <Link> swaps to a <div> during edit mode so unsaved work can’t be lost via an accidental click on card chrome navigating to the department detail page. The hover-lift treatment from Session 17a (slate-blue accent, -2px translate, shadow grow) is dropped on the div variant — the lift classes simply aren’t included in that variant’s className composition, so the card visually settles into a “locked to current state” mode without separate styling logic.
A new helper isCurrentUserOrgAdmin() was added to lib/auth/access.ts. It returns true only for super_admin / org_admin and mirrors the departments_org_admin_write RLS predicate exactly. The existing isCurrentUserAdmin() — which also returns true for dept_admin — is preserved for use cases where dept_admin should still pass (admin route gating, the workspace rail profile dropdown’s “Admin” visibility check). Both helpers now live side by side; callers pick the one whose scope matches the action they’re gating.
The seed file at supabase/seed/0001_org_and_departments.sql was deliberately not updated in this session. Production copy will be edited live via the new affordance once deployed; forcing the operator to hand-write all eight descriptions before seeing the UI defeats the “edit where noticed” pattern this session establishes.
Reasoning: Direct manipulation is the established convention in cutting-edge product design — Linear, Notion, Vercel, Figma, Cursor all follow it. The pattern: the thing you want to edit is the thing you click; permission-gated affordances hide from users who can’t use them. Admin routes are an artifact of older product design where “admin” was a separate persona; modern products treat admin as “same person, more permissions, same flow.” For an operator who notices a copy issue while looking at the department grid, the natural action is to fix it where it’s noticed, not context-switch to a settings page.
Permission-gated affordance scope must match RLS scope. The earlier isCurrentUserAdmin() helper returned true for dept_admin even though departments_org_admin_write excludes them — using that helper for this affordance would surface a pencil that produced an opaque “permission denied” on save for dept_admin users. The new helper exists to keep app-layer gating in sync with DB-layer gating. The principle generalizes: when adding affordances for actions backed by RLS-restricted writes, build a helper that mirrors the RLS predicate exactly rather than reusing a broader role check.
The Link → div swap during edit mode is a small piece of plumbing but the right semantic shape. Navigation truly isn’t available mid-edit; rendering an <a> whose onClick calls preventDefault would be a lie at the HTML level. The div variant also naturally disables the hover-lift treatment because the lift classes are only included in the Link variant’s className composition — no separate isEditing styling branch needed in the component.
Optimistic update was preferred over server-only revalidation because the model-picker precedent in Session 17a already established the pattern. Consistency wins over alternative validation strategies that have no specific reason to differ here.
Alternatives considered:
/workspace/admin/departments admin route. Rejected — context switch defeats the operator’s natural flow; “go find a settings page” is the older pattern that direct manipulation replaces.Consequences:
lib/actions/agents.ts’s pattern (auth → role → Zod → UPDATE → revalidate), optimistic update via useTransition with toast.error revert on failure, hover-revealed affordance using the established opacity-0 group-hover:opacity-100 focus-visible:opacity-100 motion-reduce:opacity-100 class composition.isCurrentUserOrgAdmin() is now available. Mirror-RLS-exactly is the convention for any new admin-gated affordance whose backing action is RLS-restricted to org-level admins. The choice between isCurrentUserAdmin() and isCurrentUserOrgAdmin() is the choice between “any admin tier can act here” and “only org-level admins can act here.”DepartmentCard is now a client component. Card-level transient state has a place to live; future card-level features (delete confirmation, reorder drag handle, hover-revealed metadata) can attach without further refactor.supabase/seed/0001_org_and_departments.sql still contains the original (now inaccurate) department descriptions. A fresh local dev environment will produce the old copy. Acceptable through Phase 2; a future consolidation session — perhaps when multi-org ships, perhaps sooner — should update the seed to match whatever production copy has converged on.Date: 2026-05-13 Status: Accepted
Context: Templates infrastructure has existed since Session 8a (D-025 — the native-agent runtime built alongside user-owned agents) and Session 8f-A (the fork pattern, anchored by forked_from_agent_id FK with ON DELETE SET NULL). Pattern B — canonical org-curated templates that users fork — was the intent but had never been activated. Session 21 deliberately retired the launchpad’s Templates section pending product clarity on who curates and how.
Session 27’s framing surfaced the real shape. In-house legal departments and law firms need a curated set of approved agents per department, with knowledge-management or org-admin lead curating; sole practitioners don’t need templates and just create user-owned agents directly. The “Department Agents” a user saw on the launchpad before Session 27 were system-seeded rows with is_template = false — semantically intended as canonical but flagged wrong. Three product surfaces needed coherent treatment in one session: who can manage templates, what users do when they click one, and what visual signals communicate “this is the org’s canonical version.”
Decision: Activate Pattern B. Migration 0019 flips is_template = true on every created_by IS NULL row (15 rows in dev). Templates surface on the department launchpad as “Department Agents” — the user-facing label preserved from the prior section title for semantic continuity. Users think “the firm’s NDA review tool,” not “the NDA review template”; admin surfaces (edit-page banner, trash chip) use Template vocabulary where the technical specificity matters.
Admin lifecycle on templates — edit, soft-delete, create — is gated to super_admin / org_admin only via Session 26’s isCurrentUserOrgAdmin(). dept_admin is intentionally excluded at the application layer; templates are org-wide artifacts, and centralized curation matches the customer model for the foreseeable cohort.
The edit surface reuses /workspace/agents/[id]/edit with a permission tweak (gate widened from “owner only” to “owner-of-non-template OR org-admin-of-template”) and a warn-palette banner above the form: “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 — it surfaces the snapshot semantics in plain language without leaking the word “snapshot.”
Soft-delete uses existing Session 8f-B infrastructure with 30-day undo. The shared /workspace/agents/trash surface widens for admins: it now lists template deletions alongside the admin’s own user-owned deletions, with a “Department Agent” chip on template rows for scannability. Non-admins continue to see only their own user-owned deletions.
Click behavior on Department Agents: primary click navigates to /workspace/agents/<template-id> — the chat surface, not the fork form. Fork-on-click is retired. This is the substantive product-correctness change: interaction-first products separate use from customization. Forking on click conflated them; clicking now lets users converse with the firm’s canonical version without creating a personal copy until they deliberately want one.
The Customize affordance is chat-surface only, post-engagement. The top-right corner of agent-header.tsx (where Edit lives for owned agents) renders “Customize” for non-admin users viewing a template. Clicking forks the agent AND copies the active conversation (if one exists) into a fresh conversation under the new agent. The original template’s conversation stays attached to the template, untouched. Snapshot semantics on the copied conversation: re-snapshot from the new agent (not preserve the source’s snapshot), so future template edits don’t ghost-update the user’s prompt and future edits to the user’s fork land on subsequent conversations as expected.
The template signal is a persistent “Department Agent” chip in the agent header meta-chip row using Aperture’s slate-blue mono-caps vocabulary (matching the existing Web Search chip). Renders whenever agent.is_template is true, regardless of viewer. No first-turn banner — passive persistent context is the cutting-edge convention.
Three-way top-right slot logic in agent-header.tsx:
canManageTemplates → Edit link (admin path).!canManageTemplates → Customize button.Schema decisions surfaced in Step A.2 research were confirmed by reading. Conversations remain user-scoped via existing conversations_user_owns RLS — multiple users chatting with the same template each get their own thread automatically, no schema work needed. system_prompt and model are snapshotted per-conversation at creation time (per migration 0004’s design intent and CLAUDE.md’s AI Integration Rules), so admin edits don’t disturb in-flight conversations. tools_enabled and agent_attachments are NOT snapshotted — flagged in Consequences as behavior to monitor.
forkAgentFromConversationAction implementation: three-step transactional insert (new agent → new conversation → bulk message insert) with best-effort soft-delete rollback on conversation-copy failure. Users have no DELETE policy on agents (migration 0010 is UPDATE-only), so hard rollback isn’t available via the user-scoped client; soft-delete via deleted_at = now() hides the orphan and lets it surface in the admin’s 30-day trash window for recovery or eventual cron hard-delete.
Reasoning: Templates as data, not code: the pattern survives multi-tenant rollout cleanly. Each org curates its own templates; the schema’s organization_id scoping does the work.
“Department Agents” user-facing label vs. “Template” engineer-facing label: the cutting-edge convention is to never leak data-model vocabulary as user-facing copy unless users think in those terms (Notion, Linear, Figma, Cursor consistently). Legal practitioners think “the firm’s NDA review tool,” not “the NDA review template.” Admin surfaces use Template vocabulary where technical specificity matters.
Chat-with-template vs. fork-on-click: ChatGPT’s GPT Store, Cursor’s community modes, Figma’s Community files — all converged on click-to-use, fork-as-deliberate-action. Forking on click pollutes My Agents with one-off copies and treats engagement as a heavyweight commitment. The user’s mental model is “use the firm’s tool”; customization is what happens when usage reveals a gap.
Conversation copy on Customize: when the user clicks Customize mid-conversation, what they were doing matters. Carrying the conversation into the new copy preserves that intent. Starting the user’s copy with an empty thread treats the prior conversation as throwaway. Re-snapshotting from the new agent (rather than preserving the source’s snapshot) is the cleaner option: at fork-creation 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, and user edits to their fork land as expected on subsequent conversations.
Persistent chip vs. one-time banner: chips are passive context users learn once and absorb forever; banners are temporary state requiring dismissal. The chip carries the load.
Org-admin-only (not dept-admin) for template management is tighter than RLS allows. Session 26’s D-041 established the mirror-RLS principle — don’t surface affordances that would 403 because of an RLS gate. Session 27 inverts the framing: deliberately narrower than RLS for product-policy reasons (templates are org-wide artifacts; centralized curation matches the customer model). Worth flagging because the tightening direction is opposite D-041’s and the reasoning is product, not technical.
Edit-page reuse vs. dedicated /admin/templates route: same underlying schema, same form fields, same validation. Building a parallel route for template editing would duplicate ~200 lines of form logic for a gate-and-banner difference. The reuse path with permission tweak + visual signal is the right scope now; if template editing diverges significantly later (draft/publish, version history, approval queue) a dedicated route earns its place at that time.
Alternatives considered:
/admin/templates/[id]/edit route. Rejected — duplicates form logic for a gate-and-banner difference; reuse path with permission tweak is the right scope.Consequences:
canManageTemplates resolves true; the affordance shape (overflow menu with Edit + Delete, confirmation dialog with org-stakes copy, toast Undo) mirrors the MyAgentCard pattern from Session 8f-B with permission scope widened.isCurrentUserOrgAdmin remains the org-level admin gate. The mirror-RLS principle from D-041 is not universal — Session 27 deliberately gates tighter than RLS for product-policy reasons. Future similar product-policy gates should explicitly note the divergence in their own ADR.tools_enabled and agent_attachments are NOT snapshotted per-conversation — they read live from the agent row per turn. Admin edits to a template’s web-search toggle or attached files will propagate to in-flight conversations on the next turn. This is the existing schema’s behavior (not a Session 27 introduction) but becomes more user-visible now that templates are admin-mutable. Acceptable for the customer cohort through Phase 2; if a customer surfaces a need for “edits are atomic at conversation boundary” behavior, snapshot tools_enabled and attachments at conversation creation alongside system_prompt and model.isTemplate branch in agent-card.tsx routing to /workspace/agents/new?fork_from=<id>) is retired from card click. The new-agent page still accepts the fork_from URL param for explicit fork-from-template flows from the chat-surface Customize button.createTemplateAgentAction sets created_by = NULL on new templates (not the admin’s user_id). Templates are org-canonical, not personal — the NULL anchor places conceptual ownership at the organization, not at the individual admin who authored the template. Matches the pre-existing convention for seeded canonical agents.forkAgentFromConversationAction is the largest piece of net-new code in this session. Failure handling uses best-effort soft-delete rollback because users have no DELETE policy on agents. Future hardening (RPC for true transactional rollback, or a service-role action) is acknowledged but deferred — current behavior produces a soft-deleted orphan that the admin can find in trash, not silent corruption.Date: 2026-05-13 Status: Accepted
Context: Session 17b shipped the “single-centerline alignment” promise — every chat-surface element centered at the same x-axis. Verification at the time happened with longer message content that filled the 3xl ceiling, masking a latent issue.
Session 27’s smoke surfaced the regression: the agent header content, user message bubble, and assistant prose wrapper were not aligned for short content. Diagnosis traced to mx-auto max-w-3xl without w-full on three wrappers — 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.
Subsequent operator inspection raised a second perception concern: even with the centerline math correct, the chat surface didn’t read as a contained chat column the way ChatGPT / Claude.ai / Cursor surfaces do. Right-anchored user bubbles are the cutting-edge convention; the prior left-anchored bubble shape contributed to the “elements floating on a page” perception even when geometrically aligned.
Decision: Fix the shrink-to-content centerline regression by adding w-full to the three wrappers Session 17b missed:
agent-header.tsx:155 — flex container around name / description / chips / top-right action.message-bubble.tsx assistant variant — wrapper around prose + citations + download button.message-bubble.tsx user variant — restructured (see below).Right-anchor user message bubbles. Replace the prior left-anchored inline-block bubble with <div className="mx-auto flex w-full max-w-3xl justify-end"> outer wrapper plus a naked inner bubble (no inline-block, max-w-full retained as the per-bubble width cap). User bubbles now sit flush against the column’s right edge with shrink-to-content width capped at the column width. Matches ChatGPT, Claude.ai, Cursor, and the established messaging-app convention.
Chat-surface containment — the deeper “the column doesn’t read as a contained surface” perception — is deferred to Session 28 as its own focused effort.
Reasoning: The cutting-edge chat-surface convention right-anchors user messages. Every modern AI product (Claude.ai, ChatGPT, Cursor, GitHub Copilot Chat) and every messaging app (iMessage, WhatsApp, Slack) places user content on the right and counterparty content on the left. The pattern leverages a deeply-learned mental model: “what I said” lives on one side, “what they said” lives on the other. Left-anchored user bubbles forced the eye to derive the speaker distinction from styling alone (tinted card vs. bare prose) — which the Aperture spec already does — but right-anchoring layers on the spatial cue for free.
The shrink-to-content fix is a true regression. Session 17b’s CHANGELOG explicitly promised the centerline contract that wasn’t being delivered for short content. Acknowledged as a regression rather than a new feature.
Geometric measurement via browser console getBoundingClientRect confirmed alignment was correct after the w-full and right-anchor fixes — header content, user bubble outer wrapper, and composer all at the same x-coordinate. The remaining “chat surface still feels off” perception is a containment issue, not a centerline issue. Separating those problems lets each get its own clean session.
Alternatives considered:
Consequences:
w-full max-w-3xl pattern is the canonical wrapper shape for chat-surface elements; future additions should match.flex … justify-end as the new pattern. Assistant prose stays left-anchored full-column. The visual rhythm is now legibly back-and-forth.Date: 2026-05-11 Status: Accepted
Context: Session 27 closed the centerline math (D-043 — added w-full to three wrappers, right-anchored user bubbles, verified header content + message wrappers + composer all at x=288 via browser getBoundingClientRect). Operator smoke then surfaced that geometric alignment alone didn’t resolve the deeper concern: the chat surface still didn’t read as a contained chat column. D-043 deferred this to Session 28 as its own focused effort.
Step A research established an architectural fact about the cutting-edge convention. Every modern AI chat surface — Claude.ai, ChatGPT, Cursor, GitHub Copilot Chat — uses what the research labels “contained without containing”: visual anchoring via a persistent composer card at the bottom + strong page padding + per-turn rhythm. None wraps the conversation column in a card with bg / border / shadow. legalOS already had the page padding and the composer card; what was missing was a deliberate top boundary and a stronger anchor signal at the composer. The agent header’s bottom hairline used --border (#f2ede3) — a barely-visible 0.013 lightness delta against the page background — and the composer had no upward-directional shadow signaling that scrollable content disappears beneath it.
A second product issue surfaced in the same smoke. The pre-first-message identity panel from Session 19 (<ChatEmptyState>, spec §2.8) duplicated the agent header’s name and description and added a facts row (Model / Web search / Last updated) that the agent header was already capable of showing. The empty state read as unpolished and reflected an older AI-product convention — the substantial welcome screen, the IBM Watson era — rather than the cutting-edge AI-native convention of trusting the user to know what they want and getting out of the way.
Decision:
Top boundary strengthening (Decision A). Two changes in agent-header.tsx: (1) bottom-border token upgraded from border-border to border-hairline-strong — same 1px width, stronger tone (#eae4d8 vs. #f2ede3, the documented “card outer chrome” tone in Aperture’s surface ramp); (2) 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. The combination reads as a deliberate threshold without resorting to a heavy border.
Composer-as-anchor (Decision B). Two changes in message-input.tsx: (1) the composer card’s shadow stack gains an upward-pointing layer at the top of the existing stack — 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 this card; (2) 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 at the surface’s base rather than a card floating in vertical space.
Empty-state elimination. The <ChatEmptyState> panel and its chat-empty-state.tsx file are deleted entirely. MessageList’s early-return branch that swapped in the panel when messages.length === 0 && !isStreaming is removed; MessageList now always renders the standard message-list path. ChatInterface no longer derives isEmpty or passes empty-state props. The model + web-search chips on AgentHeader (previously hidden when emptyState was true to avoid duplicating the panel’s facts row) now render unconditionally as part of the agent header’s meta-chip row. Net visual: when a user opens a fresh agent they see the agent header at top (name + description + Department Agent chip + model chip + web-search chip) and the composer at bottom, with empty space between. ChatGPT pattern.
Double-<main> accessibility cleanup. The workspace layout’s outer <main> (in app/workspace/layout.tsx) is demoted to <div> — it was structural chrome wrapping the rail-plus-content grid, not the page’s main content. The chat page already has its own <main> (semantically correct). The workspace landing page and the department-detail page, which previously returned fragments and relied on the layout’s <main>, gain their own <main> wrappers with flex flex-col gap-9 to preserve the prior spacing behavior. Each page now has exactly one <main> landmark, satisfying the one-per-page accessibility contract.
Reasoning: The “contained without containing” pattern is the cutting-edge convention across every modern AI chat product. Wrapping the conversation in a contained card would have diverged from the convention rather than matching it. The investment was in the boundaries (top hairline, bottom composer anchor) and in signaling depth (the composer’s upward shadow), not in adding a container.
The Aperture surface ramp already contained the right token for the stronger hairline — --hairline-strong, documented in Session 22b’s palette retune as “card outer chrome.” The change is a one-token swap, not a token introduction; the design system was prepared for this.
The breathing-room change (mb-4 → mb-7) follows the cutting-edge convention of “deliberate space as boundary” over “visible line as boundary.” Borders alone feel heavyset; the combination of stronger 1px hairline plus deliberate space reads as a higher-fidelity threshold than either piece alone.
Eliminating the empty-state panel honors the AI-native principle that users come to a chat agent already knowing what they want to do. The panel was orientation infrastructure that the agent header already provides redundantly. ChatGPT, Claude.ai, and Cursor all converged on the same pattern — empty middle, composer at bottom — because the underlying observation is consistent: welcome screens are an older paradigm from the era of “this is a complex system, here’s what you can do.”
The double-<main> fix was identified during Step A research and bundled because it’s accessibility-adjacent to the containment work (both concern the chat surface’s structural shape) and trivially small. Pages that previously relied on the layout’s <main> had their <main> wrappers added with flex flex-col gap-9 so the previously inherited gap spacing is preserved — visual rendering unchanged.
Alternatives considered:
<main> accessibility fix to a separate session. Rejected — trivially small, structurally adjacent to the layout work in this session, free to do.Consequences:
agentUpdatedAt is no longer plumbed through ChatInterface — it was only used by the deleted panel’s “Last updated” fact column. The page’s agent_attachments query still returns full rows but only .length is consumed downstream; a future cleanup could downgrade the query to a count-only fetch.<main> violation is resolved. Each page now has exactly one <main> landmark. Pages that previously returned fragments (workspace landing, department detail) wrap in <main> with flex flex-col gap-9 to preserve their visual spacing.scrollbar-stable + -mr-[15px] three-piece machinery) remain intact. Session 28 worked within them.AgentHeader now render unconditionally. The earlier emptyState prop logic that hid them was infrastructure for avoiding duplication with the now-deleted empty-state panel; with the panel gone, the conditional has no purpose.Date: 2026-05-12 Status: Accepted
Context: D-035 deferred per-user department gating to a future session, on the premise that the open-signup posture (every authenticated user lands on all 8 departments) was a deliberate Phase-2 demo rather than a load-bearing decision. Session 26’s audit established that the user_department_roles infrastructure was already in place — table + RLS (udr_read_own, udr_admin_read_dept, udr_admin_write from migration 0001) + the has_department_access(dept_id) SQL function + the is_department_admin(dept_id) companion. The UI layer was a demo overlay: a LOCKED_DEPARTMENT_SLUGS = ["product", "compliance"] as const constant on the workspace landing that locked two departments for everyone regardless of their actual user_department_roles rows.
Operator framing during Session 29 surfaced the real customer model: law firms and in-house legal departments onboarding associates. The access model needs to support gated access during ramp-up (“here’s what your firm uses; here’s what you’ll have access to after orientation”) plus an org-level defaults pattern that scales to a 50-attorney firm without manual per-user setup per new hire. The demo posture wasn’t compatible with that — every signup got everything, and there was no surface for an admin to express “by default, new associates get Commercial and General Tools.”
Step A research confirmed schema readiness: row presence in user_department_roles already drives has_department_access(), the department_role enum carries ('dept_admin', 'user') where 'user' is the plain-access value (no enum extension needed), and the seed grants dept_admin on every department only to the explicit admin email. The only schema gaps were the absence of an org-level defaults table and an RLS over-tightening from migration 0015 — departments_read_accessible_or_admin hid every inaccessible department from non-admin users, which blocked the locked-but-visible UX outright (the user can’t see what they don’t have access to).
Decision:
Activate per-user access end-to-end. LOCKED_DEPARTMENT_SLUGS retired from app/workspace/page.tsx. The launchpad grid and the workspace rail now drive locked-vs-accessible state from real user_department_roles data via a new helper getAllDepartmentsWithAccess(userId) in lib/auth/access.ts. The helper returns DepartmentWithAccess[] — base shape extending AccessibleDepartment with a hasAccess: boolean field — so narrower-typed consumers (workspace top bar, breadcrumb) accept the richer shape via structural assignment.
Locked-but-visible UX, not hidden. Both the workspace landing grid and the rail show every department in the org, with inaccessible ones rendered in their existing locked variant: muted card / muted rail row, lock icon, “Request access” mailto link scoped to the department’s name and pointing at siteConfig.adminEmail. The card variant was preserved verbatim from the Phase-2 demo (LockedDepartmentCard in department-card.tsx); the rail’s locked-row variant is new in workspace-rail.tsx. Mirrors Notion / Linear / Slack convention: users see what their org has even when they can’t enter it yet, with a natural “ask for access” pathway.
Org-level admin-configurable defaults. New table public.organization_default_departments (migration 0020) records the set of departments auto-granted to a new user at first provisioning. Org-admin write-gated (organization_default_departments_org_admin_write), open-read inside the org (organization_default_departments_read_same_org). Backfilled with commercial + general-tools as the canonical defaults for the existing org via a generic slug-keyed insert. ensure_user_provisioned() (migration 0021) extended 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 existing unique constraint 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.
RLS relaxation on public.departments. Migration 0020 §3 drops departments_read_accessible_or_admin (from migration 0015) and replaces it with departments_read_same_org. Every org member can now read every department row’s id, slug, name, description. The agents inside each department remain RLS-gated via agents_read_accessible (uses has_department_access(department_id)) — content is gated, structure is not. The 0015 tighter policy was prophylactic, not load-bearing for the customer model: department metadata isn’t sensitive on its own, and the locked-but-visible UX requires every member to read every department’s name to render the locked card / rail entry.
New admin User access surface at /workspace/admin/users. Two sections on one page. Top section: “Default access for new users” — chip toggles over the full department list, controlled by addDefaultDepartmentAction / removeDefaultDepartmentAction. Main section: per-user access matrix — every user in the org as an expandable row (chevron disclosure), each user’s row revealing a chip-toggle group of the same department list controlled by grantDepartmentAccessAction / revokeDepartmentAccessAction. Page-level gate is isCurrentUserOrgAdmin() → notFound() (tighter than admin/layout.tsx’s requireAdminUser() which admits any dept_admin); user-access management is org-level work. Four parallel server reads at page load: full departments list, org users, current defaults, all user_department_roles rows bucketed by user. Plain Record<user_id, department_id[]> shape across the RSC boundary (Set/Map don’t serialize).
Chip toggle vocabulary. Aperture slate-blue filled chips (bg-chat-cite-bg + text-primary, matching the Department Agent / Web search chip vocabulary from Session 27) 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 (“Click a department to toggle access.” / “Click a department to toggle whether it’s a default for new users.”) followed by two color-dot indicators with explicit labels (“Granted” / “Click to grant”; “Currently a default” / “Click to add as default”). The legend + icons were added in a polish pass during Session 29 smoke testing when the operator surfaced state-ambiguity in the initial color-only design.
Server actions in lib/actions/admin-users.ts (new). Four exports: grantDepartmentAccessAction, revokeDepartmentAccessAction, addDefaultDepartmentAction, removeDefaultDepartmentAction. Centralized gateOrgAdmin() helper composes getUser + isCurrentUserOrgAdmin + organization-id resolution; centralized verifySameOrg() confirms the target user and target department both belong to the caller’s organization as defense-in-depth (RLS would reject cross-org writes, but the explicit check returns a clean validation error). Zod-validated inputs, discriminated-union { ok: true } | { ok: false; error: string } return, PG-error-code-only logging on failure (no PII per backend-security.md). Grant + revoke also call revalidatePath('/workspace') so the user’s rail and landing reflect the change on their next request.
Mailto request-access pattern preserved. Locked cards (LockedDepartmentCard in department-card.tsx) and locked rail rows (new render branch in workspace-rail.tsx) both build a department-scoped mailto: link to siteConfig.adminEmail. Two call sites of inline mailto-construction — judgment call to inline rather than extract a shared helper, near the duplication threshold but not over it.
Reasoning: The schema was already designed for this. The work was activation, not architecture. Row presence in user_department_roles as the access mechanism (versus a separate access boolean) keeps the model simple: grant = INSERT, revoke = DELETE, idempotent at the unique constraint. The 'user' value in department_role is the plain-access role; no enum extension required.
Locked-but-visible chosen over hidden for two reasons. First, cutting-edge AI-native products converge on this pattern (Notion / Linear / Slack all show locked-but-visible structure with a request-access pathway). Second, the customer model demands it: hiding inaccessible departments makes the firm’s tooling invisible to onboarding associates and breaks the “request access” mental model — you can’t ask for what you can’t see. The 0015 prophylactic gating was honest at the time it was added (Session 20’s stranger-protection posture) but doesn’t survive the customer model.
Org-level defaults chosen over no-defaults (force admins to grant per user explicitly) because the alternative breaks at scale. A law firm onboarding 5 associates per month would otherwise need to manually grant 5 × N departments per month. Defaults express “what we are by default” and admins layer exceptions on top. The defaults-apply-once-at-first-provisioning semantic is critical for stability: without it, an admin’s revoke would silently be undone the next time the user signed in. The zero-grants gate on Stage 2 preserves admin intent — any existing grant (manual or previously-defaulted) blocks further automatic defaults.
The chip clarity polish (icons + inline legend) deferred to mid-session because the initial implementation was theme-consistent and aria-correct (aria-pressed on the buttons, role="group" on the chip rows), but the operator’s smoke surfaced that color-alone differentiation isn’t enough for users who don’t read color as semantic state. The Check / Plus icons plus the legend match modern toggleable-chip convention (GitHub topic chips, Linear labels, Notion tags) and resolve the ambiguity without changing the underlying interaction. WCAG 1.4.1 (“don’t rely on color alone”) was effectively the trigger.
Alternatives considered:
departments_read_accessible_or_admin RLS policy and route locked-card metadata through a security-definer RPC. Rejected — adds plumbing for prophylactic gating that doesn’t earn its place; the simpler same_org policy is honest about what’s gated (agent content) versus what’s visible (department structure).Check / Plus icons + inline legend added for clarity. WCAG 1.4.1 grounding made the change non-optional once flagged.Consequences:
LOCKED_DEPARTMENT_SLUGS is gone. Future “locked” treatment on departments is driven by real access data, not constants. If a future product decision wants to lock departments at the build level (e.g., “Coming soon” placeholders), that needs a separate mechanism — flagged but not anticipated.organization_default_departments is a new persistent table that org-admins manage via the new UI. Future features that need org-level configuration (default agent templates, default knowledge sources, default integrations) should follow the same pattern: dedicated table, org-admin-write RLS, admin UI section.ensure_user_provisioned is now two-stage (users row creation, then defaults grants). Any future modification must preserve both stages and the zero-grants gate on Stage 2. The function’s best-effort EXCEPTION WHEN OTHERS block on the defaults INSERT means provisioning failures don’t block requests; admin-recoverable via the new UI if a defaults insert ever fails.departments SELECT policy is now permissive at the row level (same_org). Any future change that needs to hide departments per-user would need to revert or extend this — flagged for future awareness. The agent-content RLS (agents_read_accessible + has_department_access) remains the access enforcement boundary; relaxing the structure-level policy did not weaken it.Check vs. outlined with Plus, inline legend with color-dot indicators, optimistic useTransition + toast.error revert) is now the project convention for any future binary admin toggle. Future admin surfaces (feature flags, role promotion, etc.) should match.getOrgUsers, getAllUserDepartmentRoles, getOrganizationDefaults, and the four admin actions all gate on org-admin even though RLS would admit dept_admin in some cases. Org-level admin operations stay org-admin only. The pattern: app-layer gates tighter than RLS when the product policy is “this is org-level work.”siteConfig.adminEmail) is now duplicated across two render call sites — the locked card and the locked rail entry. Acceptable at two; if a third call site appears, extract a buildDepartmentRequestAccessHref(name) helper.Date: 2026-05-12 Status: Accepted
Context:
The admin surface in Session 23 shipped as three flat cards on /workspace/admin, navigated to via a single “Admin” item in the workspace rail’s profile dropdown. With more admin tools planned (invitation gate, agent admin UI, evals, cost dashboards) the flat-cards approach was about to stop scaling — both the landing and the rail entry needed structure. Two coupled questions surfaced: how should the chrome express that the user is in an administrative mode rather than the daily-driver workspace, and how should the admin tools be organized once there are more than three of them.
Decision:
Three coordinated changes ship as one commit (43f3733):
Dual-rail mode. The left rail is now pathname-aware. On /workspace/admin/* routes it renders as AdminRail (a sibling component to WorkspaceRail) with an “Admin” top-line link and three captioned groups — Access, Insights, Value. Off admin routes it renders the existing WorkspaceRail unchanged. The switch happens client-side via a thin RailSwitcher component that takes both server-rendered rails as ReactNode props and picks one based on usePathname(). Brand mark in both rails routes to /workspace, serving as a universal home gesture.
Entry and exit via the avatar menu. The existing WorkspaceProfileBlock dropdown’s “Admin” item now flips label and href contextually — “Admin” → /workspace/admin from workspace pages, “Back to workspace” → /workspace from admin pages. The menu always shows the destination, not the current location. The isAdmin prop gating is unchanged; non-admins still see no entry.
Grouped landing + source-of-truth array. /workspace/admin retained as a cards-grid landing page (rather than redirecting to the first admin tool) so admins always land somewhere predictable on entry. Cards are now grouped by Access / Insights / Value with section headers matching the rail. A single ADMIN_NAV_GROUPS array in lib/admin/nav.ts is the source of truth — both the rail and the landing consume it; adding a new admin tool is a one-line append. The ← Admin back-links on the three admin sub-pages were removed since the rail provides the same affordance globally.
A follow-up tweak in the same commit normalized admin tool labels to Title Case (“User Access”, “Adoption Metrics”, “Productivity Calculator”) across rail, card titles, breadcrumb leaves, and page h1s — deviating from the UX-writing skill’s sentence-case rule. The breadcrumb was also reworked: every non-last segment that resolves to a real route now renders as a <Link> (via a new STATIC_SEGMENT_HREFS map + resolveSegmentHref helper); scoping segments with no route (currently just “Departments”) render as plain spans.
Reasoning:
The dual-rail pattern matches admins’ actual mental model — entering Admin is entering a different mode of the application, not navigating to a deeper page. Mirroring the rail grammar (one top-line link + N captioned groups) in admin mode keeps the visual language consistent across modes while making clear which mode the user is in. The Linear and Vercel admin surfaces both work this way.
Keeping /workspace/admin as a cards landing rather than redirecting to the first tool earns its keep two ways: (a) admins entering the section always land on a predictable surface regardless of where they were last, and (b) the landing leaves room to grow into a glanceable dashboard with live metrics per card (deferred to README Future / Backlog). The strict-mirror landing now is intentionally a redundant second index of the same items; that’s acceptable as a staging point because both surfaces share a source-of-truth array and cannot drift.
The avatar-menu trigger was chosen over a persistent top-bar switcher because admin mode is bursty — users enter, do a thing, leave. A permanent chrome affordance for a rare action wastes top-bar real estate; the contextual menu entry hides naturally for non-admins and stays out of the way for admins not currently switching. The label-flip (“Admin” ↔ “Back to workspace”) makes the menu’s contents an honest signal of intent.
Title Case for admin tool labels is a scoped deviation from the UX-writing skill’s sentence-case rule. The reasoning: the Workspace rail’s department items are proper nouns (“Commercial”, “Litigation”) and naturally render Title Case. Sentence case on admin tool labels creates visual disparity between the two rails. Title Case throughout puts both rails on the same typographic footing. The deviation is scoped to admin tool names; the rest of the app continues to follow sentence case per the skill.
Alternatives considered:
Rejected — Flat nav with no categories. Three items don’t need categories today and category headers for single-item groups read as scaffolding the product hasn’t grown into. Operator chose categorized nav anticipating growth and for visual consistency with the Workspace rail.
Rejected — Admin lives at a top-level URL like /admin. Would have required threading a parallel layout system; nesting under /workspace/admin/* reuses the existing workspace chrome’s auth gates, top bar, and route group with no duplication. The mental model (Admin is a mode of Workspace, not a separate app) matches the actual data model — admin users are users with extra capabilities, not a separate principal.
| **Rejected — Persistent top-bar mode switcher (W | Admin segmented control).** More discoverable for first-time admins but wastes top-bar real estate for a rarely-used action. Discoverability can be solved later with onboarding nudges if it becomes a real problem. |
Rejected — Last-visited admin page on entry. Routing /admin to wherever the user last was inside admin felt user-friendly in the abstract but makes the entry behavior unpredictable — same click, different destination depending on hidden state. Always-land-on-/workspace/admin is the better fit for a surface admins visit, not live in.
Rejected — Strict sentence case across the board. The UX-writing skill’s default rule; would have kept admin labels as “User access” / “Adoption metrics” / “Productivity calculator”. Operator chose Title Case for visual parity with department names. Skill rule still holds elsewhere.
Consequences:
The ADMIN_NAV_GROUPS source-of-truth array becomes the canonical extension point for new admin tools. Adding a tool: one entry in lib/admin/nav.ts, both the rail and the landing pick it up automatically; the breadcrumb needs an STATIC_SEGMENT_HREFS entry + a ROUTE_TABLE row.
The STATIC_SEGMENT_HREFS map in workspace-breadcrumb.tsx now has to be kept in sync with admin route additions. Future maintenance cost is small (one entry per tool); worth flagging because it’s a second place the new-admin-tool checklist needs to touch.
Title Case on admin tool labels means future admin-section additions should follow the same pattern for internal consistency. Other parts of the app continue to follow the skill’s sentence-case rule. The CLAUDE.md skill-routing should eventually note this scoped exception.
Agent-name resolution in resolveSegmentHref matches by name string (agents.find((a) => a.name === seg)) and returns the first match. Collision risk if two agents share a name; tighten to id-keyed resolution if collisions surface. Accepted at current cohort scale.
Date: 2026-05-13 Status: Accepted
Context:
After Session 30 shipped the admin nav revamp, the operator turned attention to the Workspace rail and surface architecture. The pre-Session-31 rail carried three coming-soon entries (Knowledge / Matters / Resources) below the Departments group — placeholder categories that hadn’t yet been built and had no committed product shape. With more product surfaces planned (workflows for legal task orchestration, integrations for connecting CLMs and DMS via MCP, a help surface for documentation), the three placeholders no longer represented the product’s actual direction. Separately, the operator raised whether /workspace itself should be promoted from a department launcher to a cards-grid dashboard surfacing the product’s full set of domains.
Decision:
Session 31 ships as commit 5947326 with two coordinated outcomes:
Rail restructured around four product domains, each with multiple sub-leaves. The RESOURCE_GROUPS array in components/workspace/workspace-rail.tsx was replaced. New shape: Knowledge (Research / Vault / Sources, all coming-soon pending Session 32’s reshape), Workflows (My Workflows + Template Library), Integrations (Connections + Marketplace), Help (Guides + What’s New). The first leaf in each non-Knowledge group routes to a real placeholder page; second leaves and all Knowledge leaves fall back to coming-soon URLs. Three new placeholder pages shipped at /workspace/workflows, /workspace/integrations, /workspace/help — each a real designed page describing what the surface will become, not a generic coming-soon component. Five new coming-soon area entries (knowledge-vault, knowledge-sources, workflows-templates, integrations-marketplace, help-whats-new) for the sub-leaves that don’t yet have real routes.
Breadcrumb renders visually lowercase via CSS text-transform. The outer breadcrumb container received a lowercase class so every visible segment renders lowercase, while the underlying segment data (ROUTE_TABLE strings, STATIC_SEGMENT_HREFS keys) preserves natural case. The breadcrumb labels for the five new sub-leaves were added to RESOURCE_AREA_LABELS so they render as polished labels (“template library”, “what’s new”) rather than raw hyphenated slugs.
Naming calls that landed in the same commit: rail leaf labels follow Title Case across all groups (matching the Session 30 deviation for admin tool names — single typographic convention now applies to all nav items). “All Workflows” renamed to “My Workflows” to honestly name what the list contains (workflows the user/org authored, not all workflows in the world).
Reasoning:
The four-category structure (Knowledge / Workflows / Integrations / Help) emerged from a brainstorm grounded in how mature legal AI products organize their feature surfaces. Legora ships Workflows as an orchestration layer composing native tools; legal research products treat content partnerships (EDGAR, Westlaw, regional case law) as a distinct surface from operational integrations (CLMs, DMS); every mature help surface in premium products splits structured docs from changelog/release-notes. The taxonomy reflects those patterns rather than inventing a new vocabulary.
Knowledge specifically was reshaped from a single-leaf placeholder into a three-leaf architecture (Research / Vault / Sources) to encode the operator’s vision for the surface as a research domain with three sources (firm internal corpus, open web, trusted legal content partnerships) backed by an admin-configurable Sources surface for content integrations. The rail expresses that intended shape now even though the destinations are still coming-soon — the rail is a navigation contract that should reflect the product’s planned structure, not just what’s currently built. Session 32 will build out Knowledge’s real routes; the rail entries already exist for them to land into.
The breadcrumb lowercase decision reversed Session 30’s “match the rail’s Title Case” choice. Session 30 optimized for visual consistency between rail and breadcrumb; the lived experience showed Title Case breadcrumbs compete with page h1s (also Title Case) for typographic attention. Lowercase breadcrumbs read as ambient chrome — the URL-bar mental model — and let h1s carry the page’s voice. Linear, Vercel, Notion, GitHub all use lowercase or sentence-case breadcrumbs for the same reason. Implementation via text-transform: lowercase on the outer container preserves segment data in its natural case (department names like “Commercial” stay “Commercial” in the DOM tree; only the visible rendering is lowercase) so the data model stays honest.
Sub-leaf names were chosen against industry patterns: “My Workflows” + “Template Library” matches Zapier/n8n/Linear automations; “Connections” + “Marketplace” matches Stripe Apps/Vercel Integrations/Notion Integrations; “Guides” + “What’s New” matches Linear/Vercel/Stripe docs+changelog. Each pairing solves a real product job (cold-start for workflows, discovery for integrations, engagement for help).
Alternatives considered:
Rejected — Promoted /workspace to a five-category cards-grid dashboard. Attempted mid-session. Built a WorkspaceDashboard component reading from lib/workspace/dashboard.ts (source-of-truth array mirroring the Session 30 admin pattern), with a stagger-children entrance animation. 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 the dashboard files from the working tree and restoring app/workspace/page.tsx and components/workspace/workspace-modules.tsx from git. 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 dashboard concept moves to the roadmap for a future session when category surfaces have real content to surface.
Rejected — Workflows / Integrations / Help under a single umbrella category (“Ecosystem”). Operator initially proposed this. Pushed back: workflows are user-authored sequences (verb), integrations are connection points to outside systems (noun), help is product documentation. Different jobs, different lifecycles. One umbrella forces a name that’s true to all three, which is how umbrella-categorized SaaS navs end up reading vague. Split into three peer categories instead.
Rejected — Monitors as a top-level category. Considered surfacing regulatory monitoring as a fifth product domain peer to the others. Decided it’s a separate product (Legora ships it as a standalone product called Monitors with its own data model — subscriptions, feed items, triage state, audit trails). Added to README Future / Backlog instead.
Rejected — Strict-mirror dashboard cards (the staging pattern that worked for admin landing in Session 30). Same approach: cards-grid as v1, enrich into dashboard tiles with live data later. Failed for /workspace because the admin landing’s small surface (three cards, all real working tools) tolerates being a thin index. The workspace landing’s five-card surface where four cards are placeholders fails the test — a thin index of “coming soon” reads worse than a substantive single-purpose surface.
Rejected — Real routes for all sub-leaves on day one. Building placeholder pages for Vault, Sources, Template Library, Marketplace, What’s New (six pages total) would have shipped a thicker patch. Used coming-soon URLs instead so the rail navigation contract is in place while individual surfaces can land in future sessions without rail churn.
Consequences:
The rail’s RESOURCE_GROUPS data shape changed from a flat single-leaf-per-group array to a nested RailGroup[] where each group carries a leaves: RailLeaf[] array. Future rail additions follow this shape; adding a new sub-leaf is appending one entry to a group’s leaves array. The RailLeaf.href optional field is the extension point — leaves without an href fall back to coming-soon URLs automatically.
The breadcrumb’s natural-case data + visual-lowercase presentation split is now the canonical pattern for the breadcrumb. Future ROUTE_TABLE additions should preserve natural case in segment strings; the lowercase rendering is automatic. Session 30’s D-046 entry that documented Title Case breadcrumbs is now superseded by this entry on the specific question of breadcrumb case — the rest of D-046 stands.
The dashboard transition’s failure-and-revert pattern produced a process lesson worth carrying forward: when reverting a UI surface, restore from git literally (git show <commit>:<file> > <file>) rather than asking Claude Code to recreate the file from a description of what it used to look like. Interpretive recreation bakes in unintended changes from intermediate patches; literal restoration is byte-for-byte. This case: the hero file went through display-scale typography changes during the dashboard attempt; the “revert” via description recreated those typography changes around the pre-S31 copy. The literal restore from commit 49b8d1e fixed it. CHATBOT_HANDOFF.md should encode this rule.
Four planned future sessions inherit from this one: Session 32 wires Knowledge’s three sub-leaves to real routes (/workspace/knowledge with Research as default, plus /vault and /sources children); Session 33 builds the Workflows surface (My Workflows index + Template Library); Session 34 builds the Integrations surface (Connections list + Marketplace catalog); Session 35 builds Help (Guides v1 + What’s New changelog). The workspace dashboard concept is deferred to Session 36 or later, contingent on those four sessions shipping real content for the cards to surface.