Skip to content

fix(lint)!: judge a roll-up's summaryOperations.filter keys against its child object - #31337

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-52y8an
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-52y8an

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Refs #31224

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — skills/** and content/docs/** teach summaryOperations.filter keys that are fields of the child object, which this rule accepts. The reasoning ships in the os explain object-field-ref-unknown entry.

维护者速读

  • 改了什么:@objectstack/lint 的 object-field-ref-unknown 规则新增一个位置:汇总字段 summaryOperations.filter 里的每个键(含 $and / $or / $not 分组),都按 summaryOperations.object 指向的子对象来判定,子对象没有的键报 error,并给出最接近的字段名;重算查询不能执行的写法也报 error:点号路径(改写为一层嵌套 { account: { status } })、读时计算的字段(如 formula)、关系下的第二层条件。另加 os explain 段落、测试和一个 lint minor changeset。
  • 为什么改:Enforce-or-remove: four metadata shapes cloud's authoring rules warn about still parse clean (boolean required, open flow start config, orphan action nodes, roll-up filter keys) #31224 第 D 项。引擎把这个 filter 当作子对象聚合查询的 where(与父外键匹配取 AND)。在真实 SqlDriver(better-sqlite3)上实测:键写错时,每次插入子记录都报 SummaryRecomputeError(记录其实已写入),汇总值一直停在旧值。之前没有任何门检查这些键:单个字段的 Zod 解析看不到另一个对象的字段表。
  • 风险与回滚:收窄:os lint/validate/build 与对象保存/发布门会拒绝此前接受的汇总字段。普查为零:showcase 4 个带 filter 的汇总(status、billable、amount,都是子对象真实字段),hotcrm 0 个。回滚即 revert。
  • 先看:packages/lint/src/validate-object-field-refs.ts 里 summaryOperations.filter 那一段,以及模块头新增的一条位置说明。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/validate-object-field-refs.test.ts,预期全部通过,其中新 describe 的 10 条为本卡新增。
  • 需要你做的:无,席位做契约复审后落地。

What changes

  1. packages/lint/src/validate-object-field-refs.ts: a new field-level position in validateObjectFieldRefs. For a field whose summaryOperations names an object and carries a filter, the position reads the authored keys itself (judgeSummaryFilter): it descends $and / $or / $not groups and operator objects; a dotted relation key is refused with the nested-form cure, a dotted computed or scalar head (classifyDottedFilterHead) or a formula key (isVirtualSearchField) with the denormalise cure; the nested one-level relation form is accepted, and beneath it an empty condition, a dotted key, an undeclared key or a second level is refused. Each key is resolved against the CHILD object, never the object that owns the summary. The rule's shared judge does the resolution, so the verdict, the "Did you mean", severity error and the three family skips are the same as at every other position: a child the stack does not define, a child with no readable field map, and a registry-injected system column. Path: objects[i].fields.FIELD.summaryOperations.filter.KEY (group arms keep their index, e.g. …filter.$and[1].$or[1].stauts). Hint: "Fix the key — summaryOperations.filter runs on the child object summaryOperations.object names, so each key is a field of that child, not of the object that owns this summary." The module header gains the position with its reader and the measurement.
  2. packages/lint/src/rule-explanations.ts: an os explain object-field-ref-unknown paragraph for the position, saying which object, what is walked, and what a miss costs.
  3. .changeset/31224-summary-filter-key-unknown.md: '@objectstack/lint': minor, BREAKING banner, ADR-0087 not-required (no-migration-prescription).

Why lint, not parse. The verdict needs the child object's field map, which one field's Zod parse cannot see. The rule runs where the object graph is: validateObjectFieldRefs is in the reference-integrity suite with runtimeTypes: ['flow', 'object'], so the error reaches os lint / os validate / os build, the live object save and the per-item and package publish. A draft stores with the finding in _diagnostics.

Measured consequence (one-off probe, not kept): a real SqlDriver (better-sqlite3) plus a real ObjectQL, with parent pr_report.total = sum(pr_line.amount).

  • With filter: { status: 'ok' }, a child insert gives total = 5.
  • With filter: { stauts: 'ok' }, the same insert answers SummaryRecomputeError ("the triggering records WERE written") and total stays 0.

No packages/spec, metadata-protocol source, runtime or ui/ change.

Tests

  • packages/lint/src/validate-object-field-refs.test.ts, new describe "summaryOperations.filter (addresses the CHILD object, Enforce-or-remove: four metadata shapes cloud's authoring rules warn about still parse clean (boolean required, open flow start config, orphan action nodes, roll-up filter keys) #31224)":
    • Refused: a misspelt key (error, path, "Did you mean", hint); a key only the parent has; misses inside $and / $or / $not (paths pinned).
    • Accepted: child fields with operators, and the nested one-level relation form { account: { status, industry: { $in } } }.
    • Refused, the shapes the recompute query refuses:
      • the dotted spelling account.status, whose hint is the nested spelling;
      • a dotted path into a scalar field, with the denormalise cure;
      • a dotted key with an unknown head, which gets the unknown verdict;
      • a formula key, at the top level and one level down;
      • beneath a relation: an unknown related key, a dotted key, a second relation holding a condition, and an empty condition.
    • Not judged: a non-summary field.
    • Not judged: a child the stack does not define; a roll-up with no filter.
    • The file's two coverage lists, which require every position to have fired, gain .summaryOperations.filter..
  • packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts: an object-field-ref-unknown row in CASES (a self-referential roll-up with filter: { stauts: 'open' }). The draft saves and reads valid: false; publish refuses with the same words; the package publish refuses and rolls back.
  • Reverse check (one-off, not kept): with the new block guarded off, 5 red (the three refusals and both coverage lists); restored, green.
  • Census: showcase has 4 filtered roll-ups (expense-report.object.ts: status, billable, amount, all fields of showcase_expense_line), 0 unknown keys; hotcrm main has 0 filtered roll-ups. Control: 9 roll-ups in examples carry the live key summaryOperations.field.

Acceptance notes

Verification

Final head 8fc74827: the branch was restarted from origin/main 11c1326d (#31287 merged). 8fc74827 follows the contract review (6716423527): the position reads the authored keys itself instead of walkFilterFieldKeys, which flattens a dotted key and a nested condition into one path. The recompute's where serves only the nested one-level form, so a dotted key, a computed field and a second relation level are refused, and the three texts no longer teach the dotted spelling. Measured on a real SqlDriver + ObjectQL (one-off probe, not kept): { report: { name: 'r' } } sums (total 5); { 'report.name': 'r' } and a formula key each answer SummaryRecomputeError on the child insert, and the total stays 0.

  • Gates on 8fc74827: 67 derived by dispatch-gates --commands, all exit 0. They ran over the full build of 834abfdb, with @objectstack/lint rebuilt for 8fc74827 and the teardown fixture commit fetched. --ran reconciles 67 accounted, 0 NOT-MEASURED, 0 unrun.
  • Changeset level: check-changeset-no-major with a pull_request event carrying this body exits 0.
  • Build and dogfood: full pnpm build (119 tasks), then pnpm --filter @objectstack/dogfood test under the verify lock (rerun on 8fc74827 after the lint rebuild): 435 files passed, 3 skipped; 3610 tests passed, 41 skipped; VERDICT command-exit 0.
  • Suites: @objectstack/lint 197 files / 8363 tests passed. @objectstack/metadata-protocol 324 files passed, 4 skipped (22538 tests), including the new CASES row (51/51 in that file).

CI runs the full set.


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV

…ts child object

The engine runs `summaryOperations.filter` as the where of an aggregate on
the child object, ANDed with the parent-FK match. A key the child lacks makes
every child write answer SummaryRecomputeError while the summary keeps a
stale value (measured on a real SqlDriver), and no authoring door judged the
keys: one field's parse cannot see another object's field map.

validateObjectFieldRefs gains the position: every filter key, walked by
walkFilterFieldKeys ($and / $or / $not groups, operator objects, relationship
paths), is resolved against the child that summaryOperations.object names,
with the family's skips and its "Did you mean" verdict.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV
@github-actions

github-actions Bot commented Nov 8, 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 list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 226 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 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 — 4 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 30e0bd160ee2afc1557746df0b6806625f37a408 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 30e0bd160ee2afc1557746df0b6806625f37a408

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 8, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract-review-claim: session_01FEkQ5mw7HFYn9o4LEGMQdK · head 834abfd · 2026-11-08T02:42Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 834abfdb7452180d9f566f593d3503652dc901bc
Local-runs: none
Scope: .changeset/31224-summary-filter-key-unknown.md, packages/lint/src/rule-explanations.ts, packages/lint/src/validate-object-field-refs.test.ts, packages/lint/src/validate-object-field-refs.ts, packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts + packages/lint/src/filter-walk.ts, packages/lint/src/object-graph.ts, packages/lint/src/system-fields.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/reference-integrity-suite.ts, packages/lint/src/runtime-gate.ts, packages/lint/src/data-model-rules.ts, packages/lint/src/validate-rollup-source-visibility.ts, packages/lint/src/index.ts, packages/spec/src/data/field.zod.ts, packages/spec/src/data/filter.zod.ts, packages/spec/src/data/search-fields.ts, packages/objectql/src/summary-aggregate.ts, packages/objectql/src/engine-summaries-and-expansion.ts, packages/objectql/src/filter-comparand-shape.ts, packages/objectql/src/relation-filter-lowering.ts, packages/objectql/src/engine-top-level.ts, packages/objectql/src/engine-judge-filter.test.ts, packages/objectql/src/engine-nested-relation-lowering.test.ts, packages/metadata-protocol/src/protocol-save.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/protocol-history-and-publish.ts, examples/app-showcase/src/data/objects/expense-report.object.ts, skills/objectstack-data/rules/field-types.md, content/docs/data-modeling/field-types.mdx, scripts/pm/clause2-line.mjs, scripts/pm/record-recognisers.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .claude/skills/pm-dispatch/SKILL.md, hotcrm src/sales/objects/account.object.ts

Review of PR #31337 (card #31224, item D) on the narrowing face: the PR body and the committed changeset both carry Clause-②: no (narrowing), and reviewFace maps a (narrowing) arm on any declaration text to this tier; no schema path and no governed path is in the five-file list. Read: the net diff origin/main...834abfdb (merge base 11c1326d, one commit, +145/-1), the card body and every comment (triage 6711648543, claim 6712039823, plan 6712129038, seat rulings 6712144718 and 6714195854, dev reports 6713825906 and 6716296533), the check-runs on this head, and the item-C records 6714019852 / 6714102343 / 6714550986 for shape only. Nothing was built, run or re-run; hotcrm was read through the GitHub contents API at its main tree 6961ab13.

① Derived judgments

What the hunk does, read against the code it calls — right as far as it refuses. The new block in validateObjectFieldRefs (validate-object-field-refs.ts, after the FIELD_NAME_SLOTS loop) reads field.summaryOperations, takes object as the child and, when filter is present, hands the filter to walkFilterFieldKeys (filter-walk.ts) and judges every emitted field through the rule's shared judge with against: child, verbatim: false. judge resolves through resolveFieldPath (object-graph.ts) over indexServedObjectGraph, so the three family skips hold exactly as at the other positions: a child the stack does not define (object-not-in-stack), a child with no readable field map (no-field-map) and a hop through a registry-injected column (injected-hop) are unknowable and silent (isUnjudgeable), a leaf that is an injected column (id, created_at, owner_id, organization_id, as injectedColumnsFor plans them per object) resolves ok, and a field a sibling package adds through objectExtensions is in the served graph (objectsAsServed). The walk reads the shapes the position admits: the spec's FilterConditionSchema (filter.zod.ts, a record intersected with $and / $or / $not) is a plain object, so the array and view-rule branches of the walk are dead here; $and / $or arms keep their index in the path, $not descends, an unrecognised $ key is skipped, an operator object is a leaf, and a nested object with no $ key is read as a relation hop. The engine's reading of the same value is buildSummaryIndex (engine-summaries-and-expansion.ts): a plain object is kept as SummaryDescriptor.filter, and aggregateSummaryValue (summary-aggregate.ts) runs engine.aggregate(child, { where: { $and: [fkMatch, filter] } }). So a key the child lacks is refused by lint where the engine would answer a recompute failure, with the same object. The verdict sentence is the family's (describeFieldPathVerdict): it names the key, the child object and the nearest child field, the path is objects[i].fields.F.summaryOperations.filter.KEY (group arms indexed), served item-relative at the door, and the hint names the cure and the object the key belongs to. No false refusal was found in any shape the engine serves: a child field, a child field with operators, a system column, an extension-added field, a key inside a group, and the nested one-level relation form all pass.

Doors. validateObjectFieldRefs is the one member of the reference-integrity suite that judges an object write (authoring-rules.ts, the #15254 paragraph; reference-integrity-suite.ts), so the refusal reaches os lint / os validate / os build, the active save (protocol-save.ts, assertRuntimeAuthoringRules at :1677 and runAuthoringGate at :1749), the per-item and package publish (protocol-history-and-publish.ts:950), and a draft keeps the finding in _diagnostics (the new CASES row in protocol.draft-publish-verdict.test.ts pins draft saves valid: false, publish refuses, package publish rolls back). The differential of the runtime gate (runtime-gate.ts) charges a finding located on the written item to the write, so a parent saved with a bad filter is refused and a stored sibling is not. The restore door was not traced separately here; the item-C record read it as a write through the save door, and no file of this diff changes that.

Census. Showcase: expense-report.object.ts defines both objects; the four filtered roll-ups (approved_amount on status, reimbursable_amount on billable, rejected_count on status, over_limit_count on amount) name fields showcase_expense_line declares at :170 / :173 / :176. The other example roll-ups (app-crm opportunity, showcase invoice and project, the CLI lint corpus) carry no filter. hotcrm main: 21 *.object.ts files, one with summaryOperations (src/sales/objects/account.object.ts, a sum over crm_account.annual_revenue with relationshipField, no filter). Docs: content/docs/data-modeling/field-types.mdx and protocol/objectql/types.mdx show filter: { type: 'signup' } on an engagement child, a child field. Nothing shipped is refused.

MUST-FIX: the tree pins and documents, as accepted, a relationship spelling the engine refuses at every recompute. The new test "passes child fields, operators, and a relationship path through the child" asserts validateObjectFieldRefs is silent on filter: { 'account.status': 'active' }; the changeset's Now bullet says "a relationship path is resolved through the child"; the module header says "nested relationship hops"; the os explain paragraph says "or a relationship path". Read against the engine: assertFilterIsMaterializable (filter-comparand-shape.ts:442-510) runs on the where of every verb including aggregate (engine-top-level.ts:884; engine-judge-filter.test.ts pins { 'account.name': 'Acme' } refused INVALID_FIELD / 400 on aggregate), its collector collectFilterFieldNames (:582-603) descends $and / $or / $not so the { $and: [fkMatch, filter] } that aggregateSummaryValue builds is walked, and a dotted key whose head is a relation field is refused with the words "No backend serves the path" and the prescription to nest the condition beneath the relation field. Since #20802 the engine serves the nested one-level form { account: { status: 'active' } } at where (relation-filter-lowering.ts, "Where. where, on every verb that takes one (find, findOne, count, aggregate, ...)") and still refuses the dotted spelling and any second level. The recompute catches the throw and records it (engine-summaries-and-expansion.ts:471-481), which the call sites surface as SummaryRecomputeError while the child row stays written and the summary stays stale: the card's defect, byte for byte, for a stack this PR passes at every door and now pins as a supported shape. walkFilterFieldKeys cannot tell the two spellings apart (conditionFieldKeys flattens the nested form to the same field and the same path as the dotted key), so the position needs a read of the authored keys of its own: refuse a dotted key (the engine's own dotted arm: key.includes('.'), descending the three combinators) with the engine's cure, keep the nested one-level form accepted, move the pin to the nested form, and reword the changeset bullet, the header and the explanation so a release-owned text does not teach the refused spelling. The direction of the narrowing is not in question, this is a false accept declared as a capability in the diff, not a false refusal.

Should-fix: a formula child field as a filter key. Lint resolves it as a declared field and stays silent; the engine refuses it on every verb (filter-comparand-shape.ts:515-550, isVirtualSearchField, SEARCH_VIRTUAL_TYPES = { formula } in search-fields.ts; summary and autonumber are stored and are fine), with the same stale-summary outcome. The leaf verdict carries meta.type, so the position can refuse formula leaves with the engine's denormalise cure. Same for a second relation level ({ account: { owner: { name } } }), which lint resolves as two hops and the engine refuses ("Forward, one level"). Both can land in the same parity pass as the must-fix.

Should-fix (minor): the block fires on a summaryOperations carried by a field whose type is not summary. buildSummaryIndex reads only type === 'summary' and lintSummaryRollups (data-model-rules.ts) guards on it; this block does not. It refuses dead metadata with a true sentence, so no capability is lost; guard on the type for consistency with the two readers.

Observations, not findings. A dotted path into a json child field is refused hop-not-relationship here as at every path-resolving position of the family; the SQL driver refuses it too, the memory and mongodb carve-out in classifyDottedFilterHead is the engine's deliberate pass-through and is not new to this position. A filter object spelled { field, operator } is read by the shared walk as a view rule; validate-rollup-source-visibility.ts already reads this position through the same walk, so the reading is the platform's, not this PR's.

② Semver level

  • Changeset @objectstack/lint: minor — right. AGENTS.md Post-Task Checklist step 3: a Metadata-protocol kind other than none takes at least minor on a package the diff moves. check-changeset-no-major.mjs level axis: narrowed must grade at least one moved package minor and must sit beside a (narrowing) arm; both hold, and Check Changeset concluded success on this head. BREAKING banner present; the single ADR-0087 marker reads not-required (no-migration-prescription) with its reason (no key added, removed or renamed; a refused key never filtered what the author meant, so there is no meaning-preserving rewrite), the disposition item C landed with; the body's "What an author does" names a fix of the metadata, not a consumer-code rewrite, the same shape check-adr-0087-registration.mjs admitted on 31224-flow-node-unreachable.md. Lint & Repo Gates, which carries that gate, concluded success.
  • Metadata-protocol: narrowed — right. A summary field an author could write is now refused at the object save and publish doors: "a value refused or a requirement tightened is narrowed" (step 3), as seat ruling 6714195854 states.
  • Clause-②: no (narrowing) — right, and the item-C reasoning is consistent with it, not against it. clause2-line.mjs reads the value as the answer to whether the card widens an accept set or the public surface. Item C answered yes because it added FLOW_NODE_UNREACHABLE to packages/lint/src/index.ts and a new RULE_EXPLANATIONS key for a new rule id. This diff touches neither: index.ts is not in the file list, OBJECT_FIELD_REF_UNKNOWN is the existing export (index.ts:614), and rule-explanations.ts gains one paragraph inside the existing OBJECT_FIELD_REF_UNKNOWN_EXPLANATION.paragraphs array, whose key object-field-ref-unknown already sits in RULE_EXPLANATIONS on main (:5931). A paragraph of an existing entry is prose, not a key; no export, no rule id, no explanations key is added, so no is the truthful value and (narrowing) the arm. Body and changeset agree. The must-fix above changes none of this: the parity fix adds no export either.
  • Skills-docs: none — ... — present in both, byte-equal, and the reason checks out. The pm-dispatch template (.claude/skills/pm-dispatch/SKILL.md:313) owes the body a third line for a (narrowing) arm; the body carries it line-initial. skills/objectstack-data/rules/field-types.md:132 names filter? without an example; the content/docs examples filter on a child field. Once the must-fix lands, the "relationship path" wording in the changeset and explanation is the only text that needs to move, and it is this PR's own.
  • The ! in fix(lint)!: — no mechanical effect, consistent with the BREAKING banner, as judged for item C.

③ Boundary flags

  • MUST-FIX — the dotted relationship spelling pinned as accepted (① above). A FAIL on its own.
  • Dev open_questions: none (6716296533). The dev's declaration deviation from the claim is the seat's own ruling (6714195854), followed exactly.
  • Dev flags carried, none blocking: the "measured consequence" probe and the "reverse check" were one-off and are not in the tree; the census numbers were re-derived above from the tree and from hotcrm and agree (4 / 0 / 0 unknown keys; 9 unfiltered example roll-ups). The dev's gate report says 66 derived gates exit 0 and check-changeset-no-major exit 0 with the body; not re-run here, CI is the confirmation.
  • Check-runs on 834abfdb at this read: success — Lint & Repo Gates (required) and every Lint · lane, Build Core (required), Dogfood Regression Gate (required), Governed Surface Queue Guard (required), Live E2E UI (required), Type Check · source gates, Type Check · debt ledger, Build UI Deps, Check Changeset, Check PR Size, Plan Test Core shards, PR-body guards, Auto Label, Flag docs affected by code changes, filter; still in progress, every one a required context or a lane of one — TypeScript Type Check (lanes Type Check · workspace, Type Check · consumer gates), Test Core (1–8/8), Test UI (1–16/16), Type Check UI, Build & E2E UI; skipped (opt-in or path-filtered) — Lint UI, Build Docs, Dogfood Verify CLI, UI Published Artifacts, Temporal Conformance, Packed-tarball smoke, 18.x upgrade harness, the schedule gate. No red on this head at this read. The verdict below does not wait on the in-progress ones: it is decided by the contract finding in ①.
  • Tier of this record: served at CONTRACT_REVIEW_TIER on the narrowing face, the tier reviewFace (scripts/pm/record-recognisers.mjs) owes for a (narrowing) arm on any declaration text; hits is empty on this file list, so the Scope floor is every path of the diff.
  • PR body (data, not judged): its gate, dogfood and suite readings are the dev's on 834abfdb; the body's claim "a relationship path is resolved through the child" is the sentence ① refutes for the dotted spelling.

④ Design judgment

Against the published spec on main: FieldSchema.summaryOperations.filter (field.zod.ts) admits any FilterConditionSchema and describes it as "a query where FilterCondition" over the child; the narrowing refuses, at lint, a key that condition could never run against the child it names, and takes no declared capability away at any consumer: every key the engine's where serves on the child (a declared or extension-added field, an injected column, an operator object, a group, the nested one-level relation form) still passes, and a child the stack does not define stays unjudged, the same install-order reasoning lintSummaryRollups records. Lint is the right altitude: the verdict needs the child's field map, which one field's Zod parse cannot see, and the rule already sits on every door an object write takes. The parity with the runtime is right in the refusing direction and wrong in the accepting one: the diff declares the dotted relationship spelling supported at this position while the engine refuses it at the one call that reads it (aggregate's where, through assertFilterIsMaterializable), which is the exact silent-stale-summary shape the card enforces against, written into a pinned test, the module header, the os explain entry and a release-owned changeset. That is the must-fix; the formula-leaf and second-level cases are the should-fixes beside it. With those closed the design holds: the narrowing makes the spec's own description of the key real, packages/spec is untouched, and not-required (no-migration-prescription) is the right ADR-0087 disposition.

Implemented-by: claude/project-thread-52y8an
Reviewed-by: session_01FEkQ5mw7HFYn9o4LEGMQdK

VERDICT: FAIL

…mpute query serves them

The recompute's where serves only the nested one-level relation form; it
refuses a dotted key, a field computed on read and a second relation level,
and each refusal fails every child write's recompute (measured on a real
SqlDriver). walkFilterFieldKeys flattens a dotted key and a nested condition
into one path, so the position now walks the authored keys itself: an unknown
key keeps the family's verdict; a dotted key is refused with the nested
spelling (relation head) or the denormalise cure, a formula key with the
denormalise cure, and beneath a relation an unknown, dotted, second-level or
empty condition is refused. The block is guarded on a `summary` field.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8fc74827539ea8075a67076d2f22786467681747
Local-runs: none
Scope: .changeset/31224-summary-filter-key-unknown.md, packages/lint/src/rule-explanations.ts, packages/lint/src/validate-object-field-refs.test.ts, packages/lint/src/validate-object-field-refs.ts, packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts + packages/lint/src/object-graph.ts, packages/lint/src/system-fields.ts, packages/lint/src/filter-walk.ts, packages/lint/src/index.ts, packages/spec/src/data/filter-dotted-head.ts, packages/spec/src/data/search-fields.ts, packages/spec/src/data/filter.zod.ts, packages/objectql/src/filter-comparand-shape.ts, packages/objectql/src/relation-filter-lowering.ts, packages/objectql/src/no-operator-object-door.ts, packages/objectql/src/number-comparand-declared-type-door.ts, packages/objectql/src/engine-top-level.ts, packages/objectql/src/summary-aggregate.ts, packages/objectql/src/engine-nested-relation-lowering.test.ts, packages/objectql/src/engine-judge-filter.test.ts, scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs

Delta review of PR #31337 (card #31224, item D) on the narrowing face, following the FAIL record 6716423527 on 834abfdb. The new head is one commit on top of it (8fc74827, "read a roll-up filter's keys as authored, the way the recompute query serves them"); the merge base with origin/main is still 11c1326d, the net diff is the same five files (+382/-1), and packages/spec, packages/objectql and packages/lint/src/index.ts are byte-identical to the merge base. The v2 PR body was read fresh. Read-only: nothing built, run or re-run. Findings of 6716423527 that this delta does not touch — the child-object binding, the three family skips, the doors (lint / validate / build, active save, publish, drafts kept in _diagnostics), the census (showcase 4 filtered roll-ups on child fields, hotcrm 0) — stand as written there and are not repeated.

① Derived judgments

The must-fix landed — right. The position no longer imports filter-walk.ts (validate-object-field-refs.ts imports classifyDottedFilterHead and isVirtualSearchField from @objectstack/spec/data instead; no walkFilterFieldKeys reference remains in the file). judgeSummaryFilter reads the authored keys itself: $and / $or arms (indexed) and a $not body are descended, any other $ key is an operator and ends the descent, and each remaining key is judged by its own spelling. The dotted spelling account.status is now refused with the hint Nest the condition beneath the relation field: { account: { status: VALUE } }. (pinned), and the accepted pin moved to the nested one-level form account: { status: 'active', industry: { $in: ['saas'] } } — the form the engine serves at where on every verb including aggregate (relation-filter-lowering.ts, #20802). The changeset Now bullets, the module header and the os explain paragraph no longer say a relationship path is resolved; each names the dotted spelling as refused with the nested cure. Verified by reading the three texts at this head.

Both should-fixes landed — right. A formula key is refused through isVirtualSearchField (SEARCH_VIRTUAL_TYPES = { formula }, search-fields.ts), at the top level and one level down, with the denormalise cure (pinned on is_big and account.score_band). The block is guarded on meta?.type === 'summary' (meta is the owner's graph slice for the field), matching buildSummaryIndex and lintSummaryRollups; pinned by "judges only a summary field".

Parity with the engine, verdict by verdict. The engine's reading of the recompute where ({ $and: [fkMatch, filter] }, summary-aggregate.ts) is two doors: assertFilterIsMaterializable (filter-comparand-shape.ts, run for aggregate at engine-top-level.ts:884, its collector descending $and / $or / $not) and, for a no-operator object beneath a relation column, admitRelationCondition (relation-filter-lowering.ts, handed the condition by the where walk in number-comparand-declared-type-door.ts, servesRelations: true).

  • Dotted key, relation head: engine INVALID_FIELD with the nested route; lint refuses with the nested route. Match.
  • Dotted key, virtual or scalar head: engine INVALID_FIELD with the denormalise remedy; lint classifyDottedFilterHead is not null and not relation, refused with the denormalise cure. Match. The classification is the engine's own function (filter-dotted-head.ts), so relation is exactly REFERENCE_VALUE_TYPES (lookup, master_detail, user, tree, single or multiple), and a lookup and a master_detail head read alike.
  • Dotted key, head the classifier leaves null (structured JSON, multiple, file, unreadable type): engine passes it through; lint continues. Match (silent both sides).
  • Dotted key, head the child does not declare: engine's door has nothing to classify (the name is the ingress gate's and the driver's); lint gives the family's unknown-name verdict on the head, with "Did you mean". The card's defect (a key the child lacks), refused; right.
  • formula key at the top level: engine INVALID_FIELD (isVirtualSearchField); lint refuses. Match. summary and autonumber are stored columns on both sides and pass.
  • Beneath a relation, read against admitRelationCondition: empty ({}) refused on both sides; a dotted-key refused on both; an undeclared-key refused on both; a second-level (a key that is a REFERENCE_VALUE_TYPES field holding a no-operator object) refused on both, lint spelling the same test (classifyDottedFilterHead(innerMeta) === 'relation' && isNoOperatorObject(value[inner])); the pins (statsu, parent.name, parent: { name }, {}) are the engine's own four cases in engine-nested-relation-lowering.test.ts ("keeps refusing ... a second level, a dotted key, an undeclared key, {}"). An unregistered-target is refused by the engine and left unjudged by lint (!graph.get(target) → continue): the family's object-not-in-stack skip, an under-refusal, never a false one.
  • Shapes the engine serves beneath a relation, checked for a NEW false refusal: an operator object on a related field (industry: { $in: [...] }) passes (the inner key is declared, its value has a $ key so the second-level test is false) — pinned; an operator object on the relation column itself ({ account: { $in: [...] } }, { account: { $exists: true } }) is not a no-operator object, so lint skips it as the engine does; a multiple: true relation head is relation on both sides and its nested condition is admitted on both (classifyDottedFilterHead tests REFERENCE_VALUE_TYPES before multiple); a platform-provisioned column beneath a relation (id, created_at, updated_at, the engine's PLATFORM_PROVISIONED_COLUMN_TYPES) is in lint's fieldOf through injected (the spec's resolveInjectedSystemColumns, the same plan the registry's applySystemFields injects from), so it passes; a related formula key is refused by lint and, on the engine side, by the related object's own find door when the lowering reads it (the engine runs the condition as a find on the related object, relation-filter-lowering.ts "As the caller"), so both refuse. $and / $or beneath a relation ({ account: { $or: [...] } }): the engine's admitRelationCondition reads $or as an undeclared key and refuses (undeclared-key; $-keys are not special-cased there, and the condition is handed on only when it is a no-operator object, which a $or key makes it not — either way not served); lint reads it as an unknown related key and refuses. Match. An empty $and: [] / $or: [] at any level: nothing to judge, lint silent, the engine's identities apply. No shape the engine serves is refused by the new code.

Observations, not findings. (1) A no-operator object beneath a non-relation child field ({ amount: { a: 1 } }, { meta: { a: 1 } }) is refused by the engine (#20546 / #20745) and left silent by lint, which judges only relation heads there: an under-refusal of the same class the card did not scope. (2) Lint's local isNoOperatorObject is isRec plus no $ key, which unlike the engine's would treat a Date instance beneath a relation as an empty condition; a Date is not a value JSON metadata can carry at this position, so no authored shape reaches it. (3) The v2 PR body's "What changes" item 1 still says "every key is walked by walkFilterFieldKeys (filter-walk.ts: ... nested relationship hops)", which the Verification section and the tree contradict — a body sentence, no push needed (③).

② Semver level

  • Changeset @objectstack/lint: minor — still right. The frontmatter level, the two line-initial declarations (Clause-②: no (narrowing), Metadata-protocol: narrowed), the Skills-docs: none — ... line, the BREAKING banner and the single ADR-0087 marker not-required (no-migration-prescription) are byte-identical to 834abfdb; the delta rewrites the Now bullet into the unknown-key verdict plus three refused shapes and adds an Accepted bullet. AGENTS.md Post-Task Checklist step 3 and the level axis of check-changeset-no-major.mjs are satisfied as before; Check Changeset concluded success on this head (run 42441011374), and the body-edit re-run (42445751685) was in progress at this read. The new bullets prescribe a metadata fix (nest the condition; denormalise a computed value), not a consumer-code rewrite, so the no-migration-prescription disposition stands as check-adr-0087-registration.mjs reads it; Lint & Repo Gates concluded success.
  • Metadata-protocol: narrowed — right, as before, and the delta narrows further in the same direction (more values refused at the same doors).
  • Clause-②: no (narrowing) — right. The delta adds two imports from @objectstack/spec/data and two module-private constants and functions; packages/lint/src/index.ts is untouched since the merge base, no rule id or RULE_EXPLANATIONS key is added (the explanation paragraph is edited inside the existing object-field-ref-unknown entry). Body and changeset agree.
  • Skills-docs: none — ... — present in body and changeset, byte-equal; the reason (skills and docs teach child-field keys, which the rule accepts) still holds, and the nested form the docs' FilterCondition example shows is now the accepted form.

③ Boundary flags

  • Must-fix from 6716423527 — closed in the tree (① above): dotted spelling refused with the nested cure, pin moved to the nested form, three texts reworded.
  • Should-fix, formula key and second relation level — closed (①). Should-fix, summary-type guard — closed (①).
  • Should-fix (PR body, no push): stale sentence in "What changes" item 1 still naming walkFilterFieldKeys and "nested relationship hops" as the reader (① observation 3). The Verification section already states the v2 reading; align item 1 with it.
  • Dev open_questions: none on the card for this head; the dev's account of the round (authored-key read, dotted / computed / scalar / unknown heads, nested form accepted, formula at two levels, four refusals beneath a relation, summary guard, texts reworded) was verified line by line against the tree above and holds.
  • Check-runs on 8fc74827 at this read: success — every required context: Lint & Repo Gates and all Lint · lanes, TypeScript Type Check and all Type Check · lanes, Test Core (1–8/8 and the aggregate), Build Core, Governed Surface Queue Guard, Build & E2E UI, Type Check UI, Test UI (1–16/16 and the aggregate), Live E2E UI, Dogfood Regression Gate; also Build UI Deps, Check Changeset (first run), Check PR Size, Plan Test Core shards, PR-body guards (both runs), Auto Label, Flag docs affected by code changes, filter. Skipped (opt-in or path-filtered) — Lint UI, Build Docs, Dogfood Verify CLI, UI Published Artifacts, Temporal Conformance, Packed-tarball smoke, 18.x upgrade harness, the schedule gate, and the Auto Label / Check PR Size legs of the body-edit re-run. In progress — the body-edit re-run of Check Changeset (42445751685) only. No red on this head; nothing to attribute.
  • Tier of this record: served at CONTRACT_REVIEW_TIER on the narrowing face, the tier reviewFace (scripts/pm/record-recognisers.mjs) owes for a (narrowing) arm on any declaration text; hits is empty on this file list, so the Scope floor is every path of the diff.
  • PR body (data, not judged): its Verification section reports 67 gates exit 0 on 8fc74827 (over the 834abfdb build with lint rebuilt), dogfood 3610 passed, lint 8363 passed, metadata-protocol 22538 passed, and a one-off probe in which { report: { name: 'r' } } sums while { 'report.name': 'r' } and a formula key answer SummaryRecomputeError. Not re-run here; CI on this head is the confirmation and is green.

④ Design judgment

Against the published spec on main (FieldSchema.summaryOperations.filter admits any FilterConditionSchema and describes it as a query where on the child) and the engine's own reading of that where at recompute: the narrowing now refuses exactly the shapes the recompute query refuses — a key the child lacks, the dotted relation spelling, a dotted path into a computed or scalar field, a computed field, and beneath a relation an empty condition, a dotted key, an undeclared key or a second level — and keeps every shape it serves: declared and extension-added fields, injected columns, operator objects, $and / $or / $not groups, and the nested one-level relation form with operators on related fields. The classification and the virtual predicate are imported from the spec rather than restated, and the beneath-relation tests are the four admitRelationCondition refuses, so the two readers cannot drift on the cases they share. No declared capability is taken from a consumer; the one gap left (a no-operator object beneath a scalar or JSON child field, which the engine refuses and lint does not judge) is an under-refusal outside the card's scope. Lint stays the right altitude; packages/spec and the runtime are untouched; not-required (no-migration-prescription) is the right ADR-0087 disposition. The design holds.

No must-fix open on this head.

Implemented-by: claude/project-thread-52y8an
Reviewed-by: session_01FEkQ5mw7HFYn9o4LEGMQdK

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK

维护者速读

  • 改了什么:validateObjectFieldRefs 新增一个字段级位置,检查 summaryOperations.filter 的每个键。这些键按子对象(summaryOperations.object)的字段表核对,不按拥有汇总的对象核对。有三种写法会被拒:点号键(account.status,提示改用嵌套写法)、点号计算或标量头(提示反范式化)、formula 键。嵌套一层的关系写法 { account: { status } } 可以通过。另附 os explain 段落和 @objectstack/lint minor changeset。
  • 为什么改:这是 Enforce-or-remove: four metadata shapes cloud's authoring rules warn about still parse clean (boolean required, open flow start config, orphan action nodes, roll-up filter keys) #31224 的 D 项。过去写错的过滤键要到子记录写入、重算汇总时才以 SummaryRecomputeError 失败,现在在 lint、validate、build、保存和发布时就会报出。
  • 影响面:只改 lint(5 个文件,+382/-1),收紧了元数据协议(narrowed)。普查结果:showcase 的 4 个带过滤的汇总都在子字段上,hotcrm 为 0,现有包不会被拒。
  • 风险与回滚:误拒的风险已由契约复审逐个判决对照引擎核过,引擎能执行的写法没有新增拒绝。回滚即 revert。
  • 先看:packages/lint/src/validate-object-field-refs.ts 的 judgeSummaryFilter。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/validate-object-field-refs.test.ts
  • 需要你做的:无,由席位落地。合并前你仍可否决。
  • 席位意见:ACCEPT。Fable 契约复审首轮 FAIL(6716423527):必须修的是点号键被拍平后误判为可用,应该修的是 formula 键和 summary 类型守卫。修复后 delta PASS(6717136546)。复审提出的正文过时句已改正。

席位核对

Verdict: ACCEPT · head 8fc74827539ea8075a67076d2f22786467681747 · card #31224 (item D) · claim 6712039823 (amended 6712144718, 6714195854) · report 6716296533

  1. Shape: draft, base main, branch restarted from 11c1326d after fix(lint)!: refuse a flow node no edge path from the start node reaches #31287 merged. Refs #31224 and no closing keyword; the card stays open for B1. 5 files, +382/-1.
  2. Premise measured: the review read the verdicts one by one against the engine's two doors, assertFilterIsMaterializable and admitRelationCondition. Every refusal has an engine counterpart, and no shape the engine serves is refused. The dev's one-off probe on SqlDriver + ObjectQL is reported in the body: the nested form sums, while the dotted form and a formula key answer SummaryRecomputeError.
  3. Gates: every check-run on this head is success or skipped by its own path filter. The two PR-body guards re-ran after the body patch. check-governed-merges --pr 31337: NOT governed, 383 changed lines.
  4. Accepted deviations:
    • Under-refusals the review named as observations stay out: a no-operator object beneath a non-relation child field, and an unregistered relation target, which is skipped under the family's object-not-in-stack rule. Neither creates a false refusal.
    • The stale "What changes" item 1 sentence (review ③) was corrected in the body by a signed patch. No push was needed.
  5. Out of scope and where it went: item B1 (the spec half) comes next on the same card, on a restarted branch. Item A is no-work and is named when the card closes. Nothing filed.
  6. Landing: Clause-②: no (narrowing) / Metadata-protocol: narrowed, minor on @objectstack/lint, ADR-0087 not-required (no-migration-prescription). CONTRACT_REVIEW_TIER on the narrowing face: FAIL 6716423527, then delta PASS 6717136546 on this head.

Ruling-ref: the maintainer, 2026-11-07T16:00Z: 「你有 fable ,车道契约复审可以自己做」; 2026-11-07T16:59Z: 「你应该使用 pm-dispatch 技能审核和落地pr」; standing landing authorisation 2026-10-25T11:35Z: 「按照 pm-dispatch 规则复核过的都授权」.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 8, 2026 04:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 8, 2026 04:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit 38fc529 Nov 8, 2026
72 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-52y8an branch November 8, 2026 04:49
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 size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant