Skip to content

feat(events): edit a published geolocation as a new revision - #293

Merged
vidit-admin merged 27 commits into
mainfrom
claude/geoloc-edit-history-76c973
Aug 19, 2026
Merged

vidit-admin merged 27 commits into
mainfrom
claude/geoloc-edit-history-76c973

Conversation

@vidit-admin

@vidit-admin vidit-admin commented Aug 18, 2026 •

Copy link
Copy Markdown
Member

What

A geolocated event was frozen. An analyst who mistyped a coordinate while confirming detections had no way to fix it, and no path that would have preserved what the record said before the fix.

This adds the revision model and the owner's correction path.

  • events.revision_no says which version the live row is. It starts at 1 (every existing row backfills there) and moves forward one step per correction. A version number is a public address, so it only ever increases and a revision is never deleted.
  • event_revisions is append-only: one row per superseded version, holding who edited, when, an optional note, and a JSONB snapshot of the editable fields as they stood. History reads as "the snapshots, then the live row", so the publication paths (create_with_evidence, geolocate, _publish_detection) are untouched: version 1 is the published row itself.
  • POST /events/{id}/revise (owner-only, geolocated-only) files the snapshot, applies the edit and increments the row, in one transaction under the same row lock close and geolocate take, so two concurrent edits take their numbers in order rather than racing.
  • GET /events/{id}/revisions reads the superseded versions back, newest first. Public, like the event: a corrected record is auditable only when its corrections are readable.
  • /events/{id}/edit no longer turns a published row away. One form, two shapes, selected by the row's state: Submit detection as before, or Edit geolocation with an optional edit note and a Save revision that writes on the click that made it. The event page gains Edit this geolocation for its author, behind the existing ⋯ menu, and prints v2 beside the byline once an event has been corrected.

/events/{id}/history and /events/{id}/vN land here too; see Version history below.

The editability contract

After publication the evidence anchor is immutable: source_url and the source media are what the published claim rests on. The endpoint declares no field for either, so it cannot be asked to move them, and the form renders both read-only under a lock marker whose ? says why. A wrong source is still handled by closing the event with a reason and posting it again, or by an admin.

Everything else the publish form wrote is editable and versioned: title, both coordinate sets, the event date and hour, the source post time, the graphic flag (still ratcheting), tags, conflicts, the proof body with its inline images, and the secondary source links, which are mirrors rather than the evidence origin and sit outside the anchor. source_snapshot_url is accepted, since it archives the anchor rather than changing it.

Two invariants ride with it:

  • The published floor is re-checked on the post-edit state, so a correction cannot drop the row below what publishing it required: a source media on the row, a proof image in the final body, one conflict, one capture_source tag.
  • A proof image a past version still shows is never deleted. Media are not versioned, so the shared intake asks the snapshots before dropping a proof row the current body no longer references.

Errors: 403 for anyone but the owner, 404 for a soft-deleted or withheld row, 409 invalid_state off geolocated, and the existing 400 floor codes (invalid_coordinates, invalid_proof, proof_image_required, tag_requirements_not_met, media_required, too_many_source_links, the file and snapshot codes). 422 caps note at 280 characters.

Tests

  • Backend, tests/events/test_revisions.py (10 new): the happy path (snapshot holds the pre-edit state, row moves to version 2), owner-only 403, 409 off each of detected / requested / closed, the anchor unmoved when source_url / files / remove_media_ids are posted anyway, the floor held on a dropped conflict and an image-less proof, a proof image kept because a version still shows it, the history read (ordering, note, editor, empty before any edit, 404 unknown), and two concurrent revises taking their numbers in order under the row lock.
  • Frontend, app/events/[id]/edit/page.test.tsx (6 new): the lifted gate, the read-only anchor, saving on one click and landing on the event, the note posted and the anchor never assembled, the floor held before posting, and a state with no owner edit.

Review fixes

Round 1

