Skip to content

Research: backend engineering conventions of the sibling work-permit API

Question: What backend conventions does smart-work-permit-api encode that a new Elysia/Bun/Prisma service in this organisation must match — and which of them would break the offline-first, account-free, document-storing shape of the Chemical Safety Assistant if copied by habit?

Sources: primary only — the working tree at /Users/kan/Me/Work/kanverse/projects/work-permit/app/smart-work-permit-api/, read read-only. Every finding cites a real file path in that tree.

Findings continue the R- sequence. A sibling note holds R-46 upward; this note starts at R-76. No id here is reused or renumbered.

Coverage gate. This note declares new R-nn ids, which turn the coverage gate RED until stage 1 accounts for them. That is expected and correct.


Findings

(written incrementally as the source tree is read)

R-76 The repo's own conventions doc is a template doc with a project preamble bolted on — the two disagree and the preamble wins

AGENTS.md (654 lines) opens with two project-specific sections ("the prompt & decision log", "what this repo is now") and then says explicitly: "Everything below this section is the Elysia template's conventions doc. It is still accurate for how to write code here; it says nothing about what this API does." So the directory tree, module tables and naming tables in AGENTS.md describe the template, not the current tree — e.g. it lists modules/auth, file, upload only, while the real tree has 13 modules. Treat the template half as the style contract and read src/ for the actual inventory. CLAUDE.md and GEMINI.md are both symlinks to AGENTS.md, so all three agent harnesses read one file.

Source: smart-work-permit-api/AGENTS.md lines 1-20, 100-260; CLAUDE.mdAGENTS.md symlink Confidence: high

R-77 Module layout is CQRS-shaped: modules/<domain>/{commands,queries}/<action>/ with a three-file triad per action

Every feature domain is an Elysia plugin module at src/modules/<domain>/, containing <domain>.module.ts plus commands/ (mutations) and queries/ (reads). Each action is its own directory holding exactly three files: <action>.http.controller.ts (the Elysia route), <action>.service.ts (business logic class) and <action>.model.ts (Elysia t schemas, namespace pattern). Some modules add a lib/ directory for domain helpers. The module is mounted in src/app.module.ts under a global /api prefix. This is the single most structurally binding convention in the repo — a new service that flattens routes into one file per domain will not look like this codebase.

Source: smart-work-permit-api/AGENTS.md §Architecture Patterns; directory tree under smart-work-permit-api/src/modules/ — 12 mounted modules (area, audit, auth, certificate, dashboard, facility-plan, file, notification, permit, sync, upload, user) plus modules/better-auth/, which holds only user-better-auth.service.ts, has no .module.ts, and is absent from app.module.ts's .use() chain: a service-only directory that looks like a module and is not one Confidence: high

R-78 File naming is suffix-typed, and the suffix is the type

The repo names files by role with a mandatory suffix: .http.controller.ts, .service.ts, .model.ts, .repository.ts, .guard.ts, .plugin.ts, .extension.ts, .util.ts, .interface.ts, .enum.ts, .type.ts, .spec.ts. Directories and files are kebab-case (build-query.util.ts, prisma-transaction-client.type.ts). Shared code lives under src/libs/{common,config,enums,guards,interfaces,middlewares,models,plugins,types,utils}; shared data access lives in src/repositories/, outside src/modules/.

Source: smart-work-permit-api/AGENTS.md §Naming Conventions; smart-work-permit-api/src/libs/Confidence: high

R-79 Style is Oxlint, not ESLint, and it forbids semicolons and trailing commas

Linting is Oxlint configured in oxlint.config.ts (9.9 KB) with the @stylistic JS plugin: 2-space indent, single quotes, no semicolons, no trailing commas, LF, 1tbs braces, inline import { type Foo } enforced by typescript/consistent-type-imports. typescript/no-explicit-any is offany is permitted. no-console is a warning; only console.error is allowed. bun run lint / bun run lint:fix.

Source: smart-work-permit-api/oxlint.config.ts; AGENTS.md §Code Style & Linting Confidence: high

R-80 "Done" is mechanically defined by ./init.sh, which runs three gates unconditionally and reports a combined summary

./init.sh runs lint, bun test and bun run typecheckall three, unconditionally, not abort-on-first-failure — and prints a combined LINT / TEST / TYPECHECK / RESULT block; exit 0 only if all three pass. AGENTS.md records why and forbids reverting it: abort-on-first-error "is what let four lint errors hide the typecheck gate from ever running for two weeks". The typecheck became a hard gate on 2026-08-21 and may not be silenced with any, ts-ignore or a tsconfig.json rewrite. Done additionally requires: new business logic ships with at least one .spec.ts, and the passing command output is pasted into the item's evidence field in feature_list.json.

Source: smart-work-permit-api/AGENTS.md §Definition of Done; smart-work-permit-api/init.shConfidence: high

R-81 Errors carry a machine-readable errorCode; the human message is English and clients never render it

A repo-level invariant: every error response carries an errorCode (ENTRANTS_STILL_INSIDE, FIRE_WATCH_NOT_ELAPSED, CERT_EXPIRED, CERT_MISSING) and the message field is English developer text that no client displays. Adding a code is a three-repo change with a written propagation procedure (docs/main/CONTEXT.md §2). For a Thai-language product this convention is directly reusable: it puts all user-facing wording on the client and keeps the API language-free.

