Skip to content

feat(settings,plugin-email): state the live mail transport on Setup → Email - #25448

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-25335-email-transport-statement
Oct 23, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-25335-email-transport-statement

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread

Part of #25335 — the server half. The card stays open for the console half (see What is left).

Clause-②: yes (widening: service-settings exports SettingsService.registerLiveStatus / liveStatusOf, SettingsLiveStatus, SettingsLiveStatusProvider and SETTINGS_LIVE_STATUS_BANNER_ID; nothing is refused)
Metadata-protocol: none

What changed

Setup → Email showed what is stored, not what is in use. Out of the box the mail namespace stores provider smtp with an empty host, so the form reads as SMTP while every message goes to the development log and is recorded in sys_email as sent. The page now states the live transport at the top.

  • @objectstack/service-settings: SettingsService.registerLiveStatus(namespace, provider) and liveStatusOf(namespace, ctx). GET /api/settings/:namespace asks the provider on every read and puts its answer first in manifest.specifiers, as a spec-valid info_banner with the reserved id live_status (SETTINGS_LIVE_STATUS_BANNER_ID). The registered manifest and in-process getNamespace() are unchanged. The banner is added in the route, not in getNamespace(), because the mail plugin calls getNamespace('mail') while rebuilding the transport the statement waits on. A provider that throws gives an error-severity "Live status unavailable" banner and one warn line; the statement is never silently dropped.
  • @objectstack/plugin-email: registers the mail statement (src/live-transport-status.ts). The branch is EmailService.isConfigured():
    • false (log transport): "No mail leaves this machine", warning severity. The body says messages are recorded in sys_email as sent and none is delivered.
    • true: "Mail is delivered through SMTP (host:port)", or Resend, Postmark, or a host-supplied transport, at info severity.
    • The provider first waits for any mail re-apply still running. A read made just after a save then names the transport that save built.

Landing: the claim expected plugin-email and service-settings, and that is where the change landed. The page is built from GET /api/settings/:namespace (manifest plus values). The descriptor lives in service-settings/src/manifests/mail.manifest.ts, and the live fact lives in plugin-email. No packages/spec change: info_banner (bannerText, bannerSeverity) already exists, and the console already renders it.

One pattern: the SMS page does not state its live sender today, so there was no reader shape to copy. registerLiveStatus is namespace-generic; service-sms can register the same statement from ISmsService.isConfigured().

What is left — the console half (not done here)

The statement follows a swap on the next read of the page, not yet in place after Save. @object-ui/console 18.0.0 (SettingsView, read from the npm tarball because this session has no objectui access) merges only the PUT response's values into the page after a save. It keeps the manifest from its first GET and never refetches, so the banner keeps the pre-save statement until the page is reloaded. The fix is a console change: refetch GET /api/settings/:namespace after a successful save, or after any action. The server already answers the new statement on that read.

Seam: runtime:service-settings GET /api/settings/:namespace manifest.specifiers[0] (info_banner id live_status) → renderer:@object-ui/console SettingsView save handler (merges PUT values only; manifest never re-read)

Tests

  • plugin-email/src/live-transport-status.pin.test.ts (5 tests): pins the reading of isConfigured(). It covers the out-of-the-box state, a swap saved from the page (read before the re-apply settles), and that the branch is isConfigured(), not the transport class.
  • service-settings/src/settings-routes.live-status.pin.test.ts (6 tests): the banner heads the GET payload and parses under SettingsNamespacePayloadSchema. The provider is asked on every read, the registered manifest stays untouched, nothing is added without a provider, a throwing provider gets an error banner, and an unknown namespace is refused.
  • Three route-test fakes gained liveStatusOf: async () => undefined (the read route now calls it).
  • pnpm --filter @objectstack/plugin-email test: 37 files / 676 tests passed. Typecheck passed (tsc --noEmit && check:test-typecheck).
  • pnpm --filter @objectstack/service-settings test: 60 files / 995 tests passed. Typecheck passed.

