Skip to content

fix(service-messaging): the email channel answers unavailable while only the log transport is live - #30547

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-cufcnh
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-cufcnh

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread
domain:server#6 · os-bill · session_01Utp6CXCS98uhsFQYk64epE

维护者速读

  • 改了什么:没配发信方式(只有「写日志」的假通道)时,邮件渠道不再自称可用;通知不再为邮件写投递记录,而是记一条「未配置发信」的抑制原因;已写下的投递记录被拒发,不再标成「成功」。
  • 为什么改:执行 cloud#2549 裁定 B 第 ③ 项。过去所有托管租户在管理员配好邮箱前,邮件通知都显示「已送达」,其实一封没发。
  • 风险与回滚:只动 service-messaging 的邮件渠道一个文件;没有实现 isConfigured 的第三方邮件服务行为不变。回滚即还原本 PR 一次提交。
  • 先看:packages/services/service-messaging/src/email-channel.ts 的 emailTransportRefusal 与 isAvailable / send 两处调用;其次是测试文件末尾新增的 describe。
  • 两分钟验证:pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2 src/email-channel.test.ts,预期 47 个全绿,其中 a LogTransport is no transport (#30510) 下 5 个。
  • 需要你做的:无,席位落地。

Fixes #30510

Clause-②: no
Metadata-protocol: none
Skills-docs: none — git grep -n -i -c "logtransport|log transport|transport_not_configured|email channel|e-mail channel" -- 'skills/**' has no hit (exit 1)

What changes

plugin-email registers the email service on every boot. With no mail settings saved, its live transport is the LogTransport: a send succeeds, writes sys_email, and no mail leaves the process. The email channel answered isAvailable() from "an email service is registered", so fan-out wrote a delivery row and the dispatcher marked it success while nobody was mailed. Every hosted tenant is in this state until its admin configures mail (objectstack-ai/cloud#2549, ruling B, item 3).

  • email-channel.ts: new emailTransportRefusal(email) asks the service's own deliverability answer, IEmailService.isConfigured() (already on the spec contract, already implemented by plugin-email from the live transport and already following the mail settings hot swap). An explicit false makes isAvailable() answer { available: false, reason: 'transport_not_configured' } and makes send() refuse with that same reason, graded permanent, without calling the service. It is read on every call, never cached, so a transport the settings swap in after boot flips the channel back with no restart.
  • EmailSenderSurface (the channel's structural view of the service) gains the optional isConfigured?(): boolean, mirroring the contract member.
  • The existing reason token is reused. No new reason, no new error code, no spec change, no plugin-email change.

Tests

New top-level describe email channel — a LogTransport is no transport (#30510) in email-channel.test.ts (the package's shared harness for this channel). Its double mirrors plugin-email's EmailService on the one axis that matters: isConfigured() answers from a swappable live transport.

  • unavailable with transport_not_configured while the transport only logs;
  • send() refuses with the same reason, graded permanent, and the service's send is never called (no fake sys_email row);
  • swap to a real transport: available and delivers; swap back: unavailable again;
  • control: a service with no isConfigured stays available and still delivers;
  • end to end (MessagingService + MemoryNotificationOutbox + dispatcher): an emit on a log-only deployment writes no delivery row and reports the suppression; after the swap one row lands success.

The producer half is already pinned in plugin-email (email-plugin.mail-settings.test.ts, "registered email service — isConfigured follows the mail settings": false on the out-of-the-box boot, true after a saved SMTP config, no restart).

Acceptance notes

  • Absent isConfigured is read as unknown and keeps today's behaviour (available). The contract makes the member optional and says a consumer must not read absence as true; this channel keeps sending through such a service, as it did before, because refusing would mute every host-supplied email service that predates the member. This follows the same choice the fan-out ruling made for a channel with no isAvailable. Only an explicit false refuses. If the seat reads the contract the other way, flipping it is a one-line change in emailTransportRefusal.
  • The real composition (plugin-email + service-messaging in one kernel) is pinned as two halves joined by the spec contract: service-messaging cannot import plugin-email (no dependency), and a dogfood pin is outside this card's file surface.
  • Serial note: this PR does not touch plugin-email, EmailService or the LogTransport, so it does not overlap seat 5's Setup small fixes: impersonation landing, account email lifetime, sender name, agent sets in pickers #30212 row 3.

Verification

All on head fbfbaf47 (origin/main fa9c0aa2 merged; no new commits on main since the branch point).

Red on main, green on the fix, ablation
  • Red (tests only, on fa9c0aa2): Tests 4 failed | 43 passed (47); the four new pins red, the control green.
  • Green: Test Files 1 passed (1) · Tests 47 passed (47).
  • Ablation via scripts/ablation-replace.mjs (anchor if (typeof email.isConfigured !== 'function') return undefined; replaced so the helper always returns undefined, the pre-fix behaviour): anchor 1 to 0, blob deebc1c7be23 to a81f47328d2c; Tests 4 failed | 43 passed (47); restored, blob equals HEAD, git diff HEAD empty. Tests import the channel from src/ by relative path, so no build sits between the mutation and the run.
Package suite, typecheck, lint, gates
  • pnpm --filter @objectstack/service-messaging test under the verify lock: Test Files 66 passed (66) · Tests 817 passed (817) · VERDICT command-exit 0.
  • pnpm --filter @objectstack/service-messaging typecheck: exit 0 (tsc --listFiles includes email-channel.test.ts).
  • eslint --no-inline-config on the two changed source files: exit 0. The repo-wide pnpm lint is left to CI's Lint job.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 62 commands; each run with its exit code captured before any pipe. --ran reconciliation: 62 derived, 59 run, 3 NOT-MEASURED, 0 UNRUN. The three exit-3 prerequisite refusals: pnpm check:dual-build-cjs-loads (needs the whole workspace built), pnpm check:i18n (needs its build closure; this diff touches no translation bundle), node scripts/check-plugin-teardown-shape.mjs --self-test (needs an unshallow clone; checker health only). CI runs all three.

Generated by Claude Code

…transport only logs (red)

Claude-Session: https://claude.ai/code/session_01Utp6CaGmRyZ3XL5MuUgEZg
Co-authored-by: Claude <noreply@anthropic.com>
…nly the log transport is live

The channel read a registered email service as available, so on a deployment
with no mail settings every e-mail notification dispatched to success while
plugin-email's LogTransport sent nothing. It now asks the service's own
isConfigured() on every call: false answers transport_not_configured at
fan-out and refuses send() with the same reason. A service without the
optional member keeps today's behaviour.

Claude-Session: https://claude.ai/code/session_01Utp6CaGmRyZ3XL5MuUgEZg
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-messaging, touching 6 documentable anchor(s).

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

  • content/docs/kernel/runtime-services/push-service.mdx (via transport_not_configured (literal, a string literal in createEmailChannel))
  • content/docs/kernel/runtime-services/sms-service.mdx (via isConfigured (symbol, a method of interface EmailSenderSurface))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v18/18-3.mdx (via EmailSenderSurface (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 6 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 c045c341329ddcd83abe4adf12428e100b7a700a → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#6 · os-bill · session_01Utp6CXCS98uhsFQYk64epE

席位记录 · 2026-11-05T15:28Z · Head-sha fbfbaf47 · ACCEPT

维护者速读(席位按 diff 校正)

  • 改了什么:没配发信方式、只剩「写日志」假通道时,邮件渠道如实报「未配置发信」:通知不再写邮件投递记录,改记抑制原因;已写下的投递记录被拒发,不再标成「成功」。
  • 为什么改:执行 cloud#2549 裁定 B 第 ③ 项。托管租户在管理员配好邮箱前,邮件通知都显示已送达,实际一封没发。
  • 风险与回滚:只改 service-messaging 的 email-channel.ts,问的是 spec 合约上早已有的 IEmailService.isConfigured(),plugin-email 早已按当前传输实现并跟随热切换;没实现该成员的邮件服务保持今天的行为。回滚即 revert 本 PR。
  • 先看:email-channel.ts 的 emailTransportRefusal,以及 isAvailable / send 两处调用。
  • 两分钟验证:pnpm --filter @objectstack/service-messaging exec vitest run --maxWorkers=2 src/email-channel.test.ts,47 条全绿,其中 a LogTransport is no transport (#30510) 5 条。
  • 需要你做的:无。非受管面、非 BREAKING,技能不要求你审,席位落地。

席位意见:同意落地。每次调用都现读服务的答案、不缓存,热切换后不用重启;沿用既有原因 transport_not_configured,不新增原因或错误码;不碰 plugin-email,与席位 5 #30212 改的 EmailService 没有重叠。

席位核对

  1. 形态:draft→ready,目标 main,Fixes #30510;作者 objectstack-fleet[bot](中继);报告 mcp_calls 0。
  2. 前提实测:origin/main 的 email-channel.ts isAvailable 只看 getEmail() 是否有值(席位读过);spec contracts/email-service.ts:430 有可选的 isConfigured?(),plugin-email email-service.ts:752 已实现(席位 grep)。PR 在 fa9c0aa2 上新钉 4 条红,消融 4 条转红后已还原。
  3. 门禁 check-runs(钉 head fbfbaf47):48 条,35 success、13 skipped、0 失败;Lint & Repo Gates、TypeScript Type Check、Test Core 均 success(含报告里 NOT MEASURED 的三条,由 CI 跑过)。
  4. 接受的偏差:内核整合钉拆成两半,由 spec 合约连接(service-messaging 不依赖 plugin-email,生产方那半 plugin-email 早已有钉);全仓 pnpm lint 交 CI。报告 open_questions 的 A(没实现 isConfigured 的服务按「未知」继续可用,只有明确的 false 才拒)由席位接受:裁定只管 LogTransport 这种情况,B 会让所有早于该成员的宿主邮件服务静默。
  5. 范围外去向:无新卡。Skills-docs: none(grep 无命中)。changeset patch,Clause-②: no,Metadata-protocol: none。
  6. 落地条款:check-governed-merges --pr 30547:NOT governed,212 行 ≤ 3000。全部 check 绿后 ready + auto-merge。合并后席位写落地、摘标、结线程。

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant