Skip to content

fix(spec): give the declared-index unique surface its own rejection message - #11213

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-10928-unique-scope-message
Aug 23, 2026
Merged

fix(spec): give the declared-index unique surface its own rejection message#11213
os-sam merged 2 commits into
mainfrom
claude/issue-10928-unique-scope-message

Conversation

@claude

@claudeclaudeBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#10928

What

UniqueScopeSchema is shared by FieldSchema.unique and IndexSchema.unique, but its
rejection message was written from the field-level viewpoint only:

Invalid unique scope 'nonsense_scope'. Allowed: true/false, 'organization'
(one holder per organization — the explicit spelling of true), or 'global'
(one holder across the whole installation).

The parenthetical holds at field level, where bare true resolves per-organization. It is
false on a declared index, where bare true sets neither driver flag and the index
materializes over exactly fields — there 'global' is what true spells, as
IndexSchema.unique's own describe() already said.

The message is read at the one moment it is most likely to be obeyed: the author has just
been refused on this very key and is looking for the accepted spelling. An author holding a
working unique: true on a declared index was told 'organization' is what it spells;
taking that advice asks the driver to prepend the NULL-safe organization key part at
registration — a materialization change, silently, on an index that may already exist on a
deployed database. That is the unannounced index reinterpretation ruled out by #8323
(maintainer, 2026-08-13) and staged by #5082, reaching authors through the platform's own
error text rather than at review time.

Verified on origin/main at dispatch before building anything: the three sites the card
quotes still read as quoted (field.zod.ts schema def and field describe(),
object.zod.ts index describe()), and the helper truth table still holds. The premise
survived.

How

The card ranked a per-surface error map first but recorded its cost as unmeasured. Measured
here, and option 1 landed — with one deviation from the suggested shape, driven by the
measurement:

  • How the map is wired.UniqueScopeSchema is
    lazySchema(() => z.union([…], { error: uniqueScopeError })). A $ZodErrorMap receives
    only the issue, and on both surfaces the issue is invalid_union on path
    ['unique'] — so one map cannot tell the surfaces apart. A per-surface message requires
    a per-surface schema; there is no cheaper wiring.
  • Where the second schema lives — not where the card assumed. The obvious shape is a
    second exported schema in field.zod.ts. Measured: packages/spec/src/data/index.ts
    does export * from './field.zod' and ./data is a published entry point
    (package.jsonexports), so a new export there widens the package's public API — which
    this card explicitly must not do. The declared-index union is therefore module-local to
    object.zod.ts
    and exported nowhere. No symbol is added to or removed from any
    published surface.
  • The member list is duplicated on purpose (three literals) rather than re-derived through
    a shared factory, because a shared factory would have had to be exported. The duplication
    is made safe by the equivalence pin below, and both docblocks say so.

packages/spec now holds two hand-written $ZodErrorMaps instead of one. Both are out of
alias-integrity.test.ts's unrecognized_keys class by the same measurement the
existing one is (they branch on invalid_union), not by an added exemption — that file's
live control over field.zod.ts needed no change, and the docblock that claimed "the last
hand-written map, and it stays one" is updated rather than left to go stale.

The declared-index refusal now reads

Invalid unique scope 'nonsense_scope'. Allowed: true/false, 'organization' (one holder
per organization — the driver prepends the NULL-safe organization key part to `fields` at
registration), or 'global' (one holder across the whole installation — materialized over
exactly `fields`, and the positional meaning of bare true on a declared index: bare true
is warned by lint unique/unscoped-declared-index in 17.x and rejected at protocol 18,
#5082).

The field-level message is byte-identical to before — the hint is correct there and
that is the common surface. The 'tenant'/'org' near-miss clause is unchanged and shared
verbatim: nothing about that answer is surface-dependent.

Message text only — the constraint, asserted

Per #8323 this PR may not move a single value across the accept/reject line. It does not:
accepted values, parse results, defaults, scope semantics and the refusal envelope
(invalid_union on path unique) are identical on both surfaces. #5082 is untouched —
bare true stays valid in 17.x and still means positional 'global' on a declared index.
The new unique-scope-message.test.ts (22 cases) pins both halves: the surface contrast,
and a 15-value table asserted to be treated identically on both surfaces, parse result
included.

Reverse verification

Ran from the committed state, mutation confirmed on disk by anchor counts before the run
(injected 1, deleted 0) — not by the editor's exit code — with a trap … EXIT INT TERM
restore. No build leg: the tests import ./object.zod relatively from source, so the
exports-resolution condition for a dist ablation does not hold and nothing in dist/
could keep the mutation alive.

Pointing IndexSchema.unique back at the shared UniqueScopeSchema (the exact pre-fix
wiring) gives 2 failed | 20 passed:

  • the DECLARED-INDEX surface names 'global' as the positional meaning of bare true
  • the contrast is real — the two surfaces do not emit the same text

The direction is the plain one — red — but the split is the informative part and was the
prediction: the 15 accept/reject parity cases, the envelope assertion and the defaults
assertion all stayed green under the mutation, because they were green before the fix
too. They are a pin on the #8323 constraint, not evidence that the fix works; only the two
message assertions carry that. Restore proved on disk (mutation marker absent, fix marker
present, git diff empty), then 22/22 green again.

Verification

Gate families derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
against the actual diff (the script derived its own change set; it confirmed --repo against
this checkout's remote). All runs on a5f19a9.

Green (each quoted from the gate's own verdict line, exit code captured before any pipe):

  • pnpm --filter @objectstack/spec exec vitest run — the whole spec package: Test Files 417 passed (417) / Tests 11091 passed (11091), including the new file's 22 cases.
  • pnpm --filter @objectstack/spec typechecktsc --noEmit + check:scripts-typecheck + check:test-typecheck, exit 0.
  • pnpm --filter @objectstack/spec check:generated — all 14 artifacts current, exit 0. check:authorable-surface, check:api-surface, check:export-origins, check:dual-source-exports also green standalone.
  • All 21 path-derived families, and the 5 convention-triggered ones for "adds or edits a test file": check:cross-package-test-inputs, check:doc-formula-expressions, check:empty-state, check:liveness, check:merge-driver, check:objectui-changeset, check:slot-lookup, check:spec-parsed-alias, check:strictness-ledger, check:test-source-alias, check:type-source-resolution, check:variant-docs, check:changeset-gate-self-tests, check-adr-0087-registration, check-changeset-no-major, check-ci-filter-parity, check-cross-package-test-inputs, check-dev-prereqs, check-empty-changeset, check-plugin-teardown-shape, check-affected-docs, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:type-check-debt, check:nul-bytes.

Two verdicts that were about the build, not the diff — recorded because each reads exactly like a real failure:

  • check-dev-prereqs and check:doc-formula-expressions failed on the first pass with ERR_MODULE_NOT_FOUND / "The workspace is not built — 67 of 67 workspace packages declare an entry point under dist/ that is not on disk". Both green after turbo run build.
  • check:generated then reported api-surface/ stale. It is an mtime comparison, and I had edited a test file after the build: gen:api-surface refused outright with "dist/**/*.d.ts is OLDER than src" rather than writing a wrong baseline. After pnpm --filter @objectstack/spec build, check:api-surface and check:generated are both green and git status is clean — so no generated artifact moved.

Generated-schema stability, measured rather than assumed. Splitting one shared schema
instance into two structurally identical ones could have changed $ref reuse in
toJSONSchema (it keys on node identity). Hashed all 1584 generated JSON schemas on the
clean tree before the first edit and again after: byte-for-byte identical. The only
delta in the listing is a 1585th file, openapi.json, absent from the baseline because
gen:schema's cleanup had wiped gen:openapi's output before the snapshot was taken.

Not run locally: the repo-wide pnpm lint, and the rest of the ~137-family farm CI runs
on every PR regardless. This is a declared narrowing, not a claim of coverage.


Generated by Claude Code

…essage
UniqueScopeSchema's shared rejection text called 'organization' "the explicit
spelling of true" on both surfaces. True at field level; false on a declared
index, where bare true is the positional spelling of 'global'. Following the
message there changes materialization on an index that may already exist — the
unannounced reinterpretation #8323 rejects and #5082 stages.
object.zod.ts now carries a sibling error map and a structurally identical
union. Message text only: accepted values, parse results, defaults, scope
semantics and the invalid_union/path envelope are unchanged on both surfaces,
pinned by unique-scope-message.test.ts.
Fixes#10928
check:test-typecheck refused the new file: uniqueIssue() was typed to
ReturnType<typeof parseField>, so the index half of every shared assertion was
unwritable (4x TS2345). Spelled as the union of the two surfaces instead — which
is what the file is about.
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 4 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation.mdx(via IndexSchema (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via invalid_union (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/field.zod.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d25f700735373f65ec86bc009fc87034ff6efa54packageMentionDocs.

Which tree this was computed on

This run read content/docs from c26b5ab91e9162a22e66ac6796ef4757c98f5125 — the merge of head a5f19a98637abc0c1e6a54559e0c3fab37b3a394 into base d25f700735373f65ec86bc009fc87034ff6efa54, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c26b5ab91e9162a22e66ac6796ef4757c98f5125 && git checkout c26b5ab91e9162a22e66ac6796ef4757c98f5125
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d25f700735373f65ec86bc009fc87034ff6efa54 a5f19a98637abc0c1e6a54559e0c3fab37b3a394 && git checkout -B drift-repro d25f700735373f65ec86bc009fc87034ff6efa54 && git merge --no-ff a5f19a98637abc0c1e6a54559e0c3fab37b3a394
node scripts/docs-audit/affected-docs.mjs --json d25f700735373f65ec86bc009fc87034ff6efa54

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d25f700735373f65ec86bc009fc87034ff6efa54 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 23, 2026
@os-sam
os-sam marked this pull request as ready for review August 23, 2026 03:58
@os-sam
os-sam added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit f34f56bAug 23, 2026
39 checks passed
@os-sam
os-sam deleted the claude/issue-10928-unique-scope-message branch August 23, 2026 04:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/mteststooling

Projects

None yet

2 participants

@os-sam@claude