Notifications — NOTIFY-01 sync completion emails¶
Version: 1.0
Updated: 2026-04-13 (session 52)
Shipped in: session 52 (TGA-QUEUE-01 + writeStep UNIQUE migration prerequisites)
Applies to: tga-sync, cricos-sync (and any future worker that chains through write_snapshot)
What it is¶
Every time tga-sync or cricos-sync completes a cron cycle — success OR no-change — it sends an email via CF Email Service (env.EMAIL binding) to admin@rtopacks.com.au summarising what the sync did. This is the primary operational signal that the sync pipeline is healthy. A missing email IS the pager.
Substrate note (16 May 2026): the original Resend send path was retired in EMAIL-SEND-CF-MIGRATION-01 Phase 4. The mail-substrate decision rationale lives at
mail.md. The recipient address shown here (admin@rtopacks.com.au) reflects current code; older copies of this doc referred toalex@rtopacks.com.au.
The email fires from the write_snapshot phase, just before the worker advances to done. Both success and no-change paths are wired, verified by a session 52 dry-run that deleted 5 rows (2 institutions + 3 courses), re-synced, and confirmed the email listed exactly those 5 rows as new.
Email shape¶
Subject: [tga-sync] cycle complete — 40 invocations, 3 changes
From: RTOpacks Sync <noreply@rtopacks.com.au>
To: alex@rtopacks.com.au
Run ID: tga-sync-2026-04-13T16:00:00Z
Trigger: cron
┌─────────────────────┬──────────┬────────────┬─────────────┐
│ Phase │ Status │ Records in │ Records out │
├─────────────────────┼──────────┼────────────┼─────────────┤
│ init │ complete │ - │ - │
│ sweep_statuses │ complete │ 12,515 │ 3 │
│ sync_training │ complete │ 2,400 │ 14 │
│ sync_orgs │ complete │ 1,200 │ 0 │
│ ... │ ... │ ... │ ... │
│ write_snapshot │ running │ - │ - │
└─────────────────────┴──────────┴────────────┴─────────────┘
What changed:
• RTO 12345: status changed Current → Non-current
• Qualification XYZ123: superseded by XYZ456
• Training component ABC789: new release published
(or, if no changes:)
✅ No changes detected — corpus is stable since last run
The "What changed" section reads from regulatory_events rows written during the run. The rendering is two branches: the green no-changes block if zero events, the bulleted list (with a collapsible details block if >10) otherwise.
Source¶
Module: scripts/workers/tga-sync/src/notify.ts (and the matching cricos-sync/src/notify.ts).
Key function: sendSyncEmail(env, worker, runId, trigger).
Called from the write_snapshot phase handler in runBatchLoop():
if (phase === "write_snapshot") {
// ... write snapshot work ...
try {
await sendSyncEmail(env, "tga_sync", runId, trigger);
} catch (e) {
console.error("tga-sync: NOTIFY-01 email failed:", e);
// don't block the cycle on a Resend outage
}
await selfChain("done");
return;
}
Critical: the email call is awaited inside try/catch, not fire-and-forget with .catch(). An earlier version used sendSyncEmail(...).catch(() => {}) without await, which let the worker shut down before the Resend fetch completed — no email arrived on the first test. Fixed by explicit await.
writeStep schema dependency¶
The email reads from ops-db.sync_steps (via writeStep()). Session 52 migrations added:
- UNIQUE (
run_id,sync_type,step) constraint — prevents duplicate step rows from retries. Dedup migration collapsed 973 pre-existing rows to 217. ON CONFLICT DO UPDATEwitherror = excluded.error(overwrite, not COALESCE) — lets a successful retry clear a prior "partial" error message. Previously COALESCE preserved stale "partial — 0 records" text after a successful re-run.
If you add a new worker that writes step rows, use the same helper — don't reimplement the INSERT. File: scripts/workers/tga-sync/src/lib/write-step.ts.
Secrets¶
| Secret | Where | Notes |
|---|---|---|
RESEND_API_KEY |
tga-sync, cricos-sync (both workers) |
Same key on both. Created via wrangler secret put RESEND_API_KEY against each worker. |
Verify presence:
cd scripts/workers/tga-sync && npx wrangler secret list | grep RESEND
cd ../cricos-sync && npx wrangler secret list | grep RESEND
If either is missing, the email call will fail with a "no API key" error and the worker will log the error and continue — the sync itself still completes, but you'll stop getting emails until the secret is restored.
Operational expectations¶
- Sunday ~2am AEST: tga-sync email lands. Missing = investigate.
- 1st of month ~4am AEST: cricos-sync email lands. Missing = investigate.
- Email shows "failed" step: pager event. Look at the error column, identify the phase, re-trigger manually with
curl -X POST https://<worker>.dark-firefly-3289.workers.dev/trigger. - Email lands but "What changed" shows an unexpected entry: cross-check with the Observatory regulatory events feed for that run_id.
- Missing email + worker deployment in progress: likely fine, re-check after deployment completes.
Testing a new email without waiting for cron¶
Both workers expose a manual trigger:
curl -X POST https://tga-sync.dark-firefly-3289.workers.dev/trigger
curl -X POST https://cricos-sync.dark-firefly-3289.workers.dev/trigger
For a full cycle including NOTIFY-01, the chain needs to reach write_snapshot. You can force-skip expensive phases by writing stale page cursors into D1 that exceed the page caps — this causes each phase to advance immediately. The session 52 force-cycle test used this technique.
For a dry-run with real change-detection content, manually DELETE a handful of rows from cricos_institutions or cricos_courses before re-triggering, then watch the email list the deleted rows as "new". Restore the rows via re-sync — the system is idempotent.
Out of scope (future briefs)¶
- Multiple recipients. Currently hard-coded to
alex@rtopacks.com.au. Add an env-var list when ops grows. - Escalation on repeat failures. A failed sync emails once. A second consecutive failure should ping a different channel.
- QB push failure emails.
qb-reconcilewrites tobilling_ledgerand Observatory but doesn't email. If QB reconcile goes quiet for N days, there's no direct signal. Future. - Email for the on-demand
tga-ingestconsumer. Low value — the consumer runs continuously in response to site traffic, there's no "cycle" to notify on.
Related docs¶
docs/docs/infrastructure/worker-patterns.md— where NOTIFY-01 sits in the phase state machinedocs/docs/operations/observatory-guide.md— Observatory is the non-email view of the same datadocs/docs/ops/standing-rules.md— NOTIFY-01 as a standing operational expectation