Skip to content

fix(spec): refuse a blank $between endpoint at the runtime filter door - #19374

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-19071-between-blank-endpoint-runtime
Sep 21, 2026
Merged

os-project-manager merged 3 commits into
mainfrom
claude/issue-19071-between-blank-endpoint-runtime

Conversation

@os-sam

@os-sam os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes #19071

Clause-②: no

The runtime filter door now refuses a blank $between endpoint, exactly as the authoring schema door already does.

The split this closes

RANGE_ENDPOINT_DESCRIPTION — the published endpoint contract shared by both of $between's bounds — has stated since 2026-09-17 (#18012, batch #146 item 5 letter A) that "BOTH are required NON-BLANK: an empty string, null and undefined are refused, and the refusal names the blank side". That rule shipped at the schema door only.

Re-measured on this branch's base 2277d1fcd at 2026-09-20T13:22Z, all three of the card's probes reproduce, control included:

probe before this PR
parseFilterAST({ at: { $between: ['', ''] } }) returned unchanged — same object reference
parseFilterAST({ at: { $between: ['2026-01-01', ''] } }) returned unchanged — same object reference
control parseFilterAST({ at: { $between: [null, 1] } }) threw Operator "$between" on field "at" requires two non-null bounds

One published sentence, two truth values. The door that passed it is the one an embedder reaches by handing a lowered filter straight to a driver, where the range stops bounding on the blank side while still reading as a complete two-element range.

What this changes

assertListComparandShapes' $between arm refuses '' and undefined at either bound with INVALID_FILTER / 400, naming the blank side (MIN or MAX plus the index) and carrying the schema door's own two prescriptions — write the bound you meant, or drop $between for {"$gte": min} / {"$lte": max} if only one side was ever bounded. The longest assembled form measures 466 characters against the unrelaxed 500-character client bound, and the bound test grew the new cases.

Order inside the arm is arity, then null, then blank, so a pair that is blank on one side and null on the other keeps the message it has had since 2026-08-31.

⚠️ One ruled word could not be implemented as written — please read this cell

The ruling says the runtime door refuses a blank endpoint "exactly as the schema door does (empty-after-trim string, null, undefined)". Those two halves disagree, and the parenthetical is the one that does not hold: the schema door does not trim.

endpoint pair schema door on 2277d1fcd this PR's runtime door
[' ', 'M'] (whitespace-only) success: true passes
['\t\n', 'M'] success: true passes
['', 'M'] refused, names MIN refused, names MIN

rangeEndpointSchema's own comment states the rule as endpoint !== '' and says in as many words: "⛔ Not a trim and not a whitespace rule: the ruling is the empty string, and widening it here would narrow a published face further than ruled." filter.test.ts then pins RangeOperatorSchema.safeParse({ $between: [' ', 'M'] }) green on purpose, with the comment "this assertion is what keeps a later reader from widening it without a ruling of their own".

So an empty-after-trim predicate here would have re-opened the very split this card exists to close — in the opposite direction, with whitespace passing the authoring door and being refused one step later — and narrowed a published face further than any ruling has. This PR implements the operative clause ("exactly as the schema door does", "the same guidance as the schema door") and leaves whitespace-only endpoints legal at both doors. ⛔ Nothing is re-adjudicated: if the intent really was to trim, that is a second narrowing of a published face and wants its own ruling, and it is one line here plus the filter.test.ts pin on the other side.

Scope held, deliberately

Verification

Every reading below was taken in this worktree at HEAD 7682aebc (after git merge origin/main), unless the line says otherwise.

  • pnpm --filter @objectstack/spec build — green.
  • pnpm --filter @objectstack/spec test — 502 files / 14696 tests passed.
  • pnpm --filter @objectstack/spec typecheck — green (tsc --noEmit, scripts project, and the test-layer debt ledger held at 54 files / 259 errors / 144 pinned signatures).
  • pnpm --filter @objectstack/objectql exec vitest run src/engine-filter-array-lowering.test.ts src/engine-comparand-type-door.test.ts src/query-expression-conformance.test.ts src/protocol-explicit-filter-field-gate.test.ts — 4 files / 289 tests passed. First attempt was a PREREQUISITE failure, not a red: the closure was unbuilt and the run died on "Failed to resolve entry for package @objectstack/core". Built @objectstack/objectql^... and re-ran.
  • Gate family re-derived in this worktree from the real changed paths: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack — 81 families at this head (75 before the changeset existed). All 81 run with exit codes captured to disk before any pipe; --ran reconciles 81 derived / 80 run / 1 NOT MEASURED / 0 UNRUN.
  • The one NOT MEASURED is node scripts/check-plugin-teardown-shape.mjs --self-test, exit 3: its positive control is pinned to commit 621a487607881c66b2899b7e3477115229a156b4, which this shallow checkout cannot reach; git fetch --deepen 500 did not bring it. That command grades the checker's own fixtures, not this diff — the PR-relevant form, node scripts/check-plugin-teardown-shape.mjs, exits 0.
  • Three gates first answered exit 3 PREREQUISITE NOT MET on an unbuilt tree (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt) and one hit my runner's own 150-second cap (check:query-options-erasure). All four are green after pnpm build, and check:type-check-debt re-measures 4 ledger entries / 53 raw errors, none above its recorded number.
  • pnpm lint — the whole repo, eslint . --no-inline-config, exit 0 at 7682aebc. No narrowing was claimed and none was needed.

Acceptance notes

Noted while measuring, ⛔ not filed by this dispatch and ⛔ not fixed here:

  • A { $field } reference as a $between endpoint is refused by the schema door and accepted by the runtime door. The same two-door shape as this card, one endpoint spelling over, and already ruled on the schema side: RANGE_ENDPOINT_DESCRIPTION says "A { $field } reference is NOT an endpoint shape", ruled 2026-08-11 under [spec] FieldReferenceSchema is declared in the $between endpoints but NO backend resolves a $field inside a list #7596. Reproduction on 2277d1fcd: RangeOperatorSchema.safeParse({ $between: [{ $field: 'a' }, 'M'] }) answers success: false, while parseFilterAST({ f: { $between: [{ $field: 'a' }, 'M'] } }) returns the filter unchanged. It is left alone here because its refusal needs its own wording and, being a second narrowing of a published face, its own ruling — the same reason this card exists. Dedupe words: field reference between endpoint · parseFilterAST runtime door · filter-comparand-shape · 7596 endpoint shape · two doors disagree.
  • @objectstack/hono fails its dts build under turbo run build --concurrency=2 --filter='./packages/*' --filter='./packages/*/*', with TS7016: Could not find a declaration file for module '@objectstack/plugin-hono-server', and builds clean under pnpm build on the same tree minutes later. Reads as a build-ordering race in a shared warm cache rather than a defect in the tree; recorded because the gate prescription for check:type-check-debt names that exact command. Carries no reproduction that does not depend on cache state, so it is an observation, not one of the three filing classes.

Generated by Claude Code

`parseFilterAST` lowered `{ $between: ['', ''] }` unchanged while the
published endpoint contract (`RANGE_ENDPOINT_DESCRIPTION`) states that both
endpoints are required NON-BLANK — one sentence with two truth values,
depending on which door a caller came through. The door that passed it is
the one an embedder reaches by handing a lowered filter straight to a
driver, where the range stops bounding on the blank side while still
reading as a complete range.

The comparand-shape door now refuses `''` and `undefined` at either bound,
naming the blank side (MIN / MAX plus the index) and carrying the schema
door's own two prescriptions. Scope is the schema door's notion of blank
and nothing wider: `null` keeps its own 2026-08-31 message (it prescribes
the null predicate), whitespace-only endpoints stay legal at both doors,
and falsy endpoints and falsy `$in` / `$nin` members are untouched.

The `$between` row of the `filter-comparand-shape.test.ts` pin is inverted
and only that row; the parity pin that replaces it reads both doors rather
than restating either.

Claude-Session: https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 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 32708262d787c4a151dbeba2991b287b175df958packageMentionDocs.

Which tree this was computed on

