Skip to content

Commit 52b26a0

Browse files
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.
1 parent c13861d commit 52b26a0

31 files changed

Lines changed: 2908 additions & 23 deletions

File tree

README.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ down and testable.
1010

1111
## Current state — read this first
1212

13-
**Phases 0 (Architecture), 1 (Foundation) and 2 (Fleet Command) are complete.** You can run the
14-
application, sign in as any of four roles, and watch the fleet on a chart with simulated AIS
15-
positions. Operations, weather, risk, alerts, assets and incidents are **not built yet** — phases 3
16-
to 8.
13+
**Phases 0 to 3 are complete.** You can run the application, sign in as any of four roles, watch the
14+
fleet on a chart with simulated AIS, and move operations through an enforced lifecycle with
15+
plan-versus-actual and an activity feed. Weather, risk, alerts, assets and incidents are **not built
16+
yet** — phases 4 to 8.
1717

1818
| Capability | Status |
1919
| --- | --- |
2020
| Architecture, domain model, decision rules, threat model |[docs/](docs/) |
21-
| PostgreSQL schema — 24 tables, 2 migrations, CHECK constraints, partial unique index ||
21+
| PostgreSQL schema — 25 tables, 3 migrations, CHECK constraints, partial unique index ||
2222
| Authentication — e-mail/password, Argon2id, database sessions, rate limiting ||
2323
| RBAC — 4 roles, 36 permissions, one matrix, checked server-side ||
2424
| Multi-tenancy — `TenantContext` required by every data access ||
@@ -27,7 +27,9 @@ to 8.
2727
| Deterministic idempotent seed — 2 organizations, 4 roles, 8 vessels, 6 locations ||
2828
| **Fleet Command** — chart, vessel list, side panel, vessel detail with tabs ||
2929
| **Simulated AIS** — deterministic provider, position history, scheduled refresh ||
30-
| Operations, weather, risk, alerts, assets, incidents, analytics | 🔜 Phases 3–8 |
30+
| **Operations Center** — enforced lifecycle, plan vs. actual timeline, activity feed ||
31+
| **Vessel double-booking refused** with the conflicting operation named ||
32+
| Weather, risk, alerts, assets, incidents, analytics | 🔜 Phases 4–8 |
3133
| Ocean AI | 🔜 Phase 9 |
3234
| E2E tests, metrics, deployment | 🔜 Phase 10 |
3335

@@ -40,12 +42,17 @@ light, because three of the four inputs to that score do not exist before phase
4042
```text
4143
lint ✓ no errors
4244
typecheck ✓ no errors
43-
test ✓ 78 passed (10 files)
44-
build ✓ 9 routes
45+
test ✓ 118 passed (13 files)
46+
build ✓ 11 routes
4547
```
4648

47-
Against a real PostgreSQL 17: both migrations apply to an empty database, all 10 CHECK constraints
48-
and the partial unique index exist, and the seed is idempotent.
49+
Against a real PostgreSQL 17: all three migrations apply to an empty database, all 10 CHECK
50+
constraints and the partial unique index exist, and the seed is idempotent.
51+
52+
An operation walks Planned → Preparing → Ready → In Progress → Completed with the actual start
53+
stamped once and the actual end on completion, four events recorded, and Completed → Planned refused
54+
with *"Completed is a final status."* Twenty operations created concurrently receive twenty
55+
contiguous codes.
4956

5057
All four seeded roles sign in and receive a shell that matches their permissions — a Viewer sees 10
5158
permissions and no Administration module, an Operator 17 with `alert:acknowledge` but not
@@ -212,7 +219,7 @@ deletable.
212219

213220
## Roadmap
214221

215-
Phase 0 Architecture ✅ · 1 Foundation ✅ · 2 Fleet Command ✅ · 3 Operations · 4 Environmental
222+
Phase 0 Architecture ✅ · 1 Foundation ✅ · 2 Fleet Command ✅ · 3 Operations · 4 Environmental
216223
Intelligence · 5 Risk & Alerts · 6 Asset Monitoring · 7 Incidents · 8 Analytics & Command Center ·
217224
9 Ocean AI · 10 Production Readiness.
218225

docs/DATABASE.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,16 @@ The registry in `src/lib/db/tenant.ts` is checked against this schema by
136136
`tests/unit/tenant-models.test.ts`, which parses the file and fails if a model with an
137137
`organizationId` column is not registered for scoping. That test found `Membership` missing.
138138

139-
### 4.3 Denormalised last position
139+
### 4.3 OperationCounter
140+
141+
Human-readable codes (`OP-2026-0042`) are allocated from one row per (organization, year),
142+
incremented by a single upsert. The obvious alternative — read `MAX(code)`, increment, retry on
143+
conflict — was implemented first and **failed a ten-way concurrency test**: every retry round only
144+
lets one caller through, so the worst case needs as many attempts as there are callers, and it breaks
145+
exactly when the product is busy. Sequences may show gaps when a transaction takes a code and rolls
146+
back; a gap is much cheaper than a duplicate.
147+
148+
### 4.4 Denormalised last position
140149

141150
`Vessel` carries `lastLatitude`/`lastLongitude`/`lastPositionAt`/`lastPositionSource` alongside the
142151
`VesselPosition` history. The fleet map reads every vessel's current position on each render, and

docs/DECISIONS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,5 +93,6 @@ or from meaning "next week, out of curiosity".
9393
| --- | --- |
9494
| 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. |
9595
| 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. |
9697
| 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. |
9798
| 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. |

docs/ROADMAP.md

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
Legend: ✅ Implemented · 🚧 In Development · 🔜 Planned
44