Source: smart-work-permit-api/AGENTS.md §Invariants Confidence: high

R-82 docs/openapi.json is generated, never hand-edited, and contract changes have a cross-repo sync gate

The OpenAPI document is produced by ./scripts/dump-openapi.sh from the running Elysia app. A contract change (route, payload, errorCode) must: regenerate it, copy docs/openapi.json into both frontends' docs/api/, close the matching row in their docs/api/GAPS.md, and pass node scripts/check-contract-sync.mjs at the workspace root. Hand-editing the generated file is forbidden.

Source: smart-work-permit-api/AGENTS.md §Definition of Done and §Invariants; smart-work-permit-api/scripts/dump-openapi.shConfidence: high

R-83 Validation is Elysia's own t (TypeBox), never Zod, and every action's schemas live in a namespace matching the file

Request/response validation uses Elysia's built-in t. Each <action>.model.ts exports a namespace named for the action, holding both the schema and its inferred type under the same identifier:

typescript
export namespace UploadModel {
  export const body = t.Object({ file: t.File(), subFolder: t.Optional(t.String()) })
  export type body = typeof body.static
}

Shared schemas come from src/libs/common/model.common.ts: CommonResponseModel ({ message: 'success' }), CommonPaginationResponseModel, and CommonPaginationModel (page, limit, sortBy, sortOrder, search). There is no Zod anywhere — a new service that reaches for Zod by reflex would diverge on the one thing Elysia gives for free, because the same t schemas are what feed the OpenAPI document (R-82).

Source: smart-work-permit-api/AGENTS.md §Elysia Model/Schema Pattern; smart-work-permit-api/src/libs/common/model.common.tsConfidence: high

R-84 There is one central error handler, mounted on the top-level app rather than inside the app plugin, and the error body is { code, message }

src/libs/middlewares/error-handler/index.ts exports a global ErrorHandler that is attached directly to the top-level Elysia app, deliberately not inside AppPlugin — plugin-scoped onError would not see errors thrown outside the plugin's scope. It handles the custom error classes, Elysia's NOT_FOUND code, Elysia's VALIDATION code (mapped to 400), and generic 401/500. Custom exceptions live in src/libs/middlewares/error-handler/domain/exceptions/: BadRequestError (400), UnauthorizedError (401), ConflictError (409). Services throw them; controllers do not build error responses by hand. Success bodies are { message: 'success', data } for a single resource and { message, data, count, page, limit, totalPage } for a list; error bodies are { code: number, message: string } — note the error shape does not reuse message: 'success' envelope and is a different shape entirely.

Source: smart-work-permit-api/AGENTS.md §Error Handling and §Response Format Conventions; smart-work-permit-api/src/libs/middlewares/error-handler/Superseded in part by R-97. This is the documented shape from AGENTS.md; reading the file shows six exception classes, not three, and five envelope fields, not two. Build against R-97.

Confidence: high for the documented shape, which is incomplete

R-85 Prisma is a shared singleton exported from prisma/index.ts — and the repository layer is partial and mostly bypassed: services import prisma directly

The client is instantiated once in prisma/index.ts, wrapped with the custom extensions, and re-exported along with the Prisma types. Import path is @prisma/index, never @prisma/client — importing the default client silently loses every extension.

AGENTS.md presents a repository layer as the data-access convention: BaseRepository<ModelName, Model> in src/repositories/base.repository.ts, whose every method takes an optional tx (PrismaTransactionalClient) so any call can be enlisted in a transaction. The real tree does not follow it. There are 8 repositories for 12 Prisma models (area, audit-log, base, certificate, example, facility-plan, permit, user — none for notification, gas-log, entrant-event), and 33 non-test files under src/modules/ import from @prisma/index directly, of which ~20 import the prisma value and call it: prisma.$transaction(...) in permit/commands/{submit,close,update}/…service.ts and user/commands/{create,update}, prisma.facilityPlan.findFirst(...) in permit/commands/submit, prisma.gasLogEntry.findMany(...) in permit/queries/gas-log, and even raw SQLprisma.$queryRaw in notification/queries/list/list.service.ts. Module lib/ helpers do the same (permit/lib/audit-log.util.ts, permit/lib/record-entrant-scan.ts, permit/lib/overlapping-permits.util.ts).

The accurate rule for a new service: one extended PrismaClient singleton in prisma/index.ts, imported directly by services and module lib/ helpers; BaseRepository exists but is a convenience for simple CRUD, not an enforced boundary. Controllers still never touch Prisma — the seam that is actually respected is controller → service, not service → repository. Do not plan a new service around a repository layer this codebase does not really have.

Source: smart-work-permit-api/prisma/index.ts; smart-work-permit-api/src/repositories/ (8 files); smart-work-permit-api/src/modules/notification/queries/list/list.service.ts ($queryRaw); smart-work-permit-api/src/modules/permit/commands/submit/submit.service.ts; AGENTS.md §Repository Pattern (aspirational) Confidence: high — verified by grep -rn "from '@prisma/index'" src/modules/

R-86 Four Prisma client extensions are installed on $allModels, and soft delete is mandatory

