Skip to content

fix(plugin-email): wire sys_email persistence + the template loader in init, so a boot-time verification send stops throwing TEMPLATE_NOT_FOUND - #24113

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23946-email-template-loader-boot-order
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23946-email-template-loader-boot-order

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23946
Clause-②: no

What was wrong

On a boot with email verification required, objectstack dev --seed-admin (the default) logged this once, before the server was ready:

ERROR [Better Auth]: Failed to run background task: Error: TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService

That verification send also left no sys_email row.

Premise, checked on origin/main (8a83cebc15) before any change

  • Reproduced once on a fresh DB, from examples/app-showcase: OS_AUTH_REQUIRE_EMAIL_VERIFICATION=true, objectstack dev --ui --seed-admin -p PORT -d file:FRESH/data.db.
    • The boot printed the ERROR line exactly once.
    • Afterwards the DB had 0 sys_email rows.
    • The seeded admin's row had email_verified = 1.
  • The order, as the stack trace shows it:
    1. AuthPlugin.maybeSeedDevAdmin calls signUpEmail.
    2. better-auth calls runInBackgroundOrAwait, and with no background handler configured it awaits sendVerificationEmail.
    3. That reaches EmailService.sendTemplate, then resolveAndRenderTemplate, which throws (email-service.ts:1315).
  • Why the loader was missing at that point:
    • The seed runs in plugin-auth's own kernel:ready hook.
    • The served composition starts Auth ahead of EmailServicePlugin (boot plugin list: …, Auth, …, SettingsServicePlugin, EmailServicePlugin, …). The kernel runs kernel:ready handlers one at a time, in the order the plugins started.
    • EmailServicePlugin installed persistence and the loader (setPersistence / setTemplateLoader) only inside its own kernel:ready hook. So the seed's send reached a service that had neither.
  • The card says packages/cli/src/commands/dev.ts "signs the admin up in-process". In fact dev.ts only forwards OS_SEED_ADMIN. The sign-up runs in plugin-auth: AuthPlugin.maybeSeedDevAdmin, a kernel:ready hook.

The fix: option (a), wire persistence + the template loader in init

EmailServicePlugin.init now resolves the data engine (objectql, else data) and installs sys_email persistence and the registry template loader. It does this before it registers the email service. The wiring itself moved unchanged into a wireEngine method that runs once per engine. The kernel:ready hook calls the same method, which wires only a host whose engine registered after init, so init records nothing when the engine is absent.

Everything the two read is available at init. Each point was checked in the code, not assumed:

  • objectql is registered in ObjectQLPlugin.init (packages/objectql/src/plugin.ts:645). ObjectQL is a hard dependencies entry of this plugin, so it inits first.
  • engine.registry is a field set when the engine is constructed (packages/objectql/src/engine.ts:4448).
  • The loader resolves protocol on every load, not at wiring time.
  • Persistence calls engine.insert / engine.update only when a message is sent. The live boot below shows that in the seed's window the row lands.

Why not (b), holding the send until the plugin's own kernel:ready. The early send in this boot is awaited inside AuthPlugin's kernel:ready hook, by better-auth and then by the seed. The kernel runs this plugin's kernel:ready handler only after that hook returns. A hold that waits would therefore block until its bounded wait ran out, on every verification-armed dev boot, and then still not send. A hold that does not wait would answer queued with no row behind it. A crash would lose that message, and the resend path would lose its error. Option (a) needs neither, because nothing the loader reads arrives later than init.

The card's other option (the dev seed not firing sendOnSignUp) is now moot. Measured below: the send is accounted for, and the ERROR is gone. As noted above, that option would land in plugin-auth, not in packages/cli.

The log level is not lowered and no error is swallowed. A missing template still throws TEMPLATE_NOT_FOUND: NAME (locale=…), as before.

Pin and ablation

The pin is in email-plugin.outbox-sweep.test.ts. It reuses that file's existing engine double, so the engine-double ledger does not change. It boots a real LiteKernel (the same resolvePluginOrder as ObjectKernel) with three plugins, registered in this order:

  1. ObjectQL's slot: the engine, the manifest door, and a protocol that serves the templates the manifest door was handed.
  2. A stand-in for plugin-auth's seed. Like AuthPlugin, it depends only on ObjectQL. From its own kernel:ready it sends auth.verify_email with the same arguments plugin-auth uses.
  3. EmailServicePlugin.

