04 — Real API contract & integration guide (Contractor web app)
The backend (smart-work-permit-api, Elysia + Prisma + better-auth) is built and running. This document replaces the assumptions in 01-backend-elysia-tasks.md wherever the two disagree: that file was the plan, this is what the server actually does.
Source of truth is docs/api/openapi.json in this repo — generated from a live boot, never hand-edited. Regenerate it after any backend change:
cd ../smart-work-permit-api && ./scripts/dump-openapi.sh # writes docs/openapi.json
cp ../smart-work-permit-api/docs/openapi.json docs/api/openapi.jsonProse below summarizes; on any conflict the JSON wins. It was first verified against a running server on 2026-08-17, re-checked against docs/openapi.json and the route guards on 2026-09-10 after CR round 3 replaced the work-window fields, made a worker an entity, and added areas, area drawings, permit coordinates and inspector visits — and re-checked again against the current router/model files on 2026-09-12 (wayfinder 128), after CR round 4 deleted Area entirely and replaced it, and the permit's geo coordinate, with a Pin on a named FacilityPlan (wayfinder 104/105/106).
1. Invariants
| Thing | Value |
|---|---|
| Base path | /api/v1 — app prefix /api + module prefix /v1/<module> |
| Casing | camelCase in both directions. Do not run humps over requests or responses |
| Success body | { "message": "success", "data": … } |
| Paginated body | { "message", "data": [], "count", "page", "limit", "totalPage" } |
| Pagination query | page=1, limit=10, sortBy=createdAt, sortOrder=desc, search='' |
| Error body | { "code": <http status>, "message": string, "errorCode"?: string } |
| Dates out | ISO-8601 UTC strings |
| Dates in | startDate, endDate, issuedDate, expiryDate, dateFrom, dateTo → YYYY-MM-DD; dailyStart, dailyEnd → HH:mm / HH:mm:ss clock time, no date part |
Two responses deliberately break the success envelope: login answers {success, data:{token,user}} and the health route GET / (outside /api) answers a bare object. Everything else is enveloped.
Date fields are asymmetric — you send 2026-08-18, you get back 2026-08-18T00:00:00.000Z. Format them for display; do not feed a response value straight back into <input type="date">.
dailyStart / dailyEnd are worse than asymmetric and this is the one trap in this document worth memorizing. They are Postgres TIME columns — a clock time with no date at all — and Prisma reads every one of them back anchored to 1970-01-01T00:00:00Z. Convert them to local time before displaying them. Rendering them as a UTC wall clock shifts every migrated permit by the deployment's offset, and nothing errors: the backfill preserved each permit's real instant, so the wrong render is quietly plausible.
workDate, workTimeStart and workTimeEnd no longer exist, in either direction. Sending them is not a rename that still works; they are gone.
Enums, exactly:
| Enum | Values |
|---|---|
PermitType | hot confined heights |
PermitStatus | DRAFT PENDING REJECTED ACTIVE FIRE_MONITOR CLOSED EXPIRED |
UserRole | contractor safety_officer inspector — there is no admin |
JsaPhase | pre process post |
EntrantDirection | IN OUT (uppercase) |
2. Auth — session cookie, not bearer
Authorization: Bearer <token> returns 401. Verified: no better-auth bearer plugin is mounted. The session lives in a cookie.
POST /api/v1/auth/user/public/login { email, password } # password min length 8
→ 200 { success: true, data: { token, user: { id, name, firstName, lastName, email, image, role } } }
→ Set-Cookie: better-auth.session_token=… (dev: SameSite=Lax, no Secure)
→ Set-Cookie: __Secure-better-auth.session_token=… (prod: SameSite=None; Secure)user.roleis the domain role (permitRolein the DB). Route by it; a contractor account always reportscontractor.- The returned
tokenis not an API credential here. Keep it only as a client-side "is logged in" marker if your router guard wants one. - The cookie name changes between dev and prod — better-auth adds the
__Secure-prefix whenuseSecureCookiesis on, which isNODE_ENV === 'production'. Never hardcode the name.
Client requirements:
withCredentials: trueon the axios instance (already set in this repo'sHttpRequest.ts).- The API's
CORS_ORIGINmust name this app's exact origin — credentialed CORS refuses*. Ask for your dev origin to be added; the API's.env.exampleshipsCORS_ORIGIN="http://localhost:8080,http://localhost:8081".
Other auth routes: POST /auth/user/logout, POST /auth/user/public/user-request-password-reset{email}, POST /auth/user/public/user-reset-password {newPassword, token}, GET /auth/user/check-bearer/user (session probe).
Two things that do not exist, despite appearing in older docs or in this repo's provider: pre-login, check-token-reset-password, and every *-branch endpoint (active-branches, select-active-branch, approve-branch, reject-branch) — those are lending-app leftovers. Also, better-auth's own /api/auth/* routes are not mounted; only the routes listed here exist.
Self-registration is disabled in this deployment (USER_DISABLE_SIGNUP). Contractor accounts are provisioned by a safety officer via POST /api/v1/users.
3. What a contractor account may call
Role column below: ✅ allowed for contractor, ⛔ 403 FORBIDDEN_ROLE, * any signed-in user.
Permits — /api/v1/permits
| Method | Path | Contractor | Notes |
|---|---|---|---|
| GET | / | * | Paginated. Scoped to your own permits automatically — the contractorId filter is ignored for contractor accounts. Filters: status, type, dateFrom, dateTo; search matches id/title/location/foreman |
| POST | / | ✅ | Required: {type, title, foreman, startDate, endDate, dailyStart, dailyEnd}. Optional: description, location, scheduleNote, outdoorWork, ppeDeclared, ppeNote, pinId — the pin the contractor selects, placed and named by safety (src/modules/permit/commands/create/create.model.ts). → DRAFT, id WP-{HOT|CONF|HT}-{YYYYMMDD}-{NNN} |
| GET | /:id | * | Full detail. 403 on someone else's permit |
| PATCH | /:id | ✅ | The wizard's save. All fields optional — see §4 |
| POST | /:id/submit | ✅ | → PENDING. This is where server-side validation bites — see §5 |
| POST | /:id/mark-complete | ✅ | Hot work only → FIRE_MONITOR, starts the 30-minute fire watch. 403 NOT_HOT_WORK, 403 PERMIT_NOT_ACTIVE |
| POST | /:id/approve | ⛔ | safety_officer |
| POST | /:id/reject | ⛔ | safety_officer |
| POST | /:id/close | ⛔ | safety_officer only (wayfinder 098, 2026-09-11 — reverses "closure is the foreman's action"; contractor/inspector request instead, below). Always 403 CLOSURE_REASON_REQUIRED without reason. 403 FIRE_WATCH_NOT_ELAPSED unchanged. ENTRANTS_STILL_INSIDE is retired — a Confined Space close with entrants still checked in now succeeds, auto-checking each out with 'system' provenance |
| POST | /:id/close-request | ✅ | wayfinder 098 — contractor (own permit) or inspector (any) raises a request; does not close. 403 PERMIT_NOT_ACTIVE outside ACTIVE/FIRE_MONITOR |
| GET | /:id/qr | * | { token }. 403 PERMIT_NOT_ACTIVE until approved |
| GET | /qr/:token | public | Live status projection: {id,type,title,location,status,entrantCount,fireWatch,latestSafetyReading}. Rate-limited 30 req/60 s → 429 RATE_LIMITED |
| GET | /:id/entrants | * | [{workerId, workerName, checkedInAt}] — who is currently inside. workerName is echoed for display; identity is workerId |
| GET | /:id/gas-log | * | { data: entries, overdue: boolean } — note overdue sits beside data |
| POST | /:id/gas-log | ⛔ | inspector |
| POST | /:id/entrants/scan | ⛔ | inspector |
| GET | /:id/audit | * | Hash-chained audit rows for your permit |
| POST | /:id/entrants/scan | ⛔ | inspector — body {workerId, direction, offlineClientId?, source?}. The worker's QR card encodes the id |
| POST | /:id/inspector-visits | ⛔ | inspector — starts one visit at scan time. Changes no permit field |
| POST | /:id/inspector-visits/:visitId/submit | ⛔ | inspector — fills the visit once with {ppeChecklist?, notes?, photos?}. No PATCH route exists |
| GET | /:id/inspector-visits | ⛔ | inspector and safety_officer only. Whether a contractor may read these is an open question (wayfinder 083) |
Certificates — /api/v1/certificates
| Method | Path | Contractor | Notes |
|---|---|---|---|
| GET | / | * | Paginated, scoped to your own. Rows carry a computed expired — do not recompute it |
| POST | / | ✅ | {workerId, certType, issuedDate, expiryDate, filePath?}. workerName and role were dropped — the worker is a row now, and role only ever copied the person's own role onto every card |
| PATCH | /:id | ✅ | Same body, all optional |
| GET | /worker/:workerId | * | That worker's certificates. Keyed by id, not by name |
Workers — /api/v1/workers
A worker is an entity as of 2026-09-09. workerName exists on no table: Certificate, PermitWorker and EntrantEvent all carry workerId NOT NULL.
| Method | Path | Contractor | Notes |
|---|---|---|---|
| GET | / | * | Paginated. Contractors read their own; officers and inspectors read all |
| GET | /:id | * | One worker |
| POST | / | ✅ | {name, role, idCardNo?, phone?}. 409 WORKER_ALREADY_EXISTS carries the existing workerId — an inline "create worker" should select that worker, not strand the user on an error they cannot act on |
| PATCH | /:id | ✅ | Your own workers only |
| DELETE | /:id | ✅ | Soft delete (deletedAt). A worker on a permit is never hard-deleted |
Uniqueness is per contractor, case- and whitespace-insensitive, enforced on a derived nameKey. Clients never send nameKey. The worker's QR card encodes the workerId, which is what the entrant scan reads.
Pins — /api/v1/pins
Area (and its grants, approve/reject flow) is deleted — wayfinder 106/121/122. A Pin is a named position on a FacilityPlan, placed and named by safety; the contractor selects one instead of proposing their own (wayfinder 104/105). Verified against smart-work-permit-api/src/modules/pin/pin.module.ts and its controllers.
| Method | Path | Contractor | Notes |
|---|---|---|---|
| GET | / | * | Every role may read (wayfinder 104 ruling 19 — the per-contractor scoping Area had is explicitly not carried forward). ?planId= filters to one plan; ?active=true is the contractor's picker (pin and its plan both active); ?active=false is the safety management view of retired pins; omit for everything |
| GET | /:id | * | One pin by id, active or not — any row referencing a pin must always resolve it |
| POST | / | ⛔ | safety_officer. {planId, name, x, y} — x/y are percentages of the plan image frame. Position is frozen from here on; no write path for x/y past create |
| PATCH | /:id | ⛔ | safety_officer. {name} only — the one editable field a pin has past create |
| POST | /:id/deactivate | ⛔ | safety_officer. Retires the pin without deleting it; still resolves by id for anything that references it. Idempotent |
Facility plans — /api/v1/facility-plans
Plans are a flat, independently-active named set with immutable images (wayfinder 104) — not a version chain. GET .../active (singular) is gone; there is no "the active plan" concept once several can be active at once (see the "Everything else" table below).
| Method | Path | Contractor | Notes |
|---|---|---|---|
| GET | / | * | Every role may read. ?active=true/?active=false filter; omit for everything |
| GET | /:id | * | One plan version by id, active or not — a permit frozen against an old version must always resolve it |
| POST | /upload | ⛔ | safety_officer. Multipart image upload for a new plan version → {filePath, …}, passed to POST / |
| POST | / | ⛔ | safety_officer. {name, fileRef} — registers a new, inactive plan from an already-uploaded image |
| POST | /:id/activate | ⛔ | safety_officer. Makes this version active; the frame is frozen from here on. Plans are independently-active — activating one never deactivates another |
| POST | /:id/deactivate | ⛔ | safety_officer. Retires a named plan without deleting it; every pin on it and every permit frozen against it keeps resolving it by id. Idempotent |
A permit's pin is optional at submit unless the deployment has an active plan with at least one active pin on it — see PERMIT_POSITION_REQUIRED in §5. There is no AREA_REQUIRED-style opt-in flag; this gate is implicit-by-data (src/modules/permit/commands/submit/submit.service.ts).
Everything else
| Method | Path | Contractor | Notes |
|---|---|---|---|
| GET | /api/v1/notifications | * | limit only (default 50), no pagination envelope, unread first |
| POST | /api/v1/notifications/:id/dismiss | * | id is numeric |
| POST | /api/v1/upload | * | multipart file, optional subFolder → {fileUrl, filePath, fileType, originalName} |
| GET/DELETE | /api/v1/file?filePath= | * | signed URL / delete |
| GET | /api/v1/audit | ⛔ | safety_officer — facility-wide log |
| GET | /api/v1/dashboard/summary | ⛔ | safety_officer |
| POST | /api/v1/sync/batch | ⛔ | inspector — offline replay |
| POST | /api/v1/users | ⛔ | safety_officer — account provisioning |
4. The permit wizard against PATCH /api/v1/permits/:id
One endpoint backs every step. All fields optional; send only what the step changed.
{
"title": "…", "description": "…", "foreman": "…",
"location": "…", // free-text note; nothing queries it (wayfinder 036/106)
// The work window: a calendar range plus ONE daily window repeating across it
"startDate": "2026-08-18",
"endDate": "2026-08-20",
"dailyStart": "08:00", // clock time, NO date part
"dailyEnd": "17:00",
"scheduleNote": "not working Sat/Sun",
"outdoorWork": false,
// wayfinder 097 — full replace on every PATCH that includes the key
"ppeDeclared": ["helmet", "harness"],
"ppeNote": "…",
// GAPS.md row J — contractor create-wizard step 3 "Safety Checks", Yes/No/N-A per item
// (13-17 items depending on permit type, keyed like `hot-1`..`hot-17`). Pre-work reference
// data, NOT a safety gate — no server-side validation of item membership/completeness, no
// submit/approve gate, no audit-log row. Whole-array replace on every PATCH that includes the
// key (same convention as jsaSteps/workers above, not photos' per-slot upsert — there is no
// "slot" in a single JSON column). Omit to leave unchanged, send `null` to clear. Distinct
// from `closureChecklist` below — that one is a different, close-time-only checklist.
"preWorkChecklist": [{ "itemKey": "hot-1", "answer": "yes" }, { "itemKey": "hot-2", "answer": "na" }],
// Where. wayfinder 105/106: this ONE field REPLACES `areaId`, `position` (`{planId, planX,
// planY}`) and the geo coordinate (`mapUrl`/`latitude`/`longitude` — wayfinder 068, reversed
// the day after it shipped; those fields no longer exist on the wire in either direction).
// The pin is placed and named by safety (wayfinder 104); the contractor only selects one.
// Omit to leave unchanged, send `null` to clear, send an id to set/replace.
"pinId": 12,
// REPLACED WHOLESALE — send the full list every time, not a delta
"jsaSteps": [{ "phase": "pre", "step": "…", "hazard": "…", "control": "…", "sortOrder": 0 }],
"workers": [{ "workerId": 42, "roleOnPermit": "…", "bloodPressure": "…", "alcoholReading": "…" }],
// APPENDS a new reading row; the permit exposes only the latest one back
"safetyReading": { "lel": 0, "o2": 20.9, "co": 3, "wind": 12, "height": 8 },
// UPSERT per slotKey. fileRef is the `filePath` returned by POST /upload
"photos": [{ "slotKey": "before", "fileRef": "permits/abc.jpg", "originalName": "…", "fileType": "image/jpeg" }]
}Four things about this endpoint that a reader gets wrong:
workers[]sendsworkerId, never a name. Register the worker first (POST /workers, which answers409 WORKER_ALREADY_EXISTSwith the existing id when they are already on file).pinIdaccepts only an id that resolves (prisma.pin.findFirst, existence only — notactive; a client can still detect a since-deactivated pin itself). A bad id is a plain400 Bad Requestwith noerrorCode— same convention as every other request-validation 400 (src/modules/permit/commands/create/create.service.ts,src/modules/permit/commands/update/update.service.ts).PATCH {}is refused with400 PERMIT_UPDATE_EMPTY. Presence of a key is what counts, not its value:jsaSteps: []andpinId: nullare real edits (a deliberate clear).403 PERMIT_NOT_EDITABLEinACTIVE,FIRE_MONITOR,CLOSEDandEXPIRED.DRAFTandREJECTEDedit normally.PENDINGis accepted as a withdrawal: the same transaction drops the permit back toDRAFT, audits it and re-notifies the officers. Warn the user before the edit begins — it leaves the review queue and must be resubmitted.
The permit detail response shape (what you render):
{
"id": "WP-HOT-20260817-001", "type": "hot", "status": "DRAFT",
"title": "…", "description": "…", "location": "…", "foreman": "…",
"startDate": "…", "endDate": "…", "dailyStart": "…", "dailyEnd": "…",
"scheduleNote": "…", "outdoorWork": false,
"ppeDeclared": ["helmet"], "ppeNote": "…",
"preWorkChecklist": [{ "itemKey": "hot-1", "answer": "yes" }] | null,
// GAPS.md row J — contractor step-3 answers, read-only for the
// Safety/Inspector app. Distinct from closureChecklist below.
"createdById": "…", "createdBy": { "id", "email", "firstName", "lastName" } | null,
"createdAt": "…", "updatedAt": "…",
"submittedAt": null, "approvedById": null, "approvedBy": null, "approvedAt": null,
"rejectedReason": null, "rejectedAt": null,
"closedById": null, "closedBy": null, "closedAt": null, "closureChecklist": {…},
"closeRequestedAt": null, "closeRequestedById": null, "closeRequestedBy": null,
"closeRequestedRole": null, "closeRequestReason": null,
"fireMonitorStartedAt": null, "qrIssuedAt": null,
"pinId": 12, // wayfinder 105 — REPLACES planId/planX/planY/areaId/latitude/longitude.
// null means "unplaced" (pre-105 permits were not migrated, ruling 10)
"entrantCount": 0,
"fireWatch": { … } | null,
"gasReadingStatus": { "dueAt", "overdue", "graceEndsAt", "escalated" } | null,
// NULL unless Confined Space in ACTIVE/FIRE_MONITOR
"overlappingPermits": { "checked": false, "permits": [] },
// advisory ONLY; `checked:false` means "no pinId, nothing compared"
// (wayfinder 105 re-keyed this from `areaId` to `pinId`)
"jsaSteps": [ … ], // FLAT, each with a `phase` — group client-side
"workers": [ … ], // `workerId` + the worker's `name`/`role` echoed for display
"photos": [ … ],
"latestSafetyReading": { … } | null, // singular, and named this — not `safetyReading`
"validationSummary": { "scope": "safety_readings", "passed", "failures": [ … ] }
}gasReadingStatus is the server's verdict on a 2-hour re-test interval with a 30-minute grace. Render it; never recompute the threshold. overlappingPermits never gates anything — checked: false and checked: true, permits: [] must not share one reassuring empty state.
Author fields (createdBy, approvedBy, closedBy) are objects or null, never name strings.
5. Errors — map the code, never render the message
Complete errorCode vocabulary:
GAS_OUT_OF_RANGE LEL_MISSING O2_MISSING CO_MISSING WIND_MISSING WIND_OUT_OF_RANGE
CERT_EXPIRED CERT_MISSING ENTRANTS_STILL_INSIDE FIRE_WATCH_NOT_ELAPSED
PERMIT_NOT_ACTIVE PERMIT_NOT_PENDING PERMIT_NOT_EDITABLE PERMIT_NOT_SUBMITTABLE
PERMIT_NOT_CLOSABLE PERMIT_UPDATE_EMPTY PERMIT_POSITION_REQUIRED CLOSURE_REASON_REQUIRED
NOT_HOT_WORK INVALID_QR_TOKEN RATE_LIMITED USER_ALREADY_EXISTS WORKER_ALREADY_EXISTS
ACCOUNT_DEACTIVATED LAST_SAFETY_OFFICER
FILE_TYPE_NOT_ALLOWED FILE_TOO_LARGE UPLOAD_FOLDER_NOT_ALLOWED STORAGE_UNAVAILABLE
UNAUTHENTICATED FORBIDDEN_ROLEAREA_NOT_APPROVED, AREA_NOT_PENDING and AREA_REQUIRED are gone along with Area itself (wayfinder 106) — a bad or missing pinId is a plain, code-less 400 (see §4 above), never one of these. PERMIT_POSITION_REQUIRED (already listed above) is the one pin-shaped code that survives, unchanged in spelling, now gated on a pin existing rather than an approved area.
The authoritative list is CONTEXT.md §2, and scripts/check-contract-sync.mjs fails if the backend emits a code either frontend cannot localize.
404s, ownership 403s and request-validation 400s carry no errorCode — its absence is normal. Map what you get to localized EN/TH copy and fall back to a generic string; the backend's message is English developer text and must never reach a user.
The codes the contractor app will actually hit, and where:
| Flow | Codes |
|---|---|
POST /:id/submit | LEL_MISSING O2_MISSING CO_MISSING WIND_MISSING GAS_OUT_OF_RANGE WIND_OUT_OF_RANGE CERT_MISSING CERT_EXPIRED (HTTP 400, message joins every failure with ; ), PERMIT_NOT_SUBMITTABLE (403) |
PATCH /:id | PERMIT_NOT_EDITABLE, PERMIT_UPDATE_EMPTY |
POST /:id/mark-complete | NOT_HOT_WORK, PERMIT_NOT_ACTIVE |
GET /:id/qr | PERMIT_NOT_ACTIVE |
| any guarded route | UNAUTHENTICATED (401), FORBIDDEN_ROLE (403) |
Which readings are required depends on the permit type (server-enforced at submit): lel for hot/confined when outdoorWork is false, o2 for hot/confined, co for confined, wind for heights. Thresholds are LEL 0%, O₂ 19.5–23.5%, CO ≤50 ppm, wind ≤25 km/h; fire watch 30 min; gas re-test 120 min with a 30-min grace; cert warning 30 days. Every one of these is server-owned — the gas interval is exposed on the permit payload as gasReadingStatus precisely so no client has to hold a second copy of it.
Flag-gated. CERT_TYPE_REQUIRED is off by default. Unset, any unexpired certificate satisfies any permit type. Set, a permit type requires its matching certType (hot → Hot Work, confined → Confined Space Entry, heights → Working at Heights) at both the submit check and the entrant scan. Gas Testing is a legal certificate type that satisfies no permit.
Validation is server-authoritative. Show the server's verdict. Client-side range hints are fine as UI affordance, but the submit result is the truth.
6. Integration recipe
This repo's HttpRequest.ts / Interceptors.ts came from the same template as the safety/inspector app, so it has the same four mismatches. The equivalent changes there are done and green; mirror them.
1. Put the prefix in the baseURL, not in each provider.
export const API_PREFIX = '/api/v1'
this.url = `${url ?? import.meta.env.VITE_APP_API_URL ?? ''}${API_PREFIX}`Then a provider's urlPrefix is just /permits, /certificates, … Strip any hardcoded /api/v1.
2. Unwrap the envelope once, in onResponse.
const body: any = response.data
const isEnvelope = typeof body === 'object' && !Array.isArray(body)
&& body.message === 'success' && 'data' in body
if (isEnvelope) {
const rest = { ...body }
delete rest.message
// Siblings (pagination's count/page/limit/totalPage, the gas log's `overdue`) must survive —
// unwrapping straight to `data` silently drops the field the screen exists to show.
return Promise.resolve(Object.keys(rest).length > 1 ? rest : rest.data)
}
return Promise.resolve(body) // login's { success, data }, blobs, xlsxProviders then type the payload directly (Promise<IPermitDetail>), and list providers type Promise<IBasePaginationResponse<T>> = { data, count, page, limit, totalPage }.
3. Delete the humps conversion, both directions. The API is camelCase. Camelizing responses is not merely redundant — it rewrites the keys inside closureChecklist and any free-form payload object, corrupting user data. (The request-side decamelizeKeys is commented out in this repo today; delete it rather than ever re-enabling it — it would rename scheduleNote to schedule_note and break every write.)
4. Error handling. Reject with the raw {code, message, errorCode}; key your i18n off errorCode. 401 still means "log out and go to login" — the session cookie is gone or expired.
Smoke check. The safety/inspector repo ships scripts/smoke-api.mjs, which logs in against a running API and asserts these shapes (envelope, pagination, error bodies, permit detail keys). It skips cleanly when no API is reachable. Copying it is cheaper than discovering a shape drift in a page test.
7. Known gaps
- The permit list row carries no entrant count and no fire-watch remainder — only
GET /permits/qr/:tokenand the permit detail reportentrantCountandfireWatch. If a list screen needs those, it needs a backend change, not a client workaround. Whether a contractor may read inspector visits is undecided (wayfinder 083)— resolved.GET /permits/:id/inspector-visitsnow opens to the contractor on their own permit, notes included (wayfinder 119);inspector/safety_officerstill read any permit's (src/modules/permit/queries/inspector-visit-list/inspector-visit-list.http.controller.ts).How a plan version is retired is undecided (wayfinder 084)— resolved. Plans are a flat, independently-active named set, not a version chain (wayfinder 104);POST /facility-plans/:id/deactivateretires one without deleting it, idempotently. See the Facility plans table in §3.GET /notificationshas no pagination —limitonly.- Notification ids are numeric; permit ids are strings (
WP-…); certificate ids are numeric. - The audit log is hash-chained (
hash,prevHash) and append-only; there is no mutation endpoint.