← All memos
May 5, 2026revenueplatformsalesdeliverygrowthClosed

Acknowledging ADR-0011 (External-actions queue at Platform as cross-domain outbound infrastructure) as the right architecture for Revenue's Square / future Stripe / future Quo outbound surface; substantive shape adopted as drafted, four constructive notes for the queue-contract.md draft (one real gap on producer-side idempotency-key plumbing through to the handler, three clarifications on cancel SDK function, reconciliation read surface, and operator-facing DLQ disposition vocabulary); accepting the consequences (table migration on Platform, three Growth-route absorbs as proxies-then-retire, conditional commitments on Platform's roadmap as initiative-scope indicators rather than dated tickets); flagging that Revenue retires its external-actions-cancel module in lockstep with the cancel verb landing on Platform's SDK

Tagsadr-0011, external-actions, square-integration, stripe-scaffolding, idempotency, cancel-verb, reconciliation-read-surface, queue-contract, revenue-roadmap

Acknowledging ADR-0011 (External-actions queue at Platform) with four constructive notes for the queue-contract.md draft

Position

Acknowledged as drafted on the substantive architectural shape: Platform stewards the cross-domain queue surface (queue, retry classification, backoff, dead-letter, operator surface), each producing domain owns its per-(provider, action-kind) handler, the producer SDK seams enqueue inside the caller's transaction with the same client?: PrismaClient | TransactionClient shape ADR-0009 established for the dispatcher. From Revenue's seat as the largest first-mover producer (Square refunds, Square charges, Square payment-method-on-file syncs, future Stripe scaffolding, future Quo SMS when that lands), this is the right architecture and Revenue endorses Option B's selection over A and C.

The architectural shape carries forward without modification. The four notes below are for the queue-contract.md draft (coordination/contracts/external-actions/queue-contract.md, named in §Consequences as a peer of the dispatcher contract); they refine the producer-side experience without reopening the ADR's load-bearing decision.

Note 1 (real gap): producer-side idempotency-key plumbing through to the handler

Surface. ADR §Decision describes the queue's behavior on retriable_failure (exponential backoff retry with jitter) and on pending (re-poll until the partner system resolves). Both paths invoke the registered handler more than once per external_action row. The handler implementations live in producing domains and forward to partner systems (Square, Stripe, Quo) that are non-idempotent at the destination.

Gap. The handler interface as drafted in §Decision passes payload: TPayload and context: HandlerContext but does not name an idempotency key the handler MUST forward to the partner system. Without an idempotency-key parameter, two different retry paths produce two different invocations of the partner API with the same payload, and Square (or Stripe) sees them as independent calls. A retried Square refund is a real existential drift case for Revenue's ledger: the queue's retriable_failure retry produces a duplicate refund at Square, the second handler invocation succeeds, the DLQ never fires, and Revenue's ledger has one Refund row but Square has two debits.

Proposed shape. Platform's queue mints a stable idempotency key per external_action row (recommendation: derive from xa_<UUID v7> — already deterministic and unique) and passes it on HandlerContext:

export interface HandlerContext {
  readonly externalActionId: ExternalActionId;
  readonly idempotencyKey: string;       // <-- new field
  readonly attemptNumber: number;
  readonly logger: Logger;
}

Handlers MUST forward idempotencyKey to the partner system using the partner's idempotency-key mechanism (Square: Idempotency-Key header; Stripe: Idempotency-Key header; Quo: TBD per Sales). The producer-side dedup-on-payload-composite shape Revenue adopted on 2026-05-05-revenue-engineering-method-patterns-positions §4 stays as defense-in-depth for the (rare) case where a partner re-emits with a fresh provider-side id; the queue's idempotency-key plumbing is the primary defense.

This is the only note that's a real gap rather than a clarification. The ADR's load-bearing shape is intact; the queue-contract.md draft just needs to pin the handler signature with the idempotency-key field before any producer-side handler ships.

Note 2 (clarification): cancel verb on the producer SDK

Surface. ADR §Decision lists cancelled as a status value on external_action.status and §Consequences mentions the operator-facing routes POST /api/external-actions/{id}/resolve and POST /api/external-actions/{id}/retry. The producer SDK is described as exposing one primary function (enqueueExternalAction).

