Skip to content

fix(service-storage)!: 引擎写入/读取失败不再伪装成功 —— sys_file 业务真相丢失时响亮失败 (#5216) - #5232

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5216-storage-metadata-loud-failure
Aug 4, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-5216-storage-metadata-loud-failure

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5216

按 PM 在认领评论里的裁定走 方案 A + 读路径细化:引擎在场时写失败直接抛,读路径区分 miss 与 outage,Map 退化为 engine 缺席时的替身。

一处事实订正:是 6 写 / 2 读,不是 5 写 / 3 读

Issue 正文的 8 行清单本身是准的,PM 派发词里的「5 处写 / 3 处读」是笔误。metadata-store.ts 的 8 处引擎调用是:

方法 调用 分类
createFile insert('sys_file') 写
getFile findOne('sys_file') 读
updateFile update('sys_file') 写
deleteFile delete('sys_file') 写
createSession insert('sys_upload_session') 写
getSession findOne('sys_upload_session') 读
updateSession update('sys_upload_session') 写
deleteSession delete('sys_upload_session') 写

6 写 2 读。裁定的形状逐处适用,不受这个计数影响。

写路径(6 处):抛,并且不再往 Map 里写

if (this.engine) 已经把「没接引擎」分流掉了,所以这些 catch 捕获的只可能是已接好的引擎的运行期失败。现在它们统一包成 StorageMetadataStoreError 抛出。

Map 残影的处理:引擎在场时根本不写 Map,而不是「写了再回滚」。理由是这才是问题的机制本身 —— 旧代码是先 this.files.set(...) 再调引擎,所以引擎写丢了以后,紧随其后的 getFile() 从 Map 里读到那条「以为写成功了」的记录,同进程内的自检也看不出异常。回滚只能消除失败那一次的残影,消除不了「同一份数据有两个源」这件事;把 Map 写入整体收进 if (!this.engine) 分支之后,引擎在场时 Map 恒为空,残影在结构上不可能出现,而且读路径也不必再区分「Map 里的是权威还是影子」。这同时让类注释所声称的事第一次成为真的:Map 服务的对象就是 engine === null 的那条分支。

读路径(2 处):miss 与 outage 分开,两处都判定为抛

  • miss(findOne 返回空)—— 设计内的答案,返回 null,REST 层照旧 404。行为不变。
  • outage(findOne 抛)—— 传播出去。

两处都选「抛」而不是「error 日志后回退」,理由按调用方语义:getFile 的三个调用方(/upload/complete、/files/:fileId/url、/files/:fileId)和 getSession 的三个(chunk、complete、progress)在拿到 null 时一律回 404 FILE_NOT_FOUND / UPLOAD_SESSION_NOT_FOUND。也就是说,静默回退在这里不是「降级到旧数据」,而是把一次引擎故障翻译成「这个文件不存在」——把持久的业务真相报告为缺失,比 500 更糟,且调用方无从分辨。加上写路径改动之后引擎在场时 Map 恒为空,「回退到 Map」实际等价于「返回 null」,也就是等价于那个假 404。多 worker 下更明显:Map 只有本进程的影子,回退会让同一次读在不同 worker 上给出不同答案。

上层调用方:一个都没改,并且这是被验证过的,不是假设

storage-routes.ts 的每个 handler 本来就是 try { … } catch (err) { sendError(res, 500, 'INTERNAL', err?.message) },所以 store 抛出的错误自然落成 500 —— REST 层不需要任何适配。storage-routes.metadata-outage.test.ts 直接驱动 handler 断言了这一点(500 且 success: false,而不是原来的 200)。

仓库内 StorageMetadataStore 的构造点只有 storage-service-plugin.ts:360 一处;packages/cli、plugin-dev、qa/dogfood 只用 StorageServicePlugin,不碰这个 store。所以本 PR 没有修改任何调用方。

没有引入新的错误码:一个专门的 STORAGE_METADATA_UNAVAILABLE(503 更诚实)需要在 packages/spec 的 ERROR_CODE_LEDGER 注册,而本单 ⛔ packages/spec。500 INTERNAL 已经满足「不再是 200」这个验收点,错误码收窄可以另立单。

错误对象

StorageMetadataStoreError(已从包根导出,连同 StorageMetadataOperation 类型):

  • objectName —— sys_file / sys_upload_session
  • operation —— insert / update / delete / findOne
  • cause —— 引擎自己的错误(本包编译在 lib: ES2020,早于 Error.cause,所以是自己声明的字段)
  • message —— 按 AGENTS.md「Degradation log levels」的要求,同时带后果与修复。日志级别那条规则本身在这里通过「rethrow」满足,所以没有给 store 加 logger 构造参数;后果与修复写进 message,反而能一路走到 500 的 body 和宿主的日志里。

例:

StorageMetadataStore: sys_file insert failed against the data engine — the sys_file
row was NOT written, so the uploaded bytes have no durable record and are
unaddressable after this process exits. Restore the data engine (connectivity /
permissions / `sys_file` schema migration); the process-local Map fallback serves
only deployments with NO engine wired (tests, dev), so it cannot stand in here.
Cause: Error: …

关于 DURABILITY_CRITICAL_CALLEES:故意不加

AGENTS.md 说发现新的 durability seam 要在同一个 PR 里登记进 scripts/check-durability-degradation-log-level.mjs。这里判断是不该加,理由两条:

  1. 修复方式是把 catch 整个删掉,这个文件里已经没有 catch 可供该 gate 检查;
  2. 该 gate 按被调方法名匹配。这里的被调方法是 insert / update / delete —— 引擎的通用数据面动词。把它们加进词表会命中全仓库每一个包着引擎写入的 catch,而其中绝大多数(包括 storage-routes.ts 自己那些 catch → sendError(500),gate 看不出「回 500 给调用方」也是一种传播)会变成需要 baseline 豁免的假阳性 —— 那正好是把 baseline 变成没人信的清单的做法。

本处的回归保护由新增的单元测试承担。

测试

changeset:.changeset/storage-metadata-loud-failure.md,major,写明了 breaking 的影响面(能观察到的变化是「原本无人察觉的数据丢失现在变成一个 500」,没有需要迁移的东西)。

https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd


Generated by Claude Code

…s success (#5216)

`StorageMetadataStore` wrapped all eight of its `IDataEngine` calls in
`try { … } catch { /* ignore */ }` — no logger, no rethrow, no degradation
flag. `if (this.engine)` had already separated "no engine wired" out, so
those catches could only fire on a RUNTIME failure of a wired engine, and
every one was swallowed behind a process-local Map write that made the
loss invisible inside the same process. A failed `sys_file` insert lost
mostly-permanent business truth (#5202) while the API answered 200.

With an engine wired, the engine is now the only store:

- writes (createFile/updateFile/deleteFile, createSession/updateSession/
  deleteSession) propagate as `StorageMetadataStoreError` and mirror
  NOTHING into the Map, so no shadow can make a lost write look landed;
- reads (getFile/getSession) separate MISS from OUTAGE — `findOne`
  returning nothing still yields `null` (404 unchanged), a thrown engine
  error propagates rather than serving this worker's stale local guess;
- the Map is now exactly what the class doc claimed: the engine-absent
  stand-in. `new StorageMetadataStore(null)` is unchanged in every respect.

The error message carries the CONSEQUENCE and the FIX per AGENTS.md
"Degradation log levels", and `objectName`/`operation`/`cause` identify
the failure. No route needed editing: the storage handlers already wrap
everything in `catch → sendError(500, 'INTERNAL', …)`, so a lost write is
now a 500 and a read outage is a 500 instead of a false 404.

Tests: metadata-store.test.ts (engine-null behaviour unchanged, no Map
mirroring with an engine present, every write/read outage loud, miss still
null) and storage-routes.metadata-outage.test.ts (the HTTP-visible half).
Both fake engines route `delete` through `assertEngineDeleteDispatch`
(#4550/#5197), which is why `@objectstack/objectql` joins devDependencies.

Fixes #5216

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
@vercel

vercel Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 11:14am

Request Review

@github-actions github-actions Bot added the size/l label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/plugin-endpoints.mdx (via @objectstack/service-storage)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-storage)
  • content/docs/plugins/packages.mdx (via @objectstack/service-storage)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-storage)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling labels Aug 4, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 11:36
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 718b229 Aug 4, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5216-storage-metadata-loud-failure branch August 4, 2026 11:47
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ts that decided them (objectstack-ai#20757)

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

## What changed

This is the eleventh stage of the `domain:services` lane of the
dead-citation sweep. It covers `packages/plugins/plugin-email/src/**`
and nothing else. By the seat's census at the claim (`5902547086`), 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 10 (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`, PR objectstack-ai#20717 as `cbaf04c1f`, PR objectstack-ai#20729 as `d2820876f`, PR
objectstack-ai#20737 as `4dfff176b`, PR objectstack-ai#20742 as `697845d19`). That is **16 sites on
16 lines in 8 files, covering 4 numbers**:

- 7 census sites (every census site this package has);
- 9 sites in test comments, which the census defers. Three of them carry
`objectstack-ai#13190`, a dead number that stands only in test files here, so the
census never judged it; it was read on its own (404);
- no site the gate's grammar cannot see (the package has none that is
dead, see Acceptance notes).

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: **4 distinct shas**. No number in this package has an ADR or
ruling record of its own (a grep of `docs/adr/` and
`scripts/adr-anchors/` finds only ADR-0131 naming `objectstack-ai#11741`, as evidence
in its D7, not as the record of that decision; nothing else under
`docs/` names the four), 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
(16 lines out, 16 in, over 8 files), so no line citation into these
files moves. Every one of the 16 changed lines carried a dead citation;
there is no reflow line. No code token moves (see the guard below).

**No citation number is added.** The added lines carry no tracker number
at all. Over the whole diff, added minus removed is negative for the
four dead numbers and zero for every other number, and no number is new
to the diff. No PR number is the citation on an added line: the two `PR
objectstack-ai#8675` spellings became that pull request's squash commit.

10 dead sites are left on purpose, all of them `describe` / `it` titles
(see the list below).

One more file: a `patch` changeset for `@objectstack/plugin-email`,
because the rewritten prose ships (see Changeset below).

## Census: `plugin-email`, 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-email/`. Each run counts as a reading only
because its board frontier equals the newest issue or pull-request
number, read by a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
plugin-email sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `97005aed0`, run 2026-09-30T02:00:45Z to 02:04:02Z |
enumerated, 186 pages, frontier objectstack-ai#20748 (newest objectstack-ai#20747 before, objectstack-ai#20748
after: a pull request opened at 02:03:20Z, inside the run) | 1,064 |
**7** | 7 | 4 | 3 |
| after | head `15a7d69a7`, run 02:11:19Z to 02:14:30Z | enumerated, 186
pages, frontier objectstack-ai#20753 (newest objectstack-ai#20753 before and after) | 1,057 | **0**
| 0 | 0 | 0 |

The before count matches the seat's census and A1 (7 sites: `objectstack-ai#13189` ×4,
`objectstack-ai#11741` ×2, `objectstack-ai#8675` ×1). The before run's board moved during the run;
its frontier equals the newest number at the run's end, which is A1's
criterion (stage 7's precedent). The whole-repo drop is 7, exactly this
diff's census sites. The `resolves` tally is 33,029 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 `15a7d69a7`; the head
`23283d394` 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-email/src` (50 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, 37,072 rows) and did not report
it. The eleven numbers the census never saw, because they stand only in
test files or as the second half of a slash pair here, were read one by
one on the issues endpoint: `objectstack-ai#13190` answers 404; `objectstack-ai#5169`, `objectstack-ai#5286`,
`objectstack-ai#10619`, `objectstack-ai#16506`, `objectstack-ai#20374`, `objectstack-ai#5197` answer 200 as issues, and `objectstack-ai#8348`,
`objectstack-ai#5191`, `objectstack-ai#5211`, `objectstack-ai#5232` as pull requests.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `97005aed0` | 360 | **26** | 7 | 9 | 0 | 10 |
| after, `15a7d69a7` | 344 | **10** | 0 | 0 | 0 | 10 |

Its src-comment column equals the census's 7, which is the control on
the second instrument. The 323 live citations are the same in both
readings, and the drop of 16 citations is exactly the rewritten sites.
11 extracted tokens are not tracker references at all and are not
judged: the HTML entity `&objectstack-ai#39;` (6 sites in the template engine and its
tests) and the fixture subjects `Invoice objectstack-ai#42` to `Invoice objectstack-ai#45` (5
sites). A third, raw reading (every `#` followed by 2 to 6 digits,
whatever surrounds it) finds 371 occurrences and 26 dead before, 355 and
10 after. Beyond the gate's grammar it sees 11 tokens, none dead: the
nine second numbers of the `#A/#B` lines (all live), the excused `Prime
Directive objectstack-ai#12`, and the CSS colour `#2563eb`.

## 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 all 16 line and anchor
pairs).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#13189` | 13/4 | 8/5 | `33fbd3566` (PR objectstack-ai#13375): the SMTP port guard
tests integrality (`Number.isInteger`), so a fractional port such as
`587.5` is refused at construction, and the generated refusal sentence
reads `(expected an integer 1-65535)`, the range still rendered from the
constants. Its changeset headline names `objectstack-ai#13189`; its diff writes the
integrality docblocks the rewritten lines sit in. New to the sweep |
| `objectstack-ai#13190` | 5/1 | 3/2 | `56c5b1dbe` (PR objectstack-ai#13316):
`smtpOptionsFromMailSettings` passes a present-but-unreadable
`smtp_port` through to the guard instead of omitting it (which had
silently fallen back to 587); absent and `''` still mean "not set", and
no second refusal was added. Its changeset headline names `objectstack-ai#13190`; its
diff writes the `objectstack-ai#13190` comment block itself. New to the sweep |
| `objectstack-ai#11741` | 6/3 | 3/3 | `b706af987` (PR objectstack-ai#11839): `SendEmailInput` /
`SendTemplateInput` gain an optional `organizationId`, which
`plugin-email`'s writer stamps verbatim onto `sys_email.organization_id`
(pass-through only, no resolution or fabrication), and `sendTemplate`
forwards it as a producer of `send()`. Its message names `objectstack-ai#11741` as the
card that commit closed; `git blame` puts all three rewritten lines in
it. The `plugin-auth` stage's anchor for the same number |
| `objectstack-ai#8675` | 2/2 | 2/0 | `c9f595083`: the squash commit of the pull
request that was `objectstack-ai#8675` (its subject ends `(objectstack-ai#7987) (objectstack-ai#8675)`):
`sys_account`'s OAuth token columns are declared `internal: true`. Its
diff records the trap both lines describe: those columns are `required:
false`, so inferring "key missing, therefore the strip ran" broke
ordinary sign-in (16 red tests), which is why the readback carries the
`absenceProvesStrip` discriminator. New to the sweep |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 4), and every one is an
ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 4;
control leg: stage 1's landing `422db788a` exit 0; the history is
complete, `--is-shallow-repository` false, 15,155 commits). Each of the
4 numbers answers 404 on the issues endpoint, which serves pull requests
too. Independently, the package's own shipped `CHANGELOG.md` pairs
`b706af9`, `33fbd35` and `56c5b1d` with the same three decisions.

