Skip to content

fix(schema)!: refuse a body-level @unique on a list or vector property - #1306

Merged
ragnorc merged 12 commits into
ModernRelay:mainfrom
ragnorc:fix/1305-unique-list-vector
Nov 9, 2026
Merged

ragnorc merged 12 commits into
ModernRelay:mainfrom
ragnorc:fix/1305-unique-list-vector

Conversation

@ragnorc

@ragnorc ragnorc commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Schema admission now refuses a body-level @unique whose member is a list or vector property, as it already refuses the property-level shorthand and Blob members. Before this change, init, lint, schema plan and cluster validate accepted such a schema, and then every write that gave the property a value failed with scalar key: unsupported column type List(...) for @unique/@key enforcement.

Closes #1305

Reproduction

On main 0b4684875ded4ecc5e829a2e7f7f208a06e65a04, with the issue's schema (tags: [String]? plus @unique(tags)):

$ omnigraph init --schema s.pg ./g.omni
initialized /tmp/r1305/g.omni
$ omnigraph mutate -e 'query m() { insert U { slug: "u2", tags: ["a"] } }' --target ./g.omni
scalar key: unsupported column type List(Field { data_type: Utf8, nullable: true }) for @unique/@key enforcement
disposition: {"effects":"none","action":"fix"}

The Vector(2)? variant fails the same way with FixedSizeList(...), and so does an edge with @unique(@src, tags). Verdict: reproduced, all three variants.

The new parser tests fail on main: test_reject_body_unique_containing_blob_list_or_vector_property_issue_1305 (the schema is accepted) and test_persisted_contract_parser_allows_only_historical_body_unique_issue_1305 (parse_schema accepts the historical source).

Root cause

crates/omnigraph-compiler/src/schema/parser.rs checks constraints in two places. The property-level annotation check refuses @unique on list and vector properties. The body-level check, validate_type_constraints, refuses list, vector and Blob members in its Constraint::Key arm, but its Constraint::Unique arm refused only Blob members. The write path's canonical_scalar_key (crates/omnigraph/src/loader/mod.rs) builds unique keys from scalar columns only, so a list or vector member always fails there.

