Skip to content

fix(spec): the stored-filter conversion's TODO for a null-valued key is true on every block, and no longer says to drop the key - #20709

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20662-null-key-reason
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20662-null-key-reason

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20662

Clause-②: no (author-shown wording only; no accept or reject moves)

What changes

The ADR-0087 D2 conversion page-component-filter-record-to-rule-array leaves a record-form filter with a null-valued key as stored and reports it as a TODO, which os migrate meta --stored prints. Its reason said the renderer skips that key, so it "constrains nothing today", and told the operator to "Drop the key". At the .objectui-sha pin dd3f7e1be356 that is true only on a block that queries an object. On a block whose rows are inline, the key selects the rows whose value is null, so dropping it widens the block.

Following triage's direction (comment 5893989151: one wording true on both kinds of block, no "drop the key" advice), the reason now:

  • states what the key does on each kind of block: skipped where the block queries an object, so it constrains nothing; matched where its rows are inline (data: { provider: 'value' } or staticData), so it selects the rows whose value is null;
  • says that no one rule keeps both;
  • names the rule for the rows with no value, {"field":"owner_id","operator":"is_null"} (built from the key), and says that a filter which leaves the key unconstrained has no rule for it. It advises neither rewrite. The choice is the author's.

The verdict does not move. The filter is still declined, left byte-identical and reported as one TODO, on any block. The reason text does not branch on where the rows come from; the conversion never reads that.

Round 2 (seat note 5897754447, a claim amendment): the empty-operator-object reason beside it ({ amount: {} }) said the object "constrains nothing". At the pin the renderer refuses it instead. Where the block queries an object, convertFiltersToAST throws through refuseEmptyOperatorMap (INVALID_FILTER, 400). Where the block's rows are inline, ValueDataSource.find answers no rows through zeroKeyConditionRefusal. The reason and its docblock sentence now say that, say that no rule spells an operator object with no operator, and keep the renderer's own remedy, dropping the key. The verdict does not move.

Files:

  • packages/spec/src/conversions/registry.ts: the reason string in recordFilterToRules, the sentence in its docblock, and the conversion entry docblock's parenthetical in "What is left exactly as stored" ("the renderer skips that key today"). That parenthetical is a fourth copy of the same claim in the same file. It is text only and fixed in place: same defect, same file under this claim, same gates. Round 2 rewrites the empty-operator-object declined reason and its docblock sentence, text only. Round 3 (review 5898951990) drops "a data array" from the null-key reason's inline list: at the pin a bare data array reaches no ValueDataSource.find. Round 4 makes the rationale in the conversion entry docblock's ## Reach paragraph name the inline sources the filter reaches at the pin (data: { provider: 'value' } or staticData), and adds that a bare data array reaches none of them (object-calendar draws it unfiltered; object-map / object-gantt do not take it as a record source). Comment text only; the verdict sentence is unchanged.
  • packages/spec/src/migrations/entries/semantic/18.element-data-source-and-object-block-filter-rule-array.ts: the same claim in the D3 entry's reason. packages/spec/src/migrations/registry.ts is regenerated by gen:migration-registry and not edited by hand. Round 3 narrows the inline list in its older sentence ("None of this depends on where a block's rows come from …") to data: { provider: 'value' } or staticData.
  • packages/spec/src/conversions/page-component-filter-record-to-rule-array.test.ts: the DECLINED_ROWS comment that restated the null-key claim; the all-or-nothing test's comment, which named owner_id: null for a row that is deleted_at: { $null: true }; and two new pins. A null-valued key gets the same reason on an inline-row object-map and an object-bound one. That reason names the is_null rule for the key, and the block's door takes that rule; the control is that the door refuses the stored record. An empty operator object gets the same reason on both blocks, and that reason names INVALID_FILTER, a code in StandardErrorCode.
  • .changeset/20662-null-key-todo-reason.md: @objectstack/spec patch, covering both reasons.

Verification record

Pin reading (dd3f7e1be356, raw source):

  • packages/core/src/utils/filter-converter.ts convertFiltersToAST skips a key whose value is null/undefined (skippedNullKeys).
  • packages/core/src/adapters/ValueDataSource.ts find sends an object $filter to matchesFilter, and its simple-equality arm compares with comparandEquals, which is value === target. Its is_null arm is value === null || value === undefined. Server-side, parseFilterAST lowers is_null to { $null: true }.
  • The inline branches of ObjectMap / ObjectCalendar pass useResolvedFilter(schema.filter) to new ValueDataSource(...).find. filter-tokens.ts resolveContextTokens returns a null value unchanged.
  • The spec: retire the inline-row decline in page-component-filter-record-to-rule-array once the objectui pin carries objectui#10767 #20305 dev's live probe (report 5893209491) measured that find with { owner_id: null } selects only the null row, and not the 'u1' row or the row that has no key.

Lit, at base 31ed067639 (the stored-migration pass and formatStoredMigrationReport, the function os migrate meta --stored prints through, over a one-page stub sys_metadata holding an object-grid that queries deal and an object-map with data: { provider: 'value' }, both filter: { owner_id: null }):

      TODO page-component-filter-record-to-rule-array: {"owner_id":null} left as stored at pages[0].regions[0].components[1].properties.filter — On the `object-map` block `inline`, this filter has the key `owner_id` set to null: the renderer skips a null-valued key, so today it constrains nothing, while an `equals` rule would test for null. Drop the key, or write a rule that tests for null if that is what it should select. Left as stored, it keeps loading unchanged, but it is not the rule-array form its door declares — rewrite it by hand.

The object-grid line carried the same sentence.

Dark, at a51c02fe83 (same probe, spec rebuilt):

      TODO page-component-filter-record-to-rule-array: {"owner_id":null} left as stored at pages[0].regions[0].components[1].properties.filter — On the `object-map` block `inline`, this filter has the key `owner_id` set to null, and what that key selects depends on where the block's rows come from, so no one rule keeps it: where the block queries an object, the renderer skips a null-valued key, so it constrains nothing; where its rows are inline (`data: { provider: 'value' }`, a `data` array or `staticData`), it selects the rows whose `owner_id` is null. Decide which rows it should select: the rows with no `owner_id` value are the rule `{"field":"owner_id","operator":"is_null"}`, and a filter that leaves `owner_id` unconstrained has no rule for it. Left as stored, it keeps loading unchanged, but it is not the rule-array form its door declares — rewrite it by hand.

In both runs the row is still skipped with two TODOs. The probe file was temporary and is not in the diff.

Round 2, empty operator object (same printer probe, with filter: { amount: {} } on the same two blocks). Lit at c5eed1b4d3: "... this filter has the key amount set to an empty operator object, which constrains nothing — and no rule says "nothing". Drop the key. Left as stored, ...". Dark at 3fcedfb564: "... set to an empty operator object, which names the field and no operator, so no rule spells it. The renderer does not ignore it today: where the block queries an object, it refuses the filter (INVALID_FILTER, 400); where its rows are inline, it answers no rows. Drop the key. Left as stored, ...". Both runs: row skipped, two TODOs. Pin reading at dd3f7e1be356: filter-converter.ts:818 calls refuseEmptyOperatorMap, whose FilterOperatorError has code = 'INVALID_FILTER' and httpStatus = 400; ValueDataSource.find answers [] when zeroKeyConditionRefusal returns a refusal.

Round 3, the inline list (the null-key printer probe, rows null / u1 / missing). Lit at 3fcedfb564: "... where its rows are inline (data: { provider: 'value' }, a data array or staticData), it selects the rows whose owner_id is null. ...". Dark at a6e54de377: "... where its rows are inline (data: { provider: 'value' } or staticData), it selects the rows whose owner_id is null. ...". Pin reading at dd3f7e1be356: record-source.ts:303-307 folds staticData to { provider: 'value', items }, and the value branches hand the resolved filter to ValueDataSource.find (ObjectMap.tsx:950-952, ObjectCalendar.tsx:708-710, ObjectTree.tsx:911-913, ObjectGantt.tsx:1009-1010). A bare data array reaches none of them: ObjectCalendar.tsx:387-389, 599-600, 647 draws it with no fetch and no filter, and the view-data arm refuses an array (record-source.ts:179). The round-1 Dark quote above is the a51c02fe83 reading, before this narrowing.

Tests and gates, at 6f1396efa2 (this branch merged with origin/main 671d4c164f through scripts/pm/os-regen-merge.sh; the delta against main is exactly the five files above). Round 4's one-comment commit eaf2d6e6e8 re-ran the conversions and migrations set (1034 passed), spec typecheck, check:generated (15 up to date), check:doc-authoring and check:issue-citations, all green; the derived gate list is unchanged:

  • @objectstack/spec local project: 575 files, 16972 passed, 1 todo. typecheck, including the test layer, passed.
  • repo project, narrowed to the three files that read the conversion and migration registries (conversions-major18-merge, step18-rationale-merge, retired-key-migrate-sentence): 35 passed. The full repo project did not finish inside the foreground cap and is NOT MEASURED locally; CI runs it.
  • check:generated: all 15 artifacts are up to date against a spec rebuilt after the merge.
  • dispatch-gates --commands derived 87 commands. 84 exited 0, including check:migration-registry, check:spec-changes, check:upgrade-guide, check:docs, check:api-surface, check:authorable-surface, check:objectui-pin-citations, check:doc-authoring, check:nul-bytes and check:adr-0087-registration. Three exited 3 with PREREQUISITE NOT MET, because they need a whole-workspace build: check:dual-build-cjs-loads, check:lean-entry-closure and check:type-check-debt. They are NOT MEASURED locally. --ran reconciles 87 derived: 84 run, 3 NOT-MEASURED, 0 unrun.
  • Ablation of the new pin (scripts/ablation-replace.mjs, from the committed state): the anchor operator: isNull })} was replaced so the reason named an equals/null rule instead. The new test went red: expected the reason to contain {"field":"owner_id","operator":"is_null"}. The other five null-related tests stayed green. The file was restored: its blob matches HEAD and git diff HEAD is empty.
  • Round-2 ablation of the empty-operator pin, from committed bcda701b88: the anchor "block queries an object, it refuses the filter (INVALID_FILTER, 400); where its rows " was replaced with "block queries an object, it constrains nothing; where its rows ". The pin went red: expected the reason to contain INVALID_FILTER. The file was restored: its blob matches HEAD f1f29e6f4802 and git diff HEAD is empty.

