Development guide
This page is for contributors, not users
It's for anyone changing the CapacityLens code. If you just run or use CapacityLens, you don't need it — see the glossary or the guide instead.
This page gets the CapacityLens source running on your machine, explains how the repository is laid out, and lists every check CI runs before a change merges. It's for anyone editing code, not just running the product. Getting a dev server up takes a few minutes; running the full check suite takes longer.
Prerequisites
- Node 24.19.0 or newer within 24.x, selected by
.nvmrc. - pnpm, through Corepack — the version is pinned in
package.json'spackageManagerfield. lsof, which ships with macOS; install thelsofpackage on minimal Linux systems.- Docker, only if you plan to run the Docker Compose smoke tests.
Set up the repository
nvm use
corepack enable
pnpm installCheck Node 26 compatibility
Node 24.x, starting at 24.19.0, is supported and remains the development, build and deployment baseline. Node 26.x, starting at 26.9.0, has experimental compatibility maintained on a best-effort basis. Use the latest patch within either line. Other majors are outside this policy. Node 26 becoming LTS does not change the project's default or support commitment.
The experimental floor includes the SQLite backup completion fix in Node 26.9.0. Earlier Node 26 releases are refused. Package constraints, the source preflight and packaged entrypoints enforce the same runtime floors. The discovery record preserves the earlier investigation; issue #710 records current evidence and limitations.
Run the local compatibility check
Select official Node 26.9.0 without changing .nvmrc:
nvm install 26.9.0nvm use 26.9.0node --versionNode 26 does not bundle Corepack. If pnpm is not already available, use the standalone installer. From the repository root, download it:
curl --fail --show-error --location https://get.pnpm.io/install.sh --output /tmp/install-pnpm.shInstall the version named by packageManager:
env PNPM_VERSION="$(node --input-type=module -e 'import { readFileSync } from "node:fs"; console.log(JSON.parse(readFileSync("package.json", "utf8")).packageManager.split("@")[1])')" sh /tmp/install-pnpm.shFollow the installer's shell setup instructions, then verify the runtime and package manager:
node --versionpnpm --versionThe pnpm version must match package.json. Install the frozen dependencies:
pnpm install --frozen-lockfileRun the focused compatibility check:
pnpm run check:node26It checks storage compatibility, SQLite authorization and timer-dependent backup completion, then exercises the packaged server and import worker with temporary fictional data. The packaged check verifies startup and periodic snapshot contents/integrity and clean shutdown. It reports runtime, package-manager version, revision, platform and elapsed time. Preserve the actual results and any known failures when reporting compatibility; do not describe selected checks as complete support. For a later official 26.x release, select that exact version before running it.
Run this locally once weekly while development is active. It adds no scheduled GitHub Actions runs or checks to ordinary small fixes. Full coverage, dependency audits and browser suites are not part of the routine Node 26 check; run broader tests only when investigating a failure. Node 26-specific failures can remain documented without holding up a Node 24 release. Shared correctness or security defects retain their normal priority, and unsafe data paths must not be concealed by experimental status. Compatibility fixes have no guaranteed deadline.
Return to the normal runtime before the repository's required checks:
nvm useRun modes
pnpm run dev # SQLite API :8787 + web :5173, seeded development data
pnpm run dev:demo # web :5173, editable in-memory data, resets on reload
pnpm run dev:access # isolated password-auth role lab: API :8897 + web :5473Those are the lane-0 ports — what a single checkout binds. See Port lanes for what happens when several checkouts run at once.
Port lanes
Every local server takes its port from a lane: an integer from 0 to 9 that one run holds for its duration. A port is base + lane, so lane 0 is the historical 5173/8787/4173 and ten concurrent worktrees never collide.
pnpm run dev, preview, test, gate, gate:server, gate:all, e2e and the documentation servers all run through scripts/with-lane.mjs, which claims a lane before the command starts and releases it after. Configuration files only read the resolved lane, so there is nothing to pass by hand:
pnpm run e2e # claims the lowest free lane, prints which
CAPACITYLENS_PORT_LANE=4 pnpm run e2e # pin a laneThe claim also reserves a share of the machine's CPUs and passes it to Vitest and Playwright as a worker count, so ten concurrent runs divide the cores instead of each assuming it owns them. A run that arrives when the pool is empty still gets one worker — nothing queues.
Two deliberate exceptions:
pnpm run dev:accesskeeps fixed ports, so it is single-flight machine-wide.- Documentation screenshots are captured by hand on
:5199, which no automated run binds.
If a lane's port is still held when a run claims it, the launcher reports the process by pid and working directory, then stops. It never kills anything itself. To see what is holding the lanes, and to stop servers left behind by earlier runs:
pnpm run lanes # every lane port in use: pid, directory, and the run that owns it
pnpm run lanes --stop-orphans # stop listeners from this repository's worktrees that no live run owns--stop-orphans leaves claimed lanes and other checkouts' processes alone. It also stops a server started directly in one of this repository's worktrees without the launcher, since nothing records that such a server is still wanted.
An empty VITE_CAPACITYLENS_API means same-origin server mode. A non-empty value must be an absolute HTTP(S) origin with no credentials, path, query or fragment; surrounding whitespace and a trailing slash are normalized. Only VITE_CAPACITYLENS_DEMO=1 selects the in-memory adapter. An invalid non-empty API or feedback-mailbox build value fails Vite configuration before bundling.
The in-memory demo has no real membership roles. To inspect the implemented Owner/Admin/Editor/Viewer flows against the password-auth server, use the isolated access lab described below. It documents the local-only credentials, the prebuilt Wayne Enterprises fixture, and the expected visibility matrix.
Development-only variables
These server variables exist for development and tests. They are not operator configuration, so .env.example and the configuration guide leave them out, and Compose does not pass CAPACITYLENS_ALLOW_RESET or CAPACITYLENS_BOOTSTRAP_ADMIN_PASSWORD into the production API container. Production refuses each of the first three.
| Variable | What it does |
|---|---|
CAPACITYLENS_ALLOW_RESET | Set 1 to expose the destructive POST /api/test/reset route for development and tests with sign-in off. |
CAPACITYLENS_CREATE_ADMIN_ADMIN | First-owner helper, also available as --create-owner-admin-admin. Creates admin@admin.admin only when the password user table is empty. |
CAPACITYLENS_BOOTSTRAP_ADMIN_PASSWORD | Required password for that owner helper. Keep it in a secret manager; the server never generates or prints it. |
CAPACITYLENS_DEV_API_PORT | API port used by scripts/dev-fullstack.mjs. Default 8787. |
Browser-test and rehearsal controls (API_PORT, CAPACITYLENS_E2E_PHASE and the browser selectors) are described under Browser upgrade rehearsal.
The access lab
The lab is destructive only to the fixed local file server/.access-lab.db, which is recreated on every run. Its launcher and setup boundary remove inherited CAPACITYLENS_*, BETTER_AUTH_* and VITE_CAPACITYLENS_* configuration, then pin the API to 127.0.0.1, password auth, the lab database and the local Vite origin. The setup script also refuses every path except that exact non-symlink repository fixture, including a same-named database in another directory. Never use these fictional credentials on a real installation.
Start the complete lab:
bashpnpm run dev:accessOpen http://127.0.0.1:5473. Wayne Enterprises, a private client/project and a time-off note are already present. Sign in with any persona; every persona uses
access-lab-password-2026:Persona Email Role Lucius Fox owner@capacitylens.devOwner Alfred Pennyworth alex.admin@capacitylens.devAdmin Barbara Gordon erin.editor@capacitylens.devEditor James Gordon vic.viewer@capacitylens.devViewer Compare the sidebar role badge, Team & access, edit affordances, private names and time-off note against the roles and permissions table. Stop the command with Ctrl-C before running auth-backed Playwright; the access lab is single-flight on fixed ports, outside the lane system, and keeps a separate database.
Useful automated counterparts are:
pnpm exec playwright test --project=auth-backed \
e2e/login.auth.spec.ts e2e/invite.auth.spec.ts \
e2e/members.auth.spec.ts e2e/viewer.auth.spec.tsRepository map
shared/src/types/entities.ts— canonical data model.shared/src/domain/andshared/src/lib/— environment-independent rules.src/store/useStore.ts— client orchestration and history.src/data/— persistence, in-memory demo and opt-in offline cache.src/components/scheduler/— grid/view-model.server/src/app.ts— HTTP boundary and authorization.server/src/accountStore.ts— account-scoped whole-slice and targeted lifecycle storage boundary.server/src/tables.ts— schema/column specification.
Read AGENTS.md, DECISIONS.md and DEFENSIVE-CODING.md before broad changes.
Task navigation
Use these entries to find the first owner for a familiar kind of change, then follow only the boundaries that the task reaches. They supplement the repository map; they are neither an exhaustive dependency list nor a substitute for the required validation policy.
Allocation forms and recurrence
Start: src/components/scheduler/AllocationModal.tsx composes the allocation form and delegates its state, fields and commands.
Follow through: Read src/components/scheduler/useAllocationModalState.ts, src/components/scheduler/AllocationTargetFields.tsx and src/components/scheduler/AllocationScheduleFields.tsx for field ownership, src/components/scheduler/AllocationModalFieldLayout.tsx for layout, and src/components/scheduler/allocationSubmit.ts plus src/lib/repeatingAllocations.ts when save or recurrence behaviour changes.
Tests: Start with the src/components/scheduler/AllocationModal.*.test.tsx suites (AllocationModal.create.test.tsx, AllocationModal.edit.test.tsx and AllocationModal.repeat.test.tsx for save and recurrence) and src/components/common/compactFormLayouts.test.tsx; include e2e/modal-layout.spec.ts, e2e/allocation-modal-layout.spec.ts and e2e/allocation.spec.ts when their layout or complete browser flow is affected.
Time off and recurrence
Start: src/components/timeoff/TimeOffForm.tsx owns the personal time-off form and connects its draft, permission and repeat state to saving.
Follow through: Read src/components/timeoff/useTimeOffRepeat.ts, src/components/timeoff/timeOffFormSubmission.ts and src/lib/repeatingTimeOff.ts for repeat projection and persistence; shared date-series rules live in shared/src/lib/repeatingDates.ts.
Tests: Start with src/components/timeoff/TimeOffForm.repeat.test.tsx, src/lib/repeatingTimeOff.test.ts and src/store/useStore.timeOff.test.ts; use e2e/timeoff.spec.ts for the complete browser flow.
Capacity and visible Utilisation
Start: src/components/scheduler/visibleSpan.ts resolves the actual visible date window and its labels from the viewport.
Follow through: src/components/scheduler/schedulerRowCapacity.ts builds row capacity sources for both visibleWindow and the fixed overSoonWindow; src/lib/capacity.ts owns the straight-line availability, allocation and Utilisation calculations. Preserve those two windows as separate signals.
Tests: Start with src/components/scheduler/visibleSpan.test.ts, src/lib/capacity.test.ts and src/components/scheduler/schedulerModel.test.ts; check e2e/holiday-overallocation.spec.ts when visible capacity or over-capacity presentation changes.
Overview horizons
Start: src/components/capacity-overview/CapacityOverviewView.tsx connects active scoped data, calendar settings and the Overview toolbar controls, including the selected horizon, to the table.
Follow through: capacityOverviewDates.ts owns the four-, eight- or twelve-week horizon of single weeks, the first cut to the remainder of the current week; capacityOverviewModel.ts owns eligibility, precise period aggregation, display rounding, grouping and filtering. Access policy is shared in shared/src/domain/access.ts, while src/auth/capacityOverviewAccess.ts gates the sidebar and direct route.
Tests: Start with src/components/capacity-overview/capacityOverview.test.ts, CapacityOverviewTable.test.tsx and src/auth/capacityOverviewAccess.test.ts; use e2e/capacity-overview.spec.ts and e2e/capacity-overview.auth.spec.ts for browser coverage.
Scheduler gestures and viewport
Start: src/components/scheduler/SchedulerGrid.tsx composes the timeline viewport, row model, virtual window and interaction surfaces.
Follow through: Read src/components/scheduler/useAllocationGesture.ts for allocation movement and resizing, src/components/scheduler/useSchedulerViewport.ts for scrolling and column geometry, and src/components/scheduler/useSchedulerGridVirtualization.ts with src/components/scheduler/virtualWindow.ts for rendered rows.
Tests: Start with the src/components/scheduler/AllocationBar.*.interaction.test.tsx suites (AllocationBar.pointerDrag.interaction.test.tsx for drag and reassignment; AllocationBar.keyboard.interaction.test.tsx for keyboard and popover), src/components/scheduler/useSchedulerViewport.test.tsx and src/components/scheduler/virtualWindow.test.ts; use e2e/scheduler.spec.ts and e2e/snap-week.spec.ts for browser-level gesture and viewport behaviour.
Client account projection and scoped reads
Start: src/store/useScopedData.ts is the client read seam that projects the active account and its active-only view.
Follow through: src/auth/PermissionProvider.tsx projects the active membership into UI and store capabilities, using the account summaries owned by src/auth/useAccountSummaries.ts. These client projections control visibility and affordances; they never authorize a server operation.
Tests: Start with src/store/useScopedData.test.tsx, src/auth/PermissionProvider.test.tsx and src/store/multitenancy.test.ts; use e2e/private-name-permissions.auth.spec.ts when role-based projection reaches the signed-in browser flow.
Server tenant authorization
Start: server/src/routes/appAuthorization.ts resolves membership, capability and fresh-session requirements for account-scoped requests.
Follow through: Inspect the route that consumes the authorization seam, such as server/src/routes/entityRoutes.ts, then server/src/accountStore.ts when the operation crosses the scoped storage boundary. Client visibility or permission projection is never server authority.
Tests: Start with server/src/app.authz.test.ts; include server/src/db.accountStore.test.ts and e2e/viewer.auth.spec.ts when storage isolation or end-to-end role enforcement is affected.
Online persistence and refresh
Start: src/data/persistence/attachPersistence.ts composes the store subscription, write queue, account switching, refresh and browser lifecycle hooks.
Follow through: Read src/data/persistence/refreshController.ts, src/data/persistence/accountSwitch.ts and src/data/persistence/writeQueue.ts for the relevant coordination path. src/data/ServerSyncAdapter.ts owns whole-slice loading and ordered, transactional batch diffs against the server.
Tests: Start with the src/data/persist.*.test.ts suites (persist.attach.test.ts for writes, retries and page lifecycle; persist.reconciliation.test.ts for batch conflicts), src/data/persist.overlap.test.ts and the src/data/ServerSyncAdapter.*.test.ts suites; use e2e/persistence.db.spec.ts and e2e/resilience.db.spec.ts for database-backed browser boundaries.
Offline snapshots
Start: src/data/offlineCache.ts is the public facade for opt-in cached identity, account lists and read-only account slices.
Follow through: src/data/offline/records.ts owns validated encrypted record reads and writes, src/data/offline/state.ts owns the offline episode and preference state, and src/data/offline/crypto.ts plus src/data/offline/shell.ts own the device boundary and cached app shell. The service worker lives at public/offline-worker.js.
Tests: Start with the src/data/offlineCache.*.test.ts suites and src/data/offlineWorker.test.ts; include the offline transport cases in src/data/ServerSyncAdapter.diff.test.ts and src/data/clearLocalStorage.test.ts and the src/data/offlineCache.*.test.ts suites when cleanup or browser storage boundaries change.
Installation package
Start: scripts/package-managed-release.mjs builds production/ (web app, deployed server and the operator files from packaging/release/) and, with --archive, the release archive and its checksum under release/.
Follow through: packaging/release/ holds the environment example, systemd unit, nginx and Caddy site files and INSTALL.md exactly as operators receive them. server/src/index.ts decides whether the server serves the web app (CAPACITYLENS_WEB_DIR or the release marker) and server/src/routes/staticWeb.ts serves it. server/package.json → build:runtime bundles the server and the owner-password recovery tool. .github/workflows/release-provenance.yml publishes the archive and .github/workflows/gate.yml → release-package-smoke runs it.
Tests: Start with pnpm run package:managed-release:test and server/src/routes/staticWeb.test.ts; the gate's release-package-smoke job is the end-to-end check, and its steps can be run locally against an extracted archive.
Maintain an entry when its starting point or ownership changes. A task brief should link to the relevant entry and name the exact implementation and test paths it needs, rather than copy this whole section. For work not mapped here, use targeted source, caller and import searches. Import and schema changes continue to follow Database migrations, while standing invariants remain in their existing authoritative documents.
Input boundaries
Ordinary POST, PUT, PATCH and batch writes validate supplied fields before the import repair functions run. server/src/validate/strict.ts rejects unknown fields, wrong types, invalid choices, non-preset colours, malformed dates, invalid weekdays and out-of-range numbers. It checks the merged resource availability pair. Omitted legacy availability fields, confidential pins and server-owned lifecycle/series fields preserve their stored values; other optional fields follow their individual PUT/PATCH contracts. Explicit null clears an availability date or avatar URL. A blind writer's hidden note or private name remains pinned to the stored value; archive/delete state and repeat-series membership belong to their dedicated actions. Existing revision conflict behavior still requires parseable stored and supplied timestamps. PATCH uses the URL row ID even when a legacy caller echoes a different valid body ID; the body ID does not move or rename the row. shared/src/lib/strings.ts and src/lib/validation.ts apply NFC and whitespace folding before text length checks; passwords remain exact.
Import is the separate repair path. First external sign-in also repairs a provider-supplied profile name, which the person cannot correct in a CapacityLens form. shared/src/data/strictJson.ts scans untrusted JSON for more than 64 nesting levels, duplicate decoded object keys, non-finite numeric values, unsafe integers and nonzero numbers that underflow to zero before JSON.parse handles syntax. Browser file reading rejects invalid UTF-8. The server applies the scan to application JSON requests and then uses Fastify's default hardened JSON parser; /api/auth/ retains provider/framework-owned parsing. The existing request and import caps are 5 MiB, import records are capped at 200,000, and batches at 5,000 operations. Approved domains have 50 raw entries and 16 KiB aggregate UTF-8 text, checked before IDNA conversion. Local search fields accept up to 256 UTF-16 units. Setup secrets accept at most 512 UTF-16 units at configuration and request boundaries. These are resource and input limits, not authorization; server role, scope and field checks remain independent.
A few rules the codebase enforces structurally, worth knowing before you touch the relevant area:
- Lifecycle archive, unarchive and soft-delete use owned entity-level writes through
AccountStore; purge uses targeted SQLite cascades and restamps only surviving rows whose nullable relationship was cleared. Don't route a single lifecycle action through whole-slice replacement — synchronous SQLite would make its latency and write amplification proportional to every row in the tenant. - Shared interactive validation failures use
DomainErrorand a stableDomainErrorCode. Fallback English is safe for logs and version skew, but browser surfaces translate the code rather than matching or rendering that prose. ServerValidationErrorpreserves a shared code in HTTP 400 responses. Add a code, fallback and Paraglide mapping together whenever a new domain rule rejects a caller. - Server-mode persistence refreshes a visible active-company slice every 60 seconds and on focus or visibility return (shared 30-second throttle). All paths reuse the account-switch refresh orchestrator so the rendered slice and adapter diff snapshot advance atomically; failed saves defer ordinary refreshes until their optimistic state can be preserved or reconciled.
- Page-teardown persistence preflights the aggregate browser keepalive budget before dispatching any request. Don't validate sibling keepalives independently — browsers apply their quota across all in-flight keepalive requests for the page.
- Batch validation assembles a request-local projection from only the account slices named by its operations, then advances that projection in operation order through per-table id and reverse-FK indexes. Account import likewise reads only the target slice before replacement. Don't replace the batch indexes with repeated
findIndex/map/filterrebuilds, or either read path with whole-databaseloadState()— relationship validation needs complete target slices, not quadratic copies or unrelated tenant rows. When you add a new AppData foreign key, update the relationship graph inserver/src/BatchStateProjection.tsalongside the table definition and its cascade-parity tests. - The 5,000-operation atomic batch cap is also an event-loop availability bound. The inclusive server integration test applies 5,000 real updates and requires the handler to finish within four seconds under the supported Node 24 gate — below the packaged container healthcheck's five-second timeout. Don't split the transaction or add an in-process queue as a latency workaround: splitting breaks ordered atomicity, and a queue can't preempt a synchronous SQLite turn. If the boundary test exceeds its budget, reduce the shared
MAX_BATCH_OPS(shared/src/data/transfer.ts, used by client and server), or move the database work to a genuinely isolated execution model.
Name modules and keep their contracts small
For example, ResourceLane.tsx names its principal component, while gestureMath.ts names a cohesive set of gesture calculations. A contributor changing a calculation should need its implementation, its explicit inputs and focused tests. Moving the calculation into a helper that receives the whole scheduler state does not reduce that reading work.
These conventions apply to new code and deliberate migrations. Existing differences are tracked debt. A green lint result does not yet prove that all these conventions hold.
Names and exports
| Kind | Convention | Example or exception |
|---|---|---|
| Principal component | PascalCase file and matching named export | ResourceLane.tsx exports ResourceLane. |
| Principal type or interface | PascalCase file and matching named export | A new dedicated SessionSummary.ts would export SessionSummary. |
| Hook | camelCase file and matching use export | useScopedData.ts exports useScopedData. |
| Principal utility function | camelCase file and matching named export | reloadPage.ts exports reloadPage. |
| Cohesive set of functions or types | camelCase capability name; name each export for its role | gestureMath.ts, entities.ts, ports.ts; keep a short ownership comment when the grouping is not obvious. |
| Executable script | kebab-case filename | check-import-cycles.mjs, rehearse-migrations.ts. |
| Constants | UPPER_SNAKE_CASE for fixed module policy values; camelCase for local values | MAX_BATCH_OPS; a local remainingAttempts. |
| Tests | Owner name plus .test or .spec, optionally a named behavior before the suffix | ResourceLane.test.tsx, useStore.allocations.test.ts. |
Prefer named exports for application code. A framework-required default export, such as a Vite configuration, keeps its framework convention. A collection file must own one named capability; names such as utils, helpers, misc or a broad index barrel do not explain that ownership. Do not create a separate file for every small private helper.
Source-owned UI primitives retain registry-compatible filenames such as button.tsx and toggle-group.tsx; this naming exception does not exempt them from local behavior review. Tool configuration, declaration files and generated outputs retain the names their tools require. Released migrations and database fixtures retain their exact names and contents. Package exports and externally consumed symbols need explicit compatibility exceptions until a separately authorized migration changes the contract. Existing filenames outside these categories are migration debt, not an open-ended naming exemption.
Use readable acronym casing in new internal names: HttpResponse, accountId. Preserve existing exported semantic aliases, including ID, ISODate, ISOTimestamp, PrincipalId, WorkspaceId and IsoInstant. Use the alias owned by the relevant contract rather than replacing it with string or inventing a parallel alias. This convention introduces no branded IDs and changes no wire fields.
Account vocabulary
The code, UI and wire names for each concept are in Account vocabulary.
Import paths and ownership
Import from the owning module, not through a broad re-export barrel. Use these paths consistently for each boundary:
- Across workspace packages, use the declared
@capacitylens/shared/...export. Never reach into another package with../../shared/src/...in production code. - In the browser app, use
@/...for any import that leaves the importing file's folder and./...only for modules in that folder or below it;../is rejected by lint. For example, scheduleractivityOptions.tsimports@/lib/displayOrder;useSchedulerGridVirtualization.tsimports./virtualWindow. Shared UI primitives and@/i18nare explicit app-wide capabilities. The few tests that read repository files outsidesrc/(package.json,scripts/, nginx templates) keep their relative paths and are allowed by name ineslint.config.js. - In server and shared code, use relative paths within the package; those packages have no source-root alias. Keep cross-feature imports directed toward the owner below.
- Use
import typefor declarations used only as types. In a mixed import, split the type declarations into a separateimport typestatement from the same owner.
Keep imports in readable groups: platform modules, external packages, shared package contracts, other features, then local modules. Sort specifiers within a group when order is irrelevant. Preserve side-effect import order and document order-sensitive setup; never apply a sorting fix that changes initialization behavior.
| Owner | Responsibility | Allowed boundary |
|---|---|---|
shared/src/domain, shared/src/lib, shared/src/types | Pure scheduling rules and entities | Other pure shared modules; no browser, Node, HTTP or database capabilities. |
shared/src/account | Portable identity, workspace and membership contracts | Explicit ports and pure policy; no auth-vendor or storage implementation. |
src/store | Client mutations, IDs, timestamps and undo/redo | Shared domain rules and explicit local slice capabilities. |
src/data | Persistence, refresh and offline snapshot lifecycle | Adapter contracts and named state-owner operations. |
src/account, src/auth | Account protocol, session and identity coordination | Validated client results consumed by UI features. |
src/components/scheduler | Grid geometry, view-model and interactions | Scoped data and narrow actions; no persistence internals. |
server/src/accounts/flows | Portable account use-case coordination | Identity/admin ports, transaction and command-ledger contracts. |
server/src/accounts/identityPort, adminPort | Vendor and SQLite implementations | Their corresponding portable ports; vendor/storage details stay inside adapters. |
server/src/routes, server/src/accounts/routes | HTTP parsing, authorization and response mapping | Owned use cases and storage boundaries; UI visibility never authorizes an operation. |
server/src/accountStore.ts, server/src/tables | Scoped product storage and column specifications | Explicit storage operations and immutable versioned migrations. |
The company-joining client in src/account/companyJoinClient.ts owns its browser URLs alongside accountClient.ts. On the server, accounts/adminPort/joiningProviderIntent.ts checks the local identity and access restrictions when starting a provider journey. accounts/adminPort/joiningProviderCallbacks.ts reads the exact provider account, local identity and durable email proof at the callback boundary, then updates the company-bound intent. Invitation storage stays in controlTables/invites.ts; HTTP routes do not read these tables directly.
src/store/useStore.ts, server/src/app.ts and the auth configuration entry points compose their owned capabilities. Their ability to connect implementations is an explicit composition responsibility, not permission for neighboring helpers to import those implementations. An extraction must receive only the operations it consumes; avoid whole-store inputs, ReturnType<typeof parentFactory> contracts and mutable context bags at feature boundaries.
Inside a module, function verbs, variable names, parameter style and result shapes follow Name functions, variables and results.
Checks
Run these before proposing a change, on the Node version in .nvmrc:
pnpm run gate:all
pnpm run e2egate:all already includes coverage. e2e runs the demo, database-backed and password-auth flows in Chromium. A prose-only change needs formatting and a content and link review instead; rebuild the documentation when its sources change. Add these checks when their trigger applies:
| Check | Run it when a change touches |
|---|---|
pnpm run test:account-conformance | Authentication, accounts, invitations, membership, authorization, sessions or erasure. |
pnpm run rehearse:migrations | A database migration, the persisted authentication shape or the Better Auth version. |
pnpm run mutation | The pure logic it mutates (see Mutation testing), or before a release. |
Installing dependencies configures lightweight Git hooks. Each commit lints only staged authored JavaScript and TypeScript files, so small commits stay fast. Each push runs the complete repository lint to catch configuration and cross-file effects. Set SKIP_SIMPLE_GIT_HOOKS=1 for a single Git operation only when diagnosing a hook problem; pull-request checks remain authoritative.
Account-security versioning
When account-security behavior changes, update the version at the boundary that owns the rule:
- portable identity behavior: the account contract and every
IdentityPortconformance fixture; - database/auth-library behavior: the minimum-security or schema version and its migration/rehearsal evidence;
- browser/server propagation: the shared command version and the tests proving every implementation accepts it.
The pull-request description should name the changed version and link the conformance, migration, or propagation test that proves all implementations moved together. If no version changes, explain why the change preserves the existing contract.
What gate checks
gate compiles translations, type-checks, lints with zero warnings, runs Vitest with enforced coverage floors, rejects any new measured executable module with zero covered lines, and builds the SPA. A short exact-file allow-list records existing zero-coverage debt; broad patterns are forbidden so unrelated new files can't inherit an exception.
Run gate:all for the combined app and server gate. It executes the checks shared by gate and gate:server once, then runs the app-only and server-only checks in their established order. The separate commands remain available when only one workspace needs validation.
Lint also holds the typed packages to the mechanical rules of the code conventions page: identifier casing, no negated boolean names, and at most three parameters. Existing violations are recorded per file and rule in eslint-suppressions.json at the repository root. A count that rises fails lint, and an entry that is no longer needed fails lint until pnpm exec eslint . --prune-suppressions removes it, so the baseline only shrinks. Inline eslint-disable comments sit outside that per-file baseline, so the ratchet does not count them.
Shared production compiles without Node types. Its standard web declarations preserve the existing Headers contract, UUID generation and UTF-8 encoding; lint permits ECMAScript globals except globalThis, plus console, crypto and TextEncoder. Passing the entire global object would bypass the named capabilities. Browser and Node runtime globals and Node imports are rejected. Both gates check the production compiler graph, including dynamically imported declarations, for Node types and accidental test imports. Colocated tests use shared/tsconfig.test.json with Node types and typed promise linting. pnpm --filter @capacitylens/shared type-check checks both projects. The root tsconfig.json references the shared production and test projects, the browser application, the end-to-end project, and Node tooling, each with its own compiler environment. pnpm run typecheck compiles messages and then runs the complete solution with tsc -b. It does not cover the server workspace, which pnpm run gate:server type-checks separately. The root tsconfig.json has no inputs of its own, so pnpm run typecheck is the command to reach for rather than tsc at the repository root.
Both gates verify the effective lint configuration against the authored source inventory and representative new files. The JavaScript and TypeScript recommended rules cover scripts and declarations too, including .mts and .cts. Lint does not cover the documentation: it excludes docs/, all of docs-src/ (including the VitePress configuration, theme and Vue components), scripts/docs-lightbox.js and scripts/docs-standalone.mjs, as well as generated output.
| Source | Promise linting | Environment and other checks |
|---|---|---|
App .ts/.tsx, including tests | Typed | React rules and browser globals; filesystem tests use the Node compiler project. |
Server .ts files under server/src/ and server/scripts/, including tests | Typed | Node compiler project and globals. |
| Shared source and colocated tests | Typed | Separate pure production and Node test projects, as described above. |
| E2E, root/package configuration and remaining scripts | Untyped | Recommended lint rules; compiler checks apply where a tool has its own TypeScript project. |
| Public scripts | Untyped | Browser globals; the offline worker receives service-worker globals instead. |
| Documentation sources, VitePress, lightbox and standalone build | Not linted | Excluded in eslint.config.js. |
“Untyped” means ESLint does not load a TypeScript project for promise analysis. It does not exempt the source from linting. Real temporary production and test files prove that floating and misused promises fail in all three typed packages; handled promises pass. Category-specific regressions also check browser/worker isolation, shared purity and documentation source coverage.
The enforced coverage floors:
| Metric | Browser/shared floor | Server floor |
|---|---|---|
| Statements | 92% | 87% |
| Branches | 87% | 80% |
| Functions | 92% | 90% |
| Lines | 94% | 89% |
The build also enforces a raw and gzip byte budget on the main JavaScript entry chunk; route-level lazy chunks stay separate so authentication and settings code don't inflate first load unnoticed. The checked constants live beside the checker in scripts/bundleBudget.mjs — treat that file, not this page, as the authoritative size limit. Vite's generic uncompressed chunk warning shares that raw boundary, while the post-build checker additionally enforces the gzip boundary. The checker requires exactly one JavaScript module entry in the built HTML and refuses to guess if another entry appears; attribute order and quoting don't affect discovery.
Treat both the coverage floors and the size budget as maintainer-owned regression boundaries, not automatic ratchets. When a gate approaches or reaches one: add focused coverage, remove dead code, or preserve/split lazy chunks before proposing a threshold change. Any intentional change must include the measured before/after coverage or raw+gzip entry size, and explain why the added tested behavior or first-load cost is justified. Changing a number only to restore green is not acceptable. Tighten a boundary only when repeated gate results show stable headroom. The canonical policy is recorded in DECISIONS.md.
Checking the shadcn UI primitives
The checked-in src/components/ui/* primitives are source-owned. Before refreshing an installed primitive, inspect the pinned registry result without overwriting local code:
pnpm run ui:registry add <component> --dry-run
pnpm run ui:registry add <component> --diffRe-merge every adjacent documented local deviation deliberately, then run pnpm run ui:check. That check is part of pnpm run gate and detects unreviewed changes to the protected primitive surface. Update its reviewed digests only after inspecting the complete diff and retaining the documented product behavior. The committed shadcn MCP is an approved equivalent inspection path; ui:check requires its CLI version to match ui:registry so both compare against the same upstream baseline.
The shared Badge follows the registry's compact pill shape, invalid-state treatment and link-aware variants. Its brand default/link colours and AA-tuned warning and danger variants are CapacityLens-owned deviations and must survive a future refresh. Button deliberately keeps colour-only transitions so disabled opacity clears immediately, and Alert keeps opaque destructive description text for the verified contrast boundary.
What gate:server checks
gate:server checks the Node/SQLite workspace.
- GitHub CI divides ordinary server test files into four parallel, bounded shards and launches each file in a fresh Vitest process, so a file can't leak native SQLite, authentication or AsyncLocalStorage state into the next file. Each child has a 90-second limit; each shard has a four-minute circuit breaker.
- The AsyncLocalStorage/lock-heavy account-flow conformance file runs alone in the independent account-conformance process pool.
- The credential-onboarding crash harness and the process-heavy migration regression each run separately in dedicated single-worker forks, so their intentional child-process termination and native resource failures stay isolated and report their assertions instead of stranding the complete unit run. Migration regression subprocesses have a 30-second execution limit and a 45-second Vitest assertion budget, leaving room to report a child timeout even when a shared runner is contended.
- Both root and shared Vitest projects pin
TZ=UTC; timezone-specific helper coverage must set its zone deliberately in an isolated child process rather than inheriting a maintainer's machine.
test:account-conformance is the stable, independently reportable account-boundary check. It runs the shared contract/policy tests, the same IdentityPort contract against Better Auth, trusted-local and vendor-free implementations, the coordinator invariants, SQLite account-adapter tests, profile validation and whole-tree architecture rules.
Resilience browser tests
e2e/resilience.db.spec.ts covers failures that cross the browser, API and SQLite boundaries. Run it independently with:
pnpm exec playwright test e2e/resilience.db.spec.ts --project=db-backedKeep the application and database real. Intercept only the request whose failure the test needs to control, then prove all four parts of the recovery contract:
- The failure is visible and understandable.
- The interface does not claim that an uncommitted change was saved.
- Server state is neither partially changed nor duplicated.
- Releasing the failure restores a usable interface and the expected durable state.
Use two independent browser contexts for concurrent-edit coverage so the server produces the stale-write conflict naturally. Use e2e/failRequestsUntilReleased.ts for transport and HTTP failures that cannot be scheduled reliably against the real server. Do not mock unrelated requests or replace the persistence adapter in these tests.
rehearse:migrations upgrades a released database fixture and verifies preservation, rollback and recovery behavior. See Database migrations below for how to run it against a real installation copy.
Run pnpm run policy:file-sizes to check file lengths. The checker, scripts/check-file-sizes.mjs, scans tracked production and .test./.spec. TS/TSX files under src, server/src and shared/src. It excludes .d.ts files, src/paraglide, node_modules and e2e directories.
The production ceiling is 400 lines and the test-file ceiling is 800 lines; both come from scripts/file-size-exceptions.json. That file carries permanent design exceptions and temporary ratchet baselines. A successful run prints File-size check passed with the source-file count, both ceilings and temporary-exception count.
The checker also prints an unenforced approximately N lines diagnostic for long top-level functions. These approximate lengths help identify functions to review; they do not affect whether the check passes.
Both gates run pnpm run policy:comment-voice, which applies the comment rules in DEFENSIVE-CODING.md to comments under src, shared and server/src.
Both gates also run pnpm run policy:import-cycles to reject runtime import cycles. Explicit import type and export type clauses are excluded. Inline type bindings follow each package's verbatimModuleSyntax setting: an empty import or re-export can still initialize its module. The check reads the effective compiler configurations, including inherited settings, and rejects missing or invalid configurations. Both gates run pnpm run policy:dependencies:test to verify the scanner and cycle checker against regression fixtures. The account architecture suite uses the same parser and resolver for its ownership checks; unresolved and nonliteral imports fail visibly.
Account ownership checks discover every source file in the coordinator, product-route, account-route and auth-builder directories, including new nested siblings. Ownership traversal includes type-only dependencies. The named Db type is a terminal public contract; importing other facade exports or importing it at runtime still follows the graph. Three concrete adapter type imports are pinned by server/src/accounts/conformance/architecture.test.ts. The storage mapper may import its named AccountMember row type. Tests pin the permitted consumers and symbols and reject stale, duplicate or widened entries. SQL ownership retains exact file lists; membership in a neighboring directory grants no SQL access.
Both gates also run the cryptographic implementation-path discovery check: a new primitive, certificate/key path or TLS configuration must be reviewed into docs-src/security/crypto-inventory.json. The hard gate inventories tracked working-tree files and fails separately when a crypto-like source file is still untracked — an inventory entry can't bless a file that exists only in one checkout, so add an intended source file to Git before reviewing it into the inventory. Generated output is skipped — including the committed docs/ build — because those files are copies of sources the check already scans.
Mutation testing
The mutation configuration deliberately measures the pure shared/scheduler/browser helper layer. React hooks are excluded because their effect and event orchestration is exercised by component and cross-browser tests rather than isolated pure-function mutation. The mutation score is not evidence for the Zustand store, React orchestration, or the Fastify/Better Auth implementation — those are covered by focused unit/component tests, the server integration gate and E2E. Review surviving, timed-out and uncovered mutants rather than accepting the aggregate score alone. The latest triage is recorded in docs-src/security/reviews/mutation-review-2026-07-18.md.
Cross-browser checks
pnpm run e2e:webkit
pnpm run e2e:firefox
pnpm run e2e:browsers
pnpm run e2e:allKeep specs browser-agnostic. Screenshots and axe checks are the visual/accessibility oracles. e2e:all runs Chromium plus the server-backed projects first, then WebKit and Firefox in isolated Vite-only invocations; all three phases run even when an earlier phase fails. The e2e workflow runs those same phases as independent matrix jobs, so the browser engines run in parallel and a failure in one engine can't suppress the others. It runs on pushes to main, release tags, a monthly schedule and manual dispatch, not on pull requests.
The production API image builds server/dist/index.mjs and server/dist/importWorker.mjs with pnpm --filter capacitylens-server build:runtime, then runs plain Node without the TypeScript transformer. pnpm run gate:server includes that build, so source-only and bundled-runtime drift is caught before container construction.
CapacityLens supports current evergreen Chromium, Firefox and WebKit/Safari behavior, represented by the pinned Playwright release. The security baseline assumes HTTPS, Secure/HttpOnly/SameSite cookies, CSP/frame enforcement, Fetch Metadata or Origin on unsafe cross-site browser requests, and Web Crypto for encrypted offline access. Online mode stays usable when Web Crypto is unavailable, but offline snapshot creation fails visibly instead of falling back to plaintext. Obsolete or plugin browsers are unsupported; the application doesn't weaken headers or crypto for them.
Node 24 is the supported gate, but the vitest setup pins globalThis.localStorage/sessionStorage to a usable Storage instance (falling back to an in-memory stub), so contributor runs on newer Node releases with the experimental global storage accessor don't cascade failures through test files.
User-text hygiene is single-sourced across the browser and server, but its Unicode property escapes use each engine's bundled Unicode tables. Keep the supported browser matrix and Node version current together: during a staggered engine upgrade, newly assigned code points can be conservatively refused by the older runtime until both sides recognize their category.
GitHub Actions policy
CapacityLens is a public repository. Pull requests run static analysis and CodeQL, plus the docs build when documentation inputs change. The gate, e2e, docker and security workflows run on pushes to main and release tags; gate, e2e and security also run on schedules (see When CI runs). workflow_dispatch stays available for deliberate reruns:
gh workflow run gate.yml --ref main
gh workflow run e2e.yml --ref mainThe gate workflow exposes independent jobs for workflow static analysis, DCO, application checks, server checks, account conformance, released-database migration rehearsal and the production dependency audit. The migration job also runs the process-heavy migration regression tests in an isolated single-worker pool; both migration phases and the ordinary server unit phase have bounded step runtimes so a leaked native handle can't consume the complete job timeout. Independent jobs run even when another category fails, so a red application test can't hide an account, migration or dependency result. Coverage uploads when the repository has a CODECOV_TOKEN secret.
Server tests that pass but do not exit
Treat process exit as an assertion. The characteristic failure looks like a list of green test files or passing assertions followed by silence, until GNU timeout returns 124 (an inner child terminated by SIGTERM commonly reports 143). GitHub's orphan-process cleanup may additionally name MainThread, esbuild or another descendant. This is a lifecycle defect, not evidence that the suite needs a larger timeout.
The ordinary server runner discovers test files under server/src, removes the three deliberately isolated suites, sorts the remainder, distributes them across four shards and launches one fresh Vitest process per file. To inspect or reproduce the same boundary:
pnpm --filter capacitylens-server test:unit-shard -- 3/4 --list
pnpm --filter capacitylens-server test:unit-shard -- 3/4
pnpm --filter capacitylens-server exec vitest run src/example.test.ts --pool=forks --no-file-parallelismStart with the last announced filename and inspect every owned Fastify instance, SQLite connection, timer, process signal listener and spawned child. Use registerServerFixtureCleanup() for ordinary app/database fixtures. Subprocess tests must bound the child itself and avoid captured stdio pipes when a compiler or other descendant can inherit them — temporary-file capture is the established entrypoint-test pattern. A process-heavy test that intentionally exercises termination may get a dedicated required job, but passing assertions must never be manufactured with forced exits, weaker cleanup, thread-pool reuse or a larger outer timeout.
When CI runs
Static analysis and CodeQL analyze every pull request targeting main. The focused static-analysis workflow checks whole-repository formatting first, then compiles translations, type-checks the shared and application projects, and lints all authored sources. The heavier workflows run when the merge reaches main and on release tags; gate and e2e also run monthly and security weekly. To see those gates green before merging, dispatch them against the branch:
gh workflow run gate.yml --ref <branch>
gh workflow run e2e.yml --ref <branch>Opening a pull request and pushing to its branch previously fired gate, e2e, docker and security on every event — several full passes per change. Focused format/lint/type-check and CodeQL jobs now cover every proposed commit without repeating the full suites. Staged-file lint on commit, whole-repository lint on push and whole-repository formatting on pull requests provide early feedback; pnpm run gate:all and pnpm run e2e remain the complete local checks, and CI is the record.
Two jobs used to depend on pull-request context and now read the pushed commit range (github.event.before..github.sha) instead: DCO sign-off and dependency review. Both skip when that range doesn't exist — branch creation and force pushes. Feature commits carry their own Signed-off-by trailers. Creating a pull request means publishing it on GitHub and leaving it open for review; it never implies permission to merge. After the maintainer explicitly authorises the merge of that specific pull request, use a normal merge commit and delete the remote feature branch:
gh pr merge <number> --merge --delete-branchCI jobs
The e2e workflow runs cross-browser behavior. Each Playwright phase writes a distinct HTML report, JUnit result and trace directory; failed jobs retain those artifacts for seven days. Docker Compose smoke tests stay separate so the README badges report independent status.
CodeQL runs on pull requests targeting main, on main itself and on its weekly schedule. Its commit-specific concurrency key preserves analysis for each revision even when changes arrive quickly. OpenSSF Scorecard runs on main and weekly. The security workflow performs full-history secret scanning, dependency review, source SBOM generation, container vulnerability scanning and two OWASP ZAP baselines. A separate release-only workflow packages each published tag, including the runnable server archive and its checksum, generates its SBOM, creates GitHub build attestations and attaches the artifacts plus the recognized .intoto.jsonl provenance bundle to the GitHub Release. It is manually runnable with an existing release tag for deliberate rebuilds and backfills. The blocking ZAP scan boots the hardened posture — password authentication and scheduled backups, with credentials minted and masked per run — so a finding there is a regression in the recommended configuration. A second, non-blocking job scans the explicit no-login posture (sign-in mode off) weekly and uploads its report as an artifact. Reviewed secret-scan fixtures are allowlisted by value in .gitleaks.toml, which pnpm run security:gitleaks-config checks on every gate run. Because a scheduled or main run has no reviewer watching it, a failure there — or a cancellation that leaves the run with nothing to read — opens or comments on a security-scan-failure issue, and a later clean run closes it. A cancellation caused by a newer push is not reported, since that's cancel-in-progress working as intended. See docs-src/security/reviews/security-review-2026-07-14.md for assessment scope and residual controls.
main is protected against deletion and force pushes, and changes must arrive through a pull request. The Lint and type-check status is required before merge; no approving review is required while the project has one active maintainer. The heavier post-merge workflows still report complete suite results on main.
The gate's release-package-smoke job builds the release archive on every main push, unpacks it, fills in the three empty lines of its capacitylens.env.example, and starts the server with Node's --env-file. It requires deep health with the database, audit and backup all good, the web app at / with its Content-Security-Policy header, no generated secret in the captured output, and the bundled owner-password recovery tool reaching its identity lookup.
The coverage badge needs a Codecov project and a repository secret named CODECOV_TOKEN; uploads are deliberately skipped until that secret exists. Uploads are best-effort because the required local gate already enforces coverage thresholds and must not depend on Codecov availability. Scorecard needs publish_results: true and its OIDC permission, which are configured in .github/workflows/scorecard.yml.
Dependabot's monthly npm, GitHub Actions and Docker updates stay enabled; pnpm is updated from / because the root workspace owns the shared lockfile. Because its pull-request bodies quote registry metadata and a base-image digest carries none, .github/workflows/dependabot-summary.yml comments a plain-English summary of each update on the pull request. The comment is advisory and gates nothing.
Publish a release
The version pull request described in AGENTS.md → "Version and CI policy" ends when it merges. Publishing is a separate maintainer task. Nothing in .github/ or scripts/ creates tags or releases.
There is one version bump per batch of changes, and every bump is published as a GitHub release. Releases before 0.41.0-alpha.3 are kept in CHANGELOG-ARCHIVE.md.
Find the merged release commit and build the release package from it once, as the
release-provenanceworkflow will after publication:bashgit fetch origin main --tags git log --oneline -5 origin/main git switch --detach <release-commit> pnpm install --frozen-lockfile pnpm run build pnpm --filter capacitylens-server run build:runtime pnpm run package:managed-release pnpm run package:release-archive (cd release && sha256sum -c capacitylens-X.Y.Z.tar.gz.sha256)The packager refuses to finish without the web app, the server bundles and the operator files, and the archive step refuses environment files, databases, backups, audit logs and symlinks that leave the release.
Tag that commit and push the tag. Earlier releases use lightweight tags on the release merge commit:
bashgit tag vX.Y.Z <release-commit> git push origin vX.Y.ZWait for the tag's own
gate,e2e,securityanddockerruns to succeed. A green run onmainis not enough: concurrency cancels supersededmainruns, so the release commit may have none of its own. The gate also re-runs the production dependency audit, so a new advisory can fail a tag even without a code change.bashgh run list --branch vX.Y.ZPublish the GitHub release with notes taken from the version's
CHANGELOG.mdsection. Decide whether it is a prerelease. Every alpha so far was published as a full release. A prerelease is never marked Latest, sohttps://github.com/Kevinjohn/capacitylens/releases/latest, which the README links to, keeps pointing at the previous full release.bashgh release create vX.Y.Z --verify-tag --title "CapacityLens <name>" --notes-file <notes.md>Add
--prereleaseif you decided on one.Confirm that
release-provenanceran for the tag and attached its five files. Publishing also triggers thepagesworkflow.bashgh run list --workflow release-provenance.yml --limit 1 gh release view vX.Y.Z --json assets --jq '.assets[].name'Expect
capacitylens-web.tar.gz,capacitylens.spdx.json,capacitylens-X.Y.Z.tar.gz,capacitylens-X.Y.Z.tar.gz.sha256andcapacitylens-release.intoto.jsonl. If the run failed, rerun it for the existing tag:gh workflow run release-provenance.yml -f tag=vX.Y.Z.Then verify the published archive itself, not the local candidate. Download it into an empty folder outside the checkout, check its checksum and attestation, and unpack it:
bashgh release download vX.Y.Z --repo Kevinjohn/capacitylens --pattern 'capacitylens-X.Y.Z.tar.gz*' sha256sum -c capacitylens-X.Y.Z.tar.gz.sha256 gh attestation verify capacitylens-X.Y.Z.tar.gz --repo Kevinjohn/capacitylens tar -xzf capacitylens-X.Y.Z.tar.gzRepeat the checks of the gate's
release-package-smokejob against that folder: write an environment file withnode server/dist/index.mjs init, giving--public-urla loopback address,--dba writable path and--outthe file, and startnode --env-file=<file> dist/index.mjsfrom itsserver/folder. Expect deep health with"db":true,"audit":"ok"and a backupstatusof"ok", HTML at/with aContent-Security-Policyheader, and, after stopping the server,node --env-file=<file> dist/reset-owner-password.mjs <database> nobody@example.com --confirm-server-stoppedfailing withNo identity matches that address.
Before a schema-bearing release
Before you tag a release that changes the database schema, run the migration rehearsal against the committed fixture and against a representative database. It never prints tenant content. Record the source schema version, the row and table counts and the result in the release evidence.
Database migrations
The portable AppData/export format uses EXPORT_SCHEMA_VERSION in shared/. The physical SQLite file independently uses DB_SCHEMA_VERSION and PRAGMA user_version in server/src/db.ts. Never reuse one number for the other: an export-only change must not block an otherwise compatible server rollback, and a control/auth database change must not escape downgrade refusal.
Database v8 is the explicit-runner baseline. An immutable ordered migration advances one version inside one BEGIN IMMEDIATE transaction and stamps user_version plus the CapacityLens application_id in that same commit. The same transaction inserts a row into capacitylens_schema_migrations containing the version, name, SHA-256 definition checksum and application timestamp. Startup validates the complete ledger before planning writes and refuses a missing, reordered, renamed or checksummed-different migration. A fresh database runs the retained v8 baseline and then every migration, exactly as a released file advances through the migrations it has not yet applied. Shape introspection remains a post-migration assertion and a v0-v7 baseline repair, not the mechanism for silently applying new fields. That assertion verifies the TABLES write contract (declared types, nullability and id primary keys) and rejects unknown required columns or constraints that could reject a valid entity write. Nullable or defaulted extension columns stay forward-compatible because every product write names its columns explicitly.
Database v21 indexes every scoped table by accountId; v23 separately indexes every non-account foreign-key child column so SQLite parent deletes and cascades don't scan whole child tables. Startup verifies the owner, column, uniqueness, collation and direction of both index sets.
For every persisted change:
- Update shared types and full fixtures where the portable shape changed.
- Update
TABLESand fresh-database DDL. - Add the next immutable database migration and a complete checksum definition. Never edit or delete a migration that shipped — a changed definition is intentional startup incompatibility, not a repair mechanism. Restore the released migration and add a new version instead.
- Make required fields additive first, backfill and validate, then rebuild to enforce
NOT NULL. A rename/rebuild must preserve indexes, triggers, constraints and foreign keys explicitly. - Update import sanitisation independently of the physical migration.
- Before changing migration code, generate a sanitised
.dbfixture with the released build. Keep one fixture per shipped top-level database version and auth shape underserver/src/fixtures/databases/; tests copy it before opening and never migrate it in place. Intermediate migration steps that never appeared as a released build'suser_versiondon't get synthetic fixtures — the next released fixture exercises those steps in their real sequence. - Assert data preservation, fresh/migrated schema equivalence, idempotent reopen, transaction rollback/retry,
quick_check,foreign_key_check, future-version refusal and auth convergence. - Add operator-facing migration/rollback notes to
CHANGELOG.mdand the operator docs.
Before releasing any schema-bearing build, run the automated rehearsal. With no argument it uses the committed password-auth v7 fixture:
pnpm run rehearse:migrationsAlso run it against a representative long-lived installation. The command uses SQLite's online backup API and never opens the source for writes. It remaps ids, replaces names/notes/emails and credential/session/invite/historical MFA material, enables secure deletion and vacuums the temporary copy before testing it. Unknown tables fail closed until their sensitive columns are reviewed. Temporary artifacts are deleted by default:
pnpm run rehearse:migrations -- --source /path/to/capacitylens.dbThe rehearsal verifies the happy-path migration, pre-migration snapshot equivalence, row-count and integrity preservation, checksum-ledger convergence, idempotent reopen, rollback after an injected ENOSPC, and WAL recovery after killing a process with the real migration transaction open. Use --keep only in a protected development environment when the anonymised artifacts are needed for diagnosis; never commit an installation-derived database.
Schema v25 historically added the CapacityLens-owned federated-link observation/ceremony and SSO activation-state tables, an atomic observation trigger, and Better Auth UNIQUE(providerId, accountId) plus UNIQUE(userId, providerId) concurrency backstops. Its migration and released database fixtures are historical records; preserve their exact definitions and use the checked-in fixture ledger when rehearsing a later schema version.
Sign-in mode controls the authentication methods people may use. Enabled providers determine which SSO options are available. The access policy determines who may join the company. CAPACITYLENS_MODE accepts off, password-only, sso-only and password-and-sso; the old password and sso values fail startup with migration guidance. The shared AccountMode contract owns these values, while the server keeps the existing authMode wire field. Password-only ignores retained provider credentials and links; mixed mode needs at least one configured provider; SSO-only needs Google or tenant-specific Microsoft. The schema is selected from active authentication capabilities, and existing session assurance is checked against the current mode on every request. See Upgrades for coordinated environment changes and rollback.
App-owned control tables share the application migration stream. Better Auth stays pinned and owns its own tables; startup reruns its introspection migration and then verifies that no table or column work remains before accepting traffic. Every Better Auth upgrade needs a password-mode fixture containing synthetic users, credential accounts and sessions. A dependency/plugin upgrade that can change Better Auth's desired schema must also advance DB_SCHEMA_VERSION (a named marker migration is sufficient when no app-owned SQL is needed), so the previous server refuses the file before the library-owned DDL runs.
Pre-migration snapshot tests fault-inject permission, file-sync, rename and directory-sync failures and prove initialization stays uncalled. The normal migration rehearsal exercises the real Node 24 filesystem primitives, but destructive power-loss behavior still depends on the host filesystem, mount options and storage hardware, and needs an operator-level storage test where warranted.
Database v17 adds capacitylens_audit_outbox. Product routes enqueue their data-minimised audit record inside the mutation transaction, then synchronously drain in sequence order. The file sink fsyncs before the outbox delete and recognizes a stable auditId, so tests must cover rollback, restart recovery, append/delete replay and sink-failure retention whenever this pipeline changes.
Database v18 adds capacitylens_sync_sessions and capacitylens_sync_row_provenance. Browser sync batches carry one random per-page session id and a monotonic sequence. The server rejects a lower sequence that arrives late and uses the exact hashed row result of the preceding same-session batch to distinguish a safe successor from an intervening external edit. Ordered browser batches always enforce these stale preconditions, even in the explicit single-writer concurrency mode; direct API writes retain their configured optimistic-concurrency policy. Ordering rows expire after seven days. For an existing-row PUT or batch PUT, updatedAt is an exact server-revision precondition: omission, malformation, an older value or a caller-authored future value returns 409. A partial PATCH may omit the precondition for compatibility, but any supplied value must match exactly. PATCH is a merge: omitting a field preserves its stored value, explicit null clears an optional column, and explicit null for a required column is rejected with 400. Optional values repaired by the shared import sanitizer normalize to absence consistently before SQLite encoding. Row provenance carries its owning account explicitly and is removed as part of workspace erasure. Current servers return one server-owned revision for each PUT table/id and no others; a superseded ordered batch returns an empty revision list. During a rolling-version window the client also accepts a successful legacy receipt with missing or partial revision metadata, logs the skew and continues without the unavailable timestamp translations. The ok result stays mandatory, and a present applied count must still equal the submitted operation count.
Database v19 installs product-table triggers for every parent/child tenant relationship. They reject cross-company references on insert or update, make scoped accountId values immutable, and are verified (including their enforcement bodies) on every boot. The migration refuses a pre-existing cross-company edge rather than guessing which tenant label or reference to repair; use the verified pre-migration snapshot and an explicit operator repair before retrying.
Database v20 moves the app-owned capacitylens_bootstrap_claim table into the immutable migration ledger. It upgrades only the two definitions emitted by older CapacityLens builds, clears their unauthenticated five-minute claim lease, and preserves an already-current tokenized lease. Any other shape fails closed after the normal pre-migration snapshot instead of receiving speculative DDL. Runtime auth setup only verifies the exact table definition and expires stale leases.
Database v21 adds one non-unique accountId index to every tenant-scoped product table. Startup verifies each index's owning table, key column, direction, collation, uniqueness and partial-index flags. The query-plan regression requires both scoped reads and whole-slice deletes to use these indexes, so one company's synchronous work doesn't scale with unrelated companies' rows.
Database v22 repairs any built-in Internal client carrying an archive or deletion tombstone from the historical legacy-id replacement path. It clears both lifecycle fields and advances the row's revision so a repaired singleton is active and distinguishable from its pre-migration value. The write boundary independently rejects any future replacement that would promote an inactive row.
Production startup validates pure configuration, opens without application DDL, plans the upgrade, plans application-ledger and Better Auth schema work, and writes a verified capacitylens-pre-migration-vN-to-vM.db rollback snapshot before applying anything. Scheduled backups may stay disabled; this one-shot safety snapshot is mandatory for an existing on-disk database that needs any of those migrations. It is not retention-pruned automatically; repeated attempts for one version pair atomically refresh that one file.
CapacityLens supports coordinated restarts, not mixed-version writers. Do not add down migrations. Rollback uses the old image and its matching pre-migration snapshot while the API is stopped. If mixed-version/zero-downtime deployment is introduced later, schema changes must switch to an expand → backfill/dual-read-write → contract sequence across releases.
Persistence diagnostics
The server-mode client keeps process-local persistence counters for failed saves, retries, reconciliations, superseded reloads, rebases and discarded edits, plus the current write-suspension state (usePersistenceDiagnostics). The counters intentionally contain no tenant values and reset whenever a fresh persistence lifecycle attaches. The Owner/Admin Diagnostics page (src/components/diagnostics/) adds them to its copied support report. They're diagnostic breadcrumbs, not durable telemetry or an operator health endpoint.
Test data and generated files
Sample organisations and people must be fictional. Never copy production names, notes, domains or ids into fixtures, screenshots or stories. Paraglide output, test reports, local databases and local agent configuration are ignored and must not be committed. The only committed database files are the sanitised released-schema artifacts under server/src/fixtures/databases/.
Ports
The complete E2E matrix also uses web/API ports 5273, 5373 and 8887. Stop an existing dev stack before E2E — Playwright intentionally refuses to reuse the demo/auth servers because persistence flavour matters. When a focused Playwright command explicitly names only ordinary core spec files (for example, pnpm exec playwright test e2e/timeoff.spec.ts), the harness starts only the demo Vite server. Unfiltered, directory-filtered and mixed selections retain the complete server set unless an explicit scope flag selects a narrower supported matrix.
The access lab reserves web/API 5473/8897 and is single-flight machine-wide.
Browser upgrade rehearsal
The browser rehearsal runs the database-backed E2E specs against a staged production build. It is separate from the migration rehearsal in Upgrades, which checks schema migration and rollback behavior.
Build the release:
pnpm run buildThen prepare a disposable test deployment with the built dist/ files served through scripts/serve-dist.mjs. Its same-origin /api proxy must point to an auth-off test API with a temporary database and CAPACITYLENS_ALLOW_RESET=1 in a non-production environment. The E2E specs reset and reseed that API, so never point the browser rehearsal at production or data that must be kept. For the staged site's base URL, run:
CAPACITYLENS_REHEARSAL_URL=http://127.0.0.1:4173 \
VITE_CAPACITYLENS_API=http://127.0.0.1:4173 \
pnpm run e2e:rehearsalscripts/serve-dist.mjs defaults to http://127.0.0.1:4173 and proxies /api to the API port configured by API_PORT (default 8787). Set those ports to match the staged test API. The rehearsal project supplies no web or API servers itself; both must be running before the command.
Development/test environment controls are intentionally separate from production configuration. API_PORT belongs only to scripts/serve-dist.mjs; Playwright/package orchestration owns CAPACITYLENS_E2E_PHASE, CAPACITYLENS_WEBKIT, CAPACITYLENS_WEBKIT_ONLY, CAPACITYLENS_FIREFOX, CAPACITYLENS_FIREFOX_ONLY, CAPACITYLENS_VITE_ONLY. CAPACITYLENS_REHEARSAL_URL is the one operator-supplied test control: it points the rehearsal browser project at the staged upgraded deployment. CI pins ACTIONLINT_VERSION; update that pin alongside its download/checksum workflow review. CAPACITYLENS_E2E_PHASE must contain only letters, numbers, underscores and hyphens; unset or empty selects default. Invalid values fail configuration rather than aliasing two runs into one report directory.