Ablations (scripts/ablation-replace.mjs; every leg restored to the HEAD blob with an empty git diff HEAD):

  1. if (!source.isConfigured()) replaced by if (!(source.options.transport instanceof LogTransport)): 5 tests went red, but with ReferenceError: LogTransport is not defined. That is a crash, not the mutation, so this run does not count; it is reported here, not hidden.
  2. The same branch replaced by source.options.transport.constructor.name === 'LogTransport': 1 failed, 4 passed. The red test is "the branch is isConfigured(), not the transport class".
  3. The same branch replaced by if (false): 4 failed, 1 passed.
  4. await Promise.allSettled([...applying]); deleted from the plugin: 1 failed, the swap test (expected 'warning' to be 'info').

Gates (local, at 4789209)

node scripts/pm/dispatch-gates.mjs --commands derived 70 commands, and all 70 exited 0. --ran reconciliation: "70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero — all 70 recorded an exit code and none of them is 3)". One gate needed a fix along the way: on the first sweep, check:engine-double-contract flagged the new pin's fake engine update(). The fake now declares only find(), which is all this boot reads. Everything CI runs beyond the derived set is CI's.

Acceptance notes

  • The live_status banner has no zh-CN, ja-JP or es-ES text. Its text is computed per request, and the console's banner lookup by id would replace it with a fixed translation. A translated statement needs per-state ids or a parameterised lookup.
  • With no email plugin mounted, the page states nothing new. The mail/test fallback already says no email service is mounted.
  • origin/main moved 7 commits since the base f0fdd45bbc. None touch plugin-email or service-settings, so no merge was taken.

🤖 Generated with Claude Code

https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy

@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 Oct 23, 2026
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-email, @objectstack/service-settings, touching 18 documentable anchor(s).

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

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

Coarse fallback — 13 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 d0a1361bdabc153b2fb927b8f7ef6f024ecc6891 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 d0a1361bdabc153b2fb927b8f7ef6f024ecc6891 → 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

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 39af42e75abdfef4f6be187cc0836925fa2d253c
Local-runs: none

The face is the declaration face. This review is owed only by the Clause-②: yes (widening: …) line: no published schema or governed path is in the tree (check-governed-merges --pr 25448: 0 hits), and the PR declares no narrowing. Rendered by the lane seat as its review of record.

① Derived judgments

  1. The new public surface of @objectstack/service-settings — right, and additive only. The new members are SettingsService.registerLiveStatus(namespace, provider) and liveStatusOf(namespace, ctx). The types SettingsLiveStatus and SettingsLiveStatusProvider and the constant SETTINGS_LIVE_STATUS_BANNER_ID = 'live_status' are exported from index.ts. No existing member changes its signature. getNamespace() and the registered manifest are untouched, so in-process readers see exactly what they saw before.
  2. The GET /api/settings/:namespace payload — a widening, right. When a provider is registered, one info_banner heads manifest.specifiers. liveStatusOf runs after getNamespace has already passed the namespace's read gate, so the statement reaches no caller the values did not already reach. The banner shape (type, id, label, required: false, bannerText, bannerSeverity) is one the spec's info_banner already accepts, and the new pin parses it under SettingsNamespacePayloadSchema. Without a provider the payload is unchanged. No write path is touched, so no input is newly refused.
  3. The throwing provider — right. It becomes an error-severity "Live status unavailable" banner plus one warn. The cause text goes to the log, not to the page.
  4. plugin-email's statement — right. The branch is EmailService.isConfigured(), read at call time; the transport class only names the transport. The provider awaits Promise.allSettled over in-flight re-applies, so it cannot reject, and it only waits on work already started. Registration goes through the structural MailSettingsSurface behind a typeof … === 'function' guard, so a host whose settings service predates the method keeps booting. registerLiveStatus throws on an unknown namespace, the same rule registerAction already applies one call later. The built-in mail manifest is always registered.
  5. Interaction with plugin-email: saving mail provider "None (log only — no real delivery)" keeps the previous SMTP transport, so mail is still delivered while the page and the log say it is not #25450, noted rather than judged. plugin-email: saving mail provider "None (log only — no real delivery)" keeps the previous SMTP transport, so mail is still delivered while the page and the log say it is not #25450 is the defect where saving provider log keeps the SMTP transport. Under it, the banner says mail is delivered through SMTP, which is what the live transport actually does. The statement is therefore truthful about the defect, not masking it. plugin-email: saving mail provider "None (log only — no real delivery)" keeps the previous SMTP transport, so mail is still delivered while the page and the log say it is not #25450 fixes the transport and is dispatched after this PR lands.

② Semver level

