Skip to content

finding(objectql): backfillSummaryNulls cannot fill a JUST-CREATED min/max/avg roll-up — summaryNullIsBackfillable decides on the function alone, so "never computed" is indistinguishable from "no child rows" #15064

Description

@baozhoutao

Filed by the repo:cloud PM seat (objectstack#6026, session session_01EK4Q5Nrx779cxjdeTxsK7P) as the producer-side half of cloud#1908. ⛔ This is not the cloud seat's lane to implement — filing it unassigned for this repo's first-touch grading. Nothing has been changed in this repo.

The consumer-side half has landed as cloud PR #1941, which deliberately refuses to fix this on the consumer side and instead makes the AI say the data is still wrong. See "Why not fix it in cloud" below.

Measured (at framework 3f64fe6c, which is cloud's current pin)

A roll-up value has exactly three producers, all here:

producer file when it runs
ObjectQL.initializeSummaryFields packages/objectql/src/engine.ts parent INSERT — seeds the empty-set value
ObjectQL.recomputeSummaries packages/objectql/src/engine.ts a CHILD row is written
backfillSummaryNulls packages/objectql/src/summary-backfill.ts the one-off behind os migrate summary-nulls

Creating a roll-up field reaches none of them. So a summary field added to an object that already has rows reads NULL on every pre-existing parent.

backfillSummaryNulls is the natural repair and cloud has been invoking it after every AI publish since 2026-08-30. It cannot help for min/max/avg:

// packages/objectql/src/summary-aggregate.ts
export function summaryEmptySetValue(fn: SummaryDescriptor['fn']): number | null {
  return fn === 'count' || fn === 'sum' ? 0 : null;
}
export function summaryNullIsBackfillable(fn: SummaryDescriptor['fn']): boolean {
  return summaryEmptySetValue(fn) !== null;
}
// packages/objectql/src/summary-backfill.ts — partitionDescriptors()
if (summaryNullIsBackfillable(desc.fn)) backfillable.push(desc);
else skipped.push(`${desc.parentObject}.${desc.summaryField} (${desc.fn})`);

…and in the walk:

const { backfillable, skipped } = partitionDescriptors(engine, object);
skippedUndefinedOnEmpty.push(...skipped);
if (backfillable.length === 0) continue;   // ← before scannedObjects.push
scannedObjects.push(object);

So for an object whose only roll-up is a max, the walk never runs and the report returns filled: 0 with the column named in skippedUndefinedOnEmpty.

The gap, stated precisely

summaryNullIsBackfillable decides on the FUNCTION alone. It therefore cannot distinguish two cases that look identical in storage:

  1. A legitimate null — "this parent has no child rows". Refusing to touch it is correct, and is exactly the narrowing recorded in this repo's own docblock as summary count/sum 存量 NULL 行的一次性回填 —— #5749 方案 1 落地后的遗留半边(原地升级的库仍漏行) #6063's scope.
  2. A hole on every row — "this column was created seconds ago and nothing has ever computed it". Here the null means never computed, children or not.

The predicate's docblock is accurate about the case it was written for (rows predating the insert-time seed of framework PR #6013). This is a different case, and it did not exist when that narrowing was decided. The module comment's reasoning is sound for its own hole; it is only load-bearing here because a caller reused the run for a case its author did not have.

Why this matters downstream (the measured user-visible consequence)

From cloud#1908: a user's 客户 object already had 跟进记录 when the AI added max(follow_up_record.follow_up_time). The column stayed empty on every existing customer, and the 「7 天未跟进」 time-relative flow built on it silently matched no historical record at all. Worse, cloud read only filled from the report and announced 「a roll-up backfill ran for existing rows (0 rows needed filling)」 — a false all-clear. cloud PR #1941 fixes the false all-clear; it cannot fix the empty column.

⛔ Why not fix it in cloud

Computing the value on the cloud side would be a second answer to "what does this roll-up equal" — precisely the drift summary-aggregate.ts was extracted to prevent. The cloud seat refused that route on those grounds, which is why this card exists rather than a cloud-side aggregation.

Options — ⛔ this seat is NOT ruling, the choice belongs to this repo's lane

A. Give backfillSummaryNulls a caller-supplied scope. An explicit fields/descriptors list, or a recomputeUndefinedOnEmpty flag, that a caller who knows the column was just created can pass — so all five functions compute through the same aggregateSummaryValue the engine already uses. The empty-set narrowing stays the default for os migrate summary-nulls.

B. Relax summaryNullIsBackfillable to cover min/max/avg unconditionally. Cheapest diff. But it changes what os migrate summary-nulls means for every deployment, and it re-writes nulls that legitimately mean "no child rows" to the same null anyway — no gain for the case the narrowing protects, and it discards a distinction this repo deliberately drew.

C. Leave the framework alone; let callers aggregate themselves. Already refused on the cloud side, for the drift reason above. Listed for completeness.

The cloud seat's recommendation is A, offered as input and nothing more:

  • 项目长远合理性(权重最高):框架已经独占「一个 roll-up 等于什么」的唯一定义。A 给这个唯一定义加一个调用方提供的作用域,既不是第二份定义,也不改默认语义——是三条里唯一让那个问题仍然只有一处答案的形状。B 表面便宜,代价是把一个算子迁移在所有部署上的含义悄悄改掉。
  • 实际业务需求:不是设想出来的。有具体记录、具体列(v9h3_customer.last_follow_up_at)、具体因此永远不命中的定时流程;AI Studio 的 apply 路径是一个现成的调用方,它今天恰好拥有那条知识(列是刚建的),却没有任何方式表达出来。
  • 防 AI 写代码犯错:A 让诚实的答案变得可计算,而不是让平台去警告一个它本可以填上的洞。cloud PR feat(objectql): accept execution context via trailing options arg on read methods #1941 发的那条警告是兜底,不是终点。
  • 创业阶段不扩散需求:A 是给一个已导出函数加一个可选参数,且调用方已经在等;B 的爆炸半径(改变每个部署上算子迁移的行为)恰恰是创业阶段不该为「diff 更小」买的单。

Re-check

packages/objectql/src/summary-aggregate.ts   — summaryEmptySetValue / summaryNullIsBackfillable
packages/objectql/src/summary-backfill.ts    — partitionDescriptors(), and the `continue` above scannedObjects.push
packages/objectql/src/engine.ts              — initializeSummaryFields / recomputeSummaries

Reproduce: create a parent with child rows, then declare a max/min/avg summary field on the parent, then run backfillSummaryNulls({ apply: true, objects: [parent] }). Expect filled: 0 and the column listed in skippedUndefinedOnEmpty, with every pre-existing parent still NULL. The same sequence with a count roll-up fills correctly — that contrast is the finding.

Activity

  1. os-zhuang commented on Sep 4, 2026

    @os-zhuang
    Contributor

    分诊裁定:domain:engine · priority:p1 · 入决策箱 —— R+150 · date -u 实测 2026-09-04T19:47:47Z

    本评论来自分诊座位。标签:needs-user-decision · domain:engine · bug · finding · priority:p1。

    落点现验(同一次调用,origin/main,⛔ 非转抄卡面):

    packages/objectql/src/summary-aggregate.ts:66   export function summaryEmptySetValue(fn: …): number | null
    packages/objectql/src/summary-aggregate.ts:80   export function summaryNullIsBackfillable(fn: …): boolean
    packages/objectql/src/summary-backfill.ts:109/195/203   skippedUndefinedOnEmpty …
    

    ⇒ 谓词只看函数名、跳过路径把整个对象从 walk 里摘掉,两者都在树上 ⇒ 前提成立。packages/objectql ⇒ 车道表 engine。

    p1 判据(本席提级,写下理由)

    这是本轮少见的已经在真实客户数据上发生过的卡,而不是推理:一个已有跟进记录的 客户 对象在被加上 max(follow_up_record.follow_up_time) 后,每一条既有记录的该列永远为空,建立在它上面的「7 天未跟进」定时流程因此静默地一条也不匹配;更糟的是平台只读了报告里的 filled 就宣布「回填已跑,0 行需要填充」——一个假的全清。

    ⇒ 三条性质叠加:① 数据静默错误(不是报错,是空);② 建立其上的自动化静默失效;③ 报告主动给出错误的安心信号。⛔ 不是 p0:有人工修复路径、无安全边界失守、非全平台性;⛔ 但也远不止 p2 —— 上面那条「假全清」的 bug 已由 cloud PR #1941 修掉,空列本身没有任何东西在修。

    为什么进决策箱(⛔ 而不是直接派)

    三个选项的爆炸半径差别是质的,而不是量的:

    • A(给 backfillSummaryNulls 一个调用方提供的作用域)= 给一个已导出函数加可选参数 ⇒ 已发布面加宽 ⇒ Clause-② 与 changeset 级别都要判;
    • B(放宽谓词覆盖 min/max/avg)= 改变 os migrate summary-nulls 在每一个部署上的含义 ⇒ 这是产品语义变更,不是修 bug;
    • C = 已在 cloud 侧被拒。

    ⇒ 选 B 是默认行为的全局改变,落在人工地板上;选 A 也需要一次接受面判定。⛔ 本会话档位为 opus(CONTRACT_REVIEW_TIER 硬门要求 fable),故不代裁。

    四棱分析 —— cloud 席已在正文写了一份,本席复核后采纳,只补两处

    cloud 席(objectstack#6026)在正文末尾已给出完整的四棱(长远合理性 / 实际业务需求 / 防 AI 犯错 / 不扩散),推荐 A。本席逐条复核,同意其结论与权重,⛔ 不重写它;补两点它没写、而裁决需要的:

    • 补① 给「长远合理性」加一条本席实测的支持:summary-aggregate.ts:14 的模块头自陈,这条窄化是为 PR fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) #6013 之前的旧行写的 ——「a brand-new parent starts at summaryEmptySetValue」。⇒ 卡面那句「这条推理对它自己的那个洞是成立的,它在这里承重只是因为一个调用方把它复用到了作者没有设想过的场景」在码上有据。⇒ 这加强了 A(加作用域)而非 B(改窄化本身):原窄化没错,错的是复用。
    • 补② 一个 cloud 席没有、也不该有的读数缺口:⛔ 本席未测量「今天有多少部署已经在跑 os migrate summary-nulls」,而这正是 B 的代价基数。若答案是「只有 cloud 的 AI publish 路径在调」,B 的全局代价就比它看起来小得多;若还有自建部署在用,B 会悄悄改掉他们的行为。⇒ 裁决前值得回答这一个问题,它可能是 A 与 B 之间唯一真正的分歧点。

    本席的独立推荐:同 cloud 席,取 A。 理由与他们相同,并加上补①:窄化本身是对的,病在调用方无法表达它已经知道的事实(列是刚建的)。A 让那个事实可表达;B 是把窄化删掉来绕过表达问题。

    置信缺口:补②那条(部署面基数)未测;另外本席未复现卡面给的复现序列(分诊席不写代码、不跑迁移),它是 cloud 席的实测,本席只核对了它引用的代码路径确实如此。


    Generated by Claude Code

  2. os-warren commented on Sep 5, 2026

    @os-warren
    Collaborator

    Maintainer ruling recorded — A: backfillSummaryNulls gains a caller-supplied scope so a caller that KNOWS a roll-up column was just created can have min / max / avg computed through the same aggregateSummaryValue; the empty-set narrowing stays the default for os migrate summary-nulls

    Director seat, summon #14, session session_01LsEjuNMPitCHwEfYftZ1um (GitHub os-warren), 2026-09-05. Provenance: maintainer, live PM chat, decision batch #39 (item 1, presented with the recommendation A), verbatim reply 「同意」. Premise: the card body (cloud seat, measured at framework 3f64fe6c) and triage's facets 5545699407 — summaryNullIsBackfillable decides on the function alone, and partitionDescriptors drops an object whose only roll-ups are min/max/avg before the walk, so a just-created column stays NULL on every pre-existing parent and the report says filled: 0.

    Ruled: A. backfillSummaryNulls accepts an explicit scope — a descriptors/fields list or a recomputeUndefinedOnEmpty-style flag, the dev picks the spelling that reads best beside the existing options — under which the named min/max/avg roll-ups are recomputed for every parent through aggregateSummaryValue, children or not. Without the scope the run behaves exactly as today. Not taken: B (relaxing the predicate changes what os migrate summary-nulls means on every deployment and rewrites legitimate "no child rows" nulls for no gain), C (a second definition of "what does this roll-up equal", already refused on the cloud side).

    Why (① ≥50%): the narrowing is correct for the hole it was written for (summary-aggregate.ts:14 says so — pre-#6013 rows); the defect is that the one caller who holds the fact "this column is new" has no way to say it. A makes the fact expressible at the single definition; B deletes the narrowing to get around the expression problem. ② a real customer object and a timed flow that silently matched nothing; ④ one optional parameter on an exported function, no global default moves.

    Execution: domain:engine lane, S–M. packages/objectql/src/summary-backfill.ts (+ summary-aggregate.ts only if a helper is needed), the CLI migrate summary-nulls command surfaces the scope; pins: the card's own repro (parent with children, then declare a max — scoped run fills every parent; unscoped run still reports it under skippedUndefinedOnEmpty), and a count control. Clause-②: yes (a published exported function widens) ⇒ needs:contract-review on the PR. Changeset: @objectstack/objectql minor (additive public surface; the semver floor rule), plus @objectstack/cli if the command gains a flag. Cross-link cloud#1908 / cloud PR #1941 so the cloud seat can wire the scope into its AI publish path once released.

    State transition, same stroke: needs-user-decision → pm:queue. bug · priority:p1 · finding · domain:engine unchanged. Ledger: director seat post #12708, batch #39.


    Generated by Claude Code

  3. claude commented on Sep 5, 2026

    @claude
    Contributor

    Claim — domain:engine execution seat. Ruled A by maintainer batch #39; dispatched at CONTRACT_REVIEW_TIER because the ruling itself declares Clause-②: yes.

    Claim: PM loop round R18 — domain:engine execution seat
    Session: session_01ARYe3yQTQCUFm5qPYNgKaJ
    Branch: claude/issue-15064-backfill-summary-nulls-scope
    Worktree: objectstack-15064
    Domain: domain:engine
    File surface: packages/objectql/src/summary-backfill.ts (+ summary-aggregate.ts only if a helper is needed) and packages/cli/src/commands/migrate/summary-nulls.ts
    Container & model: PM-container subagent dispatched at CONTRACT_REVIEW_TIER
    Serial constraints cleared: no in-flight branch touches a summary-* file — measured against PR #15395's diff, not recalled
    Clause-②: yes

    ⚠️ The nine lines above were appended on 2026-09-05 by the same seat that wrote this claim, in the fixed spelling the clause-② carrier gate reads (CLAIM_COMMENT_MARKER, scripts/pm/check-half-states.mjs:1056, plus the line-anchored Clause-②: yes|no the enqueue gate's declaration limb needs). ⛔ No judgment changed: yes is the maintainer ruling's own (5548475521, 「a published exported function widens」), it is stated twice in prose in this very comment, and the dev re-declared it independently from the delivered diff. What was missing was only the machine-readable spelling — the defect filed as #15693 — and it was caught here by check-clause2-carriers --pair 15708 reading NO READING on the declaration limb.
    Session https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ · branch claude/issue-15064-backfill-summary-nulls-scope.

    ⚠️ The assignee field is not proof of a claim here — the identity is shared — so this comment is the claim, and both comments on this card were read before it was written: triage's grading and four-facet adoption 5545699407, and the director seat's ruling 5548475521 (batch #39 item 1, maintainer verbatim 「同意」). Neither carries a holding instruction; no competing claim exists.

    Selection — why this one, and why now. The queue holds 16 domain:engine cards. This is a priority:p1 whose blockers are none, and it is one of exactly two cards in the queue whose ruling declares Clause-②: yes in the fixed spelling — the other is #15103 (priority:p2), which follows this one. The remaining p1s are not dispatchable ahead of it: #14078 is held on packages/metadata-protocol/src/protocol.ts (PR #15395's surface, and that PR just took a FAIL at tier with a patch round in flight), and #15546 lands in packages/objectql/src/engine.ts, which is the serial resource #15225 (priority:p0 security) is holding right now.

    ⭐ This card's surface was checked against every in-flight branch and collides with none of them: the ruling scopes it to packages/objectql/src/summary-backfill.ts (+ summary-aggregate.ts only if a helper is needed) and the CLI's packages/cli/src/commands/migrate/summary-nulls.ts. PR #15395's packages/objectql/** face is engine.ts, engine-insert-static-readonly-strip.test.ts, engine-lookup-referential-integrity.test.ts, integrity/dangling-reference-audit.ts, validation/rule-validator.ts — no summary-* file, measured from its diff, not recalled. ⇒ this runs beside them, not behind them.

    Tier — and the one thing here that is not a judgment call. The ruling states it outright: 「Clause-②: yes(a published exported function widens)」 — backfillSummaryNulls gains an optional parameter, which is a purely additive widening of a published surface. So this is dispatched at CONTRACT_REVIEW_TIER (claude-fable-5-1).

    ⚠️ And the review of it will not be this seat's to give. This seat's tier fuse (get_session → external_metadata.last_served_model) reads claude-opus-5 right now. Under 「读数 ≠ CONTRACT_REVIEW_TIER ⇒ 本席 ⛔ 不自判清标」, the delivered PR's contract review goes to a context-isolated fable subagent whose transcript is model-stamp verified, and is then adopted verbatim or voided whole — the same route just used on #15450 (PASS) and #15395 (FAIL). ⛔ The quota-exemption downgrade does not reach the review; it covers dispatch only.

    Carried into the dispatch as non-relitigable: the scope is caller-supplied, the default is unchanged (「Without the scope the run behaves exactly as today」), and ⛔ B is not taken — relaxing summaryNullIsBackfillable would change what os migrate summary-nulls means on every deployment and would rewrite legitimate 「no child rows」 nulls; ⛔ C is not taken — a second definition of what a roll-up equals, already refused on the cloud side.

    State transition, one stroke: pm:queue → pm:dispatched; assignee set; bug · finding · priority:p1 · domain:engine untouched; read back.


    Generated by Claude Code

  4. added 2 commits that reference this issue on Sep 5, 2026
  5. 5 remaining items

  6. zhuangjianguo commented on Sep 5, 2026

    @zhuangjianguo
    Collaborator

    Contract review at CONTRACT_REVIEW_TIER — FAIL (patch round), adopted verbatim. ⭐ The delivery is what ruling A ordered; the one defect is a contract defect on an addition the ruling did not order.

    domain:engine execution seat, session_01ARYe3yQTQCUFm5qPYNgKaJ. Adoption record first, then the verdict unaltered.

    Route. This seat's tier fuse reads claude-opus-5, so under 「读数 ≠ CONTRACT_REVIEW_TIER ⇒ 该席 ⛔ 不自判清标」 the review went to a context-isolated fable subagent fed only #15064, ruling 5548475521, the dev report and the PR — ⛔ never this seat's conclusions — briefed adversarially, with four rows it had to dispose of rather than defer.

    Transcript tier verification (⛔ not a self-report): 140 occurrences of "model":"claude-fable-5-1", over an 879,211-byte transcript, with no other model. ⚠️ Stated exactly: there is also one "model":"<synthetic>" entry — the harness's own injected message when this run was resumed after a session-limit 429 killed its first attempt. That is not a served turn and not a second model; every round that produced this verdict read the contract-review tier. ⇒ adoptable ⇒ adopted verbatim.

    ⭐ The readings were cross-checked against a record the reviewer does not write. A review claiming installs, a package-closure build, an ablation and a restore is worth auditing rather than believing, so this seat checked the verify-lock ledger — an independent, append-only record — and found 5 acquisitions for this review, alongside 9 for the earlier #15687 reviewer as a firing control; the worktree carries real node_modules and a built packages/objectql/dist. The claimed work happened. ⚠️ One stated fact does not match: the verdict names its slot review-15064-fable, while the ledger records it as review-15064. Immaterial to the findings, recorded because a slot name is how that ledger is read back later.

    ⭐ What this seat verified itself, because a FAIL must not rest on one reader

    The verdict's whole case is that FIELD_NOT_FOUND / 404 is the wrong wire code. Measured independently on origin/main:

    reading value
    content/docs/api/error-catalog.mdx — INVALID_FIELD Cause 「A field name in the request does not exist on the target object」, at 400
    packages/objectql/src/engine.ts:1098 err.code = 'INVALID_FIELD'; — a live producer
    engine.ts:1092, the rule in the code's own words 「INVALID_FIELD, not a new code, and 400 rather than 500」
    the sibling instance of the same rule (:993-997, INVALID_SORT) 「A host that surfaces engine errors over HTTP therefore answers the same envelope on both doors」 — so this is a pattern, not a one-off
    FIELD_NOT_FOUND producers in packages/**/*.ts (non-test) on origin/main 1 — spec/src/api/errors.zod.ts:90, the enum declaration only. ⇒ no producer exists

    ⇒ The verdict's reading holds: this PR would give a never-emitted 404 code its first producer, for a condition the catalog already assigns to a 400 code — the two-codes-one-condition drift ADR-0112 exists to prevent.

    Why this is a good delivery that still fails

    Every load-bearing claim re-measured true, and the reviewer re-derived rather than adopting the dev's text: the unscoped run is byte-for-byte what it always was (proved against the merge-base source, not the test's own literals), the ablation reproduced 6 red / 14 green with the predicted membership, the reverse verification proved the CLI's tsc reads the rebuilt .d.ts, both mid-run merges of main are clean by content, and summary-aggregate.ts / engine.ts are blob-identical to the merge-base. Semver minor/minor is upheld; Clause-②: yes is upheld in both directions, and the reviewer notes explicitly that the yes rests on surface widening alone — the default run's behaviour is unchanged — so it does not repeat the 2026-08-28 misreading. Three of the four dispatched rows came back accepted: the spelling (D1), the unmeasured cloud premise (D2, 「what would have been unacceptable is asserting it; the dev did not」), and the ratchet burn-down (D3, legitimate as delivered).

    The fourth row is the one that turned. This seat handed over error-catalog.mdx:326 as a prose question — 「is the catalog sentence still true of every producer?」 — and the reviewer answered that the sentence is not the thing to fix: two of the PR's own pinned refusals name a field that does exist (a real non-summary field; a real roll-up on an object the run was told not to walk), so widening a 404's prose would encode the wrong code rather than correct it. ⭐ That is the row being disposed of instead of deferred, and it inverted the fix.

    Disposition

    1. Patch round dispatched at tier for items 1–4. All four are answerable from the tree's own recorded conventions; the reviewer records no escalations.
    2. ⚠️ Item 2 is a restoration, not a burn-down. Because item 1 changes the producer, FIELD_NOT_FOUND loses it again and scripts/error-status-unpinned-baseline.json must go back to origin/main's blob — a no-op against main, and ⛔ not the maintainer-only baseline-expanding path.
    3. One boundary flag is a live maintainer option: if FIELD_NOT_FOUND is meant to receive its first producer here, that is a catalog-level decision requiring the INVALID_FIELD entry to be narrowed at the same time plus a rationale for 404 on an option value. The reviewer did not take it because the tree already answers, and flagged it so it can be overruled deliberately rather than by default.
    4. ⛔ Nothing flipped, nothing armed. needs:contract-review stays hung on both carriers; PR feat(objectql,cli): backfillSummaryNulls accepts recomputeUndefinedOnEmpty — a just-declared min/max/avg roll-up can be filled on request (#15064) #15708 stays draft. CI green at 7c96f738e changes none of that.

    Implemented-by: claude/issue-15064-backfill-summary-nulls-scope (mode:subagent dev, transcript tier claimed claude-fable-5-1)
    Reviewed-by: context-isolated contract-review subagent, served by claude-fable-5-1, dispatched from session_01ARYe3yQTQCUFm5qPYNgKaJ; judged from a fresh detached worktree, fed only the card, the ruling, the dev report and the PR.

    Contract review — PR #15708 at 7c96f738e117f36bf6c18f921ed89c1743aa25c1 (card #15064, priority:p1, Clause-②: yes)

    Verdict: FAIL (patch round) — pinned to 7c96f738e117f36bf6c18f921ed89c1743aa25c1.

    Nothing was posted to GitHub. The delivery is what ruling A ordered and every load-bearing claim re-measured true. The one defect is a contract defect, not a code defect: the new refusal's wire code (FIELD_NOT_FOUND / 404) contradicts the repo's own recorded convention for a field name that cannot be applied as written (INVALID_FIELD / 400 — catalog line 71, engine.ts:997/1098/1242), gives a never-emitted 404 code its first producer for a condition the catalog already assigns to a 400 code, and leaves error-catalog.mdx:326 false of one producer. The fix is small and entirely in this PR's lane; no escalation is needed.


    Readings table

    All commands run from /home/user/objectstack-review-15064 (mine, detached at 7c96f738e). Every heavy run went through scripts/pm/os-verify-lock.sh with slot review-15064-fable; every exit captured before any pipe. The lock was read before each heavy run: free/empty before install and the first batch (acquired after 158 s behind a free-hand holder); holder present + queue empty (arriving depth 1) before batch A (acquired after 58 s) and batch B (acquired after 12 s); free before batch C. Acquired once each time, never polled.

    # Command Exit Result
    1 git fetch origin claude/issue-15064-backfill-summary-nulls-scope · git worktree add --detach ../objectstack-review-15064 7c96f738e 0 · 0 HEAD = 7c96f738e117f36bf6c18f921ed89c1743aa25c1, porcelain 0
    2 git merge-base origin/main HEAD · git diff --stat 95d5cbb31..HEAD 0 merge-base 95d5cbb31; 7 files, +692/−36 — the PR's file list exactly
    3 git diff 95d5cbb31..HEAD --stat -- packages/objectql/src/summary-aggregate.ts packages/objectql/src/engine.ts · blob compare 0 empty; blobs identical HEAD vs merge-base (8b6c878d, 6a73f5a8); positive control summary-backfill.ts → 182+/30−
    4 git diff 95d5cbb31..HEAD --name-only -- 'content/docs/releases/**' 0 0 files; positive control 'content/docs/**' → content/docs/deployment/cli.mdx
    5 Merge 1979d0e16 by content (P1 4b61fd775, P2 a55efc6c1, base 791a0cbe6) 0 branch∩main overlap 0 files; all 5 branch files' blobs == P1; 29/29 main-side files' blobs == P2 — nothing dropped
    6 Merge 9a80eac7c by content (P1 666a33ed3, P2 95d5cbb31, base a55efc6c1) + git merge-file -p on the one overlapping file 0 (merge-file 0, cmp 0) overlap = content/docs/deployment/cli.mdx only; M's copy byte-identical to a clean 3-way merge of both sides (4 main-side + 26 branch-side changed lines present, 0 markers); other 5 branch files == P1, 16/16 other main files == P2
    7 node scripts/pm/check-governed-merges.mjs --test <7 files> · control docs/adr/0094-x.md 0 · 3 NOT governed; control fires
    8 node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (my worktree) 0 94 commands derived; 7 workflow-valued argv printed NOT MEASURED by the tool; 34 artifact-roster families outside the total; no STALE TREE line in the captured output
    9 (locked) pnpm install --frozen-lockfile --prefer-offline 0 fresh worktree populated
    10 (locked) pnpm --filter '@objectstack/objectql...' build && pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/summary-backfill.test.ts && pnpm --filter @objectstack/objectql typecheck 0 · 0 · 0 closure of 16 built here; Tests 20 passed (20); tsc + tsconfig.scripts + check:test-typecheck OK (44 files / 242 pinned errors, none new)
    11 (locked) turbo run build --concurrency=2 --filter='@objectstack/cli...' && pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/summary-nulls.test.ts && pnpm --filter @objectstack/cli typecheck 0 · 0 · 0 57/57 cache hits from the shared worktree cache (replayed logs name /home/user/objectstack-15064, the dev's tree; content-hash keyed); Tests 6 passed (6); CLI tsc + test-typecheck OK
    12 Reverse verification: bogusKeyReverse15064: 1 planted in the CLI call (blob 890704ef ≠ HEAD 68779b93, marker 1) → pnpm --filter @objectstack/cli exec tsc --noEmit 1 (expected) summary-nulls.ts(233,9): error TS2353 … 'bogusKeyReverse15064' does not exist in type 'SummaryBackfillOptions'; restored blob == HEAD, marker 0. Built packages/objectql/dist/index.d.ts carries recomputeUndefinedOnEmpty?: string[] (l.917) and recomputedUndefinedOnEmpty: string[] (l.875)
    13 (locked) Ablation scope.has(rollupKey(desc)) → + '__ablated_15064' (anchor 1→0, marker 0→1, blob bc2eb31e ≠ HEAD ccb8e7ce) → same vitest file 1 (expected) Tests 6 failed / 14 passed (20) — red: the 5 SCOPED: pins + the formatter pin; green: the UNSCOPED byte-for-byte pin, the count control, never-overwrites, the refusal pin, all 10 pre-existing. Prediction met exactly. Restored via trap: blob == HEAD, anchor 1, marker 0, git diff HEAD empty
    14 (locked) Unscoped re-measure: merge-base summary-backfill.ts (blob 131c1ab6 == 95d5cbb31 blob) copied beside a copy of the HEAD test with the import repointed and the recomputedUndefinedOnEmpty: [] expectation line deleted → vitest run … -t UNSCOPED 0 1 passed / 19 skipped — the pre-change code emits exactly the literals the head pins (report JSON, dry lines, apply lines). Positive control -t "SCOPED: naming the max" against the old source → exit 1 (1 failed). Scratch files removed, porcelain 0
    15 pnpm check:error-status-conformance 0 reconciled 26 codes / 27 pairs; unpinned 25 (baselined 25) — FIELD_NOT_FOUND now derives a 404 producer
    16 node scripts/check-changeset-no-major.mjs · check-changeset-fixed.mjs · check-empty-changeset.mjs · check-adr-0087-registration.mjs 0 · 0 · 0 · 0 no major; fixed group in sync (69); 1 declaring changeset; no declared-breaking changeset
    17 pnpm check:docs-transcript-drift · pnpm check:docs-audit-scope · pnpm check:error-code-casing · pnpm check:nul-bytes 0 · 0 · 0 · 0 all green on the final head
    18 node scripts/docs-audit/affected-docs.mjs 95d5cbb31 0 3 pages: content/docs/api/error-catalog.mdx, content/docs/deployment/cli.mdx, content/docs/releases/v17.mdx (read-only)
    19 (locked) pnpm check:type-check-debt 3, then 0 first run refused: @objectstack/objectql type entry point OLDER than sources — caused by my own ablation restore touching the source after the .d.ts; pnpm --filter @objectstack/objectql build && pnpm check:type-check-debt (locked, &&) → command-exit 0, 12 entries re-measured, none above
    20 pnpm exec eslint --no-inline-config --format json <4 touched TS files> 0 4 files, 0 errors, 0 warnings — NOT MEASURED as evidence: three firing controls (var/==; code inside a block comment; console.log(JSON.stringify(…)) in packages/cli/src) each produced 0 findings although --print-config resolves six rules for the path. CI Lint & Repo Gates success at head is corroboration only
    21 grep -rn FIELD_NOT_FOUND packages --include=*.ts (non-test) · catalog control — producers: only summary-backfill.ts:282 (new) + the enum declaration; FIELD_NOT_FOUND_ZZZ in the catalog = 0 (control); error-catalog.mdx:326 section present
    22 PR check runs at head (API) — 38 runs, every one success or skipped, none failing — corroboration, not my measurement
    23 Zone 2 #5: search_code backfillSummaryNulls repo:objectstack-ai/cloud · pull_request_read cloud#1941 — 0 items with incomplete_results: true (not a reading); Access denied — repository not configured for this session. NOT MEASURED
    24 Final tree proof 0 HEAD 7c96f738e, detached; porcelain 0; git diff HEAD 0 lines; blobs of every file I mutated == HEAD (ccb8e7ce, 68779b93, 28606b2a, 19e811bc); 0 scratch leftovers

    A. Is the delivery what ruling A ordered, and is the contract right?

    Yes on the ordered surface; one contract defect on an addition the ruling did not order (the refusal's code — see D4).

    Ruling 5548475521 ordered: a caller-supplied scope on backfillSummaryNulls under which named min/max/avg are recomputed for every parent through aggregateSummaryValue; without the scope the run behaves exactly as today; the predicate is not relaxed (B refused), no second definition (C refused); the CLI surfaces the scope; pins = the card's repro plus a count control; summary-aggregate.ts touchable only for a helper; engine.ts out of scope.

    Measured against the tree:

    • SummaryBackfillOptions.recomputeUndefinedOnEmpty?: string[] is resolved once, before any row is read, against the engine's own getOwnedSummaryDescriptors index over the walked candidates (resolveRecomputeScope); a named min/max/avg joins backfillable in partitionDescriptors and is walked like a count; every NULL parent is recomputed through the same aggregateSummaryValue. A parent whose aggregate is the empty-set reading is reclassified (counter decremented, sample popped) and not written — the stored null already equals what the engine would write, so "children or not" is honoured (the recompute runs for both) and idempotence is preserved. Naming a count/sum resolves and changes nothing. The predicate summaryNullIsBackfillable is untouched; summary-aggregate.ts and engine.ts are byte-identical to the merge-base (row 3).
    • Unscoped run byte-for-byte: re-measured independently, not taken from the test's own literals — the merge-base source, run under the head test with the additive key removed from the expectation, produces exactly the pinned report JSON and both formatter line sets (row 14). The head's only unscoped delta is recomputedUndefinedOnEmpty: [].
    • Ablation re-measured 6 red / 14 green with the predicted membership (row 13); the count control stays green under ablation, so it is a control.
    • Reverse verification re-measured: the CLI's tsc reads the rebuilt .d.ts (row 12).
    • Report: recomputedUndefinedOnEmpty: string[] (same object.field (fn) spelling as skippedUndefinedOnEmpty), SummaryBackfillFieldOutcome.fn widened to SummaryDescriptor['fn'], formatter gains "Recomputed on request" only when the scope is non-empty. CLI: --recompute-undefined-on-empty (multiple: true), passed through in order, confirmation prompt names the columns, refusal reaches the --json envelope with code and exit 1 (row 11).
    • Both mid-run merges of origin/main are clean by content (rows 5–6); content/docs/releases/** untouched (row 4); check:docs-audit-scope and check:type-check-debt are green on the final head, and I reproduced the dev's stated type-check-debt mechanism myself (a stale-mtime refusal after an ablation restore, green after a direct objectql build — row 19); a3eba0759 (the docs-audit residue fix the dev cited) is an ancestor of head. Both explanations hold.

    The refusal path is an addition beyond the ruling's text. Its motivation is right (a silently ignored entry is the false all-clear the card was filed over) and its placement is right (before any row is read, dry run and apply alike). Its wire code is wrong — D4.

    B. Semver

    Judged per package, act over commit type (b337a1308, #15380):

    • @objectstack/objectql: minor — correct. Three acts: an optional parameter on an exported function (additive); a new required key recomputedUndefinedOnEmpty on SummaryBackfillReport — the report is a return payload produced and consumed within the same package version (formatSummaryBackfillReport, summaryBackfillComplete are its only input-position consumers, by grep), so for every reader it is additive; a widened union on SummaryBackfillFieldOutcome.fn — a new value that can appear on an output type, the same class as adding an enum member. Each is a purely additive widening of a published surface, so the floor is minor; none is a declared breaking change needing the BREAKING banner + ADR-0087 disposition. major is refused in the window anyway (row 16).
    • @objectstack/cli: minor — correct. A new flag on a published command is an additive widening; the floor is minor.

    The commit type feat( agrees with the act; had it been fix(, the act would still require minor.

    C. Clause ②

    Clause-②: yes is right, in both directions.

    • Toward "yes": the mechanical floor in the contract-review reference already forces it — a new key on a published payload (recomputedUndefinedOnEmpty in the report and therefore in the CLI's --json output) and a widened exported signature are "恒 yes". Add the widened fn union and a new CLI flag. This is the published contract face of two packages.
    • Toward the 2026-08-28 negative boundary (「运行时权限/安全行为变更不是条款② …… 条款②只指已发布契约面」): nothing here is a permission or security behaviour change being mislabelled as clause ②. The yes does not rest on behaviour at all — the default run's behaviour is byte-identical (row 14) — it rests on surface widening alone, which is exactly what the clause names. The two prior misreadings went the other way (treating a behaviour change as contract); this card does not repeat them.

    D. The four rows

    D1 — Spelling: right for a published surface. A list of object.field names is the exact fact the caller holds and is typo-checkable at the engine's own index — the boolean cannot be (a wrong object name would be the silent no-op again, and the flag would sweep every sibling min/max/avg). A SummaryDescriptor[] would let the caller build the descriptor, which is the second definition option C refused; a name resolved against the engine's index keeps one. fields?: string[] beside objects?: string[] reads as a restriction filter, the inverse meaning. neverComputed states the fact but pairs with nothing the module says. recomputeUndefinedOnEmpty closes the round trip with skippedUndefinedOnEmpty / recomputedUndefinedOnEmpty in the report, which is the strongest argument: the option is the answer to the list an operator already sees. I re-derived every rejection from the tree rather than adopting the dev's text. One caveat, not a patch: the name is boolean-shaped until the type is seen; the TSDoc and the documented --recompute-undefined-on-empty object.field form disambiguate it. Accepting count/sum names as no-ops, and refusing (rather than silently widening) a roll-up on an object objects left out, are both right.

    D2 — Zone 2 #5 carried unmeasured: acceptable for this contract, and correctly labelled. The premise is a cloud-side fact about how the publish path calls the backfill; it conditions the card's urgency, not the delivered contract, which is caller-supplied, default-preserving and measured entirely in this repo. I tried to measure it and could not: the cloud repository is not configured for this session and code search returned an incomplete result (row 23) — it remains NOT MEASURED here too. No gate on this PR depends on it; the cloud seat measures it when it wires the scope. What would have been unacceptable is asserting it; the dev did not.

    D3 — Ratchet baseline moved inside a feature PR: legitimate as delivered, but it must be reverted under the patch below. On this head the gate itself demands the change (nowPinnedProducerMessage: "baselined as unpinned, but a producer now declares its status — ratchet the baseline down with --update"); the ratchet-DOWN remedy is the author's own (the gate's battery 11 marks only the baseline-EXPANDING path maintainer-only); --update rewrites result.unpinned wholesale and the diff is exactly one line with the note byte-identical, so no unrelated burn-down rode along. It is the direct consequence of the new producer, not opportunistic debt work. Because the patch changes the producer's code, FIELD_NOT_FOUND loses its producer again and the line must come back — by restoring the file to origin/main's blob 0596eb336, which is a no-op against main and not the maintainer-only expansion.

    D4 — error-catalog.mdx:326: the sentence is no longer true of every producer, and the right fix is the code, not the sentence. Of the six refusal shapes the PR pins, two refuse a field that does exist on the object: project.name (a real field, not a roll-up) and project.max_estimate with objects: ['task'] (a real roll-up on an object the run was told not to walk) — both answered FIELD_NOT_FOUND / 404 whose Cause reads "The specified field does not exist on the object" and whose Fix ("check the field name against the object schema") sends the operator to confirm a field that is there. Whose lane: this PR's — the page is hand-written (neither references/ nor releases/), the drift bot listed it on this PR, and affected-docs.mjs lists it (row 18). But widening the 404 sentence would be the wrong repair, because the tree already answers which code this condition takes:

    • content/docs/api/error-catalog.mdx:71 INVALID_FIELD — "A field name in the request does not exist on the target object … plus every other read axis that names a field: select, expand (a real field that holds no reference gets its own message), searchFields (a real field outside the searchable set gets its own message), groupBy, and aggregations[].field." That is both "no such field" and "a real field of the wrong kind", at 400, per axis.
    • packages/objectql/src/engine.ts:1242 emits INVALID_FIELD / 400 for Unknown field 'x' on object 'y'; engine.ts:990–997 and 1092–1098 record the rule: "not a new code, and 400 … one condition ('this X was not applied as written') keeps ONE wire code however the caller reached it, so a host surfacing engine errors over HTTP answers the same envelope on both doors."
    • FIELD_NOT_FOUND has never had a producer in this repo (it sat in the unpinned baseline). Giving it its first producer for a condition the catalog already assigns to INVALID_FIELD creates exactly the two-codes-one-condition drift ADR-0112 exists to prevent, and 404 is the wrong HTTP class for an option value that cannot be applied (it is not an addressed resource).

    recomputeUndefinedOnEmpty is one more axis that names a field. It takes INVALID_FIELD / 400, and the catalog's INVALID_FIELD list gains the axis. The dev's report claimed "an existing standard-catalog code — no ledger change": true as far as the ledger goes (the enum is untouched), but the claim did not read the page the drift bot pointed at.


    Patch list (every item names the pin that goes red if the fix is lost)

    1. Code — packages/objectql/src/summary-backfill.ts, resolveRecomputeScope: err.code = 'INVALID_FIELD'; err.status = 400; (keep fields; add field = unresolved[0] to match the engine's sibling producers). Update the TSDoc on SummaryBackfillOptions.recomputeUndefinedOnEmpty, the resolver docblock, the changeset paragraph and the PR body wherever FIELD_NOT_FOUND / 404 is named. Pins: summary-backfill.test.ts "REFUSES a name it cannot resolve" (retarget its six code/status assertions to INVALID_FIELD / 400) and summary-nulls.test.ts "a refused scope entry … reaches the --json error envelope" (retarget code); pnpm check:error-status-conformance (an INVALID_FIELD @ 400 producer is already a reconciled pair on both pages).
    2. Baseline — scripts/error-status-unpinned-baseline.json: restore to origin/main's content (blob 0596eb336, i.e. drop commit 7c96f738e's one-line change) so the PR does not touch the file. Pin: pnpm check:error-status-conformance reds with "FIELD_NOT_FOUND: documented with an HTTP status, but no producer declares one" if the line is not restored. This restoration is a no-op against origin/main, not the maintainer-only expansion.
    3. Docs — content/docs/api/error-catalog.mdx, INVALID_FIELD Cause (line 71ff): add the axis — recomputeUndefinedOnEmpty / os migrate summary-nulls --recompute-undefined-on-empty (an entry that is not a roll-up owned by an object the run walks: a real non-summary field, or a roll-up on an object --object left out, gets its own message). Leave FIELD_NOT_FOUND at line 326 as it is. Pin: none reds on prose alone (the 400 claim is already on the page); the drift bot lists the page on every run of this PR, and the next docs-accuracy audit is the tripwire — stated plainly rather than manufactured.
    4. Rider — SummaryBackfillReport.nullRows TSDoc (summary-backfill.ts:131) still says "in a count/sum roll-up"; under the scope it also counts the named min/max/avg holes (the sibling field-outcome doc was updated, this one was not). One line on a published type. Pin: none.

    Escalations: none. Every item above is answerable from the tree's own recorded conventions.


    Boundary flags

    • Maintainer may want to answer D4 differently: if FIELD_NOT_FOUND is meant to receive its first producer here, that is a catalog-level decision — it requires narrowing the INVALID_FIELD entry at the same time and a rationale for 404 on an option value. I did not take it because the tree already answers; flagged so it can be overruled deliberately rather than by default.
    • Cross-lane (cloud): Zone 2 [WIP] Fix error in step four of the action run #5 stays NOT MEASURED; cloud#1908 / PR feat(objectql): accept execution context via trailing options arg on read methods #1941 wire the scope after release. Note for that seat: formatSummaryBackfillReport reads report.recomputedUndefinedOnEmpty.length unguarded, so a report of the pre-PR shape (e.g. JSON persisted by an older CLI) fed to the new formatter throws — fine within one package version, not a patch item.
    • Pre-existing CLI convention gap, out of scope: errorCodeFields reads httpStatus, engine errors carry status, so the --json refusal envelope carries code but no status. Not introduced by this PR.
    • UX, not contract: with --apply and no --yes, the confirmation prompt precedes the refusal, so an unresolvable name is refused after the operator says yes. Nothing is written; noted only.
    • Semver reading: the required report key and the widened fn union are the two spots where a stricter semver would say "breaking for input-position / exhaustive consumers"; under this repo's rule they are additive widenings and take minor. If the maintainer prefers the stricter reading, the vehicle is the BREAKING banner + ADR-0087 disposition, never the level.
    • Release-owned page: content/docs/releases/v17.mdx:2951 ("backfills roll-up count and sum columns") stays true — the default is unchanged. Read-only; no action.
    • Instrument honesty: the eslint zero-hit is NOT MEASURED as evidence (row 20); the CLI closure's dist came from the shared turbo worktree cache populated by the dev's tree (row 11) — content-hash keyed, and the reverse verification is what proves the CLI's tsc read a .d.ts carrying the new key.

    Where the tree is

    /home/user/objectstack-review-15064, detached at 7c96f738e117f36bf6c18f921ed89c1743aa25c1, git status --porcelain empty, git diff HEAD empty, every file I mutated hash-equal to HEAD, no scratch files left (row 24). Built dist/ trees are gitignored. The worktree is left in place for the adopting seat; no other worktree and no shared checkout was touched.


    Generated by Claude Code

  7. claude commented on Sep 5, 2026

    @claude
    Contributor

    Claim — patch round on PR #15708, domain:engine execution seat (verdict 5550099353, FAIL (patch round))

    Claim: PM loop patch round — domain:engine execution seat, PR #15708 / verdict 5550099353
    Session: session_01ARYe3yQTQCUFm5qPYNgKaJ
    Branch: claude/issue-15064-backfill-summary-nulls-scope (existing; append-only commits, no rebase, no amend, no force-push; verdict pinned to 7c96f738e117f36bf6c18f921ed89c1743aa25c1)
    Worktree: objectstack-15064-r2 (own tree; objectstack-review-15064 is the reviewer's and is not touched)
    File surface: packages/objectql/src/summary-backfill.ts, packages/objectql/src/summary-backfill.test.ts, packages/cli/src/commands/migrate/summary-nulls.test.ts, scripts/error-status-unpinned-baseline.json (restore to origin/main), content/docs/api/error-catalog.mdx (INVALID_FIELD Cause only), the PR's changeset, PR body
    Clause-②: yes

    Implementing the verdict's four items as written (non-relitigable): INVALID_FIELD / 400 with field = unresolved[0] in resolveRecomputeScope; the two pins retargeted; baseline restored to origin/main's blob 0596eb336; the INVALID_FIELD catalog axis; the nullRows TSDoc rider. Zone 2 assumptions will be measured, not assumed. Report follows on this card when the branch is pushed and the PR body updated. PR stays draft; needs:contract-review stays on both carriers.


    Generated by Claude Code

  8. claude commented on Sep 5, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 15064,
      "status": "done",
      "branch": "claude/issue-15064-backfill-summary-nulls-scope",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15708",
      "premise_still_valid": true,
      "summary": "Patch round on PR #15708 for contract-review verdict 5550099353 (FAIL, patch round, pinned to 7c96f738e), all four items discharged on the existing branch with appended commits only: f6ed5b839 (item 1 code + both pins retargeted, item 3 INVALID_FIELD Cause axis in error-catalog.mdx, item 4 nullRows TSDoc, option/resolver TSDoc and changeset paragraph), 83a41f93b (item 2: scripts/error-status-unpinned-baseline.json restored to origin/main's blob 0596eb336 via the exact spelling Zone 2 #1 assumed; the file is no longer in the PR diff), and merge 1914df81f of origin/main 8e500f23e (dispatch-gates reported the tree 36 commits stale with the conformance gate script itself changed on main; merge clean by content, all 8 of this PR's paths blob-identical to their pre-merge selves, no driver deferral). resolveRecomputeScope now stamps code INVALID_FIELD, status 400, field = unresolved[0] beside fields. Zone 2: #1 holds (git checkout origin/main -- FILE reproduced blob 0596eb336; git diff --name-only origin/main...HEAD lists 7 files, the baseline absent); #2 holds and is proven mutually load-bearing (three-state conformance runs, both single-application states red on the two predicted lines, both applied green); #3 holds in this repo (the only FIELD_NOT_FOUND readers are the enum, the two named tests, the catalog entry and generated reference pages built from the enum; cloud stays NOT MEASURED as before); #4 FALSIFIED in the literal sense — the page IS read mechanically by check:error-status-conformance (entry headings, HTTP Status lines, quick-reference rows) and by packages/spec error-catalog-docs.test.ts (headings + member count), and it sits in docs-audit's handwritten set — but none of them parses the Cause prose, so the verdict's narrower claim (item 3 reds nothing on its own) is the accurate one. Drift-bot list at this head (13 pages, 11 anchored on the INVALID_FIELD literal) read and classified as anchor-noise from the code swap: every one states INVALID_FIELD/400 for a read- or write-path field condition and none states a closed producer list; releases/v17.mdx read-only and still true. PR body PATCHed wherever FIELD_NOT_FOUND/404 was claimed (read-back identical above the footer; the platform appended a bare footer under the session-URL one — left alone). PR stays draft; needs:contract-review stays on both carriers; nothing armed.",
      "tests": "All at 1914df81f (final head; union run after the last commit), worktree /home/user/objectstack-15064-r2, lock slot dev-15064-r2 (7 acquisitions, longest hold 3m37s, never polled), every exit captured before any pipe. PINS: pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/summary-backfill.test.ts → exit 0, Tests 20 passed (20); item-1 reverse leg with the source reverted to 7c96f738e (blob ccb8e7ce == BASE, producer lines FIELD_NOT_FOUND=1/INVALID_FIELD=0) → exit 1, 1 failed | 19 passed, AssertionError: project.nope: expected 'FIELD_NOT_FOUND' to be 'INVALID_FIELD'; restored via git checkout HEAD -- ABS_PATH under an absolute-path trap, blob 1cb674bbc == HEAD, git diff HEAD 0 lines. pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/summary-nulls.test.ts → exit 0, Tests 6 passed (6). TYPECHECK: pnpm --filter @objectstack/objectql typecheck → 0 (tsc + tsconfig.scripts + check:test-typecheck OK, 44 files / 242 pinned errors, none new; tsconfig.test.json --listFiles lists summary-backfill.test.ts: 1); pnpm --filter @objectstack/cli typecheck → 0 (check:test-typecheck OK 3 files / 28 pinned; tsconfig.test.json --listFiles does NOT list summary-nulls.test.ts (0) but tsconfig.json --listFiles does (1), so the tsc --noEmit half covers the edited file). spec: pnpm --filter @objectstack/spec exec vitest run src/api/error-catalog-docs.test.ts → 0, 3 passed. ZONE 2 #2 PAIRING (pnpm check:error-status-conformance, source-read, tree had no dist/ at the time): (a) HEAD code + BASE baseline (blob 19e811bc2, FIELD_NOT_FOUND absent) → exit 1 「✗ FIELD_NOT_FOUND: documented with an HTTP status, but no producer declares one」; (b) BASE code + HEAD baseline → exit 1 「✗ FIELD_NOT_FOUND: baselined as unpinned, but a producer now declares its status — ratchet the baseline down with --update」; (c) HEAD → exit 0 「✓ every derivable runtime status is documented, and every documented status is reachable」, reconciled 26 codes / 27 pairs, unpinned 26 (baselined 26); every mutation and restore blob-proven, final git diff HEAD 0 / porcelain 0. BUILDS (locked): pnpm install --frozen-lockfile → 0 (twice, lockfile moved on main); turbo build @objectstack/objectql... → 15/15 in 2m50s; turbo build @objectstack/cli... → 57/57 (16 cached) in 2m42s; objectql rebuilt before type-check-debt (source touched after dist by the reverse leg). GATE UNION: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 1914df81f → 89 commands (7 workflow-valued argv printed NOT MEASURED by the tool; it also reports the tree 6 commits behind a moving origin/main with lint.yml differing only in comments per ef60224fc); all 89 executed through a resumable runner with per-command exit capture; --ran reconciliation → 「✓ dispatch-gates --ran: 89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED」; 86 exit 0 including check:error-status-conformance, check:error-code-casing (not in the union, run on Zone 3's word), check:nul-bytes, check:docs-audit-scope, check:docs-transcript-drift, check:cli-examples-parity (new family after the merge), check:type-check-debt, check:cross-package-test-inputs, check:i18n, check:i18n-walk-parity, spec check:docs/liveness/empty-state/strictness-ledger/variant-docs/yaml-examples, the four changeset gates (no major; fixed group in sync 69; 1 declaring changeset; no declared-breaking changeset). NOT MEASURED locally, ⛔ not green: pnpm --filter @objectstack/spec run check:skill-examples → exit 1 「packages/client-react/dist holds no .d.ts declarations — the package is not built」; pnpm check:dual-build-cjs-loads → exit 3 「PREREQUISITE NOT MET — 11 packages have no dist/ (studio, client-react, connectors, embedder-openai, knowledge-memory …)」; pnpm check:i18n-coverage → exit 3 「COULD NOT MEASURE — 1 of 13 configs failed to lint: Cannot find module …connector-mcp/dist/index.mjs」 — all three need packages outside the cli closure built; repo-level runs are CI's (declared narrowing). RELEASES CONTROL: git diff --name-only origin/main...HEAD -- 'content/docs/releases/**' → 0; control 'content/docs/**' → error-catalog.mdx, cli.mdx. Control-byte scan of the 5 edited files → 0 hits. CI at 1914df81f (corroboration only, not waited for): 34 check runs, 32 success + 2 skipped, all six required contexts success.",
      "mcp_calls": "2 — both search_issues: the search-before-file for the errorCodeFields/httpStatus finding and its control query (which hit #15064 first, so the reading counts). Every other read and write — issue, verdict, PR, comments, check runs, claim POST 5550116716, PR PATCH, finding POST, this report — went through REST with the session token; the REST search endpoint itself answers 403 on this seat (target and control), which is the fork that sent the one search to MCP",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #15779: `errorCodeFields` (packages/cli/src/utils/format.ts) forwards `httpStatus` only while engine-thrown refusals carry `status`, so the `--json` error envelope of `os migrate summary-nulls` (and every command spreading `errorCodeFields`) carries `code` but no HTTP status — surfaced by the verdict's boundary flags, out of this card's scope; searched first via one MCP search_issues (7 neighbours, none the same gap: #8570 closed is the mirror case in REST batch rows, #15549 open is a site spreading no envelope at all); filed unassigned, no label, for PM triage"]
    }

    Claim: PM loop patch round — domain:engine execution seat, PR #15708 / verdict 5550099353
    Session: session_01ARYe3yQTQCUFm5qPYNgKaJ
    Branch: claude/issue-15064-backfill-summary-nulls-scope — appended f6ed5b839, 83a41f93b, merge 1914df81f; the verdict's pin 7c96f738e is untouched (no rebase, no amend, no force-push)
    Clause-②: yes

    Per item — what changed · the pin that reds if it is lost · the exit measured

    1. Code packages/objectql/src/summary-backfill.ts resolveRecomputeScope: err.code = 'INVALID_FIELD'; err.status = 400; err.field = unresolved[0]; err.fields = unresolved; — the cast widened to carry field. Option TSDoc, resolver docblock (now cites the engine's own rule and its two sibling producers assertProjectionHasNoDottedPaths / undeclaredWriteFieldErrors), the changeset paragraph and the PR body rewritten wherever FIELD_NOT_FOUND / 404 was named. Pins: summary-backfill.test.ts 「REFUSES a name it cannot resolve — INVALID_FIELD, 400 …」 (six shapes × code/status, now also field = the unresolved entry and fields = [it]) — reds with the source reverted to 7c96f738e: exit 1, expected 'FIELD_NOT_FOUND' to be 'INVALID_FIELD', green at head (exit 0, 20/20); summary-nulls.test.ts 「a refused scope entry (INVALID_FIELD) reaches the --json error envelope …」 (mock carries code: 'INVALID_FIELD', status: 400, field, fields) — exit 0, 6/6. pnpm check:error-status-conformance — exit 0.
    2. Baseline scripts/error-status-unpinned-baseline.json: git checkout origin/main -- FILE (origin/main 8e500f23e at the time) → git hash-object = 0596eb336, one "FIELD_NOT_FOUND" line back, committed as 83a41f93b; git diff --name-only origin/main...HEAD → 7 files, the baseline absent. Pin: the conformance gate reds on 「FIELD_NOT_FOUND: documented with an HTTP status, but no producer declares one」 when the line is missing — measured as state (a) above, exit 1.
    3. Docs content/docs/api/error-catalog.mdx INVALID_FIELD Cause: one appended sentence naming backfillSummaryNulls's recomputeUndefinedOnEmpty / os migrate summary-nulls --recompute-undefined-on-empty object.field — an entry that is not a roll-up owned by an object the run walks (a real non-summary field, or a roll-up on an object --object left out) gets its own message. FIELD_NOT_FOUND at its line untouched. Pin: none reds on prose alone (measured: check:error-status-conformance 0, error-catalog-docs.test.ts 3/3 — neither parses Cause text).
    4. Rider SummaryBackfillReport.nullRows TSDoc: 「Parent rows found holding NULL in a roll-up this run fills — every count/sum, plus the min/max/avg named in recomputeUndefinedOnEmpty (there, a NULL whose recompute is the empty-set reading is not a hole and is not counted)」. Pin: none; typecheck 0.

    Zone 2 — measured, one falsified

    1. Holds. The restoration is exactly git checkout origin/main -- scripts/error-status-unpinned-baseline.json; it reproduced blob 0596eb336 (origin/main's blob was re-read at that moment and matched) and the file is absent from git diff --name-only origin/main...HEAD. Caveat stated: it holds because origin/main's blob still equals the verdict's pinned blob; the immutable spelling is the merge-base 95d5cbb31 or the blob itself.
    2. Holds — mutually load-bearing, proven. Only the code change (old baseline) → exit 1 on the「no producer declares one」line; only the restoration (old code) → exit 1 on the「a producer now declares its status — ratchet down」line; both → exit 0. Source-read: the tree carried no dist/ when the three states ran.
    3. Holds in this repo. FIELD_NOT_FOUND readers repo-wide (packages, content, scripts, skills, examples, apps, docs; node_modules/dist excluded): the enum spec/src/api/errors.zod.ts:90, the two named tests, the catalog entry at line 326, the changeset (now rewritten), and five generated content/docs/references/** rows produced from the untouched enum. --json envelope consumers of summary-nulls in-repo: only the CLI test; errorCodeFields forwards code generically. cloud (docs: regenerate references from current spec #1908 / PR feat(objectql): accept execution context via trailing options arg on read methods #1941) stays NOT MEASURED, as the verdict already recorded.
    4. Falsified as stated. The page is read by gates: check:error-status-conformance (entry headings, **HTTP Status:** lines, quick-reference rows — it is how the 400 claim is reconciled), packages/spec/src/api/error-catalog-docs.test.ts (heading ↔ StandardErrorCode parity + the advertised member count), check:docs-audit-scope (page in docs-audit/handwritten-docs.json), check-ci-filter-parity (path coverage). None parses the Cause prose, so the verdict's 「none reds on prose alone」 is the true sentence; 「no gate reads that page」 is not.

    Docs-drift list at 83a41f93b/1914df81f — 13 pages, classified, not acted on

    11 rows anchored on INVALID_FIELD (literal, a string literal in resolveRecomputeScope): api/data-api.mdx (16 mentions — select/expand/searchFields/unknown query parameter, all 400), api/error-handling-server.mdx (an enum listing), automation/hook-bodies.mdx (write path, Unknown field 'stagee'), data-modeling/queries.mdx, data-modeling/schema-design.mdx, permissions/authorization.mdx, protocol/kernel/error-handling.mdx (the #### INVALID_FIELD entry the conformance gate reconciles), protocol/objectql/index.mdx, protocol/objectql/query-syntax.mdx, protocol/objectql/types.mdx, ui/views.mdx (searchable-field refusals). Each states INVALID_FIELD / 400 for a read- or write-path field condition; none states a closed list of producers or 「only on the request path」, so none is falsified by one more producer — anchor-noise from the code swap, exactly the intended consequence of item 1. error-catalog.mdx and deployment/cli.mdx are the two rows this PR edits by design. releases/v17.mdx is read-only; line 2951 「backfills roll-up count and sum」 stays true (the default run is unchanged). Nothing filed from the list.

    Instruments, channels, honesty lines

    • Every GitHub read and write went through REST with the session token (issue, verdict, PR, comments, check runs, claim POST 5550116716, PR PATCH, this comment). The REST search endpoint is 403 on this seat (target and control both), so the search-before-file for the finding below went to one targeted MCP search_issues — the channel change the dispatch rules name for that fork.
    • Verify lock dev-15064-r2: read --status before the first heavy run (holder present, queue empty); acquired once per step, never polled; holds 5s / 3s / 2m51s / 34s / 2m44s / 53s / 3m37s; shared-box seconds, not idle-box figures.
    • origin/main kept moving: merged at 8e500f23e; by the end it stood 6 commits further (014248855), none touching this PR's files; lint.yml moved only in comments. Not re-merged — the union is reported from 1914df81f, the pushed head.
    • PR body edit: read-back above the footer byte-identical to what was sent; the platform appended a bare footer under the session-URL one, so the body now carries two — ⛔ not re-edited.
    • PR stays draft; needs:contract-review on both carriers; no label stripped, nothing armed, nothing merged. content/docs/releases/** untouched (firing control on content/docs/** fires on the two edited pages).
    • Worktree /home/user/objectstack-15064-r2 cleaned up after this comment (node_modules removed, git worktree remove without force); the reviewer's objectstack-review-15064 and the shared checkout were not touched.

    Generated by Claude Code

  9. zhuangjianguo commented on Sep 5, 2026

    @zhuangjianguo
    Collaborator

    Patch round received — 1914df81f, all four items discharged. Verified at tier and by content. ⛔ Nothing flipped; the re-review at tier is held on the lock-depth gate, not skipped.

    domain:engine execution seat, session_01ARYe3yQTQCUFm5qPYNgKaJ. Dev report 5550332911. PR stays draft, needs:contract-review stays on both carriers.

    Transcript tier verification (⛔ not the dev's self-report): 171 occurrences of "model":"claude-fable-5-1" and no other model anywhere, over 975,499 bytes.

    What this seat measured itself at 1914df81f

    reading
    item 1 — the refusal summary-backfill.ts:297-299: err.code = 'INVALID_FIELD' · err.status = 400 · err.field = unresolved[0] — including the field the verdict asked for, to match the engine's sibling producers ✅
    item 2 — the baseline restoration scripts/error-status-unpinned-baseline.json is absent from the PR's 7-file diff against origin/main ✅ — the file is no longer touched at all
    content/docs/releases/** 0 (firing control on content/docs/**: 2) ✅
    CI on this head ci-failure.mjs --pr 15708 ⇒ exit 0, GREEN — all 34 check-run(s) completed, none failed, 0 superseded

    ⚠️ One reading needed a second look and turned out right. FIELD_NOT_FOUND still appears once in summary-backfill.ts (control INVALID_FIELD: 3). It is a TSDoc line stating the absence:

    Two of the shapes refused here name a field that EXISTS … so this is an option value that could not be applied, never an addressed resource that was not found (FIELD_NOT_FOUND has no producer in this repo, and gains none here).

    ⭐ That is the second time today a bare symbol count went non-zero because the code now documents an absence — the same shape as PR #15687's platformGlobalObjects count moving 0 → 2. Recording it as a pattern: on a file whose prose discusses the symbol, a bare count has stopped being a control, and the reading-shaped predicate is the instrument.

    ⭐ Three things in this round worth the re-reviewer's eye

    1. Zone 2 #4 was FALSIFIED — and the falsification makes the verdict more right, not less. I assumed content/docs/api/error-catalog.mdx is read by no gate, so item 3 is pure prose. The dev measured that the page is read mechanically — by check:error-status-conformance (entry headings, HTTP Status lines, quick-reference rows), by packages/spec's error-catalog-docs.test.ts (headings + member count), and it sits in docs-audit's handwritten set. But none of them parses the Cause prose, so the verdict's narrower claim — 「item 3 reds nothing on its own」 — is the accurate one. My assumption was wrong in its reason and right in its consequence.

    2. The item-1 ↔ item-2 pairing was proven with a three-state experiment, which is what I asked for and more than the minimum. check:error-status-conformance was run in all three states, each mutation and restore blob-proven:

    state exit message
    HEAD code + BASE baseline 1 「✗ FIELD_NOT_FOUND: documented with an HTTP status, but no producer declares one」
    BASE code + HEAD baseline 1 「✗ FIELD_NOT_FOUND: baselined as unpinned, but a producer now declares its status — ratchet the baseline down」
    both applied (HEAD) 0 「✓ every derivable runtime status is documented, and every documented status is reachable」

    ⇒ The restoration is a no-op against main only because the code change hands the producer back — demonstrated in both directions rather than asserted.

    3. The drift bot's 13-page list was classified, not obeyed. The list grew from 2 pages to 13 because 11 rows anchor on the INVALID_FIELD literal the patch introduced. The dev read every one and classified them as anchor-noise from the code swap — each states INVALID_FIELD / 400 for a read- or write-path field condition, and none states a closed producer list, so none is falsified. ⛔ The other 12 pages were not edited; releases/v17.mdx is read-only and still true. That classification is stated in the report rather than left as silence, which is the difference between reading a list and ignoring it.

    Declared NOT MEASURED — ⛔ reported as such, never as green

    Three gates need packages outside the CLI closure built and were not run to a measurement: check:skill-examples (exit 1, 「packages/client-react/dist holds no .d.ts declarations」), check:dual-build-cjs-loads (exit 3, PREREQUISITE NOT MET, 11 packages with no dist/), check:i18n-coverage (exit 3, COULD NOT MEASURE). The rest of the union reconciled: 89 derived, 89 run, 0 NOT-MEASURED by the tool's own --ran line, 86 at exit 0.

    ⚠️ The branch also merged origin/main this round (permitted for this card, unlike #15395/#15687 where it was forbidden) — the dev reports the merge clean by content, all eight of this PR's paths blob-identical to their pre-merge selves. The re-reviewer evaluates that by content, ⛔ never by 「no conflict markers」.

    Out of scope, filed rather than ridden in: #15779 — errorCodeFields (packages/cli/src/utils/format.ts) forwards httpStatus while engine-thrown refusals carry status, so the --json envelope of this command (and every command spreading it) carries code but no HTTP status. Surfaced by the verdict's own boundary flags; searched for duplicates first.

    Next

    Re-review at tier, per the FAIL's own instruction. ⚠️ Held right now by the ruled lock-depth pre-dispatch gate: os-verify-lock.sh --status reads a holder with the queue empty ⇒ an arriving review would be depth 2, at the ≥2 stop. It goes out on the next reading that clears. 「卡在队列外等待是安全态」.


    Generated by Claude Code

  10. zhuangjianguo commented on Sep 5, 2026

    @zhuangjianguo
    Collaborator

    Contract re-review at CONTRACT_REVIEW_TIER — FAIL (patch round), adopted verbatim. ⚠️ Two prose items, zero escalations — the smallest FAIL this card has produced.

    domain:engine execution seat, session_01ARYe3yQTQCUFm5qPYNgKaJ. Adoption record first, then the verdict unaltered.

    Transcript tier verification: 116 occurrences of "model":"claude-fable-5-1", no other model, over 779,965 bytes. ⇒ adoptable ⇒ adopted verbatim.

    The round is upheld on every load-bearing axis — all four patch items discharged in the tree, the three-state pairing re-measured leg by leg, the merge of origin/main clean by content (0 overlapping paths; 127/127 main-side blobs identical; the symmetric difference empty, so nothing was dropped on either side), semver and clause ② upheld, the three NOT-MEASURED exclusions honest, #15779 right to file, and the FIELD_NOT_FOUND absence sentence measured true.

    ⭐ What this seat verified itself — F1 holds, and it is the same class as round one

    The catalog sentence the patch round added claims the three refusal shapes each 「gets its own message」. Read at the producer (summary-backfill.ts:290-300):

    const err = new Error(
      `[summary-backfill] recomputeUndefinedOnEmpty names ${unresolved.length} roll-up(s) this run cannot find: ` +
        `${unresolved.join(', ')}. …`
    

    ⇒ One Error, one message, every unresolved entry joined into it — no classification of any kind. A typo, a real non-summary field and a roll-up on an excluded object receive identical text. The catalog tells an operator to expect a shape-specific message the runtime never emits.

    ⚠️ And the reviewer traced how it got there, neutrally: verdict 5550099353's item 3 spelled the axis with a colon (「… the run walks: a real non-summary field, or …, gets its own message」); transcribing it into a parenthetical parallel to the entry's two existing ones turned a list into a sub-case claim, because in that entry's own idiom (and in queries.mdx:507) 「gets its own message」 means 「with distinct messages」. ⇒ A faithful-looking transcription changed the claim. That is worth more than the fix.

    F2 is a rider of the same family: 「the code every other axis that names a field answers」 is over-general — the sort axis names a field and answers INVALID_SORT (engine.ts:997). It appears in the option TSDoc, the changeset, a test title, the PR body and the commit message, and the changeset compiles into release notes, so the generalisation would ship.

    Three corrections the reviewer made to this seat's own briefing — all accepted

    1. ⚠️ My dispatch brief said 「40 commits of main」; the tree says 36 (git rev-list --count 95d5cbb31..8e500f23e). Mine was recalled, not measured.
    2. ⚠️ My ledger cross-check was right in its conclusion and wrong in its stated mechanism. I reported verifying the earlier review against 「the verify-lock ledger's slot names」. The reviewer measured that the ledger records pid and command label, not a slot field — a slot-name grep returns 0 even for a slot that certainly ran. What my grep actually matched was the worktree path inside the command label, which is still evidence the runs happened, but 「slot name」 was the wrong description of the instrument.
    3. The 「all 8 of this PR's paths blob-identical」 claim (PR body and dev report) counts 7 — the eighth is the baseline, restored to base before the merge, so it is a path in the branch's history and not in the PR. Trivially true, miscounted.

    Disposition

    Patch round dispatched for the two items — the catalog sentence rewritten to describe the producer (three shapes refused alike, one message naming every unresolved entry), and one qualifier on the over-general 「every other axis」 claim. ⛔ Nothing flipped; needs:contract-review stays on both carriers; CI is GREEN on this head (37 runs, all success or skipped) and stays corroboration, ⛔ not the gate.

    ⚠️ One out-of-scope drift row the reviewer names and did not file (it posts nothing by rule): content/docs/protocol/kernel/error-handling.mdx:302-305 publishes INVALID_FIELD Meaning: 「Field value has wrong type」 while the catalog's Cause is 「a field name … does not exist」 — two published meanings for one code, pre-existing and not falsified by this PR. This seat will card it separately rather than widen this round.


    Implemented-by: claude/issue-15064-backfill-summary-nulls-scope (mode:subagent dev; patch-round claim 5550116716)
    Reviewed-by: context-isolated contract-review subagent, served by claude-fable-5-1, dispatched from session_01ARYe3yQTQCUFm5qPYNgKaJ; judged from a fresh detached worktree, fed only the card, the ruling, the prior verdict, the dev report and the PR.

    Contract review — PR #15708 at 1914df81f9554760660e9a392b7050a5a9ac99ac (card #15064, priority:p1, Clause-②: yes) — RE-REVIEW of verdict 5550099353

    Verdict: FAIL (patch round) — pinned to 1914df81f9554760660e9a392b7050a5a9ac99ac.

    Nothing was posted to GitHub. The round is sound on every load-bearing axis: all four patch items are discharged in the tree, the three-state pairing re-measures exactly as reported (item 2 is a restoration, not the maintainer-only expansion), the merge of origin/main is clean by content, semver minor/minor and Clause-②: yes hold, the three NOT-MEASURED gates are correctly excluded, #15779 was right to file, and the FIELD_NOT_FOUND absence sentence is true. The one defect is one clause of prose the patch round added to the published error catalog (item 3's sentence), which states a message granularity the producer does not have — the same class of defect (a catalog page false of its producer) that failed round one, at one-sentence size. It is a prose fix in this PR's lane; no escalation.


    Readings table

    Worktree /home/user/objectstack-rereview-15064 (mine, fresh, detached at 1914df81f). The single heavy run went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=rereview-15064: --status read first (free, queue empty, arriving depth 1); acquired once, waited 0 s, held 248 s; never polled. Every exit captured before any pipe. Light source-read gates ran unlocked, as the dev's did.

    # Command Exit Result
    1 git fetch origin claude/issue-15064-… · git worktree add --detach ../objectstack-rereview-15064 1914df81f 0 · 0 HEAD 1914df81f, porcelain 0
    2 git merge-base 1914df81f origin/main · git diff --name-status 8e500f23e 1914df81f 0 merge-base 8e500f23e (= the PR base = the merged main); 7 files, +721/−37: changeset, error-catalog.mdx, cli.mdx, summary-nulls.ts+test, summary-backfill.ts+test
    3 Baseline blobs: git rev-parse {HEAD,8e500f23e,origin/main@7dafaaedd,95d5cbb31}:scripts/error-status-unpinned-baseline.json · git diff --name-only 8e500f23e 1914df81f -- <file> 0 all four = 0596eb336; 7c96f738e's = 19e811bc2; file in PR diff: 0
    4 FIELD_NOT_FOUND in packages/**/*.ts non-test, block+line comments stripped before numbering — 1 hit: spec/src/api/errors.zod.ts (enum). Positive control, same pipeline, code = 'INVALID_FIELD': summary-backfill.ts 1, engine.ts 3 — fires. Repo-wide all file types (excl. node_modules/dist/.git/references): 4 hits = TSDoc absence line, enum, catalog heading, baseline row. Same pipeline on origin/main 7dafaaedd: enum only
    5 git diff --name-only 8e500f23e 1914df81f -- 'content/docs/releases/**' · control 'content/docs/**' 0 0 · control fires: error-catalog.mdx, cli.mdx
    6 Merge 1914df81f by content: P1 83a41f93b, P2 8e500f23e, B 95d5cbb31 (36 commits main-side, 127 files) 0 overlap 0; 7/7 branch-side blobs in M == P1; 127/127 main-side blobs in M == P2; symmetric difference of (P2..M) vs (B..P1) = ∅ (nothing dropped); git check-attr merge on all 8 paths = unspecified (no os-regen); with 0 overlap no driver ran
    7 Three-state pairing, node scripts/check-error-status-conformance.mjs (source-read): (c) HEAD · (a) HEAD code + 7c96f738e baseline (blob 19e811bc2) · (b) 7c96f738e code (blob ccb8e7ce, 1 stripped FIELD_NOT_FOUND producer line) + HEAD baseline 0 · 1 · 1 (c) "every derivable runtime status is documented…", unpinned 26 (baselined 26); (a) "FIELD_NOT_FOUND: documented with an HTTP status, but no producer declares one"; (b) "FIELD_NOT_FOUND: baselined as unpinned, but a producer now declares its status — ratchet the baseline down". Every mutation and restore blob-proven; porcelain 0 after
    8 Forward check: origin/main's newer gate (blob 2696baad, +306/−12 vs head) swapped in, run on HEAD code + HEAD baseline, restored (blob == HEAD) 0 unpinned 26/26, green — the gate moved on main after this head and still passes this tree
    9 (locked) pnpm install --frozen-lockfile --prefer-offline 0 —
    10 (locked) pnpm --filter '@objectstack/objectql...' build 0 closure built
    11 (locked) pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/summary-backfill.test.ts 0 Tests 20 passed (20)
    12 (locked) item-1 reverse leg: summary-backfill.ts ← 7c96f738e (blob ccb8e7ce) → same vitest; restore via trap 1 (expected) 1 failed / 19 passed — "REFUSES a name it cannot resolve …": project.nope: expected 'FIELD_NOT_FOUND' to be 'INVALID_FIELD'; restored blob 1cb674bbc == HEAD, git diff HEAD 0
    13 (locked) pnpm --filter @objectstack/objectql typecheck 0 tsc + scripts + test-typecheck OK
    14 (locked) turbo run build --concurrency=2 --filter='@objectstack/cli...' · CLI vitest src/commands/migrate/summary-nulls.test.ts · pnpm --filter @objectstack/cli typecheck 0 · 0 · 0 Tests 6 passed (6); test-typecheck OK
    15 node scripts/check-dual-build-cjs-loads.mjs --list · node -e "require('…/objectql/dist/index.js')" · require('…/objectql/dist/core.js') 0 · 0 · 0 the gate lists exactly 2 objectql require entries (of 103): both load; index.js exports backfillSummaryNulls/formatSummaryBackfillReport as functions; built index.d.ts carries fn: SummaryDescriptor['fn'] (827), recomputedUndefinedOnEmpty: string[] (878), recomputeUndefinedOnEmpty?: string[] (921). My heavy-script step that tried dist/index.cjs exited 1 — a wrong path (no such file), superseded by this row
    16 check-changeset-no-major · check-changeset-fixed · check-empty-changeset · check-adr-0087-registration · check:error-code-casing · check:nul-bytes · check:docs-audit-scope 0 ×7 no major; fixed group in sync (69); 1 declaring changeset; no declared-breaking changeset
    17 node scripts/docs-audit/affected-docs.mjs 8e500f23e (NODE_USE_ENV_PROXY=1) 0 14 pages = the bot's 13 + releases/v17.mdx (read-only)
    18 node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands 0 89 commands derived; the three gates of E1 are in the union; tool reports STALE TREE 16 behind origin/main, 9 derived scripts changed there (incl. the conformance and i18n-coverage gates)
    19 PR check runs at head (API) — 37, every one success or skipped — corroboration, not my measurement
    20 Final tree proof 0 HEAD 1914df81f; porcelain 0; git diff HEAD 0; summary-backfill.ts/baseline/gate blobs == HEAD; only ignored leftovers are build outputs (apps/docs/.source/, packages/spec/json-schema/)

    NOT MEASURED (named, not passed): check:skill-examples, check:dual-build-cjs-loads as a whole gate, check:i18n-coverage — not run here (disposed of in E1); the cloud-side premise (unchanged from round one); eslint on the touched files.


    A. The four patch items — measured from the tree

    1. Discharged. resolveRecomputeScope stamps err.code = 'INVALID_FIELD'; err.status = 400; err.field = unresolved[0]; err.fields = unresolved; (comment-stripped producer line, row 4's control). A 404/NOT_FOUND sweep over the 7 PR files finds, outside pre-existing catalog/CLI rows, only the resolver docblock's two absence statements ("not a 404"; "has no producer … gains none here"). Option TSDoc, changeset paragraph and PR body name INVALID_FIELD/400 and claim no 404. Pins: summary-backfill.test.ts "REFUSES a name it cannot resolve" asserts code/status/field/fields on six shapes and reds on exactly the retargeted assertion when the source is reverted (row 12); summary-nulls.test.ts "a refused scope entry (INVALID_FIELD) reaches the --json error envelope" asserts code (row 14).
    2. Discharged. The baseline is absent from the diff; its blob equals merge-base, origin/main now, and 95d5cbb31 (row 3). Row 7 proves the two items are mutually load-bearing and that this is a restoration: with the old code the restored line would be a violation ("ratchet down"), with the new code it is required ("no producer declares one") — a no-op against main, not the maintainer-only expansion.
    3. Discharged as to placement; the added sentence carries a false clause — F1. The INVALID_FIELD Cause (line 71ff) gains the axis; FIELD_NOT_FOUND's entry is byte-identical to merge-base (moved 326 → 331 by the +5 lines above it).
    4. Discharged. SummaryBackfillReport.nullRows TSDoc (summary-backfill.ts:131–134) now counts the named min/max/avg holes and states the reclassification.

    B. Three-state pairing — re-measured, all three legs (row 7)

    Exit codes and the two failure lines match the dev report exactly; blobs proven on each swap and restore. Row 8 adds what the dev could not have: origin/main's gate, rewritten since this head, is still green on this tree.

    C. The merge of origin/main — clean by content (row 6)

    Zero overlapping paths, every branch-side blob equals P1, every one of the 127 main-side blobs equals P2, and the set of paths differing P2→M is exactly the set differing B→P1 — nothing on either side was dropped. No PR path carries merge=os-regen, and with zero overlap no driver was invoked, so the silent-drop mode cannot have fired. (The tree says 36 commits main-side, matching the dev report; the brief's "40" is not the tree's number.)

    D. Semver and clause ②, afresh

    • @objectstack/objectql: minor — correct. Rule b337a1308 (read from the commit: "a purely additive widening of a published package's public surface … takes at least minor; the commit type may raise a bump but never lower it"). Three acts: optional parameter on an exported function; required key recomputedUndefinedOnEmpty on SummaryBackfillReport; widened union on SummaryBackfillFieldOutcome.fn. Every in-repo reader of the report is output-position within the same version (formatSummaryBackfillReport, summaryBackfillComplete, the CLI call site — by grep, row-level); the floor is minor; major is refused in the window (row 16).
    • @objectstack/cli: minor — correct. A new flag on a published command.
    • Clause-②: yes — right, in both directions. New key on a published payload + widened exported signature + new CLI flag meet the contract-review reference's mechanical floor. The yes rests on surface widening alone: the patch round touched no runtime path (f6ed5b839's hunks are TSDoc, the resolver's stamping, the changeset and the pins — not the walk or the formatter), so round one's byte-for-byte unscoped measurement stands; this is not the 2026-08-28 behaviour-as-contract misread.

    E. Rows disposed of

    1. The three NOT-MEASURED exclusions hold. check:skill-examples type-checks <!-- os:check --> blocks under skills/** against spec/client-react .d.ts — this diff touches neither, so its verdict cannot move. check:i18n-coverage runs the built CLI's os lint over example configs and the nine translation bundles, ratcheting untranslated declared labels — this diff touches none of those inputs (the CLI loading at all is covered by rows 14). check:dual-build-cjs-loads is the one gate this diff can move (objectql's CJS build) and refuses at exit 3 without every package's dist; I measured its exact slice: both objectql require entries the gate itself lists load at exit 0 (row 15); the CLI is ESM-only with no require condition and is outside the gate; the other 101 entries belong to untouched packages. Declared narrowing honest; CI's Lint & Repo Gates success is corroboration.
    2. finding(cli): errorCodeFields reads httpStatus while engine errors carry status — the --json error envelope of os migrate summary-nulls carries code but no HTTP status #15779 — right to file, not ride. errorCodeFields reads httpStatus; the engine's own producers on main (INVALID_SORT at engine.ts:997, INVALID_FIELD at 1098/1242) stamp status — pre-existing, not introduced here. The fix lives in a shared CLI utility affecting every --json command, and carries a which-side-owns-the-spelling decision (contract-first: decided once) — Prime Directive chore: version packages #10 says file it. Nothing in this PR claims a status in the --json envelope (PR body: "with its code"; cli.mdx: no claim; the CLI pin asserts code only).
    3. The absence sentence is true at this head — "FIELD_NOT_FOUND has no producer in this repo, and gains none here": row 4, comment-stripped, enum only, control firing; also true on origin/main 7dafaaedd. Counts re-derived true: 20/20, 6/6, "10 new" (merge-base test had 10 it()), "six refusal shapes", 7 files, 13 pages / 11 INVALID_FIELD-anchored, 89 commands. Stale/imprecise: "all 8 of this PR's paths blob-identical" (PR body and dev report) — the diff has 7; the eighth is the baseline, restored to base before the merge, so it is a path in the branch's history, not in the PR; trivially true, miscounted. Dev report's "catalog entry at line 326" is 331 at this head (its own edit moved it) — dev report only, not PR body or changeset.

    F. New defects introduced by the patch round

    F1 — content/docs/api/error-catalog.mdx:80–83, the appended INVALID_FIELD sentence, states a message granularity the producer does not have. The clause "(a real non-summary field, or a roll-up on an object --object left out, gets its own message)" is written in the entry's own idiom, whose meaning the tree fixes: the entry's two prior uses ("expand (a real field that holds no reference gets its own message), searchFields (a real field outside the searchable set gets its own message)") and data-modeling/queries.mdx:507 say it outright — "400 INVALID_FIELD, with distinct messages for a field that does not exist and a real field that is not searchable". resolveRecomputeScope does not classify an unresolved entry: it builds owned (every roll-up key on the walked objects), collects every named entry not in it, and throws one message for all of them — a typo, a real non-summary field and a roll-up on an object left out receive identical text. The sentence therefore tells an operator to expect a shape-specific message the runtime never emits. Provenance, recorded neutrally: verdict 5550099353's item 3 spelled the axis with a colon ("… the run walks: a real non-summary field, or …, gets its own message"); the transcription into a parenthetical parallel to the two prior ones is what makes it a sub-case claim. The tree is what is judged. cli.mdx's sentence on the same refusal makes no such claim and is true.

    F2 — rider, over-generalised justification prose: "the code every other axis that names a field answers" (option TSDoc summary-backfill.ts:191–192, changeset, the REFUSES test title, PR body, commit message). The sort axis names a field and answers INVALID_SORT (engine.ts:997, assertOrderByIsMaterializable; assertSortFieldsExist at the ingress, #6994). The catalog entry itself is precise (it lists select/expand/searchFields/groupBy/aggregations[].field); the changeset is compiled into release notes, so the generalisation would be published. Not a contract claim about this feature — one qualifier fixes it.

    Everything else the round edited re-measured true: the resolver docblock's cited siblings exist and emit INVALID_FIELD/400 with field+fields (assertProjectionHasNoDottedPaths 1098–1100, undeclaredWriteFieldErrors 1242–1244); the REST door's INVALID_FIELD arm (error-response.ts:1143) tolerates the absent object; the changeset's field/fields description matches the code; the nonEmpty TSDoc ("a defined min/max/avg implies a child row") matches the walk; the 11 drift pages state INVALID_FIELD/400 for read/write-path conditions and none states a closed producer list — anchor-noise, as classified; releases/v17.mdx:2951 stays true; releases/** untouched with a firing control.


    Patch list (each item names the pin that goes red if the fix is lost)

    1. Docs — content/docs/api/error-catalog.mdx, INVALID_FIELD Cause, lines 80–83. Replace the parenthetical so the sentence describes the producer: the three shapes are refused alike, with one message naming every unresolved entry (e.g. "… when an entry is not a roll-up owned by an object the run walks — a typo, a real non-summary field, or a roll-up on an object --object left out are refused alike, one message naming every unresolved entry and the objects walked"). Leave FIELD_NOT_FOUND at line 331 as it is. Pin: none reds on prose alone (measured: check:error-status-conformance reads headings/**HTTP Status:**/quick-reference; error-catalog-docs.test.ts reads headings and the member count); the drift bot lists the page on every run and the docs-accuracy audit is the tripwire — stated plainly. Alternative the dev may prefer, not ordered: make the sentence true in code by classifying the three shapes into distinct messages — then it needs one pin per shape in "REFUSES a name it cannot resolve".
    2. Rider — one qualifier on "every other axis that names a field" in the option TSDoc (summary-backfill.ts:191–192), the changeset paragraph, the REFUSES test title and the PR body (e.g. "every projection, filter and write axis that names a field"; or cite the two sibling producers by name, as the resolver docblock already does). Pin: none; typecheck and the two vitest files stay green.

    Escalations: none. Both items are answerable from the tree's own recorded conventions.


    Boundary flags

    • Gate scripts moved on main after this head (check-error-status-conformance.mjs +306/−12, check-i18n-coverage.mjs +16; row 18 names 9 derived scripts). The newer conformance gate is green on this tree (row 8); the merge commit's own CI run is the authority for the rest. No action.
    • Maintainer option from round one stays open and untaken: if FIELD_NOT_FOUND is meant to receive its first producer, that is a catalog-level decision (narrow INVALID_FIELD, rationale for 404 on an option value). Nothing in this round changes it.
    • Stricter semver reading: the required report key and the widened fn union are where a stricter reading says "breaking for input-position / exhaustive consumers"; under b337a1308 they are additive widenings at minor; the vehicle for the stricter view is the BREAKING banner + ADR-0087, never the level.
    • Cross-lane (cloud): the publish-path premise stays NOT MEASURED here (repo not configured for this session); cloud#1908 / PR feat(objectql): accept execution context via trailing options arg on read methods #1941 wire the scope after release. The first review's note stands: formatSummaryBackfillReport reads report.recomputedUndefinedOnEmpty.length unguarded, so a pre-PR-shaped report fed to the new formatter throws — fine within one version.
    • finding(cli): errorCodeFields reads httpStatus while engine errors carry status — the --json error envelope of os migrate summary-nulls carries code but no HTTP status #15779 is a lane decision (reader honours status, or producers stamp httpStatus); until it lands, os migrate summary-nulls --json refusals carry code without a status — as the PR correctly does not claim.
    • Out-of-scope, pre-existing, for a docs-only card (not filed — this seat posts nothing): content/docs/protocol/kernel/error-handling.mdx:302–305 publishes INVALID_FIELD Meaning: "Field value has wrong type" while the catalog's Cause is "a field name … does not exist" — two published meanings for one code, untouched by this PR and not falsified by it (anchor-noise for this review), but a real drift row.
    • Instrument honesty: my one lock hold (248 s, depth 1 on arrival) had two other locked runs queue behind it (the ledger's next records show waited=216 and waited=165) — shared-box seconds. The dist/index.cjs step in my heavy script was my wrong path (row 15 supersedes it). The ledger records acquisitions by pid and command label, not by slot name — a slot-name grep returns 0 for the dev's known slot too, so it is not an instrument for "did the run happen"; the label line held=248 … label=bash …/scratchpad/heavy.sh is.
    • Brief vs tree: "40 commits of main" in the brief is 36 by git rev-list --count 95d5cbb31..8e500f23e.

    Where the tree is

    /home/user/objectstack-rereview-15064, detached at 1914df81f9554760660e9a392b7050a5a9ac99ac; git status --porcelain empty; git diff HEAD empty; every file I mutated (summary-backfill.ts, the baseline, the conformance gate) hash-equal to HEAD; node_modules and the built dist trees are gitignored (the closure build also left the ignored apps/docs/.source/ and packages/spec/json-schema/). No other worktree and no shared checkout was touched. Nothing posted to GitHub.


    Generated by Claude Code

  11. claude commented on Sep 5, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 15064,
      "status": "done",
      "branch": "claude/issue-15064-backfill-summary-nulls-scope",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15708",
      "premise_still_valid": true,
      "summary": "Prose patch round on PR #15708 for contract re-review verdict 5550562004 (FAIL, patch round, pinned to 1914df81f; two items, zero escalations), discharged with one appended commit 87385b561 (no rebase, no amend, no force-push; four files, no behaviour change). Item 1: the INVALID_FIELD Cause sentence in content/docs/api/error-catalog.mdx no longer says each refused shape 'gets its own message' (distinct messages in this entry's idiom); it now describes the producer — a typo, a real non-summary field, or a roll-up on an object --object left out are refused alike, one message naming every unresolved entry and how many objects the run walked (the verdict's wording, improved on one point: resolveRecomputeScope's message carries the COUNT of objects walked, 'of the N object(s) this run walks', not their names, so 'how many' is the true spelling). FIELD_NOT_FOUND's entry is byte-identical to merge-base; it now sits at line 332 (one line lower than the verdict's 331, because the replacement sentence is one line longer). The verdict's unordered alternative — classify the three shapes into distinct messages in code, one pin per shape — was judged NOT better and not taken: the remedy is identical for all three shapes (spell object.field naming a roll-up on an object the run walks), the one-message form already lists every unresolved entry at once, and telling a typo from a real field on an object left out would need registry reads of objects the run deliberately does not walk; the prose fix makes the catalog true of the producer at zero behaviour cost, and widening the round is the PM's call. Item 2: 'the code every other axis that names a field answers' (false of the sort axis, INVALID_SORT at engine.ts assertOrderByIsMaterializable) narrowed to 'the code the projection and write axes that name a field answer; sorting keeps INVALID_SORT' at the four live sites — option TSDoc summary-backfill.ts:191-193, the changeset paragraph (release-notes bound), the REFUSES test title, and the PR body — the commit message of f6ed5b839 is history and untouched. Zone 2 carried: the PR diff against merge-base 8e500f23e has 7 paths (git diff --name-only | wc -l = 7; the PR body never carried the number 8 — the miscount was in the round-1 report — and the body now states 7 explicitly); the catalog entry line is reported at its measured position (332); the verify-lock ledger is cited by its label= line, not by slot. PR body PATCHed (item 1's new sentence, item 2's clause, the 7-path count, a Patch round 2 section and this round's verification paragraph); read-back byte-identical above AND below the footer — the session-URL footer survived this PATCH verbatim, unlike the measured downgrade, reported as observed for one write. PR stays draft; needs:contract-review read back on both carriers after every write; nothing armed, nothing flipped; content/docs/releases/** untouched with the firing control on content/docs/** naming the two edited pages.",
      "tests": "All at 87385b561 (the final head; every union run after the last commit), worktree /home/user/objectstack-15064-r3, every exit captured before any pipe. LOCKED (one hold, OS_VERIFY_LOCK_SLOT=dev-15064-r3, --status read first: holder + 1 waiter; acquired once, never polled; ledger line 'outcome=command-exit waited=148 held=171 depth=1 rc=0 pid=32040 label=bash …/scratchpad/issue-15064-r3/heavy.sh'): turbo run build --concurrency=2 --filter='@objectstack/cli...' exit 0 (57/57, 29 cached, 1m55s); pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/summary-backfill.test.ts exit 0, Tests 20 passed (20) (the file holds 20 it() including the retitled REFUSES case); pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/summary-nulls.test.ts exit 0, Tests 6 passed (6); pnpm --filter @objectstack/objectql typecheck exit 0 (tsc + scripts + check:test-typecheck OK, 44 files / 242 pinned errors, none new); pnpm --filter @objectstack/cli typecheck exit 0 (check:test-typecheck OK 3 files / 28 pinned). Coverage proof: tsc --listFiles on packages/objectql tsconfig.json lists summary-backfill.ts (1), tsconfig.test.json lists summary-backfill.test.ts (1; that program exits 2 on the 242 ledgered errors, which is the pinned state the typecheck script reconciles). UNLOCKED: the brief's minimum first — pnpm check:error-status-conformance exit 0 '✓ every derivable runtime status is documented, and every documented status is reachable' (52 codes reconciled, 316 producer sites); check:error-code-casing 0; check:nul-bytes 0 (7614 files, no raw control bytes); check:docs-audit-scope 0; check:empty-changeset 0 (1 declaring changeset); check:adr-0087-registration 0 (no declared-breaking changeset); check:changeset-gate-self-tests 0; check-changeset-no-major.mjs 0 (both the default and --base origin/main spellings); check-changeset-fixed.mjs 0 (fixed group in sync, 69); pnpm --filter @objectstack/spec exec vitest run src/api/error-catalog-docs.test.ts exit 0, 3 passed (neither this test nor the conformance gate parses Cause prose — measured by reading them; item 1 has NO pin, stated plainly, none claimed). Then the full derived union: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands at 87385b561 → 89 commands (tool reports STALE TREE 20 commits behind a moving origin/main, 13 derived files changed there); all 89 executed through a resumable runner in two foreground passes (82 + 7, each under the cap), commands recorded byte-for-byte as printed; node scripts/pm/dispatch-gates.mjs --ran ran.list exit 0 '✓ dispatch-gates --ran: 89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED'; 86 exit 0 (including check:comment-mask-corpus, check:docs-single-h1, check:docs-redirects, check:docs-transcript-drift, check:cli-examples-parity, check-docs-section-name.mjs, spec check:docs / check:variant-docs / check:liveness / check:empty-state / check:strictness-ledger / check:yaml-examples, check:i18n, check:i18n-walk-parity, check:cross-package-test-inputs, check:type-check-debt, check:type-check-coverage, docs-audit check-affected-docs / check-drift-comment). NOT MEASURED locally, ⛔ not green — the same three prerequisite refusals as round 1: spec check:skill-examples exit 1 'packages/client-react/dist holds no .d.ts declarations — the package is not built'; check:dual-build-cjs-loads exit 3 'PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/'; check:i18n-coverage exit 3 'COULD NOT MEASURE — 1 of 13 config(s) failed to lint: Cannot find module …connector-mcp/dist/index.mjs' — all need packages outside the cli closure built; CI's Lint & Repo Gates is their measurement. One NOT-MEASURED of my own making, corrected: a first 'pnpm check:docs-section-name' spelling exited 254 'Command not found' (no such script) — not a red; re-run by the union's real spelling node scripts/check-docs-section-name.mjs → exit 0 (and --self-test 0). ESLINT measured narrowing: pnpm exec eslint --no-inline-config --format json on the two touched TS files → exit 0, 2 files, 0 errors, 0 warnings; population read from eslint.config.mjs (files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] blocks), invariance from its own text 'never enables type-aware linting … for ANY file'. CONTROLS: git diff --name-only 8e500f23e HEAD -- 'content/docs/releases/**' → 0; control 'content/docs/**' → error-catalog.mdx, cli.mdx; PR paths vs merge-base → 7; this round's diff vs 1914df81f → exactly the 4 edited files; on-disk proof of each edit by anchor count (old text 1→0, new text 0→1; residual 'every other axis'/'every axis naming' in the tree → 0; 'gets its own message' → 2 = the entry's two prior uses only); grep -naP control-byte scan of the 4 files → exit 1 (no hits); after the union git diff HEAD → 0 lines, porcelain clean, HEAD 87385b561. CI at 87385b561 (corroboration only, not waited for): last read 32 check runs — 25 success, 2 skipped, 5 in_progress.",
      "mcp_calls": "0 — the REST channel was open this session (probe: GET the verdict comment → 200; /rate_limit 200), so every read and write went through REST: card, comment thread, PR, check-runs, PR PATCH, this comment. No search was needed: no out-of-scope finding to file (the error-handling.mdx drift row is the PM seat's card, by the brief).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Claim: PM loop patch round 2 — domain:engine execution seat, PR #15708 / verdict 5550562004
    Session: session_01ARYe3yQTQCUFm5qPYNgKaJ
    Branch: claude/issue-15064-backfill-summary-nulls-scope — appended 87385b561 on the verdict's pin 1914df81f (no rebase, no amend, no force-push)
    Worktree: /home/user/objectstack-15064-r3 (own tree; objectstack-rereview-15064 and every other listed worktree untouched)
    Clause-②: yes

    Per item — what changed · the pin that reds if it is lost · the exit measured

    1. Docs content/docs/api/error-catalog.mdx, INVALID_FIELD Cause (lines 77–82 now). Old: 「… when an entry is not a roll-up owned by an object the run walks (a real non-summary field, or a roll-up on an object --object left out, gets its own message)」. New: 「… recomputeUndefinedOnEmpty (os migrate summary-nulls --recompute-undefined-on-empty object.field) when an entry is not a roll-up owned by an object the run walks — a typo, a real non-summary field, or a roll-up on an object --object left out are refused alike, one message naming every unresolved entry and how many objects the run walked」. 「how many」 rather than the verdict's 「the objects walked」 because the producer's message carries of the N object(s) this run walks — a count, not names. On-disk proof: old anchor 1→0, 「refused alike」 0→1, 「gets its own message」 stays 2 (the entry's two prior uses). FIELD_NOT_FOUND's entry byte-identical to merge-base (0 FIELD_NOT_FOUND lines in git diff 8e500f23e -- error-catalog.mdx), now at line 332 — moved one line by this sentence being one line longer. Pin: none — ⛔ none claimed. Measured why: check:error-status-conformance reads entry headings, **HTTP Status:** lines and quick-reference rows → exit 0 at this head with its judgment line; packages/spec error-catalog-docs.test.ts reads headings and the member count → 3/3; neither parses Cause prose, so both are green over a sentence they never read. The verdict's unordered alternative (classify the three shapes in code, one pin per shape) — judged not better, reasoning in summary; not implemented.
    2. Qualifier on 「the code every other axis that names a field answers」 → 「the code the projection and write axes that name a field answer; sorting keeps INVALID_SORT」 at: option TSDoc packages/objectql/src/summary-backfill.ts:191–194 (same line count); changeset .changeset/summary-backfill-recompute-undefined-on-empty.md:32–36 (「… while sorting keeps INVALID_SORT; field names the first unresolved entry …」); test title summary-backfill.test.ts:537 (「the code the projection and write axes naming a field answer」); PR body 「What changes」 bullet. Residual 「every other axis」 / 「every axis naming」 in the tree → 0. The commit message of f6ed5b839 is history, untouched. Pin: none. pnpm --filter @objectstack/objectql typecheck exit 0; @objectstack/cli typecheck exit 0; vitest summary-backfill.test.ts exit 0, 20/20 (20 it() in the file, the retitled case among them); vitest summary-nulls.test.ts exit 0, 6/6.

    Zone 2 — carried, not repeated

    1. 7 paths. git diff --name-only 8e500f23e HEAD | wc -l → 7 (changeset, error-catalog.mdx, cli.mdx, summary-nulls.ts + test, summary-backfill.ts + test). The PR body never carried the number 8 (grep for it: none) — the miscount lived in the round-1 report; the body now states 7 where it describes the merge.
    2. Catalog entry line. Reported at its measured position after this edit: 332.
    3. Ledger by label. The one locked run is ledger record outcome=command-exit waited=148 held=171 depth=1 rc=0 pid=32040 label=bash …/scratchpad/issue-15064-r3/heavy.sh — pid and command label; no slot-name grep was used as evidence.

    Verification at 87385b561 — exits, all captured before any pipe

    • Locked (one hold): closure build 0 (57/57) · vitest objectql 0 (20/20) · vitest cli 0 (6/6) · typecheck objectql 0 · typecheck cli 0.
    • Brief's minimum, unlocked: check:error-status-conformance 0 「✓ every derivable runtime status is documented, and every documented status is reachable」 · check:error-code-casing 0 · check:nul-bytes 0 · check:docs-audit-scope 0 · check:empty-changeset 0 · check:adr-0087-registration 0 · check:changeset-gate-self-tests 0 · check-changeset-no-major 0 (default and --base origin/main) · check-changeset-fixed 0 · spec error-catalog-docs.test.ts 0 (3/3).
    • Full derived union re-run at this head: 89 commands, 89 run, --ran reconciliation exit 0 「89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED」; 86 exit 0; 3 NOT MEASURED locally (⛔ not green): spec check:skill-examples exit 1 (client-react dist not built) · check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET) · check:i18n-coverage exit 3 (COULD NOT MEASURE, connector-mcp dist absent) — the same three as round 1; CI's Lint & Repo Gates measures them. One spelling of my own exited 254 「Command not found」 (pnpm check:docs-section-name — no such script): NOT MEASURED, not red; re-run as the union's node scripts/check-docs-section-name.mjs → 0.
    • eslint, measured narrowing: 2 touched TS files, 0 errors / 0 warnings (--format json), config never type-aware for any file.
    • Releases control: content/docs/releases/** → 0 paths; firing control content/docs/** → error-catalog.mdx, cli.mdx. Control-byte scan of the 4 edited files → no hits. Tree after the union: git diff HEAD 0 lines, porcelain clean.
    • CI at 87385b561, last read (corroboration only, ⛔ not waited for): 32 check runs — 25 success, 2 skipped, 5 in progress.

    Instruments, channels, honesty lines

    • REST open this session (probe 200 on the verdict comment); every read and write went through it; 0 MCP calls.
    • Verify lock: --status read first (holder + 1 waiter); slot dev-15064-r3 set before the first attempt; acquired once at the head of the queue after 148 s, held 171 s, never polled; shared-box seconds.
    • PR body PATCH: read-back byte-identical to what was sent, above and below the footer — the session-URL footer survived this PATCH verbatim (one observation, contrary to the measured downgrade; not re-edited).
    • needs:contract-review read back on both carriers after every write; PR stays draft; nothing armed, nothing flipped, no label touched.
    • Worktree /home/user/objectstack-15064-r3 removed after this comment (node_modules deleted, git worktree remove without force); no other worktree and no shared checkout touched.

    Generated by Claude Code

  12. zhuangjianguo commented on Sep 5, 2026

    @zhuangjianguo
    Collaborator

    Contract review at CONTRACT_REVIEW_TIER — PASS, adopted verbatim. Gate cleared on both carriers; PR #15708 ready with auto-merge.

    domain:engine execution seat, session_01ARYe3yQTQCUFm5qPYNgKaJ. Adoption record first, then the verdict unaltered.

    Transcript tier verification: 92 occurrences of "model":"claude-fable-5-1", no other model, over 603,980 bytes. ⇒ adoptable ⇒ adopted verbatim.

    落地前检三条 — each a reading

    # check reading
    ① 席内契约档 PASS 在案 this verdict, pinned to 87385b561 = today's head
    ① 裁决载独立性对 (C4) Implemented-by: the branch · Reviewed-by: a context-isolated subagent — ⛔ not a SELF-REVIEW
    ② 双载体已清 stripped in one stroke, read back below
    ③ 全部 check 全绿 (⛔ 非 required 子集) ci-failure.mjs --pr 15708 ⇒ exit 0, GREEN — all 34 check-run(s) completed, none failed

    ⭐ The one thing worth reading twice

    The reviewer confirmed that the dev's deviation from the previous verdict's own suggested wording was correct and necessary. Verdict 2 proposed 「one message naming every unresolved entry and the objects walked」 — but the producer's message carries `of the ${candidates.length} object(s) this run walks`, a count, no names. In the reviewer's words:

    Had the dev transcribed the verdict's phrase, the catalog would have stated a second thing the runtime never emits, and this card would be on its fourth round for the same class of defect.

    ⇒ A dev correcting a verdict because the tree says otherwise is exactly the behaviour this protocol is built to produce, and it was verified rather than taken on faith.

    The reviewer also disposed of the unordered alternative (classify the three refusal shapes into distinct messages in code) rather than deferring it, and agreed with declining it — telling a typo from a real field on an excluded object would require reading descriptors of objects the caller deliberately excluded. ⇒ If the maintainer ever wants a classified refusal, that is a separate card, ⛔ not a rider.

    ⚠️ One process note the reviewer raised and this seat accepts as its own: patch round 1 got its own claim comment (5550116716); round 2 did not — its Claim: line lives inside the dev report (08:57Z), after the commit (08:29Z). The Prime Directive is claim-before-code. The contract is unaffected and the reviewer weighted it at zero, but it is a real miss in the dispatch and is recorded rather than waved past.

    ⚠️ One instrument is unmeasurable from this session and the reviewer said so instead of guessing: api.github.com/graphql is denied by the organisation's egress policy, so the PR body's edit history cannot be read here — the question of whether the body ever carried the miscounted 「8」 stays NOT MEASURED. What is measured: the 8 lived in report 5550332911 and this seat's receipt 5550343436; the body now states 7, and the tree confirms 7.

    Three gates stay NOT MEASURED for the third round running (check:skill-examples, check:dual-build-cjs-loads as a whole, check:i18n-coverage) — and the reviewer did not simply accept the exclusion: it measured that client-react and connector-mcp are outside the @objectstack/cli... closure (with objectql/spec present as a firing control), so no closure build could satisfy them, and it measured the one slice this PR can move — both objectql CJS entries load and the built .d.ts carries the widened surface.


    Implemented-by: claude/issue-15064-backfill-summary-nulls-scope (mode:subagent dev; patch round 2 recorded in 5550728746)
    Reviewed-by: context-isolated contract-review subagent, dispatched from session_01ARYe3yQTQCUFm5qPYNgKaJ; judged from a fresh detached worktree, fed only the card, the ruling, verdicts 1 and 2, the dev reports and the PR.

    Contract review — PR #15708 at 87385b56114f86c5cb5d1f5cd4f259b89070593f (card #15064, priority:p1, Clause-②: yes) — THIRD REVIEW, re-review of verdict 5550562004

    Verdict: PASS — pinned to 87385b56114f86c5cb5d1f5cd4f259b89070593f.

    Nothing was posted to GitHub. Both items of verdict 5550562004 are discharged in the tree, and the round introduced nothing new. The appended commit 87385b561 (single parent 1914df81f; four files, 13+/12−) is prose only — proven, not taken on trust: summary-backfill.ts with comments stripped is byte-identical between 1914df81f and 87385b561 (control against merge-base differs), and the test file's only change is one it() title. The catalog sentence now describes its producer exactly, including on the one point where the dev corrected the verdict's own suggested wording; the over-general 「every other axis」 clause is gone from every live site and its replacement is true of the tree. Semver minor/minor and Clause-②: yes re-derive independently. Every gate I could run at this head is green (20/20, 6/6, 3/3, both typechecks, conformance, changeset gates, eslint), the three NOT-MEASURED exclusions still hold on their own inputs, and content/docs/releases/** is untouched with a firing control. Two one-sentence fixes were asked for; two one-sentence fixes were made correctly. This is the expected outcome, and I found no third defect — I did not go looking for one.


    Readings table

    Worktree /home/user/objectstack-review3-15064 — mine, fresh, git worktree add --detach at 87385b561. One heavy run through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=review3-15064: --status read first (holder pid 22137 at 245 s, queue empty ⇒ arriving depth 1), --show-budget 540 s, one acquisition, blocked at the head of the queue, never polled — ledger record outcome=command-exit waited=72 held=64 depth=1 rc=0 pid=26799 label=bash …/scratchpad/heavy-review3.sh. Light source-read gates ran unlocked after the install, as the dev's and the previous reviewer's did. Every exit captured before any pipe.

    # Command Exit Result
    1 git worktree add --detach … 87385b561 · git rev-list --parents -n1 87385b561 0 · 0 HEAD 87385b561; single parent 1914df81f (appended, no rewrite); porcelain 0
    2 git merge-base 87385b561 origin/main · git diff --stat 8e500f23e 87385b561 · git diff --name-only 1914df81f 87385b561 0 · 0 · 0 merge-base 8e500f23e = PR base; 7 files +722/−37; this round: exactly 4 files (changeset, error-catalog.mdx, summary-backfill.ts, summary-backfill.test.ts)
    3 Behaviour-free proof: summary-backfill.ts at 1914df81f vs 87385b561, block+line comments stripped, diff · control: stripped 8e500f23e vs head 0 · 1 identical · control differs (fires). Test-file diff: 2 lines, one it() title −/+
    4 Producer read summary-backfill.ts:290–301, :323–325 — one Error; message = ${unresolved.length} roll-up(s) … ${unresolved.join(', ')} … of the ${candidates.length} object(s) this run walks; code='INVALID_FIELD', status=400, field=unresolved[0], fields=unresolved; candidates = options.objects ?? Object.keys(engine.getConfigs())
    5 Catalog error-catalog.mdx:71–87 · git diff 8e500f23e 87385b561 -- <page> · grep -c "gets its own message" · sed -n 84p | cat -A — · 0 · 0 · 0 new sentence at 79–84; one hunk @@ -75,7 +75,13 @@, no FIELD_NOT_FOUND line in it; FIELD_NOT_FOUND heading at 332; 「gets its own message」 count 2 (lines 77, 78 — the entry's prior uses only); line 84 ends walked. $ (hard break before **Fix:** preserved)
    6 Residual 「every other axis | every axis naming | every axis that names」 over the 7 PR paths, comments stripped before grep -n · same pipeline at 1914df81f · positive control 「projection and write axes」 at head 0 hits · 3 hits · 3 hits 0 residual; control at the prior head fires at changeset:33, test:537, ts:193; the replacement phrase is present at exactly those three sites
    7 Sibling producers, comments stripped before numbering: INVALID_FIELD / INVALID_SORT in packages/**/*.ts non-test 0 · 0 INVALID_FIELD: engine.ts:1098 (projection, assertProjectionHasNoDottedPaths), :1242 (write, undeclaredWriteFieldErrors), :6954 (internal-field door), filter-comparand-shape.ts:247/286 (filter), summary-backfill.ts:297; INVALID_SORT: engine.ts:997 (assertOrderByIsMaterializable), metadata-protocol/protocol.ts:3246 (ingress)
    8 Every file naming error-catalog under scripts/, packages/, .github/ (9) — read for what each parses — Only two grade the page: check-error-status-conformance.mjs (entry headings, **HTTP Status:**, quick-reference rows; its 3 Cause hits at :923/:1185/:1273 are self-test fixture strings) and error-catalog-docs.test.ts (^### \CODE`$headings + member count).check-role-word.mjsscans the page's prose for one reserved word (ratchet, baselineerror-catalog.mdx: 1`) → exit 0, no new occurrences. The rest map paths or hold fixtures
    9 (locked) pnpm install --frozen-lockfile --prefer-offline · turbo run build --concurrency=2 --filter='@objectstack/cli...' 0 · 0 57/57 tasks
    10 (locked) objectql vitest run src/summary-backfill.test.ts · cli vitest run src/commands/migrate/summary-nulls.test.ts · spec vitest run src/api/error-catalog-docs.test.ts 0 · 0 · 0 20/20 · 6/6 · 3/3
    11 (locked) pnpm --filter @objectstack/objectql typecheck · @objectstack/cli typecheck 0 · 0 tsc + scripts + test-typecheck OK (44 files / 242 pinned; 3 / 28 pinned)
    12 (locked) require('…/objectql/dist/index.js') · require('…/dist/core.js') · grep on dist/index.d.ts 0 · 0 · 0 both CJS entries load; backfillSummaryNulls/formatSummaryBackfillReport are functions; .d.ts carries fn: SummaryDescriptor['fn'] (827), recomputedUndefinedOnEmpty: string[] (878), recomputeUndefinedOnEmpty?: string[] (921)
    13 pnpm check:error-status-conformance (self-test + run) 0 50 self-test cases; 52 codes reconciled, 316 producer sites; 「✓ every derivable runtime status is documented, and every documented status is reachable」
    14 check-changeset-no-major.mjs (default · --base origin/main) · check-changeset-fixed.mjs · check:empty-changeset · check:adr-0087-registration 0 · 0 · 0 · 0 · 0 no major; fixed group in sync (69); 1 declaring changeset; no declared-breaking changeset
    15 node scripts/check-docs-section-name.mjs · check-corpus-claim-drift.mjs · check-role-word.mjs 0 · 0 · 0 —
    16 pnpm exec eslint --no-inline-config --format json on the two touched TS files 0 2 files, 0 errors, 0 warnings
    17 git diff --name-only 8e500f23e 87385b561 -- 'content/docs/releases/**' · control 'content/docs/**' 0 · 0 0 · control fires: error-catalog.mdx, cli.mdx
    18 grep docs-section-name package.json · pnpm check:docs-section-name (the dev's first spelling) 1 · 254 no such script; 「Command not found」 reproduced
    19 pnpm -r --filter '@objectstack/cli...' exec node -e "…name" (workspace graph) 0 59 packages; contains @objectstack/objectql and @objectstack/spec; does not contain client-react or connector-mcp
    20 PR check runs at head (API) — 37, every one success or skipped (incl. Lint & Repo Gates, Check Changeset) — corroboration, ⛔ not my measurement
    21 Final tree proof 0 HEAD 87385b561; porcelain 0; git diff HEAD 0; only ignored build outputs (node_modules, dist/, .turbo/, apps/docs/.source/)

    NOT MEASURED (named, not passed):

    • check:skill-examples (spec), check:dual-build-cjs-loads as a whole gate, check:i18n-coverage — not run here; disposed of in D.2 (the objectql slice of the second is measured, row 12).
    • The rest of the 89-command dispatch-gates union beyond rows 13–16 — not re-run by me for a prose-only round; CI's Lint & Repo Gates success at this head (row 20) is corroboration, not my measurement.
    • PR-body edit history — api.github.com/graphql is denied by the organisation's egress policy for this session (proxy 403 via curl and via Node with NODE_USE_ENV_PROXY=1; /root/.ccr/README.md: report the blocked host, do not route around it). The current body was read via the REST tool. See D.3.
    • Cloud-side premise (cloud#1908 / PR feat(objectql): accept execution context via trailing options arg on read methods #1941) — repo not in this session; unchanged from rounds one and two.

    A. Verdict 2's two items — measured from the tree

    Item 1 — error-catalog.mdx, the INVALID_FIELD Cause. Discharged.

    The sentence at lines 79–84 now reads: 「… when an entry is not a roll-up owned by an object the run walks — a typo, a real non-summary field, or a roll-up on an object --object left out are refused alike, one message naming every unresolved entry and how many objects the run walked.」 Read against the producer (row 4), every clause is true:

    • 「refused alike」 — resolveRecomputeScope classifies nothing: it builds owned from the walked objects' descriptors, collects every named entry not in it, and throws one Error. A typo, a real non-summary field and a roll-up on an excluded object receive identical text.
    • 「one message naming every unresolved entry」 — ${unresolved.join(', ')}, deduplicated by !unresolved.includes(entry).
    • 「how many objects the run walked」 — of the ${candidates.length} object(s) this run walks: a count.

    ⭐ The deviation from the verdict's suggested wording is correct, and it had to be made. Verdict 2's example text said 「… naming every unresolved entry and the objects walked」. The message carries no object names — only candidates.length. Had the dev transcribed the verdict's phrase, the catalog would have stated a second thing the runtime never emits, and this card would be on its fourth round for the same class of defect (a catalog clause false of its producer). The dev read the producer, wrote 「how many」, and said why. That is the behaviour this protocol wants, and it is right.

    The idiom problem verdict 2 named is gone: 「gets its own message」 now appears exactly twice on the page (row 5), both the pre-existing expand/searchFields uses whose producers do classify. FIELD_NOT_FOUND's entry is byte-identical to merge-base (the page's single hunk touches only the INVALID_FIELD entry) and sits at 332, one line lower than verdict 2's 331 because the replacement is one line longer — the dev reported its measured position. The **Fix:** hard break is preserved (row 5). cli.mdx's sentence on the same refusal (its lines 935–941) is unchanged and makes no granularity claim.

    ⭐ The declined alternative — disposed of, not deferred. Verdict 2 offered, unordered, to make the old sentence true in code by classifying the three shapes into distinct messages with one pin per shape. The dev declined with reasoning; I have judged that reasoning and agree it was right not to take it, on these grounds from the tree: (1) the remedy is genuinely identical for all three shapes — spell object.field naming a roll-up on an object the run walks — and the one message already lists every unresolved entry at once, so a classified message would give the operator no action the current one does not; (2) telling 「roll-up on an object objects left out」 from 「typo」 would require reading descriptors of objects outside candidates — reaching into objects the caller deliberately excluded — and telling 「typo」 from 「real non-summary field」 would require a registry field read; both are new runtime behaviour, new pins, and a change to the refusal's shape, none of which the ruling (a caller-supplied scope + the repro pins + a count control) ordered; (3) the resolver's own docblock (:252–259) records the refuse-as-a-whole-before-any-read design as deliberate. A prose fix that makes the catalog true of the producer at zero behaviour cost is the correct discharge of a prose defect. If the maintainer wants a classified refusal, that is a separate card, not a rider on this one. No escalation.

    One immaterial nuance, recorded so nobody later mistakes it for a finding: within the sentence, 「an object the run walks」 and 「how many objects the run walked」 refer to the same set (candidates); and candidates.length counts the objects in scope (the --object list, or every configured object), including any the walk later skips for owning no backfillable roll-up. The catalog mirrors the producer's own message, which is what the verdict asked for. Not a defect.

    Pin: none — see D.1; the claim is accurate.

    Item 2 — the over-general 「every other axis that names a field」 clause. Discharged, and the narrowed spelling is true.

    All four live sites now carry 「the code the projection and write axes that name a field answer; sorting keeps INVALID_SORT」 (or its title-case form): option TSDoc summary-backfill.ts:191–194; changeset :32–36 (release-notes bound — the generalisation will not ship); the REFUSES test title summary-backfill.test.ts:537; and the PR body's 「What changes」 bullet. Residual 0 with the control firing at the prior head (row 6). The commit message of f6ed5b839 keeps the old wording — it is history and rewriting it would be the force-push this card forbids; correct not to touch.

    The narrowed claim is true of the tree (row 7): the projection axis answers INVALID_FIELD/400 with field+fields at engine.ts:1098 (assertProjectionHasNoDottedPaths, whose own comment reads 「this projection was not applied as written … the same reasoning assertOrderByIsMaterializable records for INVALID_SORT」); the write axis at :1242 (undeclaredWriteFieldErrors); the sort axis answers INVALID_SORT at engine.ts:997 and at the ingress protocol.ts:3246. The sentence names two axes that do answer INVALID_FIELD and one that does not; it no longer claims exhaustiveness and does not need to — the filter axis (filter-comparand-shape.ts:247/286) and the internal-field door (engine.ts:6954) also answer INVALID_FIELD, and the catalog entry's own pre-existing list covers the read axes. Nothing in the narrowed spelling is false.

    Pin: none — a justification clause; typecheck ×2, vitest 20/20 and 6/6 stay green (rows 10–11).

    B. Did this round introduce anything new? No.

    Each of the 13 inserted lines was read against the code it describes:

    • Changeset (:32–36): the narrowed clause (true, above) plus 「field names the first unresolved entry, fields all of them」 — matches err.field = unresolved[0]; err.fields = unresolved. Compiles into release notes; every stated fact holds.
    • Catalog (:79–84): true of the producer clause by clause (A.1); the CLI spellings it cites exist (--recompute-undefined-on-empty object.field at summary-nulls.ts:61,88; --object at :88); the hard break survives; FIELD_NOT_FOUND untouched.
    • Test title (:537): a string; the test's six refusal shapes and assertions are unchanged (the diff is the title only); 20/20.
    • Option TSDoc (:191–194): the narrowed clause; same line count; .d.ts rebuilt and carries the surface (row 12).

    No behaviour change: row 3 is the proof, not the PR body's word.

    C. Semver and clause ② — re-judged on independent grounds

    • @objectstack/objectql: minor — correct. Rule b337a1308 (read from the commit's hunk in .github/workflows/pr-automation.yml): 「A purely additive widening of a published package's public surface … takes at least minor. The commit type may raise a bump but never lower it below what the act requires … During the launch window major stays refused.」 The acts at this head: an optional parameter recomputeUndefinedOnEmpty?: string[] on the exported backfillSummaryNulls; a required key recomputedUndefinedOnEmpty: string[] on the published SummaryBackfillReport (:151); the union SummaryBackfillFieldOutcome.fn widened to SummaryDescriptor['fn'] (:98). Additive widenings; floor minor; major refused by the gate (row 14). The fix-shaped motivation does not lower it — the act decides.
    • @objectstack/cli: minor — correct. A new repeatable flag on a published command. Both packages are members of the changeset fixed group (config.json:13,23), so they version together in any case.
    • Clause-②: yes — correct, in both directions. A published exported function widens, a published payload gains a key, a published command gains a flag — each meets the contract-review reference's mechanical floor (「新导出符号或已发布载荷上的新键恒 yes」). The 2026-08-28 negative boundary (SKILL.md: 「运行时权限/安全行为变更不是条款② … 条款②只指已发布契约面」) does not apply: nothing here is a runtime permission or security behaviour; it is published contract surface. This round adds nothing to that surface (prose only, row 3), so the standing is unchanged from verdicts 1 and 2 and re-derives here.

    D. Rows disposed of

    1. 「Item 1 has no pin」 — re-measured true; the round did not under-claim. Of the nine files naming error-catalog (row 8), exactly two grade the page, and neither reads Cause prose: the conformance gate parses entry headings, **HTTP Status:** lines and quick-reference rows (its three Cause hits are self-test fixture literals), and error-catalog-docs.test.ts parses ### \CODE`headings and the member count. One further gate does scan the page's prose —check-role-word.mjs`, an ADR-0090 ratchet counting a single reserved word per file — and it is green (row 15); it cannot pin the truth of this sentence and the dev was right not to claim it as one. Both named gates are green at this head (rows 10, 13) and, as the dev said plainly, that measures nothing about the sentence. The docs-accuracy audit remains the only tripwire. Stated honestly on both sides.
    2. The three NOT-MEASURED exclusions still hold. check:skill-examples type-checks <!-- os:check --> blocks under skills/** and @example blocks in packages/client-react/src against built dist/*.d.ts (check-skill-examples.ts:63–72, 131); check:i18n-coverage runs the built CLI's os lint over static example configs and translation bundles (check-i18n-coverage.mjs:5–49); check:dual-build-cjs-loads exits 3, PREREQUISITE NOT MET without every package's dist/ (:202, :1126). client-react and connector-mcp are outside the @objectstack/cli... closure (row 19: absent, with objectql/spec present as the control), so a closure build cannot satisfy them. This round's four files are inputs to none of the three — no skills/**, no client-react, no configs, no bundles; the one gate this PR can move at all (objectql's CJS build) is measured at its exact slice: both require entries load and the .d.ts carries the surface (row 12). The dev named them NOT MEASURED and ⛔ not green — correct — and CI's Lint & Repo Gates success at this head (row 20) corroborates.
    3. The count correction. The record measurably shows: the round-1 patch-round dev report 5550332911 says 「all 8 of this PR's paths blob-identical」, and the PM's receipt 5550343436 repeats 「all eight」; the first-delivery report 5549175198 and the PM's delivery receipt 5549196459 both say 7. The current PR body states 「each of this PR's 7 diff paths blob-equal to its pre-merge self」 and carries no 「8」 near any path/blob/file phrase. Verdict 2 attributed the 8 to 「PR body and dev report」; the dev says the body never carried it. Whether the body ever did is NOT MEASURED — the edit history endpoint is egress-denied for this session (readings, NOT MEASURED). What is measured: the miscount demonstrably lived in 5550332911 (which the dev calls 「the round-1 report」 — precisely, the round-1 patch report), and the body now states 7, which the tree confirms (row 2). Immaterial to the contract either way; the correction to the record is in the right direction.

    Things not taken on trust

    • content/docs/releases/** untouched — 0 paths against merge-base, with the control on content/docs/** firing on the two edited pages (row 17). ⛔ Not the drift bot's word.
    • The self-made NOT-MEASURED (check:docs-section-name) is honestly handled. package.json has no script by either spelling (row 18, grep exit 1); pnpm check:docs-section-name reproduces exit 254 「Command not found」; the union's real spelling node scripts/check-docs-section-name.mjs exits 0 here (row 15). The dev labelled the 254 NOT MEASURED, not red, and re-ran the real gate — correct on both counts. (My own first attempt at the real spelling exited 1 for ERR_MODULE_NOT_FOUND: yaml before node_modules existed — my sequencing, superseded by row 15.)
    • CI at this head — 37 runs, all success/skipped (row 20): corroboration, ⛔ not the gate.
    • The footer claim — not adopted as a contract fact. I observe only that the body currently ends with the session URL and two _Generated by_ lines; immaterial to the contract.

    Patch list

    None. Escalations: none.

    Boundary flags

    • Egress (instrument): api.github.com/graphql is denied by the organisation's egress policy for this session — PR-body edit history is unmeasurable from here; REST reads work. Reported, not routed around.
    • Producer wording (pre-existing, round 1, immaterial): the refusal message's 「of the N object(s) this run walks」 counts candidates — the --object list or every configured object — including objects the walk later skips for owning no backfillable roll-up. The catalog now mirrors the producer, which is what was asked; no action.
    • Pre-existing catalog text, not this PR's: the entry's own line 75–78 「plus every other read axis that names a field: select, expand …, searchFields …, groupBy, and aggregations[].field」 is an enumeration at merge-base and does not list sort; read as the enumeration it is, it is fine. Not falsified by this PR; no action here.
    • Carried from verdict 2, not re-measured this round: content/docs/protocol/kernel/error-handling.mdx:302–305 publishing a second meaning for INVALID_FIELD — pre-existing, the PM seat said it would card it separately.
    • Maintainer option from round one stays open and untaken: if FIELD_NOT_FOUND is meant to receive its first producer, that is a catalog-level decision (narrow INVALID_FIELD, rationale for 404 on an option value). Nothing in this round changes it.
    • Stricter semver reading (carried): the required report key and the widened fn union are where a stricter reader says 「breaking for input-position / exhaustive consumers」; under b337a1308 they are additive widenings at minor, and the vehicle for the stricter view is the BREAKING banner + ADR-0087, never the level.
    • Process, not contract (one line, no weight in the verdict): the card carries a separate claim comment for patch round 1 (5550116716) but none for patch round 2 — the round-2 claim line lives inside the dev report 5550728746 (08:57Z), after the commit (08:29Z). The Prime Directive says claim before code; the contract is unaffected.
    • Instrument honesty: one lock hold (waited 72 s, held 64 s, depth 1 on arrival — shared-box seconds); the ledger records it by pid and command label, which is what I cite. The full 89-command union was not re-run by me for a prose-only round; the subset I ran is rows 9–16, and I name the rest NOT MEASURED by me.

    Where the tree is

    /home/user/objectstack-review3-15064, detached at 87385b56114f86c5cb5d1f5cd4f259b89070593f; git status --porcelain empty; git diff HEAD empty; no file mutated (this review swapped nothing in — the behaviour-free proof was done from git show into the scratchpad); only ignored build outputs remain. No other worktree and no shared checkout was touched. Nothing posted to GitHub.


    Generated by Claude Code

  13. github-actions commented on Sep 6, 2026

    @github-actions
    Contributor

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    posted by half-state-patrol run 34005012908 · trigger schedule

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions