Repository navigation
feat(events): edit a published geolocation as a new revision - #293
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A
geolocatedevent 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_nosays 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_revisionsis 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 lockcloseandgeolocatetake, so two concurrent edits take their numbers in order rather than racing.GET /events/{id}/revisionsreads the superseded versions back, newest first. Public, like the event: a corrected record is auditable only when its corrections are readable./events/{id}/editno 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 printsv2beside the byline once an event has been corrected./events/{id}/historyand/events/{id}/vNland here too; see Version history below.The editability contract
After publication the evidence anchor is immutable:
source_urland 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_urlis accepted, since it archives the anchor rather than changing it.Two invariants ride with it:
capture_sourcetag.Errors: 403 for anyone but the owner, 404 for a soft-deleted or withheld row, 409
invalid_stateoffgeolocated, 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 capsnoteat 280 characters.Tests
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 ofdetected/requested/closed, the anchor unmoved whensource_url/files/remove_media_idsare 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.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.
TIERS.event.ownerput "Close this request" and "Delete this request" on/events/{id}, which serves rows of any status. The owner tier splits intorevise(event page) anddispose(request page); newuseEventActions.test.tsxpins that a requested or closed row on the event surface offers no request verb.build_snapshotrecorded everyrole == "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_mediais now the intersection withextract_image_srcs(geo.proof), and the keep set is the current body plus what readable (non-redacted) versions display.source_posted_atis optional on revise._publish_detectionpublishes with the column NULL, so the required field made such a row uneditable. Absent or empty keeps NULL; the frontend floor takes arequireSourcePostedAtoption, false in revise mode and true on the confirmation path, matchinggeolocate.max_proof_images_per_eventnow counts the rows this write keeps plus the files it adds, before anything reaches S3. Message andapi.mdupdated.GET /events/{id}/revisionsmirrorsget_event's optional-current-user branch.POST /admin/events/{id}/revisions/{revision_no}/redact, admin only, audited asevent_revision_redacted, idempotent. Blankssnapshotandnote, stampsredacted_at/redacted_by_id, and keeps the row, its number, its date and its byline so/vNnever shifts.EventRevisionReadgainsredacted. 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. Migrationg5i7k9m1o3q5.closerejects ageolocatedrow, so the "close it and post it again" copy pointed nowhere.fieldHelp.evidence_anchor, therevisedocstring,api.mdandCHANGELOG.mdnow say a wrong source on a published event is an admin matter.CONFIRM_SENTENCE,fieldHelp.action_submit, theEventEditInputdoc comment and theEventStatuscomment say publishing makes the event public and later changes become versions, with the source fixed. Tests that pinned the old sentence updated.services/paginationcursor,Link: rel="next",limitclamped at 100, orderedcreated_at DESC, id DESC(which isrevision_no DESCon an append-only per-event history written under the row lock).totalis a real count, replacingMAX_REVISIONS = 200truncating withtotal = len(items).AGENTS.mdlistslib/events.ts::EDIT_NOTE_MAX_LENmirroringschemas/event.EDIT_NOTE_MAX_LENGTH.One deviation from the brief: the redact verb lives in
routers/admin.pyunder/admin/events/...rather than on the public events router, to sit besidePATCH /admin/events/{id}/moderationand sharerequire_adminpluslog_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.
attach_evidence_and_commitnow 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.source_posted_atkeeps the stored instant.parse_optional_iso_datetimemaps 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.revisereads None as "keep": only a parsed value replaces the column, andappendEventFormFieldsomits 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._safe_image_srcchecks 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_srcsruns on every path through the shared intake: an src the storage layer wrote (key_from_urlresolves it) has to match one of this event'srole='proof'rows, otherwiseinvalid_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.revision_no. The keyset ran oncreated_at, which the application clock sets and which therefore skews between instances, while the endpoint claimedrevision_noorder.list_revisionsnow orders and cuts onrevision_no(unique per event, taken under the row lock, so no tiebreaker column is needed).services/paginationgainsencode_ordinal_cursor/decode_ordinal_cursorbeside the(created_at, id)pair, andnext_linktakes the encoded cursor so one function still builds everyLinkheader. 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
v3beside 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./events/{id}/historyis 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}/vNrenders the record as that version stood, through the same body the canonical page renders (EventPageBody, fed either the live row orsnapshotToEventView(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/vNaddress, public and visibility-gated exactly like the list. Thin router overrevisions.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:
build_snapshotfiles. 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:00againstZis not an edit.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 carriesrobots: noindexand 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 staticeditandhistoryones, which keeps/events/{id}/vNa one-segment address; Next matches a static sibling first, soeditandhistorykeep their own routes, andparseVersionSegmentrefuses anything that is notvfollowed by a version number, which is a 404, as is a number past the current version.useCursorListgains 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
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.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) andapp/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
ruff check/ruff format --check/mypy/vulturepytest -n autoalembic upgrade headnpm run lintnpx tsc --noEmitnpm testnpm run buildmake gen-api-types+git diff --exit-codemake hygiene(jscpd under the 1% cap, knip, palette coverage, video routes)The
event_revisionsMermaid block indata-model.mdwas rendered locally with@mermaid-js/mermaid-clibefore committing.🤖 Generated with Claude Code