From 5de898b8f702c46aaeb9e4b2be6bebbf01524adb Mon Sep 17 00:00:00 2001 From: Nicolas CHAUVIN Date: Fri, 11 Sep 2026 16:24:03 +0200 Subject: [PATCH 1/2] docs(design): the pin follows the lock --- docs/adr/0005-exclusive-case-locking.md | 4 +- docs/adr/0024-the-pin-follows-the-lock.md | 55 +++++++++++++++++++++++ docs/adr/README.md | 1 + docs/design/product.md | 23 ++++++---- 4 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 docs/adr/0024-the-pin-follows-the-lock.md diff --git a/docs/adr/0005-exclusive-case-locking.md b/docs/adr/0005-exclusive-case-locking.md index 336d310..6abcb52 100644 --- a/docs/adr/0005-exclusive-case-locking.md +++ b/docs/adr/0005-exclusive-case-locking.md @@ -2,7 +2,9 @@ ## Status -Accepted — 2026-08-19 +Accepted — 2026-08-19 — completed by +[ADR 0024](0024-the-pin-follows-the-lock.md): the edition a case shows follows +the lock. ## Context diff --git a/docs/adr/0024-the-pin-follows-the-lock.md b/docs/adr/0024-the-pin-follows-the-lock.md new file mode 100644 index 0000000..65a8f6d --- /dev/null +++ b/docs/adr/0024-the-pin-follows-the-lock.md @@ -0,0 +1,55 @@ +# ADR 0024 — The pin follows the lock + +## Status + +Accepted — 2026-09-11 + +Completes [ADR 0005](0005-exclusive-case-locking.md): freezing the bytes +follows occupancy, not the cycle state. + +## Context + +The pin exists so a run landing mid-review does not change the bytes under a +reviewer (product.md §7, #142). It was keyed on `to-review` — which says *the +ball is with the reviewer* (ADR 0021), not *someone is looking right now*. A +case could sit frozen for weeks on a recording-less edition, hiding evidence +that arrived after it, to protect a reviewer who did not exist (observed on +`dd9c192d95e0`, #248). + +Occupancy has had its own answer since ADR 0005 shipped (#247): the lock. + +## Decision + +**The displayed edition is derived, never stored.** Asked which edition a case +shows, the server answers from what is true at that moment: + +- a **live lock** on the case → the edition stamped when it was claimed; +- otherwise → the project's latest edition. + +`case_locks` gains `edition_id`, stamped at claim, kept through heartbeats, +re-stamped on a fresh claim — including after expiry. `cases.current_edition_id` +is removed with its two maintenance mechanisms (`AdvanceCurrentEdition`, +`ReleaseToLatestEdition`); intake stops skipping `to-review` cases. + +**A delivery still advances the case at once** (#142): `deliver` re-stamps the +live lock onto the latest edition — with no live lock there is nothing to do, +the case already reads at the latest. + +**When a review settles, the state re-derives against the latest edition, +ignoring the saver's own lock** — otherwise a case could stamp `accepted` +blind to an edition already waiting, and nothing later flips it: the intake +rule that reopens a case for its videos (ADR 0023) only fires on future +pushes. + +## Rejected alternatives + +- **An `in-review` cycle status.** Forbidden by ADR 0005: occupancy is a + separate axis and never folds into the state field, or it destroys the state + it replaces. +- **Keeping the stored pin and adding the missing catch-ups** — skip held + cases at intake, release on lock release, repair at read for locks that died + in silence. A state that repairs itself on read drifts again the first time + somebody adds a read path; #248 *is* the catch-up nobody wrote. +- **Showing a later edition's recording beside the pinned captures.** A hybrid + view where the video shows a flow the captures do not: the reviewer would + judge an inconsistency the product invented. diff --git a/docs/adr/README.md b/docs/adr/README.md index 252b5ee..67f8af8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,6 +35,7 @@ on the new one. The full lifecycle is set by | [0021](0021-a-status-says-where-the-next-action-happens.md) | A status says where the next action happens | | [0022](0022-a-judgment-lands-on-the-capture-on-screen.md) | A judgment lands on the capture on screen | | [0023](0023-a-recording-is-judged.md) | A recording is judged | +| [0024](0024-the-pin-follows-the-lock.md) | The pin follows the lock | 0001 to 0008 were decided in a single design conversation on 2026-08-18/19 and settle what the product is. 0009 to 0011 followed on 2026-08-19 and settle how diff --git a/docs/design/product.md b/docs/design/product.md index 25bb76d..b6c7bad 100644 --- a/docs/design/product.md +++ b/docs/design/product.md @@ -426,15 +426,20 @@ Intake is governed by a **per-project policy** ([ADR 0007](../adr/0007-run-intak `to-review`. The refusal lists the blocking cases. This keeps pressure on finishing reviews, at the cost of blocking the whole project on one unfinished review. -- `per-case` — intake is always accepted and stored; each case keeps pointing - at the edition its reviewer is judging, and advances when that review ends. - **A delivery advances the case at once**: judging a fix means reading the - bytes that claim to fix it, so `deliver` releases the case onto the latest - edition even mid-review. The pin only protects what is still being judged of - the current sweep — accepted captures keep their verdicts, and a capture that - changed under one comes back marked `moved`, as always. Decided when a - reviewer was asked to judge a fix while the pin showed them the screen from - before it (#142). +- `per-case` — intake is always accepted and stored; a case shows the edition + its **holder** is judging + ([ADR 0024](../adr/0024-the-pin-follows-the-lock.md)): a live lock pins the + bytes stamped at claim, and a case nobody holds always reads at the latest + edition — nothing to advance, nothing to catch up. **A delivery advances + the case at once**: judging a fix means reading the bytes that claim to fix + it, so `deliver` re-stamps the live lock onto the latest edition even + mid-review (#142). The pin only protects what is still being judged of the + current sweep — accepted captures keep their verdicts, and a capture that + changed under one comes back marked `moved`, as always. When a review + settles, the state re-derives against the latest edition, ignoring the + saver's own lock. Verified by `TestAFreeCaseReadsAtTheLatestEdition`, + `TestAHeldCaseKeepsItsBytesUntilTheLockDies` and + `TestADeliveryAdvancesTheCaseOntoItsEdition`. Running the test suite is never gated. Only intake is. From a5d5526ab14406ed76ca1449eec577cad5b5337e Mon Sep 17 00:00:00 2001 From: Nicolas CHAUVIN Date: Fri, 11 Sep 2026 16:36:41 +0200 Subject: [PATCH 2/2] feat(review): the pin follows the lock --- apps/server/api/openapi.yaml | 12 + apps/server/api/src/paths/case-lock.yaml | 12 + .../00022_the_pin_follows_the_lock.sql | 14 + apps/server/db/queries/aggregates.sql | 46 +- apps/server/db/queries/intake.sql | 11 - .../internal/adapters/postgres/access_test.go | 8 +- .../internal/adapters/postgres/catalogue.go | 4 +- .../internal/adapters/postgres/comment.go | 60 ++- .../internal/adapters/postgres/deriver.go | 27 +- .../adapters/postgres/edition_test.go | 121 ++++- .../internal/adapters/postgres/evidence.go | 25 +- .../internal/adapters/postgres/intake.go | 12 +- .../internal/adapters/postgres/intake_test.go | 12 +- .../server/internal/adapters/postgres/lock.go | 38 +- .../internal/adapters/postgres/lock_test.go | 10 +- .../internal/adapters/postgres/recording.go | 2 +- .../internal/adapters/postgres/session.go | 64 +-- .../postgres/sqlcgen/aggregates.sql.go | 105 ++-- .../adapters/postgres/sqlcgen/cases.sql.go | 12 +- .../adapters/postgres/sqlcgen/intake.sql.go | 40 +- .../adapters/postgres/sqlcgen/models.go | 20 +- apps/server/internal/app/session/session.go | 6 +- .../internal/app/session/session_test.go | 4 +- apps/server/internal/ports/http/lock.go | 3 +- .../internal/ports/http/openapi/server.gen.go | 504 +++++++++--------- apps/web/src/features/review/useReview.ts | 3 +- apps/web/src/pages/case/CasePage.vue | 8 +- apps/web/src/shared/api/schema.gen.ts | 12 +- docs/adr/0024-the-pin-follows-the-lock.md | 6 +- 29 files changed, 691 insertions(+), 510 deletions(-) create mode 100644 apps/server/db/migrations/00022_the_pin_follows_the_lock.sql diff --git a/apps/server/api/openapi.yaml b/apps/server/api/openapi.yaml index b94b168..82f1e0e 100644 --- a/apps/server/api/openapi.yaml +++ b/apps/server/api/openapi.yaml @@ -1369,6 +1369,18 @@ paths: same before and after. tags: - reviews + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + fresh: + type: boolean + description: | + Opening the page (true) re-stamps the hold onto the latest + edition; the heartbeat (false, the default) keeps the bytes. responses: '200': description: The caller holds the case. diff --git a/apps/server/api/src/paths/case-lock.yaml b/apps/server/api/src/paths/case-lock.yaml index a4e13a6..f9967fb 100644 --- a/apps/server/api/src/paths/case-lock.yaml +++ b/apps/server/api/src/paths/case-lock.yaml @@ -19,6 +19,18 @@ post: answers 423, naming them. Occupancy is never a state — the case reads the same before and after. tags: [reviews] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + fresh: + type: boolean + description: | + Opening the page (true) re-stamps the hold onto the latest + edition; the heartbeat (false, the default) keeps the bytes. responses: "200": description: The caller holds the case. diff --git a/apps/server/db/migrations/00022_the_pin_follows_the_lock.sql b/apps/server/db/migrations/00022_the_pin_follows_the_lock.sql new file mode 100644 index 0000000..86bb713 --- /dev/null +++ b/apps/server/db/migrations/00022_the_pin_follows_the_lock.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- The displayed edition is derived, never stored (ADR 0024): a live lock +-- pins the bytes stamped at claim, a free case reads at the latest. The +-- stored pin and its maintenance go. +ALTER TABLE case_locks + ADD COLUMN edition_id text REFERENCES editions(id) ON DELETE SET NULL; +DROP INDEX cases_current_edition_idx; +ALTER TABLE cases DROP COLUMN current_edition_id; + +-- +goose Down +ALTER TABLE cases + ADD COLUMN current_edition_id text REFERENCES editions(id) ON DELETE SET NULL; +CREATE INDEX cases_current_edition_idx ON cases (current_edition_id); +ALTER TABLE case_locks DROP COLUMN edition_id; diff --git a/apps/server/db/queries/aggregates.sql b/apps/server/db/queries/aggregates.sql index 72a0679..9226af6 100644 --- a/apps/server/db/queries/aggregates.sql +++ b/apps/server/db/queries/aggregates.sql @@ -90,18 +90,16 @@ RETURNING *; -- the comment shows for as long as it lives — a step's name is a label, and -- positions shift (#132). Null when the step and variant had no capture, which is what -- there was to see. +-- The edition rides in from the caller's resolver (ADR 0024): the pin is +-- derived, never stored, so no query reads it off the case. -- name: AttachCommentVariant :exec INSERT INTO comment_variants (comment_id, variant_id, capture_id) SELECT @comment_id, @variant_id, ( SELECT cap.id FROM captures cap JOIN comments c ON c.id = @comment_id - JOIN cases k ON k.id = c.case_id WHERE cap.step_id = c.step_id AND cap.variant_id = @variant_id - AND cap.edition_id = coalesce( - k.current_edition_id, - (SELECT e.id FROM editions e WHERE e.project_id = k.project_id - ORDER BY e.created_at DESC, e.id DESC LIMIT 1)) + AND cap.edition_id = @edition_id::text ) ON CONFLICT DO NOTHING; @@ -232,19 +230,6 @@ SET blob_hash = EXCLUDED.blob_hash, approved_by = EXCLUDED.approved_by, approved_at = now(); --- A review that ends releases the case onto the project's most recent edition: --- it was only held back so the reviewer judged one fixed set of bytes. --- name: ReleaseToLatestEdition :exec -UPDATE cases k -SET current_edition_id = ( - SELECT e.id FROM editions e - WHERE e.project_id = k.project_id - ORDER BY e.created_at DESC, e.id DESC - LIMIT 1 - ), - updated_at = now() -WHERE k.id = @case_id; - -- What a case is judged against right now, and who wrote the reference. -- name: CaseReferences :many SELECT step_id, variant_id, environment_id, blob_hash, approved_by, approved_at @@ -368,8 +353,8 @@ WHERE cv.comment_id = @comment_id AND cv.variant_id = @variant_id; -- holds it instead. The window rides in as seconds so expiry is read, never -- written. -- name: ClaimCaseLock :one -INSERT INTO case_locks (case_id, account_id) -VALUES (@case_id, @account_id) +INSERT INTO case_locks (case_id, account_id, edition_id) +VALUES (@case_id, @account_id, @edition_id) ON CONFLICT (case_id) DO UPDATE SET account_id = EXCLUDED.account_id, claimed_at = CASE @@ -378,18 +363,37 @@ SET account_id = EXCLUDED.account_id, THEN case_locks.claimed_at ELSE now() END, + -- The pin follows the claim (ADR 0024): a heartbeat keeps the bytes, a + -- fresh claim — opening the page, expiry included — re-stamps onto what + -- is current now. Reloading is leaving and coming back. + edition_id = CASE + WHEN NOT @fresh::boolean + AND case_locks.account_id = EXCLUDED.account_id + AND case_locks.beaten_at > now() - make_interval(secs => @window_seconds::int) + THEN case_locks.edition_id + ELSE EXCLUDED.edition_id + END, beaten_at = now() WHERE case_locks.account_id = EXCLUDED.account_id OR case_locks.beaten_at < now() - make_interval(secs => @window_seconds::int) RETURNING case_id, account_id, claimed_at; -- name: ReadCaseLock :one -SELECT l.account_id, l.claimed_at, u.name AS holder_name +SELECT l.account_id, l.claimed_at, l.edition_id, u.name AS holder_name FROM case_locks l JOIN users u ON u.id = l.account_id WHERE l.case_id = @case_id AND l.beaten_at > now() - make_interval(secs => @window_seconds::int); +-- A delivery advances the case at once (#142, ADR 0024): the live lock is +-- re-stamped onto the latest edition; with no live lock there is nothing to +-- do — the case already reads at the latest. +-- name: RestampLiveLock :exec +UPDATE case_locks +SET edition_id = @edition_id +WHERE case_id = @case_id + AND beaten_at > now() - make_interval(secs => @window_seconds::int); + -- Releasing somebody else's lock, or one nobody holds, changes nothing. -- name: ReleaseCaseLock :exec DELETE FROM case_locks WHERE case_id = $1 AND account_id = $2; diff --git a/apps/server/db/queries/intake.sql b/apps/server/db/queries/intake.sql index 8014ae2..f96d333 100644 --- a/apps/server/db/queries/intake.sql +++ b/apps/server/db/queries/intake.sql @@ -103,17 +103,6 @@ SELECT * FROM variants WHERE project_id = $1; -- name: RelabelVariant :exec UPDATE variants SET label = $2 WHERE id = $1; --- A new edition does not yank the ground from under a reviewer: a case sitting --- at `to-review` keeps pointing at what its reviewer is judging, and advances --- once that review ends (product.md §7). A case that points nowhere always --- advances -- there was nothing to protect. --- name: AdvanceCurrentEdition :many -UPDATE cases -SET current_edition_id = @edition_id, updated_at = now() -WHERE id = ANY(@case_ids::text[]) - AND (current_edition_id IS NULL OR state <> 'to-review') -RETURNING id; - -- name: ProjectThreshold :one SELECT pixel_threshold FROM projects WHERE slug = $1; diff --git a/apps/server/internal/adapters/postgres/access_test.go b/apps/server/internal/adapters/postgres/access_test.go index 5cd3a5a..236f6ab 100644 --- a/apps/server/internal/adapters/postgres/access_test.go +++ b/apps/server/internal/adapters/postgres/access_test.go @@ -238,11 +238,11 @@ func TestACommentAnchorsToTheCaptureItWasWrittenAbout(t *testing.T) { t.Fatalf("edition: %v", err) } - var stepID, variantID, captureID string + var stepID, variantID, captureID, editionID string if err := repo.Pool().QueryRow(ctx, - `SELECT c.step_id, c.variant_id, c.id FROM captures c + `SELECT c.step_id, c.variant_id, c.id, c.edition_id FROM captures c JOIN steps s ON s.id = c.step_id WHERE s.case_id = $1`, kase.ID, - ).Scan(&stepID, &variantID, &captureID); err != nil { + ).Scan(&stepID, &variantID, &captureID, &editionID); err != nil { t.Fatalf("finding the capture: %v", err) } @@ -255,7 +255,7 @@ func TestACommentAnchorsToTheCaptureItWasWrittenAbout(t *testing.T) { t.Fatalf("creating the comment: %v", err) } if err := q.AttachCommentVariant(ctx, sqlcgen.AttachCommentVariantParams{ - CommentID: created.ID, VariantID: variantID, + CommentID: created.ID, VariantID: variantID, EditionID: editionID, }); err != nil { t.Fatalf("attaching: %v", err) } diff --git a/apps/server/internal/adapters/postgres/catalogue.go b/apps/server/internal/adapters/postgres/catalogue.go index a95b015..f5b2528 100644 --- a/apps/server/internal/adapters/postgres/catalogue.go +++ b/apps/server/internal/adapters/postgres/catalogue.go @@ -349,8 +349,8 @@ func (r *Repository) SummariseCases(ctx context.Context, projectID string, categ // than failing the whole listing. func countsOf(ctx context.Context, r *Repository, row sqlcgen.CasesWithCaptureCountsRow) catalogue.CaptureCounts { counts := catalogue.CaptureCounts{Total: row.Captures} - facts, err := factsOf(ctx, r.q, sqlcgen.Case{ - ID: row.ID, ProjectID: row.ProjectID, CurrentEditionID: row.CurrentEditionID, + facts, err := r.factsOf(ctx, r.q, sqlcgen.Case{ + ID: row.ID, ProjectID: row.ProjectID, }) if err != nil { return counts diff --git a/apps/server/internal/adapters/postgres/comment.go b/apps/server/internal/adapters/postgres/comment.go index 7ef5bb2..359aa2f 100644 --- a/apps/server/internal/adapters/postgres/comment.go +++ b/apps/server/internal/adapters/postgres/comment.go @@ -133,10 +133,12 @@ func (r *Repository) Judge( }); err != nil { return "", translate("recording the acceptance", err) } - if kase.CurrentEditionID != nil { + if shown, err := r.displayedEdition(ctx, q, kase); err != nil { + return "", err + } else if shown != nil { if err := q.StampCaptureReference(ctx, sqlcgen.StampCaptureReferenceParams{ CaseID: c.CaseID, StepID: c.StepID, VariantID: variantID, - EditionID: *kase.CurrentEditionID, ApprovedBy: by.ID, + EditionID: *shown, ApprovedBy: by.ID, }); err != nil { return "", translate("stamping the reference", err) } @@ -144,9 +146,11 @@ func (r *Repository) Judge( } else { // The refusal's anchor follows the refused variant: the judge // refused these bytes. - if kase.CurrentEditionID != nil { + if shown, err := r.displayedEdition(ctx, q, kase); err != nil { + return "", err + } else if shown != nil { if err := q.ReanchorCommentVariant(ctx, sqlcgen.ReanchorCommentVariantParams{ - CommentID: commentID, VariantID: variantID, EditionID: *kase.CurrentEditionID, + CommentID: commentID, VariantID: variantID, EditionID: *shown, }); err != nil { return "", translate("re-anchoring the refusal", err) } @@ -237,9 +241,11 @@ func (r *Repository) Unjudge( if err != nil { return "", translate("reading the case", err) } - if kase.CurrentEditionID != nil { + if shown, err := r.displayedEdition(ctx, q, kase); err != nil { + return "", err + } else if shown != nil { if err := q.RestoreCommentVariant(ctx, sqlcgen.RestoreCommentVariantParams{ - CommentID: commentID, VariantID: variantID, EditionID: *kase.CurrentEditionID, + CommentID: commentID, VariantID: variantID, EditionID: *shown, }); err != nil { return "", translate("restoring the coverage", err) } @@ -347,20 +353,28 @@ func (r *Repository) Edit( if err := q.DetachCommentVariants(ctx, commentID); err != nil { return appcomment.Outcome{}, translate("clearing the variants", err) } + kase, err := q.CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: comment.CaseID, Slug: slug}) + if err != nil { + return appcomment.Outcome{}, translate("reading the case", err) + } + shown, err := r.displayedEdition(ctx, q, kase) + if err != nil { + return appcomment.Outcome{}, err + } + anchor := "" + if shown != nil { + anchor = *shown + } for _, variantID := range variantIDs { if err := q.AttachCommentVariant(ctx, sqlcgen.AttachCommentVariantParams{ - CommentID: commentID, VariantID: variantID, + CommentID: commentID, VariantID: variantID, EditionID: anchor, }); err != nil { return appcomment.Outcome{}, translate("attaching a variant", err) } } - kase, err := q.CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: comment.CaseID, Slug: slug}) - if err != nil { - return appcomment.Outcome{}, translate("reading the case", err) - } before := review.CaseState(kase.State) - facts, err := factsOf(ctx, q, kase) + facts, err := r.factsOf(ctx, q, kase) if err != nil { return appcomment.Outcome{}, err } @@ -510,7 +524,11 @@ func (r *Repository) move( // the reference is stamped for every covered capture — otherwise the // derivation would read the fix's own pixels as "moved" against the // pre-fix reference (#206, seen on production as "moved · 19203 px"). - if m == review.MoveAccept && to == review.CommentAccepted && kase.CurrentEditionID != nil { + shownForSettle, err := r.displayedEdition(ctx, q, kase) + if err != nil { + return appcomment.Outcome{}, err + } + if m == review.MoveAccept && to == review.CommentAccepted && shownForSettle != nil { covered, err := q.CommentCoveredVariants(ctx, comment.ID) if err != nil { return appcomment.Outcome{}, translate("reading the covered variants", err) @@ -518,7 +536,7 @@ func (r *Repository) move( for _, variantID := range covered { if err := q.StampCaptureReference(ctx, sqlcgen.StampCaptureReferenceParams{ CaseID: kase.ID, StepID: comment.StepID, VariantID: variantID, - EditionID: *kase.CurrentEditionID, ApprovedBy: by.ID, + EditionID: *shownForSettle, ApprovedBy: by.ID, }); err != nil { return appcomment.Outcome{}, translate("stamping the reference", err) } @@ -529,12 +547,20 @@ func (r *Repository) move( // bytes that claim to fix it, and the pin was showing the reviewer the // screen from before the fix (product.md §7, #142). if m == review.MoveDeliver { - if err := q.ReleaseToLatestEdition(ctx, kase.ID); err != nil { - return appcomment.Outcome{}, translate("advancing onto the delivery", err) + latest, err := r.latestEditionID(ctx, q, kase.ProjectID) + if err != nil { + return appcomment.Outcome{}, err + } + if latest != nil { + if err := q.RestampLiveLock(ctx, sqlcgen.RestampLiveLockParams{ + CaseID: kase.ID, EditionID: latest, WindowSeconds: r.window(), + }); err != nil { + return appcomment.Outcome{}, translate("advancing onto the delivery", err) + } } } - facts, err := factsOf(ctx, q, kase) + facts, err := r.factsOf(ctx, q, kase) if err != nil { return appcomment.Outcome{}, err } diff --git a/apps/server/internal/adapters/postgres/deriver.go b/apps/server/internal/adapters/postgres/deriver.go index 2b35d53..675a5ef 100644 --- a/apps/server/internal/adapters/postgres/deriver.go +++ b/apps/server/internal/adapters/postgres/deriver.go @@ -13,7 +13,17 @@ import ( // // Captures are read at the edition the case is judged against — its pin — // falling back to the project's latest when the case was never pinned. -func factsOf(ctx context.Context, q *sqlcgen.Queries, kase sqlcgen.Case) (review.Facts, error) { +func (r *Repository) factsOf(ctx context.Context, q *sqlcgen.Queries, kase sqlcgen.Case) (review.Facts, error) { + editionID, err := r.displayedEdition(ctx, q, kase) + if err != nil { + return review.Facts{}, err + } + return factsOfAt(ctx, q, kase, editionID) +} + +// factsOfAt reads the facts against one already-resolved edition — the +// settle-time re-derivation resolves to the latest itself (ADR 0024). +func factsOfAt(ctx context.Context, q *sqlcgen.Queries, kase sqlcgen.Case, editionID *string) (review.Facts, error) { var facts review.Facts threshold, err := q.PixelThresholdByProject(ctx, kase.ProjectID) @@ -22,21 +32,6 @@ func factsOf(ctx context.Context, q *sqlcgen.Queries, kase sqlcgen.Case) (review } facts.PixelThreshold = int(threshold) - editionID := kase.CurrentEditionID - if editionID == nil { - edition, err := q.LatestEdition(ctx, kase.ProjectID) - if err != nil { - if isNoRows(err) { - // A book can start empty (ADR 0008): no edition, no captures. - editionID = nil - } else { - return facts, translate("reading the edition", err) - } - } else { - editionID = &edition.ID - } - } - if editionID != nil { rows, err := q.CaseCaptureFacts(ctx, sqlcgen.CaseCaptureFactsParams{ CaseID: kase.ID, EditionID: *editionID, diff --git a/apps/server/internal/adapters/postgres/edition_test.go b/apps/server/internal/adapters/postgres/edition_test.go index 5598485..64b91ce 100644 --- a/apps/server/internal/adapters/postgres/edition_test.go +++ b/apps/server/internal/adapters/postgres/edition_test.go @@ -3,11 +3,10 @@ package postgres_test import ( "context" "testing" + "time" "github.com/haribo/ozalid/apps/server/internal/adapters/postgres" "github.com/haribo/ozalid/apps/server/internal/adapters/postgres/sqlcgen" - "github.com/haribo/ozalid/apps/server/internal/app/session" - "github.com/haribo/ozalid/apps/server/internal/domain/actor" "github.com/haribo/ozalid/apps/server/internal/domain/review" "github.com/haribo/ozalid/internal/contract" ) @@ -51,55 +50,125 @@ func onlyCapture(t *testing.T, ctx context.Context, repo *postgres.Repository, s } func TestAnIntakeDoesNotMoveTheBytesUnderAReviewer(t *testing.T) { - // The case sits at to-review: somebody is looking. A run landing now must - // not change what they are judging (product.md §7). + // Somebody is looking means somebody holds the lock (ADR 0024): a run + // landing now must not change what they are judging (product.md §7). ctx, repo, project, kase := intakeFixture(t) first := pushEdition(t, ctx, repo, project, kase, "the form, first run", "ci") + nina := reviewer(t, ctx, repo, "nina") + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina, true); err != nil { + t.Fatalf("claiming: %v", err) + } + + pushEdition(t, ctx, repo, project, kase, "the form, second run", "ci") - held, err := repo.Queries().CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: kase.ID, Slug: project.Slug}) + grid, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) if err != nil { - t.Fatalf("reading the case: %v", err) + t.Fatalf("reading the grid: %v", err) } - if held.State != string(review.CaseToReview) { - t.Fatalf("state = %q, want to-review after the first captures", held.State) + if grid.Steps[0].Captures[0].Hash != first { + t.Errorf("grid shows %q, want the bytes the holder opened", grid.Steps[0].Captures[0].Hash) } - pinned := held.CurrentEditionID +} - pushEdition(t, ctx, repo, project, kase, "the form, second run", "ci") +func TestACaseCatchesUpAsSoonAsNobodyHoldsIt(t *testing.T) { + // The displayed edition is derived (ADR 0024): a held case keeps the + // bytes its lock stamped, and the moment the hold ends the next read + // answers the latest — no write in between. + ctx, repo, project, kase := intakeFixture(t) + first := pushEdition(t, ctx, repo, project, kase, "the form, first run", "ci") + nina := reviewer(t, ctx, repo, "nina") + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina, true); err != nil { + t.Fatalf("claiming: %v", err) + } + second := pushEdition(t, ctx, repo, project, kase, "the form, second run", "ci") - after, err := repo.Queries().CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: kase.ID, Slug: project.Slug}) + grid, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) + if err != nil { + t.Fatalf("reading the held grid: %v", err) + } + if grid.Steps[0].Captures[0].Hash != first { + t.Fatalf("the bytes moved under the holder") + } + + if err := repo.ReleaseCase(ctx, project.Slug, kase.ID, nina); err != nil { + t.Fatalf("releasing: %v", err) + } + grid, err = repo.CaseGrid(ctx, project.Slug, kase.ID, nil) if err != nil { - t.Fatalf("re-reading the case: %v", err) + t.Fatalf("reading the free grid: %v", err) } - if after.CurrentEditionID == nil || pinned == nil || *after.CurrentEditionID != *pinned { - t.Fatalf("the pointer moved while a reviewer held the case") + if grid.Steps[0].Captures[0].Hash != second { + t.Errorf("grid still shows the old edition; the free case never caught up") + } +} + +// The dd9c192d95e0 case (#248): a to-review case nobody holds is not frozen — +// it reads at the latest edition and shows a recording that landed after it +// was born. +func TestAFreeCaseReadsAtTheLatestEdition(t *testing.T) { + ctx, repo, project, kase := intakeFixture(t) + pushEdition(t, ctx, repo, project, kase, "the form, before any video", "ci") + + video := storeBlob(t, ctx, repo, "webm "+t.Name()) + still := storeBlob(t, ctx, repo, "the form, with a video") + if _, err := repo.WriteEdition(ctx, project.Slug, contract.Manifest{ + Cases: []contract.ManifestCase{{ + ID: kase.ID, + Steps: []contract.ManifestStep{{ + Name: "opens", + Captures: []contract.ManifestCapture{{ + Variant: map[string]string{"theme": "light"}, Hash: still, + Provenance: contract.Provenance{EnvironmentID: "ci"}, + }}, + }}, + Recordings: []contract.ManifestRecording{{ + Variant: map[string]string{"theme": "light"}, Hash: video, + }}, + }}, + }, nil); err != nil { + t.Fatalf("the recording edition: %v", err) } grid, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) if err != nil { t.Fatalf("reading the grid: %v", err) } - if grid.Steps[0].Captures[0].Hash != first { - t.Errorf("grid shows %q, want the bytes the reviewer opened", grid.Steps[0].Captures[0].Hash) + if len(grid.Recordings) != 1 { + t.Errorf("recordings = %d, want the video that landed after the case was born", len(grid.Recordings)) + } + if grid.Steps[0].Captures[0].Hash != still { + t.Errorf("grid shows %q, want the latest edition's bytes", grid.Steps[0].Captures[0].Hash) } } -func TestACaseCatchesUpOnceItsReviewEnds(t *testing.T) { + +// A held case keeps the bytes stamped at claim while a push lands, and reads +// the latest once the lock dies — no write in between (ADR 0024). +func TestAHeldCaseKeepsItsBytesUntilTheLockDies(t *testing.T) { ctx, repo, project, kase := intakeFixture(t) - pushEdition(t, ctx, repo, project, kase, "the form, first run", "ci") + first := pushEdition(t, ctx, repo, project, kase, "the form, first run", "ci") + nina := reviewer(t, ctx, repo, "nina") + repo.SetLockWindow(1 * time.Second) + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina, true); err != nil { + t.Fatalf("claiming: %v", err) + } second := pushEdition(t, ctx, repo, project, kase, "the form, second run", "ci") - // The reviewer judges the edition they opened, and lets go. - if _, err := repo.SaveReview(ctx, project.Slug, kase.ID, actor.Actor{ID: "nina", Kind: actor.Human}, session.Save{ - Accepted: []review.Capture{onlyCapture(t, ctx, repo, project.Slug, kase.ID)}, - }); err != nil { - t.Fatalf("saving the review: %v", err) + grid, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) + if err != nil { + t.Fatalf("reading the held grid: %v", err) + } + if grid.Steps[0].Captures[0].Hash != first { + t.Fatalf("the bytes moved under a live lock") } - grid, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) + // The heartbeat goes silent; the next read answers the latest, and no + // write happened anywhere. + time.Sleep(1200 * time.Millisecond) + grid, err = repo.CaseGrid(ctx, project.Slug, kase.ID, nil) if err != nil { - t.Fatalf("reading the grid: %v", err) + t.Fatalf("reading after the silence: %v", err) } if grid.Steps[0].Captures[0].Hash != second { - t.Errorf("grid still shows the old edition; the case never caught up") + t.Errorf("grid still shows the dead lock's edition") } } diff --git a/apps/server/internal/adapters/postgres/evidence.go b/apps/server/internal/adapters/postgres/evidence.go index da94c29..1b28e67 100644 --- a/apps/server/internal/adapters/postgres/evidence.go +++ b/apps/server/internal/adapters/postgres/evidence.go @@ -51,9 +51,8 @@ func (r *Repository) CaseGrid(ctx context.Context, slug, caseID string, editionI // Statuses are derived here, never read from storage (ADR 0021): the same // facts, the same rule, at the edition this grid displays. - displayed := kase - displayed.CurrentEditionID = &edition.ID - facts, err := factsOf(ctx, r.q, displayed) + shown := edition.ID + facts, err := factsOfAt(ctx, r.q, kase, &shown) if err != nil { return evidence.Grid{}, err } @@ -142,15 +141,23 @@ func (r *Repository) CaseGrid(ctx context.Context, slug, caseID string, editionI return grid, nil } -// resolveEdition picks the edition to read against: the one asked for, then the -// one the case is being judged against, then the project's most recent. +// resolveEdition picks the edition to read against: the one asked for, then +// the live lock's stamped one, then the project's most recent (ADR 0024). // -// The case's own pointer comes before the latest edition on purpose. A run -// landing mid-review must not change what the reviewer is looking at, or they -// would judge one set of bytes and approve another (product.md §7). +// The holder's pin comes before the latest edition on purpose. A run landing +// mid-review must not change what the reviewer is looking at, or they would +// judge one set of bytes and approve another (product.md §7) — and a case +// nobody holds always reads at the latest. func (r *Repository) resolveEdition(ctx context.Context, kase sqlcgen.Case, editionID *string) (sqlcgen.Edition, error) { if editionID == nil { - editionID = kase.CurrentEditionID + row, err := r.q.ReadCaseLock(ctx, sqlcgen.ReadCaseLockParams{ + CaseID: kase.ID, WindowSeconds: r.window(), + }) + if err == nil { + editionID = row.EditionID + } else if !isNoRows(err) { + return sqlcgen.Edition{}, translate("reading the lock", err) + } } if editionID != nil { edition, err := r.q.EditionByID(ctx, sqlcgen.EditionByIDParams{ diff --git a/apps/server/internal/adapters/postgres/intake.go b/apps/server/internal/adapters/postgres/intake.go index a863ab2..3c6a9b5 100644 --- a/apps/server/internal/adapters/postgres/intake.go +++ b/apps/server/internal/adapters/postgres/intake.go @@ -157,15 +157,9 @@ func (r *Repository) WriteEdition( } } - // A case advances onto the edition that just landed -- unless a reviewer is - // sitting on it. `to-review` means somebody is looking, and moving the - // bytes under them would have them judge one image and approve another - // (product.md §7). - if _, err := q.AdvanceCurrentEdition(ctx, sqlcgen.AdvanceCurrentEditionParams{ - EditionID: &edition.ID, CaseIds: caseIDs(m), - }); err != nil { - return appintake.Result{}, translate("pointing the cases at the edition", err) - } + // Nothing to advance: the displayed edition is derived (ADR 0024) — a + // held case keeps the bytes its lock stamped, everyone else reads the + // latest the moment it lands. // A new edition brings new video bytes, and nobody has judged them: the // cases it carries recordings for return to the reviewer (ADR 0023). diff --git a/apps/server/internal/adapters/postgres/intake_test.go b/apps/server/internal/adapters/postgres/intake_test.go index b16c47d..1f63ccf 100644 --- a/apps/server/internal/adapters/postgres/intake_test.go +++ b/apps/server/internal/adapters/postgres/intake_test.go @@ -329,17 +329,15 @@ func TestAnOlderEditionCanStillBeRead(t *testing.T) { t.Error("the older edition does not show the bytes it was taken with") } - // And the default read stays there too. The case went to to-review on the - // first captures, so a reviewer holds it; the second edition is stored but - // does not become what they are judging (product.md §7, ADR 0017). - // TestACaseCatchesUpOnceItsReviewEnds covers the other half: the case moves - // onto the newest edition once the review ends. + // And the free case reads at the latest (ADR 0024): the older edition + // stays readable on request, it just is not what a case nobody holds + // shows. TestAHeldCaseKeepsItsBytesUntilTheLockDies covers the held half. byDefault, err := repo.CaseGrid(ctx, project.Slug, kase.ID, nil) if err != nil { t.Fatalf("reading the default edition: %v", err) } - if byDefault.Steps[0].Captures[0].Hash != before { - t.Error("an incoming run moved the bytes under the reviewer") + if byDefault.Steps[0].Captures[0].Hash != after { + t.Error("a free case still shows an old edition") } } diff --git a/apps/server/internal/adapters/postgres/lock.go b/apps/server/internal/adapters/postgres/lock.go index c00da75..9c2afd7 100644 --- a/apps/server/internal/adapters/postgres/lock.go +++ b/apps/server/internal/adapters/postgres/lock.go @@ -28,7 +28,7 @@ func (r *Repository) window() int32 { // ClaimCase takes the case's lock, or renews it: one atomic statement claims // a free or expired lock and beats the caller's own. Somebody else's live // lock answers review.ErrHeld, naming the holder (ADR 0005). -func (r *Repository) ClaimCase(ctx context.Context, slug, caseID string, by actor.Actor) (review.Hold, error) { +func (r *Repository) ClaimCase(ctx context.Context, slug, caseID string, by actor.Actor, fresh bool) (review.Hold, error) { kase, err := r.q.CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: caseID, Slug: slug}) if err != nil { if isNoRows(err) { @@ -36,8 +36,15 @@ func (r *Repository) ClaimCase(ctx context.Context, slug, caseID string, by acto } return review.Hold{}, translate("finding the case", err) } + // The claim stamps what is current now (ADR 0024): these are the bytes + // the hold will keep under the reviewer. + latest, err := r.latestEditionID(ctx, r.q, kase.ProjectID) + if err != nil { + return review.Hold{}, err + } row, err := r.q.ClaimCaseLock(ctx, sqlcgen.ClaimCaseLockParams{ CaseID: kase.ID, AccountID: by.ID, WindowSeconds: r.window(), + EditionID: latest, Fresh: fresh, }) if err == nil { hold := review.Hold{By: row.AccountID, Since: row.ClaimedAt.Time} @@ -106,3 +113,32 @@ func (r *Repository) refuseHeld(ctx context.Context, q *sqlcgen.Queries, caseID } return nil } + +// displayedEdition resolves which edition the case shows (ADR 0024): the +// live lock's stamped edition, else the project's latest, else nil — a book +// can start empty (ADR 0008). Derived at read, never stored. +func (r *Repository) displayedEdition(ctx context.Context, q *sqlcgen.Queries, kase sqlcgen.Case) (*string, error) { + row, err := q.ReadCaseLock(ctx, sqlcgen.ReadCaseLockParams{ + CaseID: kase.ID, WindowSeconds: r.window(), + }) + if err == nil && row.EditionID != nil { + return row.EditionID, nil + } + if err != nil && !isNoRows(err) { + return nil, translate("reading the lock", err) + } + return r.latestEditionID(ctx, q, kase.ProjectID) +} + +// latestEditionID is the free case's answer — and the settle-time one, which +// deliberately looks past the saver's own lock (ADR 0024). +func (r *Repository) latestEditionID(ctx context.Context, q *sqlcgen.Queries, projectID string) (*string, error) { + edition, err := q.LatestEdition(ctx, projectID) + if err != nil { + if isNoRows(err) { + return nil, nil + } + return nil, translate("reading the edition", err) + } + return &edition.ID, nil +} diff --git a/apps/server/internal/adapters/postgres/lock_test.go b/apps/server/internal/adapters/postgres/lock_test.go index 981416a..585f19c 100644 --- a/apps/server/internal/adapters/postgres/lock_test.go +++ b/apps/server/internal/adapters/postgres/lock_test.go @@ -39,7 +39,7 @@ func TestAHeldCaseRefusesAnotherReviewersVerdict(t *testing.T) { marc := reviewer(t, ctx, repo, "marc") stateBefore := caseState(t, ctx, repo, project.Slug, kase.ID) - if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina); err != nil { + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina, true); err != nil { t.Fatalf("nina claiming: %v", err) } if state := caseState(t, ctx, repo, project.Slug, kase.ID); state != stateBefore { @@ -89,16 +89,16 @@ func TestASilentLockExpiresOnItsOwn(t *testing.T) { marc := reviewer(t, ctx, repo, "marc") repo.SetLockWindow(1 * time.Second) - if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina); err != nil { + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, nina, true); err != nil { t.Fatalf("nina claiming: %v", err) } var held *review.Held - if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, marc); !errors.As(err, &held) { + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, marc, true); !errors.As(err, &held) { t.Fatalf("marc claiming a live lock = %v, want Held", err) } time.Sleep(1200 * time.Millisecond) - if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, marc); err != nil { + if _, err := repo.ClaimCase(ctx, project.Slug, kase.ID, marc, true); err != nil { t.Errorf("marc claiming after the silence = %v, want the expired lock taken", err) } } @@ -118,7 +118,7 @@ func TestTwoReviewersRaceForTheLock(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, results[i] = repo.ClaimCase(ctx, project.Slug, kase.ID, by) + _, results[i] = repo.ClaimCase(ctx, project.Slug, kase.ID, by, true) }() } wg.Wait() diff --git a/apps/server/internal/adapters/postgres/recording.go b/apps/server/internal/adapters/postgres/recording.go index 49f2fad..63f78ea 100644 --- a/apps/server/internal/adapters/postgres/recording.go +++ b/apps/server/internal/adapters/postgres/recording.go @@ -73,7 +73,7 @@ func (r *Repository) moveRecording( return "", translate("recording the judgment", err) } - facts, err := factsOf(ctx, q, kase) + facts, err := r.factsOf(ctx, q, kase) if err != nil { return "", err } diff --git a/apps/server/internal/adapters/postgres/session.go b/apps/server/internal/adapters/postgres/session.go index a8ee92a..84fe229 100644 --- a/apps/server/internal/adapters/postgres/session.go +++ b/apps/server/internal/adapters/postgres/session.go @@ -45,6 +45,13 @@ func (r *Repository) SaveReview( } before := review.CaseState(kase.State) + // What the saver is looking at (ADR 0024): their lock's stamped edition, + // or the latest when nothing pins. + shown, err := r.displayedEdition(ctx, q, kase) + if err != nil { + return session.Result{}, err + } + for _, c := range save.Comments { created, err := q.CreateComment(ctx, sqlcgen.CreateCommentParams{ CaseID: caseID, StepID: c.StepID, Body: c.Body, AuthorID: by.ID, @@ -53,8 +60,13 @@ func (r *Repository) SaveReview( return session.Result{}, translate("recording a comment", err) } for _, variantID := range c.VariantIDs { + // Anchored to the bytes the reviewer is looking at (ADR 0024). + anchor := "" + if shown != nil { + anchor = *shown + } if err := q.AttachCommentVariant(ctx, sqlcgen.AttachCommentVariantParams{ - CommentID: created.ID, VariantID: variantID, + CommentID: created.ID, VariantID: variantID, EditionID: anchor, }); err != nil { return session.Result{}, translate("attaching a variant", err) } @@ -77,13 +89,13 @@ func (r *Repository) SaveReview( // last comment was settled was never looked at, and claiming otherwise // would make "who approved this" a lie. // - // A case pointing at no edition has nothing to remember yet. - if kase.CurrentEditionID == nil { + // A case showing no edition has nothing to remember yet. + if shown == nil { continue } if err := q.StampCaptureReference(ctx, sqlcgen.StampCaptureReferenceParams{ CaseID: caseID, StepID: capture.StepID, VariantID: capture.VariantID, - EditionID: *kase.CurrentEditionID, ApprovedBy: by.ID, + EditionID: *shown, ApprovedBy: by.ID, }); err != nil { return session.Result{}, translate("stamping the reference", err) } @@ -114,10 +126,10 @@ func (r *Repository) SaveReview( // Accepting had released the variant from the coverage // (ADR 0022); the take-back restores it, anchored to the bytes // on display at the pinned edition. - if kase.CurrentEditionID != nil { + if shown != nil { if err := q.RestoreCommentVariant(ctx, sqlcgen.RestoreCommentVariantParams{ CommentID: ref.CommentID, VariantID: capture.VariantID, - EditionID: *kase.CurrentEditionID, + EditionID: *shown, }); err != nil { return session.Result{}, translate("restoring the coverage", err) } @@ -153,10 +165,10 @@ func (r *Repository) SaveReview( return session.Result{}, translate("reading the settled remarks", err) } for _, remark := range remarks { - if kase.CurrentEditionID != nil { + if shown != nil { if err := q.RestoreCommentVariant(ctx, sqlcgen.RestoreCommentVariantParams{ CommentID: remark.ID, VariantID: capture.VariantID, - EditionID: *kase.CurrentEditionID, + EditionID: *shown, }); err != nil { return session.Result{}, translate("restoring the coverage", err) } @@ -197,34 +209,26 @@ func (r *Repository) SaveReview( } } - facts, err := factsOf(ctx, q, kase) + facts, err := factsOfAt(ctx, q, kase, shown) if err != nil { return session.Result{}, err } outcome := review.Compute(facts) - if outcome.State != before { - // The reviewer has let go, so the case catches up with whatever landed - // while they were looking. It was only held back to keep one fixed set - // of bytes under them (product.md §7). - if before == review.CaseToReview { - if err := q.ReleaseToLatestEdition(ctx, caseID); err != nil { - return session.Result{}, translate("releasing the case onto the latest edition", err) - } - // The caught-up edition can carry unjudged videos (ADR 0023): - // the state is derived against what the case now shows, not - // against the edition the reviewer just left. - released, err := q.CaseInProject(ctx, sqlcgen.CaseInProjectParams{ID: caseID, Slug: slug}) - if err != nil { - return session.Result{}, translate("re-reading the case", err) - } - released.State = string(before) - facts, err = factsOf(ctx, q, released) - if err != nil { - return session.Result{}, err - } - outcome = review.Compute(facts) + if outcome.State != before && before == review.CaseToReview { + // When a review settles, the state re-derives against the latest + // edition, past the saver's own lock (ADR 0024): the edition already + // waiting can carry unjudged videos (ADR 0023), and nothing later + // flips a state stamped blind to it. + latest, err := r.latestEditionID(ctx, q, kase.ProjectID) + if err != nil { + return session.Result{}, err + } + facts, err = factsOfAt(ctx, q, kase, latest) + if err != nil { + return session.Result{}, err } + outcome = review.Compute(facts) } if outcome.State != before { diff --git a/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go b/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go index cdcfcac..acb2885 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go @@ -16,13 +16,9 @@ INSERT INTO comment_variants (comment_id, variant_id, capture_id) SELECT $1, $2, ( SELECT cap.id FROM captures cap JOIN comments c ON c.id = $1 - JOIN cases k ON k.id = c.case_id WHERE cap.step_id = c.step_id AND cap.variant_id = $2 - AND cap.edition_id = coalesce( - k.current_edition_id, - (SELECT e.id FROM editions e WHERE e.project_id = k.project_id - ORDER BY e.created_at DESC, e.id DESC LIMIT 1)) + AND cap.edition_id = $3::text ) ON CONFLICT DO NOTHING ` @@ -30,6 +26,7 @@ ON CONFLICT DO NOTHING type AttachCommentVariantParams struct { CommentID string VariantID string + EditionID string } // The anchor is the capture the reviewer was looking at: the one of the @@ -37,8 +34,10 @@ type AttachCommentVariantParams struct { // the comment shows for as long as it lives — a step's name is a label, and // positions shift (#132). Null when the step and variant had no capture, which is what // there was to see. +// The edition rides in from the caller's resolver (ADR 0024): the pin is +// derived, never stored, so no query reads it off the case. func (q *Queries) AttachCommentVariant(ctx context.Context, arg AttachCommentVariantParams) error { - _, err := q.db.Exec(ctx, attachCommentVariant, arg.CommentID, arg.VariantID) + _, err := q.db.Exec(ctx, attachCommentVariant, arg.CommentID, arg.VariantID, arg.EditionID) return err } @@ -430,7 +429,7 @@ WITH latest AS ( LIMIT 1 ) SELECT - k.id, k.project_id, k.category_id, k.title, k.description, k.state, k.archived_at, k.created_at, k.updated_at, k.current_edition_id, + k.id, k.project_id, k.category_id, k.title, k.description, k.state, k.archived_at, k.created_at, k.updated_at, count(c.id) AS captures, max(e.created_at)::timestamptz AS last_edition FROM cases k @@ -450,18 +449,17 @@ type CasesWithCaptureCountsParams struct { } type CasesWithCaptureCountsRow struct { - ID string - ProjectID string - CategoryID *string - Title string - Description *string - State string - ArchivedAt pgtype.Timestamptz - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz - CurrentEditionID *string - Captures int64 - LastEdition pgtype.Timestamptz + ID string + ProjectID string + CategoryID *string + Title string + Description *string + State string + ArchivedAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + Captures int64 + LastEdition pgtype.Timestamptz } // Every case with the state of its captures at the edition it points at. @@ -487,7 +485,6 @@ func (q *Queries) CasesWithCaptureCounts(ctx context.Context, arg CasesWithCaptu &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.CurrentEditionID, &i.Captures, &i.LastEdition, ); err != nil { @@ -580,26 +577,38 @@ func (q *Queries) CategoryTreeWithCounts(ctx context.Context, projectID string) } const claimCaseLock = `-- name: ClaimCaseLock :one -INSERT INTO case_locks (case_id, account_id) -VALUES ($1, $2) +INSERT INTO case_locks (case_id, account_id, edition_id) +VALUES ($1, $2, $3) ON CONFLICT (case_id) DO UPDATE SET account_id = EXCLUDED.account_id, claimed_at = CASE WHEN case_locks.account_id = EXCLUDED.account_id - AND case_locks.beaten_at > now() - make_interval(secs => $3::int) + AND case_locks.beaten_at > now() - make_interval(secs => $4::int) THEN case_locks.claimed_at ELSE now() END, + -- The pin follows the claim (ADR 0024): a heartbeat keeps the bytes, a + -- fresh claim — opening the page, expiry included — re-stamps onto what + -- is current now. Reloading is leaving and coming back. + edition_id = CASE + WHEN NOT $5::boolean + AND case_locks.account_id = EXCLUDED.account_id + AND case_locks.beaten_at > now() - make_interval(secs => $4::int) + THEN case_locks.edition_id + ELSE EXCLUDED.edition_id + END, beaten_at = now() WHERE case_locks.account_id = EXCLUDED.account_id - OR case_locks.beaten_at < now() - make_interval(secs => $3::int) + OR case_locks.beaten_at < now() - make_interval(secs => $4::int) RETURNING case_id, account_id, claimed_at ` type ClaimCaseLockParams struct { CaseID string AccountID string + EditionID *string WindowSeconds int32 + Fresh bool } type ClaimCaseLockRow struct { @@ -614,7 +623,13 @@ type ClaimCaseLockRow struct { // holds it instead. The window rides in as seconds so expiry is read, never // written. func (q *Queries) ClaimCaseLock(ctx context.Context, arg ClaimCaseLockParams) (ClaimCaseLockRow, error) { - row := q.db.QueryRow(ctx, claimCaseLock, arg.CaseID, arg.AccountID, arg.WindowSeconds) + row := q.db.QueryRow(ctx, claimCaseLock, + arg.CaseID, + arg.AccountID, + arg.EditionID, + arg.WindowSeconds, + arg.Fresh, + ) var i ClaimCaseLockRow err := row.Scan(&i.CaseID, &i.AccountID, &i.ClaimedAt) return i, err @@ -946,7 +961,7 @@ func (q *Queries) GetCommentIssue(ctx context.Context, arg GetCommentIssueParams } const readCaseLock = `-- name: ReadCaseLock :one -SELECT l.account_id, l.claimed_at, u.name AS holder_name +SELECT l.account_id, l.claimed_at, l.edition_id, u.name AS holder_name FROM case_locks l JOIN users u ON u.id = l.account_id WHERE l.case_id = $1 @@ -961,13 +976,19 @@ type ReadCaseLockParams struct { type ReadCaseLockRow struct { AccountID string ClaimedAt pgtype.Timestamptz + EditionID *string HolderName string } func (q *Queries) ReadCaseLock(ctx context.Context, arg ReadCaseLockParams) (ReadCaseLockRow, error) { row := q.db.QueryRow(ctx, readCaseLock, arg.CaseID, arg.WindowSeconds) var i ReadCaseLockRow - err := row.Scan(&i.AccountID, &i.ClaimedAt, &i.HolderName) + err := row.Scan( + &i.AccountID, + &i.ClaimedAt, + &i.EditionID, + &i.HolderName, + ) return i, err } @@ -1085,22 +1106,24 @@ func (q *Queries) ReleaseCommentVariant(ctx context.Context, arg ReleaseCommentV return result.RowsAffected(), nil } -const releaseToLatestEdition = `-- name: ReleaseToLatestEdition :exec -UPDATE cases k -SET current_edition_id = ( - SELECT e.id FROM editions e - WHERE e.project_id = k.project_id - ORDER BY e.created_at DESC, e.id DESC - LIMIT 1 - ), - updated_at = now() -WHERE k.id = $1 +const restampLiveLock = `-- name: RestampLiveLock :exec +UPDATE case_locks +SET edition_id = $1 +WHERE case_id = $2 + AND beaten_at > now() - make_interval(secs => $3::int) ` -// A review that ends releases the case onto the project's most recent edition: -// it was only held back so the reviewer judged one fixed set of bytes. -func (q *Queries) ReleaseToLatestEdition(ctx context.Context, caseID string) error { - _, err := q.db.Exec(ctx, releaseToLatestEdition, caseID) +type RestampLiveLockParams struct { + EditionID *string + CaseID string + WindowSeconds int32 +} + +// A delivery advances the case at once (#142, ADR 0024): the live lock is +// re-stamped onto the latest edition; with no live lock there is nothing to +// do — the case already reads at the latest. +func (q *Queries) RestampLiveLock(ctx context.Context, arg RestampLiveLockParams) error { + _, err := q.db.Exec(ctx, restampLiveLock, arg.EditionID, arg.CaseID, arg.WindowSeconds) return err } diff --git a/apps/server/internal/adapters/postgres/sqlcgen/cases.sql.go b/apps/server/internal/adapters/postgres/sqlcgen/cases.sql.go index 5ccf09c..564e1d8 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/cases.sql.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/cases.sql.go @@ -31,7 +31,7 @@ func (q *Queries) ArchiveCase(ctx context.Context, arg ArchiveCaseParams) (int64 } const caseInProject = `-- name: CaseInProject :one -SELECT c.id, c.project_id, c.category_id, c.title, c.description, c.state, c.archived_at, c.created_at, c.updated_at, c.current_edition_id FROM cases c +SELECT c.id, c.project_id, c.category_id, c.title, c.description, c.state, c.archived_at, c.created_at, c.updated_at FROM cases c JOIN projects p ON p.id = c.project_id WHERE c.id = $1 AND p.slug = $2 ` @@ -57,7 +57,6 @@ func (q *Queries) CaseInProject(ctx context.Context, arg CaseInProjectParams) (C &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.CurrentEditionID, ) return i, err } @@ -66,7 +65,7 @@ const createCase = `-- name: CreateCase :one INSERT INTO cases (project_id, category_id, title, description) SELECT $1, $2, $3, $4 WHERE EXISTS (SELECT 1 FROM categories WHERE id = $2 AND project_id = $1) -RETURNING id, project_id, category_id, title, description, state, archived_at, created_at, updated_at, current_edition_id +RETURNING id, project_id, category_id, title, description, state, archived_at, created_at, updated_at ` type CreateCaseParams struct { @@ -100,7 +99,6 @@ func (q *Queries) CreateCase(ctx context.Context, arg CreateCaseParams) (Case, e &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.CurrentEditionID, ) return i, err } @@ -130,7 +128,7 @@ func (q *Queries) CreateStep(ctx context.Context, arg CreateStepParams) (Step, e } const listCases = `-- name: ListCases :many -SELECT id, project_id, category_id, title, description, state, archived_at, created_at, updated_at, current_edition_id FROM cases +SELECT id, project_id, category_id, title, description, state, archived_at, created_at, updated_at FROM cases WHERE project_id = $1 AND archived_at IS NULL AND ($2::text IS NULL OR state = $2::text) @@ -165,7 +163,6 @@ func (q *Queries) ListCases(ctx context.Context, arg ListCasesParams) ([]Case, e &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.CurrentEditionID, ); err != nil { return nil, err } @@ -211,7 +208,7 @@ UPDATE cases SET title = $2, description = $3, category_id = $4, updated_at = now() WHERE cases.id = $1 AND cases.project_id = (SELECT p.id FROM projects p WHERE p.slug = $5) -RETURNING id, project_id, category_id, title, description, state, archived_at, created_at, updated_at, current_edition_id +RETURNING id, project_id, category_id, title, description, state, archived_at, created_at, updated_at ` type UpdateCaseDetailsParams struct { @@ -241,7 +238,6 @@ func (q *Queries) UpdateCaseDetails(ctx context.Context, arg UpdateCaseDetailsPa &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.CurrentEditionID, ) return i, err } diff --git a/apps/server/internal/adapters/postgres/sqlcgen/intake.sql.go b/apps/server/internal/adapters/postgres/sqlcgen/intake.sql.go index d7536c1..fdaa8d6 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/intake.sql.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/intake.sql.go @@ -9,43 +9,6 @@ import ( "context" ) -const advanceCurrentEdition = `-- name: AdvanceCurrentEdition :many -UPDATE cases -SET current_edition_id = $1, updated_at = now() -WHERE id = ANY($2::text[]) - AND (current_edition_id IS NULL OR state <> 'to-review') -RETURNING id -` - -type AdvanceCurrentEditionParams struct { - EditionID *string - CaseIds []string -} - -// A new edition does not yank the ground from under a reviewer: a case sitting -// at `to-review` keeps pointing at what its reviewer is judging, and advances -// once that review ends (product.md §7). A case that points nowhere always -// advances -- there was nothing to protect. -func (q *Queries) AdvanceCurrentEdition(ctx context.Context, arg AdvanceCurrentEditionParams) ([]string, error) { - rows, err := q.db.Query(ctx, advanceCurrentEdition, arg.EditionID, arg.CaseIds) - if err != nil { - return nil, err - } - defer rows.Close() - items := []string{} - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - items = append(items, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const blobExists = `-- name: BlobExists :one SELECT EXISTS (SELECT 1 FROM blobs WHERE hash = $1) ` @@ -58,7 +21,7 @@ func (q *Queries) BlobExists(ctx context.Context, hash string) (bool, error) { } const casesByIDs = `-- name: CasesByIDs :many -SELECT id, project_id, category_id, title, description, state, archived_at, created_at, updated_at, current_edition_id FROM cases WHERE project_id = $1 AND id = ANY($2::text[]) +SELECT id, project_id, category_id, title, description, state, archived_at, created_at, updated_at FROM cases WHERE project_id = $1 AND id = ANY($2::text[]) ` type CasesByIDsParams struct { @@ -85,7 +48,6 @@ func (q *Queries) CasesByIDs(ctx context.Context, arg CasesByIDsParams) ([]Case, &i.ArchivedAt, &i.CreatedAt, &i.UpdatedAt, - &i.CurrentEditionID, ); err != nil { return nil, err } diff --git a/apps/server/internal/adapters/postgres/sqlcgen/models.go b/apps/server/internal/adapters/postgres/sqlcgen/models.go index aaee36f..db540d6 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/models.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/models.go @@ -50,16 +50,15 @@ type CaptureReference struct { } type Case struct { - ID string - ProjectID string - CategoryID *string - Title string - Description *string - State string - ArchivedAt pgtype.Timestamptz - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz - CurrentEditionID *string + ID string + ProjectID string + CategoryID *string + Title string + Description *string + State string + ArchivedAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz } type CaseLock struct { @@ -67,6 +66,7 @@ type CaseLock struct { AccountID string ClaimedAt pgtype.Timestamptz BeatenAt pgtype.Timestamptz + EditionID *string } type Category struct { diff --git a/apps/server/internal/app/session/session.go b/apps/server/internal/app/session/session.go index 71bc94c..d1f4fe5 100644 --- a/apps/server/internal/app/session/session.go +++ b/apps/server/internal/app/session/session.go @@ -61,7 +61,7 @@ type Result struct { // the transaction that guarantees it belongs in the adapter (backend ADR 0001). type Repository interface { SaveReview(ctx context.Context, slug, caseID string, by actor.Actor, save Save) (Result, error) - ClaimCase(ctx context.Context, slug, caseID string, by actor.Actor) (review.Hold, error) + ClaimCase(ctx context.Context, slug, caseID string, by actor.Actor, fresh bool) (review.Hold, error) ReleaseCase(ctx context.Context, slug, caseID string, by actor.Actor) error } @@ -73,8 +73,8 @@ func New(repo Repository) *Service { return &Service{repo: repo} } // Claim holds the case for this reviewer, or renews the hold — the same call // is the heartbeat (ADR 0005, #95). -func (s *Service) Claim(ctx context.Context, slug, caseID string, by actor.Actor) (review.Hold, error) { - return s.repo.ClaimCase(ctx, slug, caseID, by) +func (s *Service) Claim(ctx context.Context, slug, caseID string, by actor.Actor, fresh bool) (review.Hold, error) { + return s.repo.ClaimCase(ctx, slug, caseID, by, fresh) } // Release lets the case go; a lock not held answers nothing. diff --git a/apps/server/internal/app/session/session_test.go b/apps/server/internal/app/session/session_test.go index 70f52fd..1f43a3f 100644 --- a/apps/server/internal/app/session/session_test.go +++ b/apps/server/internal/app/session/session_test.go @@ -21,7 +21,7 @@ func (r refusingRepo) SaveReview( return session.Result{}, nil } -func (r refusingRepo) ClaimCase(context.Context, string, string, actor.Actor) (review.Hold, error) { +func (r refusingRepo) ClaimCase(context.Context, string, string, actor.Actor, bool) (review.Hold, error) { r.t.Error("the session reached the repository, want it refused first") return review.Hold{}, nil } @@ -41,7 +41,7 @@ func (r *recordingRepo) SaveReview( return session.Result{}, nil } -func (r *recordingRepo) ClaimCase(context.Context, string, string, actor.Actor) (review.Hold, error) { +func (r *recordingRepo) ClaimCase(context.Context, string, string, actor.Actor, bool) (review.Hold, error) { return review.Hold{}, nil } diff --git a/apps/server/internal/ports/http/lock.go b/apps/server/internal/ports/http/lock.go index 8afdee3..3c8cb62 100644 --- a/apps/server/internal/ports/http/lock.go +++ b/apps/server/internal/ports/http/lock.go @@ -41,7 +41,8 @@ func (s *Server) ClaimCase(ctx context.Context, request openapi.ClaimCaseRequest }, nil } - hold, err := s.session.Claim(ctx, request.Slug, request.CaseId, actorFrom(ctx)) + fresh := request.Body != nil && request.Body.Fresh != nil && *request.Body.Fresh + hold, err := s.session.Claim(ctx, request.Slug, request.CaseId, actorFrom(ctx), fresh) var held *review.Held switch { case errors.As(err, &held): diff --git a/apps/server/internal/ports/http/openapi/server.gen.go b/apps/server/internal/ports/http/openapi/server.gen.go index a259b68..e033f6a 100644 --- a/apps/server/internal/ports/http/openapi/server.gen.go +++ b/apps/server/internal/ports/http/openapi/server.gen.go @@ -950,6 +950,13 @@ type GetCaseCapturesParams struct { EditionId *string `form:"editionId,omitempty" json:"editionId,omitempty"` } +// ClaimCaseJSONBody defines parameters for ClaimCase. +type ClaimCaseJSONBody struct { + // Fresh Opening the page (true) re-stamps the hold onto the latest + // edition; the heartbeat (false, the default) keeps the bytes. + Fresh *bool `json:"fresh,omitempty"` +} + // UpdateCategoryJSONBody defines parameters for UpdateCategory. type UpdateCategoryJSONBody struct { Name *string `json:"name,omitempty"` @@ -1042,6 +1049,9 @@ type CreateCaseJSONRequestBody = NewCase // UpdateCaseJSONRequestBody defines body for UpdateCase for application/json ContentType. type UpdateCaseJSONRequestBody = CaseUpdate +// ClaimCaseJSONRequestBody defines body for ClaimCase for application/json ContentType. +type ClaimCaseJSONRequestBody ClaimCaseJSONBody + // SaveReviewJSONRequestBody defines body for SaveReview for application/json ContentType. type SaveReviewJSONRequestBody = ReviewSave @@ -4503,6 +4513,7 @@ func (response ReleaseCase404ApplicationProblemPlusJSONResponse) VisitReleaseCas type ClaimCaseRequestObject struct { Slug string `json:"slug"` CaseId string `json:"caseId"` + Body *ClaimCaseJSONRequestBody } type ClaimCaseResponseObject interface { @@ -7612,6 +7623,16 @@ func (sh *strictHandler) ClaimCase(w http.ResponseWriter, r *http.Request, slug request.Slug = slug request.CaseId = caseId + var body ClaimCaseJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + if !errors.Is(err, io.EOF) { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + } else { + request.Body = &body + } + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.ClaimCase(ctx, request.(ClaimCaseRequestObject)) } @@ -8468,247 +8489,248 @@ func (sh *strictHandler) WhoIsTheToken(w http.ResponseWriter, r *http.Request) { // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/apps/web/src/features/review/useReview.ts b/apps/web/src/features/review/useReview.ts index 50b41ea..f0a06de 100644 --- a/apps/web/src/features/review/useReview.ts +++ b/apps/web/src/features/review/useReview.ts @@ -131,9 +131,10 @@ export function useReview(slug: () => string, caseId: () => string) { /** Claim the case, or keep holding it — the same call is the heartbeat. * A 423 means somebody else reviews: the page turns read-only. */ - async function claim() { + async function claim(fresh = false) { const result = await api.POST('/projects/{slug}/cases/{caseId}/lock', { params: { path: { slug: slug(), caseId: caseId() } }, + body: { fresh }, }) if (result.response.status === 423) { const found = await api.GET('/projects/{slug}/cases/{caseId}', { diff --git a/apps/web/src/pages/case/CasePage.vue b/apps/web/src/pages/case/CasePage.vue index a3809a7..42ab0d9 100644 --- a/apps/web/src/pages/case/CasePage.vue +++ b/apps/web/src/pages/case/CasePage.vue @@ -114,10 +114,12 @@ watch( params: { path: { slug: slug.value } }, }) categories.value = tree.error ? [] : tree.data + // Opening the case is claiming it (ADR 0005, #95): a fresh claim, so + // the hold stamps what is current now (ADR 0024) — before the first + // read, or the grid would show what a previous hold pinned. The + // interval is the heartbeat, and leaving the page lets go. + await review.claim(true) await review.load() - // Opening the case is claiming it (ADR 0005, #95); the interval is the - // heartbeat, and leaving the page lets go. - await review.claim() loading.value = false }, { immediate: true }, diff --git a/apps/web/src/shared/api/schema.gen.ts b/apps/web/src/shared/api/schema.gen.ts index bebbce0..0226822 100644 --- a/apps/web/src/shared/api/schema.gen.ts +++ b/apps/web/src/shared/api/schema.gen.ts @@ -2720,7 +2720,17 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": { + /** + * @description Opening the page (true) re-stamps the hold onto the latest + * edition; the heartbeat (false, the default) keeps the bytes. + */ + fresh?: boolean; + }; + }; + }; responses: { /** @description The caller holds the case. */ 200: { diff --git a/docs/adr/0024-the-pin-follows-the-lock.md b/docs/adr/0024-the-pin-follows-the-lock.md index 65a8f6d..7bbec67 100644 --- a/docs/adr/0024-the-pin-follows-the-lock.md +++ b/docs/adr/0024-the-pin-follows-the-lock.md @@ -27,7 +27,11 @@ shows, the server answers from what is true at that moment: - otherwise → the project's latest edition. `case_locks` gains `edition_id`, stamped at claim, kept through heartbeats, -re-stamped on a fresh claim — including after expiry. `cases.current_edition_id` +re-stamped on a **fresh claim** — opening the page, expiry included. The +client says which is which: the on-load claim is fresh, the interval is a +heartbeat. Reloading is leaving and coming back — without the distinction, +a reload raced its own release and showed one edition or the other by +timing. `cases.current_edition_id` is removed with its two maintenance mechanisms (`AdvanceCurrentEdition`, `ReleaseToLatestEdition`); intake stops skipping `to-review` cases.