Skip to content

feat(engine): module substrate wave 2 — events, outbox drain, webhook delivery, admin API (HT-69) - #78

Closed
zaridan wants to merge 2 commits into
feat/ht-68-substrate-schemafrom
feat/ht-69-events-webhooks
Closed

feat(engine): module substrate wave 2 — events, outbox drain, webhook delivery, admin API (HT-69)#78
zaridan wants to merge 2 commits into
feat/ht-68-substrate-schemafrom
feat/ht-69-events-webhooks

Conversation

@zaridan

@zaridan zaridan commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Wave 2 of the module substrate (HT-69; spec §4/§5 on #76). Stacked on #77 (wave 1) — no new migrations; retarget to main after #77 merges.

  • Transactional event emission at six points (conversation created / message_received / status_changed / tags_changed / assignee_changed / reply_sent) — outbox row written in the same transaction as the state change, rollback ⇒ no event (test-forced). Thin payloads: ids and small facts, never bodies/subjects/addresses. Soft delete emits nothing, ever — including a delivery-race guard so a stranded outbound that delivers after its conversation is soft-deleted still sends the mail (invariant HT-3: clean-room protocol doc #1) but fires no event.
  • Outbox drain on the existing queue/cron pattern; fan-out per matching active endpoint, dedupe key eventId:endpointId.
  • Webhook delivery: Stripe-shape t=…, v1=… HMAC (secrets decrypted only at signing time), 10s timeout, redirects never followed, hand-rolled SSRF guard (v4+v6 private/link-local/metadata/NAT64/6to4/Teredo ranges; DNS answers all validated; resolved IP pinned into the TLS connection — live-verified against a real server, including a node:net.BlockList footgun that justified hand-rolling), retry/dead-letter via QueueProvider, auto-disable at 20 via the wave-1 store.
  • Admin API: POST/GET/PATCH/DELETE /api/v1/webhooks + POST …/{id}/test (real delivery path), secret shown once, agents.ts conventions; PATCH re-enable resets the counter.
  • Ops: /internal/health webhooks section (auto-disabled endpoints + 24h dead-letter growth, same 200/503 contract), outbox-drain cron in vercel.json, runbook Part G updated.

Review trail

Sonnet-authored → Opus adversarial review: FIX-FIRST (1 MAJOR, 1 MINOR — both fixed): the MAJOR was the soft-delete/delivery race above. The review also ran the producer signature implementation directly against the consumer verifier in the draft-assistant module repo: byte-exact interop confirmed (seconds-unit timestamp, lowercase hex, multi-v1, constant-time both sides).

Gates

typecheck 0 · lint 0 · test 0 — 1230 passed (~115 new). Per the maintainer's overnight ground rules this PR stays open for morning review.

🤖 Generated with Claude Code

zaridan and others added 2 commits July 19, 2026 00:02
… delivery, admin API (HT-69)

Implements specs/modules/substrate-v1.md §4/§5 on top of wave 1's schema and
stores (HT-68): transactional event emission at every non-deleted state
change (conversation.created/message_received in the ingestion append path,
status_changed/tags_changed/assignee_changed on the corresponding
ConversationStore writes, reply_sent on the delivery-status→'sent'
transition), a cron-driven outbox drain that fans each event out to its
matching active webhook endpoints via the existing queue, a signed HTTPS
delivery handler with resolve-then-connect SSRF pinning and its own
retry/dead-letter ceiling, and the webhooks admin API (CRUD + test-ping)
mirroring agents.ts's conventions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…edupe-key doc (HT-69 review)

MAJOR: releaseThreadLease's 'sent' branch emitted conversation.reply_sent
unconditionally, but delivery is not conversation-status-scoped — a thread
claimed/leased before its conversation was soft-deleted could still be
force-delivered afterward and fire the event, breaching spec §4's absolute
"no event of any type fires for a soft-deleted conversation" exclusion.
Gate the outbox append (not the delivery-status write, which must still
proceed — mail already went out) on the conversation's live status, read in
the SAME UPDATE...RETURNING statement via a correlated subquery, mirroring
listAwaitingDrafts' existing status <> 'deleted' carve-out for drafts.

MINOR: migration 023's doc comment said the outbox->queue hand-off is keyed
by dedupe_key = event_id (singular); corrected to the implemented per-(event,
endpoint) fan-out key event_id:endpointId, and dropped outbox-drain.ts's now-
redundant "written before finalized" apology.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0289023e-00ca-4c93-ba8a-b8b878937239

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-69-events-webhooks

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant