Skip to content

feat(annotator): the document — UUID-keyed, immutable, and a selection that survives undo because it is not in it (#40) - #115

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/40-document-model
Jul 30, 2026
Merged

feat(annotator): the document — UUID-keyed, immutable, and a selection that survives undo because it is not in it (#40)#115
JArmandoAnaya merged 1 commit into
mainfrom
feat/40-document-model

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #40. Third task of M4 (epic #38), position 3 of 14. It lands before #39 because all three of that issue's acceptance criteria are phrased against "the document" — which did not exist.

What was missing

core/state/commandLog.ts executed and undid opaque closures over nothing, and core/types.ts ended its own docstring with the question:

Left for #40, the document model: a locally drawn annotation has no server id … How a draft carries identity between the first click and the response is the document's question.

The document

interface AnnotationDocument {
  readonly asset: AssetDescriptor;              // the frame coordinates live in
  readonly schema: AnnotationSchema;            // what may be labeled, in what shape
  readonly annotations: ReadonlyMap<string, Annotation>;
}

A ReadonlyMap, not an array and not a Record: an array means somebody eventually addresses an annotation by index — the epic's named "original sin" from v1 — while a Map gives O(1) lookup by id and insertion-ordered iteration, which is the draw order. Every operation returns a new document and mutates nothing, which is what lets #39 snapshot by reference and makes undo a pointer swap.

Not named Document. That is a host global, and @types/react/global.d.ts declares a stand-in for it, so a core type by that name would shadow it in every adapter and every consumer — the class of confusion #112 exists to prevent.

Selection is filtered on READ, never pruned on write

Two decisions, and acceptance criterion 1 falls out of both.

  1. Selection is not document state. Commands transform the document; undo restores one. Selection lives beside it and is never in the snapshot — were it a document field, Ctrl+Z after a click would undo the click, and the log would fill with entries for looking at things. The kernel has no notion of selection either.
  2. selectedAnnotations resolves ids against the document at the moment it is asked. So deleting a selected annotation stops it being yielded, and undoing that delete yields it again, still selected, with no bookkeeping anywhere. An eager prune would have to be undone in step with the command log — coordination this design does not have and does not need.

Surviving reordering and edits is the same mechanism: it keys on ids, so it cannot notice iteration order, and replaceAnnotation keeps an annotation's place (Map.set on a present key does not move it — pinned by a test, because an editor where editing changes z-order fights its user).

The cost is a set that may carry stale ids: bounded by the session, invisible, never wrong because a uuid v4 is not reused. compactSelection is offered for a host that wants the hygiene, and a test states out loud that calling it is exactly what forfeits the undo behaviour above — so the trade-off is not discovered by surprise.

The undo/redo tests drive the real CommandLog, not a simulation, because the claim is about how the two pieces sit together. #39 will replace the ad-hoc closures with real commands; the asserted behaviour is what it has to keep.

Draft identity, and why there is no rebase

  • A drawn annotation gets a client-minted uuid v4 in the ordinary id — the document's key, the selection's key, and what a renderer keys elements by.
  • schema_version is provisional, from the schema in hand (which is why the schema mirror keeps version).
  • toAnnotationCreate drops both, so neither guess ever travels — precisely what makes inventing them safe.
  • No rebaseAnnotationId. The service mints its own id on POST, so swapping the local one would move a live selection and a whole undo history out from under the user, to fix a mismatch only the host can see. The engine is the session's source of truth; a host correlates with a Map<localId, AnnotationCreate> where it builds the payload. That is the epic's "annotations leave by events" read literally.

What it refuses, and the proof that it must not refuse more

Its own invariants only: a duplicate id, an unknown id on replace/delete, an annotation belonging to another asset.

It does not re-check class↔geometry agreement, required attributes, or geometry bounds — those have an owner in the kernel and wire.ts's rule 2 already argued the copies would drift. There is a concrete proof in this repository: the round-trip fixture's four annotations all carry label_class: "sign", whose class declares bbox, while three of them carry a polygon or a tag. That is valid wire data the kernel produced. A document enforcing that rule could not load its own fixture — asserted as a test rather than left as prose. What it offers instead is classNamed, so the tools (#43#45) refuse at draw time where a user can be told.

#112's boundary forced the id port, one task later

crypto.randomUUID() cannot be named inside src/core/ — verified as TS2304: Cannot find name 'crypto' against the committed tsconfig.core.json. So core/ids.ts declares type IdFactory = () => string and adapters/ids.ts implements it with crypto.randomUUID(). That is the kernel's own hexagonal shape, and it makes every test deterministic with an injected counter rather than a stubbed global. The boundary being productive, not obstructive.

The schema arrives as typed input, and it is proved rather than asserted

scripts/export_wire_fixtures.py now exports the three inputs a document is built fromasset, schema, annotations — plus an attribute_kinds vocabulary list on geometry_types' terms. A hand-written TypeScript schema fixture would have been the second spelling this whole arrangement exists to prevent.

The exported schema carries one class per carryable geometry (so #43#45 each inherit something to draw with) and every optional field in both states — a populated class with a select attribute carrying options and a default, one with color: null and no attributes, and one whose attribute has every optional at its default. A mirror only ever handed values leaves the null branch unparsed.

LabelClass.color already existed in the kernel, so "classes with colors" needed no client-side invention.

A fourth wire rule: strictness follows the round-trip, not the type

Annotations are exact-keyed because the editor hands them back — a key it dropped is a field the kernel wrote and the editor erased. The schema and the asset are input-only, so that argument does not transfer, and color/attributes/required/options/default all carry defaults which #27's rule emits as optional. allowExactKeys applies the wire's own defaults for an absent optional key while still refusing an unknown one — a second function, deliberately, rather than requireExactKeys loosened.

parseAssetDescriptor refuses an unmeasured asset

AssetOut.width/height are int | None because a pre-pipeline row has never been probed. There is no honest default: geometry here is native pixels and never normalized, so an asset with no known frame has nothing to be native to, and guessing would produce coordinates that are in range and uniformly wrong. AssetDescriptor's docstring names the same trap get_asset_image publishes four numbers to avoid — the screen↔image transform is #47's, and the document only ever holds native pixels.

tsconfig.build.json also excludes src/**/_*.ts

The tests/server/_flow.py convention, arriving on the TypeScript side for the new _fixture.ts loader. It is what keeps a node:fs import out of the shipped engine and out of the headless boundary's type gate, which inherits this exclude list — verified with --listFiles: the core project sees eight source files and no harness.

Ledger

M4's four files: FORMAT_VERSION 11, VERSION 0.0.1.dev0, openapi.json and frontend/ui-core/src/generated/api.ts both byte-identical. No route, service, model or error touched — the only Python edits are the fixture exporter and its gate. tests/fixtures/wire_annotations.json regenerated (committed artifact, gated both ways). 86 vitest tests, up from 30; 1924 Python, up from 1919; no new dependency, and the package still ships zero runtime dependencies.

Acceptance criteria

  • Selection stable across undo/redo in tests — four cases in selection.test.ts driving the real CommandLog, including the delete→undo restoration and a full unwind of a three-command run.
  • Round-trip with wire-format fixtures (bbox/polygon/classification)document.test.ts's "built from the wire, and back again" block loads the kernel's own fixture through documentFromWire and serializes every annotation back to the bytes it came from, all three variants included.

…n that survives undo because it is not in it (#40)

`types.ts` ended with the question this task answers: a locally drawn annotation
has no server `id`, so how does a draft carry identity between the first click and
the response? And #39's every acceptance criterion is phrased against "the
document", which did not exist — `commandLog.ts` undid opaque closures over
nothing.

`AnnotationDocument` is an asset, the schema its labels are judged against, and
`ReadonlyMap<string, Annotation>`. A Map because an array means somebody
eventually addresses an annotation by index — the epic's named original sin — and
because insertion order is the draw order. Every operation returns a new document,
which is what makes #39's undo a pointer swap.

Not named `Document`: that is a host global, and @types/react declares a stand-in
for it, so a core type by that name would shadow it in every adapter.

## Selection is filtered on read, never pruned on write

Two decisions, and acceptance criterion 1 falls out of both. Selection lives
*beside* the document, so it is never in a command's snapshot — were it a document
field, Ctrl+Z after a click would undo the click. And `selectedAnnotations`
resolves ids against the document at the moment it is asked, so deleting a
selected annotation stops yielding it and **undoing that delete yields it again,
still selected, with no bookkeeping anywhere**. An eager prune would have to be
undone in step with the command log; this design has no such coordination and
needs none. Surviving reordering and edits is the same mechanism.

The cost is a set that can carry stale ids — bounded by the session, never wrong
because a uuid v4 is not reused. `compactSelection` is offered, and a test states
out loud that calling it is what forfeits the undo behaviour above.

## Draft identity, and no rebase

A drawn annotation gets a client-minted uuid v4 in the ordinary `id` and a
provisional `schema_version` from the schema in hand. `toAnnotationCreate` drops
both, so neither guess travels — which is exactly what makes inventing them safe.
There is deliberately no `rebaseAnnotationId`: the service mints its own id, and
swapping it would move a live selection and a whole undo history under the user to
fix a mismatch only the host can see.

## What it refuses, and what it does not

Its own invariants only — duplicate id, unknown id, an annotation from another
asset. It does not re-check class↔geometry agreement, and there is a proof in this
repository that it must not: the round-trip fixture's four annotations all say
`label_class: "sign"`, whose class declares `bbox`, while three carry a polygon or
a tag. That is valid data the kernel produced. A document enforcing that rule
could not load its own fixture — asserted as a test.

## The boundary forced the id port

`crypto.randomUUID()` cannot be named inside `src/core/` — #112's gate, one task
later, verified as `TS2304`. So `core/ids.ts` declares `IdFactory` and
`adapters/ids.ts` implements it, which is the kernel's own hexagonal shape and
makes every test deterministic with an injected counter instead of a stubbed
global.

## The schema arrives as typed input, proved not asserted

`scripts/export_wire_fixtures.py` now exports the three inputs a document is built
from — asset, schema, annotations — plus `attribute_kinds`. A hand-written
TypeScript schema fixture would have been the second spelling this whole
arrangement exists to prevent. The schema carries one class per carryable geometry
(so #43-#45 inherit something to draw with) and every optional field in both
states.

That brought a fourth wire rule: **strictness follows the round-trip, not the
type.** Annotations are exact-keyed because the editor hands them back; the schema
and the asset are input-only, so `allowExactKeys` applies the wire's own defaults
for an absent optional key while still refusing an unknown one. And
`parseAssetDescriptor` refuses a null `width`/`height` — `AssetOut` declares both
nullable for pre-pipeline rows, and geometry here is native pixels, so an
unmeasured asset has no frame to be native to.

`tsconfig.build.json` now also excludes `src/**/_*.ts`, the `tests/server/_flow.py`
convention, which is what keeps the new `_fixture.ts` harness and its `node:fs`
import out of the shipped engine and out of the boundary gate.

Ledger, M4's four files: FORMAT_VERSION 11, VERSION 0.0.1.dev0, `openapi.json` and
the generated client both byte-identical. No route, service, model or error
touched. 86 vitest tests, up from 30; 1924 Python, up from 1919; no new
dependency, and the package still ships zero runtime dependencies.

Closes #40
@JArmandoAnaya
JArmandoAnaya merged commit 3a181e3 into main Jul 30, 2026
6 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/40-document-model branch July 30, 2026 05:58
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.

annotator: document model — UUID-addressed annotations, stable selection, schema as typed input

1 participant