Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
950b844
Centralize all backend environment access into a single typed, valida…
aaronstevenwhite Jun 17, 2026
3cc1193
Merge pull request #145 from parafovea/refactor/phase-1a-backend-config
aaronstevenwhite Jun 17, 2026
360f1e8
Add a create-release job to release.yml that extracts the matching CH…
aaronstevenwhite Jun 17, 2026
fac87bb
Merge pull request #146 from parafovea/fix/release-workflow-auto-gith…
aaronstevenwhite Jun 17, 2026
d0c633b
Centralize all frontend environment access into a single typed, deep-…
aaronstevenwhite Jun 17, 2026
3bc662d
Merge pull request #147 from parafovea/refactor/phase-1b-frontend-config
aaronstevenwhite Jun 17, 2026
10038a1
Promote the model-service to a single typed pydantic-settings Setting…
aaronstevenwhite Jun 17, 2026
df2c545
Merge pull request #148 from parafovea/refactor/phase-1c-model-servic…
aaronstevenwhite Jun 17, 2026
075576c
Add a CI configuration-drift guard (scripts/check-env-example.mjs, wi…
aaronstevenwhite Jun 17, 2026
fbb6cd3
Merge pull request #149 from parafovea/refactor/phase-1d-config-drift…
aaronstevenwhite Jun 17, 2026
96a7454
Pin Node and pnpm in one place by adding a root .nvmrc (Node 22) and …
aaronstevenwhite Jun 17, 2026
7b27938
Merge pull request #150 from parafovea/refactor/phase-2a-node-pnpm-pin
aaronstevenwhite Jun 17, 2026
206314a
Add a verify-compose CI job that validates all ten docker compose con…
aaronstevenwhite Jun 17, 2026
909a288
Merge pull request #151 from parafovea/refactor/phase-2b-compose-vali…
aaronstevenwhite Jun 17, 2026
eb03c3a
Delete the duplicate server/docker-compose.dev.yml that redefined Pos…
aaronstevenwhite Jun 17, 2026
e6b19c8
Merge pull request #152 from parafovea/refactor/phase-2c-dev-infra-co…
aaronstevenwhite Jun 17, 2026
9b2541c
Consolidate the model-service's orphaned tests directory into the con…
aaronstevenwhite Jun 17, 2026
252963a
Merge pull request #153 from parafovea/refactor/phase-3a-consolidate-…
aaronstevenwhite Jun 17, 2026
7453b6e
Add a root Makefile as the single install/lint/typecheck/test/build e…
aaronstevenwhite Jun 17, 2026
1a11923
Merge pull request #154 from parafovea/refactor/phase-3b-unified-test…
aaronstevenwhite Jun 17, 2026
2994852
Reconcile CI documentation with reality by rewriting the stale workfl…
aaronstevenwhite Jun 17, 2026
dcf10da
Merge pull request #155 from parafovea/refactor/phase-3c-ci-reconcili…
aaronstevenwhite Jun 17, 2026
c7cfc3b
Stop the startup config validation from requiring API_KEY_ENCRYPTION_…
aaronstevenwhite Jun 18, 2026
3f194e0
Merge pull request #156 from parafovea/fix/api-key-encryption-startup…
aaronstevenwhite Jun 18, 2026
b09a655
Extract the personas domain into a PersonaRepository (pure data acces…
aaronstevenwhite Jun 18, 2026
25dddcc
Make the persistent test persona name unique per test (workerIndex pl…
aaronstevenwhite Jun 18, 2026
9f14854
Merge pull request #157 from parafovea/refactor/phase-4-1-personas-se…
aaronstevenwhite Jun 18, 2026
ca12444
Extract the projects domain into a ProjectRepository and ProjectServi…
aaronstevenwhite Jun 18, 2026
bb90695
Merge pull request #158 from parafovea/refactor/phase-4-2-projects-se…
aaronstevenwhite Jun 18, 2026
6950e35
Extract the claims domain into a ClaimRepository and ClaimService, re…
aaronstevenwhite Jun 18, 2026
19caa51
Merge pull request #159 from parafovea/refactor/phase-4-3-claims-serv…
aaronstevenwhite Jun 18, 2026
e77e2f9
Extract the world-state domain into a WorldStateRepository and WorldS…
aaronstevenwhite Jun 18, 2026
646e6d8
Merge pull request #160 from parafovea/refactor/phase-4-4-world-servi…
aaronstevenwhite Jun 18, 2026
2e1bee8
Split the 2232-line import-handler.ts import state machine into focus…
aaronstevenwhite Jun 18, 2026
aaf2a87
Merge pull request #161 from parafovea/refactor/phase-4-5-import-hand…
aaronstevenwhite Jun 18, 2026
293795c
Stand up the OpenAPI to frontend-types generation pipeline by emittin…
aaronstevenwhite Jun 18, 2026
ccac9ec
Merge pull request #162 from parafovea/refactor/phase-4-6a-openapi-fr…
aaronstevenwhite Jun 18, 2026
5e3750b
Decouple the model-service contract layer from the ML stack by removi…
aaronstevenwhite Jun 19, 2026
1d9832d
Merge pull request #163 from parafovea/refactor/phase-4-6b-model-serv…
aaronstevenwhite Jun 19, 2026
4ae665e
Add the server↔model-service contract pipeline by emitting a committe…
aaronstevenwhite Jun 19, 2026
bb3d97d
Merge pull request #164 from parafovea/refactor/phase-4-6b2-model-ser…
aaronstevenwhite Jun 19, 2026
8c76b87
Resolve cross-user object names server-side by adding an optional lin…
aaronstevenwhite Jun 19, 2026
e060f7b
Merge pull request #165 from parafovea/refactor/phase-4-6c-linked-obj…
aaronstevenwhite Jun 19, 2026
2ac946d
Make POST /api/annotations idempotent on an optional client-supplied …
aaronstevenwhite Jun 19, 2026
28cdd11
Merge pull request #166 from parafovea/refactor/phase-4-7-annotation-…
aaronstevenwhite Jun 19, 2026
7db24c2
Fix the annotation autosave idle loop and box duplication by strippin…
aaronstevenwhite Jun 19, 2026
aba44e8
Merge pull request #167 from parafovea/refactor/phase-4-8-annotation-…
aaronstevenwhite Jun 19, 2026
cc4c627
Split the model-service vlm and detection loader modules into one-cla…
aaronstevenwhite Jun 19, 2026
7b82384
Merge pull request #168 from parafovea/refactor/phase-4-9-model-servi…
aaronstevenwhite Jun 19, 2026
7b88130
Consolidate the scattered demo-mode RBAC read-widening into a single …
aaronstevenwhite Jun 19, 2026
39fbf23
Merge pull request #169 from parafovea/refactor/phase-4-10-demo-gatin…
aaronstevenwhite Jun 19, 2026
62c0315
Decompose the 1403-line AnnotationWorkspace component into three cohe…
aaronstevenwhite Jun 20, 2026
abb35c2
Merge pull request #170 from parafovea/refactor/phase-4-8b-annotation…
aaronstevenwhite Jun 20, 2026
34e1f4b
Finalize the 0.5.0 changelog date to the release date.
aaronstevenwhite Jun 20, 2026
b627ef2
Wrap the literal video tag in the useAnnotationState return-type JSDo…
aaronstevenwhite Jun 20, 2026
8f4c60d
Reword the import-module JSDoc and the ImportResultDialog summary so …
aaronstevenwhite Jun 20, 2026
2a3749d
fix: resolve a batch of field-reported bugs (0.5.1) (#173)
aaronstevenwhite Jun 22, 2026
212884f
Finalize the 0.5.1 changelog date to the 2026-06-22 release date in b…
aaronstevenwhite Jun 22, 2026
07ada4c
Merge main into release/0.5.x to reconcile the squash-flattened linea…
aaronstevenwhite Jun 22, 2026
bf61da5
Parse suffix and edge HTTP byte ranges per RFC 7233 in the video stre…
aaronstevenwhite Jun 22, 2026
75f95d0
Make clicking a claim card select it in place on the Claims tab
aaronstevenwhite Jun 24, 2026
ff1ac1b
Bump the version to 0.5.3 and record the claim-card selection fix in …
aaronstevenwhite Jun 24, 2026
2278ad3
fix: stamp project scope on summary and claim writes so collaborators…
aaronstevenwhite Jun 25, 2026
5ecf50a
Merge main into release/0.5.x to reconcile the squash-flattened linea…
aaronstevenwhite Jun 25, 2026
4b0e204
Merge main into release/0.5.x to reconcile the v0.5.4 squash-promote …
aaronstevenwhite Jun 27, 2026
51febf3
Fix ten audit-reported defects spanning group-delete ability-cache in…
aaronstevenwhite Jun 29, 2026
83ada36
Merge main into release/0.5.x to reconcile the v0.5.5 squash-promote …
aaronstevenwhite Jun 29, 2026
dd25365
Backend correctness and security hardening: authz/access-control fixe…
aaronstevenwhite Jun 30, 2026
4d60f3c
Merge main into release/0.5.x to reconcile the v0.5.6 squash-promote …
aaronstevenwhite Jun 30, 2026
dee0ea7
fix: frontend data-loss & cache hardening (v0.5.7) (#190)
aaronstevenwhite Jun 30, 2026
33a7601
Merge main into release/0.5.x to reconcile the v0.5.7 squash-promote …
aaronstevenwhite Jun 30, 2026
1e69a43
Harden the model-service: release VideoCapture handles on every exit …
aaronstevenwhite Jun 30, 2026
5f62f59
Merge main into release/0.5.x to reconcile the v0.5.8 squash-promote …
aaronstevenwhite Jun 30, 2026
c8c823f
Harden the backend against a second audit by requiring authentication…
aaronstevenwhite Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.9] - 2026-06-30

The 0.5.9 patch is the first of three releases remediating a second swarm audit of the shipped 0.5.x line — the backend slice — fixing authorization, lost-update concurrency, idempotency, and data-fidelity defects in the Fastify server ([#194](https://github.com/parafovea/fovea/pull/194)). Roughly a third of the findings were gaps in the first audit's own fixes: a fix addressed specific instances of a defect class without closing every instance. Nothing is breaking.

### Fixed

#### Authorization

- The `/api/models/*` proxy routes were unauthenticated: any caller could read the model configuration and, worse, drive the shared inference service — selecting, loading, or unloading a model affects every user — with no credentials. The routes now require authentication, and the state-changing select/load/unload operations additionally require admin (`server/src/app.ts`, `server/src/routes/models.ts`).
- A persona could be attached to a project by a non-member, since the create ability passed for any self-owned persona regardless of the target project. Project-scoped persona creation now verifies project membership and returns 403 otherwise (`server/src/services/persona-service.ts`).
- The import "replace" paths overwrote or deleted an existing annotation, persona, summary, claim, or claim-relation found by id with no instance-level check, so a crafted import could clobber another user's row. Each replace now requires update (or delete) on the existing row, mirroring the ontology-import guard (`server/src/services/import/entity-importers.ts`).

#### Concurrency — lost-update hardening

- The optimistic-concurrency guard on world state and ontologies compared on `updatedAt`, which two writes landing in the same millisecond may share — letting the second silently overwrite the first. Both tables now carry a monotonic `version` column, and every guarded write compares-and-swaps on it; guard exhaustion now returns 409 and a missing row 404 rather than a generic 500 (`server/prisma/migrations/20260630120000_add_optimistic_version_and_admin_apikey_uniqueness`, `server/src/repositories/{WorldStateRepository,ProjectRepository,PersonaRepository}.ts`, `server/src/services/world-state-service.ts`).
- Persona type-deletion and persona deletion wrote the annotation delete, the ontology gloss cleanup, and the world-state cleanup as separate unguarded statements, so a partial failure left them disagreeing and a concurrent edit could be clobbered. Each now runs in a single transaction with the ontology and world-state writes routed through the version guard, recomputing from a fresh read (`server/src/services/persona-service.ts`).
- The admin "clear world state" path wrote through a bare id update that could half-survive a concurrent write; it now clears through the version guard (`server/src/services/world-state-service.ts`).
- First-access creation of a project world state raced into the compound unique constraint and surfaced a 500; it now upserts the empty row and merges through the guard (`server/src/services/project-service.ts`).
- The claim-extraction worker re-saved claims on every run with no dedup, so a job retry or double-submit duplicated every claim for a summary. Re-extraction is now idempotent: the prior extracted claims are replaced inside one transaction, preserving manually authored claims (`server/src/queues/setup.ts`).

#### Idempotency and conflict status

- Several create and update paths surfaced a duplicate as a 500 rather than a 409: the self-service profile email/username update, project and group membership adds, and admin API keys. Each now maps the unique-constraint violation to a 409 (`server/src/routes/{users,groups}.ts`, `server/src/services/{project-service,api-key-service}.ts`).
- Admin API keys were unconstrained, since the compound `@@unique([userId, provider])` does not bind rows with a null userId in Postgres, so the advertised 409 never fired. A partial unique index now enforces one admin key per provider, deduping any existing duplicates first (`server/prisma/migrations/20260630120000_add_optimistic_version_and_admin_apikey_uniqueness`).
- A claim create that lost the same-id race re-threw the raw unique violation as a 500 when the existing row was not updatable; it now returns 403, mirroring the pre-create authorization check (`server/src/services/claim-service.ts`).
- A service-layer Zod validation failure — one that bypasses Fastify's schema validation — fell through to a generic 500; it now returns 400 with the field-level issues (`server/src/app.ts`).
- The ontology-save handler returned a bespoke 500 that leaked the raw error message to the client; it now re-throws to the global handler for a safe generic 500, keeping the detail in the logs (`server/src/routes/ontology.ts`).

#### Data fidelity

- Forking a shared video summary copied the summary body but dropped every extracted claim, leaving the fork's claim view empty. The fork now deep-copies the claim tree under fresh ids and carries the denormalized `claimsJson`, re-pointing every claim reference — row ids, parent links, and the references embedded in glosses — at the new ids (`server/src/routes/sharing.ts`).
- Cross-summary import conflict detection compared relation ids against the collection id set and read the world-state row with a narrower filter than the writer used, so it could miss or misreport conflicts. Both now key on the correct row and the relation id set (`server/src/services/import/conflict-resolver.ts`, `server/src/services/import-handler.ts`).
- A merge-keyframes import resolution on an existing annotation fell through to an unconditional create and collided on the primary key; the create is now guarded by an existence check (`server/src/services/import/entity-importers.ts`).

#### Demo and cache hygiene

- The idle-demo-user sweeper deleted users but never evicted their cached abilities, leaking memory as anonymous demo sessions churned; it now invalidates each swept user's ability cache (`server/src/demo/idle-reset.ts`).
- The anonymous-session mint endpoint had neither a per-route rate limit nor a population ceiling, so it could be driven to create users faster than the sweeper reclaims them; it now rate-limits per IP and refuses once a live-anonymous-user ceiling is reached (`server/src/demo/anonymous-session.ts`).
- Admin user creation accepted an independent `systemRole` that could diverge from `isAdmin` (CASL keys on the former, the admin middleware on the latter); the two are now coupled. Admin user deletion now invalidates the deleted user's cached abilities (`server/src/routes/users.ts`).

## [0.5.8] - 2026-06-30

The 0.5.8 patch is the third and final audit-driven hardening release — the model-service slice — fixing resource and concurrency defects in the Python inference service ([#192](https://github.com/parafovea/fovea/pull/192)). Nothing is breaking.
Expand Down
2 changes: 1 addition & 1 deletion annotation-tool/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@fovea/annotation-tool",
"private": true,
"version": "0.5.8",
"version": "0.5.9",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
16 changes: 16 additions & 0 deletions annotation-tool/src/api/generated/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,22 @@ export interface paths {
};
};
};
/** @description Default Response */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @description Machine-readable error code */
error: string;
/** @description Human-readable error message */
message: string;
/** @description Additional error context */
details?: unknown;
};
};
};
};
};
post?: never;
Expand Down
40 changes: 40 additions & 0 deletions docs/docs/project/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,46 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.9] - 2026-06-30

The 0.5.9 patch is the first of three releases remediating a second swarm audit of the shipped 0.5.x line — the backend slice — fixing authorization, lost-update concurrency, idempotency, and data-fidelity defects in the Fastify server ([#194](https://github.com/parafovea/fovea/pull/194)). Roughly a third of the findings were gaps in the first audit's own fixes: a fix addressed specific instances of a defect class without closing every instance. Nothing is breaking.

### Fixed

#### Authorization

- The `/api/models/*` proxy routes were unauthenticated: any caller could read the model configuration and, worse, drive the shared inference service — selecting, loading, or unloading a model affects every user — with no credentials. The routes now require authentication, and the state-changing select/load/unload operations additionally require admin (`server/src/app.ts`, `server/src/routes/models.ts`).
- A persona could be attached to a project by a non-member, since the create ability passed for any self-owned persona regardless of the target project. Project-scoped persona creation now verifies project membership and returns 403 otherwise (`server/src/services/persona-service.ts`).
- The import "replace" paths overwrote or deleted an existing annotation, persona, summary, claim, or claim-relation found by id with no instance-level check, so a crafted import could clobber another user's row. Each replace now requires update (or delete) on the existing row, mirroring the ontology-import guard (`server/src/services/import/entity-importers.ts`).

#### Concurrency — lost-update hardening

- The optimistic-concurrency guard on world state and ontologies compared on `updatedAt`, which two writes landing in the same millisecond may share — letting the second silently overwrite the first. Both tables now carry a monotonic `version` column, and every guarded write compares-and-swaps on it; guard exhaustion now returns 409 and a missing row 404 rather than a generic 500 (`server/prisma/migrations/20260630120000_add_optimistic_version_and_admin_apikey_uniqueness`, `server/src/repositories/{WorldStateRepository,ProjectRepository,PersonaRepository}.ts`, `server/src/services/world-state-service.ts`).
- Persona type-deletion and persona deletion wrote the annotation delete, the ontology gloss cleanup, and the world-state cleanup as separate unguarded statements, so a partial failure left them disagreeing and a concurrent edit could be clobbered. Each now runs in a single transaction with the ontology and world-state writes routed through the version guard, recomputing from a fresh read (`server/src/services/persona-service.ts`).
- The admin "clear world state" path wrote through a bare id update that could half-survive a concurrent write; it now clears through the version guard (`server/src/services/world-state-service.ts`).
- First-access creation of a project world state raced into the compound unique constraint and surfaced a 500; it now upserts the empty row and merges through the guard (`server/src/services/project-service.ts`).
- The claim-extraction worker re-saved claims on every run with no dedup, so a job retry or double-submit duplicated every claim for a summary. Re-extraction is now idempotent: the prior extracted claims are replaced inside one transaction, preserving manually authored claims (`server/src/queues/setup.ts`).

#### Idempotency and conflict status

- Several create and update paths surfaced a duplicate as a 500 rather than a 409: the self-service profile email/username update, project and group membership adds, and admin API keys. Each now maps the unique-constraint violation to a 409 (`server/src/routes/{users,groups}.ts`, `server/src/services/{project-service,api-key-service}.ts`).
- Admin API keys were unconstrained, since the compound `@@unique([userId, provider])` does not bind rows with a null userId in Postgres, so the advertised 409 never fired. A partial unique index now enforces one admin key per provider, deduping any existing duplicates first (`server/prisma/migrations/20260630120000_add_optimistic_version_and_admin_apikey_uniqueness`).
- A claim create that lost the same-id race re-threw the raw unique violation as a 500 when the existing row was not updatable; it now returns 403, mirroring the pre-create authorization check (`server/src/services/claim-service.ts`).
- A service-layer Zod validation failure — one that bypasses Fastify's schema validation — fell through to a generic 500; it now returns 400 with the field-level issues (`server/src/app.ts`).
- The ontology-save handler returned a bespoke 500 that leaked the raw error message to the client; it now re-throws to the global handler for a safe generic 500, keeping the detail in the logs (`server/src/routes/ontology.ts`).

#### Data fidelity

- Forking a shared video summary copied the summary body but dropped every extracted claim, leaving the fork's claim view empty. The fork now deep-copies the claim tree under fresh ids and carries the denormalized `claimsJson`, re-pointing every claim reference — row ids, parent links, and the references embedded in glosses — at the new ids (`server/src/routes/sharing.ts`).
- Cross-summary import conflict detection compared relation ids against the collection id set and read the world-state row with a narrower filter than the writer used, so it could miss or misreport conflicts. Both now key on the correct row and the relation id set (`server/src/services/import/conflict-resolver.ts`, `server/src/services/import-handler.ts`).
- A merge-keyframes import resolution on an existing annotation fell through to an unconditional create and collided on the primary key; the create is now guarded by an existence check (`server/src/services/import/entity-importers.ts`).

#### Demo and cache hygiene

- The idle-demo-user sweeper deleted users but never evicted their cached abilities, leaking memory as anonymous demo sessions churned; it now invalidates each swept user's ability cache (`server/src/demo/idle-reset.ts`).
- The anonymous-session mint endpoint had neither a per-route rate limit nor a population ceiling, so it could be driven to create users faster than the sweeper reclaims them; it now rate-limits per IP and refuses once a live-anonymous-user ceiling is reached (`server/src/demo/anonymous-session.ts`).
- Admin user creation accepted an independent `systemRole` that could diverge from `isAdmin` (CASL keys on the former, the admin middleware on the latter); the two are now coupled. Admin user deletion now invalidates the deleted user's cached abilities (`server/src/routes/users.ts`).

## [0.5.8] - 2026-06-30

The 0.5.8 patch is the third and final audit-driven hardening release — the model-service slice — fixing resource and concurrency defects in the Python inference service ([#192](https://github.com/parafovea/fovea/pull/192)). Nothing is breaking.
Expand Down
2 changes: 1 addition & 1 deletion model-service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "fovea-model-service"
version = "0.5.8"
version = "0.5.9"
description = "Model service for fovea video annotation tool"
requires-python = ">=3.12"
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "fovea",
"private": true,
"version": "0.5.8",
"version": "0.5.9",
"description": "FOVEA - Flexible Ontology Visual Event Analyzer",
"packageManager": "pnpm@10.15.0",
"engines": {
Expand Down
27 changes: 27 additions & 0 deletions server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,33 @@
}
}
}
},
"409": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"message"
],
"properties": {
"error": {
"description": "Machine-readable error code",
"type": "string"
},
"message": {
"description": "Human-readable error message",
"type": "string"
},
"details": {
"description": "Additional error context"
}
}
}
}
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fovea/server",
"version": "0.5.8",
"version": "0.5.9",
"private": true,
"type": "module",
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- 0.5.9 backend correctness hardening.
--
-- (1) Adds a monotonic `version` column to world_state and ontologies so the
-- optimistic-concurrency guard can compare-and-swap on a value that always
-- advances. The prior guard matched on `updatedAt`, which two writes landing in
-- the same millisecond can share, letting the second silently clobber the first.
-- Existing rows start at version 0; the guard increments on every write.
--
-- (2) Adds a partial unique index enforcing one admin API key per provider
-- (userId IS NULL). The compound @@unique([userId, provider]) does not constrain
-- admin keys because Postgres treats NULL userIds as distinct, so the advertised
-- 409 never fired and duplicate admin keys could accumulate. Dedupe first.
--
-- The partial unique index cannot be expressed in schema.prisma and is created
-- here as raw SQL; the project applies migrations with `prisma migrate deploy`,
-- which leaves it in place.