Acceptance notes

  • No test pinned the false clause. The existing rows assert only the prefix "has the key owner_id set to null", so there was nothing to re-pin. The new pin checks named subjects: the same reason on both blocks, the is_null rule, and that the door takes it. It does not pin prose. The empty-operator reason was the same: only its prefix was asserted.
  • packages/spec/CHANGELOG.md's 17.5.0 entry carries the old null-key sentence. It is a released record and is not edited; the corrected text ships in this PR's changeset.

Generated by Claude Code

…s true on every block

`page-component-filter-record-to-rule-array` declines a record-form filter
with a null-valued key. Its reason said the renderer skips that key, so it
constrains nothing, and to drop it. At the objectui pin that holds only on a
block that queries an object; on a block whose rows are inline,
`ValueDataSource.find` matches the key and selects the rows whose value is
null, so dropping it widens the block.

The reason, its docblocks and the protocol-18 D3 entry now state both
behaviours, name the `is_null` rule for the rows with no value, and leave
which rows to select to the author. The verdict does not move.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tests tooling labels Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via is_null (literal, a string literal in recordFilterToRules))
What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json b291fcdae9ac6dd4152367082905cfe81ddf5fb0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d66495a5d4dbaa11130925990f31259b5c6602de — the merge of head eaf2d6e6e8d55e3d164ed72a786ee7d260997a6b into base b291fcdae9ac6dd4152367082905cfe81ddf5fb0, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d66495a5d4dbaa11130925990f31259b5c6602de && git checkout d66495a5d4dbaa11130925990f31259b5c6602de
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b291fcdae9ac6dd4152367082905cfe81ddf5fb0 eaf2d6e6e8d55e3d164ed72a786ee7d260997a6b && git checkout -B drift-repro b291fcdae9ac6dd4152367082905cfe81ddf5fb0 && git merge --no-ff eaf2d6e6e8d55e3d164ed72a786ee7d260997a6b

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b291fcdae9ac6dd4152367082905cfe81ddf5fb0 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…he key, not that it constrains nothing

`page-component-filter-record-to-rule-array` declines a record-form filter
whose key is an empty operator object (`{ amount: {} }`). Its reason said the
object constrains nothing. At the objectui pin the renderer refuses it:
`convertFiltersToAST` throws INVALID_FILTER (400) through
`refuseEmptyOperatorMap` where a block queries an object, and
`ValueDataSource.find` answers no rows through `zeroKeyConditionRefusal`
where a block's rows are inline. The reason and its docblock now say so and
keep the renderer's own remedy, dropping the key. The verdict does not move.

Also corrects a test comment that named `owner_id: null` for a row that is
`deleted_at: { $null: true }`.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3fcedfb564c7750cbaffbc654fc291bd3260886a
Local-runs: none

Inputs: card #20662 (body, triage direction 5893989151, claim 5896419836, round-1 report 5897718691, seat note 5897754447, round-2 report 5898670276), PR #20709 (body, comments, five-file list, net diff against origin/main 5757463712, which is the merge base), the objectui sources at pin dd3f7e1be356 (filter-converter.ts, ValueDataSource.ts, record-source.ts, filter-tokens.ts, useResolvedFilter.ts, ObjectMap.tsx, ObjectCalendar.tsx and its index.tsx, ObjectTree.tsx, ObjectGantt.tsx), and the 42 check-runs on this head, read once.

① Derived judgments

1. The null-key declined reason (recordFilterToRules, the value === null branch) — text plus one local const isNull = 'is_null' satisfies ViewFilterOperator that feeds JSON.stringify; the branch still returns { declined }, the filter is left byte-identical, one TODO. Right, clause by clause, except one enumerated item:

  • "where the block queries an object, the renderer skips a null-valued key, so it constrains nothing" — RIGHT. convertFiltersToAST counts the key in skippedNullKeys and continues; a filter of only such keys lowers to undefined.
  • "where its rows are inline …, it selects the rows whose KEY is null" — RIGHT for rows that reach ValueDataSource.find. The object $filter goes to matchesFilter; null fails the operator-branch guard (condition && typeof condition === 'object') and takes the simple-equality arm, comparandEquals(value, null), which is value === null: an explicit-null row is selected, a row lacking the key is not. The reason says "whose KEY is null", which is exactly that.
  • "the rows with no KEY value are the rule {"field":KEY,"operator":"is_null"}" — RIGHT on both kinds. Inline: the is_null arm is value === null || value === undefined, so it takes the null row and the row lacking the key. Object-bound: the rule lowers to the server's $null: true. The reason does not call this rule equivalent to the stored key ("no one rule keeps it"), which is the truth: on inline rows they differ on a row that lacks the key.
  • "a filter that leaves KEY unconstrained has no rule for it" — RIGHT; absence is the only no-constraint spelling in an AND list.
  • No "drop the key" advice for the null key anywhere: reason, docblock, entry-docblock parenthetical, D3 text, changeset. RIGHT.
  • Reach: at the pin the inline branches pass useResolvedFilter(schema.filter, scope) — resolveContextTokens returns a null value unchanged (if (value == null) return value) and a token-free filter is handed out as authored — straight to new ValueDataSource({ items }).find('', { $filter }) in ObjectMap, ObjectCalendar, ObjectTree, and through resolveDataSource to the same adapter in ObjectGantt. RIGHT.
  • WRONG — the enumeration inside the inline clause, "(data: { provider: 'value' }, a data array or staticData)", repeated in the changeset. data: { provider: 'value', items } (the 'view-data' ladder returns it verbatim on map and gantt) and staticData (every ladder folds it to { provider: 'value', items }) do reach ValueDataSource.find. A bare data array does not, on any block at the pin. object-calendar — the one door at this head that declares data as an array, described "Pre-fetched records — skips the internal fetch" — forwards it as the data prop through resolveExternalData (Array.isArray(raw) ? raw : undefined), hasExternalData returns the fetch effect before any query, and setData(externalData) draws the rows as given: no key of the filter is applied. The object-map and object-gantt doors say "the bare-array shortcut is refused", resolveRecordSourceConfig on the 'view-data' arm does not take an array as a record source, and the map commits a host data prop array without a query. So on a data-array block the null key selects nothing — as does every other key — and the clause is false for that spelling. Bounded: neither rewrite the reason offers moves a row there, because the whole filter is inert. Inherited: the same triple is the card body's own framing and stands, unchanged by this diff, in the D3 entry's next sentence. But this diff is what puts the triple into the reason string os migrate meta --stored prints and into the changeset, under the ruling that the wording be true on the block in front of the operator. This is the FAIL reason; the pins do not assert the enumeration, so the remedy is text only.

2. The recordFilterToRules docblock sentence. Names convertFiltersToAST (skips) and ValueDataSource.find through comparandEquals (matches); says "where a block's rows are inline" without the enumeration; "This entry never reads where a block's rows come from" matches the function (no such read). RIGHT.

3. The conversion entry docblock's parenthetical ("skipped where a block queries an object, matched where its rows are inline — no one rule keeps both"). RIGHT.

4. The D3 entry reason parenthetical and migrations/registry.ts. The parenthetical states both behaviours, names the is_null rule for "the rows with no value", and leaves the choice to the author; it carries no enumeration. RIGHT. The step-18 semantic literal for this id in migrations/registry.ts is identical to the entry file modulo indentation (108 trimmed lines compared) and sits inside the generated region: generator-only. PROTOCOL_MAJOR is 17 at this head; build-spec-changes and build-upgrade-guide loop from the support floor to 17, spec-changes.json holds no element-data-source-and-object-block-filter-rule-array record, and docs/protocol-upgrade-guide.md ends at "Protocol 16 to 17". Major 18 is not projected; both artifacts unaffected. RIGHT.

5. The empty-operator-object reason and its docblock sentence. "names the field and no operator, so no rule spells it" — RIGHT. "where the block queries an object, it refuses the filter (INVALID_FILTER, 400)" — RIGHT: filter-converter.ts:818 calls refuseEmptyOperatorMap, which throws FilterOperatorError with code = 'INVALID_FILTER', httpStatus = 400. "where its rows are inline, it answers no rows" — RIGHT: find's object arm runs zeroKeyConditionRefusal before any row, a zero-key object condition is lowered through toFilterNodeSafely, refused, and result = []. "Drop the key" kept — RIGHT: the renderer's own refusal ends "Choose an operator … or remove the key", and the conversion cannot choose an operator for the author, so the drop is the one remedy it can state. The docblock's refuseEmptyOperatorMap and zeroKeyConditionRefusal exist at the pin in the cited roles. (The same data-array caveat applies to "inline" here, but this reason does not enumerate spellings, so it is only as loose as the word.)

6. Verdict unchanged. The registry.ts diff touches two declined: literals, one new local const, and comment text; no if, no id, no return shape, no other conversion's body. The two new pins assert named subjects: the is_null rule's JSON, the object-map door taking [rule] (zero issues at filter) with the stored record refused as control, the same reason on a staticData block and an objectName block, the filter left byte-identical, and INVALID_FILTER present in StandardErrorCode.options and in the reason. No prose pinned. RIGHT.

