Skip to content

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 to alex@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:

  1. UNIQUE (run_id, sync_type, step) constraint — prevents duplicate step rows from retries. Dedup migration collapsed 973 pre-existing rows to 217.
  2. ON CONFLICT DO UPDATE with error = 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-reconcile writes to billing_ledger and 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-ingest consumer. Low value — the consumer runs continuously in response to site traffic, there's no "cycle" to notify on.

  • docs/docs/infrastructure/worker-patterns.md — where NOTIFY-01 sits in the phase state machine
  • docs/docs/operations/observatory-guide.md — Observatory is the non-email view of the same data
  • docs/docs/ops/standing-rules.md — NOTIFY-01 as a standing operational expectation