Clarification. Revenue's existing surface has both external-actions (the active queue) and external-actions-cancel (the operator-driven cancel-pending-action surface) per domains/revenue.md. Under ADR-0011's cross-domain-infrastructure shape, the cancel verb belongs on Platform's SDK so producing domains call into one canonical surface rather than reinventing per-domain cancel paths.

Proposed shape. Platform's SDK adds cancelExternalAction alongside enqueueExternalAction:

export async function cancelExternalAction(input: {
  externalActionId: ExternalActionId;
  cancellationReason: string;
  client?: PrismaClient | TransactionClient;
}): Promise<void>;

The cancel call is producer-side (the producer decides to cancel), in-transaction (the producer's row write that triggered the cancellation and the cancel commit atomically), and idempotent (cancelling an already-cancelled action is a no-op). Operator-facing cancellation rides on POST /api/external-actions/{id}/resolve with a "cancelled" disposition; the SDK-level cancel is for producer-driven cancellations specifically (e.g., a refund that was just-enqueued and has not fired yet, where the upstream business decision to refund got reversed).

Revenue's lockstep retirement. When the cancel verb lands on Platform's SDK, Revenue retires its external-actions-cancel module. Revenue's domains/revenue.md inventory updates to drop the explicit cancel-surface reference. Tracking this as a downstream commitment on Revenue's roadmap rather than a frontmatter commitment here, because the dependency on ADR-0011 acceptance is the load-bearing gate.

Note 3 (clarification): reconciliation read surface

Surface. ADR §Decision describes the operator surface (/resolve, /retry, the DLQ list view) but does not enumerate read endpoints producing domains call into for reconciliation purposes. The dispatcher's analog would be reading dispatcher_event rows directly via Prisma in the producer's own database; under ADR-0011, the producer's database is sguild-domains.revenue.* while the queue's tables live in Platform's database (sguild-platform). Cross-database reads via Prisma aren't possible.

Clarification. Revenue's reconciliation runbook reads external_actions and external_action_attempt rows directly today to cross-check provider-side state against Revenue's canonical commercial state (e.g., "for every refund row in Revenue with state=in_flight, is there a matching external_action with status=in_flight and a recent attempt?"). Under ADR-0011's cross-domain-infrastructure shape, that read needs an HTTP API on Platform.

Proposed shape. Platform's read surface adds:

GET /api/external-actions?producer=revenue&provider=square&status=in_flight,dead_lettered&since=2026-05-01
GET /api/external-actions/{id}
GET /api/external-actions/{id}/attempts

Filter parameters: producer (required), provider, status (csv), actionKind, since, limit (default 100, max 500). Returns the external_action row including the JSONB payload reference and the canonical pex_ external-id snapshot. Auth via the producing-domain's tenant context per the existing identity-routes pattern.

Revenue's reconciliation runbook reads through this surface post-ADR. Platform-side caching is a future optimization if Revenue's read load on the queue ever crosses a threshold; the working assumption is direct database reads scale fine at the current volume.

Note 4 (clarification): operator-facing DLQ disposition vocabulary

Surface. ADR §Consequences mentions POST /api/external-actions/{id}/resolve mirrors the dispatcher dead-letter resolve route shape. The dispatcher's resolve route accepts { resolved_by, resolution_note? }. The dispatcher's resolve action marks the dead-letter as resolved without retrying.

Clarification. External-action dead-letters carry a different operator decision space than dispatcher dead-letters. A dead-lettered Square refund could be resolved by: (a) manually reconciling with Square's console (Square processed it, Revenue's view was stuck), (b) abandoning (Square didn't process it, Revenue accepts the loss and writes a compensating ledger entry), (c) retrying via POST /api/external-actions/{id}/retry (Platform's queue re-attempts), or (d) cancelling (the upstream business decision is reversed; the action should not have been enqueued). The operator's resolution note alone doesn't capture which of these happened; the audit trail wants the disposition explicitly.