prisma/extensions/ adds paginate(), exists(), softDelete()/softDeleteMany() and findOrCreate() to every model. Physical .delete() on a domain model is forbidden — models carry deletedAt DateTime? and queries filter where: { deletedAt: null }. Standard fields on every domain model: id Int @id @default(autoincrement()), createdAt, updatedAt, deletedAt, and createdBy Json? / updatedBy Json? holding an IAuthor ({ id, email, firstName, lastName }) — an audit-trail convention that presumes an authenticated actor exists for every write.

Source: smart-work-permit-api/prisma/extensions/; AGENTS.md §Prisma Conventions, §Model Field Conventions; smart-work-permit-api/src/libs/interfaces/author.interface.tsConfidence: high

R-87 The Prisma schema is multi-file: one .prisma per domain under prisma/models/, wired by prisma.config.ts

prisma/schema.prisma holds only the datasource and generator; each domain model gets its own prisma/models/<domain>.prisma with @@map("snake_case_table_name"). prisma.config.ts points Prisma at the prisma/ directory as schema root. Workflow after a schema change: bun run generate (prisma format + prisma generate) then bun run db:migrate:dev. Production/CI applies migrations with bun run prisma:deploy (prisma migrate deploy); prisma:db-push exists but is labelled prototyping-only. There are 11 committed migrations under prisma/migrations/, timestamp-named with a descriptive slug (20260901074250_add_area_entity) — migrations are additive and never squashed.

Source: smart-work-permit-api/prisma.config.ts; smart-work-permit-api/prisma/models/; smart-work-permit-api/prisma/migrations/ (11 directories) Confidence: high

R-88 Seeding is split into a safe bootstrap seed and a fixture seed that refuses production

bun run seed creates the bootstrap system admin and is declared safe in any environment. bun run seed:e2e seeds manual-E2E fixtures F0-F6 (three role accounts plus permits in every status), is idempotent, and refuses to run when NODE_ENV=production. bun run seed:e2e:reset tears down and reseeds to the post-first-seed baseline. Seed sources live under prisma/seeds/.

Source: smart-work-permit-api/AGENTS.md §Build & Development Commands; smart-work-permit-api/prisma/seeds/Confidence: high

