legalos

legalOS: Decision Log

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.”


D-002 — Single-tenant deployment, multi-tenant-ready schema

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:

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.


D-003 — Database-driven agent definitions

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.


D-004 — Role-based access with per-department permissions

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.


D-005 — Hosting and backend: Vercel + Supabase, Next.js full-stack

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.

D-005a — Python/FastAPI backend (reversed)

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.

D-005b — Vite + React on GitHub Pages (reversed)

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.


Date: 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.


D-008 — Native agent API calls are server-only; API keys never reach the browser

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.


D-009 — Row-Level Security on every table from table creation

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.


D-010 — Analytics start in localStorage in Phase 1, move to Supabase in Phase 2

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.


D-011 — Skill adoption: start with ~11 Phase 0 skills; reach ~24 of 25 by Phase 7

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.


D-012 — Documentation discipline: six docs kept current, per-change updates are mandatory

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.


D-013 — Framework version: Next.js 16 (not 15)

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).


D-014 — Styling: Tailwind CSS v4 (not v3)

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:


D-015 — Component primitives: shadcn/ui on Base UI (not Radix)

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.


D-016 — Directory structure: narrow root, lib/ as home for actions and hooks

Date: 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/.


D-017 — Next.js 16 proxy file convention (formerly middleware)

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.tsproxy.ts) and the exported function name (middlewareproxy) 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:


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.


D-019 — Functional parity rule for reference ports (Constraint C)

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:


D-020 — Adoption metrics page is paraphrased; Session 6 rebuild required under Constraint C

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:

Consequences:


D-021 — Demo-data toggle on adoption metrics page completes the original’s intended real-data path

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:


D-022 — Defer claude-templates sync-back past Phase 2

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:


D-023 — Native agent runtime architecture (Phase 2 foundations)

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:

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:

Consequences:


D-024 — Consolidate to a single Supabase project (retire dev project)

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:


D-025 — Expand Phase 2 scope: agents are user-owned, configurable, and extensible

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:

Consequences:


D-026 — Rename to legalOS

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:

Consequences:

D-029 — Department restructure: merge GRRA into Public Sector, add General Tools

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:

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:

Consequences:

D-027 — Font reversal: D-022 system-ui → Inter Tight + Geist Mono, self-hosted

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:

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:

Consequences:

D-028 — Constraint B relaxed: shadcn primitives for interaction, design tokens for visual

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:

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:

D-030 — Chat surface widens to max-w-4xl while prose stays at max-w-3xl

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:

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:

Consequences:


D-031 — Citation marker stability + trace card grouping (Session 18)

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:

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:

Consequences:


D-035 — Open signup posture deferred to post-Phase-2

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:

Consequences:

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.


D-036 — Workspace relocation to /workspace prefix and marketing landing at /

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:

Consequences:


D-037 — Light-mode palette retune: warm-tan family lifted proportional to sRGB headroom

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:

Consequences:


D-038 — D-035 status: marketing landing public but no signup shipped, threat model unchanged

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:


D-039 — Login surface state machine, visual polish, and authed-user bounce

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:

Consequences:

D-040 — Custom SMTP via Resend (sandbox mode)

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:

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.

D-041 — Direct-manipulation inline edit for admin-editable copy

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.departmentsdepartments_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:

Consequences:

D-042 — Pattern B canonical templates: activation, admin lifecycle, and chat-with-template UX

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:

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:

Consequences:

D-043 — Chat-surface centerline alignment: shrink-to-content regression fix and right-anchored user bubbles

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:

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:

D-044 — Chat-surface containment via top-boundary strengthening, composer-as-anchor, and empty-state elimination

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-4mb-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:

Consequences:

D-045 — Per-user department access activation: real access model, org-level defaults, admin user-access surface, locked-but-visible UX

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:

Consequences:

D-046 — Admin nav surface: dual-rail mode, grouped landing, Title Case for tool labels

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):

  1. 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.

  2. 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.

  3. 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:

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.

D-047 — Workspace rail expanded to four product domains; dashboard transition attempted and reverted

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:

  1. 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.

  2. 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:

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.