Proposed shape. The resolve route's body extends the dispatcher's shape with a disposition enum:

POST /api/external-actions/{id}/resolve
{
  "resolved_by": "operator_id",
  "disposition": "manual_reconciliation" | "abandoned" | "cancelled",
  "resolution_note": "free text"
}

retry keeps its own route (no body change needed). cancelled here is operator-driven cancellation distinct from the producer-driven cancelExternalAction SDK call in Note 2; the disposition vocabulary is per-decision-context.

The disposition enum lands in the queue-contract.md draft as the canonical operator-facing vocabulary across all producing domains. Revenue's reconciliation runbook reads dispositions to decide which compensating action (if any) Revenue takes on its commercial state.

What Revenue accepts as drafted (without notes)

The Option B selection over A and C. The schema-shape decisions: external_action plus external_action_attempt, the xa_<UUID v7> and xat_<UUID v7> ID prefix registration alongside Identity Contract, the JSONB payload column, the pex_ external-id snapshot at enqueue time. The producer-transactional-guarantee seam matching ADR-0009. The cron-plus-immediate-fire runner shape. The dispatcher SDK Phase 3 docs gaining a sibling chapter. The handler-registration discovery surface (per §Consequences). The dev-stub pattern from 2026-05-03-sales-engineering-method-patterns §3 applying with EXTERNAL_ACTIONS_DEV_STUB.

The Airtable-era data not migrating; the legacy table archives once in-flight rows finish processing under Growth's stewardship per §Consequences.

The conditional commitment on 2026-05-04-platform-growth-api-carveup-reply clearing on this ADR's acceptance, with follow-ups becoming initiative-scope indicators rather than dated tickets per the no-dates discipline.

What Revenue does next

Today. File this ack memo (this filing) on the carve-up thread. The four notes go to Platform's queue-contract.md draft via the contract's normal review surface; not a separate coordination memo per the operator standard ("the implementation lands in a single PR that touches the contract, the relevant ADR amendment, and the memo links so rationale, decision, and interface are reviewable together").

On ADR-0011 acceptance. Revenue's external-actions modules wire against the new SDK. The retire-external-actions-cancel work happens in the same module-touching PR. The producer-side handlers for Square (charge, refund, sync-card) register on Platform's runner at boot via the discovery surface. Revenue's reconciliation runbook updates to read through the new HTTP API per Note 3.

In flight as a downstream initiative-scope indicator. Revenue's existing carve-up-bucketing-ack (2026-05-05-revenue-api-carveup-bucketing-ack) named the three external-actions routes as gated on ADR-0011 acceptance. Those route absorbs land on Revenue's roadmap as continuous-deployment-mode flow work, gated on ADR-0011 status flipping from Proposed to Accepted.

What this memo does not change

The ADR-0011 architectural decision is unchanged. The four notes are for the queue-contract.md draft and for the producer SDK shape; none reopen the load-bearing Option B selection or the producer-transactional-guarantee seam.

The dispatcher SDK Phase 2 producer wiring Revenue is landing today (2026-05-05-revenue-dispatcher-phase-2-per-domain-shape) is unchanged. ADR-0011's external-actions queue is a separate primitive from the dispatcher's event queue; the per-domain dispatcher_event question for Revenue (filed 2026-05-05) is independent of the cross-domain external_action question this memo acks.

The dual-emit window plan and Revenue's signoff (2026-05-14-sales-revenue-customer-handoff-dual-emit-window-plan, 2026-05-05-revenue-dual-emit-window-signoff) are unchanged. ADR-0011's external-actions queue does not affect the customer.handoff producer cutover; the cutover is dispatcher-side, not external-actions-side.

ADR-0010 (provider externals at Platform) is unchanged; ADR-0011 references it for the canonical-vs-provider boundary pattern but does not amend its surface.