## Wordings to check

- **Tag swaps in parentheses.** 「(objectstack-ai#13189)」 became 「(commit 33fbd35)」
at `transports/smtp-port-contract.ts:87` (a section heading), `:134` and
`transports/smtp.ts:68`.
- **Line openers.** 「objectstack-ai#11741 —」 became 「Commit b706af9 —」 at
`email-service.ts:742` and `:1439`; 「objectstack-ai#13190 —」 became 「Commit 56c5b1d
—」 at `transports/smtp.test.ts:221`; 「## objectstack-ai#13189 —」 became 「## Commit
33fbd35 —」 at `transports/smtp-port-contract.test.ts:34`.
- **`email-service.test.ts:342`**, a section rule: 「── objectstack-ai#11741 —」 became
「── Commit b706af9 —」, and its trailing rule was shortened by 10
characters so the line keeps its width exactly.
- **`internal-header-readback.ts:37`.** 「(PR objectstack-ai#8675 hit exactly this on
`sys_account`'s optional」 became 「(Commit c9f5950 records exactly this
on `sys_account`'s optional」: a commit does not "hit" a trap, it records
one, and that commit's own diff is where the 16 red tests are recorded.
- **`email-headers-internal.integration.test.ts:251`.** 「The regression
PR objectstack-ai#8675 measured on a sibling card」 became 「The regression commit
c9f5950 records from a sibling card」, the same reading.
- **`transports/smtp-port-contract.test.ts:228`.** 「objectstack-ai#13189 is the card
that SPENDS that」 became 「Commit 33fbd35 is the change that SPENDS
that」, so the noun matches the anchor.
- **`transports/smtp.ts:127`, `transports/smtp.test.ts:272`, `:276`,
`:281`, `:283`.** The number became 「commit SHA」 in place (「until commit
33fbd35:」, 「The bucket commit 56c5b1d never had to name」, 「Commit
33fbd35 made the guard test」, 「Commit 56c5b1d's rule is that」,
「commit 33fbd35 changed which numbers」).

## The 10 sites left

- **Test strings, 10 sites on 9 lines**, all `describe` / `it` titles,
left as stages 1 to 10 left theirs: `email-service.test.ts:349` and
`send-template.test.ts:63`, `:88` (`objectstack-ai#11741`);
`transports/smtp-port-contract.test.ts:225`, `:309`, `:340` (`objectstack-ai#13189`);
`transports/smtp.test.ts:230` (`objectstack-ai#13190`), `:271` (`objectstack-ai#13189`), `:293`
(`objectstack-ai#13190` and `objectstack-ai#13189`).
- No source string, operator log string, assertion message, quoted
maintainer ruling or generated file in this package carries a dead
number.
- Outside `src`, the package's `CHANGELOG.md` names three of these
numbers on 5 lines. It is release-owned and deliberately not edited here
(see Acceptance notes).

## Mechanical guard: no code token moves

The guard compares the TypeScript parser's leaf nodes (a `forEachChild`
walk, so comments are trivia and JSDoc nodes are never visited), base
`97005aed0` against head. String and template literals are therefore
read in full. It ran over all 8 touched `.ts` files.

- Real run: 7,035 base leaf tokens, **0 files with a token change**
(exit 0).
- Comment control in `email-service.ts` (「no resolution, no default, no
fabrication」 to 「… no default and no fabrication」): 0 files changed, as
expected (exit 0).
- Positive control, a code token added in `transports/smtp.ts`
(`isValidSmtpPort(port)` given `as number`): DIFFER, 587 to 588 leaf
tokens (exit 1).
- Positive control, one digit changed inside a kept test title
(`transports/smtp.test.ts:293`, `objectstack-ai#13189` to `objectstack-ai#13188`): DIFFER (exit 1).

Every mutation went through `scripts/ablation-replace.mjs` (wrap mode)
under a shell trap that restores by absolute path, and each landed
(anchor 1 to 0, blob changed). Each restore was proven byte-identical to
the HEAD blob (`1e99bd5e2bcb`, `46c13267611b`, `da5314910bc4`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

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

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`, and the package is not private. After the build,
`b706af987` appears twice in each of `dist/index.js` and
`dist/index.mjs` (the two inline comments in `email-service.ts`, which
the bundle keeps). `c9f595083` appears once in each of `dist/index.d.ts`
and `dist/index.d.mts` (the `internal-header-readback.ts` docblock), and
so does `33fbd3566` (the docblock on `SmtpTransportOptions.port`).
`56c5b1dbe` reaches nothing (test files only). Positive controls, one
unchanged line beside each shipped rewrite, land exactly where their
neighbours do: 「context, so the input's organization is the one fact it
may stamp:」 and 「caller's organization so the sys_email row it persists
is stamped.」 once in each JS file; 「token columns: inheriting」 and the
unchanged line just above the rewritten one in the `port` docblock once
in each declaration file. A never-written negative phrase appears
nowhere in `dist`. None of the 4 dead numbers is left in `dist`.

## Gates (head `23283d394`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
exits 0. `node scripts/check-issue-citations.mjs` exits 0: the
diff-scoped run found no citation added against `97005aed0` (4 files
read; test files are a deferred surface).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `23283d394` derived 61 commands:
all 55 derived at dispatch, plus `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 61 exit 0. `--ran`, fed each command with its exit code,
reports 61 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-email test`: 31 files pass and 510
tests pass. `vitest list --filesOnly` names 31 files, all the tracked
test files, the 4 touched ones included.
- `pnpm --filter @objectstack/plugin-email typecheck` exits 0 (`tsc` on
`tsconfig.json`, then `check:test-typecheck` on `tsconfig.test.json`: 0
files and 0 errors in its debt ledger). `tsc --listFiles` holds all 8
touched files in both programs, and the test program holds all 50 files
under `src/`.
- **Lint, as a proven narrowing:** eslint with inline config disabled,
over the 8 touched `.ts` files, gives 8 files, 0 errors and 0 warnings.
All 8 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 9 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), and `NON_CITATION_HEADS` excuses a number after the word
「option」. In this package: `#N-word` none, `#A/#B` 9 lines, `option #N`
none, at the base and at the head, which is the claim's 0 / 9 / 0. Every
second number on the 9 slash lines answers 200 (`objectstack-ai#5197` ×2, `objectstack-ai#5191`,
`objectstack-ai#5211`, `objectstack-ai#5232` ×2, `objectstack-ai#5177`, `objectstack-ai#4251`, `objectstack-ai#5094`), so nothing there needed
rewriting.
- **ADR-0131 names `objectstack-ai#11741`.** Its D7 cites `objectstack-ai#11741` as the writer fact
that keeps `sys_email` tenant data. That is evidence inside a later
record, not the record of what `objectstack-ai#11741` decided, so it is not this
stage's anchor, and `docs/adr/**` is a governed Tier H surface outside
this card's stages. It joins the ADR-tree residue the seat already
carries (ADR-0131's `objectstack-ai#14484`, stage 2).
- **`CHANGELOG.md` is left.**
`packages/plugins/plugin-email/CHANGELOG.md` names `objectstack-ai#11741`, `objectstack-ai#13189`,
`objectstack-ai#13190` and `objectstack-ai#8675` on 5 lines. It is release-owned (AGENTS.md,
Documentation Guardrails), a deferred surface of the citation gate, and
⛔ not part of this stage.
- **「This card」 phrases are left.** 20 comment lines in 8 files of this
package speak of 「this card」, 「the card」 or 「the two cards」. They carry
no number and neither instrument sees them. Inside the `objectstack-ai#13189` test
block, they still have the kept `(objectstack-ai#13189)` title as their referent; the
one rewritten line that said 「the card」 now says 「the change」 (above).
The rest are unchanged, as in stages 8 to 10.
- **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#13189` →
`33fbd3566`; `objectstack-ai#13190` → `56c5b1dbe`; `objectstack-ai#8675` → `c9f595083`. `objectstack-ai#11741` →
`b706af987` reuses the `plugin-auth` stage's anchor.
- **Base.** The branch is on `main` at `97005aed0`. `main` has since
moved two commits (`9c8f113c6`, `a6866da0c`). Their 14 files touch
nothing under `plugin-email`, nor `scripts/check-issue-citations.mjs`,
`.changeset/config.json` or the `doc-authoring-prose-id` baseline, and
the three console-injection scripts they change are not among this
diff's 61 derived families. So no merge was taken; the merge queue
rebuilds on the merged generation.

---
_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

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants