Skip to content

fix(metadata-protocol): close() terminates watch iterators instead of emitting a drain event - #11136

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11021-close-drains-filtered-watchers
Aug 22, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-11021-close-drains-filtered-watchers

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #11021

SysMetadataRepository.close() modelled shutdown as a metadata event, and an event is exactly what a filtered or numeric-since subscriber is entitled to drop. It broadcast a synthetic { seq: -1, ref: { org: '', type: 'view', name: '_close' } } through the same dispatch closure real events pass, then cleared the watcher registry. Both of that closure's guards reject it, and once the registry is cleared nothing else can settle the parked promise.

The design fork, decided by measurement

Triage recommended shape 2 (close() resolves pending iterators with done: true directly) as a route to verify, with an explicit stop-condition: if a real consumer depends on receiving the synthetic drain event, shape 1 (exempt it from the filters) is the compatible answer instead. Nothing depends on receiving it:

evidence measurement
the _close sentinel appears in exactly one place in the tree — the producer at sys-metadata-repository.ts:1228. No consumer names it, counts it, or branches on it.
MetadataManager.startRepositoryWatch() (repo.watch({}), the drained subscriber) hands it to applyRepoEvent(), which cannot tell it from a real one: invalidateForForeignWrite('view', '_close'), then notifyWatchers() re-emits it downstream as a deleted view named _close at seq: -1 — into the HMR SSE route and Studio's status badge. It is misread, not depended on. Its loop condition is while (!this.repoWatchClosed), which the event does not change, so receiving it does not end the loop either.
MetadataCache.start() applyEvent() invalidates the cache key for a ref that never existed, then loops. Same shape.
the two sibling implementations already terminate rather than emit: InMemoryRepository's iterator close() and metadata-fs's createWatchIterable close() both settle the waiter with { value: undefined, done: true }. There is no synthetic event anywhere else in the codebase.
LayeredRepository.multiplexWatch treats a child's done: true as "that layer finished" and ends when all children are done — shape 2 composes through it; shape 1 would forward the phantom _close to a multiplexed consumer wearing a layer label.

So shape 2, for the reason triage gave: giving shutdown a seq of -1 is what makes it collide with the since comparison in the first place.

The matrix, measured before and after

Pinned in sys-metadata-repository.contract.test.ts. "before" is the same three cases run against unmodified close().

subscription before after
watch({org:'system'}, a.seq) ⛔ parked next() still unsettled 500ms after close() ✅ { value: undefined, done: true }
watch({org:'system'}) — no since at all ⛔ still unsettled ✅ { value: undefined, done: true }
watch({}) ⚠️ settled, but with { done: false, value: { seq: -1, op: 'delete', ref: { org: '', type: 'view', name: '_close' }, source: 'sys-metadata-repo-close' } } — and the next pull then hung ✅ { value: undefined, done: true }, and so does every later pull

⭐ The middle row is the one that proves the org-filter half bites on its own: no since is involved, and it is the shape MetadataCache.start() takes for any non-empty watchFilter. A fix tested only against the since half would look complete and leave it hanging.

⚠️ The third row is a refinement of the card's matrix, which recorded it as drained. Only the single parked promise was drained; the iterator was not finished, and what it delivered was a phantom event. The new cases assert the second pull too, which is the half that hid it.

What changed

The watcher registry holds each subscription's terminator next to its event sink (WatchSubscription), and close() runs the terminator — the identical routine iterator.return() runs. A consumer that breaks its own loop and a consumer whose repository shut down under it now observe the same thing, so neither has to special-case the other.

On the "both dispatch paths" instruction — measured, and the premise refines. :1077-1078 (in replayFromHistory) and :1144-1145 (in dispatch) do both apply the since drop and matchesFilter, but only the second is on the drain event's path: replayFromHistory filters rows read out of sys_metadata_history, and the synthetic event was never written there — it went straight from close() into dispatch. Under shape 2 the question dissolves, because there is no event to filter on any path. Both filter sites are deliberately untouched and still apply to real events, which the existing invariant-6 cases pin.

The contract, now stated

Invariant 8 in packages/metadata-core/src/repository.ts — "shutdown terminates; it does not emit" — says what a repository-level close() owes a pending iterator: end every live iterator with done: true and no value, the same observation return() produces; queued or unreplayed events MAY be dropped; shutdown MUST NOT be delivered as an event, with both measured reasons written down. It is conditional because close() is not on the MetadataRepository interface, and it records where each of today's three implementations stands.

Out of scope, filed not fixed

#11127 — FileSystemRepository.close() retires the chokidar watcher and the resync sweep but never reaches its event broker, so a parked watch() iterator stays parked. Same defect class, different package; filed with its confidence stated (code read, no runtime probe) rather than fixed here. Invariant 8 names it as the one measured non-conformance rather than quietly omitting it. Also left alone: listDrafts and the org-scope path in this file, which belong to the neighbour queued behind this card.

Verification

All at 2314ff6f9, the final commit.

  • pnpm --filter @objectstack/metadata-protocol --filter @objectstack/metadata-core --filter @objectstack/metadata test — 1859 passed | 10 skipped, 165 passed, 615 passed. The two consumer packages are here because this changes what their watch loops observe at shutdown.
  • pnpm --filter @objectstack/metadata-core typecheck — Done. @objectstack/metadata-protocol and @objectstack/metadata declare no typecheck script (a --filter that matches no script exits 0 having run nothing); they are covered instead by the ratchet below.
  • pnpm check:type-check-debt on the built closure (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) — OK — 33 ledger entr(ies) re-measured in 338.6s, 1895 raw tsc error(s) total, none above its recorded number. @objectstack/metadata-protocol is a ledger entry (frozen at 63), so the new test code was re-measured, not assumed.
  • pnpm lint (eslint . --no-inline-config, repo-wide) — clean, 124s. No narrowing claimed.
  • Gate families from node scripts/pm/dispatch-gates.mjs re-derived against the real changed paths, all exit 0: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:durability-log-level, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:nul-bytes, check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-ci-filter-parity.mjs, check-cross-package-test-inputs.mjs, check-empty-changeset.mjs, check-plugin-teardown-shape.mjs, docs-audit/check-affected-docs.mjs.
  • The derivation added five changeset families and the five convention-triggered test-file families the dispatch lead did not carry; those were run too.

Generated by Claude Code

