BUILDING

docs/BUILDING.md · Build a feature / tool / integration

← Onboarding

BUILDING.md — how to add anything to ELAS the right way

This is the one file to read before you build a feature, data type, AI capability, or integration. Follow it and your work automatically respects ELAS's three rules:

  1. Multi-tenant — everything is scoped to an Org (orgId). Never leak across tenants.
  2. Permission- & license-governed — the server decides who can see/do what. Plans/entitlement + org-admin run through can() (libs/access.ts, libs/access/permission-catalog.ts, see ACCESS.md); the governed modules (CRM/Sales, PM, HR, Finance) authorize with Cedar (see the Authorization section below) — never gate those with useAccess().can("<module>.*").
  3. Agent-ready — if it's an action, expose it as an MCP tool so Artie can do it too (and inherit the same governance).
  4. Mobile-responsive — every screen, dialog, and email must work on a phone. Design mobile-first: fluid widths (w-full, max-w-*), responsive grids (grid-cols-1 sm:grid-cols-2 …), stacking with flex-wrap, no fixed pixel widths that overflow, touch-friendly hit targets. If it doesn't look right at 375px wide, it's not done.
  5. Reuse, never recreate — always build on the existing components, hooks, and libs below. Use shadcn/ui primitives (components/ui/*), the shared screen patterns, useResource/useCreateResource/useUpdateResource, useAccess, sendEmail (libs/resend.ts), the ERP resource layer, etc. Before writing a new component/util/email/route, search for an existing one and extend it. Don't fork a second version of something that already exists.
  6. Discoverable by default — every new screen/feature must be wired into BOTH the sidebar (components/AppSidebar.tsx → modules[].items) and the feature-flags registry (lib/features.ts → FEATURES, usually one screen(...) line) so platform admins can toggle it in dashboard/feature-flags. A dev-time guard in AppSidebar warns if a sidebar screen has no FEATURES entry. Every new agent tool goes in the TOOLS map (libs/artie/tools.ts) — it then appears automatically in dashboard/settings → "What Artie can do" (served live from /api/artie/tools, no edit needed).
  7. Every shared UI component has a Storybook story — any component in components/ui/* (and any new shared primitive you add there) must ship a co-located *.stories.tsx next to it (e.g. components/ui/card.tsx → components/ui/card.stories.tsx). Match the existing pattern (CSF3, import type { Meta, StoryObj } from "@storybook/react", title: "UI/<PascalName>", tags: ["autodocs"], a Default story plus variants/states; compose all required sub-parts and wrap provider-dependent components). This keeps Storybook (npm run storybook → :6006) a complete catalog alongside the auto-discovered in-app gallery (/dev/components). A PR that adds a components/ui/* primitive without a story is incomplete.

Golden rule: the API/server is the source of truth. The UI only mirrors permissions to hide/disable things. Always run npm run build AND npm test before you commit (unit-test any pure logic you add — §E).

Branch-per-change (required). Every change submitted to the repo goes on its own sub-branch (feat/…, fix/…, chore/…, docs/…) and merges to main through a pull request. Never commit or push to main directly — main is updated only by merged PRs (the lone exception is the automated release-please bot). One PR = one focused change; keep unrelated work on separate branches so reviews and reverts stay clean.

Spec-driven first. This file is the implementation half — how to build it in the code. The process around it is the spec-driven SOP in SESSION_SUMMARY.md: (1) triage the change — npx tsx scripts/triage.ts "<your issue>" tells you backend / frontend / both, whether a migration is likely, and the workflow; (2) for a backend/both change, write the spec (make spec-new FEATURE=x or make spec-draft FEATURE=x DESC="…" → specs/<feature>/{spec.yaml, requirements,design,tasks}.md) and have it reviewed; (3) generate + build it — make spec-gen emits the registration regions (// <generated:spec>) + a screen scaffold, then you fill the <spec:impl> logic + tests and run make spec (regenerate → tsc → build → test → drift gate). Frontend-only changes skip the spec and go straight to §B. The generator (scripts/spec/*) owns the generated regions — the sections below describe what it emits. It now also wires the sidebar entry (from screens[].icon/group/orgAdmin) and registers each Artie tool automatically (a write-if-absent run() stub per actions[] item under libs/artie/tools.generated/, merged into TOOLS); the only manual hookups it still prints are the typed-table migration and, optionally, a Zod arg schema for a tool (SCHEMAS in libs/mcp/elas-core.ts). The spec.yaml is the source of truth, enforced by the spec:check drift gate in CI.


Running it locally

Use the committed Docker dev container — it's the one supported way to run V3 locally.

docker compose -f docker-compose.dev.yml up --build     # or:  make run-dev
# → http://localhost:3000   (needs Docker + a .env.local in the repo root)

This bind-mounts the repo root into the container, so every file — tsconfig.json (the @/* → ./* alias), config.ts, components/, etc. — is always present; node_modules/.next stay in named volumes so a macOS/Windows host can't shadow the Linux deps. Edits hot-reload. Stop with docker compose -f docker-compose.dev.yml down (make dev-down); after a dependency change run make dev-build, and make dev-reset if node_modules goes stale. Prefer not to use Docker? npm run dev from the repo root works too (Node 20+, .env.local present).

Gotchas (these have bitten people):

  • Run from the repo ROOT, not app/. V3 is the Next app at the root — there is no nested app/ project. An old EKS-era harness that mounts -v ./app:/app (or a stray app/Makefile / app/package-lock.json) mounts only the App Router subfolder, so the container is missing tsconfig.json/config.ts → Can't resolve '@/components/...' / '@/config'. Delete those stray files and use the compose setup above. Do not use registry.elasapp.com / kubectl Makefiles — that's the separate EKS platform, not V3 (see PLATFORM_PLAN.md).
  • npm run dev:mock runs against MSW fixtures (no backend/DB) — handy for UI work, but it's a partial mock. Use plain npm run dev / make run-dev to hit the real backend.
  • If Next prints "created a tsconfig.json file for you", you're running in a dir without the real tsconfig.json (the alias) — you're not at the repo root / the wrong dir is mounted.

0. The mental model in 6 sentences

  • ELAS data lives in ERP resources (addressed by (orgId, resource, id)), reached through one generic API (/api/erp/:resource) that auto-enforces view/edit/delete permissions. Every ERP resource is a real typed table (libs/db/typed-model.ts — promoted indexed columns + a data jsonb tail); the generic erp_records JSONB store was dropped (de-JSONB migration, drizzle/0072), so getErpModel(...) returns a typed model for every resource and throws for an uncarved one — there is no JSONB fallback. The models/* still keep the familiar find/findOne/create/save/... API. The only JSONB store left is mongo_docs (a few low-volume domain models — Org/User + platform tables — via the Mongoose-compatible shim libs/db/engine.ts), and it's being retired in the final de-JSONB phase. Mongo is gone. (The old mongo-shim.ts/PgModel migration bridge was decommissioned — see PLATFORM_PLAN.md §B1.)
  • Plans unlock whole modules; seats set a ceiling. Then two authorization layers decide specific actions: can(ctx, "module.action") (login role + User.customRole→roleDefinitions + explicit grants; the permission catalog libs/access/permission-catalog.ts is what the Roles screen shows / expandRolePermissions() expands) for plan/entitlement, org-admin, and ungoverned modules — and Cedar for the governed modules (CRM/Sales, PM, HR, Finance), which composes ownership/state/scope a flat key can't. Use the module's Cedar guard + hook there, not can("<module>.*") (see the Authorization section).
  • Artie is a single AI agent that is an MCP host: it discovers tools from MCP servers and calls them. It can only do what the requesting user is allowed to (agent ⊆ user).
  • Scope guardrail (defense in depth). Artie is a business assistant. By policy in its system prompt (libs/artie/agent.ts) it refuses requests to write/read/review/debug source code, find or probe security vulnerabilities, reveal secrets/keys/env/infra, or act on ELAS's own codebase — regardless of the asker's role or framing. This is reinforced structurally: Artie's tools (libs/artie/tools.ts) are all business actions — there is no shell/file/code/SQL tool — so it cannot execute code or read source even if prompted. Don't add tools that would expose source, secrets, infra, or cross-tenant data.
  • Internal tools live in the elas-core MCP server; email/Slack/Jira in workspace; big external systems (QuickBooks, payroll) are their own MCP servers; anyone can plug in a remote MCP server from the Integrations screen.
  • A "feature" is usually: a resource + a screen + (optionally) an agent tool. An "integration" is usually: a connector lib + an MCP server + a connect flow.
  • You almost never need a new framework — you slot into the existing ones below.

Authorization — Cedar (read this before you gate anything)

We authorize with Cedar. The governed modules each have a Cedar policy set + a schema and are enforced through the module's guard, not through useAccess().can("<module>.*"):

ModuleServer guardClient hookPolicies
CRM / SalesauthorizeSalesAction / salesErpAuthorize (libs/sales/authz/*)useSalesAccess()libs/sales/authz/policies/*.cedar
Property ManagementauthorizePmAction / pmAuthorize (libs/property-management/authz/guard.ts)— (server + screenModule)libs/property-management/authz/policies/*.cedar
HR / Perf reviewsauthorizeReviewAction (libs/hr/authz/guard.ts)—libs/hr/authz/policies/*.cedar
FinanceauthorizeFinanceAction (libs/finance/authz/guard.ts)—libs/finance/authz/policies/*.cedar

The engine is libs/authz/cedar/ (engine.ts + types.ts); each module ships schema.cedarschema

  • layered NN-*.cedar policies (base RBAC → scope/ReBAC → ABAC-state → a 99-forbid-default deny).

The rules

  • Server (the boundary): in a route for a governed module, call that module's guard and deny on decision === "Deny". Do not hand-authorize with can(org, "<module>.<action>") for a Cedar module — Cedar is the source of truth, and it composes ownership/state/scope (ReBAC/ABAC) that a flat permission key can't. The generic /api/erp/:resource route already routes sales/PM resources through their guards for you; a dedicated route must call the guard itself.
  • Client (cosmetic mirror): gate the UI with the module's Cedar hook — useSalesAccess().canSeeScreen("crm"), useFinanceAccess().screen(...), useTimesheetAccess().can(...) — never useAccess().can("sales.*"). useAccess().can() defaults plain members to view/edit/export, so it fails OPEN relative to Cedar and shows controls the server will reject.
  • A new governed screen needs a Cedar screen-gate entry (e.g. Finance: FINANCE_SCREEN_GATES in libs/finance/authz/screens.ts) mapping the screen to its action, or the hook denies it and it vanishes from the nav.
  • What still uses can() / the permission catalog: plan/module entitlement (entitled), org administration (org.*), the Roles-screen catalog, and any module that does not yet have a Cedar plane (core/operations). can() and Cedar are complementary — plan gates the module, Cedar gates the action inside a governed module.

New capability in a governed module → add it to that module's Cedar schema + policies (and its screen-gate/capability map), not as a new PERMISSION_CATALOG string. See the module's specs/<module>-cedar-authz* spec for the policy shape.

Adding or changing a resource entity (the silent fail-closed trap)

  • Every entity that can be a Cedar resource MUST be listed in the appliesTo of each action it is authorized against. Evaluation is schema-aware — all five modules construct their engine as loadEngine({ policies, schema }) and libs/authz/cedar/engine.ts forwards the schema on every isAuthorized call (required so a ref attribute like resource.propertyRef resolves as a real Entity rather than a Record). Cedar therefore validates the request before evaluating any policy, and a resource type that is not registered for the action is a validation failure, not a decision: cedar-wasm returns type: "failure" and engine.ts fails closed to { decision: "Deny", determiningPolicies: [] }. The empty determiningPolicies is the tell — it means no policy ran, but in logs and the audit trail it reads exactly like a policy deny. The entity declaration, its ReBAC forbid, the loader, mapPmDocToResource-style mapping and the guard can all be present and correct in review; only a decision executed against the real engine surfaces the hole. PropertyUnit was declared and carried a rebac-unit-scope forbid but was registered on no action from 2026-07-29 to 2026-09-18, so every unit read/update/delete returned 403 to every caller except a platform super-admin, and the forbid was dead code the whole time. Register only the actions a route actually authorizes — registering one nothing uses widens the reachable decision surface for no gain.

  • Regenerate the baked policy text after ANY schema.cedarschema or *.cedar edit. The script dir is abbreviated while the lib dir is not, so they do not share a name: scripts/pm → libs/property-management/authz/, and scripts/{sales,hr,finance} → libs/{sales,hr,finance}/authz/.

    npx tsx scripts/pm/gen-policy-text.ts   # → libs/property-management/authz/policy-text.generated.ts
    

    Runtime reads the policy + schema text from that generated module, not from disk, because Next output: "standalone" does not reliably trace .cedarschema/.cedar files into the bundle. Skip the regen and your change ships to the tests and not to the app. policy-text.drift.test.ts fails CI when it goes stale — never hand-edit the generated file.

  • Cover a new entity in the module's persona matrix on both sides, and make the deny row prove a policy ran. An allow row plus an out-of-scope deny row — and the deny row must assert the deciding policy (determiningPolicies), not just decision === "Deny". An unregistered entity also produces Deny, so a bare deny assertion stays green while the entity is entirely unreachable. PM's libs/property-management/authz/schema.test.ts carries a structural tripwire that fails CI when any declared entity is registered on no action (comments stripped first, so a type named only in a comment inside appliesTo does not satisfy it); HR has a narrower hardcoded list; QC, Sales and Finance have neither yet.


A. Add a new data type (ERP resource)

Use this when you need a new kind of record (e.g. warranties, changeOrders).

Spec-driven: this is how you implement the data model your specs/<feature>/design.md describes. A new ERP resource now needs a typed Drizzle table — erp_records is gone and getErpModel() throws for an uncarved resource, so there is no schemaless JSONB fallback. The ERP_RESOURCES + ResourceTypeMap entries are still its app-facing contract; the typed table + typedSpec is its storage (step 1 below; see PLATFORM_PLAN.md §B1).

  1. Give it a typed table: add a drizzle/000N_*.sql migration + a typedSpec in libs/db/typed-registry.ts. Follow the carve pattern (scripts/pg/carve-remaining-gen.ts or any drizzle/0071-style table): org_id + promoted indexed columns + a data jsonb tail + ENABLE/FORCE ROW LEVEL SECURITY with the org_isolation policy. Apply the migration before the code that reads it ships (same order as every carve). Register it: add the name to ERP_RESOURCES in libs/erp/resources.ts.

    The id column is text, not bigint, and the spec says idType: "text". New records get a client-generatable UUIDv7 from uuidv7() (libs/ids/uuidv7.ts) — not nextId. A server-allocated id can't be minted offline, so a record created on a device would have no identity until it synced: nothing could reference it and no later offline edit could address it. A new resource has no rows, so being born correct is free — and it skips the Phase D column migration entirely (specs/data-layer-migration-closeout/). libs/db/id-type-ratchet.test.ts enforces this; the 171 resources still on bigint are legacy and listed there.

  2. Type it: add an interface + a ResourceTypeMap entry in libs/erp/types.ts (the build fails until you do — that's intentional).
  3. License/permission it: add it to RESOURCE_MODULE in libs/access.ts (which module owns it — sales/operations/finance/hr/core). That alone gives it correct view/edit/delete gating on /api/erp/:resource.
  4. Catalog it (if admins should assign it in Roles): add a row to PERMISSION_CATALOG in libs/access/permission-catalog.ts — use module.<resource>.view/edit/delete (subsection under the right group) with implies: ["<module>.view"] / ["<module>.edit"] so existing permFor(resource, action) gates still work until you split enforcement. See ACCESS.md § Role templates.
  5. Use it from the client with the typed hooks: useResource("warranties"), useCreateResource, useUpdateResource, useDeleteResource, persistPatch. They call the governed generic API — no new route needed.

Once the typed table exists, that's it. The generic route handles CRUD + org-scoping + activity logging + permissions.

⚠️ Every READ of a carved table must carry a top-level orgId

Carved tables are FORCE ROW LEVEL SECURITY with the org_isolation policy (drizzle/0013_rls.sql):

current_setting('app.org_id', true) IS NULL OR org_id = current_setting('app.org_id', true)

That reads as "no org context ⇒ see everything", and behind the RDS Proxy it is the opposite. A pooled backend session that has ever run SET LOCAL app.org_id reports the empty string once its transaction ends, and '' IS NULL is false — so the policy collapses to org_id = ''. Measured on prod: every fresh connection reports is_null=false, val='' (the full write-up lives on Query.scopedToFilter in libs/db/engine.ts).

TypedModel only pins the GUC when the filter carries a non-empty, top-level orgId. So these three shapes are all unbound, and in production return only rows stored at org_id = '':

Model.find({})                                          // no filter at all
Model.findOne({ id })                                   // id only
Model.find({ $or: [{ scope: "default" }, { orgId }] })   // mentions orgId, but not at the top level

The third is the dangerous one: it looks org-scoped, and it passes under the in-memory test mock, which supports $or and has no RLS. Unit tests cannot catch this class of bug — that is how QC custom roles shipped broken. A company could create a custom role, get a success response, and then never see it again in any list, edit or authorization decision, because platform defaults live at org_id = '' and survived while every org-owned row was dropped by the database.

Do: pass the org in the filter (find({ orgId }), findOne({ orgId, id })), and when you need platform rows plus an org's rows, do two reads — one bound, one not — as libs/access/authz/role-store.ts does. There is no single filter that binds correctly for both. Guard it with a source-text check, since behaviour tests can't (role-store.test.ts has a model).

Never filter by org in JS after an unbound read. The rows never arrive.

A new resource needs a migration; a new field does not. erp_records is gone, so a brand-new ERP resource requires its own typed table + typedSpec (step 1) — getErpModel() throws otherwise. But new fields on an existing resource are still free: they land in the table's data jsonb tail and are storable + agent-readable the moment they exist. You only need DDL for a new resource, or to promote a field to an indexed column.

Checklist: ✅ typed table + typedSpec migration applied ✅ id column text + idType: "text" + uuidv7(), not nextId ✅ in ERP_RESOURCES ✅ in ResourceTypeMap ✅ in RESOURCE_MODULE ✅ catalog row in permission-catalog.ts (if role-assignable) ✅ uses useResource/useCreateResource.


B. Add a screen / feature

  1. Route: app/dashboard/<screen>/page.tsx renders your screen component and passes onNavigate={d.navigateTo} from useDashboard().
  2. Data: load with useResource(...); mutate with the hooks above (never hand-roll fetches to bypass permissions).
  3. Gate the UI: for a Cedar-governed module use its hook — useSalesAccess().can(...) / .canSeeScreen("crm"), useFinanceAccess().screen(...) (see the Authorization section). Only for an ungoverned module use const { can } = useAccess(); (e.g. can("operations.edit")). Either way it's cosmetic; the server still enforces.
  4. Module/plan visibility: the sidebar item and screen should map to a module via screenModule() so a plan that lacks the module hides it. Add to SCREEN_MODULE/MODULES in libs/access.ts if new.
  5. Bespoke-action authz — if the screen exposes actions beyond generic view/edit/delete (approve, configure, export-only, send, import): in a Cedar-governed module, add the action to that module's Cedar schema/policies and gate the route with its guard (authorize<Module>Action). In an ungoverned module, add catalog keys in libs/access/permission-catalog.ts and gate the route with can(org, "<key>") (subsection keys with implies; mark approve/configure/deploy/manage as powerOnly: true).
  6. Dates → <DatePicker> / <DateRangePicker> (components/ui/date-picker). Files → POST /api/uploads (private S3 via libs/storage/s3.ts); it returns a stable /api/files/<key> URL that presign-redirects on read (tenant-scoped). Server-side fetchers (OCR/vision/import_file) must call resolveFetchableUrl(url) first to get a direct presigned S3 URL — external services + redirect:"error" can't use the app-auth redirect. Don't reinvent these.
  7. Validate every input field — no form may save junk (e.g. asdfasdf in email/phone). Use shared helpers in libs/validation.ts (isValidEmail, isValidPhone, validateEmployeeProfile, …); show inline errors under the field on submit (and live after the first failed submit). Mirror the same rules on the server in the API route or ERP PATCH handler so UI, Artie, and direct API calls stay aligned — return 400 with an actionable message. Optional fields skip required checks but are format-validated when non-empty. Required fields block save until filled.

Checklist: ✅ route added ✅ data via hooks ✅ UI gated with the module's Cedar hook (useSalesAccess/useFinanceAccess) for a governed module, else useAccess ✅ server enforces (generic route, the module's Cedar guard, or can() for an ungoverned module) ✅ Cedar screen-gate added for a new governed screen (else catalog keys if role-assignable) ✅ all form fields validated client + server.


C. Make Artie able to do it (add an agent tool)

If your feature has an action worth automating, expose it as an MCP tool. For internal actions, add it to the elas-core server.

  1. Implement the action in libs/artie/tools.ts (TOOLS map) — run(ctx, args) using ctx.orgId. Return JSON; for reversible writes include __undo and an undoRun.
  2. Register its MCP schema in libs/mcp/elas-core.ts (SCHEMAS — a small Zod shape) so it's discoverable with a real input contract.
  3. Govern it in libs/mcp/governance.ts (ELAS_TOOL_GOV):
    • read-only → {}
    • reversible write → { undoable: true, permission: "module.edit" }
    • irreversible/external → { external: true, permission: "module.edit" }
    • permission depends on args (e.g. resource) → { permissionFrom: "resource" } Use the same permission string you added to PERMISSION_CATALOG when the action is finer than module.edit (e.g. sales.proposals.send, timesheets.pto.approve). Add that key to the catalog with implies if routes still check the broader perm.
  4. Done — the host auto-enforces permission + write-disable + bulk-confirm + audit, and the model sees the tool in its catalog.

Never call an external system or mutate data in a tool without a permission. Agent ⊆ user is non-negotiable.

REQUIRED — every CREATE tool returns a deep link to the specific new record. Set navigate on the result to a record-level route, never a bare list screen, so Artie's "Open it" button lands the user on exactly what was created:

  • Generic ERP resources → navigate: `record/${resource}/${id}` (the /dashboard/record/[resource]/[id] detail route covers any ERP_RESOURCES entry).
  • Resources with a richer dedicated detail route → use it: projects → `project/${id}`, estimates → `estimate/${id}` (routed to the builder with that draft pre-selected via navContext.estimateId).
  • The id must be the same identifier the detail screen looks up (the ERP id, or the estimate's _id string). Tool-result navigate bypasses resolveScreen, so deep-link slugs are trusted as-is — just make sure the route actually exists.

Opening an EXISTING record → open_record, never navigate. When the user names a specific record ("pull up / open / show me <the Di Stefano Cheese deal>"), Artie must deep-link to the actual item, not the list. open_record(resource, name|id) resolves it (id, or a name match like get_record) and returns the trusted navigate: `record/${resource}/${id}` — which DashboardContext routes to the resource's dedicated screen with the record selected (DEEP_LINK_SELECT: contacts→contacts, deals→crm, invoices→ar, opsContracts→project) or the generic detail route. The navigate tool only opens a bare screen, so it must NOT be used for a named record. The prompt enforces this (libs/artie/agent.ts).

Attachments + two-step (preview → confirm) → PERSIST a preview, commit by id; NEVER re-read the file on confirm. Uploaded files are not retained across turns — on the confirm turn ctx.attachment is gone and the blob URL may be unresolvable. So a tool that reads an attachment to PROPOSE something and then needs the user to APPROVE it must, on the no-confirm call, persist what it parsed to a staging/preview record (e.g. orgChartPreviews) and return its previewId; on confirm it commits that stored preview by id and does not re-open/re-OCR/re-fetch the file. Re-processing on confirm is the bug that made org-chart image import fail ("the image didn't come through" — it had already read it). Prefer one-shot tools (read + act in a single call while the attachment is present, like scan_receipt(create:true)) when no human approval is needed. Reference: import_org_chart (preview-id pattern). Known migration target: import_file re-fetches the URL on confirm — works only because /api/files URLs persist + the model re-passes url; move it to the preview-id pattern when touched.

Checklist: ✅ run() org-scoped ✅ Zod schema ✅ governance entry with the right permission ✅ __undo if reversible ✅ CREATE tools return a record deep link in navigate ✅ "open <named record>" uses open_record, not navigate ✅ attachment + confirm → persist a preview, commit by id; never re-read the file on confirm.

Back from a deep-linked record — a record reached via a deep link (Artie's "Open it", a widget drill-through) has no browser history, so router.back()/history.back() are unreliable. The generic record detail/list Back uses screenForResource(resource) (lib/flow.ts) → the resource's dedicated screen (contacts→contacts, deals→crm, invoices→ar, …) or the generic record/<resource> list. Add new dedicated-screen resources to that map.

Provision shared singletons atomically. buildSessionOrg auto-creates an Org for a user with none — but it MUST claim it atomically (User.updateOne({_id, orgId:null}, …), delete + adopt on lose) because the dashboard fires concurrent first-login requests; a naive if (!orgId) create() spawned orphan 0-user orgs. Any lazy "create-on-first-use" of a per-tenant/per-user singleton needs the same guard.

Dashboard home is a pinned custom dashboard. Dashboard.pinned (one at a time) makes /dashboard/dashboard render that CustomDashboardScreen instead of the role overview (branch in the page, not inside DashboardScreen, to respect hook order). Dashboards are org-visible by default (Share = copy link). A viewed dashboard resyncs from the server on JSON.stringify(widgets) change (not just id) so Artie's update_dashboard appends show live (after the ["erp"] invalidation Artie already fires on writes).

Required fields belong in the tool, not just the UI. create_record enforces that a contact has a name AND an email-or-phone, returning an actionable error so Artie asks for the missing detail instead of writing a half-record. Put cross-surface invariants (uniqueness, required fields) in the API/tool so chat, voice, and the UI all honor them — e.g. onboarding company-name uniqueness is enforced in POST /api/onboarding (409) with a debounced GET /api/onboarding/check-name for live UI feedback.

REQUIRED — Artie never leaks internals to the user. Replies must never mention tool/function names (list_projects, create_record, …), DB table/collection/ resource names (opsContracts, dispatchJobs, …), raw field keys, or non-human ids. Tool names are for the model to CALL, not to show. On a failed lookup, offer a plain-English next step ("want me to pull up your full project list?") — never tell the user to run a tool or check a table. This is enforced via a system-prompt guardrail in libs/artie/agent.ts (systemPrompt Guidelines). When you add a tool or surface, don't undo it by echoing tool/table names in any user-facing copy.


C2. Agent tools — what's covered + how it stays future-proof

Artie can only do what's in its tool catalog. Two layers make the app agent-drivable, and most of it auto-extends as you add features:

Generic (auto-covers every NEW resource/screen/field — no tool to write):

  • list_records / create_record / update_record / get_record — read/create/update/deep-read any ERP resource. get_record(resource, id|name) returns every field on a single record (matched by id or name), so Artie can answer detailed questions about any feature — and new collections/fields are covered the moment they exist, no per-feature tool. (Projects/estimates have richer get_project/get_estimate that add a financial breakdown.) Add a resource to ERP_RESOURCES and it's instantly agent-readable/writable (permission via permFor).
  • navigate — its allowed screens are enumerated from screenMeta at load, so a new screen in the registry is automatically navigable.
  • search_knowledge, kpi_summary, and the canned read tools.

Universal search auto-covers you too. The ⌘K palette (components/GlobalSearch.tsx → GET /api/search, libs/search/*) searches every ERP_RESOURCES record the caller can view plus every screenMeta screen they can see (canSeeScreen), returning a record/<resource>/<id> deep link per hit. Add a resource/screen and it's searchable with no search code — governed by the same view perms + canSeeScreen the sidebar uses. See specs/universal-search.

Workflow tools (one per side-effecting action): create_estimate/update_estimate/send_estimate, create_project/update_project, create_work_order, invoice_project/approve_invoice/send_invoice, create_invoice/mark_invoice_paid, create_purchase_order/approve_purchase_order, enroll_in_sequence, log_time, submit_timesheet, get_my_timesheet, scan_receipt/submit_expense (file an expense — from a receipt photo, or amount + what-it-was-for; submits for approval, optional project→billable), request_time_off/list_my_pto, move_deal, send_sms, company_overview (org snapshot for "tell me about my company"), build_dashboard/update_dashboard (compose an editable dashboard of widgets over any resources → dashboards resource, rendered/edited at /dashboard/dashboards/[id]). Widgets: metric/bar/donut/list/line(+ratio), source ERP or an MCP connector tool (read-only, via /api/artie/widget-data); clicking a widget drills through to the records (/dashboard/record/[resource] list, filtered, → [id] detail).

Self-improving loop (reflection → suggestions → macros → scoring). Artie gets better per-tenant over time WITHOUT generating code. (1) Every chat turn writes an AgentRun reflection record (tool names + outcome + heuristic signals; never arg values). (2) /api/artie/learning computes, live from those runs, per-user suggested skills (repeated intents ≥3×) and macro candidates (recurring tool sequences), plus org-wide tool scoring (usage/success). (3) An admin promotes a suggestion → it becomes a Skill (or a macro: an ordered list of EXISTING vetted tools stored on ArtieSkill.toolPlan). run_macro executes a macro deterministically — each step re-checked for permission (agent ⊆ user), the write kill-switch, dry-run, and disabled-tools. (4) Owners can disable any tool (Org.artieDisabledTools → guardrails → enforced in the agent loop). Surfaced in Settings → "Artie Learning". RULE: synthesized capabilities are macros over vetted tools, never generated code.

Project billing (milestones + itemized invoicing). Projects carry billable milestones (a generic ERP resource: name, amount, status, billed). libs/invoicing.ts is the shared engine: assembleProjectInvoice drafts an itemized invoice from completed-unbilled milestones + submitted labor hours (× rate) + approved billable pass-through expenses (with receipt backups); approveProjectInvoice flips Draft→Pending and marks milestones billed; sendProjectInvoice emails it (reuses libs/resend). It's reused by BOTH Artie (invoice_project/approve_invoice/send_invoice) and the project detail's Generate invoice button (POST /api/projects/[id]/invoice) — one engine, no duplication. Invoices gained lineItems/contractId/attachments. These wrap effects beyond a field write (auto-numbering, emailing a customer, firing a sequence, computing week/hours, profitability fields). Generate invoice opens a confirm modal (labor rate + expense/sub markup, shows what will be billed) — destructive/outward-leaning actions confirm intent, never blind-create. The project detail lists its own invoices (by contractId, finance.view), each deep-linking into AR via navContext.invoiceId; the AR detail dialog edits an invoice (customer/amount/due/description/status) gated on finance.edit.

Projects (PM command center). Projects are the opsContracts resource (ProjectDetailScreen at /dashboard/project/[id], board at /dashboard/project). Fields incl. profitability: value (contract $), directCost (budget) → gross profit = value − directCost, margin %; plus projectManager, city, origin, newOrExisting, proposalNumber, estimateId, percentComplete, dates, notes. Two creation paths: (1) accepting an estimate auto-creates the project + a work order and carries financials (libs/estimate-automation.ts → runSignedAutomation, idempotent); (2) New Project button on the board (or Artie's create_project) for jobs with no estimate. Views are role-gated: field crew see only their tasks; managers see all tasks/crew/work orders + can inline-edit; finance/owners additionally see the Profitability section. Artie sees/edits every field via create_project/update_project.

Future-proofing guarantees (so net-new tools "just work"):

  1. createElasCoreServer registers every entry in TOOLS automatically — add to the TOOLS map and it's discoverable, no registration step.
  2. govFor falls back to the tool's own declaration (external / undoable / permission) when there's no explicit ELAS_TOOL_GOV entry — so a new tool is governed correctly from its definition alone. (Only arg-derived permissions, e.g. permissionFrom: "resource", still need an explicit entry.)
  3. Voice is free: STT → the same chat/agent pipeline → the same tools. If it works by chat, it works by voice.

So the Definition of Done for any feature with an action is: add a TOOLS entry (run + a one-line args/Zod schema). Generic CRUD + navigate already cover the rest. Reading/writing data and moving around the app are covered the moment the resource/screen exists.


D. Add an integration (MCP connector)

First decision — does it need its own server?

SituationWhere it goes
A few lightweight tools wrapping an existing OAuth client (e.g. another comms tool)add to workspace (libs/mcp/workspace.ts)
A whole external system with its own OAuth + sync semantics (accounting, payroll, PM)its own built-in server (libs/mcp/<name>.ts)
A third-party that already ships an MCP servernothing to build — connect its URL in Integrations → AI tool servers (MCP)

To build a built-in connector (pattern: see quickbooks/payroll):

  1. Connector lib libs/integrations/<name>.ts: xConfigured(), OAuth/token (or per-org creds on Org), and the API calls (entity mappings). Gate everything on credentials + connection; degrade gracefully (no .env fallback, clear "not connected" errors).
  2. MCP server libs/mcp/<name>.ts: create<Name>Server(ctx) registering tools via (server as any).registerTool(name, { description, inputSchema, annotations }, handler). Annotate each tool: read tools { readOnlyHint: true }, write tools { destructiveHint: true, openWorldHint: true } — the host derives governance from these.
  3. Register the server id in BUILTIN_SERVERS in libs/mcp/host.ts.
  4. Connect flow: an /api/integrations/<name> route (OAuth callback or a creds form) that, on success, upserts an McpConnection { kind: "builtin", builtinId: "<name>", defaultPermission: "<module>.edit" }. (Or always-mount it in ALWAYS_ON if it self-gates, like workspace.)
  5. UI: add/extend the card in components/screens/IntegrationsScreen.tsx (connect/status/disconnect). Org-level connects require can("org.manage").

Capability dispatch (call a connector by what it does, not its name). When a feature must hand work to whatever connector the org happens to have connected (e.g. push payroll to ADP/Paycor/Gusto/QuickBooks), don't branch on a provider name. Annotate the connector's tool with a capability hint (e.g. payrollPushHint: true), teach govFromAnnotations in libs/mcp/host.ts to govern that hint (external/irreversible + the connection's permission), add a find<Capability>Tool(host) discovery helper, and dispatch exactly like widget-data: createMcpHost → discover by hint → requiredPermission + agent ⊆ user gate → callTool(payload) → host.close() in finally. 0 matches → graceful fallback (CSV/no-op, never an error); >1 → ask the caller to pick by serverId. All provider knowledge stays inside the connector, so a new provider is just a new connector — zero edits to the feature. (Reference: Phase E payroll — libs/timesheets/payroll-export.ts + app/api/timesheets/export/payroll.)

To consume a remote MCP server: nothing to code — Integrations → AI tool servers (MCP) → Add MCP server test-connects, stores an McpConnection (kind remote), and the host discovers its tools, governed by the permission you pick.

Checklist: ✅ connector lib gated on creds ✅ MCP server with read/write annotations ✅ registered in host ✅ connect route upserts McpConnection ✅ UI card ✅ org.manage for org-level setup.


D2. Swap the model brain (LLM / embeddings / TTS)

The reasoning model, embeddings, and voice swap behind seams. The LLM provider is resolved per tenant (cloud default; edge opt-in — see EDGE.md); TTS/ OCR/embeddings remain platform-level.

  • LLM — libs/artie/llm.ts resolves a provider via resolveLlmConfig(tenant.ai):
    • Cloud (default): Amazon Bedrock (bedrock.ts) — BEDROCK_MODEL_ID[_SMALL/MID/LARGE], AWS creds.
    • Edge (opt-in): an OpenAI-compatible local server (openai-compat.ts, e.g. Ollama) — LLM_PROVIDER=openai, LLM_BASE_URL, LLM_MODEL[_SMALL/MID/LARGE], or per-tenant Org.ai.endpoint/models. Cloud escalation OFF by default.
    • Add a provider = a lib/artie/<name>.ts with complete/stream + a branch in llm.ts.
    • Use-case-driven model routing. complete(system, prompt, { task, effort }) routes a (task, effort) pair → a tier small | mid | large → a concrete model id from env. Use-case wins first: extraction/classification → small (cheap, low-temp), planning → large; chat/draft track the user's depth slider (Fast→small, Balanced→mid, Thorough→large). Tag every complete() call with its task (e.g. receipt OCR = extraction, draft_email = draft, the agent loop = chat + the request's effort). Configure per provider — REPLICATE_LLM_MODEL_SMALL/MID/LARGE or BEDROCK_MODEL_ID_SMALL/MID/LARGE; each tier falls back to the base model (REPLICATE_LLM_MODEL / BEDROCK_MODEL_ID) when unset, so leaving tiers unconfigured = single-model behaviour (graceful degradation). resolveModel({task,effort}) reports which model a call will use (audit/observability).
  • TTS — app/api/artie/voice/route.ts, preference order Polly → Replicate → self-hosted MISO → browser fallback. Amazon Polly: TTS_PROVIDER=polly, POLLY_VOICE, POLLY_ENGINE.
  • Receipt OCR — libs/receipt-ocr.ts calls docTR over DOCTR_OCR_URL (self-hosted, see services/doctr-ocr/), then the LLM extracts fields.
  • Realtime voice (duplex) — optional always-open WS gateway (services/voice-gateway/, AWS App Runner; client libs/voice/realtime.ts, enabled by NEXT_PUBLIC_REALTIME_VOICE_URL). STT (Transcribe) + agent (proxied back to /api/artie/voice-turn, token-auth) + TTS (Polly)
    • barge-in. Invariant: a turn that enters "thinking" MUST receive exactly one terminal event (response.done or error). The gateway synthesizes one even when the upstream ends abnormally (non-OK body, {configured:false}, no done); the client also runs a 30s per-turn watchdog → onError → fall back to streaming/browser STT. Never leave the UI hanging on "Thinking." Falls back to streaming STT (NEXT_PUBLIC_STT_WS_URL) → browser STT.

Rule: every provider gates on its config (xConfigured()) and degrades gracefully when unset (LLM → rule engine; TTS → next provider/browser; OCR → manual entry). No crash, no silent wrong behaviour.

REQUIRED — edge-safe. ELAS runs whole-agent-on-edge for tenants who opt into edge (data + inference stay on-prem; EDGE.md). So:

  • Never call a model provider directly. Always go through libs/artie/llm.ts (complete/completeStream) so the per-tenant provider (cloud Bedrock vs local Ollama) is honored. Pass config from the resolved tenant config inside the agent.
  • Don't assume cloud reachability in a tool's hot path. A tool may run on an air-gapped box — guard external/cloud calls and degrade gracefully offline.
  • Don't exfiltrate business data to our cloud from a tool. Customer records, file contents, and secrets stay local; only the allow-listed config/telemetry crosses the boundary. New "send to our cloud" behavior must be opt-in + documented.

D3. Ship a marketplace addon (internal app) — incl. default roles

A first-party ("internal") marketplace app is published via libs/marketplace/internal-listings.ts + a scripts/marketplace/seed-*.ts, and installed through app/api/marketplace/install/route.ts. Installing must: (1) enable its screens (the addon slug is what AppSidebar ADDON_SCREENS gates on), (2) grant its module permissions, (3) seed any per-tenant registries from the template pack, and (4) seed its default role templates.

Default roles (required for any addon with more than one persona). Ship the addon's persona set as a libs/<addon>/roles.ts — a PersonaRoleSpec[] (id/name/description/permissions in the addon's own *. keys) and a seed<Addon>Roles(orgId, getModel) that delegates to the shared seedPersonaRolesFor (libs/marketplace/persona-roles.ts). Register it by slug in ADDON_ROLE_SEEDERS (libs/marketplace/addon-roles.ts); the installer calls seedAddonRoles(slug, orgId) on every install path (internal-app installer, generic storefront install, and pending-request approval) — seed-if-absent, idempotent, isSystem: true, never auto-assigned, never overwritten on re-install. See ACCESS.md → Marketplace addon roles.

Registered addons today: RevOps (revops → Analyst/Manager), Sales Playbook and Bid Finder. Adding another is one ADDON_ROLE_SEEDERS line + a libs/<addon>/roles.ts — no per-addon branch in the route.

Module dependencies (byo-module-licensing). If your addon needs a business module to work (it reads/writes that module's data, or extends its screens), it must be installable only where that module is enabled — otherwise it half-works on a Build Your Own account that hasn't turned the module on. You rarely declare this by hand: requiredModulesForAddon (libs/marketplace/dependencies.ts) derives the dependency from the ERP resources in your dataAccess (via resourceModule), so an addon that declares contacts/deals automatically requires sales. Add an explicit requiresModules: [...] on the listing only for a dependency not expressible as a declared resource. POST /api/marketplace/install (and admin approval) blocks with a 409 naming any missing module, using moduleEnabledForOrg (plan ∩ selection). Your addon's OWN module (e.g. revops) is not à-la-carte selectable, so it never counts as a dependency. See ACCESS.md → Build Your Own.

Checklist: ✅ internal listing + seed script ✅ install enables screens + grants permissions ✅ template-pack registries seeded ✅ default role templates seeded (ADDON_ROLE_SEEDERS → seedPersonaRolesFor), system + unassigned + idempotent ✅ addon-scoped permission keys in PERMISSION_CATALOG ✅ module deps derived from dataAccess (+ explicit requiresModules if needed) so the install gate blocks on a BYO account missing the module.


D3. Import data (any file → records or knowledge)

Bulk data loading is one universal AI pipeline — never hand-roll a per-screen CSV parser again. Drop ANY file (CSV, Excel, JSON, PDF, Word, image) and the system extracts it, classifies it, and routes it:

  • Extract (libs/ingest/extract.ts) → a table (CSV/XLSX/JSON-array) or text (PDF/DOCX/TXT/OCR'd image). Pure local parsing; image OCR via DOCTR_OCR_URL. Multi-sheet + multi-file: extractTables(name,type,buf) returns one table per non-empty sheet of a workbook (and one per CSV/JSON-array file); isTabularUpload() routes tabular files through it. extractFile() (single table / text) is still used for non-tabular files.
  • Classify + map (libs/ingest/classify.ts) → for a table, the LLM picks the target resource + maps columns to fields (small/cheap tier, edge-honoring config); for text, it decides extract-records vs file-as-knowledge. Both have a deterministic heuristic fallback so ingest never hard-depends on a model.
  • Target catalog (libs/ingest/fields.ts, IMPORTABLE) — the resources you can import into + their fields. Add a resource here to make it importable; fields are unioned with keys sampled from existing records.
  • Routes: POST /api/ingest accepts one or many files (getAll("file")) and returns either a single proposal or, for a multi-sheet workbook / multi-file batch, { multi:true, proposals[] } — a review queue (each sheet/file classified + mapped independently, capped at MAX_TABLES). No writes. POST /api/ingest/commit writes one table at a time — records via org-scoped bulk insert (permission permFor(resource,"edit")) or tenant-scoped Artie knowledge (ingestKnowledge(…, orgId)). <ImportReview> walks the queue one table at a time (Import & next / Skip).
  • UI: reuse the shared import surfaces in components/ImportPanel.tsx — never hand-roll a file <input> or a per-screen CSV parser. They all wrap useSmartImport() → the <ImportReview> dialog (editable mapping + live preview) → commit, so import behaves identically everywhere:
    • <ImportButton resource="…" label title onImported> — a button that opens the import in a popup modal (the review-first experience). Use this on a screen's "Import" (e.g. Contacts).
    • <ImportModal open onOpenChange resource title onImported> — the modal itself, to open from your own trigger.
    • <ImportDropzone resource compact onImported> — the drag-and-drop panel + review flow (the shared core). The full-page Bulk Import screen (/dashboard/import, BulkImportScreen) renders this, so the page and the modal are the same experience.
    • <SmartImportButton resource="…"> — the lightweight variant that opens the OS file picker directly (no modal), still used by Datasets / Org Chart. Prefer <ImportButton> for a review-first modal. The Artie composer's drag-and-drop also feeds the same useSmartImport() pipeline. Replace any per-screen CSV importer with one of these.
  • Agent: import_file({ url, resource?, confirm }) runs the same pipeline so Artie can import an uploaded file (analyze → confirm → commit).
  • Archives: only one file attaches per chat message, so import_file also accepts a .zip (detected by ext + PK magic): it decompresses in-process (fflate), runs each entry through extractFile, returns a manifest first (no confirm), and on confirm creates one dataset per table — the "build a dashboard from 21 files" path. Uploads allowlist permits .zip (decompressed, never executed); the composer guides multi-file selections to zip. Keep heavy/long work inside the tool call (the agent step shows "Working on it").

Knowledge follows the unified visibility ladder (user→department→org→global): knowledge rows with org_id NULL are global (only the platform super admin can add global/parametric model-training data); tenant rows are scoped user/department/org. libs/knowledge/scope.ts (allowedScopeFilter, knowledgeScopeSql, datasetReadable) computes what a caller may read; libs/knowledge/share.ts (canShareTo) governs share-up. searchKnowledge(q, k, orgId) returns the caller's in-scope chunks + global; always pass ctx.orgId from the agent. Retrieval is real pgvector ANN (embedding <=> $q cosine, HNSW index in drizzle/0011) when an embed model is configured, with a keyword fallback otherwise. Design: KNOWLEDGE_MODEL.md, spec specs/knowledge-scope/.

D4. Datasets (upload any file → reusable, dashboardable, live-fed table)

A dataset is a first-class table the user owns, separate from ERP records and from one-shot knowledge. Use it when the data doesn't map onto an existing resource but the user wants to chart it, save it, append to it, and feed it live. It reuses the ingest pipeline (extract/parse), the widget engine (D's dashboards), and the MCP host (D's connectors) — no new parsers, chart code, or connector glue.

  • Model: two ERP resources — datasets (metadata: name, columns[] with inferred type, rowCount, source file|feed|manual, upsertKey?, feed?, lastRefreshedAt) + datasetRows ({datasetId, …row}). Both org-scoped, atomic ids via nextId. Columns are inferred by inferColumns(headers, sampleRows).

  • Core lib (libs/datasets.ts): createDataset, addRows(…, {mode:"append"|"upsert", upsertKey}), datasetWidgets(ds) (auto-picks a count metric + numeric sums + first-category bar + date line + sample list), buildDatasetDashboard (→ a dashboards record whose widgets have source:"dataset", datasetId).

  • Widgets: WidgetCard gains a third source alongside erp/mcp — source:"dataset" fetches /api/datasets/:id/rows and aggregates client-side. Same drill-through UX.

  • Live feeds (libs/datasets-feed.ts): a dataset's feed either pulls from a read-only MCP connector tool (pullFeedRows → refreshDatasetFeed, upsert) on an hourly/daily cron (/api/cron/dataset-refresh, fail-closed), or receives pushes at a signed webhook (/api/datasets/:id/ingest?token=…, no session). Feeds can never run a mutating tool — isMutating(gov) is checked before every pull.

  • Routes: /api/datasets (list/create), /api/datasets/:id (get/patch/delete + cascade rows), /api/datasets/:id/rows, /api/datasets/:id/dashboard, …/ingest.

  • UI: DatasetsScreen (list + SmartImportButton) and DatasetDetailScreen (columns/rows, Build dashboard, Add data, delete). <ImportReview> shows an "Import as records | Save as dataset" toggle for structured files.

  • Agent: import_file({url, dataset:true, name?}) saves an attached/uploaded tabular file straight to a dataset from chat (preview → confirm), build_dashboard_from_dataset({dataset, name?}), connect_dataset_feed({dataset, tool?, args?, schedule?, upsertKey?}) (pull if tool given, else returns a push webhook URL), and query_dataset({dataset, filter?, groupBy?, agg?, field?, limit?}) (read-only filter/group/aggregate so Artie can answer questions about a dataset in chat — queryDataset in libs/datasets.ts).

  • Joins (dataset↔dataset, libs/datasets-join.ts): a datasetJoins ERP resource (a typed table, like every ERP resource) describes a fact dataset left/inner-joined to up to 4 dimension datasets on a normalized key (star schema). runDatasetJoin validates every dataset against the caller's scope (datasetReadable, Wall-2), checks all column keys against columns[], then compiles ONE parameterized LATERAL query over erp_datasetrows (the typed dataset-rows table) — values are $n only; identifiers are re-validated keys. It runs under RLS + orgScopedQueryWithTimeout (SET LOCAL statement_timeout, JOIN_ROW_CAP), dedupes fan-out (first row by id) and warns, and prefixes every dimension column <dimSlug>__<key> so the shared filter rail stays unambiguous. Route: POST/GET /api/datasets/join ({join} / {joinId} / ?left=&right= to propose). Widgets carry source:"join" + joinId/join; WidgetCard and summarizeDashboard render them like a flat dataset. Agent: join_datasets({left, right, on?, select?, type?, confirm?, buildDashboard?}) proposes the key (no build) then builds on confirm (datasets.edit, undoable, deep link). UI: "Join with another dataset" on DatasetDetailScreen.

When to reach for a dataset vs. the alternatives: maps onto an existing resource → import as records (D3). Unstructured reference text → knowledge (D3). Arbitrary tabular data the user wants to keep, grow, and visualize → dataset. Two datasets that share a key the user wants to combine into one view → join (above).

D5. Sales/BD CRM (configurable vertical)

A gamified business-development CRM layered on the existing sales module. AEC (civil-engineering) vocabulary is org-seeded config, not hardcoded, so any tenant can use it — see CRM_INTEGRATION_PLAN.md for the full design.

  • Vertical config (libs/sales/config.ts + Org.salesConfig): resolveSalesConfig() merges an org's overrides with AEC defaults — stage labels (the 6-stage enum is unchanged; civil-eng names like "Prospecting" are just labels), stage weights (weighted pipeline), proposal statuses/subtypes, credentials, agency roles, and company goals. Client reads it via useSalesConfig(). Never hardcode AEC strings in components — read the config.
  • New resources: proposals (pursuits — RFP/SF 330 lifecycle, team, experience, docs, timeline), salesActivities + activityTypes (the points engine), salesGoals (per-rep targets). Deal gains bid economics, Employee gains credentials/specialties/licenses.
  • Gamification (libs/sales/points.ts): a points catalog (lazy-seeded from DEFAULT_ACTIVITY_TYPES), logSalesActivity (catalog lookup + org-tz weekOf), and rollup leaderboards by period. Served from /api/sales/{activity-types, activities,points}; screens pointsdash (leaderboard) + mydash (rep pace + log).
  • Goals & pacing (libs/sales/pacing.ts + libs/sales/exec.ts): YTD rollups vs goals vs elapsed fiscal year → the Executive Dashboard (execdash: goals, YTD actuals, 4 pace wheels) and goalsdash/goalsettings. Served from /api/sales/exec.
  • Deal economics (libs/sales/deal.ts): summarizeDeals is the one source of truth for bid/sales/win-rate/weighted-pipeline rollups used across the dashboards.
  • Agent tools: create_proposal, advance_proposal, score_team_for_proposal, log_sales_activity, get_leaderboard, get_my_points, get_goal_pacing, set_sales_goals — all sales.* permissioned (set_sales_goals → sales.goals).
  • Screens live under the Sales group; each registered in AppSidebar/features/constants/flow per the checklist below.

E. Definition of done (every change)

  • Org-scoped (orgId) everywhere; no cross-tenant reads. Every read of a carved table passes a top-level orgId — find({}), findOne({ id }) and find({ $or: [… { orgId }] }) are all unbound, and behind the RDS Proxy an unbound read returns only org_id = '' rows instead of everything. Filtering by org in JS afterwards does not help: the rows never arrive. The mock has no RLS, so unit tests stay green while the product is broken — see the callout in §A.
  • People: resolve a person via libs/identity.ts (employeeForUser/resolvePersonRef) and key person-scoped data on the canonical employees.id (org.employeeId/personId(ctx)) — never on user._id directly, and never match people by name string except as a last-resort fallback. RBAC stays on the login (can(user, perm)); Employee.title/roleIds are descriptive only. (See specs/identity-model/.)
  • Server enforces permission (generic route by module, or explicit can()); UI mirrors with useAccess.
  • Permission catalog — new role-assignable actions have a row in libs/access/permission-catalog.ts (group/subsection/implies/powerOnly as needed); test expand/implies in libs/access/permission-catalog.test.ts if non-trivial.
  • Cedar module change — a new/changed resource entity is listed in the appliesTo of every action it is authorized against (an unregistered type fails closed as a schema validation failure with determiningPolicies: [], which is indistinguishable from a policy deny in logs); the baked policy text is regenerated (npx tsx scripts/<mod>/gen-policy-text.ts) or the fix never reaches the running app; and the persona matrix has an allow row plus an out-of-scope deny row that asserts the deciding policy, since an unreachable entity also returns Deny. See the Authorization section.
  • If it's an action, it's an MCP tool with a permission and (if reversible) undo.
  • Plan/module gating wired (entitled/screenModule) so the right plans see it.
  • Graceful when an integration/credential is missing — never crash, show a clear state.
  • Validate request bodies — reject non-JSON / wrong shape with a 400; never trust client input.
  • Ids — a new resource uses client-generatable uuidv7() (libs/ids/uuidv7.ts) with idType: "text", never nextId. A server-allocated sequential id means a record cannot exist until it syncs, which makes offline create/edit impossible (the offline iOS client has a local DB — see specs/data-layer-migration-closeout/ Phase D). A ratchet test (libs/db/id-type-ratchet.test.ts) fails the build if a new resource is registered as bigint. On an existing bigint resource keep using nextId/nextIds (libs/erp/sequence.ts) until its Phase D batch lands — never mix the two within one resource: maxExistingId casts the id to bigint and catches the failure, so one non-numeric id makes the seed 0 and any org without a counters row restarts at 1 and reissues a live id. Either way, never findOne()+1.
  • Paginate / cap any list endpoint (LIST_CAP) so a tenant can't pull unbounded rows.
  • Rate-limit + gate external sends (libs/ratelimit.ts, externalSendsAllowed); SMS/email/webhooks are fail-open limited and toggle-gated.
  • Crons fail-closed in production (require CRON_SECRET); webhooks dedupe (idempotency claim, e.g. claimStripeEvent).
  • SSRF-guard any server-side fetch of a user-supplied URL (assertSafeFetchUrl, redirect:"error").
  • Wrap route handlers with withRoute(...) (libs/api/route.ts) and throw httpError(status, msg) for expected failures — thrown errors become clean JSON, never a leaked stack/500. E11000 → 409, validation/cast → 400.
  • Multi-write operations — wrap dependent writes in withTransaction(session => …) (libs/erp/transaction.ts). ⚠️ Since the Mongo→Postgres cutover this runs without a real transaction (sessionless); if a path genuinely needs atomicity, use a single pg client with BEGIN/COMMIT (see the TODO in transaction.ts).
  • Timezone-correct dates — derive week/day boundaries with libs/time/week.ts (weekStart(date, org.timezone)), never new Date() calendar math on the server. Org tz lives on SessionOrg.timezone / useAccess().timezone.
  • Mobile-responsive — verified usable at ~375px wide (fluid widths, responsive grids, no overflow, tap-friendly).
  • Reuses existing components/hooks/libs — no duplicate component, util, email sender, or route created when one already exists.
  • Discoverable — new screen added to the sidebar (AppSidebar modules) AND to FEATURES (lib/features.ts); new agent tool added to TOOLS (auto-surfaces in Settings → "What Artie can do").
  • Unit-tested — any pure logic you added/changed has a co-located Vitest *.test.ts and npm test is green. Test the deterministic core in libs/ (calculators, permission/can resolution, hierarchy/cycle math, date/period boundaries, ingest transforms, rollups) — derive expected values from the implementation, import via @/…. DB-bound code and route handlers are covered by the spec's acceptance criteria + the build, not unit tests; if a function needs the DB, extract the pure part and test that. npm test is a CI gate (see CONTRIBUTING.md §2–3). Reference: libs/orgchart/hierarchy.test.ts, libs/access.test.ts.
  • npm run build green and npm test green; on its own sub-branch (feat/…/fix/…/docs/…) with a Conventional Commit subject (feat:/fix:/feat!: — so release-please versions it + adds it to the release notes; non-conventional subjects ship but are dropped from the changelog) and a Co-Authored-By trailer, opened as a PR into main — never pushed to main directly (see the Branch-per-change rule up top + §H Releases).
  • Update ACCESS.md if you added a permission/module/catalog key; update this file if you added a build pattern.

F. Where things live (quick map)

libs/access.ts            permissions, plans, seats, can()/entitled()/permFor()
libs/access/permission-catalog.ts   canonical PERMISSION_CATALOG (Roles UI + expandRolePermissions)
libs/roles/{system-templates,seed,guards}.ts   FOSA-aligned role templates, seed on GET roleDefinitions
libs/erp/{resources,types,model}.ts   ERP resource registry + types + Postgres model factory
libs/db/{pg,typed-model,read}.ts  pg pool + typed-table model (every ERP resource) + PG read path
libs/db/engine.ts         Mongoose-compatible JSONB shim — mongo_docs domain models only (Org/User + platform), being retired
libs/db/rls.ts            RLS GUC wrapper: runWithOrg/orgScopedQuery/rlsQuery (SET LOCAL app.org_id)
libs/knowledge/{scope,share}.ts   knowledge visibility ladder (allowedScopeFilter) + share-up (canShareTo)
libs/auth/{better-auth,client,server}.ts  Better Auth server + client + auth() shim (session)
drizzle/*.sql · scripts/pg/*   Postgres migrations (migrate.ts, MIGRATE_DATABASE_URL) + Mongo→PG backfills
libs/api/route.ts         withRoute() handler wrapper + httpError() (clean API errors)
libs/erp/transaction.ts   withTransaction() — runs sessionless (use libs/db/tx.ts withPgTransaction for real atomicity)
libs/datasets-join.ts     dataset↔dataset SQL join engine (runDatasetJoin/proposeJoinKey, star schema under RLS)
libs/time/week.ts         timezone-aware week/day boundaries (weekStart, mondayOfDate)
app/api/erp/[resource]/   generic governed CRUD
libs/artie/agent.ts       the agent loop (MCP host runner + legacy fallback)
libs/artie/tools.ts       internal tool implementations
libs/artie/llm.ts         swappable reasoning provider
libs/mcp/host.ts          MCP host: mounts servers, discovers tools, routes calls
libs/mcp/governance.ts    per-tool governance (agent ⊆ user)
libs/mcp/{elas-core,workspace,quickbooks,payroll}.ts   the MCP servers
libs/integrations/*       connector libs (quickbooks, payroll, jira, …)
models/McpConnection.ts    per-tenant remote/built-in MCP servers
components/ui/date-picker  shared date/range pickers
libs/receipt-ocr.ts       receipt OCR client (docTR over DOCTR_OCR_URL) → LLM field extraction
libs/storage/s3.ts        private S3 uploads: putObject/presignGet/fileUrl/resolveFetchableUrl
app/api/files/[...key]/   presign-redirect read endpoint (tenant-scoped)
services/doctr-ocr/       deployable docTR OCR microservice (FastAPI + Dockerfile + README)
services/stt-relay/       streaming STT relay (browser PCM → AWS Transcribe) — deployed on App Runner
ACCESS.md                 the permissions playbook (plans, seats, catalog, role templates)
BUILDING.md               this file — implementation checklist for every feature

Receipt OCR is a separate service. docTR is a Python library, so it runs as its own container (services/doctr-ocr/, deploy to Fly/Render/HF). Point the app at it with DOCTR_OCR_URL (+ optional DOCTR_API_KEY) via SSM (/elas/web/*). Contract: POST / { image_url } → { text }. See services/doctr-ocr/README.md.


G. Running database migrations (privileged role required)

When a change adds a Drizzle migration (drizzle/000N_*.sql), it must be applied to Postgres — npm run build does NOT run migrations. Apply the migration BEFORE deploying code that reads the new columns (or make the reader degrade gracefully on the missing column, as searchKnowledge does — catch 42703 → legacy predicate).

  • Use the privileged role, not the app role. The app's DATABASE_URL is the elas_app role: RLS-subject and DDL-less — ALTER/CREATE fail with Postgres 42501 insufficient_privilege. Migrations need the master role elas as MIGRATE_DATABASE_URL (scripts/pg/migrate.ts prefers it over DATABASE_URL):

    npx tsx scripts/pg/migrate.ts        # idempotent: skips applied files (tracked in _migrations)
    
  • Canonical MIGRATE_DATABASE_URL source = AWS Secrets Manager — one master secret per env, mirroring the app-role layout. These secrets are the source of truth for the elas master role; build the URL from the secret at run time and never commit/print it:

    EnvRDS host (:5432/elas)Master secret (Secrets Manager)
    prodelas-postgres-pilot.cotewciqk8to.us-east-1.rds.amazonaws.comelas/web/db-master
    stagingelas-web-staging.cotewciqk8to.us-east-1.rds.amazonaws.comelas/web/staging/db-master

    Both secrets are { "username": "elas", "password": "…" }. Run migrations by constructing the URL from the secret (prod shown — swap the --secret-id + host for staging):

    MIGRATE_DATABASE_URL="$(python3 -c "import json,subprocess,urllib.parse as u; s=json.loads(subprocess.check_output(['aws','secretsmanager','get-secret-value','--secret-id','elas/web/db-master','--region','us-east-1','--query','SecretString','--output','text'])); print('postgres://%s:%s@elas-postgres-pilot.cotewciqk8to.us-east-1.rds.amazonaws.com:5432/elas'%(s['username'],u.quote(s['password'],safe='')))")" \
      npx tsx scripts/pg/migrate.ts
    

    These master secrets are not in the App Runner env_keys allow-list — the runtime app never uses them (it connects as elas_app); they exist only for migrations/DDL. For local dev you may instead keep MIGRATE_DATABASE_URL in .env.local (gitignored).

  • SSL gotcha: store the URL with no sslmode= in the string and rely on ssl: { rejectUnauthorized: false } — a sslmode=require in the connection string overrides the ssl object and makes pg attempt full cert verification, which fails on the RDS cert (a classic "could not connect" red herring). migrate.ts already sets the ssl object.

  • Guard every elas_app GRANT — it does not exist everywhere. elas_app is the least-privilege runtime role, created by infra only on the hardened tiers (staging/prod/QA). On fresh local dev DBs and bare CI containers the app connects as the DB owner and elas_app is never created, so a bare GRANT … TO elas_app; throws role "elas_app" does not exist (Postgres 42704) and aborts the whole migration run — nothing after it applies. Always wrap the grant so it no-ops when the role is absent (do not create the role in a migration):

    DO $$
    BEGIN
      IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'elas_app') THEN
        EXECUTE 'GRANT … TO elas_app';
      END IF;
    END $$;
    

    Reference migrations: drizzle/0051_yardi_schema.sql, 0052_yardi_reports_ledger.sql, 0054_toast_live_grants.sql, 0086_leadform_token_resolver.sql. This class of bug hid for a while because nothing migrated a fresh DB in CI until the integration-test job started doing so — a bare grant looks fine on every already-hardened tier and only breaks where the role is absent.

  • RDS master reset / rotation. Both master DBs are us-east-1, master user elas, IAM auth off (prod = elas-postgres-pilot, staging = elas-web-staging). If the elas password is unavailable, reset it (needs rds:ModifyDBInstance): aws rds modify-db-instance --db-instance-identifier <elas-postgres-pilot|elas-web-staging> --master-user-password <new> --apply-immediately (applies with no reboot/downtime; the running app is unaffected — it uses elas_app). Then write the new password back to the env's master secret so it stays canonical: aws secretsmanager put-secret-value --secret-id elas/web/db-master --secret-string '{"username":"elas","password":"<new>"}' (use create-secret if it doesn't exist yet). Never commit or print the password.

    Rotation history: the prod (elas-postgres-pilot) elas master was reset/rotated on 2026-07-02 (the prior password had been lost) and is now stored in elas/web/db-master. That reset was the rotation — nothing further is outstanding. Staging's master lives in elas/web/staging/db-master.


H. Deploying (AWS App Runner)

The app ships as a container on AWS App Runner (DNS points at App Runner). Deploys are release-gated: a merge to main ships to staging, and prod moves only when a release is cut. Full runbooks: AWS_MIGRATION.md + RELEASES_SOP.md. What you need to know to ship:

  • CI/CD → QC (automatic, every merge). main is updated only via merged PRs (never a direct push — see Branch-per-change). A merge to main that touches app code triggers .github/workflows/deploy-web.yml → assumes the elas-gha-deploy role via GitHub OIDC (no long-lived AWS keys) → builds the Dockerfile (multi-stage node:20-slim, output: "standalone") → pushes the image (:latest + :<sha>) to ECR → rolls out QC ONLY (elas-web-qc, qc.elasapp.com). Neither staging nor prod moves on a plain merge — verify on QC first. Run migrations FIRST when the change adds one (§G) — npm run build / deploy does NOT.
  • Promote → STAGING → PROD (one-click). Staging and prod each move on a deliberate one-click promotion of the exact tested image (no rebuild): run Promote to staging then Promote to prod from the Actions tab (leave the input blank to promote whatever's on the source env; pass a full/short SHA to roll back to a specific build), or use the in-app Releases screen at /admin/releases (platform-admin only). The same :<sha> bytes flow QC → staging → prod.
  • Releases → VERSION LABEL (Conventional Commits required). .github/workflows/release-please.yml keeps a rolling chore(main): release X.Y.Z PR built from your commits; merging that PR tags vX.Y.Z and publishes a GitHub Release. This stamps a version — it does NOT deploy prod (deploy is the one-click promotion above). ⚠️ release-please only counts Conventional Commits: feat: (minor), fix: (patch), feat!:/BREAKING CHANGE: (major). A commit/PR whose subject isn't one of these still ships in the code but is silently dropped from the version bump, CHANGELOG.md, and the in-app "What's new" notes — so write conventional subjects (merges are merge-commits, so the inner commit subjects are what's scanned). Customer notes are hand-authored, not auto-drafted: write release-notes/X.Y.Z.md yourself, run npm run release-notes:build, and commit the note and the regenerated libs/release-notes/feed.generated.ts in the same commit. A drift gate (.github/workflows/release-notes.yml) fails the PR if the committed feed is stale. SOP (canonical procedure): RELEASES_SOP.md.
  • Env & secrets: NOT a .env in prod — runtime vars live in SSM Parameter Store (/elas/web/*)
    • Secrets Manager (RDS Proxy creds), read by App Runner's instance role and never baked into the image. Add a new required var to SSM (and to infra/terraform/app_runner.tf's env_keys) as part of the change, not just to .env.local.
  • Crons: the /api/cron/* schedule lives in EventBridge (infra/terraform/scheduler.tf, rules → API Destinations with the CRON_SECRET bearer). It is the single source for the cron schedule, so add a new cron there (infra/terraform/scheduler.tf) as part of the change.
  • Infra is Terraform under infra/terraform/ (remote state in S3 + DynamoDB lock); terraform plan is the drift gate. App Runner egress is VPC — it reaches a private RDS instance only through an RDS Proxy over an App Runner VPC connector (TLS verify-full), not the public RDS endpoint. This is the DB-hardening architecture; QC, Staging, and Prod are all cut over. See §J for the full model (private DB, proxy, shared egress plane, master/app split, and the Terraform gates you must not trip).
  • Ephemeral preview environments (per-PR). Add the preview label to a PR to get a throwaway V3 on containerized Lambda + a function URL, backed by an isolated Postgres, that auto-destroys after 2h idle or on PR close. Embedded auth, sends OFF, isolated DB. Guide: PREVIEW_ENVS.md; spec: specs/ephemeral-preview-envs/. (Separate Terraform root infra/terraform/preview/ — never a tier workspace.)
  • Cost tagging (project = v3). This AWS account also hosts a separate EKS/OpenSearch platform, so V3 resources carry a project = v3 cost-allocation tag to isolate V3 spend in Cost Explorer. It's applied automatically by Terraform default_tags (infra/terraform/providers.tf) — so any new AWS resource you add to this stack inherits it for free; nothing to do per-resource. The tag key must be activated once in Billing → Cost allocation tags (it appears ~24h after first use), then you can filter Cost Explorer by project = v3. Resources created outside Terraform (manual/CLI) must be tagged by hand: App Runner tag-resource, RDS add-tags-to-resource, ECR tag-resource, S3 put-bucket-tagging.

H2. Sign-in (elas-identity)

Authentication runs as a separate deployable from elas-web: the elas-identity service, in its own repo (ELASAPP/elas-identity). Prod https://identity.elasapp.com, staging https://staging.identity.elasapp.com, QA https://qa.identity.elasapp.com. V3 is an OIDC client of it (AUTH_MODE=oidc, PKCE, JWKS/RS256). Build, deploy and operations for it live in that repo, not here.

The old login.elasapp.com Better Auth service (elas-auth) is legacy: it still runs for Artie prod, is no longer built or deployed from this repo (services/auth and its workflows were removed), and is no longer managed by Terraform here (infra/terraform/removed_auth_service.tf forgets it without destroying it). V3 keeps its client-side legacy-issuer support (OIDC_LEGACY_ISSUER_URL, var.auth_oidc_tiers, var.elas_legacy_issuer_off_tiers).

H3. Onboarding a customer's SSO (Okta/SAML) — the UI

Enterprise SSO onboarding is a platform-admin UI, not a CLI script. Open the customer at /dashboard/customers/[id] → Single Sign-On section → Set up SSO. Full spec: .kiro/specs/sso-onboarding-ui.

The wizard (all server-to-server; the browser never calls the Auth_Service directly):

  1. Share SP details. Copy/download the setup packet (SP Entity ID, ACS URL, Name ID = EmailAddress, required email + optional groups attributes) and hand it to the customer's Okta admin.
  2. Import IdP metadata. Paste/upload Okta's metadata XML — V3 parses out the issuer, SSO URL, and signing cert (libs/sso/metadata.ts, XXE-safe, no new dep) and previews subject/expiry.
  3. Domains + mapping. Enter the routed email domain(s); build the group→role mapping visually (default = External seat; add groups rules). Platform-admin is never assignable (UI omits it, the API sanitizes it, and the resolver clamps it — three layers).
  4. Register → Test → Activate. Register writes the provider at the tier's Auth_Service (/sso/register); it starts inert (registered) and routes no logins until you Activate. The mapping is stored on the provider row, so edits take effect on the next login, no redeploy.

Two pieces of plumbing behind it:

  • AUTH_ADMIN_TOKEN (paired secret, /elas/auth[/<tier>]/* + /elas/web[/<tier>]/*): the Auth_Service's /sso/register gate is session-based, which a V3 server call can't satisfy — so the gate also accepts this bearer (constant-time). V3's admin routes still enforce isPlatformAdmin() first. Distinct from PROVISIONING_TOKEN.
  • Service login grants POST /api/internal/login-grants and .../revoke (Bearer HOLDPOINT_SERVICE_TOKEN, on tiers in var.holdpoint_grant_tiers): Holdpoint records that an email it approved may sign in; the invite gate (accountExistsForEmail) honours live grants. No V3 membership. specs/service-login-grants.
  • Provisioning callback POST /api/internal/provisioning/resolve (Bearer PROVISIONING_TOKEN): the endpoint the Auth_Service's provisionUser hook calls to (a) check the invite gate and (b) JIT the SSO user's membership into the customer org. Required for brand-new SSO users — without it a first-time Okta user has no V3 account to land in.

Verifying on QC (dev IdP): register a provider against a test Okta app / DummyIDP, run Test (plain user → External, a QC-Admins member → admin, offboarded user blocked), Activate, then do a real login — confirm a brand-new user is JIT-provisioned into the org. Promote the image QC → staging → prod as usual; AUTH_ADMIN_TOKEN is already seeded per tier.

J. Hardened app database (private RDS + RDS Proxy)

The app database on every tier (QC, Staging, Prod) is a private, KMS-encrypted RDS instance in private subnets, reachable only through an RDS Proxy (TLS require_tls / app-side verify-full) over an App Runner VPC connector. There is no public DB endpoint and no direct app→RDS path. QC was the reference implementation; Staging and Prod reuse the same pattern. Per-tier runbooks (the canonical, do-not-improvise procedure): RUNBOOK_QC_DB_HARDENING.md · RUNBOOK_STAGING_DB_HARDENING.md · RUNBOOK_PROD_DB_HARDENING.md.

Request path: browser → Cloudflare → App Runner elas-web[-tier] (egress VPC) → elas-<tier>-vpc-connector → elas-web-<tier>-proxy (5432, TLS) → private elas-web-<tier>-priv. AWS calls (S3/Bedrock/Polly) go over VPC endpoints; third-party calls egress via the shared NAT gateway. Security-group chain is least-privilege: connector-sg → proxy-sg → db-sg on 5432.

Two facts every dev/operator must know:

  • The app connects as least-privilege elas_app, not the master elas. DATABASE_URL and the proxy secret carry elas_app (it can read/write data but not ALTER DATABASE). The master elas credential is ops-only — it's what the write-freeze and read-only lift run as during a cutover. Don't point the app at the master.
  • The proxy's TLS cert is a public Amazon root (Amazon Root CA 1), not the RDS CA bundle. So libs/db/pg.ts trusts Node's built-in public roots (tls.rootCertificates) in addition to the RDS CA. Without that, verify-full fails and the app goes db:"down" at cutover. This fix must be in the image before any tier cuts over — it already is; don't regress it.

Per-tier resource names / paths (all under Terraform infra/terraform/{db_hardening,vpc_egress,app_runner}.tf):

QCStagingProd
Hostqc.elasapp.comstage.elasapp.comelasapp.com
App Runner serviceelas-web-qcelas-web-stagingelas-web
Private DBelas-web-qc-privelas-web-staging-privelas-web-prod-priv (Multi-AZ)
RDS Proxyelas-web-qc-proxyelas-web-staging-proxyelas-web-prod-proxy
VPC connectorelas-qc-vpc-connectorelas-staging-vpc-connectorelas-prod-vpc-connector
DATABASE_URL (SSM)/elas/web/qc/DATABASE_URL/elas/web/staging/DATABASE_URL/elas/web/DATABASE_URL (root, no /prod/)
App secret (elas_app)elas/web/qc/db-appelas/web/staging/db-appelas/web/prod/db-app
Master secret (elas)elas/web/qc/db-masterelas/web/staging/db-masterelas/web/db-master (root)
TF workspaceqcstagingdefault (local.env = "prod")

All private DBs: KMS-encrypted, deletion-protected, 7-day automated backups, storage autoscaling (max 100 GB), bound to the shared private DB subnet group. QC/Staging are single-AZ; Prod is Multi-AZ.

Shared egress plane (one for all tiers, consumed read-only). The NAT gateway (elas-shared-natgw), the two private subnets (10.20.11.0/24, 10.20.12.0/24, tagged elas-shared-app-subnet-*), and the S3/Bedrock/Polly VPC endpoints are built once by the workspace named in var.egress_infra_owner (currently qc). Every other tier resolves them read-only via data sources (data.aws_subnets.shared) and owns only its own data plane (connector/proxy/SGs/secret/DB). ⚠️ Residual coupling: the shared plane physically lives in qc's Terraform state, so a qc terraform destroy would remove it out from under Staging/Prod. Migrating ownership to the default (prod) workspace (egress_infra_owner="default" + a one-time terraform state mv) is a tracked roadmap item. Changes to vpc_egress.tf affect all three tiers — review carefully.

⚠️ Never a bare terraform apply in the staging or default (prod) workspace. The deployed state was cut over with transitional -var flags, and variables.tf defaults are not yet bumped for staging/prod (db_hardening_tiers still defaults to ["qc"], db_ready to {qc=true}; only db_instance_id lists all three). A bare apply would plan to destroy the live plane. Until the defaults are bumped (planned in each tier's Phase-6 PR), always pass the flags:

# staging
terraform -chdir=infra/terraform apply \
  -var 'db_hardening_tiers=["qc","staging"]' -var 'db_ready={qc=true,staging=true}'
# prod (default workspace)
terraform -chdir=infra/terraform apply \
  -var 'db_hardening_tiers=["qc","staging","prod"]' \
  -var 'db_ready={qc=true,staging=true,prod=true}'

qc's defaults are bumped (egress_infra_owner="qc", db_hardening_tiers=["qc"], qc-ready) — a bare qc apply is a no-op. The invariant to restore everywhere: a bare apply with no -var flags is a no-op in every workspace. Onboarding/finishing a tier means editing these defaults in that tier's landing PR, not leaving the values only in a runbook.

Operations.

  • Connect to a private DB: use an ephemeral SSM bastion + pgAdmin4 (or a VPC CloudShell using the tier's connector-sg). Tear it down after use — never leave a bastion running.
  • Health: GET /api/health → {"db":"up"} when the app reaches the DB through the proxy (503 / db:"down" otherwise). End-to-end check: scripts/db-hardening/03-verify.sh against the tier's host.
  • DB-hardening scripts live in scripts/db-hardening/ (02-restore-private-db.sh, 03-verify.sh, 04-cutover.sh host-repoint, 05-decommission.sh guarded delete). They're tier-agnostic (driven by env vars) — the runbooks show the exact invocations.
  • Migrations still run as the privileged role per §G; the everyday app path (elas_app via the proxy) is not privileged enough to run DDL.

⚠️ Open item — old public DBs still live. Each tier's original public, unencrypted instance (elas-web-qc, elas-web-staging, elas-postgres-pilot for prod) remains running and internet-exposed until that tier's Phase-6 soak decommission runs (a guarded delete that requires health db:"up" and DATABASE_URL pointing at the proxy, and takes a final snapshot). Decommission is deferred per tier until the soak window closes.