Young's Mind
Implementation

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.

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)

FileChange
builds/medusa-backend/apps/backend/medusa-config.tsDeclares the notification module explicitly, always with local→["feed"], plus the env-selected email provider.
.solos-infra/docker-compose.tenant-template.ymlProvider env block on both medusa-server and medusa-worker.
.solos-infra/docker-compose.test-tenant.ymlSame 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's resolve paths (./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 same initializeContainer → configLoader → module-loader path a real medusa start boot takes) against an isolated scratch DB. A full medusa start boot of a rebuilt image is the remaining confirmation, and the tenant's own db:migrate precondition is unchanged by this card.
  • The live-boot proof used a local stub at the src/modules/resend and src/modules/o365 resolve paths, because the real modules are not built yet. The routing shape (id + channels + options) is identical.
  • The probes ran under NODE_ENV=development (no REDIS_URL → fake redis). The Notification Module does not use redis, so routing is unaffected, but this is not a production-mode boot.
  • The notificationProviderService could not be resolved by name from the probe's container scope, so the provider rows were read directly from the database with psql instead of through the service API. The DB is the stronger evidence anyway.

10. Where the artefacts are

ArtefactWhere (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
Backupsthe 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 DBnotif_sol_2026 on the pg-temp container (droppable)
Copyright © 2026