@@ -214,9 +214,9 @@ re-derive it**:
214214 ` CountTargetsForIP ` , added by item 9 above against the same table.
215215
216216
217- ## Tier 3 — Infrastructure & Extensibility: IN PROGRESS (5 of 7)
217+ ## Tier 3 — Infrastructure & Extensibility: DONE (7 of 7)
218218
219- Two items left . See ` NEXT.md ` .
219+ Tier 4 is next . See ` NEXT.md ` .
220220
221221### Item 12 — Argon2id hasher: DONE, branch ` feat/argon2id-hasher `
222222
@@ -525,6 +525,104 @@ successes plus five unknown keys recording nothing). Manual guide:
525525` docs/testing/api-keys.md ` . ` gofmt -l ` , ` go build ./... ` , ` go vet ./... `
526526and ` go test ./... ` all clean here.
527527
528+ ### Item 17 — webhooks: DONE, branch ` feat/webhooks `
529+
530+ The engine tells the host app what happened instead of waiting to be
531+ asked. ` Config.Webhooks ` takes a ` notify.WebhookSender ` —
532+ ` SendWebhook(ctx, notify.WebhookEvent) error ` , one method, ** zero
533+ shipped implementations** , the same shape as ` EmailSender ` and
534+ ` IPGeolocator ` . ` Config.WebhookEvents ` selects which events reach it and
535+ defaults to ` cryden.DefaultWebhookEvents() ` .
536+
537+ Wired as a ** decorator over ` store.AuditStore ` ** (` webhookRecorder ` in
538+ ` webhooks.go ` ), not as a parameter on the 33 ` audit.Record ` call sites
539+ and not as a second event bus. ` New ` wraps ` Config.Audit ` when
540+ ` Webhooks ` is set; ` Record ` writes the row and then dispatches, so every
541+ existing call site notifies without a line changing and nothing in
542+ ` auth/ ` knows the type exists. Reads pass straight through to the
543+ wrapped store.
544+
545+ ` DefaultWebhookEvents() ` is sixteen events, the ones bounded by human
546+ action, and deliberately excludes ` login_success ` , ` token_rotated ` and
547+ ` login_failed ` — a thousand logged-in users at the default 15-minute
548+ ` AccessTokenTTL ` is 4,000 ` token_rotated ` deliveries an hour, and
549+ ` login_failed ` volume is chosen by whoever is attacking you. It returns
550+ a fresh slice, so ` append(cryden.DefaultWebhookEvents(), ...) ` is the
551+ documented way to add one back. There is deliberately ** no "all"**
552+ switch: it would silently start delivering event types added after the
553+ host wrote its sender.
554+
555+ Delivery is synchronous, on the request path, immediately after the
556+ audit write — so the doc comment on the interface says to enqueue rather
557+ than make the HTTP call there. A send error is logged at Error level and
558+ never fails the operation; a failed audit write still delivers; a
559+ ** panic is not recovered** and takes the request, following
560+ ` logger/multi.go ` 's own stated rule that recovery exists only where a
561+ second sink can preserve the record. ` Metadata ` is copied before
562+ delivery so a sender cannot rewrite audit history, and ` WebhookEvent.ID `
563+ is a delivery/idempotency key, explicitly not the audit row's ID — no
564+ backend reports that back.
565+
566+ Two new sentinels: ` ErrMissingWebhookSender ` (events set, no sender) and
567+ ` ErrInvalidWebhookEvent ` (an empty type). A non-empty but misspelled
568+ event type builds and is never delivered; there is no canonical list to
569+ validate against and inventing one would duplicate the constants.
570+
571+ No store change, ** no migration** , no new dependency, no external
572+ service. Tests: 17 in ` webhooks_test.go ` . Smoke test:
573+ ` cmd/smoketest/webhooks ` (75 checks over ten sections, including five
574+ logins and five refreshes delivering nothing, a sender that errors, and
575+ a sender that panics). Manual guide: ` docs/testing/webhooks.md ` .
576+ ` gofmt -l ` , ` go build ./... ` , ` go vet ./... ` and ` go test ./... ` all
577+ clean here.
578+
579+ ### Item 18 — custom email templates: DONE (no engine change), branch ` feat/custom-email-templates `
580+
581+ ** Nothing was built, and that is the finding.** The queue entry said to
582+ check ` EmailSender ` /` MagicLinkSender ` first because there was "a real
583+ chance this needs no engine change at all." There is nothing to build.
584+ Checked and confirmed:
585+
586+ - Two interfaces, two methods, ** two call sites in the whole tree** :
587+ ` SendVerification ` at ` auth/email.go:70 ` (email change) and
588+ ` SendMagicLink ` at ` auth/magiclink.go:88 ` (passwordless login). Each
589+ interface has exactly one purpose, so the "which email am I sending?"
590+ ambiguity ` notify/magic_link_sender.go ` 's doc comment worried about
591+ does not exist in practice.
592+ - Both methods pass ` (ctx, to, rawToken) ` . The engine composes no
593+ subject, no body, no HTML, no plain-text part, no from-address and no
594+ URL — it does not know the host's domain or routing, as both doc
595+ comments already say.
596+ - ` Config ` has exactly two email-shaped fields and both are those
597+ interfaces. There is no template, subject or from-address knob to
598+ override.
599+ - No third or fourth template is missing either: there is no signup
600+ verification flow (` store.PurposeEmailVerify ` has no producer outside
601+ a store smoke test) and no password-reset flow at all
602+ (` ChangePassword ` requires the current password), so nothing else in
603+ the engine wants to send mail.
604+
605+ Built instead of a feature: ` docs/testing/custom-email-templates.md `
606+ answering the question behind the item (how a host controls what those
607+ emails say, in full), ` cmd/smoketest/custom-email-templates ` (54 checks
608+ over ten sections — a real host mailer with ` html/template ` bodies, two
609+ languages and two providers, whose own composed URL round-trips back
610+ into ` ConfirmEmailChange ` and ` CompleteMagicLink ` ), and
611+ ` custom_email_templates_test.go ` , three tests that pin the verdict by
612+ reflection so a ` Config.EmailSubject ` added later fails ` go test ./... `
613+ rather than quietly making the guide wrong.
614+
615+ One real gap recorded rather than filled: both TTLs
616+ (` changeEmailTokenTTL ` 1 hour, ` magicLinkTTL ` 15 minutes) are unexported
617+ and not passed to the sender, so a template that says "expires in 1
618+ hour" hardcodes a number that could drift. Exporting two constants would
619+ fix it; adding a parameter to either send method would break every
620+ existing host implementation at compile time, which is why
621+ ` MagicLinkSender ` was a new interface rather than a second method on
622+ ` EmailSender ` . Queue it as its own item if the project owner wants it —
623+ it is not done here, on the item's own "don't build something
624+ speculative to have built something" instruction.
625+
528626## Tier 4 — AI-assisted admin features: NOT STARTED
529627
530628Four items, all read-only/surface-only by explicit, non-negotiable
@@ -605,6 +703,23 @@ project brief.
605703 ** two migrations** that have to run before the feature works:
606704 ` store/postgres/migrations/0007_api_keys.up.sql ` and
607705 ` store/sqlite/migrations/0002_api_keys.up.sql ` . Unmerged and unpushed.
706+ - ` feat/webhooks ` — item 17, complete, 7 commits, branched from
707+ ` feat/api-keys ` at ` f11e40e ` , the tip of the chain, so this branch
708+ carries items 8 through 17. Touches engine files only (` config.go ` ,
709+ ` errors.go ` , ` engine.go ` , the new ` webhooks.go ` ) plus the new
710+ ` notify/webhook_sender.go ` , so the same by-hand adjacency as items
711+ 10-12, 14, 15 and 16 applies if it is lifted onto ` main ` alone. It
712+ adds ** no dependency** , ** no migration** and no store change at all —
713+ it delivers events the audit table already recorded. Unmerged and
714+ unpushed.
715+ - ` feat/custom-email-templates ` — item 18, complete, 4 commits,
716+ branched from ` feat/webhooks ` at ` 6f84095 ` , the tip of the chain, so
717+ this branch carries items 8 through 18. ** Contains no engine change at
718+ all** — the ` feat/ ` prefix is the naming convention, not a claim. Adds
719+ one root test file, one smoke test and one guide, touching no existing
720+ Go file, so unlike every branch before it this one lifts onto ` main `
721+ with nothing to reconcile. No dependency, no migration. Unmerged and
722+ unpushed.
608723- ` fix/committed-smoketest-binary ` — not a queue item. A pre-existing
609724 bug found while working on item 14: a 9.8 MB compiled ` argon2id-hasher `
610725 binary was committed to the repo by item 12's session (` 57a5dbd ` ) and
0 commit comments