This issue is a decision, not a task. Nothing here is implemented, closed, or resolved by an
agent. It exists so the choice is made once, in writing, and everything blocked on it can point at
the answer.
All citations are at e61d3ed.
The finding
The kernel judges annotation writes against Batch.schema_version — the version pinned when the
batch was approved — and never against the project's active version.
src/visionset/kernel/services/annotation_service.py:19-25 states it as a design decision:
The version is the batch's, not the project's. Every write is judged against
Batch.schema_version — pinned at approval and moved only by an explicit BatchService.repin —
so a create_version while annotators are working does not change the rules under them.
The implementation follows it: annotation_service.py:159 reads schema = self._pinned_schema(batch),
_pinned_schema is defined at :339, and _validate at :490 checks annotation.label_class
against schema.classes — the pinned version's classes.
The orphan veto, meanwhile, is computed only against the active version.
src/visionset/kernel/services/schema_service.py:242 diffs the proposal against the active version,
:244 calls _refuse_narrowing, and :276-277 counts annotations project-wide and intersects them
with diff.destructive_classes.
The consequence, as a deterministic sequence
No race. No concurrency. No flag abuse. Reachable from REST, CLI, MCP and the SDK alike.
- Project has schema v1 declaring
car. A batch is approved, so it pins v1.
- Nobody has labeled
car yet. Publish v2 without car, with allow_destructive=True. The orphan
count for car is 0, so the publish is accepted — correctly, by the check's own terms.
- An annotator writes an annotation with
label_class="car" into the still-open batch. _validate
reads v1, which declares car. Accepted.
- The trunk now holds an annotation naming a class the active version does not declare.
Nothing re-detects this afterwards. The veto only ever fires on a diff against the active version at
publish time, so no later publish, promotion, release publish or ReleaseService.verify looks for
it.
The code that already assumes this cannot happen
src/visionset/kernel/services/release_service.py:805-809:
# A class the manifest's own `classes` does not declare cannot
# happen — `SchemaChangeWouldOrphan` refuses to remove a class
# annotations depend on — but a report that dropped a label it
# could not place would be silently wrong, so it is placed by the
# geometry it actually carries.
A manifest's classes come from the active version at publish time while each annotation carries
its own pinned schema_version, so the state this comment calls impossible is reachable by the
sequence above and lands in a release manifest. The comment is wrong at HEAD under the implemented
semantics, regardless of which invariant is chosen below — either it describes a guarantee the
kernel does not make, or (under Option A) it describes one the kernel would newly have to enforce.
The defensive branch beneath it is load-bearing today.
The two invariants
Presented as options. Neither is recommended here.
Option A — the active-version invariant (strong)
No annotation may reference a class the active schema version no longer declares.
Publishing a version that removes a class would have to account for open batches pinned to versions
that still declare it — either refusing the publish, or requiring those batches to be closed or
re-pinned first.
- Simpler global reasoning for release and export: the manifest's class list is a complete
description of what the manifest contains, and release_service.py:805-809 becomes true.
- More restrictive. A publish can be blocked by a batch somebody left open days ago, and the remedy
is administrative rather than a schema decision.
- Changes what the batch pin means. The pin currently exists so that "a
create_version while
annotators are working does not change the rules under them"
(annotation_service.py:21-23); under Option A a publish can reach into open batches.
Option B — the per-annotation-version invariant (the one implemented today)
An annotation is valid against its own batch's pin. The trunk may contain classes the active version
no longer declares.
- Releases and exports gate content, and must handle absent-class annotations explicitly —
declared before writing, never dropped silently, which is the discipline the exporters already
follow.
- The orphan veto at publish stays, as hygiene for annotations that already exist at the moment
of the publish. Its value does not depend on this decision.
- Consistent with the ratified G5 formulation — promotion gates membership; releases gate content —
and with the batch pin model in the batch-lifecycle skill.
- Costs: "which classes does this dataset actually use" stops being answerable from the active schema
alone, and every consumer of a manifest needs the absent-class path.
What is blocked on this
Not blocked on this: the typed error contract (cf. #587). The orphan veto over already-existing
annotations survives both options, so the refusal's shape can be settled independently — and the
snapshot-isolation defect (cf. #586) is a defect under either reading.
Provenance
Found by a read-only audit of the schema-narrowing / orphan gate at e61d3ed. The audit's full
findings list is in a comment on this issue.
This issue is a decision, not a task. Nothing here is implemented, closed, or resolved by an
agent. It exists so the choice is made once, in writing, and everything blocked on it can point at
the answer.
All citations are at
e61d3ed.The finding
The kernel judges annotation writes against
Batch.schema_version— the version pinned when thebatch was approved — and never against the project's active version.
src/visionset/kernel/services/annotation_service.py:19-25states it as a design decision:The implementation follows it:
annotation_service.py:159readsschema = self._pinned_schema(batch),_pinned_schemais defined at:339, and_validateat:490checksannotation.label_classagainst
schema.classes— the pinned version's classes.The orphan veto, meanwhile, is computed only against the active version.
src/visionset/kernel/services/schema_service.py:242diffs the proposal against the active version,:244calls_refuse_narrowing, and:276-277counts annotations project-wide and intersects themwith
diff.destructive_classes.The consequence, as a deterministic sequence
No race. No concurrency. No flag abuse. Reachable from REST, CLI, MCP and the SDK alike.
car. A batch is approved, so it pins v1.caryet. Publish v2 withoutcar, withallow_destructive=True. The orphancount for
caris 0, so the publish is accepted — correctly, by the check's own terms.label_class="car"into the still-open batch._validatereads v1, which declares
car. Accepted.Nothing re-detects this afterwards. The veto only ever fires on a diff against the active version at
publish time, so no later publish, promotion, release publish or
ReleaseService.verifylooks forit.
The code that already assumes this cannot happen
src/visionset/kernel/services/release_service.py:805-809:A manifest's
classescome from the active version at publish time while each annotation carriesits own pinned
schema_version, so the state this comment calls impossible is reachable by thesequence above and lands in a release manifest. The comment is wrong at HEAD under the implemented
semantics, regardless of which invariant is chosen below — either it describes a guarantee the
kernel does not make, or (under Option A) it describes one the kernel would newly have to enforce.
The defensive branch beneath it is load-bearing today.
The two invariants
Presented as options. Neither is recommended here.
Option A — the active-version invariant (strong)
No annotation may reference a class the active schema version no longer declares.
Publishing a version that removes a class would have to account for open batches pinned to versions
that still declare it — either refusing the publish, or requiring those batches to be closed or
re-pinned first.
description of what the manifest contains, and
release_service.py:805-809becomes true.is administrative rather than a schema decision.
create_versionwhileannotators are working does not change the rules under them"
(
annotation_service.py:21-23); under Option A a publish can reach into open batches.Option B — the per-annotation-version invariant (the one implemented today)
An annotation is valid against its own batch's pin. The trunk may contain classes the active version
no longer declares.
declared before writing, never dropped silently, which is the discipline the exporters already
follow.
of the publish. Its value does not depend on this decision.
and with the batch pin model in the
batch-lifecycleskill.alone, and every consumer of a manifest needs the absent-class path.
What is blocked on this
release_service.py:805-809comment and the branch under it: stale comment, or a guarantee tostart enforcing.
SchemaService.create_versionmust inspect open-batch pins at all.Not blocked on this: the typed error contract (cf. #587). The orphan veto over already-existing
annotations survives both options, so the refusal's shape can be settled independently — and the
snapshot-isolation defect (cf. #586) is a defect under either reading.
Provenance
Found by a read-only audit of the schema-narrowing / orphan gate at
e61d3ed. The audit's fullfindings list is in a comment on this issue.