Skip to content

fix(plugin-email): sys_email 的 queued 行在启动时被清扫,drain 失败升为 error (#5161) - #5191

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-5161-sys-email-queued-sweep
Aug 4, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-5161-sys-email-queued-sweep

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5161

基于 #5173 合并后的 main(9c4f1743c)工作。

问题

sys_email 的 status: 'queued' 只有一个消费时机:insert 当时的 afterInsert drain 钩子(以及 #5160 之后 send() 自己发出的 email.send.async 作业)。此后没有任何东西再看这一行。进程在 insert 之后、投递完成之前死掉,或者 drain 的投递抛错,这一行就永远停在 queued —— 一个以队列命名、却没有读者的状态,而调用方早就被告知"消息已接受"。

实现

1. 启动清扫 sweepStrandedOutbox(新文件 outbox-sweep.ts)

挂在 kernel:ready,位置在 email.send.async 订阅者注册与 #5160 的 boot 门之后 —— 注册表定型、订阅者已就位,重新入队的行才有地方落。按当前模式分流:

  • 队列模式:通过 EmailService.enqueuePersistedRow 发布 { rowId }。这是刻意复用 send() 自己的生产者:同一个 EMAIL_SEND_QUEUE 常量、同一份 publish options、同一个 sys_email: + 行 id 的 idempotencyKey。第二个生产者用自己的方式拼载荷,正是队列两半漂移的起点;而共享的幂等键让"还挂着 pending 作业的行"被清扫时收敛到那个作业上,而不是把第二个 worker 推到同一行上。
  • 内联模式(以及 publish 失败时的回退):deliverPersistedRow 就地把行推进到 sent/failed —— 进程没死的话 drain 钩子本来就会做这件事。publish 失败回退内联,与 send() 自身的判断一致:行已经提交了,把它继续留给"没人"才是本 issue 要修的 bug。

2. 捞取判据:只捞"够老"的行(5 分钟)

这是 PR 里最需要说清楚的一条。合格条件是 status='queued' 且 created_at 早于 now - 5min,而不是"早于本次 boot"。理由:

  • 几秒前插入的行不是滞留,而是某个人正在处理的在途工作 —— 可能是本进程 send() 的 insert 与 transport.send 之间,可能是本进程 setTimeout(0) 延后的 drain 钩子,也可能是多实例部署下另一个实例的同样两者。清扫它就等于把别人手里的活抢过来发第二遍。
  • "本次 boot 时间"在多实例下没有意义:兄弟实例一秒前插入的行比我的 boot 还新,但它显然不是我的。年龄是唯一在每个实例上含义相同的属性。
  • 5 分钟这个值:内联重试循环自己的退避上限是 2s,所以一次活着的投递要么早已定稿要么早已抛错;同时短到崩溃后重启仍能在同一个维护窗口里把信发出去。

年龄门之下还有两道兜底(不是许可):本进程 send() 正在持有的行(isServiceManaged)不碰;已经带 message_id 或已不是 queued 的行跳过 —— 与 email.send.async 订阅者投递前的幂等守卫同一套语义。

诚实交代边界:内联模式没有跨进程协调,本来也从来没有(drain 钩子同样如此),年龄门就是那里的全部保护。多实例的持久投递正是队列模式存在的理由,在那里幂等键使重复发布成为 no-op。

3. 边界与可见性

4. drain 钩子失败升 error

两处 catch 从 warn 升到 error,并按 AGENTS.md 的 degradation-log-level 标准带上后果(这封信没有发出、行停在 queued、本进程不会再重试)与修复(下次重启的清扫会捞;要让失败被重试和进 DLQ 就打开 Settings → Mail → "Durable queue delivery")。同时把 deliverPersistedRow 加进 DURABILITY_CRITICAL_CALLEES,以后再有 catch 把它悄悄降级会被 pnpm check:durability-log-level 挡住(该 gate 现为 14 个 seam,全绿)。

验收对照

  • 人为构造"insert 后进程死亡"的行:测试直接把行写进表(不触发钩子),重启后队列模式发出 { rowId } 作业并由真实 DbQueueAdapter 的一次 poll 推到 sent,内联模式直接推到 sent;不可发送的行推到 failed 而不是继续滞留。
  • drain 失败的日志级别与文案有用例钉住:断言 error 通道里有这行、warn 通道里没有,并逐条钉后果/修复/成因文本。
  • 默认路径不变:正常 send() 仍内联投递、app 写入的行仍由 afterInsert 钩子恰好投递一次,清扫在这两种情况下 scanned: 0。

测试

pnpm --filter @objectstack/plugin-email test    # 13 files / 195 tests passed
pnpm --filter @objectstack/plugin-email typecheck   # clean
node scripts/check-durability-degradation-log-level.mjs   # 14 seams, all loud
node scripts/check-startup-registry-verdict.mjs           # 40 seams, none recording a verdict
npx eslint packages/plugins/plugin-email/src --max-warnings=0   # clean

新增用例:outbox-sweep.test.ts(14 例,含年龄门、路由、兜底、边界与失败路径)、email-plugin.outbox-sweep.test.ts(10 例,真实 DbQueueAdapter 端到端 + drain 日志级别 + 默认路径)。

影响面

只动 packages/plugins/plugin-email/src/{email-plugin,email-service,index}.ts 与新增文件,外加 scripts/check-durability-degradation-log-level.mjs 的一条词表。未碰 headers/attachments 相关的任何东西(留给 #5177)、未碰 packages/spec、未碰 content/docs/releases/。用户可见,已带 changeset。


🤖 Generated with Claude Code

https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd


Generated by Claude Code

…t a failed drain at error (#5161)

`status:'queued'` had exactly one consumer — the afterInsert outbox drain that
fires during the insert itself (plus, since #5160, the email.send.async job
send() publishes). A process that died between the insert and the delivery, or
a drain whose delivery threw, left the row at `queued` forever: a state named
after a queue with no reader, with the caller already told the message was
accepted.

- `sweepStrandedOutbox` runs once per boot at kernel:ready, after the queue
  subscriber and the #5160 boot gate. Queue mode publishes `{ rowId }` through
  EmailService.enqueuePersistedRow (send()'s own producer, options and
  `sys_email:<id>` idempotency key, so a row with a pending job collapses onto
  it); inline mode finalizes the row in place via deliverPersistedRow.
- Only rows older than OUTBOX_SWEEP_MIN_AGE_MS (5m) are eligible — a young row
  is somebody's in-flight work, on this instance or a sibling, and age is the
  only property that means the same thing on every instance. Service-managed
  rows and rows carrying a message_id are skipped. Batch bounded at 500,
  oldest first, truncation reported.
- Boot does not await the sweep; it self-catches and reports at error, since a
  throwing kernel:ready handler is swallowed on LiteKernel (#5170).
- Both drain-hook catches now log at error with the consequence (the message
  was NOT sent, the row stays at `queued`) and the fix, per the AGENTS.md
  degradation-log-level rule, and deliverPersistedRow joins
  DURABILITY_CRITICAL_CALLEES so the level cannot regress.

Co-Authored-By: Claude Opus 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 8:35am

Request Review

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels 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/plugin-email.

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

  • content/docs/automation/flows.mdx (via @objectstack/plugin-email)
  • content/docs/deployment/environment-variables.mdx (via @objectstack/plugin-email)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-email)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/plugin-email)

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.

…l cause class

A row whose message cannot be reconstructed is already recorded as `failed` by
deliverPersistedRow, so the only way into this catch is the datasource or the
queue. Say that instead of sending the operator to inspect the row's columns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
…jectQL's own dispatch (#4550)

`check:engine-double-contract` flagged the new fake engine in
email-plugin.outbox-sweep.test.ts: its `delete()` filtered on `where.id`
directly instead of routing through `assertEngineDeleteDispatch`, which is
looser than the engine it stands in for on exactly the case a hand-written
mirror drops (`where: { id: { $in: [...] } }` reads as an id and is a
multi-row predicate the real engine rejects without `multi`).

Same shape as the sibling double in email-plugin.queue-delivery.test.ts
(b169f21); `@objectstack/objectql` is already a devDependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 08:41
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit d25f20b Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5161-sys-email-queued-sweep branch August 4, 2026 08:52
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
…ch,并收编 run-summary 的盲区实例 (objectstack-ai#5197) (objectstack-ai#5630)

同一天三个互不相同任务的 dev agent(3/3)新写假引擎全部踩 `check:engine-double-contract`
判红,错误一模一样 —— 手抄守卫、未路由 `assertEngineDeleteDispatch`(objectstack-ai#5173、objectstack-ai#5191、
objectstack-ai#5192),各花一轮 CI 往返 ≈15 分钟;objectstack-ai#5584 的新测试是第四次同款命中(objectstack-ai#5604)。这不是门禁
漏了,防线工作正常,代价纯粹是「新增测试 + 需要假引擎 + delete 路径」这个高频组合缺一行
提交前的提示。

os-dev 定义与 pm-dispatch 派发词模板各加一行同措辞纪律,把这轮往返省在提交前。两处都点名
手抄守卫**真有洞**这一实测事实,而不只是「风格不推荐」:objectstack-ai#5173 的手抄副本放行了
`where: { id: { $in: [...] } }` —— 它看着像 id,是多行谓词,真引擎无 `multi` 时拒收。
引用的样板是「门禁绿跑时自己列出的 pinned 假引擎」而不是一个会过期的计数。

第三处是 `service-automation/src/run-summary.test.ts` 的盲区实例(objectstack-ai#5197 评论定位):它的
内联假引擎 `async delete() { return false; }` 对谓词删除照单全收,而 `delete_record` 自
objectstack-ai#5393 起转发 `multi: cfg.multi === true`,所以 `{ objectName: 'deal', filter: { stale:
true } }` 这一形状真引擎是 reject。该文件既不在 pinned 也不在 DEBT 台账 —— 门禁的形参个数
判据够不到零形参的 delete(另立 objectstack-ai#5629 记录该扫描面缺口及实测口径),所以是检测器盲区,不是
已登记的债。后果不是假设:objectstack-ai#5225 里 showcase 的清扫流从上线起每次 `acted: 0`,单测全绿。

收编后按「补声明」处置而非重写断言:该 fixture 从来就不是契约内合法的,补 `multi: true`
声明其批量意图,于是 sweep 真的到达驱动,驱动报告匹配 0 行,用例原本的主题(计数器读 0)
完整保留。执行器侧的 reject 传播已由 `builtin/crud-bulk-intent.test.ts:153` 钉住,不在此
重复。

断言同时加强,这一步是实测逼出来的而非顺手:反向验证(假引擎已收编、`multi` 撤掉)预期红,
实际**仍然绿** —— 因为 `acted: 0` 既是「删了 0 行」也是「删除被拒」留下的痕迹,原用例唯一
的断言两种情形都满足,是为空而绿。补 `res.success` 与该节点 `runs: 1 / failures: 0` 之后
同一撤销才真的判红(`expected false to be true`),用例才在读它声称在读的那件事。

Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE

Co-authored-by: os-zhuang <hr@objectstack.ai>
Co-authored-by: Claude <noreply@anthropic.com>
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

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-email: sys_email 的 queued 行崩溃后永久滞留 —— 无任何轮询者,且 drain 钩子把失败 warn 掉

2 participants