7. Test comments (DECLINED_ROWS; the all-or-nothing row, which is deleted_at: { $null: true }). RIGHT. CHANGELOG untouched per the seat's ruling. RIGHT.

Gate coverage (42 check-runs on 3fcedfb564: 36 success, 5 skipped, 1 in progress at the read; no failure):

  • NOT concluded at the read: Check Changeset in run 36629793626 (fired after the PR body edit). The same job on the same head concluded success in run 36626149495. Named, not presumed.
  • Skipped: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), and the re-fired Auto Label / Check PR Size of run 36629793626 (their first runs succeeded).
  • The dev's three local NOT MEASURED: check:dual-build-cjs-loads and check:lean-entry-closure live in ci.yml's Build Core — success; check:type-check-debt in lint.yml's Type Check · debt ledger — success. The full spec repo vitest project: Test Core 1/6 to 6/6 — success.
  • check:migration-registry, check:doc-authoring, check:issue-citations, check:generated --reconcile-only: Lint & Repo Gates — success. check:spec-changes, check:upgrade-guide, check:objectui-pin-citations, check:authorable-surface: Type Check · source gates — success. check:api-surface: Type Check · consumer gates — success. check:adr-0087-registration, check:changeset-no-major: Check Changeset (run 36626149495) — success. Governed Surface Queue Guard — success; the five paths touch no governed surface, and the head repo is the base repo.
  • Docs Drift Check (advisory) flagged content/docs/deployment/cli.mdx via the is_null literal; no page under content/docs restates the old or the new reason (searched "constrains nothing", "renderer skips", "Drop the key", "set to null").

② Semver level

.changeset/20662-null-key-todo-reason.md: @objectstack/spec patch. The diff publishes changed author-shown string literals and docblocks in @objectstack/spec; no schema, export, accept set, conversion id or return shape moves, and spec-changes.json and the upgrade guide are unaffected. patch is right; skip-changeset would be wrong, since published text changes.

Clause-②: the PR body reads Clause-②: no (author-shown wording only; no accept or reject moves). Through scripts/pm/clause2-line.mjs, matchValueToken takes no as the first token after the colon; readArmToken sees a parenthetical opening with author, which is neither of CLAUSE2_ARMS nor an arm-family near miss, so it reads { arm: null } — a declared no with no arm, not malformed. The changeset's own last line, Clause-②: no, reads the same. Right: nothing widens, nothing narrows.

③ Boundary flags

Dev flags, round 1 (5897718691):

  • Lit/dark through the stored-migration protocol and formatStoredMigrationReport over a stub engine, not a live database — answered: adequate for a text-only change; the printer is the CLI's own.
  • Fourth copy (entry docblock parenthetical) fixed in place — answered: adopted by the seat in 5897754447; verified in the diff.
  • Connective change in the docblock — superseded by round 2 ("for a different reason:").
  • No test pinned the false clause; one named-subject pin added instead — answered: verified (① 6).
  • Wording: is_null as "the rows with no KEY value", the stored key as "the rows whose KEY is null" — answered: exactly the pin's two arms (=== null || === undefined against === null); right.
  • NOT MEASURED locally: the full spec repo project and three whole-workspace gates — answered by CI: Test Core, Build Core, Type Check · debt ledger, all success.
  • Attribution trailer, origin/main advance, worktree kept — not contract matters.

Dev flags, round 2 (5898670276):

  • Changeset edited beyond the listed surface ("Both filters") — answered: right; the prior "Nothing else changes" sentence would have been false.
  • The pin's named subject is the refusal code, checked against StandardErrorCode.options — answered: verified; INVALID_FILTER is in the enum at this head.
  • Only "Drop the key" kept; "Choose an operator" not added — answered: right per the order, and per what the conversion can state (① 5).
  • Lock retry; lit at c5eed1b4d3 and dark at 3fcedfb564 with an empty spec diffstat between the merges; CHANGELOG untouched; attribution; worktree — not contract matters; the CHANGELOG disposition is the seat's ruling and holds.

open_questions: none in either round. Round-1 out-of-scope findings: the empty-operator reason (folded in round 2; verified), the released CHANGELOG entry (dropped by the seat), the all-or-nothing test comment (folded in round 2; verified).

ESCALATED to the seat:

  • The D3 entry's pre-existing sentence, unchanged at this head and released in 17.5.0, carries the same triple and says the four blocks "match a rule array against those rows" for a data array too — the same over-claim at the pin. Whether it is tightened in the same round under the dev's own bounded in-place exemption (same file, same defect, text only) or left as a released record is the seat's call; the FAIL reason below is confined to the text this diff introduces.

FAIL reason (one):

  • The null-key reason's inline enumeration "(data: { provider: 'value' }, a data array or staticData)", repeated in the changeset, files a bare data array under "it selects the rows whose KEY is null". At the pin no block routes a bare data array through ValueDataSource.find: object-calendar draws it as pre-fetched rows with the internal fetch skipped and no filter applied; object-map and object-gantt refuse the bare array at the door and do not take it as a record source. Remedy: strike "a data array" from the reason's parenthetical and from the changeset, text only — no verdict, id or pin moves, since no pin asserts the enumeration — and re-run the conversion test file.

Implemented-by: claude/issue-20662-null-key-reason
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: FAIL


Generated by Claude Code

… objectui pin

The null-valued-key TODO reason, the changeset and the protocol-18 D3 entry
listed the inline row sources as `data: { provider: 'value' }`, a `data`
array or `staticData`. At the objectui pin a bare `data` array reaches no
`ValueDataSource.find`: `object-calendar` draws it as pre-fetched rows with
no filter applied, and `object-map` / `object-gantt` do not take it as a
record source. The list now names `data: { provider: 'value' }` and
`staticData` only; `migrations/registry.ts` is regenerated. Nothing else
moves.

Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
Co-authored-by: Claude <noreply@anthropic.com>
…r reaches at the objectui pin

The rationale under the conversion's `## Reach` verdict said the in-memory
renderers match the inline rows of every listed source. At the objectui pin
they take those rows from `data: { provider: 'value' }` or `staticData`; a
bare `data` array reaches none of them (`object-calendar` draws it unfiltered,
`object-map` / `object-gantt` do not take it as a record source). Comment text
only; the verdict sentence is unchanged.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: eaf2d6e6e8d55e3d164ed72a786ee7d260997a6b
Local-runs: none

Re-judging after the FAIL at 3fcedfb564 (review 5898951990, one reason). Inputs: card #20662 (body; triage direction 5893989151; claim 5896419836; seat notes 5897754447 and 5898978326; dev reports 5897718691, 5898670276, round 3 5900465814, round 4 5900584237), PR #20709 (body as patched through round 4, comments, the five-file list, the net diff against the merge base 671d4c164f, which is where origin/main was merged last; origin/main has since moved to b291fcdae9 on thirteen files, none of the five), the objectui sources at pin dd3f7e1be356 (record-source.ts, filter-converter.ts, ValueDataSource.ts, filter-tokens.ts, useResolvedFilter.ts, resolveDataSource.ts, ObjectMap.tsx, ObjectCalendar.tsx and plugin-calendar/src/index.tsx, ObjectTree.tsx, ObjectGantt.tsx, the map and gantt registrations), and the 41 check-runs on this head, read once.

① Derived judgments

1. The previous FAIL reason is cleared. "a data array" is gone from every place this PR's text lists the inline sources the filter reaches: the null-key declined literal in recordFilterToRules now reads "(data: { provider: 'value' } or staticData)"; the changeset's first paragraph reads the same; the D3 entry's older sentence ("None of this depends on where a block's rows come from …") reads the same, and its migrations/registry.ts copy matches. The recordFilterToRules docblock ("where a block's rows are inline") and the entry docblock's parenthetical ("matched where its rows are inline") never enumerated, so nothing was owed there. The only remaining bare-array mentions in the five files are the ## Reach paragraph (judged in 6 below), a pre-existing test row that asserts the conversion's verdict on an object-kanban and not the list, and unrelated text. RIGHT.

2. The remaining list is true at the pin. resolveRecordSourceConfig (record-source.ts:291-317) returns an authored data verbatim when it is on the block's declared arm (:176-181: 'view-data' takes a non-array, 'array' takes an array, 'undeclared' takes anything truthy) and folds staticData to { provider: 'value', items } on every ladder (:303-308). The value branches then hand useResolvedFilter(schema.filter, scope) — resolveContextTokens returns a null value unchanged (filter-tokens.ts:171), and a token-free filter is held as authored — to new ValueDataSource({ items }).find('', { $filter }): ObjectMap.tsx:950-952 (arm 'view-data', :188), ObjectTree.tsx:911-913 (arm 'undeclared', :632), ObjectGantt.tsx:1009-1010 through resolveDataSource case 'value' (resolveDataSource.ts:69-73; arm 'view-data', :669), and ObjectCalendar.tsx:708-710 (arm 'array', :481-487). In find's object arm (ValueDataSource.ts:1315-1325) the record goes to matchesFilter; a null condition fails the operator-branch guard (condition && typeof condition === 'object', :1044) and takes the simple-equality arm, comparandEquals(value, null), which is value === target (:433-439, :1060-1062): the explicit-null row is selected, a row lacking the key is not. "it selects the rows whose KEY is null" is exactly that. The is_null arm is value === null || value === undefined (:509-510), so "the rows with no KEY value" is exactly that rule, on both kinds of block (server-side it lowers to $null: true). Where the block queries an object, convertFiltersToAST counts the key in skippedNullKeys and continues (filter-converter.ts:633-635), and a filter of only such keys lowers to undefined (:1029-1031). No "drop the key" advice for the null key anywhere. RIGHT.

3. The calendar precision boundary the dev flags (round 3) — judged: the un-qualified list does not over-claim. On object-calendar the ladder runs on the 'array' arm, so a data: { provider: 'value' } object is off-arm and is never a record source; the spec's own door says the same — ObjectCalendarPropsSchema is a strictObject whose data is z.array(z.unknown()) ("Pre-fetched records — skips the internal fetch") and whose staticData is z.array(z.unknown()), so that spelling is refused at the door and, stored, yields no inline rows (the ladder falls to staticData, then objectName). The reason's sentence is "where its rows are inline (X or Y), it selects the rows whose KEY is null": its antecedent is a block that HAS inline rows from X or Y, and at the pin no block takes either spelling as a record source and then withholds the filter. That is the material difference from the struck bare array, which the calendar takes, draws, and never filters — the antecedent held and the consequent was false. The list is a gloss of "inline" over the four renderers, not a per-block door census, and triage's direction (5893989151) forbids making the reason's text depend on the block kind. So the parenthetical is true wherever it applies, and there is no block in front of an operator on which following it moves the wrong rows. RIGHT, with the boundary recorded here.

4. The recordFilterToRules docblock sentence and the entry docblock's "What is left exactly as stored" parenthetical. Unchanged since the previous review; the docblock names convertFiltersToAST (skips) and ValueDataSource.find through comparandEquals (matches), says "This entry never reads where a block's rows come from" (the function has no such read), and neither enumerates. RIGHT.