R-89 Path aliases are mandatory across boundaries: @/*, @prisma/*, @generated/*

tsconfig.json defines @/*./src/*, @prisma/*./prisma/*, @generated/*./generated/*, and AGENTS.md requires them "instead of relative paths that cross major boundaries". Note @prisma/* shadows the npm scope@prisma/index is this repo's own file, not a package.

Source: smart-work-permit-api/tsconfig.json; AGENTS.md §TypeScript Path Aliases Confidence: high

R-90 Environment access goes through Bun.env, and dayjs only through the plugin

Convention: process.env.VAR || Bun.env.VAR, never bare process.env. dayjs is imported from @/libs/plugins/dayjs.plugin (preloaded with utc, timezone, isBetween, buddhistEra), never from dayjs directly. Timestamps are stored UTC and displayed Asia/Bangkok by the frontend — a repo-level invariant, and directly relevant to a Thai-language product.

Source: smart-work-permit-api/AGENTS.md §Important Notes, §Invariants, §Key Utility Functions; smart-work-permit-api/src/libs/plugins/dayjs.plugin.tsConfidence: high

R-91 Docker ships the whole tree on purpose, not a bundle, because prisma migrate deploy runs at container start

The multi-stage Dockerfile (oven/bun:1.3.13-slim) is depsbuildruntime. deps runs bun install --frozen-lockfile --ignore-scripts because the postinstall needs prisma/, which is not copied at that layer. build runs bunx prisma generate against a placeholder DATABASE_URL. runtime copies the entire tree and runs bunx prisma migrate deploy && bun run src/app.ts. AGENTS.md records the reason the tree is not bundled: migrate deploy needs the Prisma CLI, prisma/migrations/ and prisma.config.ts at runtime, and @prisma/adapter-pg needs generated/client — "a bun build bundle drops all four — that is why the previous Dockerfile could not have deployed". docker-compose.yml is local-only; production is deploy/docker-compose.prod.yml with deploy/RUNBOOK.md.

Source: smart-work-permit-api/Dockerfile; smart-work-permit-api/deploy/; AGENTS.md §Docker Confidence: high

R-92 App composition is four layered Elysia plugins in a fixed order, each a named plugin

src/app.ts is the only file that calls .listen(). It composes, in this exact order: AppPlugin (CORS, request logger, OpenAPI) → ErrorHandlerAppController (root / health and a /error status-code test route) → AppModule (all 12 domain modules under prefix /api) → PermitExpiryCronPlugin. Every Elysia instance is constructed with an explicit name: (new Elysia({ name: 'AppModule', prefix: '/api' })) — Elysia deduplicates plugins by name, so the name is load-bearing, not documentation. app.ts also sets serve: { idleTimeout: 60 }. Two ordering decisions carry inline comments: the error handler is mounted on the top-level app because "this error handler is lazy loaded when use in AppPlugin will not occur", and the cron plugin is mounted only in app.ts so that no .spec.ts booting a bare test Elysia app ever pulls the cron in.

Source: smart-work-permit-api/src/app.ts; smart-work-permit-api/src/app.module.ts; smart-work-permit-api/src/app.plugin.ts; smart-work-permit-api/src/app.controller.tsConfidence: high

R-93 The OpenAPI plugin is @elysiajs/openapi with a bare documentation.info block; route metadata comes from each route's detail/description/response

app.plugin.ts calls openapi({ documentation: { info: { title: 'Elysia API', version: '1.0.0' } } }) and nothing else — the document is built entirely from the t schemas and the description / response options passed to each route (see app.controller.ts, where the health route declares description and a response: { 200: t.Object({...}) } with examples). AGENTS.md makes adding detail/description/response to every route a rule. A deliberate omission is documented in place: better-auth's own /api/auth/* paths are not merged in, because userAuth.handler is never .mount()ed and advertising them would document ~54 routes the API does not serve.

Source: smart-work-permit-api/src/app.plugin.ts; smart-work-permit-api/src/app.controller.tsConfidence: high

R-94 TRAP: package.json's test script is still the template stub that always fails — the real test command is bare bun test

"test": "echo \"Error: no test specified\" && exit 1" is untouched from the Elysia template, so bun run test fails by construction. init.sh sidesteps it by invoking Bun's built-in runner directly (bun test, not bun run test). Anyone wiring CI or a pre-commit hook to bun run test in a copy of this setup gets a guaranteed red. The package is also still named elysia-template-api with "version": "0.0.1" and "module": "src/app.js" (a path that does not exist), and README.md is the unedited 12-line bun create elysia boilerplate — the README carries no project information at all. All real documentation is in AGENTS.md and docs/main/.

Source: smart-work-permit-api/package.json; smart-work-permit-api/init.sh; smart-work-permit-api/README.mdConfidence: high

R-95 The pinned dependency set: Elysia 1.4.28 exact, Prisma 7 with @prisma/adapter-pg, Bun 1.3.13 as packageManager

Runtime deps: elysia pinned exactly to 1.4.28 (no caret), @elysiajs/cors, @elysiajs/cron, @elysiajs/openapi, @prisma/client + @prisma/adapter-pg ^7.8.0, better-auth ^1.6.8, @tqman/nice-logger (request logging), ioredis, minio, nodemailer, mailgun.js, dayjs, lodash, nanoid. Dev: oxlint ^1.61, typescript 5.9.3 exact, bun-types 1.3.13 exact, husky, lint-staged, prisma. "packageManager": "bun@1.3.13". postinstall runs bun run generate (prisma format + generate) — which is why the Docker deps layer must pass --ignore-scripts (R-91). There is no @elysiajs/swagger, no Zod, no test framework beyond Bun's built-in runner, and no ESLint.

Source: smart-work-permit-api/package.jsonConfidence: high

R-96 @prisma/adapter-pg means the Driver Adapters API: a pg Pool is the connection, so pooling and shutdown are the app's problem, not Prisma's

Prisma 7 is used through the Driver Adapters preview surface with @prisma/adapter-pg, i.e. the client is constructed over a node-postgres Pool rather than Prisma's Rust query engine binary. Two consequences a new service inherits: the generated client in generated/client must be present at runtime for the adapter to work (this is one of the four reasons the Docker image ships the whole tree, R-91), and connection-pool sizing/limits are configured on the pg side rather than through DATABASE_URL Prisma parameters.

Source: smart-work-permit-api/package.json (@prisma/adapter-pg ^7.8.0); smart-work-permit-api/prisma/index.ts; AGENTS.md §Project Overview, §Docker Confidence: medium — the adapter wiring is asserted from prisma/index.ts and the docs; pool tuning is inferred from the adapter's contract rather than found configured here

R-97 The error handler registers exceptions with Elysia's .error({...}) and returns a hand-built Response, and the error envelope has five optional fields, not two

src/libs/middlewares/error-handler/index.ts is a plain function (app: Elysia) => app.error({...}).onError(...). It registers six exception classes with Elysia's .error() map — BadRequestError (400), UnauthorizedError (401), ForbiddenError (403), ConflictError (409), TooManyRequestsError (429), ServiceUnavailableError (503), each in its own file under domain/exceptions/ — then branches in onError. It logs every error with console.error (the one console call the linter allows, R-79) and returns a manually constructed new Response(JSON.stringify(...)) rather than letting Elysia serialise. The envelope is { code, message } plus conditionally spread errorCode, failures and certificateFailures, so the documented two-field shape in AGENTS.md is incomplete. BadRequestError additionally carries a context object spread into the response. Status constants come from common/statusCode.ts.

Source: smart-work-permit-api/src/libs/middlewares/error-handler/index.ts; smart-work-permit-api/src/libs/middlewares/error-handler/domain/exceptions/Confidence: high

R-98 TRAP with a scar: validation failures reach the client through Elysia's VALIDATION code, and the JSON.parse of Elysia's message must be guarded

The VALIDATION branch maps to 400 and tries to JSON.parse(handler.error.message) to return structured field errors. A comment records the incident: Elysia usually hands a JSON string here, but a union schema whose branches include an array (GET /permits?status=…) produces plain text; the unguarded JSON.parse threw inside onError, and Bun answered with a 500 HTML fallback page instead of the documented 400 envelope — "one bad query value took the endpoint off-contract entirely". The fix is a try/catch falling back to the raw text. Any new service copying this handler must copy the guard.

Source: smart-work-permit-api/src/libs/middlewares/error-handler/index.ts, VALIDATION branch Confidence: high

R-99 prisma/index.ts is 30 lines: a PrismaPg adapter over DATABASE_URL, one $extends, and a dev-only global cache

Verbatim shape: new PrismaPg({ connectionString: process.env.DATABASE_URL })new PrismaClient({ adapter }).$extends({ model: { $allModels: { paginate, exists, softDelete, softDeleteMany, findOrCreate } } }). It imports PrismaClient from '../generated/client/client' and re-exports everything from there, plus PrismaCoreDatabase = typeof prisma. Outside production it stashes the client on globalThis.prisma (the standard hot-reload guard) — but note it only assigns, it never reads back, so the guard does not actually prevent a second client under bun --watch; it is a partial copy of the usual pattern.

Source: smart-work-permit-api/prisma/index.tsConfidence: high

R-100 Tests: 30 colocated .spec.ts files under Bun's built-in runner, and roughly a quarter of them are real Postgres integration tests

There is no Vitest/Jest — bun test discovers *.spec.ts colocated next to the code they cover (src/modules/permit/commands/close/close.service.spec.ts, src/libs/utils/generate-next-identifier.spec.ts). Tests sit at three levels: pure unit (calculate-id-differences.spec.ts), service-level — the dominant style, instantiating the service class directly (new PermitExpireService().execute()) rather than going through HTTP — and module-level lifecycle suites (permit-lifecycle.spec.ts, user-management.spec.ts, facility-plan.spec.ts) that boot a bare new Elysia().use(...). The deploy.yml comment states 23 of 85 tests hit Postgres through Prisma, so the suite is not mock-based: it asserts against a real database. What is actually asserted is business invariants, not shapes — status transitions, reading boundaries, closure guards, audit hash-chain integrity and races (audit-log-race.spec.ts), fire-watch timing, overlapping permits, and sync idempotency (sync/commands/batch/batch.service.spec.ts).

One drift to note: deploy.yml's comment says the runner "finds all 14 .spec.ts files" and counts "23 of the 85 tests"; find returns 30 spec files. The CI comment is stale — the counts in it are not a gate, only prose.

Source: 30 .spec.ts files under smart-work-permit-api/src/; smart-work-permit-api/.github/workflows/deploy.yml check job comment Confidence: high

R-101 CRON: @elysiajs/cron runs one in-process job every minute, mounted only at the entrypoint, with over-run protection explicitly enabled

src/libs/plugins/permit-expiry-cron.plugin.ts wraps @elysiajs/cron in a named Elysia plugin (pattern: '*/1 * * * *') that calls new PermitExpireService().execute(). Three deliberate choices are documented inline and are directly transferable to a scheduled corpus sync:

  1. In-process, not a host crontab entry — the job is part of the app so it deploys and scales with it;
  2. Mounted only in src/app.ts, never in app.module.ts/app.plugin.ts, because specs boot a bare new Elysia().use(...) and a cron firing mid-test against the test database is exactly the hidden side effect the suite avoids;
  3. protect: true — croner's over-run protection, passed through by @elysiajs/cron and explicitly noted as not the default. It blocks a new trigger while one is still running; the next tick picks up what the previous sweep did not finish, "so nothing is lost, only delayed". The sizing rationale is also recorded: every-minute is cheap "at facility scale (tens to low hundreds of open permits, not internet scale)".