… emitting a drain event (#11021)

`SysMetadataRepository.close()` modelled shutdown as a metadata event —
`{ seq: -1, ref: { org: '', type: 'view', name: '_close' } }` broadcast
through the same dispatch closure real events pass, then `watchers.clear()`.
Both of that closure's guards reject it: `matchesFilter` drops it for any
subscription naming an org (the synthetic ref's org is the empty string), a
type other than `view`, or a name; and `evt.seq <= since` holds for -1 against
every real seq. Dropped and then unsubscribed, nothing could settle the parked
promise and the consumer's `for await` never returned.

The subscriptions that passed both guards were no better off: they received the
synthetic event as a real one — a `view` named `_close`, deleted, at seq -1,
which MetadataManager turns into a cache invalidation and re-emits to Studio's
HMR stream — and hung on the next pull anyway, because delivering an event does
not end an iterator.

The watcher registry now holds each subscription's terminator next to its event
sink, and `close()` runs the terminator — the same routine `iterator.return()`
runs. Invariant 8 in metadata-core's repository.ts states the contract that was
unstated, and records FileSystemRepository's non-conformance (#11127).

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-core, @objectstack/metadata-protocol, touching 3 documentable anchor(s).

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

  • content/docs/concepts/metadata-lifecycle.mdx (via MetadataRepository (symbol), SysMetadataRepository (symbol))
What this run could not see
  • 6 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 9 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 afe1c4e0a1794a56817acbfd5f4f6e957ea023ce → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 afe1c4e0a1794a56817acbfd5f4f6e957ea023ce → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 22, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 22, 2026 20:28
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 22, 2026
Merged via the queue into main with commit 7d81c88 Aug 22, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-11021-close-drains-filtered-watchers branch August 22, 2026 20:43
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32596788489 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.08s
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 99 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
… that decided them (stage 13 of objectstack-ai#20595) (objectstack-ai#21640)

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

## What changed

Stage 13 of the `domain:engine` lane of the dead-citation sweep:
`packages/metadata-fs/**`, comment and docblock prose only, per the
claim (`5973452558`). Stages 1 to 11 landed as `a7d9768ec`, `d150c3039`,
`4bf4e7e70`, `13a24ece2`, `db0cf2231`, `85986144c`, `48fa7a381`,
`c205b6c35`, `c98a72d69`, `fd5a1cd59` and `f97660cdd`; stage 12
(`packages/formula`) is PR objectstack-ai#21635, in review, with a disjoint file
surface. objectstack-ai#20595 stays open: this PR does not touch `formula`, nor the
test-string sites the card carries for a widened stage, and the seat
decides the card's close-out.

Every comment or docblock site in the package that cited a tracker
number answering 404 is rewritten in ruling C+D's form C (record
`5749154545` on objectstack-ai#19123): the ADR when one records the decision,
otherwise the commit in this repository's history that made it. That is
**3 sites on 3 lines in 3 files, all citing one number (objectstack-ai#11021), all
re-anchored to one commit, `7d81c889f`**:

- **2 census sites** (2 lines: `src/repository.ts:230` and
`src/sync.ts:41`): the whole `allocated-but-absent` population of the
gate's own census in this package at the base;
- **1 test-comment site** (`test/close-terminates-watch.test.ts:103`),
outside the census glob (`test/` is not under `src/`, and the census
defers test files anyway). Same number;
- **outside the census glob, inside the claimed surface: none.**
`README.md`, `tsconfig.test.json` and `vitest.config.ts` carry 10
citations between them, and all 10 resolve on the enumerated board
(below); `package.json`, `tsconfig.json` and `tsup.config.ts` carry
none;
- **dead comment ids: none.** The package carries no ten-digit comment
id and no `issuecomment` or `discussion_r` link (git grep exit 1).

**Anchors: 1 number, by commit; 0 by ADR, 0 by repository qualifier; 1
sha.** `7d81c889f` is the anchor stage 1 (`a7d9768ec`) and stage 9
(`c98a72d69`) already chose for the same number; it is reused here and
re-proven below for this package's sentences.

Only comments changed. All three files keep their line counts (3 lines
out, 3 in, plus the changeset), so no line citation into any of them
moves. No code token moves (the guard below). All 6 changed lines under
`packages/` open with a comment marker. **No citation number is added**:
the only tracker number on a `+` line is `objectstack-ai#11127` on `sync.ts:41`,
carried over unchanged from the `-` line, and it resolves (a closed
issue). The only new nine-hex span is `7d81c889f`, 3 times.

**A `patch` changeset**: 1 of the 2 rewritten non-test lines is in the
published `dist` (the `FileSystemRepository.close()` docblock, in the
declarations and in the JavaScript esbuild emits), and `dist` is not
byte-identical with the base text (see Changeset).

## H0: the package and its size

The gate's own `node scripts/check-issue-citations.mjs --census --json`
at base `0fc80878f` (the before run below), `allocated-but-absent` per
remaining `domain:engine` package (stage 12's H0 list):

| package | before | after this stage |
|---|---|---|
| `metadata-fs` | **2** | **0** |
| `formula` | 4 | 4 (removed by stage 12, PR objectstack-ai#21635, not yet on `main`)
|
| `drivers/driver-mongodb`, `drivers/driver-turso`, `metadata-core`,
`core`, `metadata-protocol`, `objectql`, `metadata`,
`drivers/driver-sql`, `drivers/driver-memory`,
`drivers/driver-sqlite-wasm`, `plugins/plugin-pinyin-search`,
`platform-objects` | 0 each | 0 each |

`metadata-fs` reads 2, both objectstack-ai#11021, at `repository.ts:230` and
`sync.ts:41`, exactly as stage 12's head census (`b89eb86cb`) read it.
So the stage went ahead. On this branch's tree the lane total goes 6 to
4; with stage 12's PR it is 0.

## Census: `metadata-fs`, before and after

**Instrument.** The gate's own `node scripts/check-issue-citations.mjs
--census --json`, read-only and unchanged. The count is its
`allocated-but-absent` findings under `packages/metadata-fs/`.

| reading | tree | board | whole-repo `allocated-but-absent` | sites |
lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `0fc80878f`, run 21:04:24Z to 21:07:44Z | enumerated,
195 pages, frontier objectstack-ai#21636, 19,457 records | 119 | **2** | 2 | 2 | 1 |
| after | `67f182575`, run 21:14:26Z to 21:17:43Z | enumerated, 195
pages, frontier objectstack-ai#21637, 19,458 records | 117 | **0** | 0 | 0 | 0 |
| head | `32de59091`, run 21:37:42Z to 21:40:56Z | enumerated, 195
pages, frontier objectstack-ai#21639, 19,460 records | 117 | **0** | 0 | 0 | 0 |

The whole-repo drop is 2, and the before and after finding sets differ
by exactly the 2 rows of this package, removed; none was added.
`resolves` (35,763), `resolves-as-pull-request` (2,383) and
`cross-repo-unjudged` (1,254) did not move: `objectstack-ai#11127` stays on
`sync.ts:41` and still resolves. The head's only later commit is the
changeset; the head run's finding set is identical to the after run's,
line numbers included.

**Supplementary instrument, the whole package.** The census reads
neither test files nor strings nor files outside `src`. A second reading
runs the gate's own exported `extractCitations` (whole-file and
comment-prose projections) over every tracked file in the package (24)
and classifies each citation with the gate's `classifyCitation` against
one board enumerated by the gate's `enumerateBoard` (195 pages, frontier
objectstack-ai#21636, 19,457 records, read from 21:09:16Z to 21:12:31Z), the same
board for both readings.

| reading | citations | dead | src comment | test comment | test string
| other files | changelog |
|---|---|---|---|---|---|---|---|
| before, `0fc80878f` | 137 | **5** | 2 | 1 | 0 | 0 | 2 |
| after, `67f182575` | 134 | **2** | 0 | 0 | 0 | 0 | 2 |

The citation count drops by 3, the 3 rewritten sites; no respelling
stays a citation. The live counts did not move (src comment: 39 resolve
as issues, 2 as pull requests; test comment: 44 and 9; test string: 10
and 0; files outside `src` and `test`: 8 and 2; changelog: 16 and 2). A
third, raw reading (every `#` followed by 2 to 6 digits, whatever
surrounds it, `CHANGELOG.md` aside) counts 117 before and 114 after:
also a drop of 3.

**Single reads** over the issues endpoint: objectstack-ai#11021 answers 404; objectstack-ai#11127
answers 200 (a closed issue: `FileSystemRepository.close()` never
reaching its broker); objectstack-ai#11136 answers 200 (the pull request whose squash
is `7d81c889f`).

## Per-number table

`src` counts census sites, `test` the test-comment sites.

| number | src | test | anchor | kind | source | what it decided |
|---|---|---|---|---|---|---|
| `objectstack-ai#11021` | 2 | 1 | `7d81c889f` | commit | reused (stage 1 `a7d9768ec`,
stage 9 `c98a72d69`), re-proven here | `close()` terminates watch
iterators instead of emitting a drain event: it measured why a synthetic
drain event is the wrong shape (the filter and the numeric `since` drop
it, and delivering an event never ends an iterator), fixed that defect
in `SysMetadataRepository`, and wrote invariant 8 in `metadata-core`'s
`repository.ts` (the squash of PR objectstack-ai#11136) |

Each sentence, judged against the decision it describes rather than the
number:

- **`repository.ts:230`** says a synthetic drain event 「would be the
wrong shape and was measured to be so」. The measurement is
`7d81c889f`'s: its message and its invariant 8 text record both halves
(the filters drop it; delivery never ends an iterator). The
`metadata-fs` fix that wrote this docblock, `46644e25a` (objectstack-ai#11127), cites
objectstack-ai#11021 as that earlier decision.
- **`sync.ts:41`** sends the reader to invariant 8 in `metadata-core`'s
`repository.ts` and names the pair `(objectstack-ai#11021, objectstack-ai#11127)`. `git blame` at
the base puts invariant 8's statement (`repository.ts` :53 to :76) on
`7d81c889f` (its line :63 since re-anchored by stage 9), and its
conformance rows for `FileSystemRepository` and `InMemoryRepository`
(:77, :79 to :88) on `46644e25a`, the objectstack-ai#11127 fix. So the dead half of
the pair becomes `commit 7d81c88`, and the live `objectstack-ai#11127` stays.
- **`close-terminates-watch.test.ts:103`** contrasts this package's hang
with 「the sibling defect」: the filter-dependent drain-event drop in
`SysMetadataRepository`, which is the defect `7d81c889f` fixed.

The proof, per the earlier stages' standard:

- `git rev-parse --disambiguate=7d81c889f` matches exactly one commit,
`7d81c889f1f190a273e59ee584b7522ca6a792fa`.
- `git merge-base --is-ancestor` puts it under the base `0fc80878f`,
under `origin/main` `a1ca156da` at the time, and under `main`
`5b5e83f44` fetched later (exit 0 each; exit 0 is self-proving, and the
repository is not shallow).
- It names objectstack-ai#11021 in its message (the subject) and in its diff (7 diff
lines).
- `git blame` at the base puts all 3 changed lines on `46644e25a`, which
cites objectstack-ai#11021 as the sibling decision; that decision is `7d81c889f`'s
diff, as the three bullets above set out.
- No ADR names objectstack-ai#11021 or the drain-event decision (git grep over
`docs/adr` for the number, 「drain event」 and 「Shutdown terminates」, exit
1).

## Wordings to check

All 3 rewrites swap a tag in place, in forms the earlier stages already
use:

- `(objectstack-ai#11021)` became `(commit 7d81c88)` at `repository.ts:230` (stage
1's `(#N)` form).
- `(objectstack-ai#11021, objectstack-ai#11127)` became `(commit 7d81c88, objectstack-ai#11127)` at
`sync.ts:41`. The mixed commit-and-issue pair already stands in
`packages/cli` (`(commit 44813ba, objectstack-ai#14554)`).
- 「unlike the sibling defect in objectstack-ai#11021」 became 「unlike the sibling
defect commit 7d81c88 fixed」 at `close-terminates-watch.test.ts:103`,
the form
`packages/cli/src/commands/generate-multiple-json-column.pin.test.ts:57`
uses (「the defect commit ee370d3 fixed」).

**No reflow.** No line was reflowed, so `repository.ts:230` and
`close-terminates-watch.test.ts:103` are now longer than their block's
wrap. `eslint.config.mjs` declares no line-length rule, and a reflow
would move neighbouring lines. No file cites a line of any of the three
touched files (git grep exit 1), and neither changed phrase is quoted
elsewhere.

## Sites left

- **In comments (src, test, outside the glob): none.**
- **String literals: none.** The package's 10 test-string citations all
resolve.
- **Outside `src` and `test`:** the release-owned `CHANGELOG.md` names
objectstack-ai#13112 (line 47) and objectstack-ai#11021 (line 196), both answering 404; left.

## Mechanical guard: no code token moves

The guard compares base `0fc80878f` against the tree over all three
touched files, with TypeScript 6.0.3, to the earlier stages' two-reading
specification. The earlier guard scripts were scratch files, so it was
rewritten here to that specification and proven with the controls below.

- **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk.
Comments are trivia there, and JSDoc is never visited. A leaf that is
not itself a token is re-scanned with trivia skipped.
- **Reading 2**: the full token stream in parser context, from a
`getChildren` walk, with JSDoc nodes skipped. String, template and
numeric literals are compared in full on both readings.

Results, at `67f182575` (the later commit touches none of the three
files):

- Real run: 6,075 base tokens (reading 2), **0 files with a token
change** (exit 0).
- Comment controls: 「awaiting the watcher,」 to 「… the WATCHER,」
(`repository.ts`), 「Best-effort cleanup:」 to 「Best-effort CLEANUP:」
(`sync.ts`) and 「bites on its own.」 to 「bites ON its own.」 (the test
file). 0 files changed (exit 0 each).
- Positive control, an identifier (`export function createBroker(` to
`createBrokerX(`, `sync.ts`): DIFFER on both readings (exit 1).
- Positive control, a string literal (the word 「no」 in the 「empty
filter, no since」 row label, to 「NO」, the test file): DIFFER on both
readings (exit 1).
- Positive control, a template literal (the `@` separator in the
sweep-failure key template, to `#`, `repository.ts`): DIFFER on both
readings (exit 1).
- Positive control, a numeric literal (`SETTLE_MS = 2_000` to `2_001`,
the test file): DIFFER on both readings (exit 1).

Each mutation went through `scripts/ablation-replace.mjs` (wrap mode;
the anchor hit 1 before and 0 after, and the blob changed). It ran under
a shell trap that restores by absolute path from `HEAD`. Each restore
was proven equal to its `HEAD` blob (`888f39203432`, `122a45dcb516`,
`0d266189d60c`), and afterwards `git diff HEAD` was empty and the tree
clean.

## Changeset: `patch` (`dist` measured)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. One script ran under the shared verify lock (VERDICT
command-exit 0, held 79s, shared-box seconds), at `67f182575`. It built
the dependency closure first (`pnpm --workspace-concurrency=2 --filter
'@objectstack/metadata-fs^...' build`, exit 0), then ran the package's
own `build` (tsup and `check-dts-emitted`) three times, exit 0 each:

- **Leg 1**, the head text: 6 `dist` files hashed (`index.js`,
`index.cjs`, their sourcemaps, `index.d.ts`, `index.d.cts`). 1 of the 2
rewritten non-test lines appears verbatim in `dist`: the `close()`
docblock line (`repository.ts:230`), in `index.d.ts`, `index.d.cts`,
`index.js` and `index.cjs` (esbuild keeps that docblock). The other
(`sync.ts:41`) sits on an interface that is erased from the JavaScript
and never reaches the declarations (grep exit 1).
`scripts/ablation-dist-preflight.mjs` finds the head marker 「was
measured to be so (commit 7d81c88)」 in those 4 files with a clean tree
(exit 0).
- **Leg 2**, the base text put back in `repository.ts` and `sync.ts`
(proven equal to their base blobs `d551426a7545` and `48798bcf7661`,
written to the tree only, 0 paths staged): 4 of the 6 files differ from
leg 1 (`index.js`, `index.cjs`, `index.d.ts`, `index.d.cts`); the two
sourcemaps do not. The preflight finds the base marker 「was measured to
be so (objectstack-ai#11021)」 in the same 4 files (exit 0).
- **Leg 3**, after the proven restore (equal to the `HEAD` blobs
`888f39203432` and `122a45dcb516`, `git diff HEAD` empty, porcelain
empty): all 6 files are byte-identical to leg 1. The preflight's
`--absent` reading of the base marker exits 0 with a clean tree. So the
build is deterministic, and the difference is the rewrite.

So the rewrite ships.
`.changeset/20595-metadata-fs-provenance-anchors.md` declares a `patch`
for `@objectstack/metadata-fs`, comment text only, with the claim's
`Clause-②: no` line. The anchor is a commit, so the changeset names no
ADR, repository qualifier or bracketed substitution. It says which
published files carry the reworded text, as measured above. The
changeset commit touches no file under `packages/metadata-fs`.

## Gates (head `32de59091`)

- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `32de59091` (4 paths against
merge base `0fc80878f`) derived 61 commands. All 61 ran (21:25:07Z to
21:36:22Z, after the workspace build), each exit code captured before
any pipe: 61 exit 0. `--ran` reports 「61 derived, 61 run, 0
NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits 0.
- The dispatch's lead derivation (47 commands, one path) is a subset.
The extra 14 are:
- the eight families the `.changeset/` path adds: the ADR-0087
registration and empty-changeset pairs, `check:objectui-changeset`,
`check:pm-changeset-deadline-census` and two release self-tests;
    - `check:type-check-coverage` and `check:type-check-debt`;
- four gates whose sources name the touched files:
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:query-options-erasure` and `check:where-matcher`.
- **Named readings:**
- `node scripts/check-issue-citations.mjs` exits 0 (1 citation judged
across 2 files: the carried-over `objectstack-ai#11127`, which resolves).
- `pnpm check:issue-citations` exits 0 (its self-test, 173 cases, 9
batteries).
- `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id
baseline holds, no growth).
- `pnpm check:nul-bytes` exits 0 (10,001 files, no raw control bytes),
and a control-byte grep over the 4 changed files finds none (exit 1).
- The changeset gates exit 0: `check-adr-0087-registration` (「1
non-breaking changeset(s) seen」), `check-empty-changeset` (「1 declaring
changeset(s) added」), `check-changeset-no-major` (「no `major` bump」),
and `check:changeset-gate-self-tests`. The Clause-② level axis of
`check-changeset-no-major` reads the pull request body, so it does not
apply to a local run; that reading is CI's.
- **Build, tests and typecheck, under the verify lock**, at `32de59091`:
- The workspace build (`turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2`): VERDICT command-exit 0,
held 198s, shared-box seconds; 71 of 71 tasks, 20 cached.
- The tests and typecheck: held 33s. `pnpm --filter
@objectstack/metadata-fs test`: 10 test files pass, 70 tests pass (exit
0). `pnpm --filter @objectstack/metadata-fs typecheck` (`tsc --noEmit`
and `tsc --noEmit -p tsconfig.test.json`) exits 0.
- `tsc --listFilesOnly` puts every touched file in a program:
`repository.ts` and `sync.ts` in both configs, and
`close-terminates-watch.test.ts` in `tsconfig.test.json`, whose program
holds all 10 test files.
- No importing package owes a run, because the declaration files change
only in comment text.
- **Lint, as a proven narrowing, at `32de59091`:**
- eslint ran with inline config disabled (`--format json`) over the 3
touched files plus `dist/index.js` as the control.
- 4 results: 0 errors, and 1 warning, which is the control's ignore
notice. No touched file is reported ignored, and `--print-config`
resolves a config for each.
- `eslint.config.mjs` never enables type-aware linting (its lines 327
and 328 say so; `--print-config` shows no `parserOptions.project` and no
`projectService`), so a comment edit cannot move the verdict on an
untouched file.
  - The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **Base, and no merge.** The dispatch read `origin/main` at
`0fc80878f`, and the worktree was cut there. Every reading above is on
this branch's own tree. Before this PR was opened, `main` moved 4
commits, to `5b5e83f44` (`objectql`, `driver-sql` and
`service-analytics`, `spec`).
- None of them touches `packages/metadata-fs`,
`check-issue-citations.mjs` or `dispatch-gates.mjs`.
- A local `git merge-tree` of the head with `5b5e83f44` is clean (exit
0), and the anchor `7d81c889f` is under it too (`--is-ancestor`, exit
0).
- The gate's diff mode, run in a scratch checkout of `5b5e83f44` against
`0fc80878f`, judges the 26 citations those 4 commits add: all 26
resolve. So they add no dead citation to the lane's packages.
- The branch is not merged with them: the gate derivation reads the
three-dot change set against the merge base `0fc80878f`, and CI and the
merge queue run on the merged ref.
- **History.** The repository is not shallow (`git rev-parse
--is-shallow-repository` answers false), so no deepening was needed
before the blame, ancestry and history readings.
- **The same dead numbers outside this package's comments**, each left
to its own carrier: `CHANGELOG.md` (objectstack-ai#13112 and objectstack-ai#11021, release-owned).
- **Wording only:** no line without the number was changed.

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

---------

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

Development

Successfully merging this pull request may close these issues.

2 participants