5-
**Current state: Phases 0, 1 and 2 complete.** The application runs, four roles sign in, every
6-
mutation is audited, and the fleet is visible on a chart with simulated AIS. Operations start at
7-
phase 3.
5+
**Current state: Phases 0 to 3 complete.** The application runs, four roles sign in, every mutation
6+
is audited, the fleet is on a chart with simulated AIS, and operations move through an enforced
7+
lifecycle with plan-versus-actual and an activity feed. Weather starts at phase 4.
88

99
---
1010

@@ -149,7 +149,7 @@ rather than a passed one.
149149

150150
---
151151

152-
## Phase 3 — Operations Center 🔜
152+
## Phase 3 — Operations Center
153153

154154
*"What is happening with my operations?"*
155155

@@ -162,8 +162,45 @@ rather than a passed one.
162162
* Global activity feed reading `OperationEvent` (the first consumer of the events table).
163163
* Tests: every legal and illegal transition, code generation under concurrency.
164164

165-
**Acceptance:** an operator moves an operation Planned → Preparing → In Progress → Completed,
166-
each step timestamped, attributed and visible in the feed; Completed → Planned is refused.
165+
**Acceptance — met, exercised against the database on 2026-07-26:**
166+
167+
```text
168+
initial: PLANNED actualStart=null
169+
→ PREPARING actualStart=null actualEnd=null
170+
→ READY actualStart=null actualEnd=null
171+
→ IN_PROGRESS actualStart=21:38 actualEnd=null
172+
→ COMPLETED actualStart=21:38 actualEnd=21:38
173+
COMPLETED → PLANNED refused: "Completed is a final status. Create a new
174+
operation instead of reopening this one."
175+
4 events recorded
176+
```
177+
178+
* `/operations` renders the 20 seeded operations, reports 4 under way and 4 delayed, and draws
179+
plan-versus-actual bars against a now line.
180+
* The detail page for an operation that is `IN_PROGRESS` offers exactly **Suspended** and
181+
**Completed** — the buttons come from the same transition table the server enforces, so the UI
182+
cannot offer a move the action refuses.
183+
* The vessel's Operations tab lists that vessel's three operations; the activity feed appears on
184+
both the operations page and the Command Center.
185+
* 118 tests, including every legal and illegal transition, the timestamp rules, half-open window
186+
comparison, and a tenant-isolation suite for the operations queries.
187+
188+
**The test that changed the implementation.** Codes were allocated by reading the highest existing
189+
code and retrying on conflict. With ten concurrent creates that **failed**: each retry round only
190+
lets one caller through, so the worst case needs as many attempts as there are callers — it breaks
191+
exactly when the product is busy. Replaced by an `OperationCounter` row per (organization, year)
192+
incremented in a single upsert, which serialises only the allocation. The test now runs **twenty**
193+
concurrent creates and gets twenty contiguous codes.
194+
195+
**Known limitation, stated rather than implied.** The double-booking check runs inside the writing
196+
transaction, but PostgreSQL's default isolation still lets two concurrent transactions each read a
197+
clear schedule and both commit. Closing that needs an exclusion constraint over a time range; until
198+
then the check catches the case that actually happens (a person planning against a schedule they can
199+
see) and not a simultaneous double insert.
200+
201+
**Deferred:** create and edit forms for operations. The actions, validation, scheduling rules and
202+
audit exist and are tested; what is missing is a planning screen, and it belongs with the admin
203+
module rather than bolted onto a read-only view.
167204

168205
---
169206

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- CreateTable
2+
CREATE TABLE "OperationCounter" (
3+
"organizationId" TEXT NOT NULL,
4+
"year" INTEGER NOT NULL,
5+
"lastSequence" INTEGER NOT NULL DEFAULT 0,
6+
7+
CONSTRAINT "OperationCounter_pkey" PRIMARY KEY ("organizationId","year")
8+
);

prisma/schema.prisma

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,24 @@ enum Role {
135135
VIEWER
136136
}
137137

138+
/// Atomic allocation of human-readable operation codes.
139+
///
140+
/// Reading MAX(code) and retrying on conflict does not survive concurrency: with
141+
/// N simultaneous callers the worst case needs N attempts, so it fails exactly
142+
/// when the product is busy. One row per (organization, year), incremented with a
143+
/// single upsert, serialises only the allocation — which is the narrowest lock
144+
/// that solves it.
145+
///
146+
/// Sequences may show gaps when a transaction allocates a code and then rolls
147+
/// back. That is normal for a sequence and preferable to a duplicate.
148+
model OperationCounter {
149+
organizationId String
150+
year Int
151+
lastSequence Int @default(0)
152+
153+
@@id([organizationId, year])
154+
}
155+
138156
// ─── Fleet ─────────────────────────────────────────────────────────────────
139157

140158
model Vessel {

prisma/seed/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { PrismaClient, Role } from '@prisma/client'
55
import { hash, type Algorithm } from '@node-rs/argon2'
66

77
import { seedFleet } from './fleet'
8+
import { seedOperations } from './operations'
89

910
/**
1011
* Deterministic demo data.
@@ -126,6 +127,7 @@ async function main() {
126127
)
127128

128129
const fleet = await seedFleet(prisma, demo.id)
130+
const operations = await seedOperations(prisma, demo.id)
129131

130132
const [organizations, users, memberships] = await Promise.all([
131133
prisma.organization.count(),
@@ -135,7 +137,7 @@ async function main() {
135137

136138
console.log(
137139
`Seed complete — ${organizations} organizations, ${users} users, ${memberships} memberships, ` +
138-
`${fleet.vessels} vessels, ${fleet.locations} locations.`,
140+
`${fleet.vessels} vessels, ${fleet.locations} locations, ${operations.operations} operations.`,
139141
)
140142
console.log('All seeded records are DEMO data. Sign in with the credentials in the README.')
141143
}

0 commit comments

Comments
 (0)