@sguild/dispatcher 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/contracts/README.md +30 -0
- package/contracts/coach-availability/README.md +355 -0
- package/contracts/coach-availability/README.v2.md +263 -0
- package/contracts/coach-availability/schema/payloads/coach.assigned-v1.json +91 -0
- package/contracts/coach-availability/validation/delivery-assignment.md +89 -0
- package/contracts/coach-availability/validation/sales-offer-construction.md +76 -0
- package/contracts/coaching-confirmation/README.md +96 -0
- package/contracts/coaching-confirmation/schema/payloads/coaching.lesson.confirmation_decided-v1.json +142 -0
- package/contracts/coaching-confirmation/schema/payloads/lead.coach.confirmation.requested-v1.json +124 -0
- package/contracts/credit-reservation-funding-state/README.md +147 -0
- package/contracts/credit-reservation-lock/README.md +433 -0
- package/contracts/credit-reservation-lock/delivery-state-vocabulary.md +73 -0
- package/contracts/credit-reservation-lock/reservation-create-api.md +191 -0
- package/contracts/credit-reservation-lock/reservation-release-api.md +171 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +1 -1
- package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +2 -3
- package/contracts/credit-reservation-lock/validation/lesson-lifecycle.md +318 -0
- package/contracts/event-envelope/README.md +205 -0
- package/contracts/event-envelope/schema/envelope-v1.json +2 -2
- package/contracts/event-envelope/validation/event-vocabulary.md +270 -0
- package/contracts/event-types-registry.json +337 -24
- package/contracts/external-actions/README.md +338 -0
- package/contracts/finance-mart/README.md +238 -0
- package/contracts/finance-mart/cac-payback.md +113 -0
- package/contracts/finance-mart/cash-position.md +98 -0
- package/contracts/finance-mart/cohort-summary.md +72 -0
- package/contracts/finance-mart/customer-journey-audit.md +92 -0
- package/contracts/finance-mart/ltv.md +92 -0
- package/contracts/finance-mart/margin.md +87 -0
- package/contracts/finance-mart/pnl.md +83 -0
- package/contracts/finance-mart/reconciliation.md +98 -0
- package/contracts/finance-mart/revenue-recognition-rollup.md +87 -0
- package/contracts/finance-mart/unit-economics.md +94 -0
- package/contracts/growth-warehouse-api/README.md +162 -0
- package/contracts/identity/README.md +184 -0
- package/contracts/identity/person-canonical-fields.md +120 -0
- package/contracts/identity/person-externals.md +267 -0
- package/contracts/identity/person-resolution-semantics.md +144 -0
- package/contracts/identity/person-role-taxonomy.md +120 -0
- package/contracts/identity/schema/payloads/intake.captured-v2.json +60 -0
- package/contracts/identity/schema/payloads/intake.matched-v2.json +123 -0
- package/contracts/identity/schema/payloads/person.updated-v1.json +8 -2
- package/contracts/identity/schema/payloads/role.assigned-v1.json +50 -0
- package/contracts/identity/schema/payloads/role.retired-v1.json +54 -0
- package/contracts/identity/validation/client-table.md +131 -0
- package/contracts/identity/validation/coach-handling.md +100 -0
- package/contracts/identity/validation/person-graph.md +140 -0
- package/contracts/lead-lifecycle/README.md +187 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.handoff.context.recorded-v1.json +108 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.qualified-v1.json +54 -0
- package/contracts/lead-lifecycle/schema/payloads/sales.lead.onboarded-v1.json +120 -0
- package/contracts/lesson-lifecycle/README.md +118 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.cancelled-v1.json +30 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.delivered-v1.json +29 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.rescheduled-v1.json +157 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.scheduled-v1.json +107 -0
- package/contracts/lesson-lifecycle/validation/README.md +5 -0
- package/contracts/mart-consumer-api/README.md +108 -0
- package/contracts/order-flow/README.md +106 -0
- package/contracts/order-flow/schema/payloads/order.created-v1.json +58 -0
- package/contracts/order-flow/schema/payloads/order.updated-v1.json +63 -0
- package/contracts/payment-flow/README.md +157 -0
- package/contracts/platform-comms/README.md +84 -0
- package/contracts/platform-comms/schema/payloads/platform.comms.inbound-v1.json +83 -0
- package/contracts/platform-geography-snapshot/README.md +205 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.market.archived-v1.json +36 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.market.upserted-v1.json +59 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.service-area.archived-v1.json +36 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.service-area.upserted-v1.json +65 -0
- package/contracts/portfolio-mart/README.md +133 -0
- package/contracts/portfolio-mart/cohort-funnel-panel.md +76 -0
- package/contracts/portfolio-mart/cross-discipline-performance.md +91 -0
- package/contracts/portfolio-mart/cross-market-performance.md +84 -0
- package/contracts/portfolio-mart/health-composites.md +88 -0
- package/contracts/portfolio-mart/org-topology.md +70 -0
- package/contracts/portfolio-mart/portfolio-level-funnel-health.md +92 -0
- package/contracts/portfolio-mart/validation/consumer-isolation.md +33 -0
- package/contracts/portfolio-mart/validation/decoupling-discipline.md +34 -0
- package/contracts/refund-flow/README.md +136 -0
- package/contracts/refund-flow/sales-callable-refund-initiation-api.md +218 -0
- package/contracts/sales-scheduling-surface/README.md +532 -0
- package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.cancelled-v1.json +42 -0
- package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.created-v1.json +115 -0
- package/contracts/sales-scheduling-surface/validation/composite-hold-create.md +97 -0
- package/contracts/sales-scheduling-surface/validation/lock-state-machine-conformance.md +84 -0
- package/contracts/sales-scheduling-surface/validation/sales-close-orchestration.md +77 -0
- package/contracts/warehouse-silver/README.md +118 -0
- package/contracts/warehouse-silver/coaching-utilization-columns.md +105 -0
- package/dist/events.d.ts +63 -0
- package/dist/events.js +293 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -1
- package/dist/postgres-consumer.js +2 -1
- package/dist/validator.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# External Actions Queue Contract
|
|
2
|
+
|
|
3
|
+
**Status:** v1.0.0
|
|
4
|
+
**Date:** 2026-05-06
|
|
5
|
+
**Owner:** Platform
|
|
6
|
+
**Consumers:** Revenue (Square outbound), Delivery (lesson-side notifications), Sales (Quo / SMS), Platform itself (Better Auth email-send, webhook-acknowledgments)
|
|
7
|
+
**Related ADRs:** ADR-0011 (this contract is the spec the ADR commits to), ADR-0009 (dispatcher cross-process transport; the producer-transactional-guarantee seam mirrors here), ADR-0010 (provider externals; the `pex_` resolution snapshot rides on every action), ADR-0002 (entity ID prefixes; introduces `xa_` and `xat_`)
|
|
8
|
+
**Related contracts:** Event Envelope Contract (`../event-envelope/README.md`; events that originate actions reference back via `originating_event_id`)
|
|
9
|
+
|
|
10
|
+
## 1. Purpose and scope
|
|
11
|
+
|
|
12
|
+
Sguild systems frequently need to perform an outbound effect against a partner system: HTTP POST to Square, email send via the auth provider, SMS via Quo, webhook acknowledgment back to a third party, and so on. These effects are non-idempotent at the destination, must be retried on transient failure, must dead-letter on terminal failure, and must compose with a Postgres transaction in the producing domain so the row write that motivates the action and the action itself land atomically from the caller's seat.
|
|
13
|
+
|
|
14
|
+
This contract specifies the queue Platform stewards on behalf of every producing domain: the table shape, the producer SDK, the handler contract, the retry-classification taxonomy, the operator surface, and the responsibilities of producers and consumers.
|
|
15
|
+
|
|
16
|
+
The queue is the outbound-side mirror of the dispatcher. The dispatcher (per the Event Envelope Contract) handles inbound: events fan out from one producer to many consumers with at-least-once delivery. The external-actions queue handles outbound: one action goes to one destination with retry, response correlation, and dead-letter.
|
|
17
|
+
|
|
18
|
+
Out of scope: per-(provider, action-kind) request and response shapes (those live in the producing domain's module; the queue's `payload` and `response` columns are JSONB and the queue does not see the shape), provider authentication (each handler manages its own credentials), the bus underneath the runner (today the runner is a Vercel cron; transport choice is a Platform implementation detail).
|
|
19
|
+
|
|
20
|
+
## 2. Normative language
|
|
21
|
+
|
|
22
|
+
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted per RFC 2119.
|
|
23
|
+
|
|
24
|
+
## 3. Terminology
|
|
25
|
+
|
|
26
|
+
- **Action.** One logical outbound effect. Persists as one row in `external_action`.
|
|
27
|
+
- **Attempt.** One try against an action. Persists as one row in `external_action_attempt`. An action has 1..N attempts before reaching a terminal state.
|
|
28
|
+
- **Producer.** The domain that enqueues the action. Must be one of `platform`, `growth`, `sales`, `delivery`, `revenue`, `coaching`.
|
|
29
|
+
- **Provider.** The external system the action targets, identified by a free-text tag the producing domain owns (e.g., `square`, `quo`, `better-auth-email`). The queue treats provider as an opaque routing key; it does not enumerate providers in the schema.
|
|
30
|
+
- **Action kind.** The specific operation against a provider, identified by a free-text tag the producing domain owns (e.g., `square.refund.create`, `quo.sms.send`). The (`provider`, `action_kind`) tuple selects the registered handler.
|
|
31
|
+
- **Handler.** The producing-domain code that executes one action kind against one provider. Implements the `ExternalActionHandler` interface. Returns a classification.
|
|
32
|
+
- **Classification.** The handler's verdict on an attempt: `succeeded`, `terminal_failure`, `retriable_failure`, or `pending`.
|
|
33
|
+
- **Runner.** The Platform-side process that picks pending actions off the queue, dispatches to the registered handler, writes the resulting attempt, and advances the action's status.
|
|
34
|
+
|
|
35
|
+
## 4. The `external_action` row
|
|
36
|
+
|
|
37
|
+
Each action persists as one row. Fields below are normative.
|
|
38
|
+
|
|
39
|
+
### 4.1 Required fields
|
|
40
|
+
|
|
41
|
+
| Field | Type | Description |
|
|
42
|
+
|-------|------|-------------|
|
|
43
|
+
| `id` | text, `xa_<UUID v7 canonical>` per ADR-0002 | Action identifier. Globally unique. |
|
|
44
|
+
| `producer` | enum (`platform`, `growth`, `sales`, `delivery`, `revenue`, `coaching`) | Which domain enqueued the action. |
|
|
45
|
+
| `provider` | text, lowercase, dot-allowed | The partner system identifier. Producer-defined. |
|
|
46
|
+
| `action_kind` | text, lowercase, dot-allowed | The operation against the provider. Producer-defined. |
|
|
47
|
+
| `payload` | jsonb | The handler-specific request shape. The queue does not validate the payload; the handler does. |
|
|
48
|
+
| `status` | enum | One of `pending`, `in_flight`, `succeeded`, `dead_lettered`, `cancelled`, `resolved`. |
|
|
49
|
+
| `attempt_count` | integer | Count of attempts written so far. Starts at 0; increments before each attempt. |
|
|
50
|
+
| `next_attempt_at` | timestamp | When the runner is allowed to next attempt. For `pending` actions the runner picks rows where `next_attempt_at <= now()`. |
|
|
51
|
+
| `idempotency_key` | text | Stable per action. MUST be unique per (`producer`, `provider`, `action_kind`). Passed to handlers on every attempt and forwarded to the partner system's idempotency mechanism. |
|
|
52
|
+
| `created_at` | timestamp | Enqueue time. Producer-side wall-clock at row write. |
|
|
53
|
+
| `updated_at` | timestamp | Last status or attempt change. |
|
|
54
|
+
|
|
55
|
+
### 4.2 Optional fields
|
|
56
|
+
|
|
57
|
+
| Field | Type | Description |
|
|
58
|
+
|-------|------|-------------|
|
|
59
|
+
| `originating_event_id` | text, `evt_<UUID v7>` | If the action was kicked off by a dispatcher event (per the Event Envelope Contract), the event id. Lets operators trace from inbound event to outbound action. |
|
|
60
|
+
| `external_id_snapshot` | jsonb | The `pex_` resolution snapshot at enqueue time, per ADR-0010. Carries `{provider, external_id}` so audit can reconstruct the canonical-to-provider mapping the producer used, even if the underlying `person_externals` row changes later. |
|
|
61
|
+
| `correlation_handle` | text | Producer-defined free text used to correlate response back to producer-side state (e.g., a row id in the producer's domain). |
|
|
62
|
+
| `dead_letter_reason` | text | Set when status flips to `dead_lettered`. Names the terminal failure cause; never reset. |
|
|
63
|
+
|
|
64
|
+
### 4.3 Field rules
|
|
65
|
+
|
|
66
|
+
`id` MUST be `xa_<UUID v7>`. v7 because audit benefits from the time-ordered prefix.
|
|
67
|
+
|
|
68
|
+
`producer` MUST match the actual producing domain. Cross-domain enqueues (one domain enqueueing on behalf of another) are NOT permitted; if domain A wants to trigger an outbound effect that B owns, A emits a dispatcher event and B's subscriber enqueues the action.
|
|
69
|
+
|
|
70
|
+
`provider` and `action_kind` are producer-defined but MUST be stable. Renaming a provider or action_kind is a breaking change; the producing domain coordinates with Platform on the migration path.
|
|
71
|
+
|
|
72
|
+
`payload` MAY be any JSON-serializable value. The queue does not enforce a schema; the handler does. The producing domain SHOULD include enough information in the payload that the handler does not need to read additional state at execute time (handlers run on Platform's runner, possibly delayed; reading state at execute time is a foot-gun for stale reads).
|
|
73
|
+
|
|
74
|
+
For outbound communications, producers MUST preserve the domain's comms-routing obligations before the action can reach a provider. Sales and Delivery actions that target a minor Person MUST route through Platform's Guardian-aware comms-routing endpoint before enqueueing, or the producing-domain handler MUST call that endpoint before sending and send only to the returned routed recipient. Producers MUST NOT place minor contact details directly in `external_action.payload`; payloads carry either the routed adult recipient decision or enough immutable context to prove the routing decision used at execution time.
|
|
75
|
+
|
|
76
|
+
`status` transitions are constrained: `pending → in_flight → {succeeded, dead_lettered, pending}` (the trailing `pending` covers the `retriable_failure` and `pending` classifications, which schedule a future attempt). `cancelled` is a terminal state set by either producer-driven cancellation through the SDK or operator-driven cancellation through the resolve surface. `resolved` is a terminal state for a dead-lettered action the operator has disposed without re-queueing.
|
|
77
|
+
|
|
78
|
+
`attempt_count` increments BEFORE writing the attempt, so an attempt that crashes before completing still leaves an audit trail (the next attempt sees `attempt_count = N+1` where N is the count after the previous successful write; the runner reconciles on restart).
|
|
79
|
+
|
|
80
|
+
`originating_event_id` SHOULD be present when the action is kicked off by a dispatcher event. Producers that emit actions from synchronous server actions (no inbound event) leave it null.
|
|
81
|
+
|
|
82
|
+
`idempotency_key` MUST be stable for the lifetime of the action. If the producer supplies an `idempotencyKey` at enqueue time, the queue stores that value and treats a duplicate enqueue with the same key as a no-op. If the producer omits it, the queue mints one deterministically from the `xa_` action id. The same stored key is passed to the handler on every attempt, including retries and `pending` polls.
|
|
83
|
+
|
|
84
|
+
## 5. The `external_action_attempt` row
|
|
85
|
+
|
|
86
|
+
Each attempt persists as one row.
|
|
87
|
+
|
|
88
|
+
### 5.1 Required fields
|
|
89
|
+
|
|
90
|
+
| Field | Type | Description |
|
|
91
|
+
|-------|------|-------------|
|
|
92
|
+
| `id` | text, `xat_<UUID v7 canonical>` per ADR-0002 | Attempt identifier. Globally unique. |
|
|
93
|
+
| `action_id` | text, FK to `external_action.id` | Which action this attempt belongs to. |
|
|
94
|
+
| `attempt_number` | integer | 1-indexed sequence within the action. |
|
|
95
|
+
| `started_at` | timestamp | Runner-side wall-clock at handler invocation. |
|
|
96
|
+
| `ended_at` | timestamp | Runner-side wall-clock at handler return (or timeout). |
|
|
97
|
+
| `classification` | enum | `succeeded`, `terminal_failure`, `retriable_failure`, or `pending`. |
|
|
98
|
+
|
|
99
|
+
### 5.2 Optional fields
|
|
100
|
+
|
|
101
|
+
| Field | Type | Description |
|
|
102
|
+
|-------|------|-------------|
|
|
103
|
+
| `response` | jsonb | The handler's structured response (the partner system's response payload, parsed). Present on `succeeded` and SHOULD be present on `terminal_failure` and `retriable_failure` if the partner returned a structured body. |
|
|
104
|
+
| `error_message` | text | Single-line error summary on non-`succeeded` classifications. |
|
|
105
|
+
| `error_stack` | text | Full stack trace on non-`succeeded` classifications. Operator audit only. |
|
|
106
|
+
| `poll_until` | timestamp | On `pending` classification, the deadline past which the action SHOULD escalate to dead-letter rather than continue polling. Producer-supplied per attempt. |
|
|
107
|
+
|
|
108
|
+
### 5.3 Field rules
|
|
109
|
+
|
|
110
|
+
Attempts are append-only. Once an attempt row is written it MUST NOT be mutated. Re-running an attempt produces a new row with the next `attempt_number`.
|
|
111
|
+
|
|
112
|
+
`classification` is the single field that drives the runner's next move. The runner does not inspect `error_message`, `response`, etc. when deciding what to do next; the handler is responsible for compressing the partner system's response down to one of four classifications.
|
|
113
|
+
|
|
114
|
+
## 6. The producer SDK
|
|
115
|
+
|
|
116
|
+
The Platform-supplied SDK exposes one primary function for enqueueing.
|
|
117
|
+
|
|
118
|
+
### 6.1 `enqueueExternalAction`
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
export async function enqueueExternalAction(input: {
|
|
122
|
+
producer: Domain;
|
|
123
|
+
provider: string;
|
|
124
|
+
actionKind: string;
|
|
125
|
+
payload: unknown;
|
|
126
|
+
originatingEventId?: EventId;
|
|
127
|
+
externalIdSnapshot?: { provider: string; externalId: string };
|
|
128
|
+
correlationHandle?: string;
|
|
129
|
+
idempotencyKey?: string;
|
|
130
|
+
client?: PrismaClient | TransactionClient;
|
|
131
|
+
}): Promise<ExternalActionId>;
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The SDK also exposes producer-driven cancellation:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
export async function cancelExternalAction(input: {
|
|
138
|
+
externalActionId: ExternalActionId;
|
|
139
|
+
cancellationReason: string;
|
|
140
|
+
client?: PrismaClient | TransactionClient;
|
|
141
|
+
}): Promise<void>;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Producers MUST pass `client: tx` when calling from inside `prisma.$transaction(async (tx) => …)` so the action enqueue and the producer-side row write land atomically. Producers that enqueue outside a transaction MAY omit `client`; in that case the SDK uses the default Prisma client.
|
|
145
|
+
|
|
146
|
+
Producers MUST set `producer` to their own domain. The SDK validates the value against the calling module's known domain at startup; cross-domain enqueues are rejected at the type system if possible and at runtime otherwise.
|
|
147
|
+
|
|
148
|
+
`provider` and `actionKind` MUST match a (provider, action_kind) pair that has a registered handler. The SDK validates registration at startup; an enqueue against an unregistered handler raises immediately. Unregistered enqueues are a producer bug, not a runtime condition.
|
|
149
|
+
|
|
150
|
+
`idempotencyKey`, when set, MAY produce a return value pointing at an already-enqueued action with the same key. Producers MUST handle the return value as "the action is enqueued; might already be in flight" rather than assuming a fresh enqueue.
|
|
151
|
+
|
|
152
|
+
`cancelExternalAction` is idempotent. Cancelling an already-cancelled action is a no-op. Cancelling an action that has already reached `succeeded`, `dead_lettered`, or `resolved` returns a conflict rather than rewriting history. Producers SHOULD call it inside the same transaction as the domain write that records the upstream business cancellation.
|
|
153
|
+
|
|
154
|
+
### 6.2 What the SDK does NOT do
|
|
155
|
+
|
|
156
|
+
The SDK does not execute the handler synchronously. Enqueue is a write to the `external_action` table only; the runner picks up the row on its next tick.
|
|
157
|
+
|
|
158
|
+
The SDK does not retry the enqueue on transient Postgres failure. Producers compose `enqueueExternalAction` inside their own transactions; transaction-level retry is the producer's responsibility.
|
|
159
|
+
|
|
160
|
+
The SDK does not return the handler's response. Handlers run asynchronously; producers that need the response correlate via `correlation_handle` or `originating_event_id` and read the relevant `external_action_attempt` row out of band.
|
|
161
|
+
|
|
162
|
+
## 7. The handler contract
|
|
163
|
+
|
|
164
|
+
Each producing domain implements a handler per (provider, action_kind) pair.
|
|
165
|
+
|
|
166
|
+
### 7.1 Interface
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
export interface ExternalActionHandler<TPayload, TResponse> {
|
|
170
|
+
readonly provider: string;
|
|
171
|
+
readonly actionKind: string;
|
|
172
|
+
execute(
|
|
173
|
+
payload: TPayload,
|
|
174
|
+
context: HandlerContext,
|
|
175
|
+
): Promise<HandlerResult<TResponse>>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface HandlerContext {
|
|
179
|
+
readonly actionId: ExternalActionId;
|
|
180
|
+
readonly idempotencyKey: string;
|
|
181
|
+
readonly attemptNumber: number;
|
|
182
|
+
readonly enqueuedAt: Date;
|
|
183
|
+
readonly originatingEventId: EventId | null;
|
|
184
|
+
readonly externalIdSnapshot: { provider: string; externalId: string } | null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type HandlerResult<TResponse> =
|
|
188
|
+
| { classification: "succeeded"; response: TResponse }
|
|
189
|
+
| { classification: "terminal_failure"; error: string; response?: TResponse }
|
|
190
|
+
| { classification: "retriable_failure"; error: string; response?: TResponse }
|
|
191
|
+
| { classification: "pending"; pollUntil?: Date };
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Handlers MUST forward `context.idempotencyKey` to the partner system using that provider's idempotency mechanism when one exists (for example Square and Stripe `Idempotency-Key` headers). When a provider has no explicit idempotency surface, the handler MUST use the key as the producer-side correlation key in logs and response reconciliation. The key is the primary defense against duplicate external side effects across retriable failures and `pending` polls; producer-side payload-composite dedup is defense in depth, not a substitute.
|
|
195
|
+
|
|
196
|
+
### 7.2 Classification semantics
|
|
197
|
+
|
|
198
|
+
`succeeded`. The action completed at the partner system. The runner writes the attempt with classification `succeeded`, sets the action status to `succeeded`, and stops.
|
|
199
|
+
|
|
200
|
+
`terminal_failure`. The action will not succeed on retry. The runner writes the attempt and sets the action status to `dead_lettered` with `dead_letter_reason` derived from the handler's error message. Examples: validation errors at the partner system, authentication failures (until credentials change), `404 Not Found` on a resource the action references.
|
|
201
|
+
|
|
202
|
+
`retriable_failure`. The action might succeed on retry. The runner writes the attempt, schedules `next_attempt_at` per the backoff schedule, and re-queues the action as `pending`. After the retry budget is exhausted (per §8.2), the action escalates to `dead_lettered`. Examples: connection timeout, `500 Internal Server Error` from the partner, rate limit exceeded.
|
|
203
|
+
|
|
204
|
+
`pending`. The action is accepted at the partner system but the response is not yet available. The runner writes the attempt, schedules `next_attempt_at` per the poll cadence, and re-queues the action as `pending`. The handler MAY supply `pollUntil`; if the partner doesn't resolve by that time, the runner escalates to `dead_lettered`. Examples: Square refund settlement (typically minutes), webhook delivery confirmation from a system that ack's asynchronously.
|
|
205
|
+
|
|
206
|
+
### 7.3 Handler timeout
|
|
207
|
+
|
|
208
|
+
Handler execution is bounded at 30 seconds per attempt by default. Producers that need a longer timeout MUST opt in by registering a handler-specific timeout at registration time. Handlers that exceed their timeout return a synthetic `retriable_failure` attempt with `error_message = "handler timeout exceeded"` and standard backoff applies.
|
|
209
|
+
|
|
210
|
+
### 7.4 Handler registration
|
|
211
|
+
|
|
212
|
+
Each producing domain registers its handlers at module-load time. The Platform SDK exposes a registration surface:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import { registerHandler } from "@platform/external-actions-sdk";
|
|
216
|
+
import { squareRefundCreateHandler } from "./square/refund";
|
|
217
|
+
|
|
218
|
+
registerHandler(squareRefundCreateHandler);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Registration MUST occur before the runner picks up an action of that (provider, action_kind). The runner verifies registration at boot and refuses to start if any (provider, action_kind) referenced by `pending` actions in the table has no registered handler.
|
|
222
|
+
|
|
223
|
+
## 8. Retry classification and backoff
|
|
224
|
+
|
|
225
|
+
### 8.1 Backoff schedule
|
|
226
|
+
|
|
227
|
+
Default backoff is exponential with jitter:
|
|
228
|
+
|
|
229
|
+
| Attempt # | Base delay | Jitter window |
|
|
230
|
+
|-----------|------------|---------------|
|
|
231
|
+
| 1 → 2 | 5 seconds | ±2 seconds |
|
|
232
|
+
| 2 → 3 | 30 seconds | ±10 seconds |
|
|
233
|
+
| 3 → 4 | 2 minutes | ±30 seconds |
|
|
234
|
+
| 4 → 5 | 10 minutes | ±2 minutes |
|
|
235
|
+
| 5 → 6 | 1 hour | ±10 minutes |
|
|
236
|
+
| 6 → 7 | 6 hours | ±30 minutes |
|
|
237
|
+
|
|
238
|
+
After the 7th attempt, the action escalates to `dead_lettered` regardless of classification.
|
|
239
|
+
|
|
240
|
+
### 8.2 Retry budget per (provider, action_kind)
|
|
241
|
+
|
|
242
|
+
The default of 7 attempts MAY be overridden per (provider, action_kind) at handler registration time. Producers SHOULD raise the cap for actions with a long natural delay (e.g., webhook acknowledgments from systems that retry on hour-long cycles) and lower it for actions that should fail fast (e.g., real-time SMS sends where a delayed retry has no value).
|
|
243
|
+
|
|
244
|
+
### 8.3 Pending poll cadence
|
|
245
|
+
|
|
246
|
+
`pending` classification uses a flat 30-second poll interval with ±10s jitter, regardless of attempt number. The handler MAY override the next poll time by returning `pollUntil` plus the next attempt's natural cadence; the runner schedules the smaller of the two.
|
|
247
|
+
|
|
248
|
+
### 8.4 Dead-lettering and operator resolve
|
|
249
|
+
|
|
250
|
+
An action reaches `dead_lettered` when:
|
|
251
|
+
- A handler returns `terminal_failure`.
|
|
252
|
+
- The retry budget is exhausted on `retriable_failure`.
|
|
253
|
+
- The `poll_until` deadline passes on `pending`.
|
|
254
|
+
|
|
255
|
+
An action reaches `cancelled` separately when the producer calls `cancelExternalAction` before execution completes, or when an operator resolves a dead-lettered action with `disposition = cancelled`.
|
|
256
|
+
|
|
257
|
+
Operators resolve dead-lettered actions via the routes in §9.
|
|
258
|
+
|
|
259
|
+
## 9. Read and operator surface
|
|
260
|
+
|
|
261
|
+
The Platform repo exposes reconciliation reads for producing domains and operator routes for dead-letter disposition.
|
|
262
|
+
|
|
263
|
+
### 9.1 Reconciliation reads
|
|
264
|
+
|
|
265
|
+
Producing domains read queue state through Platform HTTP APIs rather than cross-database Prisma reads.
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
GET /api/external-actions?producer=revenue&provider=square&status=in_flight,dead_lettered&since=2026-05-01
|
|
269
|
+
GET /api/external-actions/{id}
|
|
270
|
+
GET /api/external-actions/{id}/attempts
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The list endpoint requires `producer` and supports optional `provider`, `status` (CSV), `actionKind`, `since`, and `limit` filters. `limit` defaults to 100 and caps at 500. Responses include the action row, `external_id_snapshot`, `correlation_handle`, `idempotency_key`, and enough attempt summary data for reconciliation runbooks to correlate provider-side state back to producer-owned domain rows.
|
|
274
|
+
|
|
275
|
+
### 9.2 `POST /api/external-actions/{id}/resolve`
|
|
276
|
+
|
|
277
|
+
Marks a dead-lettered action as resolved. Operator-only; requires an authenticated session.
|
|
278
|
+
|
|
279
|
+
Request body:
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
{
|
|
283
|
+
"resolved_by": "<operator-id>",
|
|
284
|
+
"disposition": "manual_reconciliation",
|
|
285
|
+
"resolution_note": "<optional free text>"
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`disposition` is one of `manual_reconciliation`, `abandoned`, or `cancelled`. `manual_reconciliation` means the operator verified the partner-side outcome and reconciled local state. `abandoned` means the action will not be retried and the producing domain must apply any compensating business state it requires. `cancelled` means the upstream business decision has been reversed and the action should not execute.
|
|
290
|
+
|
|
291
|
+
Response: 200 with the updated action row. Resolution sets `resolved_at`, `resolved_by`, `disposition`, and `resolution_note` on the action; `status` flips from `dead_lettered` to `resolved`, except `disposition = cancelled` flips to `cancelled`. Resolution is one-shot: 409 on a row that was already resolved or cancelled.
|
|
292
|
+
|
|
293
|
+
### 9.3 `POST /api/external-actions/{id}/retry`
|
|
294
|
+
|
|
295
|
+
Re-queues a dead-lettered action for one more attempt. Operator-only.
|
|
296
|
+
|
|
297
|
+
Request body:
|
|
298
|
+
|
|
299
|
+
```json
|
|
300
|
+
{
|
|
301
|
+
"operator_note": "<free text describing why retry is appropriate>"
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Response: 200 with the action's new `pending` status. Retry resets `next_attempt_at` to now, increments a separate `operator_retry_count`, and writes an audit entry. After 3 operator retries on the same action, further retries return 409 (the operator surface deliberately makes "retry forever" non-trivial; if 3 retries didn't fix it, escalation to root cause is the right move).
|
|
306
|
+
|
|
307
|
+
### 9.4 `GET /api/external-actions/dead-lettered`
|
|
308
|
+
|
|
309
|
+
Returns the list of active (un-resolved, un-cancelled) dead-lettered actions, optionally filtered by producer or provider. Operator dashboard read.
|
|
310
|
+
|
|
311
|
+
## 10. Versioning
|
|
312
|
+
|
|
313
|
+
This contract follows the same `@vMAJOR.MINOR.PATCH` cadence as the Event Envelope Contract.
|
|
314
|
+
|
|
315
|
+
MAJOR bumps for breaking changes (column removal, behavior reversal). MINOR bumps for additive changes (new optional column, new classification value). PATCH bumps for clarification (wording fixes, examples).
|
|
316
|
+
|
|
317
|
+
Bumps require a memo announcing the change, with a soft response window for consumer ack per the operator standard.
|
|
318
|
+
|
|
319
|
+
## 11. Migration and rollout
|
|
320
|
+
|
|
321
|
+
The Airtable-era `External Actions` surface on the Growth repo does not migrate. Pre-existing rows finish processing on the legacy path under Growth's stewardship until they retire or dead-letter; the Postgres queue starts fresh on Platform's table at the moment producers start emitting against it.
|
|
322
|
+
|
|
323
|
+
Per ADR-0011 action item #5 and the operator standard (`_OPERATOR.md` Contracts section, two-week deprecation window), the three Growth-side routes (`POST /api/external-actions`, `/retry`, `/sync`) are absorbed by Platform as proxies during the deprecation window, then retire. Each absorb files its own reply memo on the carve-up thread.
|
|
324
|
+
|
|
325
|
+
## 12. References
|
|
326
|
+
|
|
327
|
+
- ADR-0011 (`../../adrs/ADR-0011-external-actions-at-platform.md`). The decision this contract specs.
|
|
328
|
+
- ADR-0009 (`../../adrs/ADR-0009-dispatcher-cross-process-transport.md`). The dispatcher transport whose producer-transactional-guarantee seam mirrors §6.1's `client?` parameter.
|
|
329
|
+
- ADR-0010 (`../../adrs/ADR-0010-provider-externals-at-platform.md`). The `pex_` resolution snapshot rides on every action via §4.2's `external_id_snapshot`.
|
|
330
|
+
- ADR-0002 (`../../adrs/ADR-0002-person-id-shape.md`). The entity ID prefix template; `xa_` and `xat_` register here.
|
|
331
|
+
- Event Envelope Contract (`../event-envelope/README.md`). Events that originate actions reference back via §4.2's `originating_event_id`.
|
|
332
|
+
- `2026-05-25-growth-api-carveup-inventory`. Growth's carve-up that flagged the three external-actions routes as cross-cutting and asked for the ADR.
|
|
333
|
+
- `2026-05-04-platform-growth-api-carveup-reply`. Platform's reply that took the cross-domain-infrastructure position and reserved ADR-0011.
|
|
334
|
+
|
|
335
|
+
## 13. Change log
|
|
336
|
+
|
|
337
|
+
- **v1.0.0** (2026-05-06) — Promoted on ADR-0011 acceptance. Folded Revenue's review notes into the normative surface: stable action-level idempotency keys in handler context, `cancelExternalAction`, producing-domain reconciliation reads, and explicit DLQ disposition vocabulary. Folded Sales and Delivery's comms-routing notes into producer payload rules: Guardian-aware routing remains mandatory for minor-facing outbound communication, and minor contact details do not ride directly in queue payloads.
|
|
338
|
+
- **v0.1.0** (2026-05-04) — Initial draft paired with ADR-0011. Table pair, producer SDK, handler contract, retry classification, operator surface, and Growth route migration story.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Finance Mart Contract
|
|
2
|
+
|
|
3
|
+
**Status:** v2.0.0
|
|
4
|
+
**Date:** 2026-05-19
|
|
5
|
+
**Owner:** finance (derived finance store and the finance gold section)
|
|
6
|
+
**Consumers:** finance reporting consumers (leadership reporting and accounting exports); no operational domain consumes this contract
|
|
7
|
+
**Related ADRs:** ADR-0015, ADR-0016, ADR-0006, ADR-0003, ADR-0013, ADR-0014
|
|
8
|
+
**Sub-specs (authoritative):** revenue-recognition-rollup.md, margin.md, cash-position.md, reconciliation.md (v1.0.0 manifests carrying forward to the named v2.0.0 sub-sections); unit-economics.md, pnl.md, cac-payback.md, ltv.md, cohort-summary.md, customer-journey-audit.md (v2.0.x manifests landed 2026-05-19)
|
|
9
|
+
**Validations:** n/a in v2.0.0; the reconciliation validation note, the consumer-isolation validation note, and the customer-grain audit-posture validation note land under `validation/` as the derived finance store models ship
|
|
10
|
+
|
|
11
|
+
## 1. Purpose and scope
|
|
12
|
+
|
|
13
|
+
The Finance Mart Contract specifies the read surface of the `finance` section of the gold mart (ADR-0016): the financial view of Sguild across domains that leadership reporting and accounting exports read, namely unit economics, profit and loss, margin, customer acquisition cost and payback, cash runway, lifetime value, and reconciliation against Revenue's transaction-grain ledger, at the business reporting grain. Finance composes these metrics over the warehouse silver tier and the conformed identity and geography spine, materializes them into its derived finance store, and serves them through this contracted section. The contract is what lets finance reporting consumers build on a stable surface rather than on Finance's internal store shape, and it is the artifact that defines the firewall bound for the finance consumer tag per ADR-0016.
|
|
14
|
+
|
|
15
|
+
Finance is a cross-domain reporting domain per ADR-0015. It owns no source-of-record data, mints no role records, and produces no domain events. Every fact in this contract originates in another domain and reaches Finance through silver. The contract therefore specifies a read surface and the guarantees Finance makes about it; it specifies no write path, no event, and no operational behavior.
|
|
16
|
+
|
|
17
|
+
In scope: the served surface and how it is read, the grain and dimensions the mart is keyed by, the metric groupings (six sub-sections plus one proposed sub-section) the section exposes, the §8 customer-grain audit carve-out absorbing Portfolio's §9.3 cohort_customer_audit per the path-1 consolidation, the cross-cutting reconciliation guarantee, the versioning and deprecation policy, and the consumer and producer responsibilities. Out of scope: the silver tier shape (the `warehouse-silver` contract, owned by Platform), the gold mart shell and the consumer-firewall mechanism (Platform, per ADR-0016), Revenue's transaction-grain ledger and the orders, payments, credit reservations, and refunds behind it (Revenue owns these, per ADR-0006; finance-mart reconciles against them and does not restate them), strategy reporting metrics (the `portfolio-mart` contract, owned by Portfolio per ADR-0015), any operational domain's per-domain gold section, canonical geography and Organization records (Platform, per ADR-0013 and ADR-0014), Person identity (Platform), coordination-layer artifacts including initiative attribution (Platform's coordination surfaces). If a need to write a fact rather than report one appears, that is a domain-kind boundary question per ADR-0015, not a finance-mart change.
|
|
18
|
+
|
|
19
|
+
## 2. Normative language
|
|
20
|
+
|
|
21
|
+
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted per RFC 2119.
|
|
22
|
+
|
|
23
|
+
## 3. Terminology
|
|
24
|
+
|
|
25
|
+
- **Finance section**: the `finance` section of the single gold mart (ADR-0016), built across silver faces and the spine rather than over a single warehouse. The materialized surface this contract governs.
|
|
26
|
+
- **Derived finance store**: Finance's own Prisma deployment, where composed financial metrics are materialized before they are served. An implementation detail of the producer, not part of the contracted surface.
|
|
27
|
+
- **Silver face**: one conforming dbt view per warehouse, per the `warehouse-silver` contract. Finance reads across the five faces (growth, sales, delivery, coaching, revenue).
|
|
28
|
+
- **Identity and geography spine**: the conformed reference dimension carrying the Person dimension and the Organization to Market to Service Area to Lesson Site geography hierarchy, per the `warehouse-silver` contract.
|
|
29
|
+
- **Business reporting grain**: the grain at which Finance reports for aggregate metrics, namely a reporting period and a place in the geography hierarchy. Distinct from transaction grain.
|
|
30
|
+
- **Transaction grain**: the grain at which Revenue holds commercial truth, namely the individual order, payment, credit reservation, ledger entry, or refund. Finance reconciles to this grain; it does not report at it.
|
|
31
|
+
- **Cohort grain**: per-org-market × per-intake-cohort, with intake-week defining the cohort. Used by `cac_payback`, `ltv`, and `cohort_summary` sub-sections.
|
|
32
|
+
- **Customer grain**: per-customer (one row per `customer_id`). Reserved for the §8 customer-grain audit carve-out; aggregate metrics never expose customer grain.
|
|
33
|
+
- **Recognized revenue**: revenue attributed to a reporting period under Finance's recognition logic, as distinct from cash received or amounts invoiced.
|
|
34
|
+
- **Reconciliation**: the cross-cutting guarantee that a business-grain figure on finance-mart ties back to Revenue's transaction-grain ledger within a stated tolerance, and that the variance is itself surfaced.
|
|
35
|
+
- **Finance reporting consumer**: a leadership-facing or accounting-facing report or export that reads the finance section. Not a domain in the closed set; no operational domain consumes this contract.
|
|
36
|
+
|
|
37
|
+
## 4. Surface
|
|
38
|
+
|
|
39
|
+
### 4.1 The served surface
|
|
40
|
+
|
|
41
|
+
The finance section is materialized, not computed per query, per the gold tier model in ADR-0016. Consumers read the section through the gold mart's consumer-firewall mechanism: the finance consumer tag is bounded by this contract, so a finance reporting consumer sees the cross-silver composition this contract defines and nothing outside it. The firewall bound is contract-shaped rather than warehouse-shaped, which is the distinction ADR-0016 draws between a reporting section and an operational section.
|
|
42
|
+
|
|
43
|
+
Every row the section serves carries an `as_of` timestamp (UTC ISO 8601) recording the refresh point of the underlying materialization. Consumers SHALL treat `as_of` as the freshness signal and SHALL NOT assume the section is real-time; it is materialized on the gold refresh cadence, which Platform owns and documents.
|
|
44
|
+
|
|
45
|
+
The v2.0.0 surface is organized into six sub-sections (`unit_economics`, `pnl`, `margin`, `cac_payback`, `runway`, `ltv`) covering 14 metrics, one proposed seventh sub-section (`cohort_summary`) covering the cohort-grain financial columns Portfolio's `cohort_funnel_panel` cannot serve, the §8 customer-grain audit carve-out (`customer_journey_audit`, consolidating Portfolio's §9.3 `cohort_customer_audit` per the 2026-05-19 path-1 resolution), and the cross-cutting reconciliation guarantee. The grouping topology aligns with the Them OS data-mart forecasting spec §8 per the 2026-05-19 registry encoding decision.
|
|
46
|
+
|
|
47
|
+
### 4.2 Grain and dimensions
|
|
48
|
+
|
|
49
|
+
Finance metrics are reported at one of three grains, all keyed from the conformed identity and geography spine:
|
|
50
|
+
|
|
51
|
+
- **Business reporting grain**: a reporting period × geography_key, where geography_key is the (Organization, Market, Service Area) tuple per ADR-0013 and ADR-0014. Used by `pnl`, `margin`, and most of `runway`. The smallest reporting period a metric supports is named in that metric's manifest.
|
|
52
|
+
- **Cohort grain**: per-org-market × per-intake-cohort, with intake-week defining the cohort. Used by `cac_payback`, `ltv`, and `cohort_summary`. Some metrics carry a second dimension (intake-week × current-week) for cumulative cohort views.
|
|
53
|
+
- **Reporting date**: a point-in-time grain. Used by `cash_position` and `runway_weeks` in the `runway` sub-section.
|
|
54
|
+
|
|
55
|
+
The §8 customer-grain audit carve-out is the only customer-grain surface in the section; aggregate metrics never expose customer grain. Per ADR-0014, per-discipline financial books follow the per-Organization boundary; cash is held at Organization grain per the `_cash_pooling_model: pooled_at_organization` declaration in `runway` §4.7.
|
|
56
|
+
|
|
57
|
+
### 4.3 `unit_economics`
|
|
58
|
+
|
|
59
|
+
Unit economics metrics: per-active-student revenue intensity, revenue concentration, and other operational-unit-level economic ratios. Composed across Revenue's recognition and Delivery's active-student counts.
|
|
60
|
+
|
|
61
|
+
Metrics in v2.0.0:
|
|
62
|
+
|
|
63
|
+
- `revenue_per_active_student`: GAAP earned revenue summed by `org_market × lesson_week` divided by Delivery's `active_student_count` summed by `org_market × snapshot_week` (snapshot-week-end value). Cohort floor: `active_student_count < 5` emits `null:insufficient_data`. Rollup rule: `weighted_mean_by_revenue`. Absorbed from Revenue's section per `2026-05-19-platform-mart-revenue-per-active-student-relocation` and Finance's acceptance in `2026-05-19-finance-revenue-per-active-student-absorption`.
|
|
64
|
+
- `revenue_concentration`: top-N customer revenue share of total revenue at `org_market × reporting_period`. Top-N cutoff declared in the sub-section manifest. Rollup rule: `weighted_mean_by_revenue` (the concentration ratio rolls up weighted, not summed).
|
|
65
|
+
|
|
66
|
+
Both metrics gate on Delivery's `active_student_count` reaching `beta` (the `delivery.customer.status='active'` silver column confirmed per `2026-05-19-delivery-revenue-per-active-student-ack`) and on Revenue's recognition compute reaching `beta`.
|
|
67
|
+
|
|
68
|
+
### 4.4 `pnl`
|
|
69
|
+
|
|
70
|
+
Profit and loss metrics: recognized revenue, P&L summary composing revenue minus cost-of-service minus operating expense, at the business reporting grain.
|
|
71
|
+
|
|
72
|
+
Metrics in v2.0.0:
|
|
73
|
+
|
|
74
|
+
- `revenue_recognition_summary`: recognized revenue for a reporting period, rolled up across domains and sliced by the geography hierarchy. Carries forward from v1.0.0 §4.3 with the manifest at `revenue-recognition-rollup.md`. Composed from the revenue silver face and the spine; reconcilable per §4.11.
|
|
75
|
+
- `pnl_summary`: composite P&L view at `reporting_period × geography_key`. Numerator is recognized revenue minus cost-of-service minus operating expense. Cost-of-service silver gate per Revenue's P2/L ask; operating-expense allocation rule named in the sub-section manifest. Rollup: `sum`.
|
|
76
|
+
|
|
77
|
+
### 4.5 `margin`
|
|
78
|
+
|
|
79
|
+
Margin metrics: gross margin and operating margin at the business reporting grain.
|
|
80
|
+
|
|
81
|
+
Metrics in v2.0.0:
|
|
82
|
+
|
|
83
|
+
- `gross_margin`: recognized revenue minus cost-of-service at `reporting_period × geography_key`, with the manifest at `margin.md` (carrying forward from v1.0.0 §4.4). Rollup rule: `sum` for absolute amount, `weighted_mean_by_revenue` for percentage.
|
|
84
|
+
- `gross_margin_pct`: gross margin as a percentage of recognized revenue. Rollup: `weighted_mean_by_revenue`.
|
|
85
|
+
- `contribution_margin`: gross margin minus directly-attributable marketing and sales cost. Per-cohort allocation rule named in the sub-section manifest.
|
|
86
|
+
- `operating_margin`: recognized revenue minus total operating cost. Rollup: `weighted_mean_by_revenue` for percentage.
|
|
87
|
+
|
|
88
|
+
All margin metrics gate on Revenue's cost-of-service silver columns landing.
|
|
89
|
+
|
|
90
|
+
### 4.6 `cac_payback`
|
|
91
|
+
|
|
92
|
+
Customer acquisition cost and payback metrics: per-cohort acquisition cost, time-to-payback, and payback distribution.
|
|
93
|
+
|
|
94
|
+
Metrics in v2.0.0:
|
|
95
|
+
|
|
96
|
+
- `cac`: per-cohort customer acquisition cost. Cost decomposition: `_cac_cost_decomposition: marketing_plus_sales_sdr` per the 2026-05-19 100-percent-deployment decision. Numerator composes Growth `ad_spend_reconciled` plus Sales SDR-time allocation per cohort; denominator is per-cohort first-lock count. Grain: `cohort_week × geography_key`. Rollup: `weighted_mean_by_cohort_size`.
|
|
97
|
+
- `cac_payback_weeks`: weeks until cumulative gross margin per cohort meets or exceeds CAC. Definition: `_payback_definition: cumulative_gross_margin_geq_cac`. Bucket granularity per `payback_distribution`.
|
|
98
|
+
- `payback_distribution`: histogram of cohort payback across monthly buckets. `_payback_bucket_granularity: monthly`. Cohorts with no payback within the observation window emit `null:insufficient_observation_window`.
|
|
99
|
+
|
|
100
|
+
Gates: Growth attribution silver, Revenue's `ad_spend_reconciled` cross-warehouse compute, Sales cohort-triangle silver carrying SDR-time allocation per cohort. If Sales does not expose SDR-time allocation, Finance falls back to marketing-only CAC and supplements via a registry edit memo.
|
|
101
|
+
|
|
102
|
+
### 4.7 `runway`
|
|
103
|
+
|
|
104
|
+
Cash runway metrics: cash position, burn rate, weeks of runway at the reporting date.
|
|
105
|
+
|
|
106
|
+
Metrics in v2.0.0:
|
|
107
|
+
|
|
108
|
+
- `cash_position`: cash held by the Organization at the reporting date, with market-grain and service-area-grain rows as informational allocations per `_cash_pooling_model: pooled_at_organization`. Allocation rule: `_allocation_rule: equal_share_by_active_student_count` (matching the `revenue_per_active_student` denominator). Rows carry `_balance_kind: held` at Organization grain or `_balance_kind: allocated` at market and service-area grain. Manifest at `cash-position.md` (carrying forward from v1.0.0 §4.5 with the v2.0.0 pooling-and-allocation extension).
|
|
109
|
+
- `burn_rate`: net negative cash flow over the reporting period at `reporting_period × geography_key`. Gates on Revenue's cost-of-service silver and on operating-expense allocation. Rollup: `sum`.
|
|
110
|
+
- `runway_weeks`: current cash divided by trailing-three-month average burn at `reporting_date × geography_key`. Formula: `_runway_formula: current_cash_div_trailing_3mo_avg_burn` per the 2026-05-19 100-percent-deployment decision. Emits `null:not_applicable` when burn is non-positive over the trailing window. Rollup: `weighted_mean_by_cash_position`.
|
|
111
|
+
|
|
112
|
+
### 4.8 `ltv`
|
|
113
|
+
|
|
114
|
+
Lifetime value metrics: per-cohort customer lifetime value.
|
|
115
|
+
|
|
116
|
+
Metrics in v2.0.0:
|
|
117
|
+
|
|
118
|
+
- `ltv_per_first_lock_cohort`: cumulative per-customer gross margin since intake, summed across the cohort, divided by distinct customer count. Decomposition: `_ltv_decomposition: gross_margin` per the 2026-05-19 100-percent-deployment decision. Grain: `cohort_week × geography_key`. Gates on cost-of-service silver (for gross margin) plus Sales cohort-triangle silver (for cohort identity) plus Revenue cash-ledger silver (for customer-grain cumulative revenue). Rollup: `weighted_mean_by_cohort_size`.
|
|
119
|
+
|
|
120
|
+
### 4.9 `cohort_summary` (proposed)
|
|
121
|
+
|
|
122
|
+
Proposed seventh sub-section covering the cohort-grain financial columns Portfolio's `cohort_funnel_panel` cannot serve per portfolio-mart §4.4. Finance's position in `2026-05-19-finance-cohort-panel-financial-columns-position` selected this as Portfolio's option-2 resolution; the Them OS caller assembles the full cross-domain panel by joining Portfolio's `cohort_funnel_panel` (non-financial columns) to this sub-section (financial columns).
|
|
123
|
+
|
|
124
|
+
Metrics in v2.0.0 (pending Platform encoding confirmation):
|
|
125
|
+
|
|
126
|
+
- `revenue_earned_by_cohort`: GAAP earned revenue at `org_market × intake_cohort × current_week`. Period attribution: intake-week for the cohort key, current-week for the cumulative state. Rollup: `sum`.
|
|
127
|
+
- `revenue_paid_by_cohort`: cash revenue at the same grain. Rollup: `sum`.
|
|
128
|
+
|
|
129
|
+
If Platform reads the §8 spec text as placing cohort-grain financial views inside `unit_economics` instead of as a separate sub-section, Finance follows the spec and migrates the two metrics under `unit_economics` in a v2.0.x patch.
|
|
130
|
+
|
|
131
|
+
### 4.10 §8 customer-grain audit carve-out: `customer_journey_audit`
|
|
132
|
+
|
|
133
|
+
The §8 carve-out consolidates Portfolio's §9.3 `cohort_customer_audit` per the path-1 consolidation in `2026-05-19-portfolio-mart-scope-resolution-path-1-accepted` and Finance's acceptance terms in `2026-05-19-finance-mart-registry-portfolio-consolidation-position` and `2026-05-19-finance-customer-audit-consolidation-cleanness`.
|
|
134
|
+
|
|
135
|
+
Row shape: per-customer audit, one row per `customer_id`, returning the customer's journey snapshot across Revenue, Delivery, and the cohort it intook into. Fields: `customer_id`, `intake_week`, `first_lock_week`, `first_delivery_lesson_week`, `lessons_completed_to_date`, `revenue_paid_to_date`, `current_status`. The cohort dimension is a slicing filter off `intake_week`, not a parallel row shape.
|
|
136
|
+
|
|
137
|
+
Field excluded from the row: `current_initiative_attribution`. Initiative attribution is a coordination-layer artifact, not a financial fact; consumers needing initiative attribution join to the Platform-owned coordination surface (the `/initiatives` view or its mart-surface equivalent if §10 returns to the Them OS spec) at audit time.
|
|
138
|
+
|
|
139
|
+
Posture: audit-only, rate-limited, tagged `cross_domain_customer_grain=true`. The carve-out is bound to this contract; Portfolio consumers reading the audit do so through Finance's access pattern. The rate-limit, the firewall bound, and the deprecation discipline are Finance's.
|
|
140
|
+
|
|
141
|
+
### 4.11 Reconciliation cross-cutting guarantee
|
|
142
|
+
|
|
143
|
+
The reconciliation guarantee ties finance-mart to Revenue's authoritative records. For every reported figure in the sub-sections above, reconciliation exposes the tie back to Revenue's transaction-grain ledger and the variance against a stated tolerance. Revenue's ledger, orders, payments, credit reservations, and refunds are the authoritative transaction-grain source per ADR-0006; finance-mart reconciles against them and never restates or competes with them. A finance-mart figure that does not reconcile is a Finance incident per `domains/finance.md`, and reconciliation is where that condition is made visible rather than hidden. The reconciliation tolerance for each figure is named in the `reconciliation.md` manifest (carrying forward from v1.0.0 §4.6 with extension for the v2.0.0 metrics added in `unit_economics`, `cac_payback`, `ltv`, and `cohort_summary`).
|
|
144
|
+
|
|
145
|
+
Reconciliation is a guarantee that applies to every figure in every sub-section, not a sub-section itself. The §4.6 guarantee in v1.0.0 carries forward unchanged in substance.
|
|
146
|
+
|
|
147
|
+
### 4.12 What the finance section does not expose
|
|
148
|
+
|
|
149
|
+
- **No transaction-grain records.** No individual order, payment, credit reservation, ledger entry, or refund. A consumer needing transaction-grain truth reads Revenue's surface, not this one.
|
|
150
|
+
- **No strategy metrics.** Cross-Market, cross-discipline, and portfolio-level funnel metrics are the `portfolio-mart` contract's surface per ADR-0015. The boundary is subject: if the question is strategy rather than financial, it is Portfolio's.
|
|
151
|
+
- **No silver or bronze.** The finance section is a gold-section surface. A consumer reads finance-mart, not silver, not bronze, and not Finance's derived store.
|
|
152
|
+
- **No Person or geography source records.** Finance joins the spine for those facts and exposes only what a business-grain financial figure needs. Aggregate metrics are aggregate by default; the §8 audit carve-out is the only customer-grain exposure and carries the rate-limit and audit-only posture named in §4.10.
|
|
153
|
+
- **No initiative attribution.** Coordination-layer artifacts including initiative attribution are sourced from Platform-owned coordination surfaces, not from finance-mart. The §4.10 carve-out explicitly excludes `current_initiative_attribution` from its row shape.
|
|
154
|
+
- **No events.** Finance produces nothing on the event envelope.
|
|
155
|
+
|
|
156
|
+
## 5. Versioning policy
|
|
157
|
+
|
|
158
|
+
### 5.1 Semantic versioning
|
|
159
|
+
|
|
160
|
+
- **Patch** (2.0.0 to 2.0.1): editorial clarifications, typo fixes, manifest landings for previously declared sub-sections. No change to the served surface that breaks consumers.
|
|
161
|
+
- **Minor** (2.x.y to 2.x+1.0): additive changes. A new optional column on a sub-section, a new metric inside an existing sub-section, a new optional dimension, a new reconciliation dimension. Consumers on older minors continue to work.
|
|
162
|
+
- **Major** (2.x.y to 3.0.0): breaking changes. Removing or renaming a sub-section or a column, changing a column type, changing a grain, narrowing a dimensional key.
|
|
163
|
+
|
|
164
|
+
### 5.2 Deprecation policy
|
|
165
|
+
|
|
166
|
+
On major-version publication, the previous major enters a two-week deprecation window per the org contract-currency standard and the Finance quality bar in `domains/finance.md`. The v1.0.0 → v2.0.0 deprecation window opens 2026-05-19 (v2.0.0 publish date) and closes 2026-06-02 (two weeks later, aligning with the default-accept window on Platform's registry encoding per `2026-05-19-platform-mart-contract-registry-encoded`).
|
|
167
|
+
|
|
168
|
+
No finance reporting consumer SHALL run against v1.0.0 after 2026-06-02. v1.0.0 consumers running after the window contribute to drift and are a Finance quality-bar regression.
|
|
169
|
+
|
|
170
|
+
The deprecation window is planned before the new major ships, not after, per `contracts/README.md` and `finance-mart` §7. This contract's v2.0.0 publish FYI memo (`2026-05-19-finance-finance-mart-v2-publish-announcement`) names the window and the consumer migration path.
|
|
171
|
+
|
|
172
|
+
### 5.3 Additive discipline within a major
|
|
173
|
+
|
|
174
|
+
New columns MUST default to null or a backwards-compatible value. A new metric inside an existing sub-section is additive and does not change existing metrics. New sub-section manifests landing as v2.0.x patches (for `unit_economics`, `cac_payback`, `ltv`, `cohort_summary`, and the customer-grain audit carve-out) are additive landings, not new major versions; the sub-sections are declared in v2.0.0's §4 and the manifests follow as compute develops. Consumers MUST treat unknown columns, unknown metrics, and unknown sub-section manifests, added in a later patch or minor, as safe to ignore.
|
|
175
|
+
|
|
176
|
+
### 5.4 Upstream churn is not a contract change
|
|
177
|
+
|
|
178
|
+
A change to a silver face, the spine, or Revenue's ledger schema does not by itself bump this contract. Finance absorbs upstream churn in its composition logic. This contract bumps only when the served surface that finance reporting consumers see changes. That insulation is the point of the reporting-domain tier: silver insulates Finance from bronze, and this contract insulates finance reporting consumers from Finance's composition.
|
|
179
|
+
|
|
180
|
+
### 5.5 The v1.0.0 → v2.0.0 reshape rationale
|
|
181
|
+
|
|
182
|
+
v2.0.0 reshapes the served surface from v1.0.0's four-grouping topology (`revenue_recognition`, `margin`, `cash_position`, `reconciliation`) to v2.0.0's six-sub-section-plus-one-proposed topology (`unit_economics`, `pnl`, `margin`, `cac_payback`, `runway`, `ltv`, plus the proposed `cohort_summary`), plus the §8 customer-grain audit carve-out (consolidating Portfolio's §9.3 per the 2026-05-19 path-1 resolution), plus the cross-cutting reconciliation guarantee unchanged in substance. The reshape aligns the contract to the binding Them OS data-mart forecasting spec §8 per Finance's 2026-05-19 acceptance in `2026-05-19-finance-mart-registry-encoding-response`, Platform's confirmation in `2026-05-19-platform-mart-registry-finance-v2-path-confirmed`, and the per-metric metadata review filed in `2026-05-19-finance-mart-registry-per-metric-metadata-review`.
|
|
183
|
+
|
|
184
|
+
Migration mapping from v1.0.0 to v2.0.0:
|
|
185
|
+
|
|
186
|
+
- v1.0.0 `revenue_recognition` (§4.3) → v2.0.0 `pnl` §4.4 (`revenue_recognition_summary` carries the manifest `revenue-recognition-rollup.md`; `pnl_summary` is new).
|
|
187
|
+
- v1.0.0 `margin` (§4.4) → v2.0.0 `margin` §4.5 (`gross_margin` carries the manifest `margin.md`; `gross_margin_pct`, `contribution_margin`, `operating_margin` are new).
|
|
188
|
+
- v1.0.0 `cash_position` (§4.5) → v2.0.0 `runway` §4.7 (`cash_position` carries the manifest `cash-position.md` with the v2.0.0 pooling-and-allocation extension; `burn_rate`, `runway_weeks` are new).
|
|
189
|
+
- v1.0.0 `reconciliation` (§4.6) → v2.0.0 cross-cutting reconciliation guarantee §4.11 (manifest `reconciliation.md` carries forward; extended to cover the v2.0.0 added metrics).
|
|
190
|
+
|
|
191
|
+
New v2.0.0 sub-sections and their metric sets are declared in §4.3, §4.6, §4.8, §4.9, §4.10 with manifests landing as v2.0.x patches.
|
|
192
|
+
|
|
193
|
+
## 6. Consumer responsibilities
|
|
194
|
+
|
|
195
|
+
- A finance reporting consumer SHALL read the finance section through the consumer-firewall tag and SHALL NOT read silver, bronze, another gold section, or Finance's derived store directly. Consumer isolation is a Finance quality-bar signal per `domains/finance.md`.
|
|
196
|
+
- A consumer SHALL key on a sub-section's declared grain (business reporting, cohort, or reporting date per §4.2) and SHALL NOT infer a finer grain than the sub-section exposes. A consumer that needs transaction-grain detail SHALL read Revenue's surface, not finance-mart. A consumer that needs customer-grain detail SHALL use the §4.10 audit carve-out under its rate-limit posture, not a non-audit endpoint.
|
|
197
|
+
- A consumer SHALL treat `as_of` as the freshness signal and degrade gracefully when the section is materialized behind real time.
|
|
198
|
+
- A consumer SHALL reference reporting periods explicitly rather than assuming a default period, so that a contract minor that adds a period dimension does not silently change results.
|
|
199
|
+
- A consumer SHALL treat unknown columns, unknown metrics, unknown sub-section manifests, and unknown allocation-rule values, added in a later patch or minor, as safe to ignore.
|
|
200
|
+
- A consumer needing the cross-domain cohort panel (Portfolio's `cohort_funnel_panel` joined to Finance's `cohort_summary`) SHALL perform the join on `(org_market_id, intake_cohort)` at audit time; finance-mart does not serve the joined panel as a single endpoint.
|
|
201
|
+
- An operational domain SHALL NOT consume this contract as an input to operational behavior. The finance section is a reporting surface; an operational consumer of it is a trigger to revisit ADR-0015.
|
|
202
|
+
|
|
203
|
+
## 7. Producer responsibilities
|
|
204
|
+
|
|
205
|
+
- Finance SHALL keep every finance-mart figure reconcilable to Revenue's transaction-grain ledger within the stated tolerance, and SHALL surface variance through the reconciliation guarantee rather than absorbing it silently.
|
|
206
|
+
- Finance SHALL compose every metric over the warehouse silver tier and the conformed identity and geography spine, never over bronze and never by reaching into another domain's storage.
|
|
207
|
+
- Finance SHALL keep cross-domain financial composition in the finance section and SHALL NOT push a composed metric back into any per-domain warehouse.
|
|
208
|
+
- Finance SHALL resolve all cross-domain joins through `person_id` per ADR-0003 and SHALL source Person and geography facts from the spine, never from a shadow copy.
|
|
209
|
+
- Finance SHALL stamp every served row with an `as_of` timestamp reflecting the underlying materialization refresh.
|
|
210
|
+
- Finance SHALL plan a two-week deprecation window before publishing any major version, per §5.2, and SHALL announce each major to all domains by FYI memo, mirroring the warehouse-silver publication pattern. The v2.0.0 publish FYI is filed at `memos/2026/2026-05-19-finance-finance-mart-v2-publish-announcement.md`.
|
|
211
|
+
- Finance SHALL preserve the §4.10 audit-only, rate-limited posture on the customer-grain carve-out, and SHALL NOT serve customer-grain rows outside the carve-out's access pattern.
|
|
212
|
+
- Finance SHALL NOT emit events. Finance-mart is a reporting surface; if something in Finance looks like it wants to emit, it is a reporting artifact that belongs in the section or it belongs to an operational domain.
|
|
213
|
+
- Finance SHALL decline, in writing through a memo, any request that belongs to an operational domain, to Portfolio's strategy surface, or to Revenue's commercial surface, rather than absorbing it into this contract.
|
|
214
|
+
|
|
215
|
+
## 8. Security and privacy
|
|
216
|
+
|
|
217
|
+
The finance section is composed at the business reporting grain and is aggregate by default for the six sub-sections (`unit_economics`, `pnl`, `margin`, `cac_payback`, `runway`, `ltv`) plus the proposed `cohort_summary` sub-section; most figures carry no Person association at the served surface. The composition reads PII from the spine and the silver faces (`person_id` and conformed Person attributes) to compose and reconcile figures, but the served aggregate surface carries aggregates, not Person records.
|
|
218
|
+
|
|
219
|
+
The §4.10 customer-grain audit carve-out (`customer_journey_audit`) is the one exception: it serves per-customer rows under a strict rate-limit, audit-only posture, tagged `cross_domain_customer_grain=true`. The carve-out absorbs Portfolio's §9.3 `cohort_customer_audit` per the 2026-05-19 path-1 consolidation; Finance is the only domain serving customer-grain financial audit data in the mart. Access to the carve-out is bounded by Finance's rate-limit configuration and is logged for audit; access to the aggregate sub-sections is bounded by the Platform-owned consumer firewall scoped to this contract.
|
|
220
|
+
|
|
221
|
+
Finance SHALL apply log-hygiene discipline to `person_id` and any Person attribute touched during composition that any consumer of the spine applies. The consumer firewall, a gold-tier mechanism Platform owns, is what scopes the finance consumer tag to this contract's surface; access to the section is Platform-controlled through that mechanism, with the §4.10 carve-out's rate-limit as the additional gate.
|
|
222
|
+
|
|
223
|
+
Naming the fact for the next reader: the only customer-grain row shape in v2.0.0 is the §4.10 audit carve-out; aggregate sub-sections do not expose customer grain. A future sub-section that would change that is a major-version question per §5.1, not a minor.
|
|
224
|
+
|
|
225
|
+
## 9. Future work
|
|
226
|
+
|
|
227
|
+
- **The reconciliation validation note** under `validation/`, proving that business-grain figures tie back to Revenue's transaction-grain ledger within tolerance against live data, landing as the derived finance store models ship.
|
|
228
|
+
- **The consumer-isolation validation note** under `validation/`, proving that no finance reporting consumer reads outside the finance section against live traffic.
|
|
229
|
+
- **The customer-grain audit-posture validation note** under `validation/`, proving that the §4.10 carve-out's rate-limit and audit-only posture are enforced against live traffic, that `current_initiative_attribution` is excluded from the row shape, and that cohort-slicing filters do not relax the access pattern.
|
|
230
|
+
- **Per-sub-section manifests** for `unit_economics`, `cac_payback`, `ltv`, `cohort_summary`, and `customer-journey-audit` land as v2.0.x patches as each sub-section's compute develops. The v1.0.0-carried manifests (`revenue-recognition-rollup.md`, `margin.md`, `cash-position.md`, `reconciliation.md`) carry their figures forward immediately at v2.0.0 publish; new sub-section manifests follow.
|
|
231
|
+
- **Refresh-cadence alignment** with Platform's gold materialization decision (ADR-0016). When Platform documents the gold refresh cadence and staleness monitoring, this contract gains a stated freshness expectation for the finance section.
|
|
232
|
+
- **`cohort_summary` placement decision**: if Platform reads the §8 spec text as placing cohort-grain financial views inside `unit_economics`, Finance migrates the two cohort metrics under `unit_economics` in a v2.0.x patch per the position in `2026-05-19-finance-cohort-panel-financial-columns-position`.
|
|
233
|
+
|
|
234
|
+
## 10. Change log
|
|
235
|
+
|
|
236
|
+
- **v2.0.0** (2026-05-19): Major version. Reshapes the served surface from v1.0.0's four-grouping topology to six sub-sections (`unit_economics`, `pnl`, `margin`, `cac_payback`, `runway`, `ltv`) plus the proposed seventh sub-section `cohort_summary`, plus the §4.10 customer-grain audit carve-out `customer_journey_audit` (consolidating Portfolio's §9.3 `cohort_customer_audit` per the 2026-05-19 path-1 resolution), plus the cross-cutting reconciliation guarantee §4.11 (unchanged in substance from v1.0.0 §4.6, extended to cover the v2.0.0 added metrics). Aligns the contract to the binding Them OS data-mart forecasting spec §8. Migration mapping documented in §5.5. Five `_*` registry declarations land per the 2026-05-19 100-percent-deployment decisions: `_cac_cost_decomposition: marketing_plus_sales_sdr`, `_payback_definition: cumulative_gross_margin_geq_cac`, `_payback_bucket_granularity: monthly`, `_ltv_decomposition: gross_margin`, `_runway_formula: current_cash_div_trailing_3mo_avg_burn`, `_cash_pooling_model: pooled_at_organization`, `_allocation_rule: equal_share_by_active_student_count`, `_balance_kind` per cash_position row. v1.0.0 enters its two-week consumer deprecation window 2026-05-19 → 2026-06-02 per §5.2. Published per ADR-0015 action item 5 and the Them OS spec §8 binding. Announced via FYI memo `2026-05-19-finance-finance-mart-v2-publish-announcement`.
|
|
237
|
+
- **v1.0.0 manifest addendum** (2026-05-18): Added the four authoritative grouping manifests: `revenue-recognition-rollup.md`, `margin.md`, `cash-position.md`, and `reconciliation.md`. The manifests name the served columns, source silver inputs, reconciliation tolerances, and known cost-source gap for margin. This lands the per-figure column-manifest deliverable without changing the v1.0.0 grouping boundaries. The four manifests carry forward to v2.0.0 under the sub-sections noted in §5.5; new sub-section manifests land as v2.0.x patches.
|
|
238
|
+
- **v1.0.0** (2026-05-14): Initial publication. Establishes the finance gold section's served surface, the grain and dimensions, the four metric groupings (revenue recognition rollups, margin, cash position, reconciliation), the reconciliation guarantee against Revenue's transaction-grain ledger, the consumer and producer responsibilities, and the versioning and deprecation policy with the two-week deprecation window. Published per ADR-0015 action item 5.
|