SOP — Spec-driven development on ELAS
A standard operating procedure for taking a change from idea → shipped, the spec-driven way. "Back-heavy": the backend (data + rules + services) is the system; the UI is a thin client. The spec is the source of truth; the service is generated/built from it; the running code is an artifact you don't hand-edit.
Two facts to anchor on
- When does it run from the spec? At build/deploy time, not per request. The spec generates ordinary compiled code; that code is what serves traffic. Changing a spec does nothing until you regenerate + deploy.
- Where do specs live / how does CI know? In the repo, version-controlled, under
specs/<feature>/. CI regenerates from them and runs a drift gate — so the build fails if the committed code no longer matches its spec. Specs sit beside the code; CI enforces they agree.
Step 0 — Triage: is this backend, frontend, or both?
Run the issue through the triage tool first — it decides the layer, whether a migration is likely, and which workflow below applies:
npx tsx scripts/triage.ts "add a warranties record type with an expiry date, finance can edit"
- frontend only (color/copy/layout/component/what's shown) → skip the spec entirely, go to §Frontend changes.
- backend / both (new data, field types, rules, API, permissions, jobs) → follow §1–§6.
Step 1 — Write the spec (specs/<feature>/)
Scaffold the folder, then fill it. spec.yaml is the machine contract the generator reads; the prose
is what you review. Keep them the source of truth.
make spec-draft FEATURE=warranties DESC="warranties with an expiry date" # AI-draft spec.yaml + requirements
make spec-new FEATURE=warranties # …or a blank template
# (no Docker? npm run spec:draft / spec:new)
specs/<feature>/
spec.yaml # machine-readable contract (entities/fields/actions/screens) — the generator reads THIS
requirements.md # WHAT & WHY + acceptance criteria
design.md # data model + API contract + rules→location + UI hints
tasks.md # the build checklist the agent/dev works through
requirements.md — numbered, testable acceptance criteria:
# Warranties
R1. A warranty belongs to one project and has an expiry date (date, required).
R2. Only users with `operations.edit` can create/edit; `operations.view` can read.
R3. A warranty within 30 days of expiry shows as "expiring".
design.md — the precise data model + contract (this is what makes generation deterministic):
## Data model (Postgres)
warranties(id pk, org_id fk, project_id fk→projects, expiry date not null,
status enum('active','expiring','expired') default 'active')
## API
GET/POST /api/erp/warranties · PATCH/DELETE /api/erp/warranties/:id (governed generic CRUD)
## Rules → location
R3 (expiring) → derived in the service, not stored.
## UI hints
list + a date picker for expiry; status chip.
tasks.md — the ordered checklist (migration → model → API → tests → UI).
A spec is only as precise as the data model. Postgres' typed schema is what lets a spec generate a real service —
amount numeric,status enum,customer_id fkmap 1:1 to DDL + typed queries + a typed API. (Mongo's schemaless docs couldn't be the contract — that's why the stack is Postgres.)
Step 2 — Generate, then finish it out
The in-house generator (scripts/spec/*) reads every spec.yaml and emits the registration regions
(resource registry, types + ResourceTypeMap, permission/module map, feature flags, import catalog,
flowOrder, screenMeta) plus a screen scaffold (page.tsx + a <Id>Screen stub). Generated
regions are wrapped in // <generated:spec id=…> — DO NOT EDIT.
make spec-gen # write/refresh the generated regions + scaffold any new screen
make spec # finish it out: spec-gen → tsc → build → test → drift gate (one command)
Then fill the <spec:impl> logic + tests, and do the few hookups the generator prints it can't
place: the AppSidebar entry per screen, the Artie TOOLS entry per action, and a drizzle
migration + typed-registry spec for any typed: true entity (most record types are JSONB → no
migration; §A). You can also author the spec in Kiro, but the repo's make spec-gen is the generator.
Step 3 — Review
The PR's reviewable unit is the spec + the generated diff. Review the migration especially — destructive changes (drops/renames/type changes) get extra scrutiny and a backfill plan.
Step 4 — Apply the migration & run
# add the migration as drizzle/000N_<name>.sql, then:
npx tsx scripts/pg/migrate.ts # applies drizzle/*.sql in order, tracked in _migrations
npm run build # must be green before commit
Step 5 — CI drift gate
CI regenerates from the spec and runs git diff --exit-code. If generated code ≠ spec, the build
fails. This is the guarantee that code never silently drifts from its spec. (Set this up as the
generator matures; until then the build gate + review stand in.)
Step 6 — Deploy only what changed
Granular deploys are the goal: a path-filtered CI matrix builds only the changed service's image and
syncs only its deploy manifest, so a one-module change ships one service (see PLATFORM_PLAN.md §2).
Today the web app deploys on push to main via GitHub Actions → ECR → AWS App Runner
(.github/workflows/deploy-web.yml, path-filtered + gated on a verify job). See
docs/AWS_MIGRATION.md.
The ELAS-today shortcut (no generator yet)
For a new data type, the registry IS the spec — declare it once and CRUD/API/agent access derive:
- Add the name to
ERP_RESOURCES(libs/erp/resources.ts). - Add an interface +
ResourceTypeMapentry (libs/erp/types.ts) — build fails until you do. - Map it to a module in
RESOURCE_MODULE(libs/access.ts) → view/edit/delete gating on/api/erp/:resource. - Use it from the client with
useResource/useCreateResource/useUpdateResource.
ERP resources are schemaless JSONB rows in erp_records, so a new resource/field usually needs
no migration — add a drizzle/000N_*.sql only when carving a hot resource into a typed table.
If the change is an action, add a TOOLS entry (libs/artie/tools.ts) so Artie can do it too.
Full mechanics: docs/BUILDING.md. Permissions: docs/ACCESS.md.
Frontend changes (no spec, no migration)
Pure UI (color, spacing, copy, layout, a component, responsive/mobile, what's shown/hidden):
- Edit the screen/component under
app/dashboard/<screen>/orcomponents/(reusecomponents/ui/*). - Gate visibility with
useAccess().can(...)— cosmetic only; the server still enforces. - Verify at ≤375px; don't fork a component that already exists. No spec, no DB touch.
Where the generated/manual pieces land (quick map)
| Spec says… | Lands in |
|---|---|
| a data type / fields | libs/erp/{resources,types}.ts (+ drizzle/000N_*.sql only if a typed table) |
| who can do it | libs/access.ts (RESOURCE_MODULE / permissions) |
| an action Artie can take | libs/artie/tools.ts + libs/mcp/* + libs/mcp/governance.ts |
| the API | generic /api/erp/[resource] (or a dedicated route that calls can()) |
| a screen | app/dashboard/<screen>/ + components/* + lib/features.ts + AppSidebar |
| a DB change | drizzle/000N_*.sql → scripts/pg/migrate.ts (tracked in _migrations) |
Runtime reference — how the data layer & auth this SOP targets actually work (the Postgres JSONB shim, the auth seam, TLS, IDs) is in the sections below.
Appendix — how the current backend works (so the SOP makes sense)
- Data layer: no Mongo.
getErpModel+ allmodels/*resolve to a Mongoose-compatible shim over Postgres JSONB (libs/db/mongo-shim.ts). ERP →erp_records (org_id,resource,id,data); typed models →mongo_docs (collection,doc_id,data). The shim emulates find/findOne/create/save/update/delete/count, translates Mongo filters → SQL ondata, applies update operators read-modify-write, and exposes documents whosetoJSONstripsprivateFields(secrets are visible to server code, removed only on responses). Per-modeldefaultsoncreate; atomic ids via acounterstable. - Auth: Better Auth (Postgres) at
/api/ba/*. The seamlibs/erp/org.ts(getRequestOrg → getSessionOrg → buildSessionOrg) resolves the tenantSessionOrgfrom the BA session; ~257 call sites are unchanged.libs/auth/server.tsis a NextAuth-shapedauth()shim for the older integration routes;app/api/demo+ALLOW_DEV_NO_AUTHcover demo/dev sign-in. - Connection: one
pg.Pool(libs/db/pg.ts), TLS verify-full against the bundled RDS CA. Real Postgres transactions are available viawithPgTransaction(libs/db/tx.ts) for atomic multi-writes (the shim's per-call methods are not transactional). - Tenant isolation:
org_idcolumn + Postgres RLS (org_isolationpolicy, FORCEd on the typed tables) enforced via request-scopedSET LOCAL app.org_id(libs/db/rls.ts); the app connects as the non-privilegedelas_approle so RLS actually binds. - RAG: real pgvector ANN (
knowledge.embedding <=> $qcosine, HNSW index;drizzle/0011_pgvector.sql,libs/artie/rag.ts) when an embed model is configured. - Remaining constraints (fix-forward, see
PLATFORM_PLAN.md): JS-side sort with a 10k fetch cap, no field casting, and the RDS security group still open0.0.0.0/0:5432(App Runner uses default egress to the public RDS endpoint — lock the SG before real PII).