Files
zeekay fe91b1cb27 notify: wire async send via hanzoai/tasks per CONTRACT.md
POST /v1/notify/send now defaults to durable async dispatch via
hanzoai/tasks (NotifySendWorkflow, queue notify-send) and returns 202
Accepted with {message_id, task_id, status:"queued"}. ?sync=true keeps
the inline path and returns 200 with the terminal result.

The router fails closed if async is requested but the Dispatcher is
nil or not Started (503) — no silent sync fallback, matching
hanzoai/tasks CONTRACT.md §3 ("production MUST set TASKS_ADDR").

Worker lifecycle moves out of routes/ into cmd/notifyd so the routes
package depends only on the new tasks.Dispatcher interface; tests stub
it directly. Tasks namespace defaults to "default" per CONTRACT.md §6
(single shared worker for all tenants in this PR; per-org namespacing
is the next step once liquidity tenant traffic scales).

A handful of internal helpers narrow from *base.Base to the smaller
core.App interface (Activities, metering.Write/Aggregate, tenant.New,
event.Resolve, template.LoadPublished, mountSend) so tests can drive
them with tests.NewTestApp without booting a full daemon.

Tests:
- internal/tasks/activities_test.go — Deliver idempotency
  short-circuits on status=sent and status=failed plus nil-receiver
  guard.
- internal/routes/send_test.go — sync 200 / async 202 happy paths +
  503-on-missing-dispatcher + 503-on-unstarted-dispatcher +
  500-on-dispatch-error + 401-on-missing-X-Org-Id.

References hanzoai/tasks#3.
2026-06-01 21:08:16 -07:00
..
…