What the pin asserts:

  • Positive control. When the send runs, the email plugin's outbox drain hook is not installed yet (0). After boot it is (1).
  • No error is thrown.
  • The send answers status: sent.
  • Exactly one sys_email row exists, at sent, with the rendered subject Verify your Acme email address, related_object: sys_user and the expected related_id.
  • The transport was called exactly once, so the boot sweep that runs afterwards does not send it again.

Ablation used node scripts/ablation-replace.mjs to delete the init call if (engine) this.wireEngine(ctx, engine);. The fix was committed first, so HEAD holds it.

  • The anchor went from 1 to 0 and the marker from 0 to 1. Blob 8e8ca0e6d6a6 became 855acf5ca71b.
  • The pin went red with expected Error: TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService to be undefined. The other 13 tests in the file stayed green.
  • Restore: blob is back to 8e8ca0e6d6a6, equal to HEAD, git diff HEAD is empty, and git status is clean.
  • The pin imports ./email-plugin.js relatively, so vitest reads src/ and no dist/ rebuild leg applied.

Live reading: one verification-armed boot on the fixed build

This used the same command on a fresh DB, after pnpm --filter @objectstack/plugin-email build.

  • The boot log has 0 ERROR lines, 0 TEMPLATE_NOT_FOUND, 0 Failed to run background task, and 1 Server is ready. The Dev admin … seeded on empty DB banner is present.
  • The DB has 1 sys_email row: status: sent, subject Verify your ObjectStack email address, related_object: sys_user, and related_id equal to the seeded admin's sys_user.id.
  • The seeded admin is also email_verified = 1.
  • So the send happens and is persisted. It went through the boot's LogTransport, because no mail transport is configured in dev, so it was logged rather than delivered. The seed then stamps the admin verified.

Tests

  • pnpm --filter @objectstack/plugin-email test: 34 files, 552 tests passed.
  • pnpm --filter @objectstack/plugin-email typecheck: tsc --noEmit passed. check:test-typecheck reported OK with 0 errors. tsc -p tsconfig.test.json --listFiles includes the edited test file.

Gates (local subset, at final head 404db56cca)

The branch merges origin/main at be076dad28. The incoming commits touch the hono adapter, a showcase report and an ADR note, none of which overlap plugin-email. After the merge I rebuilt @objectstack/hono.

  • I derived the commands with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 404db56cca. That gave 64 commands (30 pnpm, 34 direct node), the same list as before the merge.
  • All 64 were run, each exit code captured before any pipe, and every one exited 0.
  • Reconciliation with --ran: 64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN.
  • Among the 64: check:engine-double-contract, check:nul-bytes, check:test-source-alias, check:cross-package-test-inputs, check:durability-log-level, check:plugin-teardown-shape, check:published-files, check:dual-build-cjs-loads and check:type-check-coverage.
  • An earlier run of the same 64, at the pre-merge head d879052e5b, was also all 0. In that run check:dual-build-cjs-loads first exited 3 with PREREQUISITE NOT MET, because eight packages had no dist/. It went green once they were built.
  • I did not run the CI-owned whole-repo scans or test:repo locally. They belong to this PR's CI check-runs.

Acceptance notes

  • A send in this window uses the transport and sender the boot was configured with. The mail settings namespace (Settings, then Mail) is still applied at this plugin's own kernel:ready, which comes after. On a deployment whose only SMTP configuration lives on the settings page, such a send would go through the constructor transport. Two qualifications:
    • A plain send() in the same window already behaved this way before this change, with no row at all.
    • The only sender measured in the window is plugin-auth's dev seed, which is development-only and stamps the admin verified right after.
    • No production sender in the window was measured, so nothing was filed. Carrier: none.
  • A deployment that declares queue delivery (OS_EMAIL_QUEUE_ENABLED) still delivers a send in this window inline, because the delivery mode is applied at kernel:ready. This is unchanged, except that the send now leaves its row.
  • A send during another plugin's init / start, before ObjectQL connects its drivers, would now attempt the sys_email insert. That insert fails non-fatally (the existing warn) and the mail still goes out inline. No such sender was measured.
  • Out of scope for this PR: the card's option 1, the dev seed not firing sendOnSignUp. It is unchanged, and moot as described above.
  • Labels: the dispatch named none and the diff ships a changeset, so no label was written.

Generated by Claude Code

…n init