Decisions

  • Refuse at admission, not enforce on writes. The shorthand already refuses these members, the @key arm refuses them, and the docs already say list and vector properties cannot be keys. List or vector uniqueness would need a new key encoding and semantics (whole-list equality? order? null elements?) that nothing asks for. Refusing makes both spellings of the same constraint agree.
  • Admission mode only, like the Blob precedent. The refusal applies in ConstraintValidationMode::Admission. parse_persisted_schema_contract still accepts these members, so a graph whose accepted schema already declares one keeps opening for reads and export. Refusing in both modes would make such graphs fail to open.
  • Breaking note. Reapplying an existing source that holds such a constraint is now refused at admission, which is the same class of change as the range-bound fix (fix(schema)!: enforce range bounds exactly and preserve stored contracts #1071). The note and guide give the exit: drop the properties the constraint names in one schema change (the planner removes a non-key constraint whose members are all dropped), then add them back. A constraint that also names another member or @src/@dst cannot be removed in place; such a graph needs a rebuild. That limit is the existing removing constraints ... is not supported in schema migration v1 rule, which this change does not alter.

Alternative considered: also give canonical_scalar_key a typed refusal for list and vector columns. Not needed once admission refuses them; the only graphs that reach it are the historical ones, where the existing fix disposition is accurate.

Before and after

main 0b468487 this branch
init with @unique(tags) on [String]? initialized, then writes fail parse error: @unique is not supported on list property U.tags
init with @unique(v) on Vector(2)? initialized, then writes fail parse error: @unique is not supported on vector property U.v
edge @unique(@src, tags) initialized, then writes fail parse error: @unique is not supported on list property T.tags
open a graph whose accepted schema holds one opens opens

After, with this branch's CLI:

$ omnigraph init --schema s.pg ./a.omni
parse error: @unique is not supported on list property U.tags
$ omnigraph query -e 'query q() { match { $u: U } return { $u.slug, $u.tags } }' --target ./g.omni   # g.omni written by main
u1     | null
$ omnigraph schema plan --schema s.pg --target ./g.omni          # reapplying the old source
parse error: @unique is not supported on list property U.tags
$ omnigraph schema apply --schema s-drop.pg --target ./g.omni    # drops tags and its constraint
applied: yes
- drop property 'U.tags' of node 'U'
$ omnigraph schema apply --schema s-readd.pg --target ./g.omni   # tags back, no constraint
applied: yes
- add property 'U.tags' ([String]?) on node 'U'
$ omnigraph mutate -e 'query m() { insert U { slug: "u2", tags: ["a"] } }' --target ./g.omni
changed main via m: 1 nodes, 0 edges

For the edge graph, dropping tags plans removing constraints from 'T' is not supported in schema migration v1, as the guide says.

Tests

  • crates/omnigraph-compiler/src/schema/parser_tests.rs: test_reject_body_unique_containing_blob_list_or_vector_property_issue_1305 extends the Blob refusal cases with node list, composite list, node vector, edge list with @src and edge vector. test_persisted_contract_parser_allows_only_historical_body_unique_issue_1305 extends the persisted-contract cases with list and vector.
  • crates/omnigraph/tests/integration/lifecycle.rs: open_accepts_historical_body_unique_but_init_rejects_it_issue_1305 now runs over Blob, list and vector: init refuses, and a graph whose contract row holds the historical schema still opens.

No .gqt case: a GQT case needs a schema that loads, so it cannot express an admission refusal.

Local results (this box, CARGO_TARGET_DIR shared, aliases from .cargo/config.toml):

  • cargo test -p omnigraph-compiler --locked: 493 + 2 passed; the two new tests fail on main.
  • cargo dev-nextest over omnigraph-compiler, omnigraph-cluster, engine schema_apply, lifecycle::, validators::, legacy_columns:: and CLI cli_schema_config::/lint: 819 run, 818 passed. The one failure, schema_apply::apply_schema_column_changes_keep_data_files_ids_and_index_coverage_issue_1014, is a stack overflow at this box's default stack size; it passes with RUST_MIN_STACK=16777216, which CI sets.
  • cargo dev-clippy and cargo fmt --all --check: clean.
  • python3 scripts/check-docs.py and bash scripts/check-agents-md.sh: OK.
  • No .gqt case, benchmark or fixture declares a body @unique on a list or vector (scanned every @unique( in the tree), so no corpus schema changes meaning. RUST_MIN_STACK=16777216 cargo test -p omnigraph-gqt --locked: 559 passed, 0 failed (all 464 corpus cases).

Docs

  • docs/user/schema/index.md: list and vector properties cannot be unique, and how a graph that already declares one gets out.
  • skills/omnigraph/SKILL.md and skills/omnigraph/references/schema.md: the reference said "list/vector declarations may parse but writes fail scalar-key validation"; it now says they are refused.
  • changelog.d/unique-refuses-list-and-vector-members.breaking.md, and the regenerated docs/releases/v0.15.0.md snapshot.

Risks and what was not verified

  • A cluster deployment whose source holds such a constraint refuses its next apply after upgrading, until the source changes. That is intended; the note says what to do.
  • The drop-and-re-add exit is verified with schema apply on a standalone graph, not with cluster apply (it uses the same planner).
  • Lance is not involved: the defect is in Omnigraph's parser and its scalar-key encoding, so no pending Lance change affects it.
  • No storage reads or writes change, so the DST storage-cost golden is untouched.

Workflow run: run-wf-reproduce-modernrelay-omnigraph-issue-20261108T2142Z-0f5c4c
Response run: run-wf-respond-modernrelay-omnigraph-pr-20261109T0110Z-b8b23b
Response run: run-wf-respond-modernrelay-omnigraph-pr-20261109T0210Z-5b2fb3

A body-level @unique(p) on a list or vector property was admitted, though
the property-level shorthand refuses it and the write path's scalar key
cannot enforce it, so every write that set the property failed.

Schema admission now refuses list and vector members of a body-level
@unique, as it refuses Blob members. A persisted contract that already
holds one still parses, so existing graphs keep opening.

Closes ModernRelay#1305

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 5c8a178

Why. A body-level @unique on a list or vector property passed schema admission. After that, every write that gave the property a value failed in canonical_scalar_key. The graph could store no value in that property, and the constraint could not be removed in place.

What. The Constraint::Unique arm of validate_type_constraints now refuses list and vector members, as it already refused Blob members. The refusal applies in admission mode only. A persisted contract still parses, so a graph that already holds such a constraint still opens.

How it fits. The admission rule now matches the write path exactly. Every type that the parser still accepts as a @unique member (String and enum, I32/I64/U32/U64, F32/F64, Bool, Date, DateTime as Date64) has a branch in canonical_scalar_key (crates/omnigraph/src/loader/mod.rs:4099). Every type it now refuses (list, FixedSizeList vector, Blob) has none. Interface properties are covered too: resolve_interfaces desugars their constraints into the node before validate_constraints runs.

Impact. This removes a schema shape that the engine cannot serve. It adds no state, API or invariant. The cost is one breaking admission refusal. It follows the Blob and #1071 precedents, and the note and the guide document the exit.

Outcome: ready to merge. No blocking findings. Three optional suggestions follow.

Blocking

None.

Optional

O1. One predicate for "this member can be a key or unique member". The parser states this rule three times: in the property-level annotation loop (parser.rs:937-962), in the @key arm (parser.rs:1164, list/vector/Blob checks below it) and in the @unique arm (parser.rs:1233). canonical_scalar_key states it a fourth time at runtime. This defect is that duplication drifting: the @unique arm fell behind the other two. A small fn scalar_key_member_refusal(&PropType) -> Option<&'static str> could be used by all three parser sites, with the admission-only split kept at the @unique call site. Then the next member-type rule would change in one place. To make drift against the write path loud, an engine unit test beside canonical_scalar_key_handles_every_supported_non_string_key_type could do this: for every ScalarType that the predicate accepts, build a one-row array of to_arrow() and assert that canonical_scalar_key returns Ok. Cost: one function and one test. It removes duplicated rules that can drift. It is not required for this fix.

O2. Pin the documented exit with a test. The guide now promises two things: dropping every property a historical @unique names removes the constraint in one apply, and a constraint that also names another member or @src/@dst cannot be removed in place. Only the PR description shows these, by hand. The lifecycle test already builds a historical graph with publish_schema_contract_row (crates/omnigraph/tests/integration/lifecycle.rs:27). Adding one apply_schema step there would pin the user-facing promise: drop tags, re-add it without the constraint, then write a value. I read ends_with_dropped_properties (catalog/schema_plan.rs:963), and it does what the guide says. The test would stop a later planner change from breaking the exit silently.

O3. Cluster wording in the note. validate_bundle_with_root parses every graph's schema in admission mode (crates/omnigraph-cluster/src/deployment/execution.rs:833). It runs on capture, preview, execution and reconcile. So after the upgrade, a deployment that contains one such graph refuses every cluster apply, even one that changes only another graph's queries or policies. It also refuses to reconcile a deployment captured before the upgrade. The guide's existing line ("Complete outstanding deployments ... before upgrading") covers the second case. The changelog note says only "before reapplying its source". You could say directly that any cluster apply of that deployment is refused until the source changes. Wording only.

Checks

  • Regression proof (cheapest owner): the compiler parser tests and the engine lifecycle test. GQT cannot express this: a .gqt schema section is a fixture that must parse at case load (omnigraph-gqt-core/src/lib.rs, the schema section and refuse_embed_schema), and the format has no schema-refusal step. I agree with the PR on this.
    • Before the fix: head tests with crates/omnigraph-compiler/src/schema/parser.rs restored from 0b468487. cargo dev-nextest -E 'test(/issue_1305/)' --no-fail-fast gave 0 passed, 3 failed, each for the predicted reason. test_reject_body_unique_..._issue_1305 panicked at expect_err("node-list") because the schema was accepted. test_persisted_contract_..._issue_1305 failed parse_schema(historical).is_err(). lifecycle::open_accepts_historical_body_unique_but_init_rejects_it_issue_1305 panicked with new init must refuse: ... list property Document.tags.
    • After the fix, at 5c8a1782: the same command gave 3 passed. The temporary edit is reverted, and the worktree is clean.
  • Focused suites at 5c8a1782 (clean detached worktree, debug, RUST_MIN_STACK=16777216): cargo dev-build-tests had no warnings. cargo dev-nextest over all of omnigraph-compiler plus engine lifecycle::, validators:: and issue_1305 gave 556 passed, 0 failed. Engine binary(schema_apply) gave 50 passed, 0 failed.
  • CLI built at head: init refuses @unique(tags) on [String]? and refuses edge @unique(@src, tags) with the new messages. lint --schema refuses the node case too.
  • Admission callers checked: the engine opens through parse_persisted_schema_contract (db/schema_state.rs:326), so historical graphs still open. lint --target uses the opened catalog. Cluster config.rs parses only desired source. The cluster bootstrap check runs only when no revision has been applied yet.
  • Docs: check-docs.py OK (473 files). check-agents-md.sh OK. release_notes.py verify --version v0.15.0 --target HEAD OK. check-release-note.py with this title OK. git diff --check clean.
  • Exact-head CI at 5c8a1782: 27 success, 4 skipped (the release-tag and vocabulary-guard jobs), 0 failed.
  • Lance: not involved. Lance has no whole-value uniqueness for List or FixedSizeList columns. Its label-list index answers per-element membership (array_has), not whole-list equality. Refusing at admission therefore matches the substrate; enforcing would need a new Omnigraph key encoding and semantics.
  • Other open PRs: none changes constraint validation or the migration planner. #1291 also touches loader/mod.rs and merges cleanly with this head.

Limits

  • I did not run the drop-and-re-add exit or the historical-graph reads end to end with the CLI. That needs a graph written by the base binary. I checked the planner rule by reading the code (see O2).
  • I did not run the GQT corpus, DST or the canonical workspace graph locally. Exact-head CI covers them.
  • No performance claims; the change does no storage I/O.



Address review O2 and O3 on ModernRelay#1306: a lifecycle test plants a historical
@unique(tags) on a list, shows a write is refused, drops tags to remove the
constraint, re-adds it and writes; a constraint that also names a kept
property is refused in place. The breaking note and the schema guide now say
that every cluster apply of a deployment holding such a graph is refused.
@ragnorc

ragnorc commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Reply to the review of 5c8a1782 (O1–O3).

O2: taken, in 40e9d435. A new lifecycle test, historical_body_unique_list_ends_by_dropping_its_property_issue_1305, pins the exit that the guide promises. It plants a historical @unique(tags) on tags: [String]? with publish_schema_contract_row, as the existing test does. Then it checks four things:

  • A load that sets tags is refused with canonical_scalar_key's "unsupported column type List(…) for @unique/@key enforcement".
  • apply_schema without tags succeeds, so ends_with_dropped_properties removes the constraint.
  • apply_schema with tags back and no constraint succeeds, and the same load then writes the row.
  • With @unique(label, tags), dropping only tags is refused ("removing constraints from 'Document' is not supported"), and the schema does not change.

O3: taken, in the same commit. The changelog note now says that every apply of source that keeps the constraint is refused, including any cluster apply of its deployment. The note has a 400-character limit, so the fuller sentence is in docs/user/schema/index.md: in a cluster, the refusal covers even an apply that changes only another graph, query or policy. The v0.15.0 snapshot is regenerated in 852f7f28.

O1: not in this PR, kept as a follow-up. I agree that one predicate would stop the parser sites from drifting apart. But the shared function would also change the property-level and @key arms, which this defect does not touch. This PR fixes one defect (#1305), so I am keeping it small. The follow-up should be your shape: scalar_key_member_refusal used by all three parser sites, with the admission-only split at the @unique call site, and an engine test that runs canonical_scalar_key on every ScalarType the predicate accepts.

Local checks on this code, debug, RUST_MIN_STACK=16777216: cargo dev-nextest -E 'test(/issue_1305/)' gives 4 passed. cargo dev-nextest -E 'test(/lifecycle::/) | binary(schema_apply) | package(omnigraph-compiler)' gives 577 passed, 0 failed. cargo dev-clippy has no warnings. check-docs.py, check-agents-md.sh and release_notes.py verify --version v0.15.0 --target HEAD pass. CI: exact-head CI at 852f7f28 has 27 success, 4 skipped (the vocabulary guard and the tag jobs), 0 failed.

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 4007578 (delta from 5c8a178)

Why. The earlier review at 5c8a178 found no blocking defects and gave three optional suggestions. This review covers only what changed since then.

What changed. There are four new commits:

  • 40e9d435 adds a lifecycle test that pins the documented exit (O2). It also rewords the breaking note and the schema guide for clusters (O3).
  • 852f7f28 and 40075781 regenerate the v0.15.0 release snapshot.
  • 0dc55b42 merges main at f0df4673 (#1307, release workflow only).

git range-diff shows the four earlier commits unchanged, so the fix in parser.rs is byte-identical to the reviewed one.

Impact. The delta adds one test and corrects two sentences. It adds no API, state or invariant, and it removes a gap: the exit the guide promises is now checked in CI, not only shown by hand in the PR description.

Outcome: ready to merge. No blocking findings. O2 and O3 are fixed. O1 is still open and still optional. One small new wording suggestion follows.

Blocking

None.

Fixed since 5c8a178

  • O2 (pin the exit). historical_body_unique_list_ends_by_dropping_its_property_issue_1305 (crates/omnigraph/tests/integration/lifecycle.rs:606) plants a historical @unique(tags) on a list through the contract row. It shows a write that sets tags is refused with the canonical_scalar_key error, drops tags, adds it back without the constraint and writes the row. It then shows that @unique(label, tags) cannot be removed in place, and that the accepted schema does not move. That matches ends_with_dropped_properties (catalog/schema_plan.rs) and the guide text.
  • O3 (cluster wording). The note and the guide now say that every cluster apply of such a deployment is refused. I checked this against the code: validate_bundle_with_root parses each graph schema of the desired bundle with parse_schema (admission mode) at crates/omnigraph-cluster/src/deployment/execution.rs:834. So an apply that changes only another graph, query or policy is refused while the source keeps the constraint. An apply whose source drops the named properties is accepted, so the in-place exit also works in a cluster. The other parse_schema callers outside the compiler are tests, lint --schema and cluster config.rs (desired source only), so no path re-parses an applied schema in admission mode.

Optional

O1 (still open). One predicate for "this member can be a key or unique member". Unchanged from the earlier review. The parser still states the rule in three places, and the defect came from those copies drifting apart. It is not required for this fix.

O4 (new). "until the constraint is removed" (docs/user/schema/index.md:116). Strictly, the apply that removes the constraint is itself a cluster apply of that deployment, and it is accepted. "until the deployment's source no longer declares it" says when applies start to pass. Wording only.

O5 (new, minor). Shared setup in the lifecycle test. The new historical_graph helper (lifecycle.rs:642) repeats the setup in open_accepts_historical_body_unique_but_init_rejects_it_issue_1305 (init the base schema, resolve the persisted shape, publish the contract row, reopen). If a third historical-contract test appears, one module-level helper would keep the planting steps in one place. Not worth a change for two tests.

Checks

  • Regression proof (cheapest owner: compiler parser tests and engine lifecycle tests; GQT cannot express an admission refusal, as the earlier review explained).
    • Before the fix: tests at 40075781, with crates/omnigraph-compiler/src/schema/parser.rs restored from main at f0df4673. cargo dev-nextest -E 'test(/issue_1305/)' --no-fail-fast gave 3 failed and 1 passed. The three fix tests failed for the predicted reasons: the node-list schema was accepted (parser_tests.rs:354), parse_schema(historical).is_err() failed (parser_tests.rs:400), and init accepted the list schema (lifecycle.rs:573). The new exit test passed. That is correct: it pins the planner's drop rule, which does not depend on the admission fix.
    • After the fix, at 40075781: all 4 passed. I reverted the temporary edit, and the worktree is clean.
  • Focused suite at 40075781 (clean detached worktree, debug, RUST_MIN_STACK=16777216): cargo dev-nextest -E 'test(/issue_1305/) | test(/^lifecycle::/)' gave 28 passed, 0 failed.
  • Docs: check-docs.py OK (473 files). check-agents-md.sh OK. release_notes.py verify --version v0.15.0 --target HEAD OK (60bfd19d..40075781). check-release-note.py with this title OK. git diff --check origin/main...HEAD clean.
  • Merge of main: it brings only #1307 (build-release-archives.yml, install docs and its note). It does not touch constraint validation, the planner or the lifecycle tests.
  • Exact-head CI at 40075781: 27 success, 4 skipped (the vocabulary guard and release-tag jobs), 0 failed.
  • Lance: not involved in the delta. The earlier point still holds. Lance's label-list index answers per-element array_has/array_has_all/array_has_any, not whole-list equality, so refusing at admission matches the substrate.
  • Other open PRs: #1301 changed only by merging the same main commit. No open PR changes constraint validation or the migration planner.

Limits

  • I did not run the CLI end to end against a graph written by the base binary. The new engine test now covers the drop-and-re-add exit and the in-place refusal at the engine API.
  • I did not run a real cluster apply for the O3 wording. I checked it by reading validate_bundle_with_root and its callers.
  • I did not run the full workspace graph, GQT or DST locally. Exact-head CI covers them.
  • No performance claims. The delta does no storage I/O.

@ragnorc
ragnorc added this pull request to the merge queue Nov 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 9, 2026
@ragnorc

ragnorc commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Merged main (2e7921c4) into this branch to clear a merge conflict. It is now mergeable.

  • Conflict: only docs/releases/v0.15.0.md, the generated release snapshot. perf(engine): delete a branch merge's rows by the _rowid its walk read #1301 and this PR had each regenerated it.
  • Fix: c8ba40a6 merges main. c8130db9 regenerates the snapshot with python3 scripts/release_notes.py snapshot --target HEAD --date 2026-11-09 --replace, so it is not merged by hand. It now has both notes: this PR's breaking note and perf(engine): delete a branch merge's rows by the _rowid its walk read #1301's merge note with its PR link.
  • No code changed. No rebase and no force-push.
  • Checks: scripts/check-docs.py, scripts/check-agents-md.sh and release_notes.py verify --version v0.15.0 pass. cargo fmt --check is clean. cargo dev-nextest -E 'package(omnigraph-compiler) | test(/issue_1305/)': 495 of 495 pass. CI on c8130db9: 28 pass, 4 skipped, 0 fail.

@ragnorc

ragnorc commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

I merged main (ead05c40) into this branch to clear a new merge conflict. The branch can be merged again.

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 2a337f1 (delta from 4007578)

Why. The review of 4007578 found no blocking defects. Since then, the branch has merged main two times. This review covers only that delta, plus one documentation point found in the existing guide text.

What changed. There are four new commits:

  • c8ba40a6 merges main at 2e7921c4 (#1301). c8130db9 regenerates the v0.15.0 snapshot.
  • 81603bb0 merges main at ead05c40 (#1310). 2a337f1d regenerates the snapshot again.

The PR's own files (parser, tests, guide, skill, changelog note) are byte-identical to 4007578. The only PR-owned change is the generated snapshot. Against main, the snapshot diff is the new breaking note, its link definition and the provenance comment. The two merges bring no change to constraint validation, the schema planner or the lifecycle tests.

Impact. The code fix is unchanged and still correct. The delta adds no API, state or invariant.

Outcome: no blocking code defects. One optional documentation correction, with a test-backed reason, follows. There are also two release-order points to check before merge.

Blocking

None.

Before merge (release process, not code)

  • R1. Release order. #1310 says ead05c40 is the commit that v0.15.0 will be tagged on. If the tag comes first, this PR must not edit docs/releases/v0.15.0.md. docs/dev/documentation.md allows --replace only before the tag exists, and a published snapshot does not change. In that case, drop the snapshot hunk, and the note goes to the next release. Then re-read the breaking note's upgrade wording for that release.
  • R2. PR link after the squash. The snapshot's links map has no entry for changelog.d/unique-refuses-list-and-vector-members.breaking.md, because the squash commit does not exist yet. After the merge, the note will ship without its PR link. #1301 had the same gap, and #1310 fixed it. Plan the same follow-up before the tag.

Optional

O6 (new). One guide sentence disagrees with the planner (docs/user/schema/index.md:118-120). The guide says: "A constraint that also names another property or @src/@dst cannot be removed in place: rebuild the graph without it." The planner does not work that way. ends_with_dropped_properties (crates/omnigraph-compiler/src/catalog/schema_plan.rs:963) ends any non-key constraint when the plan drops every property it names. It refuses only when a named property stays or the constraint names a system field. The new test's own comment says this correctly ("names a property that stays", lifecycle.rs:610), and so does the changelog note ("Drop the properties it names in one change").

I proved it with a temporary step at the end of historical_body_unique_list_ends_by_dropping_its_property_issue_1305, at 2a337f1. On the historical @unique(label, tags) graph, apply_schema with only title succeeded. apply_schema(BASE_SCHEMA) then added both properties back, and the tags row loaded. cargo dev-nextest -E 'test(/issue_1305/) | test(/^lifecycle::/)' gave 28 passed. I removed the step afterwards.

So a user with @unique(label, tags) reads "rebuild" when one schema change can remove the constraint, at the cost of the label values too. A suggested wording: "A constraint that also names a property you keep, or @src/@dst, cannot be removed in place: drop all of its properties, or rebuild the graph without it." This also covers O4 if you rewrite the same paragraph.

O4 (still open). "until the constraint is removed" (docs/user/schema/index.md:116). Wording only, as before.

O1 (still open). One predicate for "this member can be a key or unique member". Unchanged and still optional.

Context (no action in this PR). Four other open PRs (#952, #1175, #1184, #1308) also regenerate docs/releases/v0.15.0.md. CI does not verify that snapshot on a PR, and its provenance comment records the generating commit. Thus each merge to main puts every other open copy in conflict, and each squash leaves a missing PR link (R2). This PR needed two main merges for this reason only. If the release-prep PR regenerated the snapshot one time, near the tag, the conflicts and the follow-up link PRs would stop. That decision belongs to the release process, not to this PR.

Checks

All at exact head 2a337f1, in a clean detached worktree (CARGO_TARGET_DIR shared, RUST_MIN_STACK=16777216). I restored every temporary edit, and the worktree was clean at the end.

  • Regression proof (cheapest owner: compiler parser tests and engine lifecycle tests; GQT cannot express an admission refusal).
    • Before the fix: crates/omnigraph-compiler/src/schema/parser.rs restored from main at ead05c40. cargo dev-nextest -E 'test(/issue_1305/)' --no-fail-fast gave 3 failed and 1 passed. The failures had the predicted reasons: the list schema was accepted (parser_tests.rs:354), parse_schema(historical).is_err() failed (parser_tests.rs:400), and init accepted the list schema (lifecycle.rs:573). The exit test passed, which is correct: it pins the planner's drop rule, not the admission fix.
    • After the fix: cargo dev-nextest -E 'test(/issue_1305/) | test(/^lifecycle::/)' --no-fail-fast gave 28 passed, 0 failed.
  • Compiler suite at c8130db (identical code to 2a337f1): cargo dev-nextest -E 'package(omnigraph-compiler) | test(/issue_1305/) | test(/^lifecycle::/)' gave 519 passed, 0 failed.
  • Docs and format: check-docs.py OK (474 files). check-agents-md.sh OK. release_notes.py verify --version v0.15.0 --target HEAD OK (60bfd19d..2a337f1d). cargo fmt --all --check clean. git diff --check origin/main...HEAD clean.
  • Merge state: 2a337f1 merges cleanly into main at ead05c40.
  • Exact-head CI at 2a337f1: 27 success, 4 skipped (the vocabulary guard and release-tag jobs), 0 failed.
  • Other open PRs: none changes schema/parser.rs or schema_plan.rs. #1101 edits docs/user/schema/index.md in another section (type namespaces), so it does not overlap.
  • Lance: not involved in the delta.

Limits

  • I did not run the CLI end to end against a graph written by the base binary. The engine tests cover the admission refusal, the open path and both exits.
  • I did not run a real cluster apply. The cluster wording was checked by code reading in the earlier review.
  • I did not run the full workspace, GQT or DST locally. Exact-head CI covers them.
  • No performance claims. The delta does no storage I/O.

@ragnorc
ragnorc merged commit 519ae01 into ModernRelay:main Nov 9, 2026
34 checks passed
ragnorc added a commit to ragnorc/omnigraph that referenced this pull request Nov 9, 2026
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.

bug: a body-level @unique on a list or vector property is accepted, then every write that sets the property fails

1 participant