Follow-up commit on the same branch, one item per review finding.

  1. Owner actions are scoped per surface. TIERS.event.owner put "Close this request" and "Delete this request" on /events/{id}, which serves rows of any status. The owner tier splits into revise (event page) and dispose (request page); new useEventActions.test.tsx pins that a requested or closed row on the event surface offers no request verb.
  2. A snapshot pins only the images its own body displayed. build_snapshot recorded every role == "proof" row, so the snapshot the revise had just staged claimed every existing image and nothing was ever deleted, while later versions claimed images they never showed. proof_media is now the intersection with extract_image_srcs(geo.proof), and the keep set is the current body plus what readable (non-redacted) versions display.
  3. source_posted_at is optional on revise. _publish_detection publishes with the column NULL, so the required field made such a row uneditable. Absent or empty keeps NULL; the frontend floor takes a requireSourcePostedAt option, false in revise mode and true on the confirmation path, matching geolocate.
  4. The proof-image cap bounds the event, not the request. max_proof_images_per_event now counts the rows this write keeps plus the files it adds, before anything reaches S3. Message and api.md updated.
  5. An admin reads a withheld row's history. GET /events/{id}/revisions mirrors get_event's optional-current-user branch.
  6. Redaction, shipped. POST /admin/events/{id}/revisions/{revision_no}/redact, admin only, audited as event_revision_redacted, idempotent. Blanks snapshot and note, stamps redacted_at / redacted_by_id, and keeps the row, its number, its date and its byline so /vN never shifts. EventRevisionRead gains redacted. A redacted version contributes nothing to the media keep set, so redacting the last version that displayed a proof image deletes that image, row and object, on the same commit-then-sweep discipline. Migration g5i7k9m1o3q5.
  7. The wrong-source remedy is reachable. close rejects a geolocated row, so the "close it and post it again" copy pointed nowhere. fieldHelp.evidence_anchor, the revise docstring, api.md and CHANGELOG.md now say a wrong source on a published event is an admin matter.
  8. "Freezes" is gone. CONFIRM_SENTENCE, fieldHelp.action_submit, the EventEditInput doc comment and the EventStatus comment say publishing makes the event public and later changes become versions, with the source fixed. Tests that pinned the old sentence updated.
  9. The history is paged. services/pagination cursor, Link: rel="next", limit clamped at 100, ordered created_at DESC, id DESC (which is revision_no DESC on an append-only per-event history written under the row lock). total is a real count, replacing MAX_REVISIONS = 200 truncating with total = len(items).
  10. AGENTS.md lists lib/events.ts::EDIT_NOTE_MAX_LEN mirroring schemas/event.EDIT_NOTE_MAX_LENGTH.

One deviation from the brief: the redact verb lives in routers/admin.py under /admin/events/... rather than on the public events router, to sit beside PATCH /admin/events/{id}/moderation and share require_admin plus log_admin_event.

Round 2

Second follow-up commit, one item per finding. Items 4 and 9 above are superseded by items 1 and 4 here.

  1. The proof-image ceiling counts what the new body displays. Counting the rows a write keeps charged the owner for images pinned only so an old version stays renderable, so an owner who swapped images across corrections spent the quota permanently with nothing left to free. attach_evidence_and_commit now counts the already-uploaded images the final proof body still references plus the files it adds; history-pinned rows count for nothing. Message, api.md (revise + geolocate) and the cap test updated, plus a new test that two image swaps in a row both pass on a one-image cap while three rows survive on the event.
  2. A blank source_posted_at keeps the stored instant. parse_optional_iso_datetime maps a missing and an empty field alike to None and the client always posted the field, so an owner who cleared the input silently wiped the instant a published record was vouched with. revise reads None as "keep": only a parsed value replaces the column, and appendEventFormFields omits the field rather than posting an empty string. Tests: blank keeps the stored date, absent keeps it, a value replaces it, and a NULL row stays NULL through a blank. Frontend test on the assembly, both legs.
  3. A proof body may only display this event's own stored images. _safe_image_src checks that an image URL lives on the media host, which says nothing about whose it is, so event B could embed event A's proof image and A's next revise or redact would delete the object out from under B. New _reject_foreign_proof_srcs runs on every path through the shared intake: an src the storage layer wrote (key_from_url resolves it) has to match one of this event's role='proof' rows, otherwise invalid_file (400). Create and geolocate carry placeholders or the event's own rows, so they are unaffected; the whole backend suite confirms it. Tests: a foreign URL is rejected and files no version, the event's own image is accepted.
  4. The history orders and pages on revision_no. The keyset ran on created_at, which the application clock sets and which therefore skews between instances, while the endpoint claimed revision_no order. list_revisions now orders and cuts on revision_no (unique per event, taken under the row lock, so no tiebreaker column is needed). services/pagination gains encode_ordinal_cursor / decode_ordinal_cursor beside the (created_at, id) pair, and next_link takes the encoded cursor so one function still builds every Link header. Tests: a history whose version 2 is stamped a day before version 1 still reads and pages 3, 2, 1, and a malformed cursor is a 422.

No deviations from the brief.

Version history

The corrections were filed and served, and nothing read them: a published event printed v3 beside its byline with no way to see what versions 1 and 2 said. Two pages close that, both public, like the endpoint and the event itself.

  • A History button sits beside the version pill on a published event, for every reader. It is not an owner control and it sits outside the three action tiers: the tiers act on the record, this one reads it.
  • /events/{id}/history is one row per version, newest first: the version number, what its edit changed, then the editor, the date and their note. The whole row is one click, the model every catalogue row uses. A past version opens its own address; the current version opens /events/{id}, because that is where the record as it stands is read. A version an admin redacted keeps its number, its byline and its link, and says so.
  • /events/{id}/vN renders the record as that version stood, through the same body the canonical page renders (EventPageBody, fed either the live row or snapshotToEventView(current, revision)), so a version cannot drift into a layout of its own. An amber banner opens it, and the action cluster is absent: sharing, reporting and editing act on the record.
  • GET /events/{id}/revisions/{revision_no} is the direct read behind a /vN address, public and visibility-gated exactly like the list. Thin router over revisions.get_revision. The current version's own number answers 404, since the live row is not filed; a redacted version answers with its blanked shape rather than 404, because the version exists and the record still shows that it does.

