You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(operations): enforced operation lifecycle with plan versus actual
Phase 3. Operations move through a transition table rather than a status
field anyone can set, the schedule shows plan against actual on one axis,
and every change leaves an event behind.
A concurrency test changed the implementation. Codes (OP-2026-0042) were
allocated by reading the highest existing code and retrying on the unique
constraint. That failed at ten parallel creates, and not marginally: each
retry round lets exactly one caller through, so the worst case needs as
many attempts as there are callers — it breaks precisely when the product
is busy. Replaced with an OperationCounter row per (organization, year)
incremented in a single upsert, which serialises only the allocation.
Different organizations, and different years, never contend. The test now
runs twenty concurrent creates and asserts twenty contiguous codes.
While writing that I nearly introduced a tenant bug: the first version
asked the scoped client for "its" organization, except Organization has no
organizationId, so the extension does not filter it and the query would
happily return somebody else's row. Raw SQL bypasses the tenant filter —
that is the point of the escape hatch and also its danger — so the
organization is now passed in from the context.
The lifecycle refuses what an operations room refuses: no jumping to
Completed without having been In Progress (a completed job with no actual
start is a hole in the record), no cancelling work already under way (it is
suspended first, then decided), and nothing reopens from a terminal status.
Suspending requires a reason, because a suspension with no reason is the
history entry someone needs next week and will not find.
The detail page reads its buttons from the same transition table the server
enforces, so the interface cannot offer a move the action will refuse.
Schedule overlap uses a half-open comparison: operations that hand over at
18:00 are normal, and treating a shared boundary as a conflict would make
the check cry wolf on every well-planned schedule. Its limitation is
written down rather than implied away — the check runs inside the writing
transaction, but default isolation still allows two simultaneous inserts to
each see a clear schedule.
118 tests. Verified against the database: an operation walks Planned →
Preparing → Ready → In Progress → Completed with the actual start stamped
once and the end on completion, four events recorded, and Completed →
Planned refused with a message that says what to do instead.
Copy file name to clipboardExpand all lines: docs/DECISIONS.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -93,5 +93,6 @@ or from meaning "next week, out of curiosity".
93
93
| --- | --- |
94
94
| 2026-07-26 | Phase 0 closed: ADRs 001–007 accepted, reference schema and domain rules specified, roadmap fixed. Project started from scratch — no code or design carried over from any previous attempt. |
95
95
| 2026-07-26 |**Phase 1 delivered.** Three decisions were revised against reality, each recorded in the table above with its evidence: pnpm → npm (corepack `EPERM`), TypeScript 7 → 5.9 (lint toolchain), PostgreSQL image pinned exactly (a floating tag restarted the container against a data directory the newer server refused to open). Two design points survived contact with the code and are worth noting: Better Auth stores the credential on `Account`, not `User`, so `User.passwordHash` was dropped; and `Membership` had to join `TENANT_MODELS` — the registry test caught that omission, which would have let one organization list another's members. |
96
+
| 2026-07-26 |**Phase 3 delivered.** One decision was forced by a failing test: operation codes were allocated by reading the highest code and retrying on conflict, which **fails at ten concurrent creates** because each retry round only lets one caller through. Replaced by an `OperationCounter` row per (organization, year) incremented in one upsert — the narrowest lock that solves it, since different organizations and different years never contend. Two smaller ones: the lifecycle lives in an explicit transition table that the UI reads to decide which buttons to show, so the interface cannot offer a move the server refuses; and schedule overlap uses a **half-open** comparison, because back-to-back operations handing over at 18:00 are normal and flagging them would make the check cry wolf on every well-planned schedule. |
96
97
| 2026-07-26 |**Phase 2 delivered.** Three decisions worth recording. The position-recording rule from DATABASE.md §7 was wrong as specified ("50 m **or** 60 s" stores everything, since the 60 s branch is always true); it is now 50 m of movement or a 15-minute heartbeat. AIS tracking eligibility lives in the **domain**, not the provider — a provider that knows a vessel is alongside is a provider that has grown business logic, and walking an FPSO across the basin is the detail that tells a domain reader nobody checked. And the one legitimate cross-tenant read (iterating organizations for the scheduled sync) is a **named function in `lib/db/system.ts`** rather than an ESLint exception for the cron directory: "this case is special" per feature directory is how a tenant boundary erodes. |
97
98
| 2026-07-26 | Reference schema validated with `prisma@7.9.0 validate`. Two consequences worth recording: Prisma 7 **removed `url` from the `datasource` block**, so the connection string moves to `prisma.config.ts` (Migrate) and to a **driver adapter** (`@prisma/adapter-pg` + `pg`) passed to `PrismaClient`; and the schema is verified to parse rather than assumed to. Documenting a schema that does not compile would be the same failure this project criticises elsewhere. |
0 commit comments