Source: smart-work-permit-api/src/libs/plugins/permit-expiry-cron.plugin.ts; smart-work-permit-api/src/app.tsConfidence: high

R-102 Gates are three-layered: husky + lint-staged locally, ./init.sh per feature, and two GitHub Actions workflows

  • Pre-commit: .husky/pre-commit is one line, bunx lint-staged; lint-staged.config.js runs oxlint --fix on *.{js,mjs,cjs,ts,mts,cts,tsx}. Lint only — no test, no typecheck at commit time.
  • Per feature: ./init.sh (R-80).
  • CI: .github/workflows/deploy.yml on push to main — a check job (lint → bun test → typecheck → ./scripts/check-cookie-domain.sh) with Postgres 16 and Redis 7 service containers, then build (buildx → GHCR, linux/amd64, gha cache) then deploy (scp the compose file + nginx.conf + backup.sh to the droplet, then docker compose ... up -d --wait, where --wait blocks on the healthcheck so a container that dies on a bad migration fails the job). .github/workflows/contract.yml boots the real app and uploads docs/openapi.json as an artifact.
  • Branch model: dev is where work lands; main is the deploy branch, reached by PR whose check job must pass. concurrency: api-deploy, cancel-in-progress: false.
  • CI runs bun test directly with a comment naming the R-94 trap, and bunx prisma migrate deploy before the suite because the integration tests need real tables.

Source: smart-work-permit-api/.husky/pre-commit; smart-work-permit-api/lint-staged.config.js; smart-work-permit-api/.github/workflows/deploy.yml; smart-work-permit-api/.github/workflows/contract.ymlConfidence: high

R-103 TRAP: redis.plugin.ts constructs its client at module scope, so a missing Redis is a boot crash, not a degraded route