A row is credited to the edit that produced it

This is the one interpretation worth flagging. A revision row carries the content of the version it holds alongside the byline, date and note of the edit that superseded it, so a row rendered from one payload alone would pair version 2's content with the note about the edit that made version 3. The list pairs version n's content with row n - 1's credit instead, the way a page history reads: who made this version, when, their words about it, and the fields it moved, all describing one edit. Version 1 was published rather than edited, so it reads Published and carries the record's own author and date.

Two consequences:

  • The changed fields are computed client-side from the two adjacent versions, since the API serves what each version held rather than what an edit did. They print under the names the event page already uses for the same values (Title, Coordinates, Proof), over the field set build_snapshot files. Tags and conflicts compare by identity, so a referential row renamed under a published event changes no version, and the source post time compares as an instant, so +00:00 against Z is not an edit.
  • The walk holds its oldest row back. A version's credit is filed on the version below it, so while Link: rel="next" says there are more pages the oldest loaded row is credit for the row above it rather than a row of its own. It appears once Load more brings the page that completes it: a row is whole or absent, never a version number with no editor beside it.

Routing and canonicality

/events/{id} stays canonical. A version page carries robots: noindex and a canonical link to the event, and asking for the current version's own number forwards there rather than serving the record at two addresses. The route is a [version] segment beside the static edit and history ones, which keeps /events/{id}/vN a one-segment address; Next matches a static sibling first, so edit and history keep their own routes, and parseVersionSegment refuses anything that is not v followed by a version number, which is a 404, as is a number past the current version.

useCursorList gains an optional reader for the rows of a page payload, since the history endpoint answers {items, total} rather than a bare array. The default is unchanged for its two existing callers.