patch on both packages, with Clause-②: yes (widening: …) and Metadata-protocol: none in both the changeset and the PR body. The PR-body line was corrected through the relay. The re-run Check Changeset on this head is green (14:27Z). AGENTS.md: a non-protocol change, widening or breaking, ships patch. Nothing a metadata author can write changes: info_banner already exists, and the reserved id is placed by the route, not authored. So none is right.

③ Boundary flags

  • The reserved id is stated, not enforced. SETTINGS_LIVE_STATUS_BANNER_ID is documented as "a manifest must not declare it". No manifest on main declares live_status (git grep), so there is no collision today. Enforcement at registerNamespace would be a later hardening, not a defect of this PR.
  • No translation for the banner. This is declared in the PR's acceptance notes, and the reason is sound: the console's id-keyed banner lookup would overwrite computed text.
  • Console half. After Save, @object-ui/console SettingsView merges only the PUT values and never refetches the manifest. The banner therefore updates on the next page read, not in place. This is declared with a Seam: line and left on the card (Part of #25335), which stays open for the ui lane.
  • SMS. service-sms can adopt the same registerLiveStatus from ISmsService.isConfigured(). That is not part of this card.
  • Ablation leg 1 crashed with a ReferenceError instead of exercising the mutation. The dev reported it as not counted and replaced it with legs 2 to 4, each of which reds the pin it targets.
  • Check-runs on this head: 48 check-runs, 39 success and 9 skipped by design, none failed.

Implemented-by: claude/issue-25335-email-transport-statement
Reviewed-by: session_016g7nYP9wdNx1GDa5XDr7Uy

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · 2026-10-23T14:38Z

domain:services seat 1 (seat post #6021) · os-sales · session_016g7nYP9wdNx1GDa5XDr7Uy · card #25335, server half (S, in-seat subagent; os-dev-report and the Clause-② addendum 6367155487 on the card) · reviewed head 39af42e75a

  • Review of record: the TIER_DEFAULT record above on this head, declaration face, PASS.
  • Diff: 12 files, +469 / −2.
    • service-settings gains registerLiveStatus / liveStatusOf. The GET /api/settings/:namespace route puts the provider's statement first in manifest.specifiers, as an info_banner with id live_status. getNamespace() and the registered manifest are unchanged.
    • plugin-email registers the mail statement from isConfigured(), waiting first for any re-apply still running.
    • Three route-test fakes gained liveStatusOf.
    • There are two new pin files, 5 + 6 cases.
  • Pins (report):
    • plugin-email: 37 files / 676 tests pass.
    • service-settings: 60 files / 995 tests pass. Both typechecks exit 0.
    • Ablations 2–4 each red their target pin, and every restore is clean. Leg 1 crashed (ReferenceError), and the dev reported it as not counted.
    • Gates at the final head: 70 derived, 70 run, 0 not measured.
  • Clause-② correction: the seat ruled the arm yes (widening: …) because new exports ship. The changeset commit (39af42e75a) carries it. The PR-body line was changed through the relay (issue_patch, read back identical). Check Changeset re-ran on the edited body and is green.
  • CI on this head: 48 check-runs, 39 success and 9 expected skips, none failed. git merge-tree onto today's main is clean.
  • Body: the seat added the attribution header and requested the os-sales reviewer, through the relay. The closing footer is present.
  • Commit trailers: model-free Co-authored-by: Claude + Claude-Session.
  • Landing: not governed (check-governed-merges --pr 25448: 471 lines). Ready + auto-merge through the relay. Part of #25335, so the card stays open for the console half: after Save, SettingsView never re-reads the manifest. The Seam: line is in the PR body, and the seat routes it to the ui lane on landing.
  • Sequenced after this: plugin-email: saving mail provider "None (log only — no real delivery)" keeps the previous SMTP transport, so mail is still delivered while the page and the log say it is not #25450 (saving provider log keeps SMTP delivering) shares email-plugin.ts and is dispatched once this and plugin-email: the Send test email refusals tell the admin to pass "to" in the request body — name the page's control instead (same family as #25345) #25424 land. Until then, the new banner honestly says SMTP is live in that state.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 23, 2026 14:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit ab510f4 Oct 23, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25335-email-transport-statement branch October 23, 2026 15:09
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

Development

Successfully merging this pull request may close these issues.

2 participants