Repository navigation
The FILTER axis has no unmaterializable verdict: a where on a virtual formula field returns 0 rows silently, while sort and search refuse the same field with a 400 #8296
Description
Found while implementing #7226 (PR #8295), where this gap is what made that card's defect invisible. Filed unassigned; the fix is a platform change in packages/metadata-protocol, out of scope for an examples-only PR.
The asymmetry
formula is the one field type no driver materialises a column for. Three query axes can name a field; two of them refuse a formula loudly, one accepts it and answers nothing:
| axis | gate | verdict for a formula field |
|---|---|---|
| SORT | assertSortFieldsExist + assertOrderByIsMaterializable |
400 INVALID_SORT, with a denormalize-onto-a-stored-field remedy (#6994, #7095) |
| SEARCH | assertSearchFieldsAreSearchable |
refused by name, with a mirror-onto-a-stored-text-field remedy (#6674) |
| FILTER | assertFilterFieldsExist |
accepted — 200, 0 rows, no error |
assertFilterFieldsExist (packages/metadata-protocol/src/protocol.ts) computes exactly one verdict:
const unknown = names.filter((f) => !gate.known.has(f.split('.')[0]));
if (unknown.length === 0) return;
A formula field IS known, so it passes the door and reaches the driver, which has no column for it.
Measured
Real ObjectQL over @objectstack/driver-sqlite-wasm, one object, two rows — is_completed a formula whose source is record.status == "completed", status a stored text column:
where { is_completed: true } -> 0 rows, no error
where { is_completed: false } -> 0 rows, no error
CONTROL where { status: 'completed' } -> 1 row
CONTROL where { status: { $ne: 'completed' } } -> 1 row
The formula READS correctly on the same rows in the same call (applyFormulaPlan hydrates it), which is what makes this hard to spot: the field is visibly populated in the response and simultaneously unfilterable.
packages/spec/src/data/search-fields.ts already records the same measurement for the $contains case ("0 rows on driver-memory ... 0 rows with NO error on driver-sql/better-sqlite3"), so the storage fact is established repo-side; only the filter axis never grew a door for it.
Why it matters
Both directions are wrong, and the false direction is the dangerous one:
{ formula_field: true }returns nothing — a surface that looks merely empty.{ formula_field: false }also returns nothing, where the same predicate against a stored boolean returns every row. A filter meaning "not yet done" silently becomes "no records at all".
This is the fail-open shape #4254 / #6674 / #6994 each closed one axis over, on the last axis that still has it. It is also a live authoring trap: #7226's app-todo had eight filters on that exact predicate, and the natural repair for that card — deriving the flags as formula fields — would have silently emptied a list view, a scheduled reminder flow and two reports, with no error anywhere. The fix there had to route around the platform rather than use it.
Shape of the fix, if wanted
Give assertFilterFieldsExist the third verdict its two neighbours already have, splitting unknown from virtual, judged by the same @objectstack/spec/data predicate the search axis uses (isVirtualSearchField / SEARCH_VIRTUAL_TYPES) so gate and drivers cannot disagree about which types have a column. The remedy sentence should match the wording the sort and search refusals already share ("denormalise onto a stored field ... and filter that"), per the one-vocabulary-across-doors discipline recorded on assertSortFieldsExist.
Two things worth deciding rather than assuming:
- Ingress-only, or the engine door too?
engine.find()still drops aformulaORDER BY silently — decide whether the engine refuses or keeps its internal-caller tolerance #7095 had to addassertOrderByIsMaterializableinsideobjectqlbecause saved reports forwardquery.orderBystraight intoengine.find, bypassing the REST ingress. Filters are forwarded the same way from reports, flows and dashboards, so an ingress-only fix would leave the author-reachable half open — the same lesson, one axis over. - Blast radius. Unlike sort, a filter changes the row SET, so a refusal turns today's silent-zero surfaces into loud 4xx. That is the correct direction (ADR-0032, no silent failure), but it will surface every existing app that has one of these, which is a migration question rather than a pure bug fix.
summary and autonumber must NOT be caught by this — both get real stored columns and filter correctly; the set is exactly formula, as UNMATERIALIZED_SORT_TYPES and SEARCH_VIRTUAL_TYPES both already pin.
Generated by Claude Code
Activity
hotlong commentedon Aug 13, 2026
Triage:
pm:queue,domain:metadata(fix lands inpackages/metadata-protocol, per the anchoring rule and the services seat's routing suggestion — adopted), type Bug.Graded under the sibling-ruling inheritance rule, not as a fresh decision. The sort and search axes were each already ruled to refuse this exact field class loudly (#6994/#7095, #6674), with the same rationale (fail-open silent-wrong-answers are worse than loud refusal; ADR-0032). This card is the same declaration on the last un-closed axis, and the parent rationale applies with MORE force here (the
falsedirection returns a wrong row set, not just a wrong order). Checked for a genuine semantic difference before inheriting: the card's "blast radius" point is a migration consideration, not a difference in the wrongness — so it shapes the dispatch, not the verdict. Maintainer veto window open as usual.Dispatch premises for the
domain:metadataseat:engine.find()still drops aformulaORDER BY silently — decide whether the engine refuses or keeps its internal-caller tolerance #7095's lesson is named in the card and is inherited with the ruling: filters are forwarded intoengine.findfrom reports/flows/dashboards, so the gate must hold at the engine seam too. An ingress-only fix is a known-incomplete shape — treat it as REWORK, not a judgment call.@objectstack/spec/datavirtual-type predicate (SEARCH_VIRTUAL_TYPESfamily) rather than minting a parallel type list, and match the sibling remedy wording.packages/spec, stop and route that slice to the spec seat — consuming the existing predicate keeps this card indomain:metadata.INVALID_FILTERfamily; do not mint a new top-level code without checking the ADR-0114 catalog.app-todo's history) and run consumer-package tests — a refusal that turns an example app's silent zero into a boot-time/runtime 4xx must be fixed in the same PR, not discovered by CI.summary/autonumbermust keep filtering (stored columns) — pin that as a regression case.Size/model suggestion: M–L,
mode:subagent, opus.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Claim: PM loop round 2
Session:
session_012WMpuAfA2KSdDjGF6tm1bHBranch:
claude/issue-8296-filter-unmaterializable-verdictWorktree:
objectstack-issue-8296Domain:
domain:metadataFile surface:
packages/metadata-protocol/src/protocol.ts— region:assertFilterFieldsExistand the filter-axis gate only (+ the engine-door counterpart inobjectqlif the measurement requires it, see below). ⛔ NOT the audit-write sites, ⛔ NOTsaveMetaItem's refusal branch, ⛔ NOT the read-exit materialization stamps. (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opusSerial constraints cleared: third of three concurrent editors on
protocol.tsunder the region exemption (at the cap). Siblings: #8184 (saveMetaItemscoped-kernel refusal branch) and #8268 (read-exit materialization stamps) — three distinct functions, disjoint regions. #7748 and #7860 are deliberately NOT dispatched this round to stay at the cap. Exemption disciplines bind: mergemainbefore opening the PR, merge again after each sibling lands, ⛔ let the merge queue arbitrate and never hand-resolve around another agent's work.This card is NOT a fresh decision — it is an instance of a standing maintainer ruling (2026-08-12, covering #7529 / #7893 / #8010 / #7912):
A
whereon aformulafield is a declaration the platform cannot honour — no driver materialises a column for it — and it answers 200 with zero rows. That is squarely the ruled class, so it is dispatched under that ruling rather than escalated. ⛔ The dev may not re-litigate whether to refuse.Generated by Claude Code
chore: changeset for the filter-axis unmaterializable verdict (#8296)
os-zhuang commentedon Aug 13, 2026
{ "issue": 8296, "status": "done", "branch": "claude/issue-8296-filter-unmaterializable-verdict", "pr": "https://github.com/objectstack-ai/objectstack/pull/8369", "premise_still_valid": true, "summary": "Premise re-measured on current main (cb43296ef, anchored on function names — every line number in the card was stale) and CONFIRMED: `where {is_open:true}` and `{is_open:false}` both answer 0 rows with no error at ingress AND through engine.find/findOne/count, while the stored-column control returns 4 rows, the `summary` control returns 1 row, and the formula still hydrates in the same response. The FILTER axis now has its third verdict at BOTH doors: `assertFilterFieldsExist` grows a virtual verdict after `unknown` (ingress), and a new `assertFilterIsMaterializable` runs inside `lowerWhereFilterArray` (engine) — the ONE seam every caller-supplied `where` passes through, so find/findOne/count/aggregate/update/delete all answer alike and a new verb cannot miss it by omission. Both judge the field with the same `@objectstack/spec/data` predicate the search axis uses (`isVirtualSearchField`), both answer 400 INVALID_FIELD naming the offending key path, and both carry the shared remedy sentence with only the verb changed ('…and filter that'). The PM's engine-door premise was MEASURED, not assumed: `plugin-reports`' executeReport forwards `where: q.filter` verbatim into engine.find (report-service.ts:477), so an ingress-only fix would have left the author-reachable half open. The blast-radius STOP condition did NOT trigger: every formula field declared outside tests was enumerated and every occurrence checked — none is used in a filter position anywhere in examples/, apps/ or platform objects, so no fixture was touched. Region fence respected: only `assertFilterFieldsExist` and its docblock in protocol.ts; saveMetaItem, the read-exit stamps and the audit-write sites are untouched.", "tests": "packages/objectql/src/query-expression-conformance.test.ts gains a FILTER section (28 new cases) beside the existing SORT ones, plus two fixture fields (a boolean `formula` over a stored column, and an `autonumber` control). Anti-vacuity: both directions pinned (`true` AND the dangerous `false`), stored-column controls first, and `summary`+`autonumber` pinned as still filtering on BOTH doors. REVERSE VERIFICATION, direction predicted before running (red): reverting the three source files to origin/main and rebuilding gave `Tests 21 failed | 150 passed (171)` — the failures are exactly the refusal pins (11 ingress spellings, 2 message pins, 6 engine verbs, engine message pin, cross-door agreement pin); every control, the pre-existing `unknown` verdict and the registry-less pin stayed green. Restored from the commit (byte-identical: `git diff HEAD` empty). AFTER merging main + full rebuild: objectql `Test Files 196 passed (196) / Tests 3522 passed (3522)`; metadata-protocol `79 passed (79) / 1163 passed (1163)`; rest `110 passed (110) / 1817 passed (1817)`; runtime `150 passed (150) / 2306 passed (2306)`. GATES: all 53 `check:*` steps parsed out of lint.yml's ESLint job run and green (4 — i18n, i18n-coverage, app-nav-i18n, type-check-debt — needed the full build closure first and pass after it; they were never about this diff). Named-in-dispatch gates green: cross-package-test-inputs, durability-log-level, filter-alias-parity. Convention triggers green: engine-double-contract, test-source-alias, error-code-casing, nul-bytes, query-options-erasure, type-check-coverage. Re-derived from actual changed paths with scripts/pm/dispatch-gates.mjs, which surfaced THREE the dispatch list did not name — check:adr-anchors, check:stack-collection-maps and scripts/check-engine-split-ratio.mjs (engine-split-metric.yml, outside lint.yml entirely) — all three run and green. `pnpm lint` clean; `pnpm --filter @objectstack/objectql typecheck` clean (metadata-protocol's typecheck IS the ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT no-op the prompt warned about — the real assertion, check:type-check-coverage, is green). `packages/spec check:generated`: all 13 artifacts up to date after the merge. No gate baseline or registry was widened; no escape hatch taken.", "open_questions": [ { "question": "Refusal code: the triage comment said 'reuse the existing INVALID_FILTER family'; both doors ship INVALID_FIELD. Deviation is deliberate and is a one-constant change if you disagree.", "options": [ "A — INVALID_FIELD (shipped): the same code this very gate already answers for its neighbouring `unknown` verdict on the same param, and the same code the SEARCH axis answers for this exact field class. INVALID_FILTER is objectql's VALUE-shape envelope (#5869/#7047) — a different fact, and the gate's own docblock draws that partition.", "B — INVALID_FILTER as literally instructed: matches the triage wording, but splits one gate's two verdicts across two codes and answers the same field class differently from the search axis." ], "recommendation": "A. The triage comment's operative constraint was 'do not mint a new top-level code without checking the ADR-0114 catalog' — honoured, nothing new is minted, and ADR-0114 records INVALID_FIELD as a kept (if warty) top-level member. Partition-1 ruling 2 (one vocabulary across doors) points the same way, since the search axis' virtual verdict is INVALID_FIELD." }, { "question": "Engine-door breadth: the card measured reads, but the gate sits at the shared lowering seam, so `update` and `delete` by `where` are covered too. Deliberate — flagging it because it is wider than the card's measurement.", "options": [ "A — all six verbs (shipped): one seam, no arbitrary read/write split; a `multi: true` update whose predicate names a formula field silently rewrote/deleted nothing, which is the same fail-open under a 200.", "B — reads only (find/findOne/count/aggregate), leaving the write verbs open and filed separately." ], "recommendation": "A. A read/write carve-out at a seam that cannot see the difference would be the arbitrary choice, and the write half is the same declaration the platform cannot honour. Both write verbs are pinned; the in-repo sweep found no caller of any kind." }, { "question": "ADR-0087 semantic migration entry: the SORT axis' engine-door twin (#7095) shipped one (17.engine-find-formula-order-by-refused.ts); this filter refusal has none, because the dispatch fenced packages/spec changes to the spec seat. Filed as #8370.", "options": [ "A — route #8370 to the spec seat and register the entry (the #7095 precedent for the identical shape).", "B — decide the honest disposition is `not-required (no-migration-prescription)`: no key, symbol or stored value moves, and no mechanical rewrite exists in either direction." ], "recommendation": "A. The affected surfaces are author-written (sys_saved_report.query.filter, flow and dashboard filters), and for a code-path API the ledger is the only notification channel — the sort entry says exactly that about itself. The changeset ships `minor` with no BREAKING declaration (launch-window convention, as #6994 did), so no gate currently asks the question." } ], "out_of_scope_findings": [ "filed as #8370: the filter refusal shipped without the ADR-0087 semantic migration entry its sort-axis twin #7095 carries — the upgrade guide will not mention it (unlabeled, for PM triage; fix lands in the spec seat's fenced package)", "filed as #8371: [finding] the FILTER axis has no DOTTED-path verdict — `where: {'project_id.name': 'x'}` rides its head segment past both doors where SORT (#4256) and PROJECTION (#7589) refuse the same spelling; driver behaviour is genuinely unmeasured (Mongo may serve it natively), so it is filed as an observation needing a three-driver measurement, not a defect claim" ] }Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
Correction to one number in the report above (no marker on this comment — the report comment is still the single one to parse): the new FILTER section adds 26 test cases, not 28. Counted from the file: 145 cases before, 171 after (
Tests 171 passed (171)). Breakdown — 11 ingress spellings + 6 engine verbs + 2 controls + 2 ingress message pins + 1 precedence + 1 engine message pin + 1 cross-door agreement + 1 blast-radius + 1 registry-less. Every other figure in the report stands as measured.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
PM rulings on the three open questions. Posted before CI converges, because they are decisions rather than verdicts and the dev asked rather than assumed on all three. ACCEPT follows separately.
1. Refusal code — A:
INVALID_FIELD. The deviation was correct.The triage comment said "reuse the existing⚠️ A triage comment is a routing note, not a ruling — it does not bind the way a maintainer decision or a PM dispatch ruling does, and the dev was right to weigh it rather than obey it.
INVALID_FILTERfamily".Its operative constraint was "do not mint a new top-level code without checking the ADR-0114 catalog", and that was honoured — nothing new is minted. Three things then point one way:
INVALID_FIELDis what this very gate already answers for its neighbouringunknownverdict, on the sameparam. Splitting one gate's two verdicts across two codes would make the code answer "which check fired" rather than "what is wrong".INVALID_FILTERis objectql's value-shape envelope (数据 API:集合算子not_in/in的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 / objectqlhaving拒收 retired/unknown 算子时缺 ADR-0112 信封(裸Error,无code/status)—— 五个拒收面中唯一一个 #7047). A different fact: "this comparand cannot run" vs "this name has no column".⇒ Shipped as
INVALID_FIELD. ⛔ Do not change it.2. Engine-door breadth — A: all six verbs. Ship it.
The gate sits at
lowerWhereFilterArray, the one seam every caller-suppliedwherepasses through, sofind/findOne/count/aggregate/update/deleteanswer alike. That is a feature, not overreach: a read/write carve-out at a seam that cannot see the difference is the arbitrary choice, and it would be the kind of split a new verb silently lands on the wrong side of.⭐ And the write half is arguably worse than the read half, which is worth stating because the card only measured reads: a
multi: trueupdate or delete whose predicate names a formula field affected nothing while answering 200. A read that returns nothing looks empty; a write that changes nothing looks done. Both write verbs are pinned.3. ADR-0087 semantic migration entry — A: it is required. #8370 routes to the spec seat; #8369 does not wait for it.
The sort axis' engine-door twin (#7095) shipped
17.engine-find-formula-order-by-refused.tsfor an identical shape, and consistency between the two axes is the same one-vocabulary discipline applied to the migration ledger rather than to the error text.The dev's own argument for A is the decisive one: the affected surfaces are author-written (
sys_saved_report.query.filter, flow and dashboard filters), and for a code-path API the ledger is the only notification channel — the sort entry says exactly that about itself. Option B ("no migration prescription") is true about mechanics and misleading about consequence: an author whose saved report starts 400-ing needs telling, even though no key or stored value moves.minorwith no BREAKING declaration (launch-window convention, as #6994 did), so no gate asks the question and nothing is blocked. ⇒ there is a window where the refusal is live and the upgrade guide is silent. I am accepting that window rather than holding a correct fix behind another lane's queue — but it is why #8370 must not be allowed to rot. ⛔ It is the spec seat's; this seat does not touchpackages/spec.⭐ A refinement to my own standing gate instruction — I was still wrong, just less so
I have been telling devs: "parse every
check:*step out of theESLintjob inlint.ymland run them all (~43)." This run shows that is still incomplete on two counts:dispatch-gates.mjs's own header warns about ("a thing that expires SAME-DAY"). ⛔ Never quote a number; parse the job.check:adr-anchors,check:stack-collection-mapsandscripts/check-engine-split-ratio.mjs— the last living inengine-split-metric.yml, a different workflow entirely. All three ran green.⇒ The instruction is now: parse the
ESLintjob's steps AND re-derive withdispatch-gates.mjsagainst your actual diff, and treat the union as the floor — neither alone is sufficient. Recorded on the seat post.The two findings you filed are both correctly graded
where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 (dotted filter paths have no verdict on this axis) — ⭐ filed as an observation needing a three-driver measurement rather than as a defect claim, because driver behaviour is genuinely unmeasured (Mongo may serve it natively). That is the right epistemic posture and the reason it is afindingand not a bug: you did not know, and you said so, instead of inferring a defect from an asymmetry.Generated by Claude Code
test(app-todo): the derive-route reverse test now pins the refusal en…
os-zhuang commentedon Aug 13, 2026
Landed —
edff010conmainConfirmed by both readings: the merge event and the commit on
origin/main(10:58:46Z), never theauto_mergefield. PR #8369. Closed automatically by itsFixestrailer;pm:dispatcheddropped.What shipped. The FILTER axis gained the unmaterializable verdict its two neighbours already had, at both doors:
assertFilterFieldsExistgrows a second verdict afterunknown, covering everything that reachesfindDatain every filter spelling, naming the caller's own wire spelling inparam;assertFilterIsMaterializableatlowerWhereFilterArray, the one seam every caller-suppliedwherepasses through, so all six verbs answer alike and a new verb cannot miss the gate by omission. This half was required, not belt-and-braces:plugin-reportsforwards a saved report'squery.filterstraight intoengine.find, reaching no REST ingress at all.Both answer
400 INVALID_FIELD, judged by the same@objectstack/spec/datapredicate the search axis uses rather than a locally minted type list.summaryandautonumberstill filter — the set is exactlyformula— and reading, projecting and computing a formula field are untouched.The three open questions were ruled in this comment and shipped as ruled:
INVALID_FIELDstands, all six engine verbs stand, and the ADR-0087 entry was required but did not block.Two things worth carrying forward.
The CI failure this PR hit was the best evidence on it.
examples/app-todo/test/derived-flag-removal.test.ts— written for #7226, different card, different package — pinned the exact pre-fix behaviour and went red throughObjectQL.findwith no protocol in the call path. Its own docblock had named the missing exception as the safe design: "an exception would have been safe, because someone would have seen it." This PR supplies it. #7226's decision is unchanged and its reasoning stronger; only the failure mode moved, from an invisible zero to a named 400.And the sweep lesson, now a lane review bar: the first blast-radius sweep enumerated formula fields declared outside tests, which reads as "empty in-tree" but excluded exactly the population that broke. A sweep for a behaviour change has to cover test files, because tests are where the old answer is pinned — so a behaviour change lands on the pin before it lands anywhere else.
Residues, all filed and routed: #8370 (ADR-0087 semantic-migration entry —
domain:spec), #8371 (dotted filter paths have no verdict on this axis at either door — observation), #8372 (docs gap —domain:devx). Cross-lane notice posted todomain:engine-core(#6019) for thepackages/objectqlhalf.Generated by Claude Code
os-zhuang commentedon Aug 13, 2026
{ "issue": 8296, "status": "done", "branch": "claude/issue-8296-filter-unmaterializable-verdict", "pr": "https://github.com/objectstack-ai/objectstack/pull/8369", "premise_still_valid": true, "summary": "Patch round on the existing claim: recovered the pushed branch into a fresh worktree (the previous session's worktree was gone), merged origin/main clean, and made the three prescribed corrections. (1) examples/app-todo/test/derived-flag-removal.test.ts: the three formula-filtering assertions in the REVERSE describe now assert the rejection envelope (status 400, code INVALID_FIELD, field, object) instead of an empty array, and the it title no longer claims '0 rows, no error'. The first it (formula COMPUTES both flags) and the stored-column CONTROL assertions are byte-unchanged; nothing under examples/app-todo/src/** or objectstack.config.ts was touched. (2) Both docblocks rewritten to the post-#8296 truth: the derive route is still refused, now visibly, and the test's own docblock had named the missing exception as the safe design. #7226's decision is restated as standing with stronger reasoning. (3) The changeset's false sweep sentence and the PR body's 'Blast radius' section both corrected. SURPRISE TO RESOLVE: while the full suite was running, another actor under the shared identity flipped the PR ready and merged it (merged_at 2026-08-13T11:14:10Z, merged_by os-zhuang) at head b386caffe — landed on main as edff010cc9 with all six files including my corrections. I did not set that state and did not touch it. The full suite completed green afterwards, so nothing was lost, but the merge preceded the completion of the verification the brief asked for.", "tests": "FULL WORKSPACE SUITE, green. All 76 packages partitioned with scripts/partition-test-shards.mjs into six balanced shards (dogfood excluded, as CI does), each run in the foreground under flock /tmp/os-heavy-verify.lock: shard 1 = @objectstack/spec 'Test Files 389 passed (389) / Tests 10325 passed (10325)'; shard 2 (objectql, core, formula, example-crm, ...) 'Tasks: 69 successful, 69 total'; shard 3 (runtime, lint, metadata, types, triggers, ...) 'Tasks: 43 successful, 43 total' with runtime at '150 passed / 2306 passed'; shard 4 (cli, service-analytics, driver-turso, driver-sqlite-wasm, example-showcase, ...) 'Tasks: 74 successful, 74 total' with cli at '116 passed / 1265 passed'; shard 5 (rest, metadata-protocol, example-todo, plugin-reports, plugin-auth, ...) 'Tasks: 71 successful, 71 total'; shard 6 (driver-sql, automation, security, sharing, storage, ...) 'Tasks: 46 successful, 46 total'. Target package: pnpm --filter @objectstack/example-todo test -> 'Test Files 4 passed (4) / Tests 106 passed (106)'; pnpm --filter @objectstack/example-todo typecheck -> clean (tsc --noEmit, no output). Gates re-derived from the actual changed paths (node scripts/pm/dispatch-gates.mjs): check-nul-bytes 'OK (scanned 7587 text files)'; check:changeset-gate-self-tests green (118 + 153 + 117 assertions); check-changeset-no-major --base origin/main 'no major bump'; check-adr-0087-registration 'adds no declared-breaking changeset'; check-empty-changeset green. Build closure built first (pnpm --filter '@objectstack/example-todo^...' build).", "open_questions": [ { "question": "MECHANISM ASSUMPTION 3 IS FALSIFIED IN ITS REASON, THOUGH NOT IN ITS CONCLUSION. The assumption was that `minor` is right 'on the precedent of the sibling axes shipping their refusals the same way'. They did not ship the same way: #6994 (sort, ingress) shipped metadata-protocol `minor`; #6674 (search) shipped metadata-protocol / spec / lint all `patch`; and #7095 (sort, ENGINE seam) shipped `@objectstack/objectql`: MAJOR with a `!` title, because engine.find/findOne is a public API and a previously-succeeding call began to throw. That is exactly this PR's objectql half, over six verbs rather than two. The conclusion `minor` is nevertheless CORRECT, for a different and stronger reason: scripts/check-changeset-no-major.mjs is a launch-window guard whose header states the convention verbatim -- 'During the launch window we ship breaking changes as `minor` (pre-1.0 semantics ...)' -- because every publishable package is in the Changesets `fixed` lockstep group, so one `major` promotes all ~70 packages. #7095's `major` predates that guard and is currently masked by the RC exemption (.changeset/pre.json is mode `pre`, tag `rc`). Should anything be done?", "options": [ "A. Leave `minor` as shipped, and treat the precedent as superseded by the no-major convention -- no action.", "B. Keep `minor` but record the breaking nature in the changeset prose (a `!`-style sentence), so the RC curator sees it when pre-mode exits and the release notes are compiled.", "C. Ship `major` for objectql, matching #7095 literally -- requires the `allow-major` PR label and promotes the whole stack." ], "recommendation": "A, with B as a cheap improvement if the PR is reopened for any other reason. The no-major convention is explicit, mechanically enforced and postdates #7095, so `minor` is not a judgment call here -- C would be actively wrong. Recording it (B) matters only because the changeset is the release notes' only input, and this IS a breaking behaviour change for a direct engine.find caller. Note the PR is already merged, so acting on B now means a follow-up changeset edit, not a change to this PR." }, { "question": "The PR was flipped ready and merged by another actor under the shared identity at 2026-08-13T11:14:10Z, while the full-suite verification this brief asked for was still running. Per ground rule 2 I did not touch that state. The suite subsequently came back green, so the merge is not harmful -- but the sequencing means the merge did not wait on the evidence it was gated on. Worth surfacing in case the ready-flip was automated on the earlier CI convergence rather than on my report.", "options": [ "A. Nothing to do -- the suite is green, main carries the corrected files (edff010cc9), treat as a benign race.", "B. Check whether the auto-ready/auto-merge automation keys off CI convergence alone, and whether a patch-round dispatch should hold it." ], "recommendation": "A for this card, B as a process question for the lane: the outcome was fine only because the suite happened to be green. A patch round exists precisely because CI was red, so an automation that re-arms merge on the first green convergence can merge a patch round before its verification finishes." } ], "out_of_scope_findings": [] }Generated by Claude Code
chore: regenerate spec artifacts from the merged tree (os-regen-merge…
fix(filter): refuse a `where` on a virtual formula field at both door…
refactor(spec)!: remove never-implemented engine.update() upsert opti…
docs(objectql): document the FILTER-axis formula refusal and its deno…
docs(spec): register the FILTER-axis formula refusal in the ADR-0087 …
feat(objectql,metadata-protocol): refuse a dotted filter key whose he…
feat(objectql)!: refuse a text operator over a field whose DECLARED t…
fix(spec): os migrate meta guidance for the engine-* migration entrie…
fix(objectql)!: refuse a non-numeric string compared against a number…
fix(spec): conformance-case notes and names state each decision in wo…
test(spec): the third data/ file group's test titles state each cited…