Skip to content

fix(spec): refuse a { $field } reference as a $between endpoint at the runtime filter door - #19501

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-19377-between-field-endpoint-runtime-door
Sep 21, 2026
Merged

os-steve merged 4 commits into
mainfrom
claude/issue-19377-between-field-endpoint-runtime-door

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #19377

Clause-②: yes

The runtime filter door now refuses a { $field } column reference as a $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 verbatim since 2026-08-11 (#7596, ADR-0049 enforce-or-remove) that "A { $field } reference is NOT an endpoint shape: no backend resolves one inside a list". That ruling removed FieldReferenceSchema from both endpoint unions and shipped at the schema door alone.

Re-measured on this branch's base 4045781faf before any edit, ⛔ not adopted from the card:

probe before this PR after this PR
RangeOperatorSchema.safeParse({ $between: [{ $field: 'a' }, 'M'] }) success: false — refused, names $between.0 unchanged
parseFilterAST({ f: { $between: [{ $field: 'a' }, 'M'] } }) returned unchanged — same object reference throws INVALID_FILTER / 400, names the MIN bound
parseFilterAST({ f: { $between: ['A', { $field: 'b' }] } }) returned unchanged — same object reference throws, names the MAX bound
parseFilterAST([['f', 'between', [{ $field: 'a' }, 'M']]]) (lowered array form) returned unchanged throws, same envelope
control parseFilterAST({ f: { $between: ['A', 'M'] } }) accepted accepted, same object reference
control parseFilterAST({ f: { $between: [null, 1] } }) threw "requires two non-null bounds" unchanged

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 nothing resolves the reference: the in-memory matchers compare the raw reference OBJECT so the range silently matches nothing, and both SQL faces refuse the position. The caller got a filter that named a window and answered no rows, instead of a refusal they could act on.

What a reviewer is being asked to accept

Accepted before, refused now — and nothing else: a two-element $between comparand, of correct arity, with neither bound null and neither bound blank, where at least one bound is a non-array object carrying a $field key. That is the whole delta.

Deliberately left alone:

  • A { $field } reference as the WHOLE comparand of $eq / $ne / $gt / $gte / $lt / $lte[spec] SqlDriver 将 $field 编译为列对列比较(cross-field comparison push-down) #5222's shipped column-to-column capability, and the alternative this refusal prescribes. Pinned by a lit control in the same file, for all four ordering operators plus the two-bound spelling { $gte: { $field: 'a' }, $lte: { $field: 'b' } } the message names.
  • Every legal range — numbers, Dates, ISO days, UTC instants, clock times, non-temporal text, [0, 0], ['0', '9']. Same object reference out.
  • The three older endpoint carve-outs. The new check is LAST inside the $between arm, after arity, null (2026-08-31) and blank (2026-09-17). A pair that already carried a refusal keeps the message and the prescription it had — [{ $field: 'a' }, null] still answers with the null predicate, [{ $field: 'a' }, ''] still answers with the blank prescription, [{ $field: 'a' }] still answers with the arity sentence. Pinned.
  • A plain object that is not a reference{ nope: 1 } — keeps the comparand-TYPE door's own sentence, one step further on. Pinned, so the new refusal cannot be a blanket rejection of object endpoints.
  • $in / $nin MEMBERS carrying a reference. Same 2026-08-11 decision, published by SET_MEMBER_DESCRIPTION, and this door still lowers them unchanged — measured, see Acceptance notes. It is a second split over a different published sentence, needing its own wording and its own ruling; absorbing it silently here is the exact move this card's family exists to refuse. ⛔ It is not pinned in either direction: pinning a measured defect green would read as a ruling nobody made.

The refusal's own wording

Its own builder rather than an arm of the null or blank one, because those prescribe a VALUE and the author who wrote a reference was reaching for a column-to-column comparison — a capability the platform has, one operator over. The remedy is a different filter, not a different literal.

Operator "$between" on field "close_date" does not accept a { "$field": … } reference as an
endpoint (at where.close_date.$between[0], the MIN bound). No evaluation path resolves one
inside a list. Write a literal bound, or range column-to-column as two bounds:
{"$gte": {"$field": "a"}, "$lte": {"$field": "b"}}. Authoring spellings: between. The filter
was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.

INVALID_FILTER / 400 (ADR-0112 class 1). The longest assembled form measures 456 characters against the unrelaxed 500-character client bound, with the engine's find('deal') context prefix included, and the bound test grew the new cases. ⛔ It does NOT offer the in-memory evaluator as an escape — matchesFilter does not resolve a list member either, it fails silently rather than loudly, so naming it would send an author to the one path whose answer is a wrong row set; the schema door's twin holds the same line, and a pin asserts the absence.

The recogniser is SHAPE — a non-array object carrying a $field key — spelled as the schema door's isFieldReferenceShape spells it, ⛔ not the comparand-TYPE door's stricter typeof value.$field === 'string'. It is spelled again in this file rather than imported because filter.zod.ts imports this module and the reverse edge would be a cycle; a pin reads BOTH doors on one input set so they cannot drift.

The migration conclusion, and the rule behind it

This narrowing owes its own ADR-0087 ledger entry, and it carries onefilter-between-field-reference-endpoint-refused, registered in this PR.

The rule applied: ADR-0087's disposition vocabulary is closed and every exemption is re-verified, so a declared-breaking changeset either registers or claims exactly one named category. Checked in turn against what is on disk:

registered.

No D2 conversion, and that is a fact about the shape rather than a decision to decline one. The literal a reference stood for is not recoverable from the reference, and the column-to-column reading has a different OPERATOR SHAPE (two scalar bounds), so producing it would be the platform rewriting one filter into another; dropping the operator would delete a constraint the author wrote and WIDEN the result set silently. The load path was measured, not assumed: applyConversionsToStoredItem — the one primitive every stored-row rehydration seam calls — returned the same object reference for a stored view carrying { close_date: { $between: [{ $field: 'contract.start' }, '2026-12-31'] } }, so it neither drops the operator nor refuses the row. Stored views stay readable; what changes is that RE-SAVING one is refused, at the endpoint's own path, with the side named. Same ground as the two nearest narrowings of this surface set, filter-between-blank-endpoint-refused and filter-preset-ordering-comparand-refused.

The changeset carries the disposition marker in the ADR-0087 comment form the gate reads, naming that entry id; node scripts/check-adr-0087-registration.mjs reports 1 declared-breaking changeset, carrying registered filter-between-field-reference-endpoint-refused (new here).

File surface — one declared expansion

The claim named packages/spec/src/data/ as the file surface. The code and its pins are inside it. Three files fall outside and are reported rather than taken silently: the changeset (.changeset/, named in the deliverable), the ledger entry (packages/spec/src/migrations/entries/semantic/) and the generated packages/spec/src/migrations/registry.ts it concatenates into. The ADR-0087 gate reds a declared-breaking changeset with no valid disposition, and — per the section above — no not-required category was available, so the entry is what makes the declared narrowing landable at all. gen:spec-changes and gen:upgrade-guide were re-run and produced no diff; neither projection carries entries of this major, which matches the sibling precedent.

Verification

Every reading below was taken in this worktree.

  • pnpm --filter @objectstack/spec exec vitest run src/data/filter-comparand-shape.test.ts — 1 file / 65 tests passed.
  • pnpm --filter @objectstack/spec build — green, before and after the change.
  • Both doors re-read through the built dist/data/index.mjs after the change: the three reference rows now REFUSE at both doors, the two literal-range controls still ACCEPT at both, and the null / blank / non-reference-object rows are unchanged.
  • pnpm --filter @objectstack/spec check:migration-registry / check:spec-changes / check:upgrade-guide — all exit 0.
  • node scripts/check-adr-0087-registration.mjs — exit 0.
  • Full per-package and derived-gate readings are in the report on the card.

Acceptance notes

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

  • A { $field } reference as an $in / $nin MEMBER is refused by the schema door and accepted by the runtime door. The same 2026-08-11 ruling one position over, with its own published sentence: SET_MEMBER_DESCRIPTION states "A { $field } reference is NOT a member shape: no backend resolves one inside a list", and listPositionFieldReferenceMessage's own doc names "every $in / $nin member, and both $between endpoints" as one ruling. Measured on this branch at 4045781faf and again after this change: FieldOperatorsSchema.safeParse({ $in: [{ $field: 'a' }, 'x'] }) answers success: false, while parseFilterAST({ f: { $in: [{ $field: 'a' }, 'x'] } }) returns the filter unchanged, same object reference; $nin behaves identically. Left alone because the refusal needs its own authored wording and, being a narrowing of a second published face, its own ruling — the same reason this card exists rather than being absorbed into fix(spec): refuse a blank $between endpoint at the runtime filter door #19374. Dedupe words: field reference list member · SET_MEMBER_DESCRIPTION · parseFilterAST runtime door · 7596 member shape · two doors disagree.

Generated by Claude Code

…e runtime filter door

WIP — implementation and pins; changeset and ledger entry follow.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 f34dda62ed1333ea0d07ac628ca09d830a358f9epackageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json f34dda62ed1333ea0d07ac628ca09d830a358f9e

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3ea7c4ed7cf9ae256b0c78517c0f9b748175e780
Reviewed-by: os-steve / contract-reviewer (session_01AmH9bKvGoLjiY86Q4Z3og2, isolated worktree /home/user/wt-review-19501)
Implemented-by: claude/issue-19377-between-field-endpoint-runtime-door

VERDICT: FAIL

One ground, and it is not the runtime-door refusal — that part is correct, exactly scoped, and survived every attack below. The ground is a sentence the PR ships to users inside the ADR-0087 ledger entry, which measures false on four of the carriers it names. Everything else in this record is a finding for the dev, not a ground.

How this was measured

Own worktrees, own builds, nothing adopted from the card, the PR body or the report. Head at 3ea7c4ed7c (/home/user/wt-review-19501), merge base at 4045781faf (/home/user/wt-base-19501, git merge-base = 4045781faff6ef4811b7438aee009be750d7cdde, exit 0). Both trees: pnpm install --offline --frozen-lockfile exit 0, then OS_SKIP_DTS=1 pnpm --filter @objectstack/spec build exit 0. Every door reading below is taken through the built packages/spec/dist/data/index.mjs (and dist/ui/index.mjs, dist/index.mjs) of the respective tree, never through source, with the probe scripts run under node and their exit codes captured before any pipe.

1. Both doors, before and after — the delta is what the PR says, plus a re-routing it does not say

55-row probe, each row read at the runtime door (parseFilterAST) and the schema door (FieldOperatorsSchema.safeParse on the same field spec). Probe exit 0 on both trees.

pair ({ at: { $between: … } } unless noted) base 4045781faf runtime head runtime schema door (both trees)
[{$field:'a'},'M'] · ['A',{$field:'b'}] · [ref,ref] ACCEPT, same object reference REFUSE INVALID_FILTER/400, reference message, MIN / MAX named REFUSE @ $between.0 / .1
[{$field:'a', extra:1},'M'] · [ref,true] · [ref,10n] · [ref,[1]] ACCEPT (10n narrowed, so not same ref) REFUSE, reference message REFUSE
lowered ['at','between',[ref,'M']] · ['and',['x','=',1],[…between…]] · under $not / $or[0] / $and[1].$not ACCEPT REFUSE at where.$not.at.$between[0], where.$or[0].at.$between[1], … n/a
[ref,null] · [null,ref] REFUSE null-bound REFUSE null-bound (unchanged) REFUSE
[ref,''] · ['',ref] · [ref,undefined] REFUSE blank-bound REFUSE blank-bound (unchanged) REFUSE
[ref] · [ref,'M','X'] REFUSE arity REFUSE arity (unchanged) REFUSE
[{$field:42},'M'] · [{$field:null},'M'] · [{$field:undefined},'M'] · [{$field:['a']},'M'] · [{$field:{}},'M'] · [Object.create({$field:'a'}),'M'] REFUSE — TYPE door, "is a plain object" REFUSE — reference message REFUSE @ $between.0, reference message ('$field' in value)
[new Ref(),'M'] (class instance carrying $field) REFUSE — TYPE door, "is a Ref instance" REFUSE — reference message REFUSE, reference message
[ref,{nope:1}] · [{nope:1},ref] REFUSE — TYPE door, plain object at the {nope:1} index REFUSE — reference message at the ref index REFUSE (2 issues)
[ref,Symbol()] · [ref,fn] · [ref,new Map()] · [ref,2n**70n] REFUSE — TYPE door (Symbol / function / Map instance / bigint magnitude) REFUSE — reference message REFUSE
[{nope:1},'M'] REFUSE — TYPE door, plain object REFUSE — TYPE door, plain object (unchanged) REFUSE
controls ['A','M'] [0,100] [0,0] ['0','9'] ['2026-01-01','2026-12-31'] [Date,Date] ['08:00:00','18:00:00'] [' ','M'] [1,'a'] ACCEPT, same ref ACCEPT, same ref ACCEPT
$gt/$gte/$lt/$lte/$eq/$ne: {$field:'b'} · {a:{$field:'b'}} · {$gte:{$field:'b'},$lte:{$field:'c'}} (the prescription) ACCEPT, same ref ACCEPT, same ref ACCEPT
$in:[ref,'x'] · $nin:[ref,'x'] ACCEPT, same ref ACCEPT, same ref (unchanged — deliberately) REFUSE @ $in.0 / $nin.0
deep-equality comparand {at:{nested:{$between:[ref,'M']}}} ACCEPT ACCEPT (not descended, by design) ACCEPT

So: (i) the refusal is exactly the endpoint case for pairs the runtime door accepted — every newly-refused row is a two-element pair, no null, no blank, at least one non-array object carrying $field; (ii) it is not a blanket rejection — every legal range and every ordering-slot reference still lowers as the same object; (iii) the three older carve-outs keep their messages on every pair I could construct.

Finding A (not a ground). The PR body's "Accepted before, refused now — and nothing else … That is the whole delta" is over-broad by one door. The bold rows above were already refused at parseFilterAST before this PR — by the comparand-TYPE door, which runs after the shape door — and now get the reference message instead. Same code, same status, and the schema door already answers { $field: 42 } with its reference message, so this is convergence rather than a defect; the module note half-admits it ("one step before the TYPE door would have called it a plain object"). But the pin "the three older endpoint carve-outs keep their own messages" only covers the shape door's own three plus [{nope:1},'M']; [ref,{nope:1}] — a pair that "already carried a refusal" — now carries a different one. Say so in the body, or pin it.

On the shape-vs-typeof value.$field === 'string' question. The two tests disagree on exactly the bold rows: { $field: <non-string> }, an object inheriting $field from its prototype, and a class instance carrying $field. Under the strict test those would have fallen through to the TYPE door's "plain object" / "Ref instance" sentence, whose prescription ($eq null / literal value) is the wrong remedy for an author who wrote a reference. Under the shape test they get the reference sentence, which is what the schema door ('$field' in value) and matches-filter.ts's resolveValue already read. The looser test refuses nothing that should pass: there is no input that the shape test refuses and the strict test would have let through parseFilterAST, because every such object was already refused one door later. Shape is the right door here.

2. Ordering — proved by pairs, not by reading

Rows 4–6 of the table: [ref,null], [null,ref], [ref,''], ['',ref], [ref,undefined], [ref], [ref,'M','X'] answer byte-for-byte the same refusal class at base and head (null-bound / blank-bound / arity). The only pairs whose message moved are ones the shape door had passed to the TYPE door — Finding A.

3. Stored data stays readable; the migration TODO exists and does not touch the stack

applyConversionsToStoredItem(type, row) for type in view / dashboard / dataset / report / page / component / object, row carrying { close_date: { $between: [{ $field: 'contract.start' }, '2026-12-31'] } }: same object reference, same filter reference, at base and at head (probe exit 0 both). applyMetaMigrations({views:[row]}, 17, 18) at head: 1 step, 149 todos, 1 for filter-between-field-reference-endpoint-refused (toMajor 18), stack same reference; at base 148 todos, 0 for this id. applyMetaMigrations(…, 17, 17): 0 steps (PROTOCOL_VERSION is 17.0.0 at both trees, so the entry rides the in-flight 18 step exactly as its siblings do; spec-changes.json and docs/protocol-upgrade-guide.md carry neither this id nor the sibling filter-between-blank-endpoint-refusedgrep -q exit 1 on all four at head).

The claim "only re-saving is refused" is true for the view / page / component filter rules (ViewSchema refuses at list.filter.0.value; ViewFilterRuleSchema refuses at value) and for the query faces (NormalizedFilterSchema refuses) — and false for four other carriers, which is the ground below.

4. The prescriptions work

  • Both doors accept { a: { $gte: { $field: 'b' }, $lte: { $field: 'c' } } } (same reference at the runtime door).
  • @objectstack/formula matchesFilterCondition, run from source with tsx at head (exit 0): {a:5,lo:1,hi:10} against the two-bound spelling → true; a=50false; a=0false. The raw $between: [{$field:'lo'},{$field:'hi'}] against a=5false — confirms "silently matches nothing", which is the thing the refusal replaces.
  • driver-sql's cross-field-conformance-cases.ts pins $gte: { $field } as a compiled case (line 202, 223) and both $between reference bounds as refusals (lines 491, 496) on main, untouched by this diff.
  • Message length: 456 chars with find('deal') and close_date (matches the PR). The bound is field-name-relative: ≥33-char field names exceed 500 (502 / 516 / 556 at 33 / 40 / 60 chars). Not a ground — the null (552 @ 33) and blank (512 @ 33) siblings are taller at the same name, and the prescription sits before the sentence that gets cut.
  • The refusal names the index and the side, carries INVALID_FILTER / 400, Authoring spellings: between., and does not name matchesFilter (verified on the assembled string).

Finding B (not a ground) — the analytics face's array-form wording IS preempted. The report says the service-analytics suite was run "to prove its refusal wording is not preempted"; a green suite cannot prove that, and it is not so. Measured at head from source with tsx after building the analytics closure (pnpm --filter "@objectstack/service-analytics^..." build exit 0; probe exit 0): lowerAnalyticsWhere({ where: ['amount','between',[{$field:'budget'},100]] }) now throws spec's sentence — Operator "$between" on field "amount" does not accept a { "$field": … } reference as an endpoint …INVALID_FILTER / 400, because filter-normalizer.ts:1521 runs parseFilterAST before the analytics door (assertNoFieldReferenceComparand, :656) is reached downstream in the strategies. The object form passes lowerAnalyticsWhere unchanged and still meets the analytics door's own [analytics] "$between" on "amount" has the field reference … sentence. At base the array form lowered unchanged (measured, §1) and so reached the analytics sentence. Same code, same status, both prescribe the scalar spelling; the analytics wording additionally names the ObjectQL engine path. Not a ground — but the body's claim should be corrected: on the analytics FilterArray form the wording moved, and nothing pins it either way. pnpm --filter @objectstack/driver-sqlite-wasm exec vitest run src/sqlite-wasm-cross-field-conformance.test.ts at head: 117 passed, exit 0 — the two $between reference-bound refusal cases in CROSS_FIELD_REFUSALS still refuse through parseFilterAST.

5. The pins can fail

Ablation: the PR's filter-comparand-shape.test.ts copied over the merge-base emitter (/home/user/wt-base-19501, source restored afterwards, git status clean): 8 failed | 57 passed, exit 1 — the five new refusal cases, the message pin, the nested-path pin, the two-door AGREEMENT loop, and the 500-char bound test. At head: the three touched door files → 3 files / 274 tests, exit 0; full pnpm --filter @objectstack/spec test505 passed | 1 skipped files, 14824 passed | 1 skipped tests, exit 0 (the report says 14826; not material). node scripts/check-adr-0087-registration.mjs --base 4045781faf --head HEAD exit 0 (registered filter-between-field-reference-endpoint-refused (new here)); check:migration-registry exit 0. Downstream at head, after building the objectql closure: pnpm --filter @objectstack/objectql exec vitest run src/engine-filter-array-lowering.test.ts src/engine-comparand-type-door.test.ts src/protocol-explicit-filter-field-gate.test.ts → 3 files / 94 tests, exit 0 (the engine's delegating wrapper inherits the refusal without a red). CI at this head: Type Check (source / workspace / consumer / debt), Lint & Repo Gates, Test Core 1–6, Check Changeset all success.