A plugin that starts before EmailServicePlugin runs its kernel:ready hooks
first, and plugin-auth's dev admin seed signs its account up from one. With
email verification armed, better-auth awaits sendVerificationEmail ->
sendTemplate inside that hook, which met a service whose loader is only
installed at this plugin's own kernel:ready: TEMPLATE_NOT_FOUND into
better-auth's background-task log, and no sys_email row.

Everything the loader and persistence read is available at init (ObjectQL is
a hard dependency and registers `objectql` in its own init; the registry is a
construction-time field; `protocol` is resolved per load), so wire both there,
before the service is registered. kernel:ready wires them only for a host
whose engine registered later.

Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
Co-authored-by: Claude <noreply@anthropic.com>
…el:ready

A real LiteKernel boot orders a stand-in for plugin-auth's dev seed (depends
on ObjectQL only, registered ahead of the email plugin) whose kernel:ready
hook sends `auth.verify_email` before the email plugin's own kernel:ready
wiring. The send must render, persist its sys_email row at `sent` and
deliver once, with no TEMPLATE_NOT_FOUND. Reuses this file's engine double.

Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-email, touching 5 documentable anchor(s).

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

  • content/docs/deployment/environment-variables.mdx (via sys_email (literal, a string literal in start; a string literal in wireEngine))
  • content/docs/kernel/runtime-services/sms-service.mdx (via sys_email (literal, a string literal in start; a string literal in wireEngine))
  • content/docs/kernel/services.mdx (via EmailServicePlugin (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via sys_email (literal, a string literal in start; a string literal in wireEngine))

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

  • content/docs/releases/v17/17-1.mdx (via sys_email (literal, a string literal in start; a string literal in wireEngine))

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
  • 1 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 — 5 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 b75129fba73a5b041fbc48cd16d50360ebd3a9ab → packageMentionDocs.

Which tree this was computed on

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

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

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

席位核对 · ACCEPT · domain:services seat 2 · session_018VqJPGA1NDNSCZsnecsChP · 2026-10-17T01:27Z

Reviewed against GitHub at head 404db56cca, from the diff and the dev's report on #23946. Clause-②: no: a boot-ordering fix inside plugin-email, and no export, type or accept set moves. So this is the seat's own review of record.

Form

  • Draft, base main. The first line is Fixes #23946, then Clause-②: no.
  • 3 files, +231 / −61, plus one patch changeset for @objectstack/plugin-email.
  • check-governed-merges.mjs --pr 24113: NOT governed.

The change. EmailServicePlugin.init now wires sys_email persistence and the registry template loader before it registers the email service, through wireEngine, once per engine. kernel:ready calls the same method and only wires an engine that registered later.

  • The premise, reproduced at 8a83cebc15. AuthPlugin.maybeSeedDevAdmin runs in an earlier kernel:ready hook, and better-auth awaits sendVerificationEmail there. The send met a service with no loader.
  • Why not a held send: the kernel runs kernel:ready handlers one at a time, so a hold would wait behind the very hook awaiting it. The dev measured that rather than assuming it. The comment records it.
  • The card's cli option is moot. dev.ts only forwards OS_SEED_ADMIN; the sign-up runs in plugin-auth.

Pin and live reading

  • The pin. A stand-in for the seed, on a real LiteKernel, sends auth.verify_email from a kernel:ready registered ahead of EmailServicePlugin. Ablating the init wiring reds it with TEMPLATE_NOT_FOUND, and the restore is proven by blob.
  • A live fresh-DB, verification-armed objectstack dev --seed-admin:
    • 0 ERROR lines;
    • one sys_email row, sent, "Verify your ObjectStack email address", related to the seeded admin;
    • the admin is still stamped verified.
  • Recorded in the PR's Acceptance notes: a send in this window uses the boot transport, because the mail settings namespace is still applied at kernel:ready.

Gates and CI

  • Gates: 64 derived, 64 exit 0, reconciled with --ran.
  • The package passes 34 files and 552 tests. Typecheck exits 0.
  • CI at 404db56cca: 40 check-runs, 34 success, 0 failure, 6 skipped (the roster's six). All required contexts are green.

ACCEPT. This act marks the PR ready and enables auto-merge through the fleet relay. It lands through the merge queue.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 01:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 01:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit 33228c5 Oct 17, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23946-email-template-loader-boot-order branch October 17, 2026 01:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants