Uh oh!
There was an error while loading. Please reload this page.
fix(spec): give the declared-index unique surface its own rejection message - #11213
Conversation
…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.
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10928
What
UniqueScopeSchemais shared byFieldSchema.uniqueandIndexSchema.unique, but itsrejection message was written from the field-level viewpoint only:
The parenthetical holds at field level, where bare
trueresolves per-organization. It isfalse on a declared index, where bare
truesets neither driver flag and the indexmaterializes over exactly
fields— there'global'is whattruespells, asIndexSchema.unique's owndescribe()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: trueon 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/mainat dispatch before building anything: the three sites the cardquotes still read as quoted (
field.zod.tsschema def and fielddescribe(),object.zod.tsindexdescribe()), and the helper truth table still holds. The premisesurvived.
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:
UniqueScopeSchemaislazySchema(() => z.union([…], { error: uniqueScopeError })). A$ZodErrorMapreceivesonly the issue, and on both surfaces the issue is
invalid_unionon path['unique']— so one map cannot tell the surfaces apart. A per-surface message requiresa per-surface schema; there is no cheaper wiring.
second exported schema in
field.zod.ts. Measured:packages/spec/src/data/index.tsdoes
export * from './field.zod'and./datais a published entry point(
package.jsonexports), so a new export there widens the package's public API — whichthis card explicitly must not do. The declared-index union is therefore module-local to
object.zod.tsand exported nowhere. No symbol is added to or removed from anypublished surface.
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/specnow holds two hand-written$ZodErrorMaps instead of one. Both are out ofalias-integrity.test.ts'sunrecognized_keysclass by the same measurement theexisting one is (they branch on
invalid_union), not by an added exemption — that file'slive control over
field.zod.tsneeded no change, and the docblock that claimed "the lasthand-written map, and it stays one" is updated rather than left to go stale.
The declared-index refusal now reads
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 sharedverbatim: 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_unionon pathunique) are identical on both surfaces.#5082is untouched —bare
truestays 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 TERMrestore. No build leg: the tests import
./object.zodrelatively from source, so theexports-resolution condition for a dist ablation does not hold and nothing indist/could keep the mutation alive.
Pointing
IndexSchema.uniqueback at the sharedUniqueScopeSchema(the exact pre-fixwiring) gives 2 failed | 20 passed:
the DECLARED-INDEX surface names 'global' as the positional meaning of bare truethe contrast is real — the two surfaces do not emit the same textThe 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 diffempty), then 22/22 green again.Verification
Gate families derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the actual diff (the script derived its own change set; it confirmed
--repoagainstthis 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 typecheck—tsc --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-exportsalso green standalone.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-prereqsandcheck:doc-formula-expressionsfailed on the first pass withERR_MODULE_NOT_FOUND/ "The workspace is not built — 67 of 67 workspace packages declare an entry point underdist/that is not on disk". Both green afterturbo run build.check:generatedthen reportedapi-surface/stale. It is an mtime comparison, and I had edited a test file after the build:gen:api-surfacerefused outright with "dist/**/*.d.tsis OLDER thansrc" rather than writing a wrong baseline. Afterpnpm --filter @objectstack/spec build,check:api-surfaceandcheck:generatedare both green andgit statusis 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
$refreuse intoJSONSchema(it keys on node identity). Hashed all 1584 generated JSON schemas on theclean 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 becausegen:schema's cleanup had wipedgen: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 runson every PR regardless. This is a declared narrowing, not a claim of coverage.
Generated by Claude Code