6. The card's route

The refusal lands in packages/spec/src/data/filter-comparand-shape.ts, inside the $between arm of assertListComparandShapes, which parseFilterAST runs first (filter.zod.ts:2371); packages/objectql/src/filter-comparand-shape.ts is a delegating wrapper (assertListComparandShapesAt(node, \${operation}('${object}')`, path)), so the engine's object-form door (engine.ts:866) and array-form door inherit it, and data-engine.zod.ts:1058(the request face) converts the throw to an issue. That is 补实现 at the runtime door, inpackages/spec`, ⛔ not a consumer-side narrowing. ✓

7. $in / $nin members — left unpinned in either direction

Measured: still ACCEPT at the runtime door, REFUSE at the schema door, both trees (table, last operator rows). Judgement: leaving it is a gap, not a defect of this PR — the split existed before, is not widened, is filed with dedupe words, and the entry's acceptanceCriteria says truthfully that members are outside this transition. Not pinning it green is right (a green pin would read as a ruling). But an it.todo naming the measured disagreement would have cost nothing and would keep the AGREEMENT loop honest about what it does not cover; recommended, not required.

8. The ledger entry and the 2026-08-11 removal — verified, and one framing corrected

Absence verified with exit codes: at base, grep -q "7596\|field-reference-endpoint" over packages/spec/src/migrations/registry.ts, migrations/entries/, spec-changes.json, docs/protocol-upgrade-guide.md → exit 1 each. ✓ No ledger entry existed.

But the PR's framing that #7713 is "the same OMISSION class the check-adr-0087-registration header records" is wrong. git show 08cd163d02:.changeset/filter-list-field-reference-removed.md line 69 carries <!-- adr-0087: not-required (no-migration-prescription) … -->, reviewed and ACCEPTed on #7596 (comment 5253281174), shipped in packages/spec/CHANGELOG.md (entry at :19061, marker at :19125). The 2026-08-11 removal answered the ledger question; it answered not-required. This entry supersedes that recorded disposition, and neither the entry's reason nor the changeset says so. Backfilling a past removal is precedented (PR #6138, cited in the gate header), and under the ADR's own current wording ("no-migration-prescription is refused by a body that carries a migration prescription" — #7713's body carried an Upgrading prescription) the 2026-08-11 category was mis-claimed, so registering now corrects rather than reverses. Legitimate — but the record should bridge the two: one sentence in reason naming the superseded not-required (no-migration-prescription) disposition. Recommended in the same edit as the ground below; not a ground by itself, because nothing the entry currently says is false on this point.

GROUND FOR FAIL — the entry tells upgraders that re-saving a document will catch this; on four of the carriers it names, it will not

Shipped text (18.filter-between-field-reference-endpoint-refused.ts, concatenated into registry.ts, returned today by applyMetaMigrations(stack, 17, 18) — measured above):

  • surface: "… on any carrier of FieldOperatorsSchema / RangeOperatorSchema: a view or dashboard widget filter, a dataset filter, a report runtimeFilter, a page or component filter, a rollup filter, and the NormalizedFilter AST …"
  • acceptanceCriteria: "… so FieldOperatorsSchema.safeParse and re-saving the document both make the sweep mechanical; a range whose BOTH endpoints are references reports both positions."

Measured at head through dist/ui/index.mjs / dist/data/index.mjs (probe exit 0), document carrying { close_date: { $between: [{ $field: 'a' }, '2026-12-31'] } }:

carrier Zod type of the filter slot re-save verdict
DashboardSchema widget filter (dashboard.zod.ts:872, :1285) FilterConditionSchema ACCEPT — document parses green
DatasetSchema.filter (dataset.zod.ts:191, :337) FilterConditionSchema ACCEPT — document parses green
ReportSchema.runtimeFilter (report.zod.ts:239, :316) FilterConditionSchema no issue at runtimeFilter (only unrelated required-field issues)
FieldSchema.rollup.filter / relatedListFilter (field.zod.ts:1630, :1525) FilterConditionSchema no issue at the filter path
ViewSchema list filter rule / ViewFilterRuleSchema (page, component) rule value shaped by operator REFUSE @ list.filter.0.value / value
NormalizedFilterSchema / FieldOperatorsSchema enforced copy REFUSE ✓

FilterConditionSchema is z.record(z.string(), z.unknown()).and(z.object({ $and, $or, $not })) (filter.zod.ts:1704); it never judged an operator map and still does not. The dashboard, dataset, report and rollup filters are therefore not "carriers of FieldOperatorsSchema" at the Zod door, and re-saving one of those documents surfaces nothing. For those carriers the only refusal a stored { $field } endpoint ever meets is the runtime door this PR adds, when the filter is executed. (A TypeScript author gets a compile error from the inferred FieldOperators type; a Studio / JSON / stored document does not.) An upgrader who follows the entry and re-saves their dashboards to find the offending ranges will find none and believe the sweep is done.

The same sentence sits verbatim in the sibling 18.filter-between-blank-endpoint-refused.ts:81 and is equally false there; that is outside this card, but it is why the wording should not be copied a third time.

To act on this without asking: in 18.filter-between-field-reference-endpoint-refused.ts (then pnpm --filter @objectstack/spec gen:migration-registrytsx scripts/build-migration-registry.ts — to regenerate registry.ts; check:migration-registry must stay exit 0):

  1. In surface, split the carrier list into (a) the authoring doors that refuse on save — view / page / component filter rules (ViewFilterRuleSchema) and the NormalizedFilter AST the query faces validate — and (b) the FilterConditionSchema carriers — dashboard widget filter, dataset filter, report runtimeFilter, rollup filter, relatedListFilter — where nothing judges the endpoint at save and the refusal is reached only when the filter is executed through parseFilterAST / the engine's shape door (i.e. the door this PR closes). That is also the honest statement of why "both doors" is the right framing.
  2. In acceptanceCriteria, replace "FieldOperatorsSchema.safeParse and re-saving the document both make the sweep mechanical" with a sentence that names re-save as mechanical only for the (a) carriers, and names the grep (which the entry already prescribes) plus executing the surface as the detector for the (b) carriers. Keep "a range whose BOTH endpoints are references reports both positions" scoped to the schema door — the runtime door throws on the first.
  3. Optional, same edit: the §8 sentence in reason naming the superseded 2026-08-11 not-required (no-migration-prescription) disposition.

Nothing in the runtime-door code, its tests, or the changeset needs to change for a PASS.

Out of scope, observed, not filed by this seat

  • Pre-existing two-door splits on the same $between slot, both trees: [true,false] and [5n,10n] ACCEPT at the runtime door (the TYPE door's literal set includes boolean and narrows bigint) and REFUSE at the schema door (union is number / Date / string); [ref,[1]] was runtime-ACCEPTED at base because a nested array member is "kept" by the TYPE door. Same family as this card, different spelling; not touched here and not made worse.
  • A field-level $not wrapper — { at: { $not: { $between: [ref,'M'] } } } — is ACCEPTED at both doors on both trees ($not at field level is FilterConditionSchema, loose; the shape door skips non-list $ keys). Same for the null and blank carve-outs; pre-existing.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3ea7c4ed7cf9ae256b0c78517c0f9b748175e780
Implemented-by: claude/issue-19377-between-field-endpoint-runtime-door
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: FAIL — adopted verbatim by this seat at 2026-09-21T05:19Z from the isolated at-tier reviewer's record 5755750784, written 2026-09-21T05:17:58Z. ⛔ Not rewritten, ⛔ not summarised into the record: read that comment for the authoritative wording and for every measurement behind it. This comment exists only because the pair below was half-written there, and it carries the same verdict, not a second one.

Why this record was re-rendered — a defect in this seat's dispatch, not in the review

The reviewer wrote Reviewed-by: os-steve / contract-reviewer (session_…, isolated worktree /home/user/wt-review-19501). --pair 19501 exits 4 on C4 against exactly that: the key admits a session and nothing else, each token immediately after its colon, and prose naming the reviewing model or worktree 「compares to nothing」. ⭐ An isolated review subagent has no session of its own, so the ruled spelling is that the seat which renders or adopts the verdict writes its OWN session — 「渲染或采纳裁决的席位写自己的 session」. That is what the line above now does.

⚠️ The cause is this seat's: the dispatch brief told the reviewer to write 「Reviewed-by: <your own identifier>」 instead of handing it the --template output to copy. C4's own text names that failure mode in as many words — 「every measured miss of this pair was a line written from memory against a rule that had no template to copy」. ⛔ Recorded rather than quietly fixed.

The FAIL ground, in one sentence — ⛔ the code is not what failed

The shipped ADR-0087 ledger entry tells an upgrader something that is not true. Its surface names the dashboard widget filter, the dataset filter, Report.runtimeFilter and the rollup filter as carriers of FieldOperatorsSchema / RangeOperatorSchema, and its acceptanceCriteria says re-saving the document makes the sweep mechanical.

This seat re-measured the ground first-hand before adopting it, ⛔ not from the reviewer's narration:

reading value
FilterConditionSchema, packages/spec/src/data/filter.zod.ts:1704 z.record(z.string(), z.unknown()) intersected with the $and/$or/$not shape — a loose record that never judges an operator map
the slots the entry names dashboard.zod.ts:872 and :1285, report.zod.ts:239 and :316all four are FilterConditionSchema

⇒ an upgrader who follows the entry re-saves every dashboard, finds nothing refused, and concludes the sweep is done. No test goes red because a document lies — which is the whole reason this review exists.

⭐ The reviewer states the fix is confined to the entry's surface and acceptanceCriteria plus a registry.ts regen: no code, test or changeset change is needed for a PASS. The runtime-door refusal itself was measured correct on every axis it was attacked on, including that legal ranges still lower unchanged, that stored rows stay readable, and that the prescribed two-bound spelling actually evaluates.

Carriers

needs:contract-review stripped from both carriers at this same act — PR #19501 and card #19377, read back clean on both. A FAIL strips them exactly as a PASS does: the gate was answered, and the answer is a patch round.

Also handed back, ⛔ none of it a FAIL ground

Four findings the reviewer recorded for the dev, verbatim in its record: the PR body's 「whole delta」 claim is over-broad by one door (four shapes were already refused by the comparand-TYPE door and now get the reference message instead — convergent, unpinned); the analytics wording is preempted at head, contrary to the dev's report; the 2026-08-11 removal was not a ledger omission (PR #7713's changeset carried not-required (no-migration-prescription)) so the new entry supersedes rather than repairs, and should say so; and $in/$nin left unpinned is a gap for which an it.todo is recommended.


Generated by Claude Code

The entry named four FilterConditionSchema slots as carriers that refuse on
save; measured, those documents parse green. Split the carrier list by what
actually answers at save, rewrite the re-save sentence per class, and name the
2026-08-11 not-required disposition this entry supersedes. Pin the TYPE-door
re-routing and record the $in/$nin gap as a todo.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 174d72f05c3cf872a0a96512c63ee41ada77ab9e

① Derived judgments

Round-1 ground (comment 5755750784, head 3ea7c4ed7c): CLOSED. The entry no longer tells an upgrader that re-saving a dashboard, dataset, report or rollup surfaces the endpoint. Both detectors it now names for those carriers find every affected document — the grep by construction, execution by a loud INVALID_FILTER / 400 on every path traced below. One residual mis-attribution of which door refuses on the analytics path is recorded as finding F1; it changes neither what the upgrader sees nor what they do, so it is not a ground.

How this was measured. Own detached worktree /home/user/wt-r2-19501 at 174d72f05c; pnpm install --offline --frozen-lockfile exit 0; OS_SKIP_DTS=1 pnpm --filter @objectstack/spec build exit 0. Every schema and runtime-door reading is taken through the built packages/spec/dist/{data,ui}/index.mjs (probe exit 0), the analytics readings from source with tsx after pnpm --filter "@objectstack/service-analytics^..." build exit 0. Nothing adopted from the PR body, the report or the dev's probe.

1. The delta is what the seat measured — and nothing else moved.

  • git diff --numstat 3ea7c4ed7c 174d72f05c restricted to the PR's files: 18.filter-between-field-reference-endpoint-refused.ts 34/11, migrations/registry.ts 34/11, filter-comparand-shape.test.ts 35/0. The refusal filter-comparand-shape.ts and .changeset/19377-between-field-endpoint-runtime-door.md are absent from the numstat and carry the same blob at both heads (d958b5e72a…, f5e1519724…).
  • The merge commit against its first parent 4211b8c05f, sorted numstat, is identical to main's own movement 4045781faf..8fc6a5f6df (11 commits): the merge introduced nothing. git diff --numstat 8fc6a5f6df 174d72f05c lists exactly the five PR files. git diff --exit-code 4045781faf 8fc6a5f6df over the five PR files plus filter.zod.ts, dashboard.zod.ts, dataset.zod.ts, report.zod.ts, field.zod.ts → exit 0: main touched none of the schemas this entry describes.
  • pnpm --filter @objectstack/spec gen:migration-registryregistry.ts byte-identical after regen; check:migration-registry, check:spec-changes, check:upgrade-guide exit 0; gen:spec-changes + gen:upgrade-guide → no diff; node scripts/check-adr-0087-registration.mjs --base 8fc6a5f6df --head HEAD exit 0 (registered filter-between-field-reference-endpoint-refused (new here)).

2. The ground, re-read over the real schemas. Document { close_date: { $between: [{ $field: 'a' }, '2026-12-31'] } }:

carrier the entry names Zod slot re-save (measured) matches entry
(b) dashboard widget filter (dashboard.zod.ts:872) FilterConditionSchema 0 issues on the filter path ✓ "parses GREEN"
(b) global-filter optionsFrom.filter (:1285) FilterConditionSchema success: true
(b) dataset filter (dataset.zod.ts:337) and measure filter (:191) FilterConditionSchema success: true both
(b) report runtimeFilter (report.zod.ts:239, :316) FilterConditionSchema 0 issues on runtimeFilter
(b) rollup summaryOperations.filter (field.zod.ts:1630) FilterConditionSchema success: true
(b) relatedListFilter (field.zod.ts:1525) FilterConditionSchema success: true
(a) ViewFilterRuleSchema rule { operator: 'between', value: [ref, 'M'] } value is string|number|boolean|null|(string|number)[] + checkViewFilterRuleValueShape REFUSE value: Invalid input ✓ "at the rule's own value path"
(a) ViewSchema list view z.array(ViewFilterRuleSchema) (view.zod.ts:2340; page :226/:578, component ×12 same) REFUSE list.filter.0.value ✓ "list.filter.N.value"
(a) FieldOperatorsSchema enforced copy REFUSE $between.0 reference message; both refs → $between.0 and $between.1 ✓ "both positions at the schema door"
(a) NormalizedFilterSchema { $and: [ {…} ] } strict AST REFUSE at $and.0 (member message: "operator map must satisfy FieldOperatorsSchema — comparand shapes included") refuses ✓; path is the member's, see F2
runtime parseFilterAST, both refs throws on the first: where.close_date.$between[0], the MIN bound, INVALID_FILTER / 400 ✓ "the runtime door throws on the first"

Every FilterConditionSchema consumer in spec was enumerated (grep -rn FilterConditionSchema packages/spec/src): the two the entry does not name are ai/solution-blueprint.zod.ts:71 (an AI blueprint that compiles INTO a widget runtimeFilter, i.e. a named carrier) and the query/request faces (data-engine.zod.ts, query.zod.ts, analytics.zod.ts where/having), which are the "saved AST filters, SDK and MCP callers" of the grep list and are judged at request time. No stored carrier is missing from the grep instruction, which is exhaustive over authored $between arrays regardless.

3. "Executing the surface" — traced per (b) carrier, because it is only a detector if execution reaches a loud door.

  • relatedListFilter: objectui deriveRelatedLists.ts:301 puts it on the related list's filter; the composed find reaches the engine seam engine.ts:866 assertListComparandShapes ("the one place EVERY caller-supplied where passes through") → the spec sentence, index + side. ✓
  • rollup filter: summary-aggregate.ts:8-10 composes { $and: [fkMatch, desc.filter] } into engine.aggregate(child, …) → same seam. The throw is caught in recomputeSummaries (engine.ts:9375 warn) and re-thrown to the writing caller as SummaryRecomputeError (:11505, :13077, :14567) — loud to the caller, not only to the log. ✓
  • global-filter optionsFrom.filter: options are fetched by a find on object → same seam. ✓
  • dataset-bound dashboard widget filter, dataset filter, report runtimeFilter: executed through service-analytics (dataset-executor.ts:1061 combineFilters(compiled.filter, selection.runtimeFilter)). Measured from source: lowerAnalyticsWhere({ where: { amount: { $between: [ref, 100] } } }) returns the same object reference (no parseFilterAST), and the leaf pass normalizeAnalyticsFilterTree throws [analytics] "$between" on "amount" has the field reference { "$field": "budget" } at index 0 of its [min, max] bounds …INVALID_FILTER / 400, from assertNoFieldReferenceComparand (filter-normalizer.ts:656), which pre-dates this PR; MAX bound → index 1; control [1, 100] lowers to gte/lte leaves. Loud, same envelope, same remedy — but see F1.

4. The new pins are honest. Schema door on the convergent rows (FieldOperatorsSchema.safeParse): { $field: 42 }, Object.create({ $field: 'a' }), a class instance carrying $field$between.0 reference message; [ref, { nope: 1 }]$between.0 reference + $between.1 Invalid input; reversed → $between.1 reference; [{ nope: 1 }, 'M']$between.0 Invalid input. The runtime door now answers each with the reference sentence at the reference's own index, and the no-reference pair with the TYPE door's "plain object" — the pin asserts convergence with the published authoring face, not a defect green. The it.todo names a measured fact: $in / $nin refuse at $in.0 / $nin.0 on the schema door and lower unchanged (same reference) at parseFilterAST; a todo pins nothing. No lint rule refuses it (eslint.config.mjs has no disabled-test rule; precedent plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts:397). Ablation: with the base emitter checked out over filter-comparand-shape.ts (git checkout 8fc6a5f6df -- …), the head test file → 9 failed, including "re-routes the pairs the comparand-TYPE door used to answer — convergence, pinned"; restored, blob back to d958b5e72a, tree clean. At head: 66 passed | 1 todo, exit 0. CI at this head: Lint & Repo Gates, Type Check ×4, Test Core 1–6, Check Changeset all success.

5. "Supersedes" — verified. git log -1 08cd163d02… (#7596) (#7713); 08cd163d02:.changeset/filter-list-field-reference-removed.md:69<!-- adr-0087: not-required (no-migration-prescription) …; shipped at packages/spec/CHANGELOG.md:19061 with the marker at :19125 inside that entry (next entry :19146). The reason now says the entry "SUPERSEDES a recorded answer rather than filling a silence: the 2026-08-11 changeset (PR #7713) carried the disposition not-required (no-migration-prescription), reviewed and accepted on #7596 and shipped in the published CHANGELOG … the earlier not-required reading is retired by this record." Every clause matches the record; the earlier "no ledger entry was written for it" sentence is still true and now correctly framed as not-an-omission. The changeset (byte-unchanged) carries registered … and no re-save sentence — nothing there needed to move.

6. The analytics correction — both forms verified (measurements in §3): FilterArray form ['amount','between',[ref,100]]lowerAnalyticsWhere throws the spec sentence (where.amount.$between[0], the MIN bound) via parseFilterAST at filter-normalizer.ts:1521; object form passes lowerAnalyticsWhere unchanged and meets the analytics door's own sentence. The dev's corrected statement is right; round 1's Finding B stands.

7. The sibling entry — claim verified, leaving it is right. 18.filter-between-blank-endpoint-refused.ts at head: surface names "a view or dashboard widget filter, a dataset filter, a report runtimeFilter, … a rollup filter" as carriers of FieldOperatorsSchema / RangeOperatorSchema, and acceptanceCriteria says "FieldOperatorsSchema.safeParse and re-saving the document both make the sweep mechanical" — the same falsehood on the same four FilterConditionSchema slots. It is #18012 / PR #19374's text; a patch round that reaches into another card's entry is exactly the widening a delta review exists to refuse. Correct to leave; the seat's to file.

Findings — not grounds, each with its measurement.

  • F1 — door mis-attribution on the analytics path. surface (b) says the endpoint "is refused only when the filter is EXECUTED, at the runtime lowering door this change closes", and acceptanceCriteria says "EXECUTING the surface, where the runtime lowering door now refuses with INVALID_FILTER / 400 naming the index and the side." For a dataset-bound dashboard widget, a dataset and a report, execution goes through service-analytics, where the object form never meets parseFilterAST: the refusing door is the analytics compiler's own assertNoFieldReferenceComparand, it names the index only, and it refused identically before this PR (§3). True for rollup, relatedListFilter and the global-filter option source. My reading here contradicts round 1's prescription (b) — "executed through parseFilterAST / the engine's shape door (i.e. the door this PR closes)" — while agreeing with round 1's own Finding B measurement; the prescription glossed its own finding and the dev's text inherited the gloss. Not a ground: on every path the upgrader gets INVALID_FILTER / 400 naming the index and prescribing the same two remedies, so nothing they find or do changes. One clause would make it exact: "…at the runtime lowering door this change closes, or — for a dataset-bound dashboard widget, a dataset or a report — at the analytics compiler's own pre-existing [analytics] door, which names the index only."
  • F2 — NormalizedFilterSchema reports at the member, not the endpoint. { $and: [{ close_date: { $between: [ref, 'M'] } }] } → one issue at $and.0 whose message refers the reader to FieldOperatorsSchema's comparand shapes; $between.0 / $between.1 is what FieldOperatorsSchema itself reports. Re-save does refuse, so the claim holds; the path attribution is the enforced copy's.
  • F3 — observed, out of scope, pre-existing: the analytics leaf pass's isFieldReference (comparand-shape.ts:228) requires a string referent, so { $between: [{ $field: 42 }, 100] } in a dashboard / dataset / report filter is split into $gte: { $field: 42 } / $lte: 100 leaves and handed on (measured: NO THROW) — the exact laundering the door's docblock exists to prevent, one referent type over. Not touched here, not made worse.

② Semver level

@objectstack/spec: minor, body declared BREAKING for callers, Clause-②: yes — the repo's rule for an accept-set narrowing on a published face (AGENTS.md § Post-Task Checklist step 3: "yes takes at least minor, (narrowing) is BREAKING"); precedent on disk .changeset/15110-…, 15124-…, 15646-… all minor with BREAKING bodies. Unchanged by this delta (changeset blob identical). No export added, removed or renamed (api-surface/ untouched in the PR's numstat vs main); the ledger entry rides the in-flight protocol 18 step, PROTOCOL_VERSION unchanged.

③ Boundary flags

  • File surface: the round-2 delta stays inside the surface the PR declared — packages/spec/src/data/ (test) and packages/spec/src/migrations/entries/semantic/ + the generated registry.ts (entry). No new path; the refusal and the changeset are byte-unchanged; the merge of main is clean (§1).
  • Cross-repo: none. objectui was read only to trace the relatedListFilter consumer at the pin; nothing there moves.
  • Deliberately left alone, seat's to file, not grounds here: the sibling blank-endpoint entry's identical re-save falsehood (§7); the $in / $nin runtime acceptance (the it.todo); F3.

Implemented-by: claude/issue-19377-between-field-endpoint-runtime-door
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 21, 2026 06:21
@os-steve
os-steve enabled auto-merge September 21, 2026 06:21
@os-steve
os-steve added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit a60c913 Sep 21, 2026
44 checks passed
@os-steve
os-steve deleted the claude/issue-19377-between-field-endpoint-runtime-door branch September 21, 2026 06:42
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants