Repository navigation
spec/lint: a date-range PRESET name is refused only under ordering operators — the same preset on the same date field passes as a bare / $eq / $in comparand #16106
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 6, 2026 分诊 ·
domain:spec/bug/priority:p2/needs-user-decision分诊席位。⛔ 不认领、不派发、不写代码、不合并、不裁决 decision-box 卡。⛔ 本 session 是
claude-opus-5,CONTRACT_REVIEW_TIER硬闸要求 fable。origin/main@932acc3d,2026-09-06T03:31Z。两侧的位置都复现
位置 发布期拒绝(#8793 落地的那一半) packages/spec/src/data/date-range-presets.ts:127—`"${preset}" is a dashboard date-range PRESET name, not a filter value. It is only `lint 规则 id packages/lint/src/validate-preset-comparands.ts:69—export const FILTER_PRESET_COMPARAND = 'filter-preset-comparand';排序算子集合 packages/lint/src/validate-preset-comparands.ts:103—const ORDERING_DOLLAR_OPS: ReadonlySet<string> = new Set(['$gt', '$gte', '$lt', '$lte']);⇒ 卡的核心结构成立:判决只覆盖排序位置,而集合是硬编码的四个。
车道
domain:spec⭐ 这一条我特意说清楚,因为它容易判错:卡的标题写
spec/lint,但两条读法的主落点都在packages/spec——- 读法 1(扩展到所有比较位):要动的是
date-range-presets.ts的发布期拒绝,lint 规则随后跟上; - 读法 2(排序位是刻意的):要动的是把这个范围写进契约的说明,同样在 spec。
packages/lint那半在两条读法里都是随动。⇒domain:spec。⛔ 不是domain:devx。为什么是
needs-user-decision卡自陈「I do not have standing to choose between them」,我同意,并且加一条它没说的、使这确实成为裁决的理由:
⭐ 两条读法对 #8793 的裁决范围给出不同的解读,而那是一个已经做过的裁决。 读法 1 说「bare temporal comparand」本来就包含
$eq/$in/裸值,排序位只是先做的一部分;读法 2 说排序位是唯一被 console 下推的位置,其余是有意排除。⇒ 这是在追问 #8690 / #8793 当初裁的到底是什么,⛔ 不是新提一个功能。分诊无权重述别人的裁决范围。定级 p2
卡把后果说准了,我复核认同并加重那句最关键的:
an unlowered preset string reaches the driver as a literal, compares false against every row, and the surface answers 200 with zero rows and no diagnostic。
close_date == 'last_30_days'… arguably the more likely authoring slip, since equality is what an author writes when they mean "in this window"。⇒ 三条叠加:① 静默(200 + 零行,无诊断);② 落在更常见的书写习惯上;③ #8690 已经认定这个后果值得修,只是修了一半。
不给 p1:没有数据损坏、没有越权;作者会看到「没有数据」而不是错误数据。
⚠️ 但 ⛔ 别把「零行」读得太轻——一个季度报表静默显示 0,比报错更容易被当成真实业务结论。⭐ 一条卡已经点到、但值得裁决者单独看的连带事实
filter-preset-comparand's own reachability is a consequence either way: on adefineStack-authored app the schema refuses the ordering positions first, so the lint rule id never appears in output at all。⇒ 这意味着
filter-preset-comparand今天在defineStack应用上是不可达的——与本轮 #16109(security-owd-alias被 spec 枚举先拒)完全同型。⚠️ 所以无论裁哪条读法,都请顺带答一句:filter-preset-comparand这一行还能不能被当作活覆盖计入退役表? ⛔ 今天不能。这条我按 #16109 的同一标准处理:一行假覆盖会让下游退掉自己真正在起作用的断言。去重
卡把 #8690 与 #8793 都读全了,判定 #8793(已关/completed)只覆盖排序半边。
⚠️ 本席位未做穷举枚举,采信其判断。
Generated by Claude Code
- 读法 1(扩展到所有比较位):要动的是
Ruling recorded — 1′: the preset-name refusal extends to every comparand position, FIELD-TYPED, at the layer that holds the field type (summon #16, director seat, 2026-09-06T04:52:23Z)
Provenance (who / verbatim / where): maintainer, 2026-09-06, live director chat, answering batch #52 item 5 (1′ field-typed extension to bare /
$eq/$inpositions on declared date and datetime fields at the lint / stack-validation layer, the field-agnostic schema door unchanged · 1 field-agnostic extension of the schema door · 2 rule it a deliberate boundary and note it on #8793; recommendation 1′, fallback 2). Verbatim: 「#12036 已转交他人,其他同意」 — 「其他同意」 adopts the recommendation on this card.Governing text:
packages/spec/src/data/date-range-presets.ts:101-113(why a preset name is refused as a bare comparand at all — #8690 C half, maintainer-ruled 2026-08-15: no layer interprets it; a declared temporal field refuses it at the engine, any other column compares the literal);packages/lint/src/validate-preset-comparands.ts:44-58(the ordering-only boundary is deliberate for a FIELD-AGNOSTIC rule: a picklist column legitimately stores values that collide with preset names, so equality cannot be judged without the field type). Protocol: the spec's own docblock states the intent — a preset name is never a filter value on a temporal field; no protocol change, the ruling completes the enforcement of what is declared.Freshness: card re-read in this stroke — 1 comment (triage 5556579482), none since.
Ruled — 1′.
- At a layer that holds the object metadata —
objectstack lint(validate-preset-comparands) and, if the stack-level publish validation resolves field types,defineStacktoo — a declareddate/datetimefield refuses one of the 13 declared preset names in EVERY comparand position: bare (implicit equality),$eq/$ne,$in/$ninand their view-rule and triple spellings, alongside the ordering positions already judged. The refusal reusesbareDateRangePresetComparandMessage(preset, operator)— one condition, one wording, the{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 convention. - The field-agnostic schema door in
data/filter.zod.tskeeps its ordering-only boundary, and its header's reason stays as written: without a field type, equality on a select column is legitimate. ⛔ Option 1 is refused. - First premise for the dev, measured before any edit: run the three residue positions (bare,
$eq,$in) as real queries against a declared date field on a real driver.packages/objectql/src/temporal-comparand-door.ts:202judges an implicit-equality comparand on the engine path, so bare /$eqmay already be refused at query time withINVALID_FILTER/ 400;$inis unmeasured. Whatever the reading, it is written onto this card, and it corrects the card's "answers 200 with zero rows" sentence if that sentence was inherited from the pre-door An unparseable date comparand on a datetime filter is passed through and compares false — HTTP 200, zero rows, no diagnostic — while an unknown{placeholder}is correctly rejected 400 (17.0.0 GA) #8690 measurement. The ruling stands either way: the authoring-time refusal is what reaches an AI author's correction loop. - Consequence for the retirement survey (hotcrm epic chore: ignore local .codex/ agent tooling directory #1579 step 3, its docs: sync recent architecture updates #1621): with a reachable field-typed equality arm,
filter-preset-comparandfires on adefineStackapp for the first time and becomes countable as live coverage; until this lands it is not (triage's reading, adopted).
Clause-②: yes — the publish / lint accept set narrows on a declared field ⇒
CONTRACT_REVIEW_TIERconstruction and review;needs:contract-reviewhung by the PR creator on both carriers. Changesets:@objectstack/lintminor(new refusal arm);@objectstack/specpatchonly if its describe text moves. Lanedomain:spec,priority:p2kept.State transition, one stroke:
needs-user-decision→pm:queue;bug · priority:p2 · domain:specretained.
Generated by Claude Code
- At a layer that holds the object metadata —
Claim: PM loop round 4 —
domain:specdispatch seat, seat post #6017, sessionsession_01T6HeZvT9wdSJD1ZxJb5Eno, 2026-09-06T13:3xZ.Branch:
claude/issue-16106-preset-comparand-field-typedClause-②: yes
Dispatched at
claude-fable-5-1(CONTRACT_REVIEW_TIER). The tier is not my reading — the ruling declares it: "Clause-②: yes — the publish / lint accept set narrows on a declared field ⇒CONTRACT_REVIEW_TIERconstruction and review." ⛔ No quota exemption is taken on this dispatch.Authority: the ruling at
5557019138(director seat, summon #16, batch #52 item 5; maintainer verbatim 「其他同意」 adopting recommendation 1′). ⛔ It is executed, not re-opened — the dev implements 1′ and does not revisit the choice between readings 1 and 2. Option 1 (a field-agnostic extension of the schema door) is refused by item 2 of that ruling.Declared file surface (anything outside this comes back to me before the edit, per the cross-domain exception):
packages/lint/src/validate-preset-comparands.ts— the new field-typed armpackages/spec/src/data/date-range-presets.ts—bareDateRangePresetComparandMessagereuse; describe text only if it movespackages/spec/src/data/filter.zod.ts— read-only. Ruling item 2 keeps its ordering-only boundary; ⛔ do not touch it.
Ruling item 3 is a first premise, not a nice-to-have — measure before any edit. Run the three residue positions (bare,
$eq,$in) as real queries against a declared date field on a real driver.packages/objectql/src/temporal-comparand-door.ts:202judges an implicit-equality comparand on the engine path, so bare /$eqmay already be refused at query time withINVALID_FILTER/ 400;$inis unmeasured. Write the reading onto this card. If it comes back refused, that corrects this card's own "answers 200 with zero rows" sentence, which may have been inherited from the pre-door #8690 measurement — say so plainly rather than quietly. ⛔ The ruling stands either way; the authoring-time refusal is what reaches an AI author's correction loop.Changesets per the ruling:
@objectstack/lintminor (new refusal arm);@objectstack/specpatch only if its describe text moves.⚠️ Carrier discipline:needs:contract-reviewis not hung here and must not be. Pre-hanging was abolished by the maintainer's 2026-08-28 ruling — the gate goes on both carriers together once the reviewable increment exists, i.e. when the PR does. The PR creator hangs it on the PR and this card in one stroke, and ⛔ does not flip ready or enqueue until an at-tier PASS clears it.domain:specPM dispatch seat · R4 · assignee + label written in one stroke above; this comment is the identity
Generated by Claude Code
Generated by Claude Code
First premise (ruling item 3) — measured before any edit: every residue position is already refused at QUERY time
Branch
claude/issue-16106-preset-comparand-field-typed, worktree atorigin/main4a1a3b0c25, packages built from that tree. Real engine (ObjectQL+ registry), objectcrm_opportunitydeclaringclose_date: date,closed_at: datetime,stage: select(an option valuethis_quarter, the picklist-collision case). 30 rows seeded, 20 inside a 30-day window. Each cell is oneengine.find('crm_opportunity', { where }), run on TWO real drivers:@objectstack/driver-memory(InMemoryDriver) and@objectstack/driver-sqlite-wasm(SqliteWasmDriver,:memory:, schema synced). Both drivers answered every cell identically.authored whereresult { close_date: 'last_30_days' }(bare, implicit equality)REFUSED INVALID_FILTER/ 400 —… at where.close_date …{ close_date: { $eq: 'last_30_days' } }REFUSED INVALID_FILTER/ 400 —… at where.close_date.$eq …{ close_date: { $in: ['last_30_days'] } }(the unmeasured cell)REFUSED INVALID_FILTER/ 400 —… at where.close_date.$in[0] …{ close_date: { $ne: 'last_30_days' } }REFUSED INVALID_FILTER/ 400{ close_date: { $nin: ['last_30_days'] } }REFUSED INVALID_FILTER/ 400{ close_date: { $gte: 'last_30_days' } }(already-judged control)REFUSED INVALID_FILTER/ 400{ closed_at: 'last_30_days' }/{ closed_at: { $in: [...] } }(datetime)REFUSED INVALID_FILTER/ 400{ close_date: { $gte: '{30_days_ago}' } }(positive control)200, count=20 { close_date: '2026-09-03' }(equality on a date works)200, count=1 { close_date: { $in: ['2026-09-03', '2026-09-02'] } }200, count=2 { stage: 'this_quarter' }(select column, non-temporal)200, count=10 — the picklist case is alive The refusal text is the engine door's:
find('crm_opportunity'): filter on 'close_date' compares a declared date field against "last_30_days" at where.close_date, which is not a date value this platform can interpret. … The filter was NOT applied.— raised before any driver read, the same envelope the ordering positions get.Correction to this card's own sentence
The card says "an unlowered preset string reaches the driver as a literal, compares false against every row, and the surface answers 200 with zero rows and no diagnostic". That sentence was inherited from the pre-door #8690 measurement and is not true today for the residue positions on a declared
date/datetimefield: bare,$eq,$in(and$ne/$nin) are refused at the engine withINVALID_FILTER/ 400 — a loud 400 on first render, not a silent zero.$in, unmeasured until now, is refused too.What remains true, and is exactly what the ruling addresses: the gap is purely at AUTHORING time. On the same filter
objectstack lintpassed (filter-preset-comparanddid not fire) anddefineStackaccepted, so the refusal reached the first viewer as a runtime 400 instead of reaching the author's correction loop. The ruling stands either way; proceeding with the field-typed arm at@objectstack/lintper 1′.
Generated by Claude Code
os-dev-report
{ "issue": 16106, "status": "done", "branch": "claude/issue-16106-preset-comparand-field-typed", "pr": "https://github.com/objectstack-ai/objectstack/pull/16347", "premise_still_valid": true, "summary": "Ruling 1′ executed: `filter-preset-comparand` (packages/lint/src/validate-preset-comparands.ts) gains a FIELD-TYPED arm that, on an author-declared date/datetime field resolved through the stack's object graph (widget→dataset→object, view data.object, flow config.objectName, page dataSource/properties, an object's own list views/relatedListFilter, a summary field's child object), refuses the 13 preset names in bare, $eq/$ne, $in/$nin and their view-rule/triple spellings, reusing bareDateRangePresetComparandMessage; filter.zod.ts untouched (item 2); item 1's defineStack conditional measured NO (stack.zod.ts carries no filter walk, grep count 0). First premise (item 3) measured on driver-memory AND sqlite-wasm and written to the card (comment 5559761543): bare/$eq/$in/$ne/$nin on a declared date/datetime field are ALREADY refused at the engine with INVALID_FILTER/400 before any driver read — this corrects the card's own '200 with zero rows and no diagnostic' sentence (inherited from the pre-door #8690 measurement); the gap was authoring-time only, so the ruling stands and the card's structural premise (lint/defineStack pass these positions) held. Draft PR #16347 opened; needs:contract-review hung on PR and card in one stroke and read back (PR: size/l + needs:contract-review; card: bug, priority:p2, pm:dispatched, domain:spec, needs:contract-review); not flipped ready, not enqueued. Changeset: @objectstack/lint minor only. origin/main (6c546ab9d0) merged before the PR, final head 96ff8f59d7. Worktree removed. REST was 403 this session (session gate), so every GitHub write went via MCP; reads went through the zero-quota page payload.", "tests": "Rule file: pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-preset-comparands.test.ts → 'Tests 15 passed (15)' (8 existing + 7 new). Package: pnpm --filter @objectstack/lint test → 'Test Files 99 passed (99)' / 'Tests 3387 passed (3387)'; pnpm --filter @objectstack/lint typecheck → tsc clean + 'check:test-typecheck: OK' — both run before AND after merging origin/main on a rebuilt packages/spec (check:generated: 'All 15 generated artifacts are up to date'). Consumer (runtime-gate host), against rebuilt lint dist: pnpm --filter @objectstack/metadata-protocol test → 'Test Files 166 passed | 2 skipped (168)' / 'Tests 2414 passed | 10 skipped (2424)' (pre-existing skips). Ablation (implementation committed first): EQUALITY_DOLLAR_OPS mutated to accept nothing — on-disk proof anchor before=1 / remaining=0 / injected=1, mutated blob 4c3893e11dfd… vs HEAD 1f9c5f48fef7… → vitest exit 1, 3 failed / 12 passed (the card's three-row test, the $ne/$nin test, the carrier-binding test); restore via git checkout HEAD -- path: on-disk blob = HEAD blob 1f9c5f48fef72bcd457390fa036c751d804243b8, git diff HEAD empty, porcelain empty; direction observed: turned red. No dist rebuild was needed for the ablation: the test imports the rule from source (relative import) and spec dist was unchanged. Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 54 families (identical list before and after the merge), all 54 run on final head 96ff8f59d7: 52 green; 2 PREREQUISITE NOT MET exit 3 (check:dual-build-cjs-loads, check:type-check-debt — both need the whole-repo build only CI performs; NOT MEASURED locally, not red); --ran reconciliation: 54 derived / 54 run / 0 unrun. Declared narrowing: turbo ls --affected lists 54 packages (everything downstream of @objectstack/lint); run locally: lint + metadata-protocol; packages/cli (a 56-cache-miss closure build) and the rest are declared to CI. CI on the PR: in_progress at report time (not waited).", "mcp_calls": "12 — 1 search_issues (dedupe for #16340), 1 issue create (#16340), 1 create_pull_request, 2 label writes (card union, PR union), 3 label reads (card pre-write, one PR-number attempt that errored — PRs are not resolvable through issue_read, the PR labels were read via pull_request_read instead — card read-back), 2 pull_request_read, 2 add_issue_comment (the measurement, this report).", "open_questions": [ { "question": "Two comments now under-describe the rule and both sit OUTSIDE the declared file surface, so they were not edited: packages/lint/src/authoring-rules.ts (the registration says the rule 'needs NO resolution context'; arm 2 reads objects/datasets when present) and packages/spec/src/data/date-range-presets.ts:101 (the TSDoc says 'bare ORDERING comparand'; the message is now also the equality/membership wording). Patch them on this PR?", "options": [ "A — one follow-up commit on PR #16347: a one-line registration-comment edit in authoring-rules.ts plus the docblock edit in date-range-presets.ts with a @objectstack/spec patch changeset", "B — leave both as-is (behaviour is correct; comment-only drift)" ], "recommendation": "A, because the stale registration comment is exactly what the next author reads before narrowing the runtime snapshot (the coverage-narrowing class #4463 records), the cost is two comment lines, and only the surface authorization is missing, not a judgement" } ], "out_of_scope_findings": [ "filed as #16340: the lint object graph carries registry-injected columns (created_at / updated_at) by NAME only, so a field-typed rule cannot judge them — measured refused at the engine (INVALID_FILTER / 400) on driver-memory; a missed catch, never a false refusal; the spec's resolveInjectedSystemColumns is the one place a type could be carried without a second copy" ] }
Generated by Claude Code
os-dev-report
{ "issue": 16106, "status": "done", "branch": "claude/issue-16106-preset-comparand-field-typed", "pr": "https://github.com/objectstack-ai/objectstack/pull/16347", "premise_still_valid": true, "summary": "Follow-up round on the PM's answer (A — patch both). Worktree recreated on the pushed branch; two comment-only edits landed as commit 23443ce169: packages/lint/src/authoring-rules.ts (the registration now describes both arms — arm 1 needs no resolution context, arm 2 reads objects/datasets when present and stays silent where absent, so RUNTIME_NEEDS_FULL_SNAPSHOT still does not apply) and packages/spec/src/data/date-range-presets.ts:101 (the TSDoc names both moments the shared wording is reported at). The spec changeset was MEASURED, not assumed: the edited TSDoc reaches 2 non-map dist files (dist/date-range-presets-BQj1X-Id.d.ts and .d.mts, re-exported by dist/data/index.d.ts; dist is in package.json files) and the old phrase is in 0 non-map files afterwards — baseline: the old phrase sat in exactly those two .d.ts/.d.mts files and a known .describe() string reached 18 non-map files — so a @objectstack/spec patch changeset was added (.changeset/spec-preset-comparand-message-tsdoc.md); check:generated after the rebuild: all 15 artifacts up to date. filter.zod.ts untouched; nothing else widened; no stash, no rebase, no force. PR #16347 body updated to the final head with the reading written in (two edits: the second replaced a model identifier with the tier name and put the attribution in body prose, because the platform appended no footer on edit); PR stays DRAFT (draft:true carried explicitly on both edits), needs:contract-review intact on PR and card (a path labeler added documentation/tests/tooling/protocol:data to the PR; nothing stripped). The rule file itself is byte-identical since the ablation. Worktree removed again.", "tests": "On final head 23443ce169: pnpm --filter @objectstack/lint test → 'Test Files 99 passed (99)' / 'Tests 3387 passed (3387)' (a first run in the fresh worktree before lint's own dist was built read '3382 passed | 5 skipped' — dist-dependent skips; with dist built the count is the full 3387, equal to the 96ff8f59d7 reading); pnpm --filter @objectstack/lint typecheck → tsc clean + 'check:test-typecheck: OK'; pnpm --filter @objectstack/spec exec vitest run src/data/date-range-presets.test.ts → 'Tests 6 passed (6)'; pnpm --filter @objectstack/spec build && check:generated → 'All 15 generated artifacts are up to date'. Gates: dispatch-gates --commands on 23443ce169 derived 74 families (20 spec/lint audit families joined once packages/spec and authoring-rules.ts moved; the list is identical before and after the commit); all 74 run: 72 green, 2 PREREQUISITE NOT MET exit 3 (check:dual-build-cjs-loads, check:type-check-debt — whole-repo build only CI performs; NOT MEASURED, not red); two more (check:doc-formula-expressions, check:docs-transcript-drift) were prerequisite-blocked until lint's dist existed and are green on re-run; --ran reconciliation: 74 derived / 74 run / 0 unrun. Unchanged from the first round and still valid (implementation byte-identical): rule file 15 passed; metadata-protocol consumer 166 files / 2414 tests green against rebuilt lint dist; ablation red 3 / 12 with restore proven by blob hash 1f9c5f48fef72bcd457390fa036c751d804243b8 and empty git diff HEAD. Declared narrowing: turbo ls --affected = 54 packages; run locally lint + metadata-protocol + the spec file's own test; packages/cli (56-cache-miss closure), spec's full suites and the rest declared to CI. CI on the PR: in_progress at report time (not waited).", "mcp_calls": "19 cumulative — first round 13 (1 search_issues, 1 issue create #16340, 1 create_pull_request, 2 label writes, 3 label reads, 2 pull_request_read, 2 add_issue_comment, 1 get_comments read-back); this round 6 (2 update_pull_request with draft:true, 2 pull_request_read, 1 add_issue_comment for this report, 1 get_comments read-back of it). REST stayed 403 (session gate); card reads went through the zero-quota page payload where its 15-item window allowed.", "open_questions": [], "out_of_scope_findings": [ "filed as #16340: the lint object graph carries registry-injected columns (created_at / updated_at) by NAME only, so a field-typed rule cannot judge them — measured refused at the engine (INVALID_FILTER / 400) on driver-memory; a missed catch, never a false refusal; the spec's resolveInjectedSystemColumns is the one place a type could be carried without a second copy" ] }
Generated by Claude Code
os-dev-report
{ "issue": 16106, "status": "done", "branch": "claude/issue-16106-preset-comparand-field-typed", "pr": "https://github.com/objectstack-ai/objectstack/pull/16347", "premise_still_valid": true, "summary": "Check Changeset compliance round. New head e05cb18796 (one changeset-level commit on top of 23443ce169; no source change): .changeset/spec-preset-comparand-message-tsdoc.md raised from patch to minor — direction 1 of the gate's two, the clause-② declaration untouched. The 'gate-required, not semantically earned' reading is recorded in the PR body's Changesets section (the spec diff is a TSDoc comment only; the widened package is @objectstack/lint, already minor; the gate reads clause-② at PR level) and deliberately NOT in the changeset prose, which ships to consumers as CHANGELOG text — a first draft of that sentence in the changeset was removed before committing. PR body now carries a standalone `Clause-②: yes` line as the durable declaration (draft:true carried explicitly on the edit; read back: draft, head e05cb18796, labels documentation/size-l/tests/tooling/needs:contract-review/protocol:data intact). Before pushing, the gate itself was run OFFLINE on the committed head with an event mirroring the PR's labels and the edited body (scripts/check-changeset-no-major.mjs --base merge-base --event): '✓ LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/*/src/** it moves is graded patch · carrier: needs:contract-review IS on this PR · declaration line: Clause-②: yes' — exit 0; the same run on the previous committed head reproduced CI's red byte-for-byte (the script reads the changeset via git show HEAD:path, so a working-tree edit alone does not clear it — a masked reading caught and discarded). Nothing else moved: filter.zod.ts untouched, no new files, no stash/rebase/force. Worktree removed.", "tests": "CI `Check Changeset` on e05cb18796: conclusion SUCCESS (run 34042065984, job 101510513543, 15:22:41Z), and it FIRED — the log carries the level-axis judgement verbatim: '✓ LEVEL AXIS: this PR declares clause-② `yes`, and no package whose `packages/*/src/**` it moves is graded `patch`. · carrier: `needs:contract-review` IS on this PR · declaration line: the PR body carries no `Clause-②:` line'. Nuance stated plainly: that run's event payload was the synchronize event of the push (~15:22:05Z), which predates the body edit (15:22:26Z), so CI judged on the carrier alone and had not yet seen the standalone line; the offline run of the same script on the same head with the edited body reads 'declaration line: `Clause-②: yes`' and passes — so the line is durable for the runs that follow the carrier strip, which is what it exists for. Sibling gates on the committed head, offline: check-changeset-no-major --self-test exit 0; check-empty-changeset --base origin/main exit 0; check-adr-0087-registration --base origin/main exit 0. The pr-labels job on the same head (101510513400) read the six labels and wrote nothing. Unchanged and still valid (the rule file and every source file are byte-identical since 23443ce169): lint 99 files / 3387 tests + typecheck OK; spec date-range-presets test 6 passed; spec build + check:generated 15/15 up to date; metadata-protocol 166 files / 2414 tests; ablation red 3/12 with restore proven by blob hash 1f9c5f48fef72bcd457390fa036c751d804243b8; 74 derived gate families, 72 green + 2 PREREQUISITE NOT MET (whole-repo build only CI performs). Other CI jobs on e05cb18796: in_progress at report time, not waited — the PM asked for this one gate's conclusion only.", "mcp_calls": "25 cumulative — first round 13, second round 6, this round 6 (1 update_pull_request with draft:true, 1 pull_request_read, 2 get_job_logs — the checks page named two job ids near the check and only one was the gate, 1 add_issue_comment for this report, 1 get_comments read-back of it). The CI conclusion and the job ids came from the zero-quota PR checks page (octicon-check beside the check name, run/job links in the same block); REST stayed 403.", "open_questions": [], "out_of_scope_findings": [ "filed as #16340: the lint object graph carries registry-injected columns (created_at / updated_at) by NAME only, so a field-typed rule cannot judge them — measured refused at the engine (INVALID_FILTER / 400) on driver-memory; a missed catch, never a false refusal" ] }
Generated by Claude Code
达档契约复核 FAIL · PR #16347 head
e05cb18796· 一条阻塞项:假拒绝domain:specPM 派发席(session_01T6HeZvT9wdSJD1ZxJb5Eno),2026-09-06T15:4xZ。档位核验 —— ⛔ 不采信自述,不采信
model传参=== harness-stamped model === 121 "model":"claude-fable-5-1" === lit control: assistant messages === 106达
CONTRACT_REVIEW_TIER,两端都亮。⚠️ 披露:复核由本席 subagent 执行,维护者当面授权。裁决 —— 逐字采纳(⛔ 不摘录、不改写)
Verdict: FAIL — one narrow, blocking false-refusal; everything else executes ruling 1′ cleanly
Reviewed at
e05cb18796in a detached worktree (removed; primary checkout and stash untouched). Every reading below was taken by me, not carried over from the PR body.Blocking (1)
B1. Arm 2 can produce a FALSE refusal at a schema-valid position: a form field's
publicPicker.filter.
views[].sections[].fields[].publicPicker.filter(packages/spec/src/ui/view.zod.ts:2329) is a static pre-filter the public-lookup route runs on the referenced object (packages/rest/src/rest-server.ts≈10310:referenceTo = picker.objectelse the field'sreference).boundObjectOf(packages/lint/src/validate-preset-comparands.ts:341-390) does not recognise apublicPickerancestor, so with the optionalobjectoverride omitted it falls through to the view'sdata.object— the parent form object. Measured (probe test, then deleted):objects: crm_opportunity.close_date: date · crm_account.close_date: select{this_quarter} views[0] form on crm_opportunity, field account (lookup→crm_account), publicPicker.filter: [{ field:'close_date', operator:'equals', value:'this_quarter' }] → REFUSED at views[0].sections[0].fields[0].publicPicker.filter[0].value ← false: the picker queries crm_account, a select column control: same with publicPicker.object:'crm_account' → quiet control: add.picker.filter (record:related_list) → binds picker.object → quietThis is exactly the failure class ruling item 2 exists to prevent ("without a field type, equality on a select column is legitimate") and the one the PR's own invariant excludes ("a missed catch is the only failure direction this arm may have"). It fires at
errorseverity in agatingrule on bothobjectstack lintand the runtime publish gate (viewis inruntimeTypes), with a message that is factually wrong for the position (the engine does not refuse it there). The trigger needs a parent/referenced field-name collision with differing types plus a preset-named option value — rare, but a real publish block.
Fix is small: inboundObjectOf, treat apublicPickerancestor as a claiming reader — bind topublicPicker.object; else resolve the enclosing form field'sfieldon the view's object viaresolveFieldPathand takemeta.reference; else returnundefined(unjudged). Add the P1 pin above (false-refusal direction) plus a positive control where the referenced object's field IS a date. No sibling lint rule readspublicPicker(grep 0, control lit), so there is no existing read to reuse.What passes
Ruling 1′ executed, and only 1′. Arm 2 refuses the 13 names in bare /
$eq/$ne/$in/$nin/ view-rule / triple positions (alias folds included), reusingbareDateRangePresetComparandMessage.packages/spec/src/data/filter.zod.ts: 0 diff lines (file present as control). Arm 1 code paths are unchanged (diff read; 8 pre-existing tests pass). Item 1'sdefineStackconditional:validateCrossReferences(stack.zod.ts:1809) has 0 hits forruntimeFilter/relatedListFilter/FilterCondition/walkFilter/'date'/'datetime'with a lit control (reference= 61) — no stack-level field-typed filter walk exists. Item 4 holds:FilterConditionSchemaaccepts bare /$eq/$inpresets (refuses$gte, control), so adefineStackapp now reaches the rule.First-premise correction holds — re-measured, not trusted. Real
ObjectQL+ registry, 30 rows (20 in-window),InMemoryDriverandSqliteWasmDriver(:memory:), identical on both: bare /$eq/$in/ mixed$in [ISO, preset]/$ne/$nin/$gteon adate, and bare /$inon adatetime→INVALID_FILTER/ 400 atwhere.close_date.$in[0]etc., before any driver read. Positives:$gte {30_days_ago}→ 20, ISO day → 1,$inISO days → 2,stage: 'this_quarter'(select) → 10. The card's "200 with zero rows" sentence is false today for these positions; the gap was authoring-time only. (Also measured: atimefield is refused at the engine too; lint deliberately does not judge it — a missed catch within the ruling's date/datetime scope.)(a) Resolution paths verified: widget→dataset→object, report/block→dataset, view
data.object(non-objectprovider ends the search), flowconfig.objectName(templated skipped), pagedataSource.object/properties.object|objectName/ pageobject,properties.dataset+properties.filter,record:related_listproperties.objectNameandadd.picker.object, an object's list views /relatedListFilter(owner rows — correct per the spec text), summaryobject,optionsFrom.object, map-form collections, nested-relation and dotted hops.lookupFiltersis not walked (correct — not inFILTER_KEYS). Not verified: time-relative flow triggers and dashboard-level filters outside widgets (the latter is unbound by construction).
(b) Stays silent on select/text columns,time, injectedcreated_at, undeclared fields, unknown dataset/object, no field map,apiprovider, templated flow target,apps[], unbound pages — all measured quiet. B1 is the one exception found.Ablation (all three legs landed on disk, restored to blob
1f9c5f48…,git diff HEADempty each time):- Leg A (reported, re-run):
EQUALITY_DOLLAR_OPS → new Set([]), bloba24c5537…→ exit 1, 3 failed / 12 passed — matches. - Leg B (mine, false-refusal direction):
FIELD_TYPED_TEMPORAL_TYPES + 'select', blobfd2f16fc…→ 1 failed / 14 passed (the arm-2 quiet-controls test) — as predicted. - Leg F (mine, false-refusal/binding direction):
UNBOUND = () => true, blob62165674…→ 2 failed / 13 passed (both "stays quiet" tests) — as predicted.
No leg came out differently from prediction. Suite: lint 99 files / 3387 tests green,typecheckOK, spec presets test 6/6.
Changesets. (i)
@objectstack/specis genuinely owed: the new TSDoc reaches exactly 2 non-map dist files (dist/date-range-presets-BQj1X-Id.d.ts/.d.mts), old phrase 0 (sub-fragmenttwo momentsalso 0), control.describe()string 18, new phrase in 0 runtime.js— the implementer's reading reproduces. Semanticallypatch;minoris gate-forced (#16361). Practically harmless: in the lockstep fixed group lint'sminoralready lifts every package, so spec's word changes only CHANGELOG text. Agree with the PR's handling (reasoning in the body, not in shipped prose). (ii)@objectstack/lintminoris right: the narrowing removes only inputs the engine already refuses at runtime (measured above), so no working program breaks — notmajor-shaped, no BREAKING banner / ADR-0087 disposition owed; notpatch, since a new refusal arm is new rule surface. B1 is the sole case where the "already refused at runtime" argument fails, and it is a bug to fix, not a contract change.Gate, carrier, scope. Offline
check-changeset-no-major.mjs --eventone05cb18796with the PR's verbatim body: carrier+line → pass (reads both); carrier stripped, line only → pass,declaration line: Clause-②: yes; neither → NOT MEASURED (control); previous head23443ce169→ red, byte-for-byte CI's message. CI job 101510513543 passed on the carrier alone (its log: "the PR body carries noClause-②:line") — the push payload predates the body edit, as stated. Commit23443ce169touched onlyauthoring-rules.ts(comment lines) anddate-range-presets.ts:101-112(TSDoc) plus its changeset;e05cb18796only the level word; rule and test blobs identical from34949aa1b0through HEAD; merge96ff8f59d7carries no extra edits.content/docs/releases/: 0.Advisory
- A1. Hop positions (
account.created_on, nested{ account: { created_on } }) are judged by arm 2 but skipped by the engine door (dotted keys / deep-equality) — a bonus catch, never false, but the shared message's "refuses the query at the engine" clause is not literally true there.{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 one-wording wins; note only. - A2. CI on
e05cb18796: three jobs (Test Core 5/6, 6/6, Lint & Repo Gates) were still in progress at my read; the rest green.
I agree with the PM's Clause-②
yes. The ruling is executed faithfully; B1 is a boundary defect in one binding path, cheap to close, and should be fixed on this PR before it lands.Implemented-by: claude/issue-16106-preset-comparand-field-typed @ e05cb18796 Reviewed-by: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno⭐ 为什么这条 FAIL 值这一轮的钱
我在派发复核时把「这条臂会不会产生假拒绝」列为要审得最狠的两件事之一,理由写在派发词里:"Worse than a missed catch." 它就在那里找到了阻塞项 —— 而且证伪了 PR 自己写下的不变量(「a missed catch is the only failure direction this arm may have」)。
这条缺陷的形状值得记:
publicPicker.filter是在被引用对象上跑的预过滤,而绑定函数不认识publicPicker祖先,于是回落到视图的data.object—— 父对象。所以当父对象和被引用对象碰巧有同名但不同类型的字段时,一个合法的 select 值会被当成 date 预设名拒绝。触发条件罕见,但它是gating规则里的error级,同时挡objectstack lint和运行时发布门 —— 挡的是一个本来合法的 publish,而且给出的理由在那个位置上是假的(引擎在那里并不拒绝)。它还自己加了两条我没要求的消融腿(Leg B、Leg F),都是假拒绝方向的 —— 报告里那一条只测了漏抓方向。
处置
⛔ 不落地。 已回发实现者修 B1(复核给了具体修法与两条 pin,包括一条假拒绝方向的和一条被引用对象字段确实是 date 的正对照)。载体保持挂着,PR 保持 draft。
⚠️ 修好后只需窄面再复核那一处绑定改动,其余七节的读数不重取 —— 除非改动越出boundObjectOf与其 pin。domain:specPM 派发席 · 席位帖 #6017 · R4 · 达档复核 FAIL,阻塞项 B1
Generated by Claude Code
Generated by Claude Code
- Leg A (reported, re-run):
os-dev-report
{ "issue": 16106, "status": "done", "branch": "claude/issue-16106-preset-comparand-field-typed", "pr": "https://github.com/objectstack-ai/objectstack/pull/16347", "premise_still_valid": true, "summary": "Repair round for review finding B1 (comment 5560322399). New head 3b6ba7b409 (one commit on top of e05cb18796; rule file + its test only). In boundObjectOf a form field's publicPicker is now a CLAIMING reader: bind to picker.object when written; else resolve the enclosing form field's `field` on the view's own object through resolveFieldPath and take meta.reference; else return undefined — unjudged, never the parent. The fall-through to the view's data.object (the parent form object) that produced the false refusal is gone; no other binding path changed (ancestorsOf now records the property name each ancestor was reached under, and the reader loop became bindAncestors so the picker branch can bind the enclosing form's object by recursing outward from the section). element:record_picker was checked and is not the same class: it declares its own `object`, already bound by the direct-object reader. Two pins added as separate tests: the review's false-refusal case (parent close_date: date, referenced crm_account.close_date: select with option this_quarter, no override) plus the object-override form and three unresolvable pickers (undeclared field / non-relationship field / text field) → QUIET; POSITIVE CONTROL: the referenced object (crm_contact) declares close_date as a date, via reference and via override → refused at views[0].sections[0].fields[0].publicPicker.filter[0].value. Scope held: filter.zod.ts untouched, no new files, no changeset level change, PR stays draft (draft:true carried on the body edit), carriers untouched; no stash/rebase/force. PR body updated to the repair head with a Repair-commit bullet, the picker reader in the binding list, the invariant sentence amended to name B1, and both ablation legs. Worktree removed.", "tests": "On head 3b6ba7b409: rule file pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-preset-comparands.test.ts → 'Tests 17 passed (17)' (15 + the 2 B1 pins); pnpm --filter @objectstack/lint typecheck → tsc clean + 'check:test-typecheck: OK'; pnpm --filter @objectstack/lint test (lint dist built) → 'Test Files 99 passed (99)' / 'Tests 3389 passed (3389)'. Ablation of the repair in BOTH directions, implementation committed first: leg P1 (false-refusal direction) — the picker reader disabled by renaming its key match (`key === 'ablated_' + PUBLIC_PICKER_KEY`; on-disk proof anchor before=1 / remaining=0 / injected=1, mutated blob cd3fbe6a6e089a25b8fab8361a9b5ec21145ba6e) → vitest exit 1, red on exactly the '[B1] stays QUIET' pin (the false refusal returns), positive control still green; leg P2 (blanket-silence direction) — every picker position forced unjudged (`const override = undefined as string | undefined; return override;`, blob 553db859ecfe2d1a2cc399cca6bb4dcb3a707a2c) → exit 1, red on exactly the '[B1] POSITIVE CONTROL' pin. Each leg restored with git checkout HEAD -- path: on-disk blob = HEAD blob 23a8afdb33349274645cf2f54a36d364e31052e2, git diff HEAD empty, porcelain empty. Discarded reading, stated: the first P2 attempt used a replacement that CONTAINED its anchor as a substring, so the on-disk proof's 'anchor remaining' count read 1 although the blob had changed (75a7be6819e2…); the proof refused it as not-landed (exit 96), the tree was restored and proven, and the leg was re-run with a non-superset replacement — nothing was read from the discarded attempt. Gates: dispatch-gates --commands on 3b6ba7b409 derived 74 families (identical to e05cb18796's); all 74 run on this head: 72 green, 2 PREREQUISITE NOT MET exit 3 (check:dual-build-cjs-loads, check:type-check-debt — whole-repo build only CI performs; NOT MEASURED, not red); --ran reconciliation: 74 derived / 74 run / 0 unrun. Unchanged and still valid: spec presets test 6/6, spec build + check:generated 15/15, metadata-protocol 166 files / 2414 tests (no consumer-visible change since), first-premise measurement, arm-2 ablation leg A plus the review's own legs B and F. CI on 3b6ba7b409: in_progress at report time, not waited.", "mcp_calls": "30 cumulative — earlier rounds 25; this round 5 (1 get_comments to read the verdict in full — comment 8 sits beyond the page payload's 15-item window, 1 update_pull_request with draft:true, 1 pull_request_read, 1 add_issue_comment for this report, 1 get_comments read-back of it). REST stayed 403.", "open_questions": [], "out_of_scope_findings": [ "filed as #16340: the lint object graph carries registry-injected columns (created_at / updated_at) by NAME only, so a field-typed rule cannot judge them — measured refused at the engine (INVALID_FILTER / 400) on driver-memory; a missed catch, never a false refusal" ] }
Generated by Claude Code
窄面再复核 PASS —— B1 已闭合 · PR #16347 head
3b6ba7b409domain:specPM 派发席(session_01T6HeZvT9wdSJD1ZxJb5Eno),2026-09-06T16:1xZ。档位核验
=== harness-stamped model === 188 "model":"claude-fable-5-1" === lit control: assistant messages === 140达
CONTRACT_REVIEW_TIER,两端都亮。裁决 —— 逐字采纳(⛔ 不摘录、不改写)
PASS — scoped to delta
e05cb18796..3b6ba7b409(one commit:packages/lint/src/validate-preset-comparands.ts+ its test). B1 is closed, nothing new was opened, and the shared binding paths are unchanged. Two advisory notes, none blocking.Q1 — B1 is closed (reproduced, not inferred)
I materialised the
e05cb18796rule (blob1f9c5f48…, verified) as a sibling in a detached worktree at3b6ba7b409— its helpers (filter-walk.ts,object-graph.ts,date-range-presets.ts) are byte-identical across the delta, so that is exactly the old rule — and ran identical stacks through OLD and NEW:- A (B1): parent
close_date: date, referencedcrm_account.close_date: select{this_quarter}, no override → OLD refuses atviews[0].sections[0].fields[0].publicPicker.filter[0].value; NEW[]. - Route semantics verified, not adopted:
rest-server.ts:10311referenceTo = picker.object, elseobj.fields[fieldName].referencewhereobj = match.object=form.data.object ?? … ?? view.object(findPublicFormView, 9886–9920). The lint's outward recursion reads exactly those (data.object/objecton the view;form.data.objectin the nested authoring shape — harness H). - Note: with the override written, OLD was already correct (the picker node's
objecthit the direct reader). B1 was strictly the no-override case; the pin's override sub-case is a regression guard, not a discriminator.
Q2 —
undefinedis unjudged; no new fall-through; no blanket silencetemporalFieldOracle(graph, undefined)returns the constantUNBOUND = () => false;boundObjectOfhas one caller (line 679); the other twoboundObjectOfhits inpackages/lint/src/are unrelated module-private functions with different signatures.- D: five unresolvable pickers (undeclared field, text field, the date field itself, no
fieldat all, arrayreference) with the parentdateone level up → OLD 5 refusals, NEW 0. D2: view also carrying a directobject→ NEW 0. E: an ordering preset at the same unbound picker still fires on both → the position is walked, arm 2 is silent, not skipped. - C (distinguishing positive): parent
close_date: select, referencedcrm_contact.close_date: date→ OLD silent (wrong), NEW refuses (right). The fix gains a true refusal; it is not silence. - Outward recursion: entered only from the picker branch;
FormFieldSchema(keys:field type options reference publicPicker … dependsOn) andFormSectionSchemacarry none of the seven reader keys, so it binds only what OLD's fall-through already reached. Q3's differential shows no position binds differently.
Q3 — blast radius
Differential OLD === NEW on: widget→dataset→object, summary
object,relatedListFilter,listViews[].filter, reportruntimeFilter(top + block), flowconfig.objectName, pagedataSource.object, pageobject,record:related_listproperties.objectName,element:record_pickerproperties.object(×2, date→refused / select→quiet),optionsFrom.object, nested + dotted hops, list viewdata.object, map-form objects/datasets, and the full discriminating quiet stack (time, injected, unknown field/dataset/object, no field map,apiprovider, unbound view, templated flow, app filter, page triple). Pre-delta 15 tests green; full lint suite 99 files / 3384 passed / 5 skipped (3389);tsc --noEmitexit 0;check:test-typecheckOK.
Not checked:datasets[].measures[].filter,dashboards[].globalFilters[].filter(onlyoptionsFrom.filter),reports[].blocks[].filter(onlyruntimeFilter), map-formviews/pages. All traverse the same loop body whose only shape change ischain[i]→{key,node}.element:record_picker: confirmed —ElementRecordPickerPropsSchema(component.zod.ts:2174) declaresobject+filterside-by-side underproperties; the chain node reached under keypropertieshasr.object, so the direct reader binds it and the picker branch is never entered.filter.zod.ts: untouched in the delta and across the whole PR (merge-base3e270d4e29: 6 files, none is it).
Ablations (each: anchor 1→0 with positive control, replacement present, blob ≠ HEAD; restore blob
23a8afdb…= HEAD,git diff HEADempty)- P1 (
PUBLIC_PICKER_KEYrenamed → reader off): rule file → only the QUIET pin red; harness A/C/D/D2/G/H/I/K red, B/E/F/J/L/Q3 green. - P2 (whole
const override = …line →return undefined): only the POSITIVE CONTROL pin red; harness B/C/F/G/H/I/J red. Replacement did not contain its anchor — I replaced the line rather than prefixing it, so the trap the implementer hit did not recur. - P3 (mine, new-fall-through direction): the branch's three
return undefinedexits →continueoutward. QUIET pin red on exactlyfields[0..2](the unresolvable pickers), positive control green; harness D/D2/K red. This is the proof the pin guards the third branch rather than mirroring the fix.
Advisory (non-blocking)
- The branch keys on the property name
publicPickeranywhere under any surface, not on the schema position. Today all 11 sub-fragment hits are inview.zod.tsonFormFieldSchema, so no schema-valid stack reaches it elsewhere; at a schema-invalid position (harness K:pages[].components[].properties.publicPicker.filterbesideproperties.object) NEW is now unjudged where OLD judged. A futurepublicPickerkey elsewhere would silently inherit this branch — a comment or achain[i-1].node.fieldguard would harden it. - Dotted
field(account.owner) and nestedFormFieldSchema.fieldspickers: the lint follows the hop / binds the immediate field'sreference; the route does a top-level direct key lookup and would 403/500. Divergent, but never a false refusal on a servable filter.
No cost the first review would have flagged:
graphwas added to a module-private signature with one caller; no public surface, no changeset impact.Hygiene: primary checkout HEAD
a4816a79d0unchanged, 0 tracked changes, stash never used; review worktree removed and pruned; no label, PR state, merge, or GitHub comment touched. (Environment note: the fresh worktree neededpnpm install+ spec/formula/sdui-parser builds; my first dependency build withOS_SKIP_DTS=1stripped spec's.d.tsand produced 103tscerrors — rebuilt with DTS, both typechecks green. That was my environment, not the delta.)Implemented-by: claude/issue-16106-preset-comparand-field-typed @ 3b6ba7b409 Reviewed-by: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno⭐ 它答对了我最担心的那一问
我给它的第二问是:修复有没有把一个回落换成另一个回落? —— 原缺陷就是回落,而换一个回落会在两条 pin 上都显绿。它没有停在「pin 绿了」,而是:
- 把旧规则实体化成兄弟模块(旧 blob 已核,三个 helper 跨增量字节相同 ⇒ 那确实是旧规则),同一批 stack 过 OLD/NEW 对跑 —— 所以 B1 是复现出来的,不是推出来的;
- 加了自己的第三条消融腿 P3,把那三个
return undefined出口改成continue向外 —— QUIET pin 精确红在三个不可解析的 picker 上,正对照仍绿。这就是「pin 守的是第三分支,不是照着修复写的」的证明; - C 腿发现修复不只是止损:父
select/ 被引用date的情形,旧规则是错的静默,新规则正确拒绝 —— 修复净赚了一条真拒绝。
它还诚实地列了没查的四个位置,并说明它们走同一段循环体、唯一形状变化是
chain[i]→{key,node}。处置
⛔ 仍不落地 —— 落地前检③未过:CI 在跑,而本仓当前的分片状况下汇总可能红(见 #16173
5560436598)。载体保持,PR 保持 draft,等 CI。两条 advisory 不阻塞:①分支按属性名匹配而非 schema 位置(今天 11 处子片段命中全在
view.zod.ts,但未来别处出现同名键会静默继承此分支);②点号路径与嵌套 picker 上 lint 与路由发散,但发散方向永不产生假拒绝。⇒ 这两条我另行立卡,⛔ 不扩本 PR。domain:specPM 派发席 · 席位帖 #6017 · R4 · B1 窄面再复核 PASS
Generated by Claude Code
Generated by Claude Code
- A (B1): parent
os-dev-report
{ "issue": 16106, "status": "done", "branch": "claude/issue-16106-preset-comparand-field-typed", "pr": "https://github.com/objectstack-ai/objectstack/pull/16347", "premise_still_valid": true, "summary": "Narrow refresh only, no re-implementation. Merged origin/main at the sha captured once at start (3e7ef9c2389e3e5e02903634947de9cece4736b4) into the branch inside a dedicated worktree; merge commit d6f1333f7ca77263c3e96ac7a17fea11c707460c with parents 3b6ba7b409 (branch tip) and 3e7ef9c238 (main). Clean merge, zero conflicts, and the merge commit is the only new commit. Purpose achieved: the honest shard aggregator from PR 16316 is now on the branch, so the aggregate Test Core check will report red whenever a shard is killed instead of a false green. The PR's own delta versus main is unchanged: same six files, same 737 insertions / 37 deletions; five of six blobs byte-identical to the pre-merge tip, and the sixth (authoring-rules.ts) differs only because main added an unrelated rule at other line ranges, with this PR's hunk proven byte-identical as a patch body. No changeset level touched, no PR body, label, draft or auto-merge change.", "tests": "Probe (post-merge, on merged head d6f1333f7c): grep -c 'an untested shard is not a passing shard' scripts/check-shard-attestation.mjs = 1. Controls: origin/main 3e7ef9c238 = 1 (lit); pre-merge branch tip 3b6ba7b409 = 0 (unlit). Worktree file reads 1 as well. Verdicts, all under scripts/pm/os-verify-lock.sh: (1) pnpm --filter '@objectstack/lint^...' build (prereq closure, includes spec) - VERDICT command-exit 0, held 112s; (2) pnpm --filter @objectstack/lint exec vitest run src/validate-preset-comparands.test.ts - Test Files 1 passed, Tests 17 passed (17), VERDICT command-exit 0; (3) pnpm --filter @objectstack/lint test - Test Files 100 passed, Tests 3457 passed / 5 skipped, VERDICT command-exit 0; (4) pnpm --filter @objectstack/lint typecheck - tsc --noEmit plus check:test-typecheck OK (2 files / 6 errors / 2 pinned signatures held in the shrink-only debt ledger, unchanged), VERDICT command-exit 0; (5) pnpm --filter @objectstack/spec build AND check:generated - 'All 15 generated artifacts are up to date', VERDICT command-exit 0. Working tree clean after every run; no os-regen deferral marker was created (the branch touches no merge=os-regen path, so the driver never ran). Merge brought 17 files under packages/lint/ and ZERO files under packages/spec/; the spec build plus check:generated was run anyway because the branch's own side edits packages/spec/src/data/date-range-presets.ts. Pushed head confirmed by git ls-remote: d6f1333f7ca77263c3e96ac7a17fea11c707460c, first attempt, no retries. Note: check:generated reports check:react-declaration-parity as 'cannot run here' (needs objectui's sdui.manifest.json) - a pre-existing environment condition, not caused by this merge; the wrapper still exited 0.", "mcp_calls": "1", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
- added a commit that references this issue
on Sep 16, 2026 - added a commit that references this issue
on Sep 17, 2026
Found while surveying which platform rules fire on
objectstack-ai/hotcrm(its #1621, step 3 of epic #1579). Read-only survey, report-first, unassigned.Measured against the pinned
@objectstack/spec@17.3.0and@objectstack/lint@17.3.0, hotcrm at1670557. Injections proven on disk before reading a verdict; restored by blob hash verified by observation.What is already shipped, and works
#8793 (closed, the ruled C half of #8690) refuses a declared preset name as a bare temporal comparand at publish time. Confirmed working, verbatim:
close_date: { $gte: '{12_months_ago}' }toclose_date: { $gte: 'last_30_days' }.defineStackrefuses, exit 1, with a precise message —"last_30_days" is a dashboard date-range PRESET name, not a filter value … As a bare "$gte" comparand nothing resolves it: a declared datetime/date field refuses the query at the engine (INVALID_FILTER / 400) …The same refusal fires from a report
runtimeFilter, so it is not dashboard-specific.The residue
filter-preset-comparandjudges onlyORDERING_DOLLAR_OPS = {$gt, $gte, $lt, $lte},ORDERING_INFIX_OPSandORDERING_RULE_OPS, and the publish-time refusal covers the same set. Every non-ordering position on the same declared date field passes both gates. Three injections into the same widget filter, onclose_date(a declared date column ofcrm_opportunity, the dataset's object):objectstack lintclose_date: { $gte: 'last_30_days' }close_date: 'last_30_days'passed: true, no findingclose_date: { $eq: 'last_30_days' }passed: true, no findingclose_date: { $in: ['last_30_days'] }passed: true, no findingBaseline for all four rows is identical (
errors: 0, warnings: 17, suggestions: 12), and the ordering row is the working control that the harness does detect this value.Why the residue matters
The consequence #8690 named is not specific to ordering operators: an unlowered preset string reaches the driver as a literal, compares false against every row, and the surface answers 200 with zero rows and no diagnostic.
close_date == 'last_30_days'is exactly that shape — arguably the more likely authoring slip, since equality is what an author writes when they mean "in this window".Two readings, and I do not have standing to choose between them:
$inpositions are simply the rest of that scope — extend the publish-time refusal (andfilter-preset-comparand) to any comparand position on a temporal field.Recorded rather than assumed.
filter-preset-comparand's own reachability is a consequence either way: on adefineStack-authored app the schema refuses the ordering positions first, so the lint rule id never appears in output at all.Dedupe
#8690and#8793read in full;#8793is closed/completed and covers the ordering half only. No open card names the equality/$in/bare positions.