Spec process

docs/SESSION_SUMMARY.md · How spec-driven dev works

← Onboarding

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

  1. 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.
  2. 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 fk map 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:

  1. Add the name to ERP_RESOURCES (libs/erp/resources.ts).
  2. Add an interface + ResourceTypeMap entry (libs/erp/types.ts) — build fails until you do.
  3. Map it to a module in RESOURCE_MODULE (libs/access.ts) → view/edit/delete gating on /api/erp/:resource.
  4. 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):

  1. Edit the screen/component under app/dashboard/<screen>/ or components/ (reuse components/ui/*).
  2. Gate visibility with useAccess().can(...) — cosmetic only; the server still enforces.
  3. 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 / fieldslibs/erp/{resources,types}.ts (+ drizzle/000N_*.sql only if a typed table)
who can do itlibs/access.ts (RESOURCE_MODULE / permissions)
an action Artie can takelibs/artie/tools.ts + libs/mcp/* + libs/mcp/governance.ts
the APIgeneric /api/erp/[resource] (or a dedicated route that calls can())
a screenapp/dashboard/<screen>/ + components/* + lib/features.ts + AppSidebar
a DB changedrizzle/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 + all models/* 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 on data, applies update operators read-modify-write, and exposes documents whose toJSON strips privateFields (secrets are visible to server code, removed only on responses). Per-model defaults on create; atomic ids via a counters table.
  • Auth: Better Auth (Postgres) at /api/ba/*. The seam libs/erp/org.ts (getRequestOrg → getSessionOrg → buildSessionOrg) resolves the tenant SessionOrg from the BA session; ~257 call sites are unchanged. libs/auth/server.ts is a NextAuth-shaped auth() shim for the older integration routes; app/api/demo + ALLOW_DEV_NO_AUTH cover 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 via withPgTransaction (libs/db/tx.ts) for atomic multi-writes (the shim's per-call methods are not transactional).
  • Tenant isolation: org_id column + Postgres RLS (org_isolation policy, FORCEd on the typed tables) enforced via request-scoped SET LOCAL app.org_id (libs/db/rls.ts); the app connects as the non-privileged elas_app role so RLS actually binds.
  • RAG: real pgvector ANN (knowledge.embedding <=> $q cosine, 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 open 0.0.0.0/0:5432 (App Runner uses default egress to the public RDS endpoint — lock the SG before real PII).