Both CI workflows carry the same comment and both provision a Redis service container purely because of it: "redis.plugin.ts constructs its client at module scope, so a missing Redis is a boot crash, not a degraded route." The same class of coupling applies to MinIO in reverse — createStorageFromEnv() builds its client at request time, so unset MinIO env vars turn upload routes into 500s before the guard under test runs, which is why deploy.yml sets dummy MinIO values it never connects to. Module-scope side effects in plugins are a live hazard in this codebase.

Source: smart-work-permit-api/.github/workflows/contract.yml and deploy.yml env comments; smart-work-permit-api/src/libs/plugins/redis.plugin.tsConfidence: high

R-104 A route is defined in a controller as a named Elysia plugin with the handler inline, the service constructed per request, and four option keys: auth, body/query, detail, response

The canonical shape, from src/modules/sync/commands/batch/batch.http.controller.ts:

typescript
export const SyncBatchHttpController = new Elysia({ name: 'SyncBatchHttpController' })
  .use(UserAuthGuard)
  .post('/batch', async ({ body, user }) => {
    const service = new SyncBatchService()
    const data = await service.execute(body, user)
    return { message: 'success', data }
  }, {
    auth: ['inspector'],
    body: SyncBatchModel.body,
    detail: { summary: '…', description: '…' },
    response: { 200: SyncBatchModel.response }
  })

The controller is thin: it constructs the service, calls one execute(), and wraps the result in { message: 'success', data }. Services are new-ed per request, not injected or cached — there is no DI container. Registration is two-layered: the module file builds a versioned route group (new Elysia({ name: 'SyncV1Route', prefix: '/v1/sync', tags: ['v1', 'Sync'] })) and wraps it in a <Domain>Module; app.module.ts mounts that under /api. So the public path is /api + /v1/<domain> + the route path — versioning lives in the module prefix, not the app prefix, and tags on the route group is what groups the domain in the OpenAPI document.

Source: smart-work-permit-api/src/modules/sync/commands/batch/batch.http.controller.ts; smart-work-permit-api/src/modules/sync/sync.module.ts; smart-work-permit-api/src/modules/upload/commands/upload.http.controller.tsConfidence: high

R-105 AUTH — exactly what exists, so it can be deliberately left out: Better Auth session cookies, a UserAuthGuard Elysia macro, three roles, and a User/Session/Account/Verification table set