This run read content/docs from 016a3b62fe1943f2b553e40c339d5c100222bd83 — the merge of head 7682aebcc52adf9954ce653397a9b22abbbf7536 into base 32708262d787c4a151dbeba2991b287b175df958, 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 016a3b62fe1943f2b553e40c339d5c100222bd83 && git checkout 016a3b62fe1943f2b553e40c339d5c100222bd83
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 32708262d787c4a151dbeba2991b287b175df958 7682aebcc52adf9954ce653397a9b22abbbf7536 && git checkout -B drift-repro 32708262d787c4a151dbeba2991b287b175df958 && git merge --no-ff 7682aebcc52adf9954ce653397a9b22abbbf7536

node scripts/docs-audit/affected-docs.mjs --json 32708262d787c4a151dbeba2991b287b175df958

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

os-sam commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 20/20 CONTRACT_REVIEW_TIER
Head-sha: 7682aebcc52adf9954ce653397a9b22abbbf7536

Isolated contract-review subagent for the domain:spec seat 3 loop. Tier-stamp control, two separate numbers: this record was produced across 20 assistant turns, and it issued 31 REST requests, exactly one of them a write (the POST of this comment). Every tree reading below was taken in a private worktree detached at 7682aebcc52adf9954ce653397a9b22abbbf7536 (merge-base with main be7382d77ee32f830e98e17a82b63947472a9eb0; the three-dot delta is exactly 3 files, +240/−3: .changeset/19071-between-blank-endpoint-runtime-door.md, packages/spec/src/data/filter-comparand-shape.ts, packages/spec/src/data/filter-comparand-shape.test.ts), 2026-09-20T14:41Z to 2026-09-20T14:54:28Z. CI was read per check NAME on that sha, newest run per name, at the time stated. Card #19071, ruling 5748839561 (summon #25 class-1 item 3, letter A), the PR body and the os-dev-report (5750455016) were read; nothing below rests on a report's narrative without a reading behind it.

① Derived judgments

⭐ The trim question — the ruled parenthetical the dev deliberately did not implement. Judged squarely: the operative clause governs, the parenthetical is a slip, and the PR as written is correct. Not a FAIL.

  1. Is the contradiction real? Yes, and it is measurable rather than a reading of intent. The schema door's rule at packages/spec/src/data/filter.zod.ts:770 is literally if (endpoint !== '') return;, under the comment at :768-769 「⛔ Not a trim and not a whitespace rule: the ruling is the empty string, and widening it here would narrow a published face further than ruled」; filter.test.ts:695-698 pins RangeOperatorSchema.safeParse({ $between: [' ', 'M'] }).success === true 「deliberately NOT judged … this assertion is what keeps a later reader from widening it without a ruling of their own」; and the published contract RANGE_ENDPOINT_DESCRIPTION (filter.zod.ts:580-583) names 「an empty string, null and undefined」, not whitespace. Re-measured by me at this head against both doors: [' ', 'M'], ['\t\n', 'M'] and [' ', 'M'] all answer schema success: true and lower through parseFilterAST unchanged, same object reference. So 「exactly as the schema door does」 and 「empty-after-trim string」 cannot both be satisfied by any implementation.
  2. Would implementing the parenthetical have narrowed a published face beyond any ruling and broken a pin that exists to prevent exactly that? Yes, on either route, and the two routes fail differently. A trim at the runtime door ALONE would have re-created the exact defect class this card was filed on — one published sentence, two truth values, whitespace passing the authoring door and refused one step later — and would narrow parseFilterAST's accept-set below what RANGE_ENDPOINT_DESCRIPTION publishes; it would NOT by itself have turned the filter.test.ts:698 pin red, because that pin sits on the schema door. A trim at BOTH doors (the only way to honour 「exactly as the schema door does」 together with a trim) breaks that pin, which exists precisely to require a ruling for that widening. Either route also falsifies the ruling's own class-1 qualification 「No floor moves … no published semantics move (the description is the contract and already says non-blank)」 — a trim moves published semantics, so it lies outside the door the director used to self-adjudicate. The parenthetical is not implementable inside the ruling.
  3. Right call, or a silent re-adjudication dressed as one? The right call, and not silent. The choice is recorded in the PR body (its own ⚠️ section), in the report's deviations, in the emitter's module note and in the arm's own comment, in the changeset's "What does NOT change", and as a row of the parity pin. On substance the dev did not choose between two admissible readings; it declined to invent a third door, taking the operative clause together with the ruling's whole rationale (「one rule, one meaning, both doors」, 「the implementation follows the declaration」). If trimming is wanted, it is a second narrowing of a published face at both doors on its own ruling — and the seat has already opened that question as [Decision] ruling 5748839561 contradicts itself on $between blank endpoints — does the parenthetical 「empty-after-trim」 govern, or the operative clause 「exactly as the schema door does」? #19376 (needs-user-decision, created 2026-09-20T14:41:44Z by the seat, after the dev's report).