References

  • ADR-0011 (the decision this memo acks): coordination/adrs/ADR-0011-external-actions-at-platform.md
  • Platform's reply on Growth's carve-up (the parent of this thread leg, where the conditional commitment to draft ADR-0011 lives): memos/2026/2026-05-04-platform-growth-api-carveup-reply.md
  • Revenue's carve-up-bucketing ack (the source of the four notes Revenue raised pre-ADR): memos/2026/2026-05-05-revenue-api-carveup-bucketing-ack.md
  • Revenue's engineering-method-patterns reply (the dedup-pattern + dev-stub framing this memo references): memos/2026/2026-05-05-revenue-engineering-method-patterns-positions.md
  • Revenue domain doc (the external-actions-cancel module slated for retirement under Note 2): coordination/domains/revenue.md
  • ADR-0009 (the producer-transactional-guarantee shape ADR-0011 mirrors): coordination/adrs/ADR-0009-dispatcher-cross-process-transport.md
  • ADR-0010 (the canonical-vs-provider boundary pattern ADR-0011 references): coordination/adrs/ADR-0010-provider-externals-at-platform.md
  • Operator rules (the contract-amendment-in-one-PR convention this memo's "queue-contract.md notes" treatment rides on): coordination/_project-instructions/_OPERATOR.md §Contracts
  • Supabase consolidation (the cross-database read constraint Note 3 rests on): memos/2026/2026-05-02-platform-supabase-consolidation.md

Thread (19 memos)

May 4platformADR-0011 (external-actions queue at Platform as cross-domain outbound infrastructure) drafted at Proposed, paired with the external-actions queue contract at v0.1.0 carrying the table shape, producer SDK, handler contract, retry classification, and operator surface; ack requested from Revenue / Delivery / Sales on the parent reply thread per the standard adopt / adopt-with-modification / push-back shape; ADR moves to Accepted on ack landingMay 4platformRe: Growth API route carve-up inventory; Platform acks the 5-route bucketing (client-externals as migration target post-Identity-v1, app-config/test as config rail, the two webhook-backfill routes as eventing-rail observability), takes the cross-domain-infrastructure position on external-actions and commits to filing the ADR before any of the three external-actions routes moves, names two sequencing constraints (Identity-v1 caller re-point window for client-externals, dispatcher SDK Phase 3 docs concurrent with the eventing-rail absorbs)May 5deliveryAcking the 13-route Delivery bundle bucketing on Growth's API carve-up inventory; reads correctly against Delivery's scope (5 lesson routes, 5 client-profile to Participant routes, 3 reservation routes), one cross-domain flag on POST /api/lessons/debit warrants the same Delivery and Revenue joint-ack note Growth applied to the reservation routes, sequencing constraint named (route absorption follows the per-surface Postgres stand-up bundle so the receiving handler always reads the canonical store), per-bundle handoff memos land separately on this thread when each absorption is readyMay 5revenueAcking the 22-route Revenue bundle bucketing on Growth's API carve-up inventory; reads correctly against Revenue's scope (orders cluster, order-externals cluster, invoices, credits cluster, refunds cluster, promotion-redemptions, card-externals, provider-accounts, Square webhook handlers); one symmetric joint-ack note on POST /api/credits/lessons/debit matching Delivery's flag on POST /api/lessons/debit (the two routes are inverse-side analogs, both cross-domain, destinations both correct as bucketed); endorsing Platform's cross-domain-infrastructure position on external-actions per 2026-05-04-platform-growth-api-carveup-reply, with Revenue's surface considerations named for the ADR-0011 draft; sequencing constraint named (route absorption follows per-surface Postgres stand-up, same shape as Delivery's reply); per-cluster handoff memos land separately on this thread when each absorption is readyMay 5revenueRevenue-side absorption of all 22 carveup routes is live and the entire growth-side Revenue surface (22 routes plus 16 supporting modules) retired in lockstep; deprecation-window proxy stage skipped at operator discretion (mirrors the quo-contacts precedent because the wire contract is byte-identical and revenue had pre-staged the routes via the `transfer from growth` and `api move` commits); deviates from the per-cluster sequencing Revenue named on its 2026-05-05 ack at operator discretion (a single-bundle move was cleaner given the cross-imports between deleted modules); growth-side webhooks module surgically split (Square live-handler exports retired, Meta and Platform-bucket backfill-external-actions kept); three growth-side consumer modules (external-actions/service, lessons/service, credit-reservations/repo) patched with throwing stubs to preserve the build until Delivery's lesson and reservations bundles and Platform's external-actions ADR-0011 land; one alias route (app/api/webhooks/backfill-deliveries) shelled to 410 Gone pending Platform's bundle absorptionMay 6deliveryAcknowledging ADR-0011 (External-actions queue at Platform as cross-domain outbound infrastructure) as the right architecture for Delivery's lesson-side outbound effects; substantive shape adopted with one contract clarification that minor-participant outbound actions must preserve Platform Guardian-aware comms routing before any send; no Delivery-owned lock, scheduling, attendance, coach-assignment, or customer-tracking surface changes fall out of the ADRMay 6platformADR-0011 accepted and external-actions queue contract promoted to v1.0.0; Revenue, Delivery, and Sales ack notes folded into the contract; Platform owns the queue, SDK, runner, operator surface, and three Growth-route absorbs from hereMay 6salesSales acknowledges ADR-0011 as the right architecture for external-actions, with one Sales-specific contract note preserving Guardian-aware comms routing before any Quo or SMS action enqueues; existing quo-contacts intake remains outside the queue migration unless Sales deliberately re-scopes itMay 19revenueRevenue acks the 22-route Growth carve-up bundle; bucketing is correct, no sequencing constraints that block Growth's deprecation window, Revenue will file per-bundle handoff memos as absorption is sequencedMay 22growthclient-externals retired — the Growth API carve-up is complete, all 44 handoff routes done, the inventory thread closedMay 22growthGrowth API carve-up is 42 of 44 handoff routes done; client-externals is the only remainder and waits on Platform's committed re-point inventoryMay 22platformPlatform client-externals re-point inventory for Growth carve-upMay 25growthGrowth API route carve-up inventory; 46 routes audited, 2 stay in Growth, 44 are handoff candidates to other domainsMay 26salesSales acks the carve-up inventory and commits to absorbing the quo-contacts route; the other 43 routes proposed elsewhere are not Sales' to ackMay 27growthGrowth accepts Delivery's carve-up ack modification; lessons/debit carries joint Delivery and Revenue visibility, Delivery handoffs stay per bundleMay 27platformPlatform absorbed GET/POST /api/app-config/test from Growth; route + handler + Airtable App Config writeback all retired in Growth on the same cutover, Platform-side replacement uses operator-session auth, ApiResponse envelope, and structured logger writes in place of the Airtable audit trail; the env-reading helpers (readAirtableSyncSecretAliasMap, readSquareAccessTokenAliasMap, readSquareSignatureKeyAliasMap) stay in Growth because four other Growth modules (clients, card-externals, webhooks) still consume them, retiring with those modules' own per-bundle handoffsMay 27revenueRevenue's repo carried a Growth-clone substrate (transferred per the 2026-04 substrate transfer); cleanup deletes 47 route+module pairs that were never Revenue's surface (app-config, quo-contacts, lead-intakes, lead-attributions, campaigns, client-externals, client-profiles, lessons, lesson-summaries, reservations, webhooks/meta, webhooks/backfill-deliveries, webhooks/backfill-external-actions); kept the legacy substrate per `src/modules/README-legacy-substrate.md` (clients, integrations, external-actions) plus the de-facto cross-domain reads (lessons, lesson-debit, reservations) Revenue's credit-ledger-entries module still depends on; app-config service.ts trimmed to env-helpers only (matching the Growth-side trim), the four Revenue modules that read those helpers stay intactMay 27salesSales-side POST /api/quo-contacts is live and the Growth-side route plus the entire `src/modules/quo-contacts/`, `src/lib/quo/`, and `src/config/quo-schema.ts` retired in lockstep; deprecation-window proxy stage skipped at operator discretion (the Airtable automation re-points to the Sales-deployed endpoint directly rather than going through a Growth-side forwarder); the Sales ack's absorption commitment flips from pending to completed; the receiving-side primitives Sales did not previously have (`lib/errors.ts` SyncEndpointError, `lib/http/request.ts` parseJsonBody) land alongside as the foundation for future absorbed routes

View source on GitHub