Web Admin Console — Architecture & Build Plan
Status: Planning (2026-06-05). Owner-approved direction; chunked for incremental delivery. Purpose: Give Managers, Owners, and Admins a deterministic, form-based web interface for structured administration — while Users continue to record time conversationally in Slack. This document is the logistics reference used to chunk the work into Lenny-sized issues.
1. Why this exists (the principle)
VERA's deterministic-execution arc (Arc 7) proved an empirical dividing line. The boundary is the user-role capability surface — drawn by capability, not by who is asking:
| Channel | Scope | Examples |
|---|---|---|
| Slack (natural language) | Exactly the user-role surface — available to every role. Every user, manager, owner, and admin keeps the full user ability in Slack. Nothing more. | Log time, edit/delete own entries, read own time / envelopes / projects / tasks / time-off |
| Web console (deterministic forms) | Every verb above the user surface. All elevated manager/owner/admin capability — writes and analytical reads. | Create an envelope (allocation), add/configure a project, deadlines, budgets, financials, PM assignment, task enablement, rates, liabilities, clients, users; and analytical reads — LER, project health, capacity, team availability, retainer/sprint status |
A manager or owner uses Slack only for their personal user-level actions; all of their elevated capability lives in the web console. This makes the Slack determinism arc effectively complete — the user row (time CRUD, model-free, live-smoked) was always the whole point.
The elevated verbs are exactly the ones that drowned in routing collisions (set_rate hijack, delete_entry hijack, colon-form extraction, fuzzy project identification). A web form dissolves that entire class of failure: you don't identify a project, you select one — the dropdown value is the real id, the label is the name. No extractor, no resolver, no fuzzy match, no model.
The load-bearing architectural fact
The MCP server (
mcp-server.js) is the deterministic core — the business logic, permission gates, and financial isolation. Slack is the conversational client. The web console is the structured client. Both call the same endpoints. No business logic is duplicated; the console is a thin form layer over endpoints that already exist and are tested.
This means most chunks below are UI + plumbing, not new business logic.
2. What changes about the Slack arc
- Keep in Slack (do not deprecate): the entire
user-role surface — log time, edit/delete own entries, read own time/envelopes/projects/tasks/time-off. This is the bedrock and the thesis, and it is already complete (CRUD all deterministic, model-free, live-smoked). All roles keep this ability in Slack; nothing above it. - Migrate to web (everything above the
usersurface): allocations (allocate_hours/update_allocation/cancel_allocation/reactivate_allocation), project create/config (create_project,update_projectfields,set_project_deadline,set_project_budget, contract value / billing rate / hours budget),assign_pm,enable/disable_task_for_project, rates, liabilities, client/user management, and analytical reads (LER,get_project_health,get_project_status, capacity, team availability, retainer/sprint status). - The Slack manager/owner gates are now legacy. The deterministic Slack gates already built for allocations (M-ENV-*) and deadlines remain functional (sunk cost, working — no need to rip them out), but the web console is the canonical surface for them and we stop investing in the Slack path: no new gates, no further hardening. Whether to formally remove them is a later cleanup decision, not a blocker.
- In-flight cleanup: land the #1091
pgTenantIdcrash fix (it auto-files error issues on every owner financial attempt — stop the bleed), then freeze all further Slack-determinism work above theusersurface. - crud-matrix.md: every manager/owner cell is now "served by the web console," not a Slack determinism target. Annotate when the console ships; the
userrow stays the Slack record.
3. Logistics — how it fits the codebase
3.1 Tech constraints (match what exists; do not introduce a SPA)
- Server-rendered HTML + vanilla JS +
fetchto JSON endpoints +express.static— the exact pattern already proven bylib/dashboard.jsand/admin. No React/Vue/Svelte. Rationale: zero new heavy deps (licensing — MIT/Apache/ISC/BSD only, perNOTICE), consistency, and the dashboard already demonstrates the pattern at the needed complexity (ApexCharts is the only vendored asset, inpublic/). - New HTML renderers live in a
lib/console/module set (mirroringlib/dashboard.js); new routes inindex.js(or a dedicatedlib/console-routes.jsmounted inindex.js). Static assets inpublic/. - Every request that touches Postgres runs inside
requestContext.run({ pgTenantId, tenantId }, …)(see the dashboard routesindex.js:2990,3045) — this is the gotcha that crashed #1091; every console DB read/write must establish context.
3.2 Business-logic reuse (no duplication)
- Console write handlers call the existing MCP endpoints the same way Slack does — via
callMCPTool(toolName, params, tenantId, …)(internalfetchtolocalhost:PORT/mcp/...) — passingrequesting_user_slack_idfrom the authenticated session andtenant_idfrom the session. - The endpoints already enforce permissions (
checkPermission,checkEnvelopePermission,isOwnerLevel, the resolver-independent server gates). The endpoint is the security boundary. The UI hiding controls a role can't use is UX/defense-in-depth, not the gate. (e.g. a Manager's session physically cannot setcontract_valuebecauseupdate_project403s — same isolation as Slack, already proven by #1084 Part A.) - Reads reuse
lib/dashboard.jsquery functions (queryDashboard,queryProjectDetail) and add new typed read queries inlib/console/as needed.
3.3 Permission & isolation (unchanged contract)
- Role resolution: session
slack_user_id→usersrow →role. Role drives nav + control visibility client-side AND is re-checked server-side by every endpoint. - Financial isolation holds for free: Manager sessions never receive
contract_value/billing_rate(gated atget_project_info), and writes 403 server-side. Do not re-implement isolation in the console — inherit it from the endpoints. - Audit trail: console writes flow through the same endpoints, so the same audit-channel posts fire (the audit logic is in the Slack layer today — see §5 open question).
4. Authentication — the one decision needed before Chunk 0
The console is per-user, role-aware — distinct from the existing auth:
ADMIN_SECRET(operator-only,/admin) — wrong: it's a single shared operator secret, not per-user, and grants tenant config not project admin.- Slug /
client_report_token(/d/:slug, public read) — wrong: unauthenticated, read-only, no user identity.
Users already live in Slack, so tie console identity to Slack. Options:
| Option | How | Trade-off |
|---|---|---|
| A. Magic link via Slack DM (recommended for v1) | User DMs VERA open admin (or clicks a button in the morning briefing) → VERA DMs a short-lived signed URL (/console?t=<signed token>) → clicking it sets a signed session cookie (slack_user_id + tenant_id, short TTL, HttpOnly/Secure). | Lowest friction; leverages the bot they already use; identity is provably their Slack account (only they receive the DM). No OAuth app setup. Token signing = HMAC with a server secret. |
| B. Sign in with Slack (OAuth) | Standard "Sign in with Slack" button → OAuth → resolve Slack user id → session. | Most standard/secure; more setup (OAuth scopes on the distributed app A0AE27UD6H3); heavier for a small client base. |
| C. Per-user static token | Like client_report_token but per user. | Simplest; weakest (long-lived shared-able token). Not recommended. |
Recommendation: Option A (magic-link via Slack DM) for v1, with a clean seam to upgrade to B later. Session = signed cookie, short TTL (e.g. 12h), re-issued via a new DM. Rate-limit the link endpoint. Reuse the crypto.timingSafeEqual + HMAC patterns already in the codebase.
→ Tom decides A/B/C before Chunk 0 is specced.
5. Cross-cutting concerns (apply to every chunk)
- Tenant scoping: every read/write wrapped in
requestContext.run; tenant comes from the session, never a URL param the user can change. - Role gating: nav + controls rendered per
role; server endpoints are the real gate. Owner/Admin see financials; Manager sees percentages + their PM'd projects; never surface dollar figures to a Manager. - No new heavy deps (licensing). Vanilla JS only.
- Audit parity: console actions should produce the same audit-channel posts as Slack. Open question: audit posting currently lives in the Slack message handler (
index.js), not the endpoints — so console calls won't post audits unless we either (a) move audit-posting into the endpoints, or (b) have the console post after a successful call. Recommend (a) eventually (single source of truth); (b) as a v1 shim. Flag in the first write chunk. - Testing: endpoint logic is already covered by
tests/mcp-routes.test.js. New console code needs: route auth tests (unauthenticated → redirect/401; wrong-role control hidden + endpoint 403), and HTML-render unit tests (mirrortests/dashboard.test.js). Pure render functions inlib/console/are unit-testable without a browser. - Mobile-reasonable: owners check things on phones; keep the CSS simple/responsive like the dashboard.
6. Chunked roadmap (each row = one future Lenny issue)
Vertical slices — each ships something usable and de-risks the next. Spec each when the prior lands (the proven George→Lenny cadence). Do not open them all at once.
| Chunk | Deliverable | Proves / de-risks | Depends on |
|---|---|---|---|
| C0 — Auth + shell + one read ✅ | Slack magic-link sign-in → signed session → a single authenticated page listing the tenant's projects (name, type, status, PM). Role shown. Sign-out. Shipped: issue #1096. | The entire stack end-to-end: auth, session, tenant context, role resolution, a Postgres read inside requestContext, server-rendered HTML. Highest-risk chunk — keep the read trivial. | Auth decision (§4) |
| C1 — Project detail (read-only) ✅ | Click a project → detail page: type, status, deadline, hours budget, PM, enabled tasks, allocations, and (Owner/Admin only) financials. Reuse queryProjectDetail. Shipped: issue #1097. | Role-gated rendering (financials hidden from Manager), data shape, navigation. | C0 |
| C2 — First write: deadline ✅ | Editable deadline on the detail page → POST → set_project_deadline endpoint with session slack_user_id → success → re-render. PM-of-project gating surfaced (non-PM control disabled; endpoint 403 handled). Shipped: issue #1101. | The write plumbing end-to-end + permission gating + audit shim (§5). One field, fully done. | C1 |
| C3 — Remaining project config writes ✅ | Generic /field route on the C2 spine covers status, notes, is_billable (manager+) and hours_budget, contract_value, billing_rate (owner only) via update_project. lib/console/field-specs.js is the single source of truth for tier, validation, coercion, and audit formatting (financial fields redacted per rule 21). Shipped: issue #1103. | Financial isolation in the UI; multi-field forms; audit redaction; the full update_project surface that was painful in Slack — now deterministic. | C2 |
| C4 — PM assignment + task enablement | Assign PM (user dropdown → assign_pm); enable/disable tasks (checklist → enable/disable_task_for_project). | Selection-not-naming (kills the identification problem); checklist UX. | C1 |
| C5 — Project create | "New project" form: type (T&M / Fixed Price / Pro Bono / Internal), client (dropdown), budget/contract/deadline per type rules → create_project. | The create wizard as a form (vs the Slack multi-turn wizard); per-type required fields. | C3 |
| C6 — Clients & Users | Client list/create/rename/archive; user list/role/employment-type/rate (Owner/Admin). | The remaining reference-data admin. | C0 |
| C7 — Rates & Liabilities (Owner/Admin) | Set/view rates (set_rate/get_rates); create/list/cancel liabilities (vendor-only). | Owner-only financial surfaces; audit suppression rules (rates never to audit channel). | C6 |
| C8 — Allocations | Envelope management UI: per-project and per-person allocation tables, create/update/cancel/reactivate envelopes (allocate_hours et al.). | The full envelope surface as forms (selection, not naming). Core — allocations are now a web verb. | C3 |
| C9 — Analytical reads / reporting | Role-aware internal views: project LER, health, burn-down, capacity, team availability, retainer/sprint status. Reuse lib/dashboard.js queries; gate financials by role (Owner/Admin see dollars, Manager sees percentages). | The box-2 analytical surface that runs through Claude in Slack today, served deterministically from existing dashboard math. | C1 |
v1 = C0–C4 (sign in, see a project, edit its config + deadline + financials + PM + tasks). That alone moves the painful structured-config surface out of Slack. C5–C9 follow as appetite allows — C8 (allocations) and C9 (analytical reads) are now core, not optional, per the refined boundary (everything above the user surface lives here).
Shipped status (2026-06-07): C0, C1, C2, C3, access-lockout, C4 (+ single-save consolidation), C5 (+ follow-up), C6 (clients), C7 (rates & liabilities, #1120/#1121), C8 (allocations), C9 (analytics) are merged. C-USERS — Users management (owner/admin only) built (#1123): list (incl. inactive), add, change role, change employment type, rename, deactivate/reactivate, all PRG with self-lockout guards (can't deactivate self, can't change own role) and the hidden-
slack_user_id-or-exact-name identity strategy. This was the last remaining chunk — console v1 is now complete. Detailed per-chunk status + PR refs live in theproject_architectural_arcsmemory (Arc 8).
C-ENV-OVERVIEW — top-level Envelopes overview (#1204): GET /console/envelopes (manager+, nav item between Clients and the owner-only items). Single table of every active allocation envelope across all projects — ID · Project · Deadline · Person · Task · Allocation · Logged · Remaining (+ % used, amber ≥80%/red >100%) · Actions. get_allocations gained an include_usage=true enrichment (hours logged, remaining, % used, display project/deadline resolved to the retainer/agile parent where applicable) — additive only, existing callers unaffected. Edit/Cancel reuse the per-project allocation modals and the existing /allocations/update / /allocations/cancel routes, which now accept an optional allow-listed return_to form field so the action redirects back to /console/envelopes instead of the project page. Cancelled-envelope display + reactivate stayed out of scope (fast-follow candidate).
Forecast page (#1177/#1178): GET /console/forecast (owner/admin only). Engine: queryForecast(granularity) in lib/forecast.js — straight-line contract revenue over each project's [start_date → deadline], projected forward from today only. Labour cost = Σ(allocated hours × real effective rate) + gap hours × agency blended rate + active liability totals, distributed over the same duration. Agency blended rate = realised Σ(hours×rate)/Σ(hours) over trailing 90 days for active delivery resources (fte+contractor); fallback to mean of current rates. Per-project allocation flag (matched / under / over) from Σ allocated vs hours_budget. Supports month / quarter / year granularity; horizon = latest active project deadline (capped 24 months / 8 quarters / 3 years). Page: granularity <select> switch; ApexCharts line chart (Revenue / Cost / Profit series, vendored /static/apexcharts.min.js, no new dep); period table (Period · Revenue · Cost · Profit · Margin · LER + Total row); per-project breakdown with colour-coded allocation-flag badges; footnote citing the blended rate. Owner-only nav tab; manager 403.
Project name crosslinks (#1179): project names are now links to their /console/p/:id detail page wherever they appear in the console. A shared _projectLink(id, name) helper in render.js (near _badge) renders <a href="/console/p/:id">name</a> for any project with an id, falling back to plain escaped text. Applied in the forecast per-project breakdown (previously plain text) and the home project list (refactored to use the helper — behaviour unchanged). Client dashboard (/d/:slug) is out of scope.
Person detail page + person links (#1199): GET /console/people/:userId (owner/admin only). Reads readUsers, readBudgets({ user_id, status: 'active' }), readRateHistory({ user_id }), readTimeEntries({ user_id }), and readDisciplines inside requestContext.run. Resolves current cost rate as the most-recent rate_history row with effective_from <= today. Builds a person view-model: { id, name, role, employment_type, discipline, email, hourly_cost, allocations: [{ project_id, project_name, task_name, allocated_hours, hours_logged, remaining }] }. Page: identity card (Name · Role · Employment type · Discipline · Email · Current cost rate), active allocations table (Project via _projectLink · Task · Allocated · Logged · Remaining, red when negative), empty-state row when no allocations, ← Back to Project Managers nav. A shared _personLink(id, name) helper (parallel to _projectLink) renders <a href="/console/people/:id">name</a> — applied to PM name cells in renderConsolePms so PM names on the PMs tab are now clickable (PMs tab is owner-only, so the target page is always reachable). No new endpoint or dependency.
Forecast period drill-down (#1200): GET /console/forecast/:period (owner/admin only). Clicking a period label in the forecast table navigates to a line-itemised P&L view for that period. queryForecastPeriod(label, granularity) in lib/forecast.js runs the same 6 queries as queryForecast but decomposes each project into individual line items: one revenue item per project (contract_value / duration_days × overlap_days); one kind: 'person' cost item per allocation (allocated hours × effective rate / duration × overlap); one kind: 'gap' item per project where budget exceeds allocations (gap hours × agency blended rate / duration × overlap); one kind: 'liability' item per active liability (agreed_amount / duration × overlap). _windowForLabel(label, granularity) converts a label string to {period_start, period_end} using the same calendar arithmetic as _periodWindows. Page (renderConsoleForecastPeriod): heading P&L — {label}; summary card (Revenue / Cost / Profit green-red / Margin / LER); revenue table (Project via _projectLink · Amount + Total); cost table (Project · Item · Amount + Total), where Item is a person via _personLink, plain Unallocated hours (blended rate) for gap, or a vendor via _personLink; ← Back to forecast link preserving ?granularity. Invalid labels (fail regex for the active granularity) redirect 302 to /console/forecast. Period labels in the forecast table changed from plain text to <a href="/console/forecast/:label?granularity=…"> links.
Per-project P&L card (#1152): the project detail page (/console/p/:id) gained a Profit & Loss card visible to owner/admin only. Displays Revenue, Cost, Profit (Revenue − Cost, green/red by sign), and Margin %. Reuses revenue/labor_cost already computed by queryProjectDetail — no new SQL. The card is set on vm.pnl only in the owner-gated analytics block, so a manager's payload never carries it.
Reports tab (#1153): new /console/reports page (owner/admin only; Reports nav item visible to owners only). Shows per-month Revenue, Cost, Profit as a table (newest first) with a CSS bar chart and a Total row. Time-based earned P&L: cost = hours × cost-rate by entry month; revenue = hours × implied billing-per-hour (T&M: billing_rate; Fixed Price / Retainer period / Sprint: contract_value / hours_budget; Internal/Pro Bono: 0). queryMonthlyPnl() added to lib/dashboard.js. No new dependencies.
Chart helper + project burn-up (#1252): lib/console/charts.js (new) — pure, dependency-free inline-SVG helpers for the server-rendered console; lineChart({ series, xDomain, yDomain, markerX, fmtX, fmtY }) draws axes, gridlines, a "today" marker, per-series polylines/area fills, hover <title> tooltips, and a legend. Colours come from --vera-color-* tokens. queryProjectBurn(projectId) (lib/dashboard.js) returns the cumulative-hours time series for a project (rolled up across period/sprint children) plus implied_today (vendor-liability hours accrued via computeImpliedVendorHours). The project detail page's Analytics & Health card renders an ideal-vs-actual burn-up chart (dashed linear-pace line vs. filled actual-hours area, today marker) whenever the project has both a deadline and an hours budget. This is the pattern-setter other chart issues (LER trend, revenue/cost bars, forecast area, capacity heatmap, portfolio quadrant, sparklines) build on.
Reports tab (#1158): three additions to the monthly P&L. (1) Vendor-liability accrual — active liabilities are distributed linearly across months using the same day-fraction basis as computeImpliedVendorCost; month sums reconcile with per-project P&L. Liability-only months (no time entries) appear in the table with revenue=0 and positive cost. Footnote updated. (2) LER column — per-month revenue / cost (dash when cost=0); Total row shows pooled LER. (3) Margin % column — profit / revenue rounded to the nearest percent (dash when revenue=0); Total row shows overall margin.
Project Managers tab (#1154): new /console/pms page (owner/admin only; Project Managers nav item visible to owners only). Compares PMs across: LER (pooled Σrev/Σcost), On course % (% of projects within 10% of the plan line, over or under), and Avg/Total Profit. Population: active client projects with pm_user_id set. queryPmComparison() in lib/dashboard.js mirrors queryDashboard's revenue/cost CTEs (numbers always reconcile with the dashboard); aggregation in JS using computeLer/scheduleStatus. Sorted by LER desc. No new dependencies.
Project Managers tab (#1159): Active / All scope toggle at the top of /console/pms. Default = Active (status=active); All = active + completed (status IN ('active','completed')); cancelled always excluded. Server re-query per scope — queryPmComparison(scope) accepts 'active' (default) or 'all' and parameterises both the revenue_calc CTE filter and the final WHERE with = ANY($1). Route reads ?scope and whitelists to 'all' or 'active'. Toggle rendered as linked .cat-btn buttons (same styling as the project-list Client/Internal toggle) with updated footnote per scope.
Note: the existing client dashboard (
/d/:slug) already computes LER / EM / burn-down — C9 is largely a role-aware, authenticated reskin of those queries for the internal audience, not new financial math.
Responsive nav refactor (#1160): replaced the horizontal top-bar nav with a left vertical sidebar (<nav class="console-sidebar">) visible on screens ≥768px and a fixed hamburger button (☰) that toggles it on mobile. Layout: <div class="console-layout"> → sidebar + <div class="console-content">. Pages that use the sidebar call the new _pageWithNav(title, navHtml, mainHtml) wrapper; centered/non-nav pages (login, owner-only gate) keep _page(title, bodyHtml). Active section highlighted with aria-current="page" and a 3px blue left border. CSS is fully inline (no new files). _consoleNav rewritten; all 7 authenticated page renderers converted.
Global nav (#1126): every console page now renders the same header nav (_consoleNav in lib/console/render.js) — Projects · Clients · (Owner/Admin: Rates · Users) · signed-in identity · sign out — with the current section marked <strong aria-current="page"> instead of a link. Replaced the four bespoke per-page header spans (home, project detail, clients, rates, users); project detail marks nothing current since it isn't a top-level section.
Project-list filter (#1127): the /console/ home table gained a Client column (_client_name, enriched from readClients() the same way _pm_name is enriched from readUsers()) and a client-side filter box (#proj-filter) above it. Each <tr data-filter="…"> carries the lowercased project + client + type + status + PM, and an inline script hides non-matching rows on input — presentation only, no refetch, no new financial/aggregate columns (those remain a deferred "rich list view" arc).
Project-list column sort (#1128): clicking a <th data-sort-key="…"> (Project · Client · Type · Status · PM) reorders <tbody> rows client-side via localeCompare on lowercased data-<key> row attributes; a second click reverses, and a ▲/▼ caret marks the active column. All five columns are text — no numeric/financial sort path exists (or is planned) for this table; that's deferred to the same future "rich list view" arc as the filter's missing columns. Plays nicely with the #1127 filter: sorting reorders only the currently-visible rows, hidden ones stay hidden.
Project-list type/status filters (#1133): the /console/ home gained two <select> dropdowns — Type and Status — beside the #proj-filter text box. Their <option> lists are derived server-side from the distinct type/status values present on the rendered parent projects (sorted, with an "All …" default), so they're always accurate and never stale; option values are lowercased to match each row's data-type/data-status, labels keep the original casing. The three controls compose with a single logical AND in one unified inline filter handler (replacing the #1127 text-only one) — text matches data-filter, type matches data-type, status matches data-status; an empty/"All" selection imposes no constraint. Presentation only, no refetch, no new endpoint; composes cleanly with the #1128 column sort (filtering re-evaluates stable data-* attributes regardless of row order).
Clear-filters button (#1135): a "Clear filters" <button id="clear-filters"> sits at the end of the filter row and is hidden by default (display:none). The unified apply() sets it visible when any of the three filter controls is active (non-empty trimmed text, or a non-"All" select value) and hides it again when all three return to empty. Clicking it resets all three controls to empty and calls apply(), which re-shows all rows and hides the button. The sort order is unaffected. Still exactly one filter IIFE, no new script blocks.
Client/Internal category toggle (#1137): a segmented <div id="category-toggle"> (Client / Internal buttons) sits top-right of the filter row, pushed there with margin-left:auto. Exactly one segment is always active — it's a view mode, not a clearable filter, and is deliberately excluded from the Clear-filters button's anyActive check. Each row carries data-category="client" or data-category="internal", derived from project_category (client → client; internal/pto/sick/leave → internal). The unified apply() ANDs an okCat predicate on top of text/type/status, and the page applies the default filter on load so internal/absence rows are hidden until "Internal" is selected (default-active = Client, the primary delivery view). Because the toggle now owns the category split, the Type dropdown (#1133) no longer offers "Internal" as an option — Client + type=Internal would otherwise be a guaranteed dead-end, and on the Internal side every row is already type Internal so the option was redundant; the Type column still displays "Internal" for those rows since that remains their real type.
Project-detail async form submits (#1138): all 9 POST forms on /console/p/:id (config save, PM assignment, task enable/disable, allocations new/update/cancel/reactivate, liabilities new/cancel) submit via fetch instead of a full navigation. The existing Post/Redirect/Get handlers are untouched — fetch(form.action, { redirect: 'follow' }) follows the 303 to /console/p/:id?saved=…/?error=… and the GET re-renders the page; the client swaps the returned <main> into the live DOM (curMain.replaceWith(newMain)), so the page reflects the new state — and its server-rendered banner — with no reload. Event delegation on document means forms inside a swapped-in <main> keep working without re-binding. The save/error banner on this page carries a banner-toast class (position: fixed, centred at the top, auto-dismissed after ~3.5s) so it floats against the window rather than sitting in-flow; other console pages keep the plain in-flow .banner. A network-error fallback inserts a red toast and re-enables the submit button; a DOM-parse fallback reloads the page. No endpoint or route changed — purely renderProjectDetail markup, CSS, and one appended script.
Allocations card heading + layout (#1141): the card title is now N Envelope(s) — the count of active envelopes is always shown, including 0 Envelopes when there are none. When there are no active envelopes the table is omitted entirely (no empty "No active envelopes." fallback row); the card shows just the heading and the Add-envelope form. The active-envelopes table sits above the cancelled-envelopes section, which sits above the Add-envelope form — mirroring the liabilities card layout (data first, action last). Both Hours inputs (<input type="number">) gained inputmode="decimal" for a numeric keypad on mobile. Presentation-only change; no endpoint or data-shape change.
Row IDs surfaced for differentiation (#1143): the Project list, Envelopes, Cancelled envelopes, and Vendor liabilities tables all gained a leading plain-text ID column (p.id / a.allocation_id / a.id / l.id) so visually-identical rows (e.g. two "Tom Hart · Strategy" envelopes) can be told apart and referenced. IDs are sequential row ids — non-sensitive, shown to every console role that can see the table. Presentation only; no endpoint or view-model change.
Table action-button alignment (#1145): .save-btn carries margin-top: 1rem for its primary stacked-form (form-stack) context; that leaked into table-cell buttons (Envelopes Save/Cancel/Reactivate, Liabilities Cancel), pushing them down. A scoped td .save-btn { margin-top: 0 } override zeroes it in every table cell at once, and the Envelopes actions cell is now a display:flex row with both the update and cancel forms set to inline-flex;align-items:center so their baselines match the hours input. Cosmetic/CSS only.
Type dropdown hidden on the Internal tab (#1146): since #1137 the Type dropdown only lists client project types (Internal was removed from its options), so on the Internal segment of the Client/Internal toggle it could only ever filter the all-internal list down to zero rows. The filter IIFE now calls syncTypeVisibility() — hides (display:none) and clears the Type <select> whenever the active category is internal, and restores it when switching back to Client. Called on category-toggle clicks and on initial page load, both ahead of apply(). Client-side only, composes with the existing text/status filters and Clear-filters.
Console nav polish (#1164): three lib/console/render.js-only changes. (1) Sticky sidebar: .console-sidebar gained position: sticky; top: 0; height: 100vh; overflow-y: auto; — the sidebar pins to the viewport top while content scrolls on desktop (≥768px); the mobile .console-sidebar.open rule uses position: fixed and still overrides it below 768px. (2) Brand link: the VERA Console brand element changed from a <div> to <a href="/console"> with text-decoration: none; color: inherit; display: block so it navigates to the project list from any child page; hover turns it blue (#0b66c3). (3) PM scope as dropdown: the segmented .cat-btn Active/All toggle on /console/pms replaced by a <select onchange="location.href='/console/pms?scope='+this.value"> dropdown matching the project-list filter style; defaults to Active projects; selecting re-queries server-side. Route and scope semantics unchanged.
Modal layer (#1166): reusable _modal(id, title, innerHtml) and _modalTrigger(id, label, btnClass) helpers added to lib/console/render.js. All console forms now live behind a trigger button → modal (with a × close button); the page shows only data and action triggers, not expanded forms inline. A global <script> appended to every _pageWithNav page closes any open modal on backdrop-click or Escape. Destructive actions (archive, cancel, deactivate) become confirm modals — the user must open the modal and click the red action button, a deliberate safety improvement. The CSS adds .modal-overlay / .modal-box / .modal-head / .modal-body / .modal-close and a .save-btn-danger class (red variant of .save-btn). Rolled out page-by-page: Clients (#1166), Project detail (#1167), Users (#1168), Rates + New Project (#1169).
Project-detail modals (#1167): renderProjectDetail and _liabilitiesCard converted to the modal pattern. Cards now show read-only dl/tables inline; the 9 submittable forms (config edit, PM assign, task enable/disable, add envelope, per-row envelope edit, per-row envelope cancel, cancelled-envelope reactivate, add liability, per-row liability cancel) live behind triggers → modals. Cancel/reactivate/liability-cancel are confirm modals with explanatory text. All modals are appended inside <main> so the #1138 async-swap logic naturally closes them on success (the server re-renders <main> with every modal closed).
Users modals (#1168): renderConsoleUsers converted to the modal pattern. Table is read-only (Name · Role · Employment · Discipline · Status); per-row "Edit" trigger opens a modal with role (hidden for self), employment-type, and rename forms stacked as separate <form>s. Deactivate and reactivate are confirm modals. "+ Add user" trigger opens the add form in a modal. Self-lockout guards (no role change or deactivate for own row) are preserved.
Rates + New Project modals (#1169): the Rates page per-row set-rate form moves behind a "Set rate" trigger → modal (table stays read-only). The project-list "+ New project" button opens the create-project form in a modal on renderConsoleHome (the home route now also reads clients and passes them so the modal's client dropdown is populated). Completes the modal rollout across the console.
Delete affordances for empty records (#1181/#1182): owner-only hard-delete is surfaced via confirm modals for records that have never accumulated history. Clients list shows a "Delete" confirm modal for active non-Internal clients with project_count === 0. Project detail shows a "Danger zone" card with a "Delete project" confirm modal when the project has no time entries, no allocation envelopes (any status), no liabilities, and no child periods. Both routes redirect away on success (client delete → clients list ?saved=client-deleted; project delete → console home ?saved=project-deleted — the detail page is gone). The project delete form carries data-full-nav="true" so the async submit handler lets the redirect do a full page navigation rather than swapping <main>. The backend delete_project / delete_client endpoints are the gate (owner-only, empty-only); the UI only hides the button when not deletable. Both delete routes post a 🗑️ <user>: Deleted <project|client> "<name>" audit message on success; no dollar figures (deletes have none — rule 21 trivially satisfied) (#1187).
Internal client protected affordances (#1183): the Internal client's Rename and Archive (and Delete) action buttons are hidden in the console clients list — replaced by a muted "Protected" label. The backend guards remain the real security boundary (rename_client and archive_client both 403 Internal); the UI suppression matches how Delete is also hidden for Internal, giving consistent visual treatment for all protected-client operations.
Edit-project modal rename (#1188): the Edit project modal now includes a Project name field as its first input, prefilled with the current name. Submitting a changed name calls rename_project (which owns the protected/em-dash/collision guards) — field updates via update_project / set_project_deadline run first (they reference the old name), then the rename. Protected time-off projects (PTO, Sick, Leave) render the field disabled with a "Protected — cannot be renamed" note; the endpoint 403s them as well (defence in depth). Rename errors (collision, protected) surface as ?error. Audit: ✏️ <user>: Renamed "<old>" → "<new>".
Workspace switcher (#1202): the sidebar nav footer now shows the current workspace name above the user name/role line, and a "Switch workspace" link for email-identity sessions (can_switch flag). The switch flow is identity-scoped (email) and re-verifies on every switch: GET /console/switch calls findUsersByEmail(user.email) and renders a list of the identity's accessible workspaces — current workspace marked, others with a "Switch" button; POST /console/switch re-runs findUsersByEmail, checks the posted tid against the live match set (posted tids not in the set are denied and redirected to ?error=denied), then re-signs a { tid, uid } session for the chosen tenant. Slack magic-link sessions (no user.email) see a notice prompting them to use an OAuth sign-in instead; the "Switch workspace" link is hidden in their nav. No new dependency; reads run inside requestContext.run.
7. Decisions (resolved 2026-06-05)
- Auth model — OAuth-first (Google / Microsoft) + Slack magic-link fallback (arc 2/3, #1192). The console login page shows "Sign in with Google" / "Sign in with Microsoft" buttons for configured providers. OAuth verifies identity by email; the
users.emailcolumn (migration 0024) is the anchor. Multi-tenant operators (email matched in multiple tenants) see a tenant picker. Session payload:{ tid, uid }whereuid=users.id(integer). The Slack magic-link flow (open consoleDM → HMAC-signed URL →/console/auth?t=<token>) is retained as a fallback; its sessions carry{ tid, sid }(Slack user id).requireConsoleSessionresolvesuidfirst, falls back tosid. No email-sending; no new npm deps (standard OAuth 2.0 code flow with built-infetch). Register callback URIs as${CONSOLE_BASE_URL}/console/auth/google/callbackand/microsoft/callback. Providers disabled by default (buttons hidden) until*_OAUTH_CLIENT_ID/SECRETenv vars are set. The Users console page (owner/admin only) is where this identity email is set: the per-row Edit modal and the Add-user form both have an Email field, wired throughupdate_user_role(new_email) andadd_user(email) — per-tenant unique, validated, audited. This is the only way to populateusers.email; OAuth sign-in matches against whatever is set there. - Audit posting — endpoint-side (target), console shim for v1. Move audit-posting into the endpoints as the single source of truth (so both Slack and console emit identical audits); a console-side post-success shim is acceptable for v1 until that refactor lands.
- Hosting — same Railway service, mounted at
/console. One deploy, shares the in-process MCP server. - v1 scope — C0–C4, confirmed.
C0 is specced as the first prescriptive Lenny issue; the cadence begins.
8. What this does NOT change
- Users keep logging time in Slack — untouched, and the reason VERA gets adopted.
- The MCP endpoints, permission gates, financial isolation, RLS, audit rules — all reused, not rewritten.
- Manager allocations stay in Slack (unless C8 proves the web form is better).
- The dashboard (
/d/:slug) stays as the client-facing read surface; the console is the internal admin surface (different auth, different audience).