-- AlterTable: optimistic-concurrency version tokens.
ALTER TABLE "world_state" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "ontologies" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 0;

-- ApiKey: an admin key (userId NULL) must be unique per provider (BUG-15).
-- Dedupe first, treating NULL userIds as equal (IS NOT DISTINCT FROM via the
-- explicit NULL guard), keeping the most-recently-updated admin key per provider
-- (older duplicates, a rare artifact of the unconstrained path, are removed).
DELETE FROM "api_keys" a
USING "api_keys" b
WHERE a."userId" IS NULL AND b."userId" IS NULL
AND a."provider" = b."provider"
AND (a."updatedAt" < b."updatedAt" OR (a."updatedAt" = b."updatedAt" AND a.ctid > b.ctid));

CREATE UNIQUE INDEX "api_keys_admin_provider_key"
ON "api_keys"("provider") WHERE "userId" IS NULL;
10 changes: 10 additions & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ model Ontology {
/// JSON structure: RelationType[]
relationTypes Json @default("[]")

/// Monotonic optimistic-concurrency token. Incremented on every guarded
/// write; the compare-and-swap guard matches on it (not on updatedAt, which
/// can fail to advance for two writes landing in the same millisecond).
version Int @default(0)

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand Down Expand Up @@ -249,6 +254,11 @@ model WorldState {
/// JSON structure: OntologyRelation[]
relations Json @default("[]")

/// Monotonic optimistic-concurrency token. Incremented on every guarded
/// write; the compare-and-swap guard matches on it (not on updatedAt, which
/// can fail to advance for two writes landing in the same millisecond).
version Int @default(0)

projectId String?
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)

Expand Down
Loading
Loading