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:
- Multi-tenant — everything is scoped to an
Org(orgId). Never leak across tenants. - 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, seeACCESS.md); the governed modules (CRM/Sales, PM, HR, Finance) authorize with Cedar (see the Authorization section below) — never gate those withuseAccess().can("<module>.*"). - Agent-ready — if it's an action, expose it as an MCP tool so Artie can do it too (and inherit the same governance).
- 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 withflex-wrap, no fixed pixel widths that overflow, touch-friendly hit targets. If it doesn't look right at 375px wide, it's not done. - 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. - 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 onescreen(...)line) so platform admins can toggle it indashboard/feature-flags. A dev-time guard inAppSidebarwarns if a sidebar screen has noFEATURESentry. Every new agent tool goes in theTOOLSmap (libs/artie/tools.ts) — it then appears automatically indashboard/settings→ "What Artie can do" (served live from/api/artie/tools, no edit needed). - 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.tsxnext 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"], aDefaultstory 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 acomponents/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 buildANDnpm testbefore 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 tomainthrough a pull request. Never commit or push tomaindirectly —mainis 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=xormake spec-draft FEATURE=x DESC="…"→specs/<feature>/{spec.yaml, requirements,design,tasks}.md) and have it reviewed; (3) generate + build it —make spec-genemits the registration regions (// <generated:spec>) + a screen scaffold, then you fill the<spec:impl>logic + tests and runmake 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 (fromscreens[].icon/group/orgAdmin) and registers each Artie tool automatically (a write-if-absent run() stub peractions[]item underlibs/artie/tools.generated/, merged intoTOOLS); the only manual hookups it still prints are the typed-table migration and, optionally, a Zod arg schema for a tool (SCHEMASinlibs/mcp/elas-core.ts). Thespec.yamlis the source of truth, enforced by thespec:checkdrift 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 nestedapp/project. An old EKS-era harness that mounts-v ./app:/app(or a strayapp/Makefile/app/package-lock.json) mounts only the App Router subfolder, so the container is missingtsconfig.json/config.ts→Can't resolve '@/components/...'/'@/config'. Delete those stray files and use the compose setup above. Do not useregistry.elasapp.com/kubectlMakefiles — that's the separate EKS platform, not V3 (seePLATFORM_PLAN.md).npm run dev:mockruns against MSW fixtures (no backend/DB) — handy for UI work, but it's a partial mock. Use plainnpm run dev/make run-devto 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 + adatajsonb tail); the genericerp_recordsJSONB store was dropped (de-JSONB migration,drizzle/0072), sogetErpModel(...)returns a typed model for every resource and throws for an uncarved one — there is no JSONB fallback. Themodels/*still keep the familiarfind/findOne/create/save/...API. The only JSONB store left ismongo_docs(a few low-volume domain models — Org/User + platform tables — via the Mongoose-compatible shimlibs/db/engine.ts), and it's being retired in the final de-JSONB phase. Mongo is gone. (The oldmongo-shim.ts/PgModelmigration bridge was decommissioned — seePLATFORM_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 cataloglibs/access/permission-catalog.tsis 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, notcan("<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-coreMCP server; email/Slack/Jira inworkspace; 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>.*"):
| Module | Server guard | Client hook | Policies |
|---|---|---|---|
| CRM / Sales | authorizeSalesAction / salesErpAuthorize (libs/sales/authz/*) | useSalesAccess() | libs/sales/authz/policies/*.cedar |
| Property Management | authorizePmAction / pmAuthorize (libs/property-management/authz/guard.ts) | — (server + screenModule) | libs/property-management/authz/policies/*.cedar |
| HR / Perf reviews | authorizeReviewAction (libs/hr/authz/guard.ts) | — | libs/hr/authz/policies/*.cedar |
| Finance | authorizeFinanceAction (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-*.cedarpolicies (base RBAC → scope/ReBAC → ABAC-state → a99-forbid-defaultdeny).
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 withcan(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/:resourceroute 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(...)— neveruseAccess().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_GATESinlibs/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
resourceMUST be listed in theappliesToof each action it is authorized against. Evaluation is schema-aware — all five modules construct their engine asloadEngine({ policies, schema })andlibs/authz/cedar/engine.tsforwards the schema on everyisAuthorizedcall (required so a ref attribute likeresource.propertyRefresolves 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 returnstype: "failure"andengine.tsfails closed to{ decision: "Deny", determiningPolicies: [] }. The emptydeterminingPoliciesis 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.PropertyUnitwas declared and carried arebac-unit-scopeforbid but was registered on no action from 2026-07-29 to 2026-09-18, so every unit read/update/delete returned403to 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.cedarschemaor*.cedaredit. The script dir is abbreviated while the lib dir is not, so they do not share a name:scripts/pm→libs/property-management/authz/, andscripts/{sales,hr,finance}→libs/{sales,hr,finance}/authz/.npx tsx scripts/pm/gen-policy-text.ts # → libs/property-management/authz/policy-text.generated.tsRuntime reads the policy + schema text from that generated module, not from disk, because Next
output: "standalone"does not reliably trace.cedarschema/.cedarfiles into the bundle. Skip the regen and your change ships to the tests and not to the app.policy-text.drift.test.tsfails 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 justdecision === "Deny". An unregistered entity also producesDeny, so a bare deny assertion stays green while the entity is entirely unreachable. PM'slibs/property-management/authz/schema.test.tscarries 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 insideappliesTodoes 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.mddescribes. A new ERP resource now needs a typed Drizzle table —erp_recordsis gone andgetErpModel()throws for an uncarved resource, so there is no schemaless JSONB fallback. TheERP_RESOURCES+ResourceTypeMapentries are still its app-facing contract; the typed table +typedSpecis its storage (step 1 below; seePLATFORM_PLAN.md§B1).
- Give it a typed table: add a
drizzle/000N_*.sqlmigration + atypedSpecinlibs/db/typed-registry.ts. Follow the carve pattern (scripts/pg/carve-remaining-gen.tsor anydrizzle/0071-style table):org_id+ promoted indexed columns + adatajsonb tail +ENABLE/FORCE ROW LEVEL SECURITYwith theorg_isolationpolicy. Apply the migration before the code that reads it ships (same order as every carve). Register it: add the name toERP_RESOURCESinlibs/erp/resources.ts.The
idcolumn istext, notbigint, and the spec saysidType: "text". New records get a client-generatable UUIDv7 fromuuidv7()(libs/ids/uuidv7.ts) — notnextId. 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.tsenforces this; the 171 resources still onbigintare legacy and listed there. - Type it: add an interface + a
ResourceTypeMapentry inlibs/erp/types.ts(the build fails until you do — that's intentional). - License/permission it: add it to
RESOURCE_MODULEinlibs/access.ts(which module owns it —sales/operations/finance/hr/core). That alone gives it correct view/edit/delete gating on/api/erp/:resource. - Catalog it (if admins should assign it in Roles): add a row to
PERMISSION_CATALOGinlibs/access/permission-catalog.ts— usemodule.<resource>.view/edit/delete(subsection under the right group) withimplies: ["<module>.view"]/["<module>.edit"]so existingpermFor(resource, action)gates still work until you split enforcement. SeeACCESS.md§ Role templates. - 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
orgIdCarved tables are
FORCE ROW LEVEL SECURITYwith theorg_isolationpolicy (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_idreports the empty string once its transaction ends, and'' IS NULLis false — so the policy collapses toorg_id = ''. Measured on prod: every fresh connection reportsis_null=false, val=''(the full write-up lives onQuery.scopedToFilterinlibs/db/engine.ts).
TypedModelonly pins the GUC when the filter carries a non-empty, top-levelorgId. So these three shapes are all unbound, and in production return only rows stored atorg_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 levelThe third is the dangerous one: it looks org-scoped, and it passes under the in-memory test mock, which supports
$orand 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 atorg_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 — aslibs/access/authz/role-store.tsdoes. 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.tshas 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_recordsis 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'sdatajsonb 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
- Route:
app/dashboard/<screen>/page.tsxrenders your screen component and passesonNavigate={d.navigateTo}fromuseDashboard(). - Data: load with
useResource(...); mutate with the hooks above (never hand-roll fetches to bypass permissions). - 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 useconst { can } = useAccess();(e.g.can("operations.edit")). Either way it's cosmetic; the server still enforces. - 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 toSCREEN_MODULE/MODULESinlibs/access.tsif new. - 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 inlibs/access/permission-catalog.tsand gate the route withcan(org, "<key>")(subsection keys withimplies; mark approve/configure/deploy/manage aspowerOnly: true). - Dates →
<DatePicker>/<DateRangePicker>(components/ui/date-picker). Files →POST /api/uploads(private S3 vialibs/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 callresolveFetchableUrl(url)first to get a direct presigned S3 URL — external services +redirect:"error"can't use the app-auth redirect. Don't reinvent these. - Validate every input field — no form may save junk (e.g.
asdfasdfin email/phone). Use shared helpers inlibs/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 — return400with 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.
- Implement the action in
libs/artie/tools.ts(TOOLSmap) —run(ctx, args)usingctx.orgId. Return JSON; for reversible writes include__undoand anundoRun. - Register its MCP schema in
libs/mcp/elas-core.ts(SCHEMAS— a small Zod shape) so it's discoverable with a real input contract. - 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 toPERMISSION_CATALOGwhen the action is finer thanmodule.edit(e.g.sales.proposals.send,timesheets.pto.approve). Add that key to the catalog withimpliesif routes still check the broader perm.
- read-only →
- 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 anyERP_RESOURCESentry). - Resources with a richer dedicated detail route → use it: projects →
`project/${id}`, estimates →`estimate/${id}`(routed to the builder with that draft pre-selected vianavContext.estimateId). - The id must be the same identifier the detail screen looks up (the ERP
id, or the estimate's_idstring). Tool-resultnavigatebypassesresolveScreen, 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 richerget_project/get_estimatethat add a financial breakdown.) Add a resource toERP_RESOURCESand it's instantly agent-readable/writable (permission viapermFor).navigate— its allowed screens are enumerated fromscreenMetaat 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 everyERP_RESOURCESrecord the caller canviewplus everyscreenMetascreen they can see (canSeeScreen), returning arecord/<resource>/<id>deep link per hit. Add a resource/screen and it's searchable with no search code — governed by the sameviewperms +canSeeScreenthe sidebar uses. Seespecs/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"):
createElasCoreServerregisters every entry inTOOLSautomatically — add to theTOOLSmap and it's discoverable, no registration step.govForfalls back to the tool's own declaration (external/undoable/permission) when there's no explicitELAS_TOOL_GOVentry — so a new tool is governed correctly from its definition alone. (Only arg-derived permissions, e.g.permissionFrom: "resource", still need an explicit entry.)- 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?
| Situation | Where 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 server | nothing to build — connect its URL in Integrations → AI tool servers (MCP) |
To build a built-in connector (pattern: see quickbooks/payroll):
- Connector lib
libs/integrations/<name>.ts:xConfigured(), OAuth/token (or per-org creds onOrg), and the API calls (entity mappings). Gate everything on credentials + connection; degrade gracefully (no.envfallback, clear "not connected" errors). - 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. - Register the server id in
BUILTIN_SERVERSinlibs/mcp/host.ts. - Connect flow: an
/api/integrations/<name>route (OAuth callback or a creds form) that, on success, upserts anMcpConnection{ kind: "builtin", builtinId: "<name>", defaultPermission: "<module>.edit" }. (Or always-mount it inALWAYS_ONif it self-gates, likeworkspace.) - UI: add/extend the card in
components/screens/IntegrationsScreen.tsx(connect/status/disconnect). Org-level connects requirecan("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.tsresolves a provider viaresolveLlmConfig(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-tenantOrg.ai.endpoint/models. Cloud escalation OFF by default. - Add a provider = a
lib/artie/<name>.tswithcomplete/stream+ a branch inllm.ts. - Use-case-driven model routing.
complete(system, prompt, { task, effort })routes a(task, effort)pair → a tiersmall | mid | large→ a concrete model id from env. Use-case wins first:extraction/classification→ small (cheap, low-temp),planning→ large;chat/drafttrack the user's depth slider (Fast→small, Balanced→mid, Thorough→large). Tag everycomplete()call with itstask(e.g. receipt OCR =extraction, draft_email =draft, the agent loop =chat+ the request'seffort). Configure per provider —REPLICATE_LLM_MODEL_SMALL/MID/LARGEorBEDROCK_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).
- Cloud (default): Amazon Bedrock (
- 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.tscalls docTR overDOCTR_OCR_URL(self-hosted, seeservices/doctr-ocr/), then the LLM extracts fields. - Realtime voice (duplex) — optional always-open WS gateway (
services/voice-gateway/, AWS App Runner; clientlibs/voice/realtime.ts, enabled byNEXT_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.doneorerror). The gateway synthesizes one even when the upstream ends abnormally (non-OK body,{configured:false}, nodone); 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.
- barge-in. Invariant: a turn that enters "thinking" MUST receive exactly one terminal
event (
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. Passconfigfrom 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 oneADDON_ROLE_SEEDERSline + alibs/<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) → atable(CSV/XLSX/JSON-array) ortext(PDF/DOCX/TXT/OCR'd image). Pure local parsing; image OCR viaDOCTR_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/ingestaccepts 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 atMAX_TABLES). No writes.POST /api/ingest/commitwrites one table at a time — records via org-scoped bulk insert (permissionpermFor(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 wrapuseSmartImport()→ 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 sameuseSmartImport()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_filealso accepts a.zip(detected by ext + PK magic): it decompresses in-process (fflate), runs each entry throughextractFile, 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 inferredtype,rowCount,sourcefile|feed|manual,upsertKey?,feed?,lastRefreshedAt) +datasetRows({datasetId, …row}). Both org-scoped, atomic ids vianextId. Columns are inferred byinferColumns(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(→ adashboardsrecord whose widgets havesource:"dataset",datasetId). -
Widgets:
WidgetCardgains a third source alongside erp/mcp —source:"dataset"fetches/api/datasets/:id/rowsand aggregates client-side. Same drill-through UX. -
Live feeds (
libs/datasets-feed.ts): a dataset'sfeedeither 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) andDatasetDetailScreen(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 iftoolgiven, else returns a push webhook URL), andquery_dataset({dataset, filter?, groupBy?, agg?, field?, limit?})(read-only filter/group/aggregate so Artie can answer questions about a dataset in chat —queryDatasetinlibs/datasets.ts). -
Joins (dataset↔dataset,
libs/datasets-join.ts): adatasetJoinsERP 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).runDatasetJoinvalidates every dataset against the caller's scope (datasetReadable, Wall-2), checks all column keys againstcolumns[], then compiles ONE parameterized LATERAL query overerp_datasetrows(the typed dataset-rows table) — values are$nonly; 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 carrysource:"join"+joinId/join;WidgetCardandsummarizeDashboardrender 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" onDatasetDetailScreen.
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 viauseSalesConfig(). 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).Dealgains bid economics,Employeegains credentials/specialties/licenses. - Gamification (
libs/sales/points.ts): a points catalog (lazy-seeded fromDEFAULT_ACTIVITY_TYPES),logSalesActivity(catalog lookup + org-tzweekOf), androllupleaderboards by period. Served from/api/sales/{activity-types, activities,points}; screenspointsdash(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) andgoalsdash/goalsettings. Served from/api/sales/exec. - Deal economics (
libs/sales/deal.ts):summarizeDealsis 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— allsales.*permissioned (set_sales_goals→sales.goals). - Screens live under the Sales group; each registered in
AppSidebar/features/constants/flowper 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-levelorgId—find({}),findOne({ id })andfind({ $or: [… { orgId }] })are all unbound, and behind the RDS Proxy an unbound read returns onlyorg_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 canonicalemployees.id(org.employeeId/personId(ctx)) — never onuser._iddirectly, and never match people by name string except as a last-resort fallback. RBAC stays on the login (can(user, perm));Employee.title/roleIdsare descriptive only. (Seespecs/identity-model/.) - Server enforces permission (generic route by module, or explicit
can()); UI mirrors withuseAccess. - Permission catalog — new role-assignable actions have a row in
libs/access/permission-catalog.ts(group/subsection/implies/powerOnlyas needed); test expand/implies inlibs/access/permission-catalog.test.tsif non-trivial. - Cedar module change — a new/changed resource entity is listed in the
appliesToof every action it is authorized against (an unregistered type fails closed as a schema validation failure withdeterminingPolicies: [], 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 returnsDeny. 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) withidType: "text", nevernextId. 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 — seespecs/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 asbigint. On an existing bigint resource keep usingnextId/nextIds(libs/erp/sequence.ts) until its Phase D batch lands — never mix the two within one resource:maxExistingIdcasts the id tobigintand catches the failure, so one non-numeric id makes the seed0and any org without acountersrow restarts at1and reissues a live id. Either way, neverfindOne()+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) andthrow 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 singlepgclient withBEGIN/COMMIT(see the TODO intransaction.ts). - Timezone-correct dates — derive week/day boundaries with
libs/time/week.ts(weekStart(date, org.timezone)), nevernew Date()calendar math on the server. Org tz lives onSessionOrg.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 (
AppSidebarmodules) AND toFEATURES(lib/features.ts); new agent tool added toTOOLS(auto-surfaces in Settings → "What Artie can do"). - Unit-tested — any pure logic you added/changed has a co-located Vitest
*.test.tsandnpm testis green. Test the deterministic core inlibs/(calculators, permission/canresolution, 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 testis a CI gate (seeCONTRIBUTING.md§2–3). Reference:libs/orgchart/hierarchy.test.ts,libs/access.test.ts. -
npm run buildgreen andnpm testgreen; 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 intomain— never pushed tomaindirectly (see the Branch-per-change rule up top + §H Releases). - Update
ACCESS.mdif 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_URLis theelas_approle: RLS-subject and DDL-less —ALTER/CREATEfail with Postgres42501 insufficient_privilege. Migrations need the master roleelasasMIGRATE_DATABASE_URL(scripts/pg/migrate.tsprefers it overDATABASE_URL):npx tsx scripts/pg/migrate.ts # idempotent: skips applied files (tracked in _migrations) -
Canonical
MIGRATE_DATABASE_URLsource = AWS Secrets Manager — one master secret per env, mirroring the app-role layout. These secrets are the source of truth for theelasmaster role; build the URL from the secret at run time and never commit/print it:Env RDS host ( :5432/elas)Master secret (Secrets Manager) prod elas-postgres-pilot.cotewciqk8to.us-east-1.rds.amazonaws.comelas/web/db-masterstaging elas-web-staging.cotewciqk8to.us-east-1.rds.amazonaws.comelas/web/staging/db-masterBoth 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.tsThese master secrets are not in the App Runner
env_keysallow-list — the runtime app never uses them (it connects aselas_app); they exist only for migrations/DDL. For local dev you may instead keepMIGRATE_DATABASE_URLin.env.local(gitignored). -
SSL gotcha: store the URL with no
sslmode=in the string and rely onssl: { rejectUnauthorized: false }— asslmode=requirein the connection string overrides the ssl object and makespgattempt full cert verification, which fails on the RDS cert (a classic "could not connect" red herring).migrate.tsalready sets the ssl object. -
Guard every
elas_appGRANT — it does not exist everywhere.elas_appis 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 andelas_appis never created, so a bareGRANT … TO elas_app;throwsrole "elas_app" does not exist(Postgres42704) 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 userelas, IAM auth off (prod =elas-postgres-pilot, staging =elas-web-staging). If theelaspassword is unavailable, reset it (needsrds: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 useselas_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>"}'(usecreate-secretif it doesn't exist yet). Never commit or print the password.Rotation history: the prod (
elas-postgres-pilot)elasmaster was reset/rotated on 2026-07-02 (the prior password had been lost) and is now stored inelas/web/db-master. That reset was the rotation — nothing further is outstanding. Staging's master lives inelas/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).
mainis updated only via merged PRs (never a direct push — see Branch-per-change). A merge tomainthat touches app code triggers.github/workflows/deploy-web.yml→ assumes theelas-gha-deployrole via GitHub OIDC (no long-lived AWS keys) → builds theDockerfile(multi-stagenode: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 stagingthenPromote to prodfrom 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.ymlkeeps a rollingchore(main): release X.Y.ZPR built from your commits; merging that PR tagsvX.Y.Zand 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: writerelease-notes/X.Y.Z.mdyourself, runnpm run release-notes:build, and commit the note and the regeneratedlibs/release-notes/feed.generated.tsin 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
.envin 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'senv_keys) as part of the change, not just to.env.local.
- 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
- Crons: the
/api/cron/*schedule lives in EventBridge (infra/terraform/scheduler.tf, rules → API Destinations with theCRON_SECRETbearer). 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 planis the drift gate. App Runner egress isVPC— it reaches a private RDS instance only through an RDS Proxy over an App Runner VPC connector (TLSverify-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
previewlabel 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 rootinfra/terraform/preview/— never a tier workspace.) - Cost tagging (
project = v3). This AWS account also hosts a separate EKS/OpenSearch platform, so V3 resources carry aproject = v3cost-allocation tag to isolate V3 spend in Cost Explorer. It's applied automatically by Terraformdefault_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 byproject = v3. Resources created outside Terraform (manual/CLI) must be tagged by hand: App Runnertag-resource, RDSadd-tags-to-resource, ECRtag-resource, S3put-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):
- Share SP details. Copy/download the setup packet (SP Entity ID, ACS URL, Name ID = EmailAddress,
required
email+ optionalgroupsattributes) and hand it to the customer's Okta admin. - 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. - Domains + mapping. Enter the routed email domain(s); build the group→role mapping visually
(default = External seat; add
groupsrules). Platform-admin is never assignable (UI omits it, the API sanitizes it, and the resolver clamps it — three layers). - 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/registergate 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 enforceisPlatformAdmin()first. Distinct fromPROVISIONING_TOKEN.- Service login grants
POST /api/internal/login-grantsand.../revoke(BearerHOLDPOINT_SERVICE_TOKEN, on tiers invar.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(BearerPROVISIONING_TOKEN): the endpoint the Auth_Service'sprovisionUserhook 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 masterelas.DATABASE_URLand the proxy secret carryelas_app(it can read/write data but notALTER DATABASE). The masterelascredential 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. Solibs/db/pg.tstrusts Node's built-in public roots (tls.rootCertificates) in addition to the RDS CA. Without that,verify-fullfails and the app goesdb:"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):
| QC | Staging | Prod | |
|---|---|---|---|
| Host | qc.elasapp.com | stage.elasapp.com | elasapp.com |
| App Runner service | elas-web-qc | elas-web-staging | elas-web |
| Private DB | elas-web-qc-priv | elas-web-staging-priv | elas-web-prod-priv (Multi-AZ) |
| RDS Proxy | elas-web-qc-proxy | elas-web-staging-proxy | elas-web-prod-proxy |
| VPC connector | elas-qc-vpc-connector | elas-staging-vpc-connector | elas-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-app | elas/web/staging/db-app | elas/web/prod/db-app |
Master secret (elas) | elas/web/qc/db-master | elas/web/staging/db-master | elas/web/db-master (root) |
| TF workspace | qc | staging | default (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.shagainst the tier's host. - DB-hardening scripts live in
scripts/db-hardening/(02-restore-private-db.sh,03-verify.sh,04-cutover.shhost-repoint,05-decommission.shguarded 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_appvia 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.