Repository navigation
spec(data): $between requires two non-blank endpoints (#18012) - #19066
Conversation
`$between` admitted an endpoint that is blank: `''` is a string, and the
endpoint union declared `number | Date | string`, so `{ $between: [1, '']
}` parsed green and a half-filled range reached the query with one
meaningless boundary and no signal at any layer.
Both endpoints are now required present and non-empty. The empty string is
the one spelling that changes what parses; `undefined` was already a union
rejection and gains the pointed sentence. `null` keeps the 2026-08-31
ruling's own message, which prescribes the null predicate — a different
remedy for a different intent.
The refusal names the blank SIDE (MIN / MAX plus the index): the only
measured producer is a builder padding a half-typed pair, so both bounds
are present and the author cannot see which one is empty.
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
…087 D3) A semantic entry, not a D2 conversion, and the load path was measured rather than assumed: `applyConversionsToStoredItem` never throws and never validates, and a stored view carrying a blank bound comes back as the same object reference. Dropping the operator would delete a constraint the author wrote and widen the result set silently. Adds the entry FILE and regenerates `registry.ts` — never typed between the `os-generated` markers. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…t rule `check:generated` proved exactly one artifact stale; regenerated with `--fix`, which touched only it. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 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 5d5f1ac5a3b85c37289d4525a0c47bbb78920b61 && git checkout 5d5f1ac5a3b85c37289d4525a0c47bbb78920b61
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4cb05cbf0a8dda962974533ee634393a66440fa e849c873cdf3358eb8fdb992049b9f3e1c18805c && git checkout -B drift-repro d4cb05cbf0a8dda962974533ee634393a66440fa && git merge --no-ff e849c873cdf3358eb8fdb992049b9f3e1c18805c
node scripts/docs-audit/affected-docs.mjs --json d4cb05cbf0a8dda962974533ee634393a66440fa |
Contract reviewServed-tier: Trees read: head Instruments: zod (1) Derived judgments
(2) Semver levelChangeset (3) Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code Generated by Claude Code |
席位记账更正 —— 条款②闸的双载体席位 我先把闸只挂在卡上,又只从卡上清掉。
⇒ 我那一条孤零零的摘除,正是剥闸签名。 补正,四步,全部有回读:
后两笔相隔 2 秒 ⇒ 合法清闸签名。重跑 ⭐ 记在这里而不是私下修掉:复核本身是真的、PASS 也是真的,错的只是我的挂标顺序。一个只有一条摘除的事件流,在 H35 与本门禁眼里都读作放行 —— 那才是这条规则存在的理由。 Generated by Claude Code Generated by Claude Code |
objectstack-ai#19864) Fixes objectstack-ai#19461 Clause-②: yes Executes maintainer ruling **5793356837** (decision batch objectstack-ai#217 item 1, letter **A**, 「217 同意」): tighten the declaration and leave stored rows untouched. The reading of the changeset rule is in its own section below. ## What changed `AdminScopeSchema.businessUnit` (`packages/spec/src/security/permission.zod.ts`) is the delegated-admin scope's one required key. It now refuses an empty value and a whitespace-only value at parse, at the key's own path: ``` FROM AdminScopeSchema.safeParse({ businessUnit: '' }) -> { success: true } TO AdminScopeSchema.safeParse({ businessUnit: '' }) -> { success: false, issues: [ ONE issue: code 'custom', path ['businessUnit'], message 'A blank businessUnit is not a delegation boundary: businessUnit is the sys_business_unit.name (machine name) of the business unit at the root of the subtree this scope delegates, ...' ] } ``` - **Non-transforming.** The key is `z.string().refine(NON_BLANK_STRING, ...)`, using the shared predicate from `shared/refinement-projection.ts`. There is no `.trim()` and no transform: `saveMetaItem` persists the submitted body verbatim, so a transform would validate one string and store another. A real name, padding included, parses byte-identical. - **Declared equals enforced.** `NON_BLANK_STRING` is a declared projectable refinement, so the published JSON Schema for `security/AdminScope` now carries `minLength: 1` and a non-whitespace `pattern`. The `.describe()` text states the rule, and the regenerated reference page carries it. - **The absent key is unchanged.** It is still exactly one `invalid_type` issue at `businessUnit`, because the refinement never runs on a non-string. - **No export added.** The message constant is module-private, like the file's existing refinement helper. `AdminScope` / `AdminScopeParsed` types are unchanged. ## Stored rows (ruling item 2): not rewritten, refused on the next write, no skip path I re-checked the consumer trace on today's `main` (`8cbc3c0084`), by symbol. Three `plugin-security` paths re-parse a STORED scope through `PermissionSetSchema` inside `saveMetaItem`. The refusal reaches all three through the existing parse, with **no consumer edit**: | path (in `permission-set-projection.ts`) | what a stored blank anchor now does | | --- | --- | | boot reconciliation backfill, `reconcilePermissionSetProjection` | the row is counted in `backfillFailed` and reported through the existing ADR-0094 D4 durability `ERROR` (first failure carries the 422 naming `adminScope.businessUnit`; the summary names the record). A valid sibling still backfills. | | restore leg, `createPermissionSetWriteThrough` restore op | the engine un-trash runs; the missing definition is reported at `ERROR` (`NOT re-authored into metadata`), carrying the same 422. | | data-door edit merge, `createPermissionSetWriteThrough` update op | a label-only edit throws `422 INVALID_METADATA` with one issue at `adminScope.businessUnit`, for a legacy record-only row and for a definition already stored in `sys_metadata`. Nothing is saved. | Reads are unaffected: the rehydration seams do not parse, and `metadata-protocol` `saveMetaItem` has no early return before its `resolveOverlaySchema(...).safeParse`. No path crashes, swallows the error silently or skips the row. ## ADR-0087 (ruling item 3) New semantic entry `packages/spec/src/migrations/entries/semantic/18.admin-scope-business-unit-blank-refused.ts`, with `registry.ts` regenerated by `gen:migration-registry` (not hand-edited). Its prose says plainly that stored rows are not rewritten, have no D2 conversion (the root cannot be inferred), and are refused on their next write. It also says that a clean boot is not a completed sweep, because a definition already stored in `sys_metadata` says nothing until it is written again. ## Changeset, and how I read the rule `.changeset/19461-admin-scope-business-unit-blank-refused.md`: `@objectstack/spec` **minor**, body carrying **BREAKING for authored metadata**, the FROM → TO migration table and one-line fix, the ADR-0087 disposition marker `registered admin-scope-business-unit-blank-refused`, and the `Clause-②: yes` line. AGENTS.md's changeset rule: `yes` takes at least `minor`; a narrowing is BREAKING, so the changeset must carry its migration and exactly one ADR-0087 disposition marker; `major` is refused in the launch window (`check-changeset-no-major`). That is the same shape as the `$between` precedent (objectstack-ai#18012 / PR objectstack-ai#19066): `Clause-②: yes`, `minor`, a `**BREAKING for authored metadata**` banner, `registered` disposition. The breaking signal that `check:adr-0087-registration` reads here is the banner: it reports `[BREAKING] registered admin-scope-business-unit-blank-refused (new here)`.⚠️ One reading to flag, not resolved here. The `Clause-②` line is copied verbatim from the claim and the ruling (`yes`). `scripts/pm/clause2-line.mjs` reads a bare `yes` as a widening and spells a pure narrowing `no (narrowing)`. This diff widens nothing. I did not rewrite the ruling's declaration; the report carries it as an open question. ## Tests - `packages/spec/src/security/permission.test.ts`: `''`, `' '` and a tab are refused as one `custom` issue at `businessUnit`, with the message naming `sys_business_unit.name`. The same refusal reaches through `PermissionSetSchema.adminScope` at `['adminScope', 'businessUnit']`. A real name parses, and a padded one is kept byte-identical (this pins that there is no transform). The absent key stays one `invalid_type` at `businessUnit`. - **Firing control.** With `permission.zod.ts` restored on disk to today's `main` blob `0e6d6902b063` (tree only, hash-verified), the 6 refusal pins go red (`6 failed | 86 passed`). Restore was verified: blob back to HEAD `0dddd0bdb439`, `git diff HEAD` empty, porcelain clean. On HEAD the file is `92 passed`. - `plugin-security`, read-only (no file edited): `permission-set-projection`, `packaged-permission-set-lock`, `delegated-admin-gate`, `delegated-admin-gate-cross-organization`, `security-plugin`, `bootstrap-seed-round-trips`, `invitation-placement`, `resolve-permission-sets-for-context.pin` pass (`477 passed`), against a spec `dist/` built from this branch. A scratch probe, not committed, drove the three stored-scope paths above with `''`, `' '` and a tab through the real registered `permission` schema: `10 passed`. A real anchor control passes all three. - `@objectstack/spec` whole package at `a5a53acaf0`: `pnpm test` gives `524 passed` files, `15444 passed | 1 todo`. `pnpm typecheck` passes (`tsc --noEmit`, scripts typecheck, and test-typecheck held at its ledger). - Lint, narrowed and measured at `a5a53acaf0`: eslint (`--no-inline-config`, `--format json`) reported 4 of the 6 changed paths, with 0 errors and 0 warnings. The `.md` changeset and the `.mdx` reference page match no `files` entry in `eslint.config.mjs`. That config never enables type-aware linting (it says so itself), so this diff cannot move the verdict on any untouched file. - Gates: `dispatch-gates --ran` accounts for all 110 derived families. 108 ran green. 2 are NOT MEASURED with `PREREQUISITE NOT MET` (exit 3), because both need a whole-workspace build: `check:dual-build-cjs-loads` and `check:type-check-debt`. CI runs both. - `origin/main` moved 3 commits past the base (`2548ba57de`, `863a775872`, `0e90a8d1c5`). None touches a path in this diff or the three stored-scope paths, so I did not merge them in; the queue rebuilds onto current `main`. ## Generated files that moved - `packages/spec/src/migrations/registry.ts` (`gen:migration-registry`) - `content/docs/references/security/permission.mdx` (`gen:schema` + `gen:docs`: the `businessUnit` description row, twice) `check:generated` reports all 15 generated artifacts up to date against a freshly built `dist/`. `spec-changes.json`, `protocol-upgrade-guide.md`, `authorable-surface/`, `api-surface/` and `export-origins/` did not move. ## Acceptance notes - `businessUnit` with surrounding whitespace around a real name (`' north_america '`) is still accepted and stored as written; the gate's exact lookup resolves it to nothing. The ruling scoped this card to blankness. Noted, not filed. - Out of scope, per the dispatch: no consumer edit (`plugin-security`, `plugin-auth`, `packages/lint`), no data migration, no other key of `AdminScopeSchema`. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18012
Clause-②: yes
Ruling executed: decision batch #146 item 5, letter A — maintainer 「146 同意」 2026-09-17T13:16Z. Carrier: the changeset
.changeset/18012-between-blank-endpoint-refused.md—@objectstack/specminor, body carrying BREAKING for authored metadata, ADR-0087 dispositionregistered filter-between-blank-endpoint-refused.What changed
$betweennow requires two endpoints that are present and non-empty. A blank bound at either side is refused at the authoring door, and the refusal names the blank side —MIN/MAXplus the index — because the only measured producer pads a half-typed pair, so both bounds are present and the author is the one person who cannot see which one is empty.Three spellings, one rule, but only one of them changes what parses:
''''is a string and the endpoint union accepted itundefinedInvalid inputnullThe refinement rides the endpoint factory
RangeOperatorSchema(the documentation copy) andFieldOperatorsSchema(the enforced copy) already share, so the two cannot drift. The published endpoint description gained the rule in the same edit — declared = enforced — which is the whole of the regeneratedcontent/docs/references/data/filter.mdxdiff (5 rows, one per carrier).The empty-string arm is an element-level
superRefine, deliberately not the tuple-level refinement the factory's docblock rules out: a tuple check does not run once an element has failed, whereas the element check runs exactly when the union accepted the endpoint, which is precisely when there is an''to report.The ADR-0087 half the ruling left to measurement
The ruling asked for a D2 conversion entry and explicitly did not pick the behaviour: 「the dev measures which the load path already does for a refused operator and follows that precedent」 (drop the operator, or refuse at load).
Measured, on
origin/mainbefore the change: the load path does neither.applyConversionsToStoredItem— the one primitive every stored-row rehydration seam calls — never throws and never validates; it replays only the positively-recognised lossless transforms in the conversion registry. A stored view carrying{ close_date: { $between: ['2026-01-01', ''] } }comes back as the same object reference. No conversion in the registry drops a filter operator either: the three filter-adjacent entries are two key strips and a key rename.So the precedent to follow is the one the two nearest narrowings of this same surface already set —
filter-preset-ordering-comparand-refusedandanalytics-date-range-array-two-bounds-required, both of which decline a D2 conversion because rewriting would be the platform guessing which bound was meant. Registered as an ADR-0087 D3 semantic entry, with no D2 conversion and no stored-metadata rewrite. Dropping the operator would be worse than guessing: it deletes a constraint the author wrote and silently widens the result set — the failure mode$nincarries in the same file.Consequence, stated rather than left to be discovered: the read path does not re-validate stored rows, so no stored document becomes unreadable. What changes is that re-saving one is refused, at the endpoint's own path, with the blank side named.
migrations/registry.tsThe ruling's Execution line sequenced this on
registry.tsafter #18319 / #18420. The dispatching seat measured that this no longer applies and said so on the card: the file's three tables are generated regions fed one-file-per-entry fromentries/, and all four PRs said to hold it each add their own entry file. This PR did the same — one new file underentries/semantic/, thengen:migration-registry. Nothing was typed between the markers; theregistry.tsdiff is 86 lines of regenerated output andcheck:migration-registryproves the regeneration faithful.Verification
Run on
e849c873cd(the merge oforigin/maininto this branch), heavy runs serialized through the shared verify lock.pnpm --filter @objectstack/spec test— 491 files / 14309 tests passed.pnpm --filter @objectstack/spec typecheck— clean (tsc --noEmit+ scripts + test-layer ledger).pnpm --filter @objectstack/spec check:generated— all 16 generated artifacts up to date after the merge. Exactly one was proved stale during the change (content/docs/references/**) and regenerated with--fix, which touched only it.pnpm lint— repo-wide, exit 0.check-adr-0087-registration --base origin/main,check-changeset-no-major --base origin/main,check-empty-changeset --base origin/main,check:nul-bytes,check:where-matcher,check:query-options-erasure,check:test-source-alias,check:spec-parsed-alias,check:cross-package-test-inputs,check:merge-driver,check:published-files,check:objectui-changeset,check:type-check-coverage,check:doc-anchors,check:docs-single-h1,check:docs-spec-enumerations,check:quick-reference-counts,check-doc-frontmatter,check-docs-section-name,check-closing-keyword-parity.check:type-check-debt— NOT MEASURED, exit 3PREREQUISITE NOT MET: it needs the whole workspace dist closure built, whichlint.ymldoes before the step and this run did not. Not a pass and not a finding. This diff adds no package and moves no ledger entry.Reverse verification — the new assertions are not vacuous
Ablated through
scripts/ablation-replace.mjs, which proves the mutation reached disk before the command runs (no-ifamily):Predeclared direction: red, and exactly the five empty-string cases went red. The
undefinedcase, thenullcase and all 162 pre-existing assertions stayed green — which is what separates "this rule is enforced" from "this file's tests pass". No build step is involved: the spec suite resolves./filter.zodfrom source, not fromdist.Fixture sweep
Every
$betweenarray literal in the tree was read for a blank or absent bound: 3 distinct sites, none of them parsing through this schema — the driver-sql undefined-comparand refusal pin, the service-analytics filter-normalizer pin, and theparseFilterASTpin infilter-comparand-shape.test.ts. No fixture had to be rewritten. Instrument radius: tracked files this repo'sgit grepmatches for$between, scanned for array literals; outside it lie the sibling../objectuicheckout (a different repo, and its half is its own card) and any range built programmatically rather than written as a literal.Acceptance notes
''.parseFilterASTstill reads an empty string as a value — pinned on purpose infilter-comparand-shape.test.ts("refuses ONLY null — falsy and empty-ish members are values, not absence"), and that file is outside this card's file surface and outside the ruling, which scoped the spec half to the schema refinement. Flagged, not filed: the two doors serve two different populations (an author saving a document vs a caller handing a where-clause to the engine) and aligning them is a decision of the same class as this card's, not a seat call. Carrier if it is ever wanted: the same file that carries the null and ordering runtime twins.{ $between: [' ', 'M'] }is green, and there is a positive assertion pinning that, so a later reader cannot widen the refusal without noticing they are doing it. The ruling enumerated'',null,undefined; narrowing a published face past what was ruled is the seat call this card's whole history refuses to make. Noted, not filed.FilterConditionSchemajudges no comparand at all — it isz.record(z.string(), z.unknown())at every field position, so it also lets the already-ruled{ $field }endpoint through. Standing shape, not a hole this narrowing opened; a test now pins it with that{ $field }control beside it so the green reads as a measurement rather than an oversight. Noted, not filed.Clause-②: yesline is copied from the dispatch's claim comment, as the ruling set it. For the record, this diff carries no widening tell: no key, enum member, union arm, export row or registry registration is added, andcheck:api-surfaceis green with no export delta. Read strictly against the clause's own question (「本卡放宽接受集或扩大公开面吗」) the direction is narrowing-only; the direction is carried in prose and by the changeset'sBREAKING for authored metadatabanner rather than by rewriting the ruling's word.$betweenpair with''— the objectui half of objectstack#18012 (batch #146 item 5, letter A) objectui#9695 and is untouched here. It is safe on its own and may land either side of this PR.Generated by Claude Code