The consuming project has Anonymous Use and no accounts. This repo is the opposite, and the coupling is deep enough that removing it is a design decision, not a deletion:

  • Better Auth (src/libs/plugins/better-auth/user-auth.plugin.ts) with the Prisma adapter, email/password, Bun.password.hash/verify, admin plugin. Public signup is closed by default and opened with USER_ENABLE_SIGNUP=TRUE. Base path /user; routes at /api/v1/auth/user/*.
  • Session cookies, not bearer tokens. Secure in production, and COOKIE_DOMAIN set to the apex with SameSite=Lax so api. / app. / safety. share one session; unset it is host-only and falls back to SameSite=None. scripts/check-cookie-domain.sh boots better-auth twice and asserts both branches, and runs as a CI gate.
  • UserAuthGuard (src/libs/guards/user.guard.ts) is an Elysia .macro() exposing an auth option: auth: true = any signed-in user; auth: ['safety_officer'] = signed in andpermitRole in the list. It resolves the session per request and injects { user, session }.
  • Four auth tables in prisma/models/users.prisma: User, Session, Account, Verification, plus a ContractorProfile. User carries better-auth's own role and a separate domain permitRole enum, banned/banReason/banExpires, active, isDemoAccount, and five name/phone fields.
  • Accounts are deactivated, never deleted (active Boolean @default(true)), because permit.createdById is a plain indexed scalar and the audit log denormalises the actor — deleting the row would leave both pointing at nothing.

The guard also encodes one transferable lesson independent of auth: it throwsnew UnauthorizedError(...) rather than returning status(401), because a bare status short-circuits before the error handler and answers with plain-text "Unauthorized", forcing every client to special-case it. Throwing is what preserves the {code, message, errorCode} envelope.

Source: smart-work-permit-api/src/libs/guards/user.guard.ts; smart-work-permit-api/prisma/models/users.prisma; smart-work-permit-api/src/libs/plugins/better-auth/user-auth.plugin.ts; AGENTS.md §Authentication Confidence: high

R-106 CONFLICT — auth is not a layer here, it is a dependency of the data model, the request logger, CI and the write path

Removing auth from a copy of this template is not "skip the guard". Concretely: every domain model carries createdBy Json? / updatedBy Json? holding an IAuthor (R-86); every non-public controller starts with .use(UserAuthGuard) and services that write take an author: IAuthor second argument (SyncBatchService.execute(body, user)); the CI check job runs a dedicated check-cookie-domain.sh gate; and USER_AUTH_SECRET is a required env var the app will not boot without. An account-free service must decide up front what createdBy holds (nothing, or a Curation-time human identity recorded out of band) rather than discovering the field mid-build. Note also the single-tenant invariant written into ContractorProfile: "This deployment is single-tenant (one domain = one company), so nothing may be scoped by it" — the same posture The Plant needs, so no tenancy machinery need be copied either.

Source: smart-work-permit-api/prisma/models/users.prisma (ContractorProfile comment); smart-work-permit-api/src/modules/sync/commands/batch/batch.service.ts; smart-work-permit-api/.github/workflows/deploy.yml; AGENTS.md §Environment Variables Confidence: high

R-107 FILE STORAGE — binaries never touch Postgres: a FileStorage port with MinIO/S3/GCS adapters, a 10 MB ceiling, a closed content-type table, and short-lived presigned reads

src/modules/upload/ implements a port/adapter design: core/ports/file-storage.ts declares FileStorage with uploadOne, uploadMany, getPresignedUrl(filePath, expiration) and deleteFile — all required, not optional, with a comment naming the incident (ticket 035: an optional method reached through ! throws a raw TypeError instead of a domain error). adapters/storage/ holds minio-storage.ts (active, with its own .spec.ts), s3-storage.ts and gcs-storage.ts; factories/storage-factory.ts exposes createStorageFromEnv(), called inside the request handler, not at module scope. src/modules/file/queries/find-one/ serves a file by returning a presigned URL with a 60-second expiry — the API never streams bytes back. The server-side policy in src/libs/config/upload.config.ts is unusually well-reasoned and worth copying wholesale:

  • UPLOAD_MAX_FILE_BYTES = 10 MB, sized to "phone photos and scanned PDFs" while keeping a single-process Bun server from being OOMed by one POST;
  • UPLOAD_ALLOWED_CONTENT_TYPES maps MIME → stored extension. The stored extension and the Content-Type handed to the object store are derived from this table, never from the client-supplied filename, so an uploader can never get text/html or script-bearing image/svg+xml served back from the storage origin;
  • two subfolder allow-lists: UPLOAD_ALLOWED_SUBFOLDERS (storage policy) and the deliberately narrower UPLOAD_CLIENT_SUBFOLDERS (what a client may name). facility-plans is in the first and not the second because the generic upload route is auth: true, and the server-owned route passes the prefix as a hardcoded literal;
  • the limits are declared twice — in t.File({ maxSize, type }) on the model so an oversized body never reaches the handler and the limits appear in the generated contract, and again in resolveUpload() server-side. application/pdf is accepted on the generic route.

Source: smart-work-permit-api/src/libs/config/upload.config.ts; smart-work-permit-api/src/modules/upload/core/ports/file-storage.ts; smart-work-permit-api/src/modules/upload/commands/upload.model.ts; smart-work-permit-api/src/modules/file/queries/find-one/find-one.service.tsConfidence: high

R-108 CONFLICT — nothing here stores or indexes large text; the closest thing is a t.Record(t.String(), t.Any()) JSON payload column

The Corpus stores SDS documents and their extracted text. This repo has no analogue: the document side is solved (R-107, object storage + presigned URLs, and a 10 MB ceiling that an SDS PDF comfortably fits), but the text side has no precedent. Free-form data here is stored as Prisma Json columns (createdBy, updatedBy, the sync payload) validated as t.Record(t.String(), t.Any()) — a shape chosen for small opaque blobs, not for multi-kilobyte Source Spans that need to be queried, offset-addressed or full-text searched. There is no full-text search, no tsvector, no vector column, no search engine anywhere in the 12 Prisma models or the dependency list; search is buildSearch(), a Prisma StringFilter with mode: 'insensitive' — i.e. ILIKE. Any Corpus text-retrieval design is new ground and must not assume this repo has an answer to copy.

Source: smart-work-permit-api/src/libs/utils/build-query.util.ts; smart-work-permit-api/prisma/models/ (12 model files, none with a text-search column); smart-work-permit-api/src/modules/sync/commands/batch/batch.model.ts; smart-work-permit-api/package.jsonConfidence: high

R-109 CONFLICT, and the most directly reusable thing in the repo — this API already has an offline-sync endpoint, and its contract is a per-item result array, not a transaction

POST /api/v1/sync/batch replays a queued batch of client-side actions. Its design answers exactly the questions a Corpus sync raises, and the answers are documented in the code:

  • Never abort the batch. "Each item is processed independently — one bad item in a batch must never abort the rest of the queued offline actions." The response is { message: 'success', data: [{ clientId, status: 'success' | 'conflict' | 'error', message?, errorCode? }] } — HTTP 200 even when items fail, with per-item outcomes.
  • Idempotency is keyed on a client-generated clientId, supplied by the offline client, not on a server sequence.
  • Per-item errorCode exists so an offline client can localize the conflict instead of rendering message — the same English-message/machine-code split as R-81, carried down to item level. For a Thai-language client this is the pattern to copy.
  • Forward and backward compatibility is planned for: payload is a free-form t.Record, and readSource() treats any unrecognised value as undefined — "an older offline client that never learned about source sends nothing and this reads as unknown… this value is client-asserted evidence, never a gate."

The conflict: the direction is opposite. Here the offline client is the origin of writes and the server is the authority that may reject them; for the Chemical Safety Assistant the server is the Corpus of record and the client pulls a read-only copy. The per-item, never-abort, client-id idempotent, errorCode-per-item envelope transfers; the write-replay semantics do not. Note also that this endpoint is auth: ['inspector'] — the sibling's offline story is gated on a session the client must have obtained while online, which an account-free product cannot reproduce.

Source: smart-work-permit-api/src/modules/sync/commands/batch/batch.model.ts; smart-work-permit-api/src/modules/sync/commands/batch/batch.http.controller.ts; smart-work-permit-api/src/modules/sync/commands/batch/batch.service.tsConfidence: high

R-110 CONFLICT — every read in this API is a live, authenticated, database-backed request; nothing is cached for reachability and there is no read-only or degraded mode

There is no service worker story, no ETag/If-None-Match handling, no client-cacheable response headers, and no "last known good" read path anywhere in the 12 modules. Redis is present (ioredis) but as an infrastructure dependency whose absence is a boot crash (R-103), not a read-through cache that degrades. Safety-critical behaviour is enforced server-side by design and AGENTS.md states the principle as an invariant: "validatePermitReadings() runs on both submit and approve; a client-side check is not a gate." That is the correct posture for a permit system whose client is always online, and it is the exact inversion of the Chemical Safety Assistant, where no safety-critical read may depend on the API being reachable. Copying this repo's "server is the gate" reflex would put the network in the path of a Handler reading First Aid guidance. The transferable half is the reason the invariant exists — a client-side check is not an authority — which for the Corpus means the client's offline copy must be a verbatim replica of server-curated content, never client-derived.

Source: AGENTS.md §Invariants; smart-work-permit-api/src/modules/ (no cache or offline read path in any of the 12 modules); smart-work-permit-api/src/libs/plugins/redis.plugin.tsConfidence: high

R-111 The repo runs a written agent harness, and the harness files are part of the convention

Beyond the code: feature_list.json (72 KB — one item per feature with dependencies, status and an evidence field holding pasted command output), progress.md (147 KB, dated append-only log), session-handoff.md (blockers, touched files, recommended next step), docs/main/PROMPT-LOG.md (the product owner's rulings, including rejected options, declared required reading before implementing anything — "if your change contradicts a ruling in that file, stop and raise it"), docs/main/dev-handoff/00-SHARED-CONTEXT.md and 04-api-contract.md. The workflow is: run ./init.sh for a clean baseline → pick one item whose dependencies are all done → implement only that → re-run ./init.sh → paste the output into evidence → update progress.md and session-handoff.md. Rules include "never start a second item" and "items marked blocked are product decisions — do not implement them speculatively". codebook.toml and skills-lock.json are also committed at the root.

Source: smart-work-permit-api/AGENTS.md §Agent harness; smart-work-permit-api/feature_list.json; smart-work-permit-api/progress.md; smart-work-permit-api/session-handoff.mdConfidence: high

R-112 Deliberate deviations and near-bug-shaped invariants a newcomer would try to "fix"

Collected, each with its recorded reason:

  1. ErrorHandler mounted on the top-level app, not in AppPlugin — inside the plugin it is lazily loaded and never fires (src/app.ts comment).
  2. PermitExpiryCronPlugin mounted only in app.ts — so specs that boot a bare Elysia never start the cron (R-101).
  3. init.sh deliberately does not set -e (R-80).
  4. package.json's test script is a failing stub, on purpose left alone; CI comments the reason (R-94).
  5. better-auth's ~54 /api/auth/* routes are excluded from the OpenAPI document because the handler is never .mount()ed (R-93).
  6. The Docker image ships the whole tree rather than a bundle (R-91).
  7. typescript/no-explicit-any is off in the linter, but any may not be used to silence the typecheck gate — two rules that look contradictory and are not (R-79, R-80).
  8. CORS_ORIGIN=* silently disables credentials: true; app.plugin.ts only sets credentials when getCorsCredentialsFlag() says so.
  9. prisma/index.ts's dev global assigns but never reads (R-99).
  10. MINIO_REGION is pinned "so presigning needs no GetBucketLocation round-trip".
  11. serve: { idleTimeout: 60 } on the root app.
  12. The audit log is append-only with a hash chain and a facility-wide advisory lock serialising read-then-insert; AGENTS.md forbids ever adding an update or delete path.

Source: smart-work-permit-api/src/app.ts; smart-work-permit-api/init.sh; smart-work-permit-api/src/app.plugin.ts; smart-work-permit-api/prisma/index.ts; smart-work-permit-api/src/libs/plugins/permit-expiry-cron.plugin.ts; AGENTS.md §Invariants, §Environment Variables, §Important Notes Confidence: high

Open questions

  • Whether the extracted text of an SDS belongs in Postgres at all, or alongside the document in object storage with only Source Span offsets in the database. Nothing in the sibling settles it (R-108).
  • What createdBy / updatedBy should hold in an account-free service, given every domain model in the sibling carries them and every service signature threads an IAuthor (R-106).
  • Whether a Corpus sync endpoint should reuse the POST /v1/sync/batch envelope inverted (server → client) or be a plain versioned snapshot pull; the sibling only demonstrates the client → server direction (R-109).
  • Whether Redis is needed at all. It is a hard boot dependency here (R-103) and nothing observed shows what it actually caches.

Coverage

Stage 0 is the head of the pipeline — it has no upstream artifact to account for. This table is intentionally empty; the first real Coverage table is written by stage 1, which must account for every R-nn above.

UpstreamLanded inEvidenceNote