Tenant template — explicit local→feed + env-selected email provider
Tenant template — explicit local→feed + env-selected email provider
Card: t_7b0702c4 (build task 2 of 8 from t_c9b50255).
Method: live probes on the pinned @medusajs/medusa: 2.21.2 on prod
(161.33.71.67, /home/ubuntu/builds/medusa-backend). Isolated scratch DB
(notif_sol_2026 on the pg-temp container). No tenant DB was written to.
1. What landed (3 files, on prod)
| File | Change |
|---|---|
builds/medusa-backend/apps/backend/medusa-config.ts | Declares the notification module explicitly, always with local→["feed"], plus the env-selected email provider. |
.solos-infra/docker-compose.tenant-template.yml | Provider env block on both medusa-server and medusa-worker. |
.solos-infra/docker-compose.test-tenant.yml | Same block (it is a rendered tenant, so it needs the same contract). |
Backups taken before the edit:
*.bak-sol-20261020 next to each of the three files.
2. The shape (the whole point of the card)
TENANT_EMAIL_PROVIDER=resend -> [local->feed, resend->email]
TENANT_EMAIL_PROVIDER=o365 -> [local->feed, o365->email]
TENANT_EMAIL_PROVIDER=none -> [local->feed]
local→["feed"] is declared unconditionally. A user notification-module
config replaces the framework default set rather than merging with it, so
omitting it silently kills the Admin bell and the framework disables the
orphaned local row in the DB — at send time, so /health stays green.
Env names: RESEND_API_KEY, RESEND_FROM_EMAIL (resend path);
O365_TENANT_ID, O365_CLIENT_ID, O365_CLIENT_SECRET, O365_FROM,
O365_MAILBOX (o365 path).
3. Evidence — 4 probes, all on the pinned runtime
Probe F — config render (8/8 pass, exit 0)
Runs the real medusa-config.ts through the real pinned defineConfig:
resend renders [local->feed, resend->email] pass
o365 renders [local->feed, o365->email] pass
none renders [local->feed] only pass
feed provider present in ALL THREE cases pass
unset TENANT_EMAIL_PROVIDER defaults to none pass
garbage TENANT_EMAIL_PROVIDER fails closed (throws) pass
empty TENANT_EMAIL_PROVIDER fails closed (throws) pass
TENANT_EMAIL_PROVIDER is case-insensitive pass
Probe G — boot fail-closed (6/6 pass, exit 0)
Calls the real notification provider loader (@medusajs/notification 2.21.2)
with a fake container (no DB):
loader accepts the tenant config as-is (resend) pass
loader REFUSES two providers on `email` (fail closed) pass
-> "Error: Multiple providers are configured for the same channel: email"
loader REFUSES two providers on `email` from `none` too pass
loader ACCEPTS `none` + ONE email provider pass
loader accepts `none` and registers only the local provider pass
loader upserts local->feed AND resend->email pass
The fail-closed behaviour is KEPT, not "fixed". The card explicitly said not to write a duplicate-channel guard, and none was written.
Probe H — live boot on the pinned runtime (all 3 cases, DB-verified)
medusa exec on the isolated DB. The notification table afterwards:
channel | status | provider_id | external_id
---------+---------+-------------+--------------------
feed | success | local | <- TENANT_EMAIL_PROVIDER=none
email | failure | | <- fails LOUDLY, not swallowed
feed | success | local | <- resend
email | success | resend | stub_1792499200046
feed | success | local | <- o365
email | success | o365 | stub_1792499205095
notification_provider after the o365 boot:
id | is_enabled | channels
--------+------------+----------
local | t | {feed}
o365 | t | {email}
resend | f | {email} <- disabled, because it is not in this boot's config
Probe I — the COUNTERFACTUAL (same DB, same runtime, ONE variable changed)
The same config with the explicit local declaration removed:
PROBEI {"steps":[{"channel":"feed","ok":false,
"error":"Could not find a notification provider for channel: feed for notification id noti_..."}]}
and the provider table immediately after:
id | is_enabled | channels
--------+------------+----------
local | f | {feed} <- the framework DISABLED the orphaned default
Re-booting with the correct config restores it (local → is_enabled=t,
feed → status=success). The trap is real, reproduced here, and closed by
the shipped config.
4. Two findings that changed the design (both measured, not assumed)
4.1 The loader RESOLVES the provider module BEFORE it validates channels
The channel check ("Multiple providers are configured for the same channel") is only reached if every provider module resolves. A missing module short-circuits first:
Error: Unable to find module ./src/modules/resend -- perhaps you need to install its package?
Consequence for the default. The unset value of TENANT_EMAIL_PROVIDER is
none, not resend. The Resend/O365 provider modules are built on sibling
cards (t_1419d598, t_154c2421) and do not exist yet. Defaulting to resend
would make every tenant whose compose file predates this change — or that
simply omits the variable — crash-loop at boot instead of booting with a
visibly dead email channel. none boots, feed works, and email fails loudly
at send time. A loud failure is recoverable; a boot loop is an outage.
When the provider modules land, flip the default to resend in two places:medusa-config.ts (the ?? "none" fallback) and the compose template
(${TENANT_EMAIL_PROVIDER:-none}). Probe F's "unset defaults to none"
expectation flips with it.
4.2 The built image loads the SOURCE .ts, not the compiled .js
The image ships both apps/backend/medusa-config.ts and
apps/backend/.medusa/server/medusa-config.js. Probed in the real container:
cwd=/app/apps/backend
source .ts exists: true
compiled .js exists: true
ts-node registered: true
RESOLVED: /app/apps/backend/medusa-config.ts
So the config must land in the source file in the build tree — editing only
the compiled output would do nothing. (The CLI registers ts-node from its own
location before require-ing the config, which is why the .ts wins.)
5. A defect the render check caught (and I fixed)
My first draft of the template comment contained the literal text {{...}}.
The onboarding script (onboard_tenant.sh step 3b) refuses any render that
still carries template variables:
if grep -q '{{' "$COMPOSE_FILE"; then
echo "ERROR: the rendered compose still carries template variables:"
So that comment would have blocked every tenant onboarding with a
misleading error. The comment was reworded and the render now leaves 0
unsubstituted placeholders and passes docker compose config (exit 0).
Lesson worth keeping: a {{ in a comment is a live failure in this repo.
The template's own {{SLUG}} placeholders are expected; a new one in prose is
not.
6. Render + validation (the onboarding path, re-run after the fix)
leftover placeholders in render: 0
docker compose config: exit 0
Step 3b "render still carries template variables": PASS
provider env block on both medusa services: 2
Env override paths both work:
TENANT_EMAIL_PROVIDER=resend RESEND_API_KEY=... (host env) -> resend, key passes through
--env-file with TENANT_EMAIL_PROVIDER=o365 -> o365, key passes through
The provider env vars are read by Docker Compose at up time, not by the
sed render — so they come from the shell or an --env-file, and they are
deliberately NOT {{...}} placeholders.
7. Why the provider block is on the WORKER too
The medusa-worker service is where the notification subscribers run. If
its provider set diverged from the server's, the worker would send mail
through a different provider than the server registered — or fail to send at
all. The two blocks are identical on purpose.
8. What I did NOT do
- I did not build the Resend/O365 provider modules — sibling cards
t_1419d598/t_154c2421. The config'sresolvepaths (./src/modules/resend,./src/modules/o365) are stubs of the real contract; the modules do not exist yet. - I did not prove a real email was DELIVERED. Every send in this card went to a local stub provider that never touches a network. This card proves routing and config, not deliverability. The live-delivery test with a real domain is still owed (it is on the Resend card).
- I did not add a duplicate-channel guard — the framework already fails closed at boot, and the card said not to write one.
- I did not touch
onboard_tenant.sh— that is ace's card (t_8d7bde82). The env contract is landed in the template; the note is on that card. - I did not change the fleet default to
resend— see §4.1. That is a one-line change once the provider modules land.
9. Honest limits
- The end-to-end probes ran
medusa exec(the sameinitializeContainer→configLoader→ module-loader path a realmedusa startboot takes) against an isolated scratch DB. A fullmedusa startboot of a rebuilt image is the remaining confirmation, and the tenant's owndb:migrateprecondition is unchanged by this card. - The live-boot proof used a local stub at the
src/modules/resendandsrc/modules/o365resolve paths, because the real modules are not built yet. The routing shape (id+channels+options) is identical. - The probes ran under
NODE_ENV=development(noREDIS_URL→ fake redis). The Notification Module does not use redis, so routing is unaffected, but this is not a production-mode boot. - The
notificationProviderServicecould not be resolved by name from the probe's container scope, so the provider rows were read directly from the database withpsqlinstead of through the service API. The DB is the stronger evidence anyway.
10. Where the artefacts are
| Artefact | Where (prod 161.33.71.67) |
|---|---|
| Landed config | /home/ubuntu/builds/medusa-backend/apps/backend/medusa-config.ts |
| Landed templates | /home/ubuntu/.solos-infra/docker-compose.tenant-template.yml, docker-compose.test-tenant.yml |
| Backups | the same paths with .bak-sol-20261020 |
| Probe F / G | /home/ubuntu/notif-probe/tcfg/probeF-config.js, probeG-boot-failclosed.js |
| Probe H / I | /home/ubuntu/notif-probe/solapp/probeH.ts, probeI.ts |
| Stub provider (probe only) | /home/ubuntu/notif-probe/solapp/src/modules/{resend,o365}/ |
| Scratch DB | notif_sol_2026 on the pg-temp container (droppable) |