5. The D3 entry reason and migrations/registry.ts. The null parenthetical states both behaviours, names the is_null rule for "the rows with no value" and leaves the choice to the author. The older sentence now lists "(data: { provider: 'value' } or staticData)" and says the four blocks "match a rule array against those rows and select the rows the stored form selected": true of the rows those two spellings produce (on the calendar, staticData's only), and the verdict half ("rewritten or left exactly as it would be on a block that queries an object") is true because the conversion never reads the source. Generator-only: the step-18 literal for this id in migrations/registry.ts (line 9333, inside the region the file header marks GENERATED and says to regenerate through gen:migration-registry) equals the entry file modulo indentation — 107 trimmed lines, zero mismatches — and the file's net diff is exactly the entry's 9-line change. main's copy of the entry carries no conversionIds (#20716's optional key), so the merges dropped nothing there. PROTOCOL_MAJOR is still derived from a 17.x PROTOCOL_VERSION; spec-changes.json holds no record for this entry; check:spec-changes and check:upgrade-guide are green on this head. RIGHT.

6. Round 4's ## Reach edit — comment only, verdict sentence unchanged, and now true. The diff's context lines show "A block whose rows ride on the node (data: { provider: 'value' }, a data array, staticData) is rewritten exactly as a block that queries an object" byte-unchanged, which is true for a bare array precisely because the conversion never reads the source. The rationale now says the in-memory renderers "take those rows from data: { provider: 'value' } or staticData" — a disjunction over the four, true as in 2 and 3 — and that "A bare data array reaches none of them: object-calendar draws it as pre-fetched rows with no filter applied, and object-map / object-gantt do not take it as a record source." Verified: plugin-calendar/src/index.tsx:204-205 forwards rest.data only when it is an array; ObjectCalendar.tsx:387-389 sets hasExternalData, :599-600 draws it as given, and :647 returns from the fetch effect, which is the only place queryFilter is read (:710, :793). On the map and gantt the 'view-data' arm rejects an array (record-source.ts:179), the ladder falls through (ObjectGantt.tsx:658-669; the map registration's own data description says a bare array "is not a record source"), and SchemaRenderer stops spreading an authored data key as a prop for object-arm blocks (record-source.ts:337-340). The tree is not named in that gloss; at the pin its 'undeclared' arm returns a truthy array verbatim, the result carries no provider, so neither the object (ObjectTree.tsx:776) nor the value (:872) arm runs and no find happens — "reaches none of them" holds for the tree as well, and the spec's tree door refuses a bare array anyway. The gloss names three of the four; incomplete, not false. RIGHT.

7. The empty-operator-object reason and its docblock sentence. Unchanged since the previous review; re-read at the pin: filter-converter.ts:818 calls refuseEmptyOperatorMap (:517), which throws FilterOperatorError with code = 'INVALID_FILTER', httpStatus = 400 (:84-85); find's object arm runs zeroKeyConditionRefusal before any row (ValueDataSource.ts:1320), a zero-key condition is lowered through toFilterNodeSafely, refused, and result = [] (:1119-1139, :1322-1323). "Drop the key" kept — the renderer's own refusal ends "or remove the key". RIGHT.

8. Verdict unchanged; pins on named subjects. The registry.ts diff touches two declined: literals, one local const isNull = 'is_null' satisfies ViewFilterOperator feeding JSON.stringify, and comment text — no if, no id, no return shape, no other conversion's body. The test file adds one import and two pins that assert named subjects: the is_null rule's JSON, the object-map door taking [rule] at filter with the stored record refused as control, the same reason on a staticData block and an objectName block, the filter left byte-identical, and INVALID_FILTER in StandardErrorCode.options and in the reason. No prose pinned; no pin asserts the list, so round 3 rightly moved none. The DECLINED_ROWS and all-or-nothing comments are right. RIGHT.

9. The merges lost nothing. Five merges of origin/main on the branch (c5eed1b4d3, 3fcedfb564, then round 3's 5e2d4aa584, 38663afe0a, 6f1396efa2); the net diff against the merge base 671d4c164f is exactly the five files (+112 / -21) and no other path moves, so nothing from main was dropped and nothing of the branch's was lost; eaf2d6e6e8 on top of 6f1396efa2 is +6 / -2 in conversions/registry.ts alone. RIGHT.

10. Not this PR's text. .changeset/20305-inline-row-filter-converts.md is untouched by this diff (zero diff lines against the merge base); its bare-array over-claim is the seat's to record in the ACCEPT. The released packages/spec/CHANGELOG.md 17.5.0 entry is untouched per the seat's ruling. The PR body's round-1 Dark quote still shows the old list, but it is labelled the a51c02fe83 reading and the Round 3 paragraph says so — a dated quote of an earlier head's output, not a claim about this one. RIGHT.

Gate coverage (41 check-runs on eaf2d6e6e8 at the read: 30 success, 5 skipped, 6 in progress, 0 failure):

  • NOT concluded at the read, named and not presumed: Lint & Repo Gates (run 36641255250) — the home of check:migration-registry, check:doc-authoring, check:spec-docblock-symbol-anchors, check:issue-citations and check:nul-bytes; Test Core 1/6, 3/6, 4/6, 5/6, 6/6 (run 36641255327) — the vitest projects including the spec repo project (2/6 is success). The dev reports the same five gates run at eaf2d6e6e8 with exit 0 and both jobs green at 6f1396efa2, with a comment-only delta since; that is the dev's report, not this read.
  • Skipped: Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), and the re-fired Auto Label / Check PR Size of run 36642206081 (their first runs succeeded).
  • Success: Build Core (check:dual-build-cjs-loads, check:lean-entry-closure); Type Check · debt ledger (check:type-check-debt) — the dev's three NOT MEASURED; Type Check · source gates (check:generated --reconcile-only, check:spec-changes, check:upgrade-guide, check:authorable-surface, check:docs, check:objectui-pin-citations — the docblock's pin citation); Type Check · consumer gates (check:api-surface); Type Check · workspace and TypeScript Type Check (the satisfies ViewFilterOperator const compiles); Check Changeset twice (check:adr-0087-registration, check:changeset-no-major, check:empty-changeset); Governed Surface Queue Guard — the five paths touch no governed surface and the head repo is the base repo; Spec property liveness; the three Dogfood shards and their rollup; Dogfood Verify CLI; Temporal Conformance; the card, branch and single-writer guards.
  • Docs Drift Check (advisory) flags content/docs/deployment/cli.mdx via the is_null literal; no page under content/docs restates the old or the new reason (searched "constrains nothing", "renderer skips", "Drop the key", "set to null" — only unrelated hits in flows.mdx and batch.mdx).

② Semver level

.changeset/20662-null-key-todo-reason.md: @objectstack/spec patch. The diff publishes changed author-shown string literals and docblocks in @objectstack/spec; no schema, export, accept set, conversion id or return shape moves, and spec-changes.json and the upgrade guide are unaffected. patch is right; skip-changeset would be wrong, since published text changes. The changeset's body covers both reasons and states the verdicts do not move.

Clause-②: the PR body reads Clause-②: no (author-shown wording only; no accept or reject moves). Read through scripts/pm/clause2-line.mjs: matchValueToken takes no as the first token after the colon; readArmToken sees a parenthetical opening with author, which is neither of CLAUSE2_ARMS nor an arm-family near miss, so it reads { arm: null } — a declared no with no arm, not malformed. The changeset's last line, Clause-②: no, reads the same. Right: nothing widens, nothing narrows.

③ Boundary flags

Dev flags, round 3 (5900465814):

  • The docblock sentence and the entry parenthetical carry no enumeration, so nothing was struck there — answered: verified (① 4).
  • The ## Reach rationale left for the seat under "Nothing else moves" — answered: the seat ordered it in round 4; verified (① 6).
  • Calendar precision boundary (a data: { provider: 'value' } object is not a record source there; only staticData is) — answered: judged in ① 3; the un-qualified list is true wherever its antecedent holds and no block at the pin contradicts it.
  • Three merges instead of one, and main moving on to b291fcdae9 unmerged — answered: net diff exactly the five files (① 9); the merge base is unchanged and the queue rebuilds on current main.
  • The §1 INLINE test row with a bare data array on object-kanban asserts the conversion's verdict, not the list — answered: verified at head; right to leave.
  • Attribution, worktree, no MCP calls — not contract matters.

Dev flags, round 4 (5900584237):

  • Two docblock-reading gates run beyond the listed set — answered: the pin-citation gate is green on this head (Type Check · source gates); the symbol-anchor gate's home is not concluded at the read (named above).
  • No PR body edit by the dev — answered: the seat applied the round-3 and round-4 pr_body_lines; the body's files list and verification record match the diff.

open_questions: none in rounds 3 or 4. Round-3 out-of-scope finding: the same bare-array over-claim in the ## Reach rationale (folded in round 4; verified) and in the landed .changeset/20305-inline-row-filter-converts.md — confirmed untouched by this diff; not this PR's text; the seat records it in the ACCEPT.

Everything the previous review judged right is still right at this head: the null-key and empty-operator reasons, the is_null rule, the verdict unchanged, the pins on named subjects, generator-only regeneration, patch, Clause-②: no.

ESCALATED: none.

FAIL reasons: none.

Implemented-by: claude/issue-20662-null-key-reason
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 23:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 99786f9 Sep 29, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20662-null-key-reason branch September 29, 2026 23:29
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…o the commits that decided them (objectstack-ai#20713)

Part of objectstack-ai#20594
Clause-②: no

## What changed

This is stage 7 of the `domain:cli` lane of the dead-citation sweep:
`packages/mcp/src`. Every comment site there that cited a tracker number
answering 404 now cites, in ruling C+D's form C (comment 5749154545 on
objectstack-ai#19123), the commit in this repository's history that decided what the
line describes, and keeps saying in its own words what that commit
decided. PR objectstack-ai#20533 is the method, and stages 1 to 6 of this card (PR
objectstack-ai#20624, PR objectstack-ai#20632, PR objectstack-ai#20656, PR objectstack-ai#20673, PR objectstack-ai#20689, PR objectstack-ai#20703) are the
precedents. The card stays open for the lane's remaining packages, so
this PR says `Part of`.

That is **17 sites on 17 lines in 9 files, covering 9 numbers**,
rewritten to **9 distinct commits**:
- the census's **10 sites**, in `mcp-server-runtime.ts` (5), `plugin.ts`
(3) and `stdio-data-bridge.ts` (2), 7 numbers;
- **7 test-file comment sites** in 6 test files (the census defers
`*.test.ts`; stages 1 to 6 took test comments too).

One more line changed: `__tests__/plugin-execution-context.test.ts:7`,
the second half of the `:6` sentence ("this face was not in that card's
inventory" now reads "not in that commit's inventory", since the card it
pointed back to is now named as a commit).

Only comments changed: **18 lines out, 18 in**, and every touched file
keeps its line count, so no line citation into these files moves. **No
citation number is added**: over the 18 line pairs, added-minus-removed
numbers is empty, and no PR number stands newly on any line. No ADR or
ruling-record file in `docs/adr/` or `scripts/adr-anchors/` records any
of these 9 decisions (a grep for the 9 numbers there reads 0 hits, with
a control number from the same tree reading 2), so every anchor is a
commit.

**No changeset, and `skip-changeset`:** none of the rewritten comments
reaches `dist` (measured below: base and head emit six byte-identical
files, and a code-mutation control changes four of them). That is stage
5's case (PR objectstack-ai#20689), not stage 6's.

## Census: `packages/mcp`, before and after

**Instrument.** The gate's own `node scripts/check-issue-citations.mjs
--census --json`, read-only and unchanged, run under `with-fleet.sh
--read` for the token. The count is its `allocated-but-absent` findings
under `packages/mcp/`. Both runs enumerated the whole board.

| reading | tree | board | whole-repo `allocated-but-absent` |
`packages/mcp` sites | lines | numbers | files |
|---|---|---|---|---|---|---|---|
| before | base `e4e5222b7b`, run 2026-09-29T19:45:33Z to 19:50:37Z |
enumerated, 186 pages, frontier objectstack-ai#20708, 18,535 numbers | 1,222 | **10**
| 10 | 7 | 3 |
| after | `459ff81088`, run 19:58:39Z to 20:02:47Z | enumerated, 186
pages, frontier objectstack-ai#20709, 18,536 numbers | 1,212 | **0** | 0 | 0 | 0 |

The whole-repo drop of 10 is exactly these sites: a site-by-site diff of
the two JSON outputs has 10 findings gone, all under `packages/mcp/src`,
and none added. The other three tallies (`resolves` 32,968,
`resolves-as-pull-request` 1,984, `cross-repo-unjudged` 994) are equal
in both runs. `packages/mcp/src` is byte-identical at `459ff81088` and
at the head.

**Supplementary scan (test files included).** The gate's exported
`extractCitations` and `classifyCitation` over all 43 `.ts` files under
`src/`, with the board from the gate's own `probeBoard`: 365 citations
and 21 dead before (src comments 10, test comments 7, src strings 0,
test strings 4), 348 and 4 after (0, 0, 0, 4). Its before list of src
comment sites is identical to the census's. The 4 left are test titles,
the form-D stage (see Acceptance notes).

## Per-site table

`git blame` at the base ties each line to the commit that wrote it, and
each anchor was read in its message, changeset or diff, not only its
subject. Where the pull request that landed an anchor still answers, its
body's first line names the dead number, which is noted.

| number | sites (base line) | anchor: what it decided |
|---|---|---|
| `objectstack-ai#13318` | `mcp-server-runtime.ts:272` | `3ec8646f1`: the bridged
tools' `readOnlyHint` / `destructiveHint` come from what the definition
declares, and a tool that declares nothing is served neither hint
(omit-when-unsourced). The line blames to `c39369d12`, the
`openWorldHint` sibling, whose changeset calls this the repair "that
preceded it". The PR that landed `3ec8646f1` answers 404 too. |
| `objectstack-ai#6724` | `mcp-server-runtime.ts:625`;
`mcp-server-runtime.metadata-outage.test.ts:289` | `4f3d2322e`: corrects
`diagnoseEmptyRead`'s falsified claim that `MetadataFacade.getObject`
differs from `get('object', n)`, in the TSDoc and in the outage test's
restatement of it. Both lines blame to it; PR objectstack-ai#6948, which landed it,
names objectstack-ai#6724. |
| `objectstack-ai#6745` | `mcp-server-runtime.ts:636` | `7a5ef0008`: adds
`metadata-service-getobject-equivalence.test.ts`, pinning `getObject(n)`
equal to `get('object', n)` across all three implementations. The line's
"PR objectstack-ai#6839 for objectstack-ai#6745" named this commit's PR (answers 200), which stays
beside the sha as a convenience link. The spec lane gave the number this
anchor. |
| `objectstack-ai#6723` | `mcp-server-runtime.ts:637`, `:652`;
`mcp-server-runtime.metadata-outage.test.ts:293` | `8ad609c69`: declares
on `IMetadataService.getObject` that it answers the same as
`get('object', name)`. `objectstack-ai#6723` was the pull request that landed as this
commit (its subject carries the number); `objectstack-ai#6505`, the issue beside it on
`:637`, answers 200 and stays. The spec lane gave the number this
anchor. |
| `objectstack-ai#17114` | `plugin.ts:8`, `:67`;
`stdio-tenancy-posture-api-key-matrix.test.ts:569` | `4af758d47`: the
last two admission doors, this one included, classify the tenancy
rejection through the shared `classifyAdmissionTenancyPosture`. All
three lines blame to it; PR objectstack-ai#17683 names objectstack-ai#17114, and stage 1 gave the
number this anchor. |
| `objectstack-ai#6216` | `plugin.ts:126`;
`__tests__/plugin-execution-context.test.ts:6` | `f586f1a89`: one
`ExecutionContext` assembler for the dispatcher, REST and share-link
sites. Both lines blame to `502dc6fe7`, which converged this stdio face
afterwards and names that convergence as its precedent. Its file list
touches no `packages/mcp` file, which is what `:7` ("not in that
commit's inventory") says. Stages 1 and 2 and the spec lane gave the
number this anchor. |
| `objectstack-ai#8422` | `stdio-data-bridge.ts:85`, `:394`;
`stdio-data-bridge.not-found.test.ts:4` | `4810dd628`: the stdio
bridge's by-id write seams throw the shared `recordNotFoundError`
envelope instead of a bare `Error`. All three lines blame to it; PR
objectstack-ai#8507 names objectstack-ai#8422. |
| `objectstack-ai#17568` | `mcp-record-id-key-mistake-refusal.test.ts:4` |
`9c9e6d08f`: pins that a missing-`recordId` refusal also names the `id`
the caller sent (test-only). The line blames to it; PR objectstack-ai#17650 names
objectstack-ai#17568. |
| `objectstack-ai#13486` | `mcp-tool-bridge-safety-annotations.test.ts:423` |
`6193e576d`: pins the bridge's two hand-copied safety name sets in the
direction the old pin could not see (the docblock's heading is that
commit's subject). The line blames to it; PR objectstack-ai#13888 names objectstack-ai#13486. |

**Anchor checks.** Every cited sha matches exactly one object (`git
rev-parse --disambiguate`, count 1 for each of the 9), is a commit, has
one parent, and is an ancestor of `main` (`merge-base --is-ancestor`
against `5757463712`, exit 0 for all 9). The checkout is not shallow.
The control leg `979ad9575` (2026-08-08, the parent of the oldest anchor
`8ad609c69` of 2026-08-08) exits 0, and the negative control, this
branch's own `459ff81088`, exits 1. Four anchors reuse the landed
stages' (`f586f1a89`, `4af758d47`, `7a5ef0008`, `8ad609c69`), so each
number carries one anchor across the tree; five are new (`3ec8646f1`,
`4f3d2322e`, `4810dd628`, `9c9e6d08f`, `6193e576d`).

**Numbers.** All 9 dropped numbers answer 404 by REST (re-probed
2026-09-29T19:54Z). The numbers kept on changed lines (`objectstack-ai#6839`, a pull
request; `objectstack-ai#6505`, `objectstack-ai#15348`, `objectstack-ai#16013`, `objectstack-ai#4435`, `objectstack-ai#5138`, `objectstack-ai#7867`) answer
200. Four slash-joined groups stand in `packages/mcp/src`, whose later
halves the citation grammar does not read (`objectstack-ai#4435/objectstack-ai#5138/objectstack-ai#7867` twice,
`objectstack-ai#5138/objectstack-ai#5581`, `objectstack-ai#7728/objectstack-ai#7823`); every half answers 200, so none is dead.

## Mechanical guard: no code token moves

**H2 holds on both readings: the parser leaf-token diff is empty, and
the emitted `dist` is byte-identical.**

**Token guard.** It compares the TypeScript parser's leaf tokens
(TypeScript 6.0.3, JSDoc nodes excluded) of the 9 touched files at base
`e4e5222b7b` and at `459ff81088`. Controls mutate the head text in
memory only.
- Real run: 21,192 base tokens, 0 differing (exit 0).
- Comment-insertion control: 0 differing (exit 0).
- Code-insertion control: all 9 files differ at token 0 (exit 1).
- String control (the first character of the `'vitest'` import specifier
in `plugin-execution-context.test.ts` flipped): exactly 1 differing
`StringLiteral`, at token 15 of that file (exit 1).

**Emitted `dist`.** `pnpm --filter @objectstack/mcp build` at the head,
then at base (the base tree of `packages/mcp/src` restored in place
under a trap-armed restore; an on-disk probe read `objectstack-ai#13318` 1 and `commit
3ec8646` 0 before that build; afterwards every touched blob equals its
HEAD blob and `git diff HEAD` is empty), with the same dependency
builds:
- all six files (`index.cjs`, `index.cjs.map`, `index.d.cts`,
`index.d.ts`, `index.js`, `index.js.map`) are **byte-identical** by
sha256. The built files do carry docblocks (14 in `index.js`, 78 in
`index.d.ts`); none of the rewritten ones is on an emitted declaration.
- Code-mutation control (`scripts/ablation-replace.mjs`, anchor: the
sync leg's typed `ctx.getService` call on `'tenancy'` in `plugin.ts`,
hit 1 to 0, its argument renamed to a marker; blob restored to HEAD
`0a1aaa7955`, `git diff HEAD` empty):
`scripts/ablation-dist-preflight.mjs` found the marker in `index.cjs`
and `index.js`, and `index.cjs`, `index.js` and both `.map` files differ
from the head build. `dist` was then rebuilt, its six sha256 values
equal the first head build, and the preflight in `--absent` mode reads
the marker absent from all 6 files with a clean tree.

A raw scan of the 9 changed files for control bytes finds none (a
positive probe on a scratch file matched).

## Changeset

**None, and `skip-changeset`.** `@objectstack/mcp`'s `files[]` is
`dist`, `README.md` and `CHANGELOG.md`, and the build above emits
byte-identical `dist` at base and head, so this diff publishes nothing
from any released package. Stage 5 (PR objectstack-ai#20689) measured the same and
shipped the same; stage 6 (PR objectstack-ai#20703) measured the opposite and carried
a `patch`.

## Gates (head `7a0f15de62`)

This host has no `flock`, so `os-verify-lock.sh` ran in its declared
unlocked mode. Its disclosure, verbatim, from each run at this head and
from the four `dist` builds (at `459ff81088`, `packages/mcp/src`
byte-identical to this head):

```text
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 25s · declare it in the PR body · pnpm --workspace-concurrency=2 --filter '@objectstack/mcp...' build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 116s (1m56s) · declare it in the PR body · pnpm turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 10s · declare it in the PR body · pnpm --filter @objectstack/mcp exec vitest run --maxWorkers=2
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 4s · declare it in the PR body · pnpm --filter @objectstack/mcp typecheck
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 3s · declare it in the PR body · pnpm --filter @objectstack/mcp build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 3s · declare it in the PR body · pnpm --filter @objectstack/mcp build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 3s · declare it in the PR body · pnpm --filter @objectstack/mcp build
os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this host, so the shared verify lock was NEVER taken and NOTHING was serialized · ran 2s · declare it in the PR body · pnpm --filter @objectstack/mcp build
```

- **Build:** `@objectstack/mcp` with its closure (9 of 81 workspace
projects), then the whole workspace, `turbo run build
--filter='./packages/*' --filter='./packages/*/*'`, 71 of 71 tasks,
after the merge. The tree was clean after both.
- **Tests:** `vitest run`: 32 files, 344 tests passed (every `*.test.ts`
under `src/`), at the head and before the merge.
- **Typecheck:** `pnpm --filter @objectstack/mcp typecheck` exits 0.
`tsc --listFiles`: `tsconfig.json` compiles the 11 non-test `src` files,
`tsconfig.test.json` all 43 including the 32 test files.
`check:test-typecheck`: 6 files, 53 errors, 8 pinned signatures, held.
- **Lint:** the repo-wide `pnpm lint` (`eslint . --no-inline-config`)
exits 0 at this head (2026-09-29T20:18:54Z to 20:19:23Z), and at
`459ff81088` before the merge.
- **Citation judging:** after merging `origin/main` (`9b384f63ae`),
`node scripts/check-issue-citations.mjs --base 9b384f6` judges 5
citations on the changed lines of 3 files (the kept numbers `objectstack-ai#15348`,
`objectstack-ai#16013`, `objectstack-ai#4435`, `objectstack-ai#6505`, and `objectstack-ai#6839` as a pull request) and exits 0:
every one resolves. Against `origin/main` after it moved to
`5757463712`, the same 5 citations, exit 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 53 families. All 53 exit
0, and `--ran` with the exit-coded record reads "53 derived, 53 run, 0
NOT-MEASURED, 0 UNRUN" (a derived zero). Among them:
`check:issue-citations`, `check:doc-authoring` (808 pinned sites, no
growth), `check:nul-bytes` (9,331 files, no raw control bytes),
`check:published-files`, `check:type-check-debt`.
- **Artifact rosters:** 36 of the 39 non-self-test roster rows exit 0,
including the three the derivation marks as keeping their roster under
one of this diff's paths (`check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`). The other three
need a pull request's context; they are run against this PR once it
exists and reported on the card. The 18 self-test-only rows grade their
checkers' fixtures and cannot judge this diff.

## Hypotheses (measured first)

- **H0 holds.** At base `e4e5222b7b` the filtered census answers 10
sites on 10 lines, 7 numbers, in 3 files, as on the seat's `0be898499f`.
The whole-repo count is 1,222.
- **H1 holds.** After the rewrite, the filtered census answers 0 for
`packages/mcp`. No site was left for an open PR (the file lists of all 8
open PRs were read at 20:08:05Z: only the Version Packages PR objectstack-ai#20639
touches `packages/mcp`, in `CHANGELOG.md` and `package.json`) or for an
unfound anchor.
- **H2 holds, on both readings.** The comment-stripped (parser-token)
diff of all 9 touched files is empty with its controls firing, and the
emitted `dist` is byte-identical at base and head with a code control
that changes it.

## Acceptance notes

- **Test titles, the form-D stage.** 4 dead numbers remain in test
string literals in `packages/mcp/src` (`describe` titles, no assertion
text): `objectstack-ai#17568` twice in `mcp-record-id-key-mistake-refusal.test.ts`
(`:151`, `:315`), `objectstack-ai#8422` in `stdio-data-bridge.not-found.test.ts:99`,
`objectstack-ai#17114` in `stdio-tenancy-posture-api-key-matrix.test.ts:592`. They
stay on the card for its form-D stage; no string moved here.
- **Outside `src/**`, a later stage of the card:**
`packages/mcp/vitest.config.ts:18` cites `objectstack-ai#8651` (404).
`packages/mcp/test-typecheck-debt.json:2` cites `objectstack-ai#13470` (404) inside
its `_comment` field, which the file itself says is generated by
`scripts/check-test-typecheck.mts`, so a fix there is at that producer,
in the `scripts/**` lane, not a hand edit. The other citations in
`packages/mcp` outside `src/**` (`CHANGELOG.md` excluded) answer 200.
- **Card-word residue, cited nowhere.** A few docblocks still say "this
card" or "the card" a paragraph away from the rewritten line (for
example `stdio-data-bridge.not-found.test.ts:19`,
`mcp-record-id-key-mistake-refusal.test.ts:19`,
`stdio-tenancy-posture-api-key-matrix.test.ts:580`, `:584`). They cite
no number, so they were left, as the landed stages left theirs; only the
one same-sentence companion (`plugin-execution-context.test.ts:7`) was
changed.
- **The moving `origin/main`.** The branch merged `origin/main` once
(`7a0f15de62`, merging `9b384f63ae`: `service-storage`,
`platform-objects` and `plugin-audit`, nothing in `packages/mcp`). A
later fetch advanced the shared ref to `5757463712`, one commit in
`platform-objects` translations. There was no second merge; CI judges
the merge ref.

## Deviations

- **One companion line (`plugin-execution-context.test.ts:7`)** beyond
the 17 sites, the second half of the `:6` sentence.
- **Commit trailers** are AGENTS.md's model-free pair (`Claude-Session`
plus `Co-authored-by: Claude`), and the pre-push trailer check passed on
every push. The harness's attribution reminder asked for a model-named
trailer and a different PR footer, and AGENTS.md overrides it. The merge
commit carries git's default message.

---
_Generated by [Claude
Code](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_

Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ommits that decided them (objectstack-ai#20717)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the seventh stage of the `domain:services` lane of the
dead-citation sweep. It covers
`packages/plugins/plugin-approvals/src/**` and nothing else. By the
seat's census at the claim (`5897866351`), it is the largest package in
the lane that no in-flight work holds. Later stages cover the other
packages, so this PR says `Part of` and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 6 (PR objectstack-ai#20609 as
`422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR
objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as
`9b384f63a`). That is **41 sites on 38 lines in 13 files, covering 14
numbers**:

- 24 census sites (every census site this package has);
- 15 sites in test comments, which the census defers;
- 2 sites the gate's citation grammar cannot see, found by a raw scan
(see Acceptance notes): the second number of `objectstack-ai#8287/objectstack-ai#8778`
(`approval-node.test.ts:462`) and 「the option objectstack-ai#8710 rejected」
(`approval-service.ts:2329`), which the gate reads as an option ordinal.

Each rewritten line now cites the commit in `origin/main` history that
decided what the line describes, and says in its own words what was
decided: **13 distinct shas**. No number in this package has an ADR or
ruling record of its own in the repository (a grep of `docs/adr/` for
all 14 finds none, and a grep of the rest of `docs/` finds only an audit
that names `objectstack-ai#11311` as evidence), so every anchor is a commit, per
ruling C's order. No number was dropped.

Only comments changed. Every touched source file keeps its line count
(41 lines out, 41 in, over 13 files), so no line citation into these
files moves. 3 of those 41 lines hold no dead citation: 1 reflow line
and 2 lost-referent lines, listed under Wordings below. No code token
moves (see the guard below).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces: `objectstack-ai#8613`
(`approval-service.ts:2295`, `:2363`, `approval-service.test.ts:751`),
`objectstack-ai#8287` (`sys-approval-request.object.ts:138`,
`approval-node.test.ts:462`), `objectstack-ai#10101`
(`backfill-platform-row-organizations.ts:9`) and `objectstack-ai#12069`
(`translations/index.ts:26`). Each answers 200. Over the whole diff,
added minus removed is 0 or negative for every number, and no number is
new to the diff. No PR number stands on an added line; the one `PR #N`
spelling in scope (`backfill-platform-row-organizations.ts:9`) became
its squash commit.

Five dead sites are left on purpose, all of them test strings (see the
list below).

One more file: a `patch` changeset for `@objectstack/plugin-approvals`,
because the rewritten docblocks and inline comments ship (see Changeset
below).

## Census: `plugin-approvals`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/plugins/plugin-approvals/`. Each run counts as a reading only
because its board frontier equals the newest issue number, read by a
separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
plugin-approvals sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `575746371`, run 2026-09-29T20:15:05Z to 20:18:28Z |
enumerated, 186 pages, frontier objectstack-ai#20709 (newest objectstack-ai#20709 before and after),
18,536 numbers | 1,195 | **24** | 22 | 6 | 10 |
| after | head `e698d2393`, run 20:28:39Z to 20:31:58Z | enumerated, 186
pages, frontier objectstack-ai#20716 (newest objectstack-ai#20714 before, objectstack-ai#20716 after), 18,543
numbers | 1,171 | **0** | 0 | 0 | 0 |

The before count matches the seat's census at the claim and A1 (24
sites). The whole-repo drop is 24, exactly this diff's census sites. The
`resolves` tally is 32,971 in both runs, and `resolves-as-pull-request`
(1,984) and `cross-repo-unjudged` (995) did not move either. The after
run was taken on `e698d2393`; the head `708244c2b` adds only the
changeset. No run was truncated or discarded: both enumerations read 186
pages at the newest frontier.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `namesThisRepository`
over every `.ts` file under `plugin-approvals/src` (76 files). It takes
its verdicts from the before census's own board reading rather than from
a second enumeration: a number is dead when that census reported it
`allocated-but-absent`, and alive when that census judged it on this
board anywhere (its `--list` extraction) and did not report it. The 18
numbers the census never saw, because they stand only in test files or
strings here, were read one by one on the issues endpoint: 14 answer
200, and `objectstack-ai#8863`, `objectstack-ai#11081`, `objectstack-ai#11286` and `objectstack-ai#11308` answer 404.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `575746371` | 981 | **44** | 24 | 15 | 0 | 5 |
| after, `e698d2393` | 942 | **5** | 0 | 0 | 0 | 5 |

Its src-comment column equals the census's 24, which is the control on
the second instrument. The 902 live citations and the 32 cross-repo
citations are the same in both readings, and the drop of 39 citations is
exactly the rewritten sites the gate grammar sees. Three extracted
tokens are not citations and stay unjudged in both readings: `&objectstack-ai#39;` (an
HTML entity) and two CSS colours, all in `action-link-pages.ts` string
literals. A third, raw reading (every `#` followed by 2 to 6 digits,
whatever surrounds it) finds 46 dead occurrences before and 5 after; the
2 it sees beyond the gate are the two gate-invisible sites above, and
its residue equals the gate's residue site for site.

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject, and `git blame` at the base puts every
rewritten line in its anchor commit or in a later commit that descends
from it (`merge-base --is-ancestor` exit 0 for each pair).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#16709` | 10/2 | 8/2 | `8c7cca1ce`: the three residues of the
stranded-inspection contract review. Item 2 (the PM ruling of
2026-09-08) keeps a row whose third read threw in the report as the
undifferentiated `failed`; item 3 moves `refineFailedRunState` inside
the `try`, so a malformed host verdict costs only its own row. Its
message numbers the items, which is why the lines keep 「item 2」 and
「item 3」. New to the sweep |
| `objectstack-ai#8710` | 6/2 | 6/0 | `04d03c3a0`: a deactivated `sys_position`
confers no sharing-rule shares, filtered at the sharing call site and
never inside the addressing primitive. Its message quotes the 2026-08-15
ruling verbatim, the same sentence the quoted blocks here carry, and its
diff writes the 「a name with no row is untouched」 fallback that
`approval-service.ts:2318` quotes. Stage 2's anchor, and
`plugin-sharing/src/position-graph.ts:42` already reads 「objectstack-ai#8613 / commit
04d03c3」 |
| `objectstack-ai#6523` | 4/3 | 4/0 | `aa4b90d9a`: the 36 enforcement signatures,
`IApprovalService` among them, converged onto the full
`ExecutionContext`. Its subject names it. Stages 2 and 6 and the spec
stage's anchor |
| `objectstack-ai#6206` | 3/3 | 3/0 | `aa4b90d9a`: the same commit, whose body applies
「the objectstack-ai#6206 ruling default (converge on the full envelope, keep no
per-site subset contracts)」. Written as the full-envelope ruling, the
form stages 2 and 6 used |
| `objectstack-ai#8778` | 4/4 | 4/0 | `7901b2dd2`: the stamp-only
`tenancy.organizationField`, Option A of the maintainer's ruling,
declared on `sys_api_key` as `active_organization_id`. The spec,
`plugin-security` and `service-storage` stages' anchor |
| `objectstack-ai#11081` | 5/1 | 5/0 | `c28e4cfae`: the two SqlDriver-backed fixtures
of `objectstack-ai#11081` stop muting their kernel and pin the expected read-refusal
noise with the runtime's shared capture. Its diff writes all five
`[objectstack-ai#11081]` tags. New to the sweep |
| `objectstack-ai#11286` | 5/1 | 2/3 | `b019891cd`: the contract test that pins the
two `managerIsProvablyOutsideOrg` screens to equal verdicts. Its subject
names it. New to the sweep |
| `objectstack-ai#11674` | 2/1 | 2/0 | `1cba33f16`: the seed loader warns at load time
when a seed defers a required column, and the ordering constraint is
documented at the four pointer-pair sites, this object among them. Stage
2's anchor for the same paragraph |
| `objectstack-ai#12493` | 2/2 | 2/0 | `aa5994e17`: the Operation Message Catalog
gains `approval_recall_not_submitter` (and `record_write_denied`) ahead
of their emitters. Its diff names `objectstack-ai#12493` throughout. Stage 2's anchor
|
| `objectstack-ai#8707` | 1/1 | 1/0 | `1408fe385`: audit rows are stamped from the
record's own organization, which its message says the maintainer's
ruling on `objectstack-ai#8287` requires; the line keeps 「honouring objectstack-ai#8287's ruling」.
New to the sweep |
| `objectstack-ai#8863` | 1/1 | 1/0 | `d200b016b`: the two negative pins that assert
the unfiltered position expansion on the approvals side. Its body names
`objectstack-ai#8863`. New to the sweep |
| `objectstack-ai#11308` | 1/1 | 1/0 | `5a916c4d4`: the one-off platform-row
organization backfill, dry run and write, which its body calls the
`objectstack-ai#11308` sweep. New to the sweep |
| `objectstack-ai#11311` | 1/1 | 1/0 | `1272f0a6b`: the squash commit of the pull
request that was `objectstack-ai#11311` (its subject carries the number), which moved
the resolver to `metadata-core` and made the approval and automation-run
writers stamp the subject's organization. New to the sweep |
| `objectstack-ai#11671` | 1/1 | 1/0 | `09b4f4e4e`: the source-hashes provenance
companion. The identical `translations/index.ts` line in
`service-messaging`, `plugin-sharing` and `plugin-security` already
cites it |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 13), and every one is an
ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 13; the
history is complete, `--is-shallow-repository` false, 15,129 commits).
Each of the 14 numbers answers 404 on the issues endpoint, read one by
one; `objectstack-ai#11311` answers 404 on the pulls endpoint too.

## Wordings to check

- **The full-envelope ruling, `approval-node.ts:29`,
`approval-service.ts:52-53` and `exec-context-annotation.pin.ts:7-8`.**
「since objectstack-ai#6523 (the objectstack-ai#6206 ruling …)」 became 「since commit aa4b90d (the
full-envelope ruling …)」, word for word the form `plugin-sharing`'s
landed `sharing-service.ts:20` and `exec-context-annotation.pin.ts:7`
use. `approval-service.ts:53` is 1 reflow line.
- **The ruling's record, `approval-service.ts:2295` and
`approval-service.test.ts:751`.** 「Maintainer ruling, 2026-08-15 (objectstack-ai#8710,
inheriting objectstack-ai#8613), verbatim:」 became 「… (commit 04d03c3, inheriting
objectstack-ai#8613), verbatim:」. The quotation under it is the ruling itself and is
untouched; `04d03c3a0`'s message carries the same sentence.
- **`approval-service.ts:2329`.** 「that is the option objectstack-ai#8710 rejected」
became 「that is the option the ruling (commit 04d03c3) rejected」.
- **The test heading, `approval-service.test.ts:749`.** 「the objectstack-ai#8710
carve-out, asserted on THIS side (objectstack-ai#8863)」 became 「the commit 04d03c3
carve-out, asserted on THIS side (commit d200b01)」: the carve-out's
record, and the commit that asserted it here.
- **A PR number, `backfill-platform-row-organizations.ts:9`.** 「objectstack-ai#10101
(landed as PR objectstack-ai#11311)」 became 「objectstack-ai#10101 (landed as commit 1272f0a)」, the
pull request's squash commit.
- **Item numbers, `approval-service.ts:4893`, `:4906` and
`stranded-request-inspection.test.ts:123`.** 「[objectstack-ai#16709 item 3]」 became
「[commit 8c7cca1, item 3]」, and likewise for item 2, beside its 「PM
ruling, 2026-09-08」, which `8c7cca1ce`'s message records under 「Item 2」.
- **Lost referents, 2 lines with no dead site** (every file keeps its
line count): `backfill-platform-row-organizations.test.ts:17` 「the one
thing this card must not do」 became 「the one thing this sweep must not
do」, and `manager-org-screen-parity.contract.test.ts:61` 「the very
decision this card is fenced out of」 became 「the very decision this pin
is fenced out of」. Each 「this card」 pointed at the number the same
comment block opened with, which is now a commit; `b019891cd`'s message
says the pin 「PINS the duplication, it does not remove it」.

## The 5 sites left

- **Test strings, 5 sites**, left as stages 1 to 6 left theirs:
- `describe` / `it` titles:
`manager-org-screen-parity.contract.test.ts:232` (`objectstack-ai#11286`),
`stranded-request-inspection.test.ts:519` and `:642` (`objectstack-ai#16709`);
- a test double's thrown message and an assertion message:
`manager-org-screen-parity.contract.test.ts:107` and `:294` (`objectstack-ai#11286`).
- There is no operator string, generated header or quoted ruling
carrying a dead number in this package. The generated
`*.source-hashes.generated.ts` headers already cite `09b4f4e4e` and are
untouched. The two verbatim quotations of the 2026-08-15 ruling carry no
number and are untouched.

## Mechanical guard: no code token moves

The guard compares the TypeScript parser's leaf nodes, with comments as
trivia and JSDoc nodes never visited, base `575746371` against head.
Template literals are therefore read in context. It ran over all 13
touched `.ts` files.

- Real run: 36,204 base leaf tokens, **0 files with a token change**
(exit 0).
- Comment control in `sys-approval-request.object.ts` (「who a row is
ABOUT」 to 「whom a row is ABOUT」): 0 files changed, as expected (exit 0).
- Positive control, a code token added in
`sys-approval-request.object.ts` (`referenceVia: 'object_name',` given a
trailing `as const`): DIFFER (exit 1).
- Positive control, one digit changed inside a kept test title
(`stranded-request-inspection.test.ts:642`): DIFFER (exit 1).

Every mutation went through `scripts/ablation-replace.mjs`, and each
landed (anchor 1 to 0, blob changed). Each restore was proven
byte-identical to the HEAD blob (`6cb56301a334`, `757ad45900ac`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/plugin-approvals`
(`.changeset/20596-plugin-approvals-provenance-anchors.md`) is included.
Its body is stage 6's, word for word, with the package name changed.

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build (a cache miss for this package, so
`dist` is this head's source), the rewritten comments reach `dist`:
`8c7cca1ce` 4 times and `04d03c3a0` 4 times in each of `dist/index.d.ts`
and `index.d.mts`; `04d03c3a0` 4 times, `1cba33f16` twice, and
`8c7cca1ce`, `7901b2dd2` and `1408fe385` once each in each of `index.js`
and `index.mjs`. Positive controls: the unchanged line 「A step routing
to nobody is」, in the same docblock as the shipped rewrite at
`approval-service.ts:2295`, is found once in each of the four files, and
the unchanged line 「itself stays unwalled (`tenancy.enabled: false`)」
beside the shipped rewrite at `sys-approval-request.object.ts:144` once
in each JS file. A never-written negative phrase appears nowhere in
`dist`. None of the 14 dead numbers is left anywhere in `dist`.

## Gates (head `708244c2b`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
(self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0:
the diff-scoped run judged 5 citations across 6 files, and all 5 resolve
(`objectstack-ai#8613` twice, `objectstack-ai#8287`, `objectstack-ai#10101`, `objectstack-ai#12069`), each already on the line
it replaces.
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `708244c2b` derived 64 commands:
all 57 derived at dispatch, plus `check:dispatcher-error-vocabulary`,
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:query-options-erasure`, `check:type-check-coverage`,
`check:type-check-debt` and `check:where-matcher`. Each ran with its
exit code captured before any pipe, and all 64 exit 0. `--ran`, fed each
command with its exit code, reports 64 run, 0 NOT MEASURED (a derived
zero), 0 unrun, and exits 0. A full `turbo run build` of `./packages/*`
and `./packages/*/*` ran first under the shared verify lock (71 of 71
tasks, exit 0), so no gate hit an unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in directories this diff touches): `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit
0.
- **Tests and typecheck, under the verify lock:**
- `pnpm --filter @objectstack/plugin-approvals test`: 51 files pass and
791 tests pass. That is every test file in the package, the 7 touched
ones included.
- `pnpm --filter @objectstack/plugin-approvals typecheck` exits 0 (`tsc`
on `tsconfig.json`, the scripts program, and the test layer on
`tsconfig.test.json`, held at its ledger of 8 files, 324 errors and 27
pinned signatures). `--listFiles`: the `tsconfig.json` program holds the
25 non-test files under `src/`, the 6 touched ones included; the
`tsconfig.test.json` program holds all 76 files under `src/`, the 51
test files and all 13 touched files included.
- **Lint, as a proven narrowing:** `eslint --no-inline-config --format
json` over the 13 touched `.ts` files gives 13 files, 0 errors and 0
warnings. All 13 are in eslint's own population (`isPathIgnored` is
false for each; a `dist` file, as the control, is ignored).
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 14 changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked.**
`CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`
(objectstack-ai#20636). In this package there is one `#N-word` spelling, 「objectstack-ai#3266-era」
(`record-reader-visibility.test.ts:342`), and two `#A/#B` spellings,
`objectstack-ai#8287/objectstack-ai#8778` (`approval-node.test.ts:462`) and `objectstack-ai#8543/objectstack-ai#8580`
(`approval-vocabularies.test.ts:66`): the claim's 3, 1 and 2. `objectstack-ai#3266`,
`objectstack-ai#8287`, `objectstack-ai#8543` and `objectstack-ai#8580` answer 200; the second number `objectstack-ai#8778` is
dead, so that one line is rewritten here.
- **A third spelling the gate cannot see, found by the raw scan.**
`NON_CITATION_HEADS` excuses any `#N` after the word 「option」 as an
option ordinal, so 「the option objectstack-ai#8710 rejected」
(`approval-service.ts:2329`) was never extracted: a dead number there
would pass the diff gate at exit 0 and never enter a census count. It is
rewritten here. Across the gate's declared surfaces at the base, the
only other `option #N` with three or more digits is
`packages/objectql/src/validation/rule-validator.ts:2202` (`option
objectstack-ai#14088`), which answers 200. Same family as objectstack-ai#20636; noted for its
closeout, not a card of its own.
- **A retired key name in this package's prose, not changed here.**
`tenancy.organizationField` left the authorable surface in `502f179cc`,
and limb 0 of the shared resolver now reads
`PLATFORM_STAMP_ORGANIZATION_COLUMNS` in `metadata-core`, keyed by
object name. Comments in this package still name the retired key as what
limb 0 reads (`sys-approval-request.object.ts:143`, the line above a
rewrite; `backfill-platform-row-organizations.ts:35`,
`approval-node.test.ts:463`, `approval-service.ts:2707`,
`backfill-platform-row-organizations.test.ts:50`), and two test fixtures
still declare it on a stub `sys_api_key` (`approval-node.test.ts:467`,
`backfill-platform-row-organizations.test.ts:54`), where it is inert
because the resolver keys by name. The anchor `7901b2dd2` is right for
the key those lines name, and nothing is wrong at runtime. Correcting
the prose would reach past the dead citations, and the fixtures are code
tokens, so none of it is changed here.
- **The census instrument did not truncate in this stage.** Both
enumerations read 186 pages at the newest frontier.
- **Anchors the next stages can reuse**, each checked here: `objectstack-ai#16709` →
`8c7cca1ce`; `objectstack-ai#11081` → `c28e4cfae`; `objectstack-ai#11286` → `b019891cd`; `objectstack-ai#11308` →
`5a916c4d4`; `objectstack-ai#11311` → `1272f0a6b`; `objectstack-ai#8707` → `1408fe385`; `objectstack-ai#8863` →
`d200b016b`.
- **Base.** The branch is on `main` at `575746371`, which is still
`main` at 20:56Z (read into a private ref), so there was no merge.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants