# Port the CFX event bus off Hookdeck onto SNS

> **Status: the public bus is done (phases 0–5). The internal bus is specified and unbuilt (§8.20).**
>
> | Phase | State                                     | Landed                                    |
> | ----- | ----------------------------------------- | ----------------------------------------- |
> | 0     | Outpost partner migration                 | 2026-08-05                                |
> | 1     | Package and the SNS publish seam          | 2026-08-05, CFXLabsInc/cfx-platform#3033  |
> | 1     | Topic and the Outpost bridge              | 2026-08-05, #3034; grants #3040; CD #3044 |
> | 1     | X-Ray tracing, delivery logs              | 2026-08-07, #3079                         |
> | 1     | Publisher swap — **completing the above** | 2026-08-08, #3081                         |
> | 3     | Bridge cutover; Hookdeck connection gone  | 2026-08-09 (§8.11)                        |
> | 4     | customer Slack, reward, card consumers    | §8.13–8.15; Attio inlined instead (§8.16) |
> | 5     | Dual-run arm deleted, `cfx-publish` gone  | §8.18; prod account cleanup 2026-08-10    |
> | 2     | Consumer-graph extraction                 | **Never shipped** — folded into §8.20.2   |
> | 6–9   | The internal bus, as one wave             | Specified 2026-08-11 (§8.20), unbuilt     |
>
> **Phase 1 landed in two parts, and the gap between them cost a day.** The first part shipped the
> registry, the `CustomerEventPublisher`, the topic and the bridge — and this document was updated to
> describe the collapse in §2 and §8.6 as though it were finished. It was not: `EntityEventPublisher`
> survived beside the new publisher, ~30 services still held it, and six of the fifteen composition
> roots declared `EVENTS_TOPIC_ARN` in their env schema without ever passing it to a publisher. Dev
> and prod both looked correctly configured — Doppler set, IAM granted, CD green — and the topic
> received nothing from those services. §8.11 records the diagnosis. **Nothing below describes
> intent; every statement about the publisher is now true of the code.**
>
> **Decided:** SNS is the internal event bus. Every consumer is a Lambda subscribed directly to the
> topic — including the one that bridges to Hookdeck Outpost, and the three auto-reconciliation
> handlers, which hand off to Temporal instead of to a queue. No queue sits in the hot path; SQS
> appears only as the DLQ behind a subscription's `RedrivePolicy`.
>
> **Ask:** approve §8.20 — the internal-bus wave. §2, §4 and §8 are built and describe the code.

Findings here were verified against the live prod Hookdeck account through its REST API, prod Aurora,
and the AWS documentation — not only against this repo's Pulumi. Several load-bearing facts are
invisible from IaC alone.

## 1. Where this starts from

CFX publishes events over HTTP to Hookdeck. Three sources carry first-party traffic: `cfx-publish`
(public), `b2b-internal` (internal fan-out), and `girasol-card-events`. Every publish is an
HMAC-signed `POST` from one of three thin classes —
[`EventPublisher`](../packages/notification-services/src/webhook/EventPublisher.ts),
[`AdminEventPublisher`](../packages/notification-services/src/webhook/AdminEventPublisher.ts),
[`GirasolCardEventPublisher`](../packages/girasol-card-services/src/GirasolCardEventPublisher.ts) —
behind roughly 200 call sites.

**Phase 0 already happened (2026-08-05).** Customer webhook delivery moved off per-partner Hookdeck
connections onto Hookdeck Outpost destinations: 12 prod and 5 dev partners migrated,
`movemoney-app` switched to Standard Webhooks (CFXLabsInc/cfx-demo#1834), partner resources removed
from both Pulumi stacks, and the legacy connections deleted from the account along with CreditCoop,
the departed STP/Coinflow vendors, and the CLI leftovers. Code removal is
CFXLabsInc/cfx-platform#3014. §3 records what that taught us.

**So customer delivery is already solved.** Outpost owns per-tenant endpoints, secrets, filters and
retries. What remains is the _internal_ bus and how CFX gets events into Outpost.

**Out of scope: all inbound vendor webhooks.** AiPrise, Victor, Metcap, RouteFusion, Quiltt, Utila,
Helius, PropelAuth, Yellowcard, Nominis, Zenus, Vibes and Greendot keep posting to Hookdeck, keep
their HMAC verification, and keep their connections. Hookdeck remains the ingress gateway and the
vendor event log. This RFC changes the egress half only.

## 2. The seam: one class, ~200 call sites

The whole migration turns on the fact that every publish funnels through three thin classes, each a
single unguarded `await fetch()`. Replace the transport inside them and no call site changes.

`EventPublisher.publish()` becomes an SNS `PublishCommand`. `AdminEventPublisher` publishes with a
different attribute set.

**`EntityEventPublisher` is deleted, not preserved** — an earlier draft said it needed no change
because it delegates. Once the envelope models entity references as a union, the only thing that
class still did was one database lookup resolving an entity id to its reference id. That arrives as
an injected resolver instead, and its `entityId` call shape becomes an arm of the one publisher. Two
classes for one bus was a wrapper, not a layer. See §8.6.

**`GirasolCardEventPublisher` stays on Hookdeck, contrary to an earlier draft of this RFC.** Its one
event, `girasol.cardTransaction.completed`, carries no `customerId` and sends no `x-cfx-customer-id`
header — it is a partner outbound webhook to Girasol, not a tenant-scoped customer event. The bus
routes on `customerId` and the bridge maps it to Outpost's `tenant_id`, so folding this event in
would mean a special case in the envelope, the bridge, and every filter policy in exchange for
deleting one thin class. It is revisited with the internal bus.

Three things change behind that seam:

- **Mint a ULID `id` per event.** There is none today: Hookdeck mints `x-hookdeck-eventid`
  downstream, reward-api uses it as its idempotency key
  ([B2bEventConsumer.ts:151](../packages/reward-api/src/consumers/B2bEventConsumer.ts)), and the
  Outpost transform reuses it for dedupe. That header disappears, so the id must come from us.
- **A failed `Publish` throws.** Today the response is not even inspected.
- Composition roots swap `EVENTS_PUBLISH_URL` / `EVENTS_PUBLISH_SIGNING_SECRET` for a topic ARN,
  across **15 packages** (enumerated in §8.9). `INTERNAL_EVENTS_PUBLISH_URL` waits for the internal
  bus and `GIRASOL_EVENTS_PUBLISH_URL` stays where it is.

**This ships on its own.** It is the largest, most mechanical piece, and it is independent of every
consumer decision below — which is why it goes first.

There are **no tests for these publishers today**. Phase 1 adds them: envelope shape, id minting,
attribute mapping, and that a publish failure propagates. §8 is the full build sheet.

## 3. What the live system taught us

Each of these changed a design decision. They are recorded because re-deriving them from code is not
possible.

### 3.1 "SERIAL" was a concurrency cap, and it was never ordering

Three connections carried `rate_limit: 1, rate_limit_period: "concurrent"`: `bank-transaction`,
`bank-internal-transfer`, `metcap-wire-transaction`. The Terraform repo confirms there is **no
ordered-delivery setting anywhere in Hookdeck's config**. `1/concurrent` guarantees one-in-flight and
nothing about order.

This matters because reproducing it on AWS is harder than it looks. §6 concludes we should not try:
the work these connections guarded already terminates in Temporal workflows keyed on the entity id,
which serializes it more precisely than a global in-flight cap ever did.

### 3.2 Retry was uniform

Every connection: `count: 5, interval: 3_600_000, strategy: "linear"` — five attempts an hour apart.
`DEFAULT_RETRY_RULE` in
[pulumi-templates/src/hookdeck/rules.ts:16-20](../packages/pulumi-templates/src/hookdeck/rules.ts) is
byte-identical, so retry was **not** lost in the Terraform → Pulumi migration, contrary to earlier
belief. Nothing needs reproducing: an SNS subscription to a Lambda retries on the managed-endpoint
policy, which is strictly more generous than five attempts over five hours. The lesson runs the other
way — see §6's error semantics, where a _deterministic_ failure now retries for far longer than
Hookdeck would have allowed.

### 3.3 No event map exists anywhere

Neither repo has an event-type registry. Event names appear only as inline literals in filter rules;
payload schemas exist only as TypeScript types beside each publisher; the Outpost topic is derived at
runtime from `body.event ?? body.data.event`. Without a registry, SNS filter policies become a fourth
uncoordinated copy of the taxonomy — the drift entropy described in
[ENTROPY.md § Two entropies](../ENTROPY.md). §5 produces it.

## 4. Target shape and topic design

```mermaid
flowchart LR
    subgraph producers["~200 call sites"]
        EP["EventPublisher"]
        AEP["AdminEventPublisher"]
    end

    PUB["cfx-events-{env}"]
    INT["cfx-internal-events-{env}"]

    EP -- Publish --> PUB
    AEP -- Publish --> INT

    subgraph public["public consumers — direct invoke"]
        LB["λ outpost bridge"]
        LC["λ backoffice customer"]
        LR["λ reward b2b"]
        LK["λ card b2b / girasol"]
    end

    subgraph internal["internal consumers — direct invoke"]
        LS["λ slack fan-out"]
        LT["λ bank-transaction"]
        LI["λ bank-internal-transfer"]
        LM["λ metcap-wire"]
    end

    TW["Temporal<br/>workflowId = entity id<br/>one run per entity"]

    PUB --> LB & LC & LR & LK
    INT --> LS & LT & LI & LM
    LT & LI & LM -- idempotentStartWorkflow --> TW

    LB -- "POST /publish<br/>Bearer + {tenant_id, topic, data}" --> OP["Hookdeck Outpost"]
    OP --> CUST["customer endpoints<br/>(tenant-owned)"]

    subgraph unchanged["unchanged — inbound only"]
        VEND["AiPrise · Victor · Metcap<br/>RouteFusion · Quiltt · Utila · …"] --> HD["Hookdeck Gateway"]
        HD --> APIS["backoffice-api · identity-api<br/>withdrawal-api · dashboards"]
    end
```

The Outpost bridge is **just another consumer**. That is what removes the need for EventBridge: SNS
cannot attach a bearer token to an outbound HTTP call, but a Lambda can, and we are building Lambdas
anyway (§6).

Note the bottom subgraph. Hookdeck is not being removed — it keeps every inbound vendor webhook, its
HMAC verification, and its event log. Only the egress half moves, and `girasol-card-events` stays
behind with it (§2).

**Start with two topics**, mirroring the two audiences that exist today:

| Topic                       | Replaces       | Consumers                                                                        |
| --------------------------- | -------------- | -------------------------------------------------------------------------------- |
| `cfx-events-{env}`          | `cfx-publish`  | Outpost bridge, backoffice customer, reward, card                                |
| `cfx-internal-events-{env}` | `b2b-internal` | slack fan-out, bank-transaction, bank-internal-transfer, metcap-wire-transaction |

Not one topic, because the internal and public streams have different audiences and different
retention concerns, and splitting them costs nothing. Not one topic per domain, because that
multiplies subscriptions without buying routing we cannot already express with filters.

**Standard topics, not FIFO.** FIFO topics cannot deliver to HTTP(S) endpoints at all, and would
require every one of the ~200 call sites to supply a `MessageGroupId` and a deduplication id. §6
covers the concurrency question they would otherwise have solved.

### Filtering, and its real limits

Publish both a small set of **message attributes** (for routing) and the full JSON body. Filter on
attributes where possible; `FilterPolicyScope: MessageBody` where the predicate needs the payload.

Today's filters that must survive:

| Consumer               | Filter                                                                                  |
| ---------------------- | --------------------------------------------------------------------------------------- |
| reward                 | `identityId $exist OR organizationId $exist`                                            |
| card                   | `data.status $exist AND customerId $exist AND NOT event = customer.terms.statusUpdated` |
| bank-transaction       | `event $or [backoffice.bankTransaction.created]`                                        |
| bank-internal-transfer | `event $or [created, statusUpdated]`                                                    |
| metcap-wire            | `event = backoffice.metcapWireTransaction.updated`                                      |

SNS supports the operators these need — `exists`, `anything-but`, `$or`, prefix, numeric ranges. Two
constraints to design around, both real:

- **Five keys maximum** per filter policy (leaf keys, for nested), and total value combinations
  capped at 150. A consumer wanting "these 12 event types, for entity X, above threshold Y" can run
  out.
- **Filter policy changes take up to 15 minutes to take effect.** This is not a hot-fix lever during
  an incident. Plan on deploying a code change to a consumer rather than editing a filter to stop a
  bad event.

Publishing a `domain` and `event` attribute alongside the body keeps most filters inside the
attribute scope and well under five keys.

## 5. The event registry

Before consumers. A typed registry: event name → payload schema → routing attributes.

**The envelope has two halves and they must be explicit:**

|                  | Fields                                                                                                       | Visibility                          |
| ---------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------- |
| Routing metadata | `customerId` (tenant id), event `id`                                                                         | **Internal only — never delivered** |
| Customer payload | `event`, `createdAt`, `data`, `identityId`/`identityReferenceId`, `organizationId`/`organizationReferenceId` | Delivered                           |

`customerId` is our internal tenant identifier. It routes the event and must be **stripped** before
delivery. **This is a leak that exists today** — the current Outpost transformation wraps the entire
original body under `data`, so `customerId` reaches every customer. Confirmed that no customer reads
it, so removing it needs no announcement.

One module that the publishers, the SNS filter policies, the Outpost bridge's payload mapping, and
the consumer validators all import. This is what makes the rest mechanical.

## 6. Consumers

**Every consumer subscribes to SNS directly. There is no queue anywhere in this design.**

SNS's retry policy for an AWS-managed endpoint is **100,015 attempts over 23 days**, far longer than
anything a `visibilityTimeout` × `maxReceiveCount` budget would give, and a subscription
`RedrivePolicy` gives each consumer its own DLQ without a queue in the hot path. That covers
durability for every consumer. The one thing SQS offered beyond it was `MaximumConcurrency` — and the
three consumers that wanted it turn out not to need it, which removes roughly $76/month and a moving
part from the design.

| Consumer                                              | Trigger               | Why                                                                        |
| ----------------------------------------------------- | --------------------- | -------------------------------------------------------------------------- |
| outpost bridge                                        | SNS direct            | Outpost is itself a highly available event gateway — it does the buffering |
| backoffice customer, reward, card, slack fan-out      | SNS direct            | Nothing to bound; SNS retry and a subscription DLQ suffice                 |
| bank-transaction, bank-internal-transfer, metcap-wire | SNS direct → Temporal | The handler only dispatches; Temporal owns concurrency and idempotency     |

Template is [`wire-email-handler`](../packages/wire-email-handler/pulumi/index.ts) — a non-HTTP
Lambda that already proves the pattern. `LambdaApp` is trigger-agnostic: SES there, an
`aws.lambda.Permission` for SNS here.

Per consumer: `src/lambda.ts` + `src/main.ts` (both required — `nx-lambda` detection needs both),
`src/handler.ts` with an `SNSHandler`, a module-scope composition root, and Pulumi wiring
`LambdaApp` plus its subscription and an error `LogMetricFilter`/`MetricAlarm`.
`architecture: arm64`. `memorySize` well above the 128 default for the consumers that build a real
graph — these init Aurora, Valkey, Slack and Temporal clients.

One consequence to accept: **SNS delivers one message per invocation.** At these volumes the
invocation cost is immaterial; it is worth revisiting only if a consumer becomes hot enough that
per-invocation init dominates.

**A Postgres role per Lambda** in
[`packages/db/pulumi/workloadIdentities.ts`](../packages/db/pulumi/workloadIdentities.ts) — only that
stack runs in-VPC and can create them. Easy to miss; without it the Lambda cannot reach Aurora. The
three dispatch handlers of §6.1 need neither, since they touch only Temporal.

### The Outpost bridge is just another consumer

One Lambda subscribes like any other and POSTs to Outpost's `/publish` with
`Authorization: Bearer`, mapping the envelope to `{tenant_id, topic, id, data}` — `tenant_id` from
`customerId`, and the payload built **without** it (§5).

This is why the design needs no EventBridge. SNS cannot reach Outpost itself: an HTTPS subscription
supports only Basic/Digest auth embedded in the URL, not a bearer token; the only header knob is
`headerContentType`; the subscription-confirmation handshake is mandatory and Outpost will not
perform it; and HTTP/S delivery is capped at **3,600 seconds of total retry, a hard limit**. A Lambda
sidesteps all four, and gets SNS's 23-day managed-endpoint retry policy instead of that 1-hour cap.

It needs no queue in front of it. Outpost is a highly available event gateway that owns per-tenant
delivery, retry and redelivery — the durability this hop would otherwise be buying is already on the
other side of it.

### 6.1 The three auto-reconciliation handlers: Temporal is the queue

`bank-transaction`, `bank-internal-transfer` and `metcap-wire-transaction` carried `1/concurrent`
(§3.1) and are the hot auto-reconciliation path. **They subscribe to SNS directly, and their handler
does nothing but dispatch a Temporal workflow.**

The reason is that they already do. Follow `BankTransactionEventConsumer.consume` and it ends at
`AchCreditDepositQuoteService.processFromBankTransaction`, which starts `achCreditDepositWorkflow`
with `workflowId: depositId`
([AchCreditDepositQuoteService.ts:173-184](../packages/deposit-services/src/AchCreditDepositQuoteService.ts)).
The mutating work was never running in the webhook handler; the handler looks records up and hands
off. A queue in front of it would have bounded the lookup, not the reconciliation.

**Temporal's guarantee is the better one.** Workflow-id uniqueness means at most one run per entity,
with no bound at all across distinct entities. `MaximumConcurrency: 2` is the opposite trade: it
serializes unrelated deposits against each other while still allowing two concurrent writers on the
same one. Hookdeck's `1/concurrent` was a crude approximation of per-key serialization, implemented
as a global cap because HTTP delivery has no notion of a key.

**Redelivery is already safe.** [`idempotentStartWorkflow`](../packages/services/src/idempotentStartWorkflow.ts)
reconciles a start against whatever run exists at that id — same type is a no-op returning the
existing handle, a different running type is awaited first — and the activities behind these
workflows check idempotency individually. A duplicate SNS delivery therefore collapses into an
existing run rather than racing it. This is the same pattern
[`RouteFusionWebhookEventConsumer`](../packages/backoffice-api/src/controllers/routefusion/RouteFusionWebhookEventConsumer.ts)
already uses for inbound vendor webhooks.

> **Correction, 2026-08-11: everything above this line in §6.1 is wrong about the code.** It was
> written from the shape the RFC wanted rather than from the call graph, and §9's "not yet traced
> end-to-end" hedge understated it — the assumption fails for all three handlers, including the one
> §9 recorded as verified. What is actually there:
>
> - **`bank-internal-transfer` starts no workflow at all.** It does four database reads, then
>   `temporalClient.workflow.getHandle(id).describe()` and `.signal("BANK_INTERNAL_TRANSFER_EVENT", …)`
>   ([BankInternalTransferEventConsumer.ts:50-69,110-134](../packages/backoffice-api/src/controllers/BankInternalTransferEventConsumer.ts)).
>   If the workflow is not `RUNNING` it logs a warning and returns; the whole block is wrapped in a
>   `try/catch` that swallows to `logger().warn`. Redelivery safety rests on the workflow's signal
>   handling, not on workflow-id uniqueness — and a signal arriving after the run ends is dropped,
>   not retried.
> - **`bank-transaction` and `metcap-wire` do mutating work inline, around the start.** All four
>   `processFromBankTransaction` implementations insert a `deposit_quote`, accept it
>   (`depositQuoteService.update` to `ACCEPTED`), create a deposit, start the workflow, and then
>   write `reconciliation.depositId` back onto the bank transaction. Four mutations in the request
>   path, one of them after the workflow is already running.
> - **The start is raw `client.workflow.start`, not `idempotentStartWorkflow`** — verified in all
>   four services (`AchCreditDepositQuoteService.ts:173`, `RtpDepositQuoteService.ts:173`,
>   `WireDepositQuoteService.ts:180`, `SwiftWireDepositQuoteService.ts:174`). The paragraph above
>   cites a function that is used at 18 other call sites and none of these. What actually dedupes is
>   a JSONB lookup on `data ->> 'idempotencyKey'` inside `DepositAdminService.create`, keyed on
>   `bankTransactionId`; the quote insert ahead of it is not keyed at all.
>
> So "the dispatch handlers need no database graph" does not hold, and neither does the claim that
> the mutating work was never in the webhook handler. §8.20.6 carries the consequence: the SNS
> handler must claim on the envelope ULID before dispatching, because a redelivery otherwise writes
> a second quote row and then throws `WorkflowExecutionAlreadyStarted` into a 23-day retry.

Two consequences worth stating:

- **The dispatch handlers need no database graph.** A Temporal client is the whole dependency, so
  these three skip the §6 consumer-graph extraction, the Aurora VPC attachment, and the per-Lambda
  Postgres role. They are the cheapest consumers to build, not the most expensive.
- **Any lookup logic in today's `consume()` moves into the workflow**, not into the handler. The
  handler's only job is to translate an envelope into a workflow id and arguments. Where a handler
  cannot derive the workflow id without a database read, the workflow takes the raw event and does
  the read in its first activity.

Do not reach for **reserved concurrency = 1** on the Lambda as an extra belt. It bounds nothing
useful here, and it would throttle deliveries that SNS then has to retry.

### Idempotency is mandatory, not optional

SNS is at-least-once, so every consumer must tolerate seeing an event twice. There are two mechanisms
and each consumer uses exactly one:

- **The three dispatch handlers (§6.1) use Temporal.** `idempotentStartWorkflow` keyed on the entity
  id collapses a duplicate into the existing run, and the activities behind it check idempotency
  individually. Nothing further is needed, and adding a second mechanism in front would only make
  the dedupe window disagree with Temporal's.
- **Every other consumer dedupes on the ULID from §2**, using the existing DynamoDB idempotency table
  (`IDEMPOTENCY_TABLE_NAME` / `IDEMPOTENCY_TTL_DAYS`, claim-first / release-on-throw —
  [wire-email-handler/src/handler.ts:339-367](../packages/wire-email-handler/src/handler.ts)).

### Error semantics invert

Today a throw becomes a 500 and Hookdeck retries five times an hour apart, then stops. On an SNS
subscription a throw is retried on the managed-endpoint policy — **100,015 attempts over 23 days** —
before the subscription DLQ. That is far more forgiving for a transient failure and far more
punishing for a deterministic one, which will now retry for three weeks.

So: **transient failures throw; deterministic failures record and return.** A malformed envelope, an
unknown event type, or a referenced record that does not exist are all terminal — log, emit the error
metric, return successfully. [wire-email-handler/src/handler.ts:44-45,180-231](../packages/wire-email-handler/src/handler.ts)
documents this lesson from the SES path.

### Extracting the consumer graphs is the real work

[`backoffice-api/src/main.ts`](../packages/backoffice-api/src/main.ts) is **1127 lines** of flat
module-scope construction with `export default httpServer` — nothing exported for reuse.
`InternalEventSlackConsumer` alone pulls 11 sub-consumers needing `slackChannelClient`,
`customerService`, `INTERVAL_BASE_URL`, `ENVIRONMENT` and ~10 query services.

Add `src/consumerGraph.ts` per app; `main.ts` and the new handlers both import it. **Ship this as a
pure refactor before any Lambda exists** so it can be verified independently.

This applies to the consumers that keep doing their work in-process. The three dispatch handlers of
§6.1 are exempt — their graph is a Temporal client, so there is nothing to extract.

The consumer classes themselves are already transport-agnostic — they import nothing from fastify,
and the coupling is ~35-50 lines per file in `configureXEventConsumerRoute`. Two traps:

- `consume()` signatures are inconsistent (`{event,data}`, `{event,idempotencyKey}`, `{event}`, a
  bare event, one returning a `Result`). Normalize at the handler boundary, not by rewriting classes.
- `CustomerEventConsumer`'s `isKnownCustomerEvent` throw lives in the **route**, not the class. It
  must come along or unknown events start being silently accepted.

**Slack fan-out** moves out of backoffice-api into `customer-slack-bot` as a second, SNS-triggered
handler beside its existing API-Gateway one. Fix while there: `TInternalAdminEvent` declares
`slack.customerSlackBotInstallation.{created,deleted}` but `consume()` never dispatches them — they
are silently dropped today.

## 7. Phases

**Do `cfx-publish` first and finish it before touching `b2b-internal`.** The auto-reconciliation
handlers all sit on the internal bus, and they are the hot path — there is no reason to disturb them
while the pattern is still being proven. The public bus has the simpler consumers and the one that
matters most to get right early (the Outpost bridge).

| Phase                                              | What                                                                  | Ships alone?        |
| -------------------------------------------------- | --------------------------------------------------------------------- | ------------------- |
| 0                                                  | Outpost partner migration                                             | **Done 2026-08-05** |
| **`cfx-publish` — the public bus**                 |                                                                       |                     |
| 1                                                  | `@cfxlabsinc/events`: registry, publisher swap, topic, bridge (§8)    | **Done 2026-08-08** |
| 2                                                  | Extract consumer graphs (§6) — pure refactor, no infra                | Yes                 |
| 3                                                  | Cut delivery over: bridge live, Hookdeck → Outpost connection deleted | **Written**         |
| 4                                                  | customer Slack (§8.13), reward (§8.14), card (§8.15) Lambdas          | **Written**         |
| 5                                                  | Delete `cfx-publish` + its HTTP routes (§8.18)                        | **Written**         |
| **`b2b-internal` — only once the above is stable** |                                                                       |                     |
| 6–9                                                | The internal bus, as one wave — see §8.20                             | **No — one wave**   |

Phase 1 bundles what the earlier draft split across three phases — the registry, the publisher swap,
the topic and the bridge. They ship together because the registry alone is a package nothing imports,
its filter policies cannot be tested until they exist, and the bridge is the one consumer with no
database graph behind it. See §8.1.

Phase 2 is a pure refactor and can land before or after Phase 1. **Phase 3 is deliberately its own
phase**: it is the single customer-visible moment in the whole public-bus migration, and bundling it
with Phase 1's large mechanical change would put both at risk at once.

**Phases 6 through 9 collapsed into one wave**, decided 2026-08-11, and the reason is that dual-run
no longer exists to split them: §8.18 deleted the legacy HTTP arm outright rather than switching it
off. Without a second path, a publisher that stops POSTing to Hookdeck starves the four
`b2b-internal` connections immediately, so the consumers cannot be a later phase. §8.20 is the build
sheet; §8.20.1 is why the wave is still incremental despite being one phase.

The earlier ordering rationale is kept because it still describes the risk: the three
auto-reconciliation handlers are the hot path, and their failure mode is a reconciliation gap rather
than a missed notification. In the wave they are the last publishers to swap and the first consumers
to be verified.

**Dual-run before cutting — on the public bus.** Publish to both SNS and Hookdeck behind a flag; run
the new Lambdas with side effects no-oped until counts reconcile, or every Slack message and Attio
task doubles. For the bridge specifically this was not optional — see §8.11. The internal bus has no
equivalent and does not need one, because both of its paths terminate in the same consumer graph;
§8.20.1 replaces the count comparison with a source-quiescence check.

## 8. Phase 1 in detail: `@cfxlabsinc/events`

Everything above is the design. This section is the build sheet for the first phase, and it is the
part to review before implementation starts.

### 8.1 What ships, and why together

A new package, `@cfxlabsinc/events`, owning four things that today are scattered or absent:

1. **The event registry** (§5) — name → payload schema → routing metadata.
2. **The SNS publish seam** (§2) — `EventPublisher` re-implemented on `PublishCommand` behind
   unchanged call-site signatures.
3. **The `cfx-events-{env}` topic** and its IAM, declared in Pulumi inside the same package.
4. **The Outpost bridge Lambda**, defined inline in that Pulumi program.

At the end of Phase 1, events flow to SNS _and_ to Hookdeck, the bridge runs in shadow mode, and no
customer-visible behaviour has changed.

The registry alone would be a package nothing imports, and its real test — whether the taxonomy fits
inside SNS's filter-policy limits — cannot run until filter policies exist. The publisher swap is the
largest and most mechanical piece and is independent of every consumer decision. The bridge is the
consumer with no database graph behind it, which makes it the cheapest place to prove the Lambda
pattern. Together they mean one round of composition-root edits across 15 packages instead of three.

### 8.2 Decisions

| Decision                                               | Rationale                                                                                                                                                      |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| One package with a `./customer` subpath                | `.` holds bus-agnostic primitives; `./customer` holds the public-bus registry and publisher. The Phase-6 `./internal` subpath drops in beside it.              |
| Pulumi lives in the same package                       | Filter policies are derived from the registry. Putting them elsewhere would reintroduce the string-literal copy the registry exists to eliminate.              |
| The bridge Lambda's code is inline, not an app package | ~35 lines, zero dependencies. An app package would add an esbuild bundle, a `tag:lambda` CI path, and a composition root it does not need.                     |
| The bridge Lambda runs **in the VPC**, via `LambdaApp` | §8.7.3. Keeps its egress on the shared transit-gateway path and lets it inherit the standard SG, log group, alarms and IAM handling.                           |
| TypeBox is the registry's representation               | Consumers validate JSON off the wire, and the repo already uses TypeBox at every API boundary. Static types come from `Static<>`.                              |
| ULID for the event `id`                                | Time-sortable ids pay for themselves in DLQ triage. Repo convention elsewhere is `nanoid`, so this is a deliberate divergence.                                 |
| Only `cfx-events-{env}` ships                          | The internal topic waits for Phase 6 rather than sitting empty.                                                                                                |
| Dual-run with a **shadow bridge**                      | §8.11. Prevents double-delivering every webhook to every customer.                                                                                             |
| SNS publish failures throw from day one                | The rollout's only signal is a count comparison. If publishes can fail silently, the counts under-report and the cutover decision rests on a number that lies. |

### 8.3 Package shape

```text
packages/events/
├── package.json              exports: "." · "./customer"
├── project.json
├── tsconfig*.json
├── src/
│   ├── index.ts              envelope, id minting, attribute mapping, SnsEventPublisher
│   └── customer/
│       ├── index.ts          barrel for the "./customer" subpath
│       ├── registry.ts       the customer event definitions
│       └── CustomerEventPublisher.ts
├── pulumi/
│   ├── Pulumi.yaml · Pulumi.dev.yaml · Pulumi.prod.yaml
│   ├── index.ts              topic, IAM, bridge Lambda, DLQ, alarms
│   └── handlers/
│       └── outpost-bridge.mjs
└── test/
```

Two constraints on this layout, both load-bearing:

- **There must be no `src/lambda.ts`.** `nx-lambda` infers a lambda project from the co-presence of
  `src/lambda.ts` and `src/main.ts` ([nx-lambda/src/plugin.ts:36-38](../packages/nx-lambda/src/plugin.ts))
  and would tag the whole package `lambda`, giving a library a `build-lambda` target and a
  `tag:lambda` CI path.
- **`pulumi/handlers/*.mjs` is a real file, not a template literal.** It is read with
  `readFileSync(new URL("./handlers/outpost-bridge.mjs", import.meta.url), "utf8")`, the pattern
  [hookdeck/pulumi/index.ts:60](../packages/hookdeck/pulumi/index.ts) already uses. A real file stays
  lintable, diffable, and directly importable from a test.

[`packages/db`](../packages/db/) is the precedent for a library that also owns a Pulumi stack:
`nx-pulumi` infers its targets from `pulumi/Pulumi.yaml` presence, independently of anything under
`src/`.

### 8.4 The registry

A single `defineRegistry({...})` call maps event name to a definition:

```ts
export const customerEvents = defineRegistry({
  "account.virtualAccount.created": {
    data: T.Object({ id: T.String(), status: T.Union([T.Literal("PENDING"), T.Literal("ACTIVE")]) }),
    scopes: ["identity", "organization"],
  },
  "customer.pricingSchedule.statusUpdated": {
    data: T.Object({}),
    scopes: [],
  },
  // …
});
```

`scopes` is the set of entity references this event may carry — any subset of
`["identity", "organization"]`. It is a set rather than a single tagged value because the two awkward
cases are cardinality, not kinds: `[]` is "no entity reference" and `["identity", "organization"]` is
"either one", neither of which needs a word invented for it. A third scope, if one ever appears, adds
one member rather than doubling an enum.

**Exactly one member of `scopes` is present on any given envelope.** The publisher branches on
whether the entity id starts with `id_` and sets one pair or the other, never both — behaviour
inherited verbatim from the deleted `EntityEventPublisher` and now living in
[`CustomerEventPublisher`](../packages/events/src/customer/CustomerEventPublisher.ts) — and
`EventWithIdentityOrOrganization` is already a union rather than an intersection. So `scopes` declares
what is _permitted_, and the derived schema is a union over those members — not a shape requiring all
of them. Getting this backwards would produce a validator that rejects every real event.

| `scopes`                       | Envelope carries                                                     |
| ------------------------------ | -------------------------------------------------------------------- |
| `[]`                           | no entity reference fields                                           |
| `["identity"]`                 | `identityId` + `identityReferenceId`                                 |
| `["organization"]`             | `organizationId` + `organizationReferenceId`                         |
| `["identity", "organization"]` | a union of the two above — exactly one pair, mirroring `EntityEvent` |

Everything else is derived from the one declaration:

| Derived artefact             | Replaces                                                                                            |
| ---------------------------- | --------------------------------------------------------------------------------------------------- |
| `TCustomerEvent` union       | [CustomerEventConsumer.ts:273](../packages/backoffice-api/src/controllers/CustomerEventConsumer.ts) |
| `customerEventNames` tuple   | `customerEventTypes`, ibid. 278                                                                     |
| `isKnownCustomerEvent`       | ibid. 282 — and it **moves out of the route into the module**                                       |
| `CustomerEvent<"name">` type | the per-publisher TypeScript generics                                                               |
| filter-policy fragments      | the inline event-name literals in the Hookdeck connection rules                                     |

Phase 1 re-points `CustomerEventConsumer` at the registry **without moving it off HTTP** — a pure type
change, verified by typecheck. Skipping it would leave the duplicate standing and make the registry a
fifth copy, which is strictly worse than not building it.

#### Building the inventory is a research task, not a copy

The 33 schemas in `CUSTOMER_EVENT_SCHEMAS` are what **backoffice-api consumes**. That is not
necessarily what publishers **emit** — an event published with no consumer declaring it would be
silently dropped from a registry derived from the union alone, then fail validation the first time a
consumer is pointed at the registry.

The inventory is therefore assembled from **both** directions and the difference reconciled
explicitly: every `eventPublisher.publish(...)` / `entityEventPublisher.publish(...)` /
`cardEventPublisher.publish(...)` call site, and the existing consumer union. Anything in one set and
not the other gets a recorded decision: register it, or declare it dead.
This turned out to matter more than expected. The consumer union declares **33** events; publishers
emit **43**. The ten published with no consumer schema are `account.depositMemo.deleted`,
`account.virtualAccount.statusUpdated`, `customer.document.created`, `customer.document.deleted`,
`entity.document.uploaded`, `entity.document.deleted`, `organization.relatedPerson.created`,
`organization.relatedPerson.updated`, `organization.relatedPerson.deleted`, and
`girasol.cardTransaction.completed`. Nothing in the union is dead — the gap runs one way only.

`girasol.cardTransaction.completed` is excluded per §2, leaving **42** events in the customer
registry.

#### The registry describes what publishers emit, not what consumers declare

Publisher payloads are consistently wider than the consumer schemas. `deposit.deposit.created`
publishes `{depositId, depositReference, type, status}` against a declared `{depositId, status}`;
`reward.reward.created` publishes eight fields against a declared one; `reward.reward.claimed`
publishes a nested `reward.amount` object against a declared `{id, status}`.

TypeBox `T.Object` is non-strict, so the wider payloads validate today and the drift is invisible.
It stops being invisible the moment the registry types the publish side: a registry carrying only
the consumer's narrow schema would make the **publisher** reject the fields it actually sends. So
each entry's `data` is the union of every field observed at that event's publish sites, with
conditionally-spread and site-varying fields marked `T.Optional`.

#### `scopes` is binary in practice

No publish site passes an explicit `identityId` or `organizationId`. Entity references enter only
through the `entityId` arm, which sets one pair or the other from `entityId.startsWith("id_")`.
Every event is therefore `["identity", "organization"]` or `[]`. The single-scope arms remain
expressible, and the schema derivation supports them, but nothing uses them today.

The trap this section originally recorded — six services declaring a field named `eventPublisher`
typed `EntityEventPublisher` (`SwapAdminService`, `VirtualAccountAdminService`,
`RouteFusionVirtualAccountService`, `VictorVirtualAccountService`, `DepositBankMemoService`,
`LedgerAccountTransferService`), so that scope followed the **declared type** and never the field
name — is gone with the class. It is worth keeping in view anyway: those six are exactly the services
that kept publishing over HTTP after the first half of Phase 1, because a field named
`eventPublisher` reads as already-migrated. See §8.11.

#### Filter-policy limits are a test, not a hope

SNS caps a filter policy at **five leaf keys** and **150 total value combinations**, and policy
changes take up to 15 minutes to take effect (§4). A registry that generates a policy exceeding either
limit fails at deploy time in the best case and silently stops matching in the worst. The generator is
unit-tested against both limits.

### 8.5 The envelope

The two halves of §5, as a type:

```ts
type Envelope = {
  // internal only — never delivered to a customer
  id: string; // ULID, minted at publish
  customerId: string; // CFX tenant id, used for routing

  // delivered
  event: string;
  createdAt: Date;
  data: object;
} & (
  | { identityId: string; identityReferenceId: string }
  | { organizationId: string; organizationReferenceId: string }
  | Record<never, never> // scopes: []
);
```

A union, not four independent optionals — an envelope never carries both pairs. This mirrors
`EntityEvent`, and which arms are legal for a given event is exactly what `scopes` declares.

The `*ReferenceId` fields are filled in by the publisher when a call site supplies an `entityId`
(§8.6), using a resolver injected from `entity-services`.

Message attributes published alongside the JSON body, for routing:

| Attribute        | Value                     | Serves                                                |
| ---------------- | ------------------------- | ----------------------------------------------------- |
| `event`          | full event name           | bank-transaction, bank-internal-transfer, metcap-wire |
| `domain`         | first segment of the name | coarse fan-out; keeps policies under five keys        |
| `customerId`     | tenant id                 | card's `customerId $exist`                            |
| `identityId`     | present only when set     | reward's `identityId $exist OR organizationId $exist` |
| `organizationId` | present only when set     | ditto                                                 |

Only card's `data.status $exist` predicate needs `FilterPolicyScope: MessageBody`.

### 8.6 Publishers

`SnsEventPublisher` in `src/index.ts` owns the transport; `CustomerEventPublisher` types it against
the registry. Call sites do not change shape. The three behaviour changes are §2's.

**There is one publisher, not two.** `CustomerEventPublisher.publish` accepts either an envelope
whose entity references are already resolved, or one carrying a raw `entityId` — the shape
`EntityEventPublisher` used to require. That second arm is the whole of what the extra class did,
once the envelope models the references as a union.

The database lookup it needed arrives as an injected `EntityReferenceResolver`
(`(entityId: string) => Promise<string>`), implemented by
[`createEntityReferenceResolver`](../packages/entity-services/src/entity/entityReferenceResolver.ts).
That is what keeps `@cfxlabsinc/events` free of drizzle — every consumer Lambda imports this package,
and none of them has a reason to load a database client. Publishing with an `entityId` and no
resolver configured throws rather than silently dropping the reference.

The identity-vs-organization branch stays on the `id_` prefix, exactly as before.

**Dual-run ordering is load-bearing.** The publisher performs the legacy HTTP POST **first**, awaited,
byte-identical to today including leaving the response uninspected — and only then issues the
`PublishCommand`. A SNS failure can therefore never prevent the delivery Hookdeck is still performing.
The reverse order would let a new, unproven dependency break the live path. The legacy branch is
deleted wholesale in Phase 3, not left behind as a fallback.

**Payload size.** Making publish throw converts a 256 KB overflow from a theoretical concern into a
hard failure at a call site. The publisher checks serialised size before publishing, and on overflow
logs the event name and size and emits a metric before throwing. Dual-run then tells us whether any
real event approaches the limit while Hookdeck is still delivering it. This closes the first open
question in §9 for the public bus.

### 8.7 Pulumi

`packages/events/pulumi/index.ts` declares:

| Resource                          | Notes                                                                             |
| --------------------------------- | --------------------------------------------------------------------------------- |
| `aws.sns.Topic`                   | `cfx-events-{stack}`, standard (not FIFO)                                         |
| `aws.sqs.Queue`                   | bridge DLQ, target of the subscription's `RedrivePolicy`                          |
| `LambdaApp`                       | bridge: in-VPC (§8.7.3), inline `AssetArchive`; runtime/arch/memory from config   |
| `aws.sns.TopicSubscription`       | protocol `lambda`, plus the matching `aws.lambda.Permission`                      |
| _(no publish grant)_              | `sns:Publish` is granted by each publisher to itself — §8.7.1                     |
| `LogMetricFilter` + `MetricAlarm` | bridge error rate; `alarmTopicArn` from the cfx-ecs stack reference               |
| `DopplerProject`                  | `cfx-events` — holds `CFX_HOOKDECK_OUTPOST_API_KEY`, mirrored from `cfx-hookdeck` |

Secrets follow the established route in ownership and diverge from it in one place, deliberately.
`DopplerProject` owns the `cfx-events` project, this stack's config, and the sync to AWS Secrets
Manager at `/cfx/events/doppler`, the same as everywhere else. What differs is the read: the bridge
takes the key from the Doppler output **directly** rather than reading the synced bundle back with
`aws.secretsmanager.getSecretVersionOutput`, the way
[wire-email-handler/pulumi/index.ts](../packages/wire-email-handler/pulumi/index.ts) does.

That read is a data source on a secret this same program creates the sync for, so on a first apply
it resolves before the sync has ever run and the whole program dies with `couldn't find resource` —
which is exactly what every `events` preview failed on between the stack landing and this change.
Both paths set the same deploy-time environment variable, so the round-trip bought nothing and cost
a bootstrap.

The value itself originates in `cfx-hookdeck`, where it already backs the
`b2b-hookdeck-outpost-event-publish` transformation this bridge was ported from, and is mirrored
into `cfx-events` on every apply. That keeps `cfx-hookdeck` the single source of truth: a rotation
there propagates on the next deploy instead of leaving two hand-maintained copies to diverge.

`OUTPOST_BASE_URL` and `BRIDGE_DELIVERY_ENABLED` stay in stack config rather than Doppler. Neither
is a secret, both are read at deploy time either way, and every value moved into Doppler is one more
that has to exist before the stack can apply at all.

#### 8.7.1 Publishers grant themselves

The `cfx-events` stack owns the topic and grants nothing. Each publisher grants itself `sns:Publish`
on its own execution/task role, from its own stack — the same shape as its S3 and DynamoDB access:

| Publisher kind     | How                                                                                  |
| ------------------ | ------------------------------------------------------------------------------------ |
| Lambda APIs (10)   | `publishTopicArns: [customerEventsTopicArn]` on `LambdaApp`                          |
| Fargate (2)        | same argument on `attachEcsTaskBaseline`                                             |
| SST dashboards (2) | an inline `sns:Publish` statement in `transform.server`, beside the existing S3 ones |

`customerEventsTopicArn` ([baselineRoles.ts](../packages/pulumi-templates/src/baselineRoles.ts)) is
`` pulumi.interpolate`arn:aws:sns:${region}:${accountId}:cfx-events-${stack}` `` — interpolated, not
read through a `StackReference`, because the physical name is fixed by this stack and a reference
would impose a deploy ordering to learn a string both sides already know. Same reasoning as the
`cfx-idempotency-{stack}` table ARN in the Lambda baseline. An omitted or empty list grants nothing.

**The earlier design — this stack enumerating publisher role names from config — was removed.** It
inverted ownership, reaching into 15 roles it did not own, and it failed in four ways the
self-granting shape cannot:

- A publisher missing from the list failed at **runtime** with `AccessDenied`, not at deploy time.
- The `pr-<N>` dashboard stages SST mints per PR could never be covered by a pinned list.
- `temporal-lambda-worker` could not be named at all — its stack deliberately declares no Lambda and
  no IAM role until Phase 3, so there was nothing to attach to.
- Two more names to hand-maintain in two files, verifiable only by an `aws iam list-roles` lookup.

The rule the old design existed to satisfy still stands wherever a stack _does_ grant across a
boundary — use a standalone `aws.iam.Policy` + `aws.iam.RolePolicyAttachment`, **never
`Role.managedPolicyArns`, never `Role.inlinePolicies`**. Both take exclusive ownership of their set
and strip anything attached out of band.
[attachBaselinePolicies.ts:20-30](../packages/pulumi-templates/src/attachBaselinePolicies.ts) records
two silent prod outages caused by exactly this, on 2026-07-29 and 2026-07-30, which detached
`rds-db:connect` from seven services between them. The self-granting shape sidesteps the question:
the grant is an `aws.iam.RolePolicy` on a role the same stack declares.

#### 8.7.2 The SST-owned roles

`admin-dashboard` and `customer-dashboard` are the heaviest publishers — `admin-dashboard` alone
constructs `EventPublisher`, `AdminEventPublisher`, `EntityEventPublisher` and
`GirasolCardEventPublisher`. Both deploy to AWS Lambda via **SST + OpenNext**, not Vercel
([deploy-env.yml:235,385](../.github/workflows/deploy-env.yml)), so they do have IAM roles and need no
static credentials.

Their roles are created by SST, outside Pulumi — which is exactly why the grant belongs in
`transform.server` rather than in this stack. The pulumi stack does not run per-PR, so a grant made
there reaches only the `dev`/`prod` stages; a statement added in `transform.server` reaches every
`pr-<N>` preview too. The admin-dashboard's API-Gateway grant already sits there for this reason.

The topic stage follows the Valkey/Aurora convention already in both configs: `prod` for prod, `dev`
for dev and every preview.

#### 8.7.3 The bridge runs in the VPC

The bridge is built with `LambdaApp`, attached to the private subnets via the cfx-ecs stack reference
— the same construction every other Lambda in the repo uses. It reaches Outpost over the `0.0.0.0/0`
route to the transit gateway, already the path
[wire-email-handler](../packages/wire-email-handler/pulumi/index.ts) takes for its internal-events
webhook and the other Lambdas take for third-party APIs.

Running it outside the VPC would work — it needs no Aurora, no Valkey, and no VPC endpoint — but it
would make the bridge the only Lambda in the repo egressing outside the shared path, with no stable
source address and no place to inspect or constrain its traffic. That is a security-posture regression
bought for nothing. The classic argument against VPC-attached Lambdas no longer applies: Hyperplane
ENIs removed the multi-second cold-start penalty in 2019.

Two consequences:

- **`LambdaApp` rather than a bare `aws.lambda.Function`.** It requires `vpcId` and `privateSubnetIds`,
  and brings the security group, log group, alarms and the §8.7.1 IAM handling with it — none of which
  is worth re-implementing by hand. Inline code is still fine: `LambdaApp` takes `code`, so it receives
  the `AssetArchive` directly.
- **It still needs no Postgres role.** VPC attachment and Aurora access are separate concerns; the role
  in [workloadIdentities.ts](../packages/db/pulumi/workloadIdentities.ts) is required only by consumers
  that actually connect to the database.

#### 8.7.4 The SNS interface endpoint lives in another repo

**Prerequisite, and it is not in this repository.** VPC endpoints are owned by the AFT `cfx-networking`
module — `aft-account-customizations/workloads-{prod,nonprod}/terraform/modules/cfx-networking/vpc-endpoints.tf`,
where `secretsmanager` and `execute-api` are declared.

Without an endpoint, every `Publish` from an in-VPC service leaves over the `0.0.0.0/0` route to the
transit gateway and the egress VPC's NAT to reach the public SNS endpoint. Acceptable for a
best-effort call; much less so for this one, because §2 makes a failed publish throw, putting SNS on
the request path of ~200 call sites. It should not also depend on three hops of shared egress plumbing.

SNS has **no gateway endpoint** — interface is the only option, so unlike S3 and DynamoDB this one is
not free: roughly $0.01/hr per AZ plus $0.01/GB, about $22/month per VPC across the three `us-west-2`
AZs. Both VPCs together cost well under the ~$76/month §6 removed by dropping SQS.

Two things this does not cover: whether the SST-deployed dashboards' OpenNext Lambdas are VPC-attached
(if not, their publishes take the public path regardless — a latency and posture difference, not a
correctness one), and the `runs-on` CI account, which has its own `cfx-networking` copy but publishes
no events and is left alone deliberately.

### 8.8 The bridge Lambda

`pulumi/handlers/outpost-bridge.mjs`, ~35 lines, no dependencies — the direct successor to the 22-line
Hookdeck transformation it replaces
([b2b-hookdeck-outpost-event-publish.js](../packages/hookdeck/pulumi/transformations/b2b-hookdeck-outpost-event-publish.js)).
It exports two symbols:

- `toOutpostPayload(envelope)` — pure. Maps to `{ eligible_for_retry, tenant_id, topic, id, data }`,
  with `tenant_id` from `customerId`, `topic` from `event`, `id` from the ULID, and `data` built
  **without** `customerId` or `id`.
- `handler` — the `SNSHandler` wrapper: parse, map, log, POST with `Authorization: Bearer`.

Pulumi inlines the entire file as `AssetArchive({ "index.mjs": new StringAsset(...) })` with
`handler: "index.handler"`; the test imports `toOutpostPayload` directly, so the mapping is unit tested
despite living outside `src/`.

**The `.mjs` extension is deliberate.** A Lambda Node runtime treats `.mjs` as ESM unconditionally, so
the archive needs nothing else in it. A `.js` file would parse as CommonJS and die on the first
`import` before any user code runs, unless a `package.json` carrying `"type": "module"` rode along —
the same trap [nx-lambda/src/plugin.ts:129-141](../packages/nx-lambda/src/plugin.ts) documents for the
bundled lambdas.

**Shadow mode** is a single early return after the mapping and the log, gated on
`BRIDGE_DELIVERY_ENABLED`. Shadow still exercises parse, validate, map and log — only the POST is
skipped. That is what makes the shadow period a real rehearsal rather than a smoke test.

### 8.9 Composition roots

**15 packages** publish on the public bus — they carry `EVENTS_PUBLISH_URL` /
`EVENTS_PUBLISH_SIGNING_SECRET` and construct an `EventPublisher`:

`backoffice-api`, `card-api`, `deposit-api`, `identity-api`, `ledger-account-api`, `organization-api`,
`reward-api`, `swap-api`, `virtual-account-api`, `withdrawal-api`, `temporal-worker`,
`temporal-lambda-worker`, `internal-dashboard`, `admin-dashboard`, `customer-dashboard`.

`customer-slack-bot` is **not** one of them, despite matching a grep for `*_EVENTS_PUBLISH_URL`: it
carries only `INTERNAL_EVENTS_PUBLISH_URL` and constructs only `AdminEventPublisher`, so it moves
with the internal bus.

Each gains `EVENTS_TOPIC_ARN` and a dual-run flag; each keeps its existing vars until Phase 3 removes
them. `admin-dashboard/e2e/testEnv.ts` needs the same treatment.

The `EventPublisher` and `EntityEventPublisher` constructions both become `CustomerEventPublisher`
(§8.6); the latter additionally passes `resolveEntityReference`. `AdminEventPublisher` stays on HTTP
until the internal bus and `GirasolCardEventPublisher` stays on Hookdeck entirely (§2) — so
`admin-dashboard`, which constructs all four, changes two of them.

**Adding the variable is not the change; passing it to a publisher is.** Six of the fifteen —
`ledger-account-api`, `deposit-api`, `swap-api`, `withdrawal-api`, `virtual-account-api` and
`temporal-lambda-worker` — landed `EVENTS_TOPIC_ARN` in their env schema and a Doppler value behind
it, then constructed no SNS publisher at all. Every downstream check passes in that state: the
schema validates, the secret resolves, the deploy is green, `sns:Publish` is granted. The only
symptom is an empty topic, which is indistinguishable from low traffic. **The gate is
`resolveEntityReference` being wired at the root**, not the variable being declared — every call site
publishing with an `entityId` throws without it, so a root that reads the ARN but omits the resolver
is worse than one that was never touched.

### 8.10 Testing

There are **no publisher tests today**. Phase 1 adds four groups.

**Registry** — every event name unique and every declared schema round-trips; generated filter policies
stay within five leaf keys and 150 value combinations; the inventory reconciliation is itself a test,
so adding a publish call site without registering the event fails CI.

**Publisher** — envelope shape including `entityId` resolution into a reference pair; a
ULID `id` is minted and two publishes of the same event get different ids; attribute mapping, including
that `identityId`/`organizationId` are absent rather than empty; `createdAt` defaults to now and an
explicit value is preserved; **a publish failure propagates**; **dual-run ordering** — the legacy POST
resolves before `PublishCommand` is issued; an oversized payload throws after emitting the metric.

**Bridge** — `customerId` and `id` are absent from the mapped `data`; `tenant_id`, `topic` and `id` map
correctly; shadow mode returns without performing the POST.

**Consumer regression** — the registry-derived `TCustomerEvent` accepts a fixture for every event the
current union accepts; `isKnownCustomerEvent` still rejects unknown names, now from the module rather
than the route.

### 8.11 Rollout, and why the bridge stays in shadow

1. Add the SNS interface endpoint in AFT (§8.7.4) and confirm it applied. **This is sequenced first.**
2. Deploy the topic and the bridge to **dev** with `BRIDGE_DELIVERY_ENABLED=false`.
3. Enable dual-publish in dev. Compare SNS `NumberOfMessagesPublished` against the Hookdeck
   `cfx-publish` delivery count.
4. Repeat both in **prod**. Soak.

Phase 1 ends there; flipping the flag is Phase 3.

While dual-run is on, the Hookdeck `cfx-publish` connection is still transforming and POSTing to
Outpost. **If the bridge also POSTs, every customer receives every webhook twice** — and the two copies
will not dedupe, because Outpost dedupes on `id`, which is `x-hookdeck-eventid` on the old path and our
new ULID on the new one. Shadow mode is what makes dual-run safe rather than a customer-visible
incident.

#### What steps 3 and 4 actually took

Both environments reached dual-run on 2026-08-08, but step 3's reconciliation could not run for two
days because the SNS side was near-zero while nothing appeared wrong. The sequence is worth keeping,
because every check in it passed:

| Checked                               | Result  | What it actually proved                                        |
| ------------------------------------- | ------- | -------------------------------------------------------------- |
| `EVENTS_TOPIC_ARN` in Doppler         | set     | nothing about any running process                              |
| The synced Secrets Manager bundle     | set     | ditto                                                          |
| `sns:Publish` on every publisher role | granted | nothing about whether anything calls `Publish`                 |
| Pulumi CD                             | green   | the topic exists                                               |
| `NumberOfMessagesPublished`           | ~0      | ambiguous — reads identically to low traffic                   |
| **Bridge log group: 0 stored bytes**  | —       | **the bridge had never been invoked, once, in either account** |

Only the last one is unambiguous, and it is the cheapest of the six. A metric at zero can always be
explained away as sampling lag or a quiet hour; a log group that has never accepted a byte cannot.
**Reach for the signal that has no benign reading first.**

Two distinct causes sat behind it, and the first masked the second:

- **Stale process env.** A Doppler value does not reach a running service. Lambdas build env once per
  execution environment under a top-level `await`, and provisioned concurrency keeps those
  environments from recycling on traffic alone; Fargate enumerates bundle keys into the _task
  definition_ at Pulumi apply, so a new key needs a deploy, not a restart. A forced redeploy of all
  fourteen services fixed this — and the topic stayed empty, which is what exposed the real cause.
- **Half the publishers were never migrated.** §8.9 has the detail. Tracing one silent event
  (`redemption.transfer.statusUpdated`) to `LedgerAccountTransferService` found it holding
  `EntityEventPublisher`, and from there the ~30 services and six roots that the first half of Phase
  1 had left behind.

#### Phase 3 shipped 2026-08-09 — the bridge is the only path to Outpost

The cutover was done directly rather than staged behind a flag, on the strength of the
step-4 reconciliation above. In one change:

- The `cfx-publish` → Outpost connection and its `b2b-hookdeck-outpost-event-publish`
  transformation are **deleted** from `packages/hookdeck/pulumi`, along with their adoption
  ids in `imports.ts`. Pulumi removes them from the Hookdeck account on apply.
- `BRIDGE_DELIVERY_ENABLED` is **gone** — the handler, the Lambda environment, both stack
  configs and the shadow-mode tests. There is no non-delivering mode left, so a bridge
  failure is now a customer webhook that does not arrive.

**Publisher dual-run stays.** `EVENTS_DUAL_RUN` and `EVENTS_PUBLISH_URL` are untouched across
the 32 files that carry them: `cfx-publish` still feeds `backoffice-api-customer`,
`b2b-card-api`, `b2b-reward-api` and `cfx-publish-to-attio-task`, and those consumers move in
Phase 4. The source and the HTTP routes go in Phase 5.

`CFX_HOOKDECK_OUTPOST_API_KEY` stays in Doppler `cfx-hookdeck/<stack>` even though nothing in
that stack reads it now — `packages/events/pulumi` reads it from there and mirrors it into
the bridge, which keeps one source of truth for the rotation. `secrets.ts` carries a note.

**Rollback is a re-provision, not a revert.** Restoring the connection means re-creating a
Hookdeck destination and connection and re-adopting them, because the import ids are gone
with the resources. The forward fix — a bridge that POSTs correctly — is the cheaper path.

#### Before Phase 3

`BRIDGE_DELIVERY_ENABLED` is a stack-config key (`cfx-events:bridgeDeliveryEnabled`) in both
environments, read with `requireBoolean` so a malformed value fails the deploy rather than silently
meaning `false`. The cutover is therefore a config edit, not a code change — but it is **not** a
config edit _alone_: setting it to `true` without disabling the Hookdeck `cfx-publish` connection in
the same change is the double-delivery incident described above. Dev first, then prod.

The reconciliation in steps 3 and 4 only became meaningful once the swap completed on 2026-08-08, so
the soak window starts there and not at the topic's creation.

**Step 4 reconciles exactly.** Prod, 2026-08-08, hourly. The comparison is SNS
`NumberOfMessagesPublished` on `cfx-events-prod` against **inbound requests to the `cfx-publish`
source** (`src_8l8mmcx2asGk`) — not Hookdeck _events_, which count once per matching connection and
would overcount fivefold. Requests happen to equal events on the `cfx-hookdeck-outpost` connection
specifically, because that connection carries a transform and a retry rule but **no filter**, so
every request produces exactly one Outpost delivery. The other four connections on the source all
filter.

| UTC   | `cfx-publish` requests | SNS published |                                |
| ----- | ---------------------- | ------------- | ------------------------------ |
| 20:00 | 167                    | 6             | pre-swap                       |
| 21:00 | 68                     | 2             | pre-swap                       |
| 22:00 | 111                    | 79            | prod deploy lands mid-hour     |
| 23:00 | 90                     | 90            | **first complete hour, exact** |
| 00:00 | 28                     | 28            | **exact**                      |

118 events across two complete hours, zero drift. Bridge invocations equal publishes hour-for-hour
over the same window, with zero Lambda errors and an empty subscription DLQ. The pre-swap rows are
the §8.9 gap measured directly: 2–6 events an hour reaching SNS against 70–170 reaching Hookdeck was
never low traffic, it was the handful of publishers that had actually been migrated.

That is the §8.11 exit criterion met. What remains before the flag flip is a longer window covering
a full daily cycle — the two reconciled hours are the quiet end of the day, and the busy hours
(13:00–15:00 UTC, 118–127/hr) have not yet been observed post-swap.

### 8.12 Risks

| Risk                                                                | Mitigation                                                                                        |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Registry inventory misses a published-but-unconsumed event          | Built from publish call sites and the consumer union, reconciled explicitly; enforced by a test   |
| SST role names are not a stable interface                           | Resolve by lookup once, pin in stack config, do not derive                                        |
| A filter policy exceeds SNS's five-key limit as consumers are added | Generator is tested against the limit now, before any consumer depends on it                      |
| An event exceeds 256 KB                                             | Size check emits a metric before throwing; measured during dual-run while Hookdeck still delivers |
| A publish throws where today it silently no-ops, surfacing as a 500 | Dual-run orders the legacy POST first; the new failure mode is observed in dev before prod        |
| 15 composition roots is a wide, error-prone edit                    | Mechanical and type-checked; `nx affected -t typecheck` is the gate                               |
| The SNS VPC endpoint lands via a separate repo and pipeline         | Sequenced first, in AFT, and confirmed applied before the publisher swap reaches either env       |

### 8.13 The customer Slack consumer, Phase 4

The first of Phase 4's three consumers is the customer-event Slack fan-out. It moves to
`customer-slack-bot`, which already owns the Slack bot infrastructure — the same OAuth
installations and bot tokens serve the internal CFX workspace and customer workspaces alike.

**Landed** (`slack-customer-events-package`):

- `USD_FORMATTER` / `formatSlackTable` → `@cfxlabsinc/slack-services`. They were in
  backoffice-api's `controllers/slack/internal.ts` and are used by both the internal-event
  consumers (which stay) and the customer-event ones (which move).
- The 13 customer consumers + their 10 payment-instrument renderers →
  `@cfxlabsinc/slack-customer-events`, a new leaf package.
- `CustomerEventSlackConsumer` now takes `AnyCustomerEvent` from the registry rather than
  `Static<typeof TCustomerEvent>` from backoffice-api's route module, and `MOVEUSD_APP_URL`
  is injected instead of read from that app's `env`.

**Why a library and not straight into `customer-slack-bot`.** backoffice-api keeps receiving
the Hookdeck deliveries until the cutover, so moving the code into the app that will
eventually own it would stop Slack messages for the whole shadow soak. A leaf package keeps
**one** copy — the drift that let `EntityEventPublisher` survive Phase 1 is the thing to
avoid — and cannot cycle: `onboarding-services` already imports `slack-services`, so folding
16 service packages into that one was not an option.

**The graph is built in `customer-slack-bot`'s composition root, not behind a factory.** An
earlier draft of this section proposed `createCustomerEventSlackConsumer({ db, valkeyClient, … })`
in `slack-customer-events`, so that both apps could call one definition. That was wrong once the
cutover stopped being staged: backoffice-api's arm is deleted in the same change, so there is never
a second caller, and a library constructing twenty services would owe every one of their
configuration values a parameter. The graph is ordinary composition-root wiring —
`src/customerEvents.ts`, ~20 services bottoming out in `db`, `valkeyClient`, `ablyRestClient`, an
SNS `eventPublisher`, `girasolClient`, and the `productQuoteService` /
`solanaAccountBalanceQueryService` / `victorAccountQueryService` trio behind
`virtualAccountAdminQueryService`.

**No shadow mode either, for the same reason.** An earlier draft called a `SLACK_DELIVERY_ENABLED`
flag mandatory. Double-posting is only possible while both arms exist; deleting backoffice-api's in
the same change removes the window rather than guarding it. The bridge's case was different — its
delivery path was unproven and customer-visible. Here the SNS path is already proven by §8.11's
step-4 reconciliation, and the consumer is internal. The exposure that remains is the minutes
between the two stacks' deploys: **land `backoffice-api` before `customer-slack-bot`** and that
window is a short gap in Slack messages rather than duplicates.

Hookdeck's `backoffice-api-customer` connection stays up through this change — it also feeds
`CustomerEventAttioConsumer`, so `CustomerEventConsumer` keeps its route and its HMAC and loses only
its Slack arm. That turned out to be brief: §8.16 deletes the Attio arm, and the route and
connection go with it.

**What Phase 4 ships:**

| Where                                      | What                                                                                                                                                            |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customer-slack-bot/src/customerEvents.ts` | The ~20-service graph and the `CustomerEventSlackConsumer`                                                                                                      |
| `customer-slack-bot/src/db.ts`             | The pool, extracted from `main.ts` so both handlers share one                                                                                                   |
| `customer-slack-bot/src/lambda.ts`         | `export const snsHandler` beside the existing `handler`                                                                                                         |
| `customer-slack-bot/src/env.ts`            | `VALKEY_*`, `ABLY_API_KEY`, `MOVEUSD_APP_URL`, `EVENTS_TOPIC_ARN`, `SLACK_BOT_*`, `GIRASOL_*` — hand-set in Doppler, not mirrored                               |
| `customer-slack-bot/pulumi/index.ts`       | A second `LambdaApp`, the subscription, its DLQ + queue policy, the invoke permission                                                                           |
| `db/pulumi/workloadIdentities.ts`          | `customer_slack_events_rw` **and a Valkey user** — the OAuth function had Aurora only                                                                           |
| `backoffice-api`                           | Slack arm deleted from `CustomerEventConsumer`; ~110 lines and 5 orphaned services out of `main.ts`; `MOVEUSD_APP_URL` and all four `GIRASOL_*` out of `env.ts` |

Three details worth carrying forward:

- **One bundle, two functions.** `@cfxlabsinc/nx-lambda` infers exactly one esbuild entry point per
  project (`src/lambda.ts`), so "a second esbuild target" was not available without changing the
  plugin. Two exports off one archive gets there instead — `handler` and `lambda.snsHandler` — and
  the two functions share a Doppler bundle, a Sentry project and a database while keeping separate
  execution roles, log groups and alarms. The cost is that the OAuth function loads the consumer
  graph at cold start, and that `env.ts` validates the consumer's keys in both.
- **`VICTOR_*` is not needed.** The earlier draft listed it. `VictorAccountQueryService` takes only
  `{ db, valkeyClient }` — no HTTP client — so no Victor credential reaches this app.
- **The subscription carries no filter policy**, for the reason the bridge carries none: enumerating
  the ~20 routed events means an event added to the consumer and forgotten in the policy is silently
  undelivered. Subscribing to everything fails safe, and the consumer no-ops on the rest.

**Verify the deploy the §8.11 way.** The unambiguous signal is the new function's log group: zero
stored bytes means it has never been invoked, which no amount of "quiet hour" explains away. Check
that before the DLQ depth or any metric.

### 8.14 The reward consumer, Phase 4

`reward-api`'s `/v1/reward/events/b2b` route becomes a second Lambda function in the same package —
`lambda.snsHandler`, its own `LambdaApp`, its own subscription and DLQ — exactly the shape §8.13
established. Three things differ, and they are the interesting part.

**Idempotency is load-bearing here, and the key changes hands.** The route passed
`x-hookdeck-eventid` into `RewardService.create` as its idempotency key, chosen because Hookdeck
holds that header stable across delivery attempts. The envelope's `id` — the ULID
`SnsEventPublisher` mints at publish — has the same property under SNS redelivery, so it is a clean
substitution. `snsHandler` throws if an envelope arrives without one rather than falling back to
something per-delivery, which would silently turn a retry into a second reward.

**The swap is one stack, but it is not atomic, and the two keys do not overlap.** Unlike the Slack
cutover — where the old and new arms live in two different Pulumi stacks — the `b2b-reward-api`
Hookdeck connection is declared in `reward-api`'s own stack, so a single `pulumi up` deletes it and
creates the subscription. The window is therefore seconds rather than minutes. It is still a window,
and an event delivered down both paths inside it carries a Hookdeck event id on one and a ULID on
the other, so the two do not dedupe against each other and the reward is granted twice. Rewards are
money: apply this during a quiet period. What bounds the exposure is that rewards only fire for
events in the per-customer configured set, which is small.

**The Hookdeck `bodyFilter` had no SNS equivalent, so it became code.** The connection filtered to
envelopes carrying `identityId` or `organizationId`, since a reward has to be credited to somebody.
SNS ANDs the keys in a filter policy, and that predicate is an OR across two of them, so it cannot
be expressed as a policy at all. The check now sits at the top of `B2bEventConsumer.consume`. This
is worth remembering for the remaining consumers: **a Hookdeck body filter does not always port to a
filter policy**, and the fallback is a guard in the consumer, not a cleverer policy.

`reward_events_rw` and a `reward-events` Valkey user join `customer_slack_events_rw` in
[workloadIdentities.ts](../packages/db/pulumi/workloadIdentities.ts), under a shared "Phase 4 event
consumers" heading — both are second functions inside an existing package, so neither fits the
uniform list, which is keyed on package name.

`HOOKDECK_HMAC_SECRET` leaves `reward-api`'s env. `HOOKDECK_DEMO_HMAC_SECRET` stays: the demo route
is fed by a **different Hookdeck account** and is open question #2 below, not part of the public bus.

### 8.15 The card consumer, Phase 4 — and why nothing filters

`card-api`'s `/v1/card/events/b2b` becomes `lambda.snsHandler` on the same bundle, its own
`LambdaApp`, subscription and DLQ. Two things are unlike the other two consumers.

**Its Hookdeck connection is not in IaC.** `b2b-card-api` exists in the live Hookdeck account and no
Pulumi program declares it — the divergence recorded elsewhere in `packages/hookdeck`. So no
`pulumi up` removes it: **deleting it is a manual step in the console**, after the subscription is
confirmed working.

That ordering is safe here in a way it would not be for rewards. This consumer's effect is a balance
**sync** — it reads the card account's state from Girasol and writes what it finds — so both paths
running briefly costs a duplicate vendor call and converges on the same state. There is no
double-credit failure mode to race, which is also why `snsHandler` carries no idempotency key where
the reward one throws without it. **Match the cutover's tightness to the consumer's failure mode,
not to a house style.**

**It is the consumer that looks like it should filter, and cannot.** §8.7 anticipated filter
policies for "the Phase 4 consumers that DO filter (reward, card, the bank handlers)". In the event
none of the three shipped one:

| Consumer       | Why not                                                                                                   |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| customer Slack | Would have to enumerate ~20 routed events; a forgotten addition is a silently missing message             |
| reward         | Which events pay is per-customer **database** configuration — the predicate does not exist at deploy time |
| card           | Its four events each admit a **different** settled status, and a policy ANDs its keys                     |

The card case is the sharpest. `{event: [4 names], data: {status: [4 statuses]}}` reads like the
right policy and is not: AND across keys admits the whole cross-product, so a `DEPOSITED` withdrawal
and a `RETURNED` transfer both pass. A policy on `event` alone is expressible, but then the
allow-list lives in Pulumi _and_ in `isAllowedEvent`, and the two drift silently in the direction of
a missed sync.

So the standing guidance for the remaining phases is the inverse of §8.7's expectation: **subscribe
to everything and guard in the consumer, unless the predicate is a single attribute that cannot
grow.** The bus carries ~120 events an hour; three consumers each seeing all of them is not a cost
worth a silent-drop risk. `filterPolicyForEvents` and `assertFilterPolicyWithinLimits` stay in
`@cfxlabsinc/events` for a consumer that genuinely earns one.

`card_events_rw` and a `card-events` Valkey user join the Phase 4 block in
[workloadIdentities.ts](../packages/db/pulumi/workloadIdentities.ts). `HOOKDECK_HMAC_SECRET` stays
in `card-api`'s env — the Girasol route is an **inbound vendor** webhook, not the CFX-published bus,
and does not move in this migration.

### 8.16 The Attio consumer, inlined rather than migrated

`CustomerEventAttioConsumer` was the other arm of `/v1/backoffice/events/customer`. It handled
exactly one event — `customer.terms.statusUpdated` — and wrote terms-of-service acceptance onto the
customer's Attio CRM record. **Sales reads that field**, so the behaviour has to survive; what does
not have to survive is the transport.

**The mutation moved into `CustomerTermsService.upsert`, the writer that produced the event in the
first place.** A Hookdeck connection, a route, an HMAC secret, a consumer class and an event hop
existed to carry one PATCH to a value the writer already had in hand. Inlining it deletes all of
that and costs one method. It also deletes a query: the consumer did a `customerService.get()` to
find `attioId`, where the writer's own `UPDATE … RETURNING` can just return it.

**This is the general shape worth noticing.** An event consumer earns its transport when the
consumer is a different service, a different deploy, or fans out to many readers. A single-reader
side effect on the same row the writer just wrote is not that — it is a method call wearing a
webhook costume. Check for the others as the remaining phases move consumers: anything whose
consumer graph bottoms out in the same package as its publisher is a candidate.

**Three guards moved with it**, all of them silent if they regress, so they are covered by tests in
`customerTermsCache.test.ts`:

| Guard                     | Why                                                                                                                               |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| empty `attioId`           | `customer.attio_id` is NOT NULL, so "no CRM record" is `""`; PATCHing `/records/` 404s                                            |
| no `MOVEUSD_TERMS_OF_USE` | `upsert` also writes cookie / e-sign / privacy policies, and only the terms of use has a field                                    |
| `BYPASSED`                | an operator **waived** the requirement — mapping it through `=== "ACCEPTED"` writes `false` and tells sales the customer declined |

**It runs last, and it throws.** Ordering it after the write, the publish and the cache bust means a
CRM outage cannot cost any of those. Throwing rather than warning is the deliberate trade for losing
Hookdeck's retries: the acceptance is already durable, `upsert` is safe to repeat, and a swallowed
failure would silently drift the record sales depends on.

**`AttioClient` itself stays**, and gains a second caller. `packages/clients/src/attio` was already
live for admin-dashboard's `/customers/new` company selector. backoffice-api's `ATTIO_*` keys are
removed, that app having had no other Attio caller — but **`customer-dashboard` now needs them**,
because `/terms/accept` is where customers actually accept and it had no Attio config before. Both
keys must be present in the `cfx-customer-dashboard` Doppler project (dev and prod) before deploy;
`env.ts` validates them, so a miss fails at cold start rather than silently skipping the sync.

**The route goes too, and with it the connection.** With both arms gone,
`configureCustomerEventConsumerRoute` had no consumers, so `CustomerEventConsumer.ts` — route,
`TCustomerEvent` wrapper and all — is deleted, and the `backoffice-api-customer` Hookdeck connection
with it. Its adoption ids are kept as a comment in
[backoffice-api/pulumi/hookdeck.ts](../packages/backoffice-api/pulumi/hookdeck.ts) rather than
dropped: as with the Outpost connection in §8.11, rollback means re-creating and re-adopting a
destination and connection, so the ids are the expensive part to lose.

**This clears the last CFX-owned consumer off `cfx-publish`.** What remains on that source is
`cfx-publish-to-attio-task` — unmanaged, owned outside engineering, zero deliveries in 90 days
(open question #4). Answering that question is now the only thing standing between here and Phase
5's deletion of the source itself.

### 8.17 The public bus as built

§4 is the design. This is what phases 3 and 4 actually delivered — worth keeping beside it, because
three things came out differently: `λ backoffice customer` split rather than moved, the Attio arm
became a direct write rather than a consumer, and **no consumer ships a filter policy**.

```mermaid
flowchart LR
    subgraph pubs["publishers · ~200 call sites, 15 composition roots"]
        CEP["CustomerEventPublisher"]
    end

    TOPIC["SNS<br/>cfx-events-{env}"]

    CEP -- Publish --> TOPIC

    subgraph cons["consumers · subscribe to everything, guard in code"]
        BRG["λ events-bridge<br/>packages/events"]
        SLK["λ customer-slack-events<br/>customer-slack-bot bundle"]
        RWD["λ reward-events<br/>reward-api bundle"]
        CRD["λ card-events<br/>card-api bundle"]
    end

    TOPIC --> BRG & SLK & RWD & CRD
    BRG -. "on failure" .-> DLQ1["DLQ"]
    SLK -. "on failure" .-> DLQ2["DLQ"]
    RWD -. "on failure" .-> DLQ3["DLQ"]
    CRD -. "on failure" .-> DLQ4["DLQ"]

    BRG -- "POST /publish" --> OP["Hookdeck Outpost"]
    OP --> CUST["customer endpoints"]
    SLK --> WS["customer Slack workspaces"]
    RWD --> RS["RewardService.create<br/>idempotent on envelope id"]
    CRD --> GIR["Girasol balance sync"]

    subgraph inline["no event hop at all"]
        CTS["CustomerTermsService.upsert"] -- PATCH --> ATT["Attio CRM"]
    end

    subgraph legacy["Hookdeck cfx-publish — all CFX consumers gone"]
        REM["cfx-publish-to-attio-task<br/>unmanaged · 0 deliveries in 90d<br/>open question #4"]
    end

    subgraph unchanged["unchanged — inbound vendor ingress"]
        VEND["AiPrise · Victor · Metcap<br/>RouteFusion · Quiltt · Utila · Girasol"] --> HD["Hookdeck Gateway"]
        HD --> APIS["backoffice-api · identity-api<br/>card-api · dashboards"]
    end
```

Two properties of that picture are worth stating outright, because both invert an earlier
assumption in this document:

1. **Every consumer subscribes to the whole topic.** §8.15 has the reasoning per consumer. The
   filter-policy helpers stay in `@cfxlabsinc/events` for a consumer that earns one.
2. **`inline` is not a consumer.** It is the same process that publishes the event, writing to Attio
   directly. §8.16 explains when that is the right shape — and it is worth checking for as phases 6
   through 8 move the internal bus.

### 8.18 Phase 5 — the dual-run arm deleted, not switched off

Phase 5 was specified as "delete `cfx-publish` + its HTTP routes". In the event it is better described
as **deleting the conditional**: `EVENTS_DUAL_RUN` is not flipped to `false`, it stops existing.

`SnsEventPublisher` loses `LegacyHttpTarget`, the `legacy` constructor arm and `publishLegacy` — the
HMAC-signed POST that mirrored every event to Hookdeck. `topicArn` becomes **required**: through
Phase 1 an absent topic legitimately meant "legacy only", and now it means "delivers nowhere", so it
must fail at construction rather than at the first silently-dropped event.

Across 32 files, three keys go with it: `EVENTS_PUBLISH_URL`, `EVENTS_PUBLISH_SIGNING_SECRET` and
`EVENTS_DUAL_RUN`. `EVENTS_TOPIC_ARN` changes from `T.Optional` to required in the same pass, for the
same reason.

**Two traps in that sweep, both worth remembering:**

1. **`INTERNAL_EVENTS_PUBLISH_URL` is a different key.** §7 flagged this already: a grep for
   `EVENTS_PUBLISH_URL` matches it. It belongs to the internal bus and must not be touched until
   Phase 6. Anchoring every pattern on a key boundary (`\bEVENTS_PUBLISH_URL`) is what keeps them
   apart — `INTERNAL_` has a word character before `E`, so `\b` will not match there.
2. **`EVENTS_PUBLISH_SIGNING_SECRET` survives in `card-api`, and only there.** It signs the OUTBOUND
   card-balance push to Girasol, whose Hookdeck source (`girasol-card-events-outbound`) verifies with
   `cfxHmac` — the same `publishSigningKey` `cfx-publish` used. That sharing is a historical accident,
   not a shared concern, but renaming the key means re-keying a live source, so it keeps its old name
   until `GIRASOL_EVENTS_PUBLISH_URL` is repointed. A blanket delete breaks card balance pushes with
   no test to catch it — the typecheck catches the missing env key, which is the only reason this
   surfaced.

**What happens to the Hookdeck objects.** The `cfx-publish` source declaration is removed, along with
the dev-only `cfx-publish-local`, `cfx-publish-test` and the `b2b-local-cli` handler that existed so
`hookdeck listen` could receive locally-published customer events. Because the prod source was adopted
with `protect: true, retainOnDelete: true`, **removing the declaration orphans it rather than deleting
it** — it survives in the account with nothing publishing to it and nothing subscribed, and has to be
deleted by hand, exactly as the Grafana OnCall objects were. `cfxPublish` also leaves
`HookdeckSourceKey`, so a handler declared against a source that no longer exists is a typecheck
failure rather than an apply failure.

`cfxHmac` stays: `b2b-internal` and `girasol-card-events-outbound` both still verify with it.

**What this does not touch.** The internal bus. `b2b-internal`, `AdminEventPublisher` and
`INTERNAL_EVENTS_PUBLISH_*` are phases 6 through 9, and the three auto-reconciliation handlers behind
them are the reason that ordering exists (§6.1).

### 8.19 The account cleanup, and why prod needed one but dev did not

Phases 3–5 removed the `cfx-publish` source, its four connections and their destinations from IaC.
**Prod kept them all alive anyway; dev deleted them by itself.** That asymmetry is the single most
useful thing to carry into phases 6–9.

Prod **adopts** its Hookdeck objects — `{ import: <id>, protect: true, retainOnDelete: true }` — so
removing a declaration orphans the live object rather than deleting it. Dev **creates** its objects
from scratch, with neither flag, so removing a declaration deletes them. Every "Pulumi removes them
from the account on apply" note written during this migration was therefore true of dev and false of
prod.

Deleted by hand on 2026-08-10, after prod was verified on the SNS path:

| Account | Deleted                                                                                                                     |
| ------- | --------------------------------------------------------------------------------------------------------------------------- |
| prod    | source `cfx-publish`; destinations `cfx-hookdeck-outpost`, `backoffice-api-customer`, `b2b-reward-api`, `b2b-card-api-prod` |
| prod    | transformations `b2b-hookdeck-outpost-event-publish` and its `-dev` twin                                                    |
| dev     | the seven Phase 0 per-partner destinations (§9 q3)                                                                          |

The four prod **connections** were already gone by then. Their `updated_at` stamps sat minutes ahead
of the requests that referenced them, which is worth knowing when reading Hookdeck history: **an
event keeps its `webhook_id` after the connection is deleted**, so a recent event showing a
`SUCCESSFUL` delivery through a connection is not evidence that the connection still exists. Query
the connection endpoint — `410 GONE` — rather than inferring from event history.

Verification that the source was safe to delete was that its newest inbound request predated the
Phase 5 rollout, with none after: publishers had stopped, and no connection remained to fan out to.

### 8.20 Phases 6–9 in detail: the internal bus, as one wave

This is the build sheet for `b2b-internal`, the counterpart to §8 and the part to review before
implementation starts. §7 listed phases 6 through 9 as four shippable steps. **Decided: no dual-run,
so they are one wave** — and the sequencing below is what keeps that from being a cliff.

#### 8.20.1 What a hard swap forces, and what recovers the safety

Phase 1's safety net does not exist any more. §8.18 deleted `LegacyHttpTarget`, `publishLegacy` and
the `legacy` constructor arm, and made `topicArn` required. Rebuilding them for the internal bus was
the alternative considered and rejected; the consequence of rejecting it is the whole shape of this
section.

**Dual-run bought exactly one thing, and it was not delivery.** It bought a _count_ — SNS
`NumberOfMessagesPublished` against Hookdeck's inbound requests, which is what caught the §8.9
half-migration after two days of every other check passing. Without a second path there is no count
to compare, so the migration needs a different way to be unable to half-happen.

**It gets one, from a property the public bus never had: both paths end in the same consumer.**
On the public bus the old and new consumers were different code in different packages, so the two
arms had to be deleted together (§8.13) or customers got everything twice. Here all four internal
consumers are `backoffice-api` classes reached from one route each, and the SNS handlers reach those
same classes through the same extracted graph. An event travels the HTTP path or the SNS path,
terminates in the same `consume()` either way, and never both.

So **the HTTP routes stay deployed through the entire swap and are deleted last**. A composition
root that has migrated publishes to SNS; one that has not still POSTs to Hookdeck; both are
consumed. A half-migrated publisher set stops being the invisible failure of §8.9 and becomes a
supported intermediate state — which is what makes eight independent package deploys safe where one
simultaneous cutover would not be.

| Step | What lands                                                                                       | Behaviour change                          |
| ---- | ------------------------------------------------------------------------------------------------ | ----------------------------------------- |
| A    | Collapse the withdrawal double-publish (§8.20.5)                                                 | Fixes a live bug; independent of the bus  |
| B    | `@cfxlabsinc/events/internal`, the topic, `internalEventsTopicArn`, the publish grants           | **None** — nothing constructs it yet      |
| C    | `backoffice-api` consumer-graph extraction, two SNS handlers, subscriptions, DLQs, DB identities | None — the topic is still empty           |
| D    | The eight composition roots swap publisher, one package at a time                                | Each package's events move path on deploy |
| E    | Delete the four routes, `AdminEventPublisher`, the env keys, the Hookdeck handlers and source    | The HTTP path stops existing              |

Step C before step D is the load-bearing order, and it is the inverse of §8.13's. There the new
consumer landed _after_ the old arm was deleted, because a live old arm meant duplicates. Here a
live old arm means coverage, and a consumer deployed before any publisher can reach it is an empty
log group — the §8.11 signal, available as a positive check before it matters.

#### 8.20.2 The consumers stay in `backoffice-api`

§6 said the Slack fan-out moves into `customer-slack-bot`. **It does not, and the reason §8.13 moved
the customer one does not apply here.**

That move was bought by the shadow soak: `backoffice-api` had to keep receiving Hookdeck deliveries
while the SNS path proved out, so the code could not live in the app that would eventually own it
without stopping Slack messages for the whole soak. A hard swap has no soak. What remains is the
cost — eleven sub-consumers and roughly eleven admin query services relocated into a package whose
Slack client is the _customer_ bot, not CFX's own workspace.

`backoffice-api` is already a Lambda app with a bundle, a Doppler project, an Aurora role and
`publishTopicArns` wired. The consumers change trigger, not address.

**Two functions on that bundle, not one and not four.** The four Hookdeck connections exist because
Hookdeck needs one connection per filter; that constraint is gone. What survives it is a difference
in failure semantics:

| Function                       | Consumes                                          | Why it is its own function                                                     |
| ------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------ |
| `lambda.internalSlackHandler`  | the 11 `InternalEventSlackConsumer` sub-consumers | Notification path. A Slack outage must not retry a reconciliation for 23 days. |
| `lambda.reconciliationHandler` | bank-transaction, bank-internal-transfer          | Money path. Its own memory, DLQ, alarm and idempotency claim.                  |

One bundle, two exports — the shape §8.13 established for `customer-slack-bot` and the only one
available, since `nx-lambda` infers exactly one esbuild entry point per project.

**This is where Phase 2 finally happens.** §6 specified `src/consumerGraph.ts` as a pure refactor
shipping before any Lambda existed; it never shipped, and `main.ts` is still 1127 lines of flat
module-scope construction with `export default httpServer` and nothing exported for reuse. The
extraction is now a prerequisite rather than a nicety, and it is scoped to the internal consumers
only — `main.ts` and both handlers import the same graph, which is what guarantees the two paths
cannot diverge while both are live.

#### 8.20.3 The package: a fourth subpath

`@cfxlabsinc/events/internal`, beside `./sns` and `./customer`. Note the built package has no `.`
export — §8.2's "`.` holds bus-agnostic primitives" became `./sns` in the event.

- **Its own envelope.** `{id, event, createdAt, data}` — no `customerId`, no entity references.
  Internal events have no tenant: they route to CFX's own Slack workspace and to reconciliation
  handlers. Message attributes are `event` and `domain` only.
- **The shared primitives are reused unchanged** — `mintEventId`, `eventDomain`,
  `assertWithinSnsLimit`, `filterPolicyForEvents`, `assertFilterPolicyWithinLimits`. The one
  exception is the registry helper: the internal bus defines its own
  `defineInternalRegistry`, a near-copy of `defineRegistry`, because `defineRegistry` constrains
  its values to `EventDefinition` — which requires `scopes`, and there are no entity references on
  this bus for a scope to name.
- **`SnsEventPublisher` is generalised.** Today `PublishableEvent` is
  `Omit<Envelope, "id" | "createdAt">`, hard-typed to the customer envelope, and
  `messageAttributesFor` emits `customerId` as a required attribute. It becomes generic over the
  envelope and its attribute mapper. This is the one edit to shipped, working code in the whole
  wave; the existing `SnsEventPublisher.test.ts` covers it.
- **`AdminEventPublisher` is deleted, not wrapped.** Same reasoning that deleted
  `EntityEventPublisher` in §8.6. Its two extension points are dead: no construction site passes
  `defaultHeaders` and none of the 32 publish sites passes `opts`.

Rejected: making `customerId` optional on the shared envelope. It is the Outpost `tenant_id`, and
§5's whole point is that it must be present to route and absent on delivery — an optional field
there invites a customer event that routes nowhere.

#### 8.20.4 The registry describes 26 names, and four of them 400 today

The §8.4 inventory discipline applies: assemble from publish call sites _and_ the consumer union,
and reconcile the difference explicitly. **32 publish call sites across 11 packages emit 26 distinct
event names. `TInternalAdminEvent` declares 22 arms.** Unlike the customer bus, the gap runs both
ways.

26 is the pre-step-A count: it includes the two `redemption.wallet.withdrawal.*` aliases that step A
deletes in favour of the `ledger.account.withdrawal.*` names, so the shipped registry holds 24.

| Kind                                          | Count | Names                                                                                                                                                      |
| --------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Published, not declared                       | 4     | `ledger.account.withdrawal.created`, `ledger.account.withdrawal.statusUpdated`, `slack.slackBotInstallation.created`, `slack.slackBotInstallation.deleted` |
| Declared, not published                       | 0     | —                                                                                                                                                          |
| Published and declared, dispatched to nothing | 2     | `slack.customerSlackBotInstallation.created`, `slack.customerSlackBotInstallation.deleted`                                                                 |

**The first row is not a silent drop — it is a 400.** The `internal` Hookdeck handler carries no
filter, so all four reach `POST /v1/backoffice/events/internal`, fail Fastify validation against the
discriminated union, and return 400 to Hookdeck, which then retries each five times an hour apart.
That is live error volume today, invisible because nothing alerts on it.

The third row confirms §6's note, with a correction to its framing: those two events are not
declared-for-nothing. Three live publish sites emit them, reachable from `customer-slack-bot`,
`temporal-worker` and `customer-dashboard`. They pass HMAC, pass validation, enter `consume()`,
match no branch, and return 200. Real traffic terminating in a no-op.

**Two payloads are wider than any schema declares**, and the registry must model what is emitted,
not what is declared — §8.4's rule:

- `backoffice.bankTransaction.created` sends `{id, bankId, amount, description, type}`; every schema
  declares `{id}`. `pick()` returns a non-fresh object so TypeScript's excess-property check does
  not fire, and TypeBox `T.Object` is open so validation does not either. Four fields have been
  travelling unmodelled at every publish.
- `backoffice.metcapWireTransaction.updated` sends `{id, bankTransactionId}` against a declared
  `{id}`, on both routes that receive it.

`createdAt` is likewise on the wire at every publish and in no schema, surviving only on TypeBox's
openness. The registry declares it.

**Status unions are narrower in the consumer than in the domain**, so four events can already carry
a value the consumer schema rejects: `backoffice.achBatch.statusUpdated` and
`backoffice.wireBatch.statusUpdated` are both missing `DELETED`,
`redemption.redemption.statusUpdated` is missing `FAILED`, and
`redemption.wallet.withdrawal.statusUpdated` is missing `CREATED`. Each is reachable — the
corresponding `update` takes the full domain union. The registry takes the domain union in every
case; narrowing it would encode a latent 400.

#### 8.20.5 The withdrawal double-publish, and why the registry forces it

`LedgerAccountWithdrawalAdminService` publishes every withdrawal **twice**
([:247-264](../packages/ledger-account-services/src/withdraw/LedgerAccountWithdrawalAdminService.ts)
and [:323-342](../packages/ledger-account-services/src/withdraw/LedgerAccountWithdrawalAdminService.ts)) —
once as `ledger.account.withdrawal.*`, and once as `redemption.wallet.withdrawal.*` smuggled through
a cast:

```ts
event: "redemption.wallet.withdrawal.created" as "ledger.account.withdrawal.created",
```

The cast exists precisely to defeat the one type check standing there, which is §3.3's "no event map
exists anywhere" stated as a line of code. A registry-derived union turns it into a typecheck
failure, so the collapse is not optional.

**The outcome today is that both copies fail, in different ways.** The honest name is undeclared, so
it 400s (§8.20.4). The cast name _is_ declared and _is_ dispatched — through an `as unknown as`
double cast — into `LedgerAccountWithdrawalEventSlackConsumer`, whose only two `postMessage` calls
are guarded on `event === "ledger.account.withdrawal.created"` and `…statusUpdated`
([:196-226](../packages/backoffice-api/src/controllers/slack/internal-events/LedgerAccountWithdrawalEventSlackConsumer.ts)).
The arriving event is never either name, so **neither branch can fire**: the consumer builds its
Slack blocks and posts nothing. Every withdrawal produces one 400 and one silent no-op, and no
withdrawal has ever produced a Slack message.

**Keep `ledger.account.withdrawal.*`, delete the alias.** It is the name the publishing service
owns, and it is the name the consumer's branches already test — so deleting the alias fixes the dead
branches rather than needing a second edit. Net effect: one publish per withdrawal instead of two,
the 400s stop, and withdrawal Slack alerts start working for the first time.

This ships as step A, ahead of and independent of the bus.

#### 8.20.6 Idempotency, which got harder than §6.1 assumed

§6.1 exempted the three reconciliation handlers from the idempotency table on the grounds that
Temporal covers them. §6.1's premise is wrong — see the correction in §6.1 itself — and the
consequence lands here.

The verified path does an unkeyed `deposit_quote` insert, then a `DepositAdminService.create` that
dedupes on `bankTransactionId` via a JSONB lookup, then a **raw `client.workflow.start`** on a
`workflowId` that already has a run. Under Hookdeck a duplicate was rare. Under SNS at-least-once it
is expected, and the third step throws `WorkflowExecutionAlreadyStarted` — which SNS then retries on
the managed-endpoint policy for 23 days, having already written a second quote row.

So **`reconciliationHandler` claims on the envelope ULID against the existing DynamoDB idempotency
table before dispatching** — `usesIdempotencyTable`, claim-first / release-on-throw, the
[wire-email-handler](../packages/wire-email-handler/src/handler.ts) pattern. `internalSlackHandler`
does the same for the ordinary §6 reason. Neither is optional, and the reconciliation one is the
difference between a redelivery being a no-op and a redelivery being a duplicate quote plus a
three-week retry storm.

Fixing the underlying services to use `idempotentStartWorkflow` is the better repair and it is **not
in this wave** — it touches four deposit-quote services on the money path and deserves its own
change with its own tests. The claim is what makes the wave safe without it.

#### 8.20.7 Pulumi

`cfx-internal-events-{stack}` is declared in `packages/events/pulumi` beside the existing topic. The
X-Ray resource-policy comment there already anticipates this: the second policy needs its own name
in the same account, not an edit to the first.

`internalEventsTopicArn` joins `customerEventsTopicArn` in
[baselineRoles.ts](../packages/pulumi-templates/src/baselineRoles.ts) as a `pulumi.interpolate`
twin, for the §8.7.1 reason — the physical name is fixed by this stack and a `StackReference` would
impose a deploy ordering to learn a string both sides already know. `snsPublishStatements` already
takes an array, so a publisher becomes
`publishTopicArns: [customerEventsTopicArn, internalEventsTopicArn]` with no template change. The
two SST dashboards each gain a second inline statement in `transform.server`, for the §8.7.2 reason
that a `pr-<N>` stage's role cannot be covered from a Pulumi stack that does not run per PR.

**Neither subscription carries a filter policy.** The three reconciliation consumers look like the
case that finally earns one — their predicate is a single `event` attribute, which is exactly
§8.15's stated exception — but the allow-list would then live in Pulumi _and_ in the handler's
dispatch, and §8.15's card analysis is that those two drift silently toward a missed event. The
handler no-ops on what it does not route, at a bus volume that makes the invocation cost immaterial.

Each function gets a DLQ with an explicit `aws.sqs.QueuePolicy` admitting `sns.amazonaws.com`
conditioned on the topic ARN. `customer-slack-bot` proves this is required rather than decorative:
without it the redrive silently drops. `backoffice_internal_events_rw` and
`backoffice_reconciliation_rw` join the Phase 4 block in
[workloadIdentities.ts](../packages/db/pulumi/workloadIdentities.ts) — only that stack runs in-VPC
and can create Postgres roles, and both handlers build a real database graph.

#### 8.20.8 Hookdeck teardown is asymmetric, and §8.19 is the reason

Removing the four handler declarations and the `b2b-internal` source **deletes them on dev and
orphans them on prod**. Prod adopts with `protect: true, retainOnDelete: true`; dev creates. Every
"Pulumi removes them on apply" note from the public-bus phases was true of dev and false of prod,
and the prod objects had to be deleted by hand on 2026-08-10. Plan the same here: a console deletion
after prod is verified on the SNS path, with the connection endpoint queried for `410 GONE` rather
than inferred from event history.

**`cfxHmac` must survive the teardown.** It is `b2b-internal`'s source auth, and it is also
`girasol-card-events-outbound`'s — the sharing §8.18 recorded as a historical accident. Deleting it
with the source breaks the outbound card-balance push to Girasol, which has no test covering it.

`INTERNAL_EVENTS_PUBLISH_URL` and `INTERNAL_EVENTS_PUBLISH_SIGNING_SECRET` leave nine packages in
step E. **`temporal-lambda-worker` declares both and constructs no publisher at all** — the §8.9
failure mode sitting pre-made, and the one package whose keys can be deleted in step B without
waiting.

`HOOKDECK_HMAC_SECRET` stays in `backoffice-api`: eight other vendor-ingress routes verify with it.
The publisher's own `x-cfx-signature` is verified by the Hookdeck source and by nothing in this
repo, so it leaves with `AdminEventPublisher`.

#### 8.20.9 Verification

**Reach for the signal that has no benign reading first** (§8.11). In order:

1. After step C, both new log groups have **zero stored bytes** — the positive check that the
   functions exist and nothing reaches them yet.
2. After each step D deploy, that package's events appear in the corresponding handler's log group.
   A package that deployed green and moved nothing is the §8.9 state, and here it is _legible_
   rather than ambiguous, because the HTTP route it left behind is still receiving.
3. Before step E, the `b2b-internal` source's newest inbound request predates the last step D
   deploy, with none after. That is the same criterion that cleared `cfx-publish` in §8.19, and it
   is a stronger one than a count comparison: it says no publisher is still on the old path, rather
   than that the two paths agree.
4. Empty DLQs on both subscriptions, and zero Lambda errors, across a full daily cycle including the
   13:00–15:00 UTC busy hours.

#### 8.20.10 Risks

| Risk                                                                      | Mitigation                                                                                                    |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| No dual-run means no count to reconcile                                   | Both paths end in one graph, so the routes stay live through the swap; step 3 above replaces the count        |
| A step D package deploys green and publishes nothing                      | Its HTTP route still delivers, so the failure is a stalled migration rather than lost events                  |
| SNS redelivery duplicates a deposit quote and wedges on `AlreadyStarted`  | Idempotency-table claim on the envelope ULID (§8.20.6)                                                        |
| `consumerGraph.ts` extraction changes behaviour while both paths are live | Pure refactor, landed in step C before any publisher moves; `main.ts` and both handlers import the same graph |
| Registry status unions narrower than the domain reintroduce a 400         | Registry takes the domain union in all four cases (§8.20.4); enforced by the inventory test                   |
| Deleting `cfxHmac` with the source breaks the Girasol outbound push       | `cfxHmac` is explicitly retained; the source declaration goes, the auth stays                                 |
| Prod Hookdeck objects survive the IaC removal and look deleted            | §8.19: adopted objects orphan rather than delete; console cleanup is a planned step, verified by `410 GONE`   |

## 9. Open questions

1. **Measure payload sizes** against SNS's 256 KB across the ~60 event types. Document/KYC-adjacent
   events are the risk. If any exceed, that type needs a claim-check we write ourselves. §8.6 closes
   this for the public bus — the publisher's size check makes an overflow loud, and dual-run measures
   it while Hookdeck is still delivering. The internal bus's types remain unmeasured.
2. **`reward-api`'s `DemoEventConsumer`** points at a separate Hookdeck account with its own
   `HOOKDECK_DEMO_HMAC_SECRET` and is not in `reward-api/pulumi/hookdeck.ts`. In or out?
3. ~~**The dev Hookdeck account** has never been audited.~~ **Closed 2026-08-10.** The premise was
   wrong: `cfx-hookdeck/dev` holds no `HOOKDECK_API_KEY`, but the dev key was never meant to live
   there — `createHookdeckProvider` defaults to **`cfx-common`**, so it is `cfx-common/dev`. The
   audit found exactly the predicted leftovers: the seven per-partner destinations Phase 0 moved to
   Outpost (`b2b-nerito_pay`, `b2b-solusef`, `b2b-balam`, `b2b-skopa`, `b2b-dashfi-b2c`,
   `b2b-movemoney`, `b2b-movemoney-demo`), all deleted. Dev had **no** `cfx-publish` source to clean:
   dev creates rather than adopts, so Pulumi genuinely deleted it — see §8.19.
4. ~~**`cfx-publish-to-attio-task`**~~ **Closed 2026-08-10** — deleted in the console.

### Unverified assumptions

Stated so nobody inherits them as fact:

- **Outpost will not complete an SNS subscription-confirmation handshake.** Never tested — it is
  moot given the bridge Lambda, but it is the reason SNS→Outpost was ruled out directly.
- **Whether Outpost accepts Basic auth** (which SNS HTTPS _can_ send) instead of a bearer token. The
  generated client types carry no security schemes. Also moot for the same reason.
- ~~**That every reconciliation path reachable from the three internal handlers ends in a Temporal
  workflow.**~~ **Traced 2026-08-11, and it does not hold for any of the three.** All three do
  mutating or multi-read work inline, `bank-internal-transfer` signals an existing workflow rather
  than starting one, and the starts that do happen are raw `client.workflow.start`. The correction
  is recorded in §6.1 and its consequence in §8.20.6. This was the assumption most likely to be
  inherited as fact, and it was: §6.1 asserted it as settled and §9 recorded only half of it as
  unverified.

## 10. Out of scope, deliberately

- **Raw vendor payload archive to S3.** Capture at ingress before parsing; gzipped JSON with Object
  Lock, partitioned `vendor/dt=`. Not Parquet — byte-fidelity is needed to re-verify signatures and
  there is no stable Glue schema across vendors. Hookdeck's event log is the only such record today,
  which is why this must exist before Hookdeck's _ingress_ half is ever retired.
- **AiPrise field stripping.**
  [`transformations/b2b-aiprise-automation.js`](../packages/hookdeck/pulumi/transformations/b2b-aiprise-automation.js)
  replaces the body with a 13-key allow-list for schema-coupling reasons, not any size limit.
  `docs/misc/aiprise-event-audit.md` analyses handler gaps without ever mentioning the transform, so
  adding a handler without widening the pick-list yields a mostly-`undefined` object.
- **Hookdeck stores inbound `x-api-key` / `x-api-secret` in cleartext** in its event log. Rotate or
  redact.
- **No outbox.** Publishing still happens after commit, in-process. Making `Publish` throw narrows
  the commit-to-publish window; it does not close it. A crash between `COMMIT` and `Publish` still
  loses the event, exactly as today.