Zone 2 — claims tested against the tree.

  1. Only the $between row of the pin was inverted — confirmed. The three-dot diff of filter-comparand-shape.test.ts removes exactly three lines: the import line (replaced to add RangeOperatorSchema), the it(...) title line (re-titled 「among the MEMBERS」), and the single expect(parseFilterAST({ at: { $between: ['', ''] } })) row. The $in: [0, false, ''] and $nin: [0, false, ''] rows and the [0, 0] endpoint row stand in place, byte-identical. Nothing else in the file moved, and in the emitter the $in/$nin null-member arm ([finding] driver-memory's matcher answers a NULL comparand inconsistently across the two readings of "no value" — $in:[null] / $nin:[null] disagree while $null / $ne:null agree #13357) is outside every hunk.
  2. The replacement pin reads BOTH doors and asserts they agree — confirmed; that is what it does. answers every endpoint spelling exactly as the SCHEMA door does iterates 13 pairs, derives schemaRefuses from RangeOperatorSchema.safeParse(...) and runtimeRefuses from a try/catch around parseFilterAST(...), and asserts equality per row; expect(refused).toHaveLength(7) guards vacuity (the seven refused rows are the three '' pairs, the two undefined pairs and the two null pairs). It restates neither door's list.
  3. RANGE_ENDPOINT_DESCRIPTION and filter.zod.ts untouched; no ADR-0087 transition registered — confirmed. git diff --name-only be7382d7...7682aebc lists the three files above only; filter.zod.ts:580-583 reads verbatim as the ruling quotes it; PR feat(spec)!: publish the $-prefix key ban the normalized filter enforces, and make the ratchet able to see it #19335 (open, draft, head 1dfe2f40) holds filter.zod.ts and this PR does not enter it. The changeset's disposition is not-required (already-registered filter-between-blank-endpoint-refused); that id exists on the ledger at this head (packages/spec/src/migrations/registry.ts:8436, migrations/entries/semantic/18.filter-between-blank-endpoint-refused.ts), and Check Changeset — which runs check-adr-0087-registration.mjs --base MERGE_BASE — is success at 14:35:46Z.
  4. Refusal order arity → null → blank — confirmed by code and by probe. filter-comparand-shape.ts:556-580: arity, then comparand.indexOf(null), then findIndex(bound === '' || bound === undefined). Probe at head: ['', null] answers 「requires two non-null bounds. Received null at where.at.$between[1]」 — the null-bound sentence is not swallowed. The new pin the null bound keeps the 2026-08-31 ruling's own message asserts the same, including not.toContain('non-blank').
  5. The undefined message change — verified with a lit control; blast radius zero. Ablation (emitter restored to the merge-base blob, tree only, test file at head): [5, undefined] was ALREADY refused, INVALID_FILTER/400, by the type door with 「Filter comparand at where.at.$between[1] is undefined. { key: undefined } cannot be told apart from an omitted key … Write the null predicate — {"$eq": null} / {"$ne": null}」 (481 chars); at head the same input answers 「requires two non-blank bounds. Received undefined at where.at.$between[1] (the MAX bound) …」 (430 chars). So the PR replaces that sentence at this position, and the prescription it replaces ($eq null) is the one the null-bound ruling refuses for a range endpoint — the change runs in the right direction. Blast radius: 49 test files repo-wide assert 「is undefined」 (the lit control); zero of them at a $between path reached through parseFilterAST. The two $between-plus-undefined tests outside packages/spec ride their own doors with their own sentences: packages/drivers/driver-sql/src/sql-driver-undefined-comparand-refusal.test.ts:152 asserts driver-sql's 「Write null if you meant the null predicate」/FieldOperatorsSchema text at filter.score.$between[1]; packages/services/service-analytics/src/__tests__/filter-normalizer-undefined-comparand.test.ts:184 uses the OBJECT form, which lowerAnalyticsWhere never hands to parseFilterAST (only the array form is lowered, filter-normalizer.ts:1521) and which assertDefinedComparands refuses as [analytics] comparand at "d".$between[0] is undefined. The type door's own test calls normalizeFilterComparandTypes directly, so the shape door does not sit in front of it there. CI Test Core is the broad control — see the readings under ②.
  6. Gates. Not re-run here as a family; the checks of record for this sha, read at 2026-09-20T14:54:28Z: 31 names successAuto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Dogfood Regression Gate (1/3), Dogfood Regression Gate (2/3), Dogfood Regression Gate (3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core, Test Core (1/6), Test Core (2/6), Test Core (3/6), Test Core (4/6), Test Core (5/6), Test Core (6/6), The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter; skipped (path-filtered): Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in); ⛔ NOT GREEN at read time, still in_progress: Lint & Repo Gatesin_progress is never green, so the seat reads these names success before any enqueue; this record does not vouch for them; no failed run on this sha. Locally at head: eslint --no-inline-config over the two touched source files exit 0; vitest run src/data/filter-comparand-shape.test.ts src/data/filter.test.ts 2 files / 223 tests passed; the full @objectstack/spec suite answers 501 passed + 1 skipped of 502 files, 14694 passed + 1 skipped of 14695 tests, exit 0 (pnpm --filter @objectstack/spec test, 14:49:44Z–14:53:32Z) — one test fewer than the report's 「14696 tests passed」 and one skipped, not material, recorded as my own reading rather than the report's. The dev's one 「NOT MEASURED」 (check-plugin-teardown-shape.mjs --self-test, exit 3, positive control pinned to a commit a shallow checkout cannot reach) grades the checker's fixtures, not this diff; its PR-relevant form exiting 0 is the reading that bears on this PR, and Lint & Repo Gates covers the family on CI.
  7. No ablation was run by the dev — its reasoning holds, and I ran one anyway. The pins cannot pass while the refusal is absent: refusalOf throws expected the shape door to refuse this filter, but it returned on a non-throw, and the parity pin carries the toHaveLength(7) vacuity guard. Measured: with filter-comparand-shape.ts restored to the merge-base blob (85 deletions) and the test file at head, filter-comparand-shape.test.ts answers 8 failed / 48 passed — the blank-endpoint refusal pins, the side-and-remedies pin, the nested-path pin, the two-door parity pin and the 500-char bound pin all go red; ['', ''] and ['2026-01-01', ''] lower unchanged (same reference) while the schema door refuses them, reproducing the card's defect. Restored afterwards: git status --porcelain empty, blob e82206fa… equals the HEAD: path.

Zone 3.

  • Docs drift, hand-read with a lit control. 10 pages under content/docs mention $between. The only pages that state the endpoint rule are the auto-generated references/data/filter.mdx rows (5 occurrences), which already carry the NON-BLANK sentence. Every hand-written mention describes $between as an inclusive two-value range, and none documents a blank or one-sided endpoint as accepted: protocol/objectql/query-syntax.mdx:262 「Range (inclusive)」, data-modeling/queries.mdx:55 「Value is in range (inclusive)」, protocol/kernel/http-protocol.mdx:395 「Between two values (tuple)」; query-syntax.mdx:1413-1431 documents parseFilterAST() as the tuple-to-object converter and makes no acceptance claim. skills/ (2 files mention $between) and docs/ carry no permissive prose either. Nothing to correct.
  • REST writes. PR timeline: the five labels (size/m, documentation, protocol:data, tests, tooling) are github-actions[bot] writes at 14:35:01–14:35:18Z; no assigned, ready_for_review, auto_merge_enabled or review_requested event exists; the PR is still draft with no assignees, no requested reviewers and no auto-merge. The report comment 5750455016 was created 14:35:56Z and last edited 14:38:19Z (the two declared PATCHes). Two issues carrying this shared session id were created at 14:41:44Z — [Decision] ruling 5748839561 contradicts itself on $between blank endpoints — does the parenthetical 「empty-after-trim」 govern, or the operative clause 「exactly as the schema door does」? #19376 (the trim decision card) and [finding] a { $field } reference as a $between endpoint is refused by the schema door and accepted by the runtime door — the #19071 shape, one endpoint spelling over, already ruled on the schema side #19377 (the { $field } endpoint finding) — and both bodies attribute themselves to the domain:spec seat 3, filed out of the dev's out_of_scope_findings after the report; under the shared identity that attribution is the bodies' own word, but the timing (six minutes after the report, three after its last edit) and their content are consistent with it. No dev write beyond the four declared was found.

② Semver level

'@objectstack/spec': minor with a BREAKING for callers banner and a FROM → TO migration table — the repo's launch-window convention (check-changeset-no-major.mjs: a breaking change ships minor; the two mandatory breaking-ness carriers are the banner and the ADR-0087 disposition, both present) and the direct precedent .changeset/18012-between-blank-endpoint-refused.md (minor, 「BREAKING for authored metadata」). Clause-②: no is declared identically on the PR body and the changeset, and the card's governing claim says the same (check-clause2-carriers.mjs --pair 19374, read 2026-09-20T14:47:18Z: DECLARED no on both limbs; its only refusal was C6, the absence of this very record). No new key, member, export or registration enters a published payload, so no is truthful. One noted deviation, non-blocking: the ruling expected 「no (narrowing)」 and both carriers say bare no — the arm is what makes the level axis enforce minor mechanically; the level chosen is the one the arm would have demanded, so the outcome is identical, but the arm is the more truthful declaration. The seat may add (narrowing) on both carriers at landing time; it changes no level.

③ Boundary flags

  • Dev flag 「one ruled word could not be implemented as written」: answered above — the operative clause governs; the parenthetical wants its own ruling, and [Decision] ruling 5748839561 contradicts itself on $between blank endpoints — does the parenthetical 「empty-after-trim」 govern, or the operative clause 「exactly as the schema door does」? #19376 now carries that question. ⛔ Not re-adjudicated here.
  • Dev flag 「undefined at an endpoint was already refused; this replaces its sentence」: verified, correct direction, blast radius zero with a lit control.
  • open_questions: [] — none to answer.
  • Not a governed-surface diff (no docs/adr/**, .claude/**, skills/**, AGENTS.md or CLAUDE.md path), so this record is the spec lane's contract review, not a Tier S landing record.
  • Readings not taken by me: the 81-family gate sweep as a family, repo-wide pnpm typecheck and pnpm lint (the CI names above stand in), and the @objectstack/objectql filter-door suites (CI Test Core).
  • Side effects of this review: none on the repository or on the PR beyond this comment; the private review worktree is removed.

Implemented-by: claude/issue-19071-between-blank-endpoint-runtime
Reviewed-by: session_01HnRAeVTLJevtQ5iCPX6JSm

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Pointer from the director seat (session_012GcsUbuqFGBibkEDMRC1eE), 2026-09-21T02:13Z: the window this PR was held for is closed — objectstack#19376 is ruled (batch #205 item 1, maintainer 「205 否」): the operative clause of ruling 5748839561 governs, the 「empty-after-trim」 parenthetical is void, whitespace-only endpoints stay legal at both doors, and this PR is correct as written. Contract review 5750553570 stands on the current head 7682aebcc5; no carrier label is hung. Landing armed by this seat in this stroke (ready already; squash auto-merge through the queue) as the ruling's execution — the domain:spec seat keeps the landing record on #19071.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

3 participants