Tests

  • Backend, tests/events/test_revisions.py (4 new): the direct read by number, 404 outside the filed history (the current version's number, an unknown number, 0, an unknown event), a redacted version served blanked rather than missing, and the withheld-row branch.
  • Frontend: lib/events.versions.test.ts (19) covers the segment parse, the snapshot mapper (the overlay, the immutables, mirrors paired with the copy archived for their own URL, a conflict falling back on its stored name, a redacted {} snapshot), the changed-field diff, and the version assembly including the held-back row and the redaction cases. app/events/[id]/history/page.test.tsx (7) and app/events/[id]/[version]/page.test.tsx (6) cover the rows, the credit pairing, the link targets, Load more, the banner, the redaction notice, the forward to the canonical page and both 404 branches.

Gates

Gate Result
ruff check / ruff format --check / mypy / vulture pass
pytest -n auto pass, 1383 passed / 3 skipped
alembic upgrade head pass
npm run lint pass, 0 errors (30 pre-existing warnings)
npx tsc --noEmit pass
npm test pass, 599 passed
npm run build pass
make gen-api-types + git diff --exit-code pass, in sync
make hygiene (jscpd under the 1% cap, knip, palette coverage, video routes) pass

The event_revisions Mermaid block in data-model.md was rendered locally with @mermaid-js/mermaid-cli before committing.

🤖 Generated with Claude Code

vidit-admin and others added 27 commits August 18, 2026 22:44
A `geolocated` event was frozen, so an analyst who mistyped a coordinate
had no way to fix it and no path that preserved what the record said
before. `POST /events/{id}/revise` is the owner's correction path: it
files the pre-edit state as an append-only `event_revisions` row, applies
the edit, and moves `events.revision_no` on, in one transaction under the
same row lock `close` and `geolocate` take.

The evidence anchor stays immutable. `source_url` and the source media
are what the published claim rests on, so the endpoint declares no field
for either and the form renders both read-only. Everything else is
editable and versioned: title, both coordinate sets, the event date and
hour, the source post time, the graphic flag, tags, conflicts, the proof
body with its inline images, and the secondary source links.

`GET /events/{id}/revisions` reads the superseded versions back, newest
first. Media are not versioned, so a proof image any snapshot points at
survives its removal from the current body and history stays renderable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…redact one

Review fixes on the published-geolocation edit path.

A snapshot recorded every proof media row on the event instead of the images
its own proof body referenced. Because the intake reads the snapshots after the
revise stages one, the fresh snapshot claimed every existing row: no proof
image was ever deleted, and later versions claimed images they never displayed.
A snapshot now carries the images its body referenced, so the keep set is the
current body plus what readable versions display.

Redaction lands with it. `POST /admin/events/{id}/revisions/{revision_no}/redact`
blanks one filed version in place: the snapshot and the note go, the row, its
number, its date and its byline stay, and the history lists it as `redacted`. A
redacted version displays nothing, so redacting the last version that showed a
proof image deletes that image, row and object. Admin only, audited, idempotent.

Also:

* `max_proof_images_per_event` bounds what the event ends up carrying (kept
  rows plus new uploads), not one request's batch.
* `source_posted_at` is optional on revise, matching what publication accepts:
  a detection published with the column NULL was otherwise uneditable. The
  frontend floor stops flagging it in revise mode, and still requires it on the
  confirmation path, as geolocate does.
* `GET /events/{id}/revisions` is cursor-paged through the shared pagination
  vocabulary, with `total` counting the whole history, replacing a 200-row
  truncation that misreported its own total. An admin reads a withheld row's
  history, the same branch `GET /events/{id}` takes.
* The owner tier of the action cluster is split per surface: the event page
  carries the published correction, the request page carries closing and
  deleting. `/events/{id}` serves rows of any status, so an unscoped tier put
  "Close this request" on a row that was not a request.
* Copy: a wrong source on a published event is an admin matter, since `close`
  rejects a `geolocated` row; and publishing makes the event public with later
  changes becoming versions, rather than freezing it.
* `AGENTS.md` lists `lib/events.ts::EDIT_NOTE_MAX_LEN` among the FE/BE mirrors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
Four review findings on the revise path.

The proof-image ceiling counted the rows a write keeps, history-pinned ones
included, so an owner who swapped images across corrections consumed the quota
permanently with nothing left to free. It now counts what the post-write body
displays: the already-uploaded images it still references plus the new files.

`source_posted_at` arrived as None for both an absent and an empty field, and
the client always posted it, so blanking the input wiped the instant a
published record was vouched with. None now means keep; only a value replaces.

An already-uploaded image src was admitted on host alone, so event B could
embed event A's proof image and A's next revise or redact would sweep the file
out from under B. Intake now requires such an src to name one of this event's
own proof rows, on every write that attaches evidence.

The history keyset ordered on `created_at`, an application clock that skews
between instances, while the endpoint claimed `revision_no` order. It orders
and pages on `revision_no`, unique per event and taken under the row lock;
`next_link` takes the encoded cursor so both cursor shapes build one header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
A published event's corrections were filed and served, and nothing read
them. A History button beside the version pill opens /events/{id}/history,
one row per version newest first, and each row opens /events/{id}/vN, which
renders the record as that version stood through the same body the canonical
page renders.

A row is credited to the edit that produced it: a revision row carries the
content of the version it holds alongside the byline, date and note of the
edit that superseded it, so the list pairs version n's content with row
n - 1's credit. Version 1 was published rather than edited. The changed
fields are computed client-side from the two adjacent versions and print
under the names the event page already uses for the same values.

GET /events/{id}/revisions/{revision_no} is the direct read behind a /vN
address, public and visibility-gated exactly like the list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
Version 1 is the publication, so it carries `geolocated_at` rather than
`created_at`, which stamps the submission or the detection. A version
above 1 whose producing revision could not be read states neither byline
nor date instead of borrowing the record's own.

A filed version is read, not acted on: the body renders read-only on
`/events/{id}/vN`, so its owner is offered no archive action on a link
the live row may no longer carry.

Tags and conflicts compare as sets of ids, the relationship being
unordered, so a reordered read announces no changed field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…y menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…edit form

The coordinate pair's View on Maps and copy move out of a line of their own
and into a third column beside the two fields, as icon buttons: checking a
coordinate against imagery is a move to the right rather than a jump down. The
cell holds the buttons' width while the pair is half-typed, so the row does not
jump, and below `sm` it spans the pair and wraps under it. The event page's
coordinates row keeps the text link, where it reads as part of a sentence.

Utilities become a per-surface tier. The edit form carries none: sharing or
reporting a row one is in the middle of rewriting acts on a record that is not
the one on screen. Its header cluster is its own controls alone (queue
position, Skip, Reject), and a surface whose every tier is off gets no row at
all rather than an empty flex item.

The utilities themselves lose the copy-link half. The address is in the address
bar the reader is already looking at, so the share row is the X intent alone,
on `useConfirmAction` now that one arming mechanism covers the whole row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…ir parses

The map link and the copy are two square ghost icon buttons on both surfaces
now, so the control a reader learns beside the coordinate fields is the one
they meet again on the event page's coordinates line. The text variant is gone
with the `compact` prop that selected it.

A pair that does not parse greys them instead of removing them: the map link
becomes a disabled button with nowhere to navigate and the copy takes
`CopyButton`'s new `disabled`, so the cell holds one width, the row never jumps
as the second half of a coordinate is typed, and a greyed control says the
point is not usable yet where a vanished one said nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…ed buttons

Colour is the state of a control: accent while it acts, neutral grey when it
cannot. Two places broke the rule.

`<Glyph>` is the new primitive for a bare 13px mark set in a line of text, the
shape the archived-copy mark already carried beside a source link. It picks its
own paint off what it renders (ACTION_GLYPH accent for a link or a button,
MUTED_GLYPH grey otherwise) and renders inert whatever it was handed while
`active` is false, so a glyph cannot be accent and dead or grey and clickable.
`ArchivedCopies` and `CoordinateActions` compose it; the coordinate actions drop
their square ghost buttons for the same marks, and the copy pairs the primitive
with `useCopyToClipboard` rather than growing a second shape on `CopyButton`.

`ArchivedCopies` becomes a read surface with two states. Recording a copy is an
edit, so it happens in the edit form's archived-copy field and files a revision,
which removes the event-page popover, its inline record form and the
`canArchive` / `eventId` / `readOnly` plumbing behind it.

A disabled `<Button>` drops the variant's tone for grey text on a neutral border
instead of a half-opacity accent, so it reads like every other inert control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
Which of a published record's links are archived is part of what that
record says, so `POST /events/{id}/archives` on a `geolocated` row files
the version it supersedes and moves `revision_no` on, credited to the
analyst who recorded the copy. `services/revisions.file_version` is the
one place a version comes to be, shared with `revise`, and every snapshot
carries the copies its version held, so `/events/{id}/vN` renders them as
they stood and the changed-field list names *Archived copies*.

Re-recording the copy a link already carries moves nothing and files
nothing; a `source_snapshot_url` pasted with an edit rides that edit's
version rather than filing a second one; a `requested` or `detected` row
is not versioned, so its copies are stored on their own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
A secondary source rots exactly as the primary does, so every link the
submit and edit forms declare now carries the same archival brick rather
than the source alone.

Backend: the create, request, geolocate and revise forms take
`secondary_snapshot_urls`, one repeated entry per `secondary_source_urls`
entry and aligned with it by position. `pair_secondary_snapshots` pairs
them on the raw lists, before normalization drops the blank, duplicate and
primary-equal rows that would shift every later index, and
`stage_secondary_snapshots` files each against the mirror it claims to
archive under origin `secondary_source`, through the same
`validate_snapshot` and the same staging helper the source snapshot uses.
A revise therefore files one revision whose new version carries the copy.

Frontend: `LinkListInput` gains `companion`, a second value per row it
keeps index-aligned through adds and removals, and `ArchiveMirrorField`
renders that value as the same brick `ArchiveSourceField` is built from,
minus the chrome a list cannot repeat. The pre-submit host check covers
every paste on both forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
`useCopyToClipboard` is the one home for the clipboard write and the flash
timer, so a second component wrapping it in one particular button shape was
vocabulary the catalogue did not need: its last caller was the profile's
Discord account. That row now renders the brand mark as a `<Glyph>` over
the hook directly, the shape `<CoordinateActions>` already uses, keeping
the part no copy control may differ on: a static accessible name, a
tooltip and mark that flip for the flash, and a sibling `role="status"`
region announcing the write.

`ACTION_GLYPH` and `MUTED_GLYPH` move into `Glyph.tsx` as module-private
constants. `<Glyph>` was their only consumer and picks between them off
what it renders, so they were an implementation detail sitting in the
shared palette vocabulary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…wMenu removed

The author's own verbs are the ones they reach for most, so a `⋯`
disclosure over two of them cost a click on every use to hide what the row
had width for. The request page now carries Close as a ghost icon button
and Delete as a red one, beside the pencil the event page already showed
in the open. What the destructive verb gets instead of a hiding place is a
colour and a second click: `useConfirmAction` with the loud
`DANGER_CONFIRM` fill while armed, in place of a `window.confirm` dialog,
disarming on a timeout, on Escape and on any click landing elsewhere.

The map side panel drops its actions entirely. It previews a row whose own
page is one click away on the title, so sharing or reporting from a
hover-sized preview put the same controls in two places.

`OverflowMenu` had no other caller, so it goes with them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
"revision" was internal jargon for what every reader-facing surface already
called a version: the UI says "Version 2 of 3" and the record is addressed at
`/events/{id}/vN`. The code said something else, so a contributor met two names
for one concept on every path between the form and the table.

One word now, end to end. The table is `event_versions`, the columns are
`version_no`, the model is `EventVersion`, the service is `services/versions`,
and the owner's correction is `save_version`, named for what it does. The edit
posts to `POST /events/{id}/versions`, the REST-clean address: the edit creates
a version, so it creates it under the collection the two reads already serve
(`GET /events/{id}/versions`, `GET /events/{id}/versions/{version_no}`), and the
admin redaction sits under the same collection. The note an edit carries is a
version note throughout: `version_note`, `VERSION_NOTE_MAX_LENGTH`, and the
field's own label.

Nothing is in production, so the two migrations that create the table and the
columns are edited in place rather than followed by a rename migration. A
database that already ran them is out of step with this branch and needs the
rename applied by hand.

`revision` survives only where it is Alembic's own word (`down_revision`, a
migration's `revision =`, `alembic revision`) or git's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
The edit form posts the whole editable state, so saving it without touching a
field asked the server to mint a version identical to the row it superseded: a
number spent in a public address space, a history row with an empty changed-field
list, and a record claiming a correction that never happened. And nothing bounded
how many times that could happen.

**A version has to change something.** The service compares the incoming state
against the live row under its lock, on the versioned fields plus the archived
copies, and raises `nothing_changed` (409) before the version is filed and before
any file is uploaded, so a refused edit stages nothing. The comparison reads
`services/versions.COMPARED_FIELDS` off the same snapshot the filing writes, so
the check and the record cannot come to disagree about what a version carries.
The note is not a versioned field: it annotates a change, and on its own there is
none to annotate. The form runs the same check first (`hasVersionChanges`, which
is `changedFields` over a candidate built from the form state, so the two field
lists are one), so an untouched save costs no request, and both refusals read
"Nothing changed since version N". The save button now names the number it would
produce, so the reader sees what they are about to create.

**An event carries at most 100 versions.** The ceiling lives in `file_version`,
the one place a version comes to be, so both writers meet it: the owner's edit
and an archived copy recorded on a published row. Past a hundred the history is
recording a loop rather than corrections, and every version costs a snapshot row
plus the proof images its body pins alive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…t hold

`<input type="datetime-local">` stops at the minute and `<input type="time">`
drops the seconds, and the edit form posted both back verbatim. On a row whose
source post time carries seconds, which is what a detection carries, that made
every save a real write: correcting a typo silently truncated the instant the
published record was vouched with, and an untouched save moved a versioned field
and so filed a version instead of being refused.

A field still holding what the row seeded it with is now left alone.
`source_posted_at` is omitted, which this endpoint alone reads as "keep what the
row holds"; `event_time` has no such contract (an absent value clears it), so it
goes back at the row's own precision instead. The submit path still posts both
verbatim, where there is no stored value to preserve. `hasVersionChanges` reads
the same rule, comparing at the input's precision, so an untouched save raises
the banner rather than the request. A blanked field keeps the row's value on
both sides, the way the endpoint reads an absent one.

The published fixture now carries seconds on both columns, so the untouched-save
test is a regression test for this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
A field's own actions belong inside it. `<Input>` gains a `trailing` slot:
content overlaid at the field's right edge, centred on its height, with the
text padding grown by `TRAILING_ROOM` so a long value never runs under it.
`FieldAdornment` and that constant are exported for `<LockedUrl>`, the one
field that renders as an anchor and has to clear the same marks.

Three fields take it. The coordinate pair drops its third grid column and
carries `<CoordinateActions>` in the longitude field, so the marks cost no
column on a phone and no line under the pair. `<DateTimeInput>` is the new
brick for the event date, the event time and the source post time: it hides
the browser's own picker button (`.picker-glyph`) and opens the same native
picker from an accent glyph, and owns `has-value` so no call site derives it.

Archiving moves into the field holding the link it archives. The block under
the Source URL is gone, label, optional marker and hosts sentence with it:
`<ArchiveAdornment>` is the mark inside the field, never grey on a form, and
`<ArchiveSnapshotField>` is the one-field line it opens, its placeholder
stating the whole contract off `SNAPSHOT_HOSTS`. A link that already carries a
copy shows the mark opening it beside the mark that replaces it, since one
link holds one copy. `<LinkListInput>` keeps each row's mark, paste and open
line aligned with its link across every add and removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
`<Glyph>` answers the pointer the same way in both its forms: the accent
lightens. `TEXT_LINK`'s hover is an underline, which a mark carrying no text
cannot show, so a glyph reacted on a text link and stayed dead as an icon.
Colour is the one channel it has, and the same rise on the link form and the
button form keeps a navigating mark and an acting one reading as one offer.
An inert glyph takes no hover, since nothing there answers.

The profile header becomes that one shape throughout: the linked accounts drop
their square ghost boxes for bare marks, and Edit profile joins them as a
`Pencil` glyph in the same row. A box around any of them outweighs the handle
the page is titled with, and a boxed Edit beside four marks reads as a tier of
action it does not belong to.

`/events/{id}/history` drops the event link from its header. The `Current` row
already opens the event, and a second way there is one control the reader has
to tell apart from the other; the subtitle keeps the title as plain text.

Every icon-only control now carries a tooltip equal to its accessible name:
the back button, a link-list row's remove, the file remove, the map panel's
close and the scrubber's reset were the ones still missing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…he source tooltip

A `<Glyph>` is now `<Button>` ghost at a smaller stop: a 13px mark centred in a
24px rounded square that lightens its accent under a tinted plate on hover. An
inert glyph keeps the same square, so a mark that lands or goes inert never
moves the line it sits in, and takes no plate, since nothing there answers.

Every host is resized around that box. `FieldAdornment` insets by the gutter the
box leaves above and below it rather than by the field's text padding, sets two
marks a hair apart so their plates read as two controls, and `TRAILING_ROOM`
grows to clear the widest adornment. `CoordinateActions` and the profile header
row space off the same box; `ArchivedCopies` centres its box on the line, which
the baseline-aligned source rows would otherwise hang it off.

The archive `?` folds into the row that carries the mark. `source_url`,
`secondary_source_urls` and `detected_from` each describe the archive mark on
their own row, so an expanded list of ten mirrors shows one explanation rather
than ten, and the `archived_copies` concept and the `help` prop are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
Every icon control on the site is now the ghost icon button: the same 32px
square with the same hover plate on page-header clusters, the profile header
row, the event page's coordinates line, the archive mark beside a source link
and every field adornment. An action is `<Button icon variant="ghost">`,
navigation is `buttonClasses("ghost", { icon: true })` on a link, and a control
with nothing to act on is that button `disabled`, which the primitive already
paints neutral grey.

The `Glyph` primitive, its test, its `/palette` entry and its `docs/design.md`
paragraph go with it, `MUTED_GLYPH` and `ACTION_GLYPH` included.

Fitting the larger square: `TRAILING_ROOM` grows to 72px (two squares, a 2px
gap and the adornment's inset), the compact field takes the default field's
height so a 32px control sits in any field with a 3px gutter, and the archived
copy beside a link pulls its square back out of the line so a row carrying a
mark stands as tall as a row without one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…ata without real names

The circle is server-rendered, so a picture that 404s errors before React
hydrates and that error never reaches onError: read the element on the ref
instead, so a finished but empty image falls back on a reload too.

Fold the palette's icon-control item into <Button>, where the rule already
belongs, and give the Avatar and clipboard demos the seed analyst's handle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…res evidence

`POST /events/{id}/archives` had no caller left once the event page's archive
popover went: the product records an archived copy through the forms, as a
version, and an endpoint nobody reaches is a second way to write the same row.
It is gone, router, service verb, schema, tests, docs and rate-limit row.

What it did that the forms did not, the edit form now does. The locked
*Detected from* field carries the same archive mark and paste line the Source
URL carries, posting `detected_from_snapshot_url` on `POST
/events/{id}/versions`, filed under origin `detected_from` and staged after
`file_version` like the source copy, so one call files one version carrying the
edit and the copies. A link being immutable says nothing about whether it rots.
A `requested` or `closed` row has no archive path, which `archival.md` now
states: its poster records the source copy at submit, and nothing writes to it
afterwards.

The 100-version ceiling was a dead end with a misleading message. A save whose
only change is archived copies is exempt from it: preserving evidence is what
this catalog is for, and an original dying while the row sits at 100 would be
unarchivable for good. An edit still stops there, and now says so, *This event
has reached 100 versions and can no longer be edited*, rather than naming an
admin who has no verb for it.

Two more corrections around the same write. A pasted copy is compared through
`source_archive.same_snapshot`, the fold `validate_snapshot` already uses, so a
re-paste that picked up a trailing slash in a browser is not filed as a
correction. And a mirror an edit removes takes its stored copy with it, the
version it superseded keeping that copy readable.

Also here, since they touch the same lines: `snapshotToEventView` ratchets
`is_graphic` against the live row (a version page renders the live media, so the
gate can only tighten); `save_version` builds its snapshot once and hands the
same object to the no-change check and to `file_version`; the history page
serves 50 versions rather than 100, so *Load more* is a control the product
exercises; the changed-field list drops the *Proof images* leg, which could
never differ without *Proof* differing; the `nothing_changed` banner prints the
server's own sentence, which names the version it actually compared against.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
`AuthorByline` gains `link={false}`, the same assembly without its anchor, and
the version-history row uses it: an anchor under the row's stretched link is a
target the mouse reaches by z-order and the keyboard reaches as its own stop, so
the two diverged on what the row does. The profile stays one tap away from the
version the row opens, and `/palette` carries the variant.

`evidence_intake` answers a batch already over `max_proof_images_per_event`
before it decodes a single file, ahead of the per-file validation loop; the
event-wide check on what the body displays stays where it is. Its ownership
check counts the event's own source media as own, since a proof body
legitimately shows an annotated frame of the footage being located, and that
object dies only with the event that owns it.

`usePinnedPopover` drops its `hover` option, which only the deleted
`OverflowMenu` passed. `LinkListInput` republishes its companion array only when
the companion moved, instead of minting a new identity on every keystroke in a
URL field.

Docstrings and comments made honest: `_history_pinned_srcs` names its
precondition (`file_version` has already bumped `version_no` and staged this
write's snapshot), the `eventVersions` `+ 1` says why the walk stops one version
above the lowest row loaded, and the vulture whitelist cites `file_version` and
`redact_version` rather than names that never existed.

`data-model.md` stops claiming `event_versions` rows are never updated and never
deleted, which redaction and the cascade both contradict: a row is never removed
or renumbered while its event lives, and redaction is the one write a filed row
takes. `design.md` picks up the unlinked byline and the history page size. The
released v0.4.x and v0.5.x changelog entries the version rename walked over get
their own words back, Alembic's "revision" included.

Tests: the Version history action's gating (event surface, geolocated, present;
every other status and surface absent), and the history row's byline carrying no
link of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…i-types regenerated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
…ry reads it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
@vidit-admin
vidit-admin merged commit 1f44857 into main Aug 19, 2026
11 checks passed
@vidit-admin
vidit-admin deleted the claude/geoloc-edit-history-76c973 branch August 19, 2026 12:55
vidit-admin added a commit that referenced this pull request Oct 3, 2026
…g.md

next.md had grown to 145 rows and about 100 KB, too long to plan from.

- planning/backlog.md holds all 145 rows with their original text, under
  the same version, Refactors and Unscheduled candidates sections, plus
  the cross-cutting rules. The empty v0.6.4 section is dropped.
- planning/next.md keeps the v0.6 rows and the v0.7 P0 and P1 rows (14
  rows), each reduced to a one-sentence outcome that links to its full
  row in backlog.md.
- CONTRIBUTING.md states the row rule and extends the shipped and
  descoped steps to backlog.md; AGENTS.md lists backlog.md.
- References that pointed at moved content (roadmap work breakdowns,
  README, issue and PR templates, CI comments, .gitattributes union
  merge, engineering.md tree, code comments) now point at backlog.md.
- One stale link in a backlog row (OverflowMenu.tsx, removed in #293)
  is unlinked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>
vidit-admin added a commit that referenced this pull request Oct 4, 2026
* chore(planning): prune next.md of shipped, stale and duplicate rows

Remove 38 rows from planning/next.md: 14 already shipped, 13 superseded or
no longer accurate, and 11 older split rows that a merged row already covers.
Move the Requests board triage row from P1 to P2.

Fix docs/engineering.md: the client-IP helper it cited no longer exists; the
rate limiter reaches the right-most XFF entry through rate_limit_key, and the
auth-events audit log stores no IP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>

* chore(planning): split next.md into a short next.md and a full backlog.md

next.md had grown to 145 rows and about 100 KB, too long to plan from.

- planning/backlog.md holds all 145 rows with their original text, under
  the same version, Refactors and Unscheduled candidates sections, plus
  the cross-cutting rules. The empty v0.6.4 section is dropped.
- planning/next.md keeps the v0.6 rows and the v0.7 P0 and P1 rows (14
  rows), each reduced to a one-sentence outcome that links to its full
  row in backlog.md.
- CONTRIBUTING.md states the row rule and extends the shipped and
  descoped steps to backlog.md; AGENTS.md lists backlog.md.
- References that pointed at moved content (roadmap work breakdowns,
  README, issue and PR templates, CI comments, .gitattributes union
  merge, engineering.md tree, code comments) now point at backlog.md.
- One stale link in a backlog row (OverflowMenu.tsx, removed in #293)
  is unlinked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>

* chore(planning): restore the short next.md after the main merge

The union merge strategy on planning/next.md reinserted the pre-split rows
when main (#371) edited them. Restore the short next.md and apply #371's
planning edits to backlog.md instead: the services/events package links and
the dropped services map row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>

* chore(planning): move the work tracker to the GitHub Project

The work tracker now lives in the GitHub Project (vidithq/projects/1):
issues in vidithq/vidit with a native type (Feature, Debt, Task, Bug)
and the Status, Priority, Version and Area fields. planning/next.md and
planning/backlog.md are deleted; git history keeps them.

Pointers updated:
- AGENTS.md: the tracker row names the Project, the backlog row is gone,
  doc rules 2 and 3 point tracker content and hedge prose at the Project.
- CONTRIBUTING.md: new work is an issue with type, Priority, Version and
  Area; a shipping PR says Closes #N; the row-writing rules are gone.
- README.md, planning/roadmap.md (work breakdown links filter the
  Backlog view by version), the PR and feature-request templates,
  docs/engineering.md repo tree, .gitattributes (CHANGELOG union merge
  only).
- docs/backups.md, docs/engineering.md, docs/ingestion.md and code
  comments link the matching issue (#396, #429, #475, #476, #505, #515).

docs-pairing: the job now requires a docs/ touch only (planning/ holds
only roadmap.md). Exemptions are unchanged; the check is renamed
"PR touches docs/" (not a required status check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: vidit-admin <admin@vidit.app>

---------

Signed-off-by: vidit-admin <admin@vidit.app>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant