@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
package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.created-v1.json
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.created-v1.json",
|
|
4
|
+
"title": "delivery.lesson-hold.created payload v1",
|
|
5
|
+
"description": "Payload for the delivery.lesson-hold.created event_type per contracts/sales-scheduling-surface/README.md section 4.6. Emitted by Delivery in the same transaction that creates the Lesson and advances the Delivery reservation lock to held.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"lesson_id",
|
|
10
|
+
"reservation_lock_id",
|
|
11
|
+
"reservation_id",
|
|
12
|
+
"organization_id",
|
|
13
|
+
"participant_id",
|
|
14
|
+
"coach_id",
|
|
15
|
+
"lesson_site_id",
|
|
16
|
+
"service_area_id",
|
|
17
|
+
"lesson_zip",
|
|
18
|
+
"window",
|
|
19
|
+
"lesson_type_id",
|
|
20
|
+
"originator",
|
|
21
|
+
"idempotency_key",
|
|
22
|
+
"originating_offer_id",
|
|
23
|
+
"offer_item_id"
|
|
24
|
+
],
|
|
25
|
+
"properties": {
|
|
26
|
+
"lesson_id": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"minLength": 1
|
|
29
|
+
},
|
|
30
|
+
"reservation_lock_id": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"minLength": 1
|
|
33
|
+
},
|
|
34
|
+
"reservation_id": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"minLength": 1
|
|
37
|
+
},
|
|
38
|
+
"organization_id": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"minLength": 1
|
|
41
|
+
},
|
|
42
|
+
"participant_id": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"minLength": 1
|
|
45
|
+
},
|
|
46
|
+
"coach_id": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"minLength": 1
|
|
49
|
+
},
|
|
50
|
+
"lesson_site_id": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"minLength": 1
|
|
53
|
+
},
|
|
54
|
+
"service_area_id": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"minLength": 1
|
|
57
|
+
},
|
|
58
|
+
"lesson_zip": {
|
|
59
|
+
"type": [
|
|
60
|
+
"string",
|
|
61
|
+
"null"
|
|
62
|
+
],
|
|
63
|
+
"minLength": 1
|
|
64
|
+
},
|
|
65
|
+
"window": {
|
|
66
|
+
"$ref": "#/$defs/window"
|
|
67
|
+
},
|
|
68
|
+
"lesson_type_id": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"minLength": 1
|
|
71
|
+
},
|
|
72
|
+
"originator": {
|
|
73
|
+
"type": "string",
|
|
74
|
+
"minLength": 1
|
|
75
|
+
},
|
|
76
|
+
"idempotency_key": {
|
|
77
|
+
"type": "string",
|
|
78
|
+
"minLength": 1
|
|
79
|
+
},
|
|
80
|
+
"originating_offer_id": {
|
|
81
|
+
"type": [
|
|
82
|
+
"string",
|
|
83
|
+
"null"
|
|
84
|
+
],
|
|
85
|
+
"minLength": 1
|
|
86
|
+
},
|
|
87
|
+
"offer_item_id": {
|
|
88
|
+
"type": [
|
|
89
|
+
"string",
|
|
90
|
+
"null"
|
|
91
|
+
],
|
|
92
|
+
"minLength": 1
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"$defs": {
|
|
96
|
+
"window": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"required": [
|
|
100
|
+
"start",
|
|
101
|
+
"end"
|
|
102
|
+
],
|
|
103
|
+
"properties": {
|
|
104
|
+
"start": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"format": "date-time"
|
|
107
|
+
},
|
|
108
|
+
"end": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"format": "date-time"
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Composite Hold Create — Validation Note
|
|
2
|
+
|
|
3
|
+
**Contract:** `sales-scheduling-surface` v1.3.0
|
|
4
|
+
**Endpoint:** `POST /delivery/v1/lesson-holds/atomic-multi-create`
|
|
5
|
+
**Owner:** Delivery
|
|
6
|
+
|
|
7
|
+
This note covers the semantics that require explicit documentation beyond the endpoint spec in §4.5: the batch-share constraints, window rules, eligibility re-read scope, idempotency semantics, and the all-or-none rollback behavior.
|
|
8
|
+
|
|
9
|
+
## Batch-share constraints
|
|
10
|
+
|
|
11
|
+
All items in a request body MUST share the same values for the four top-level shared fields:
|
|
12
|
+
|
|
13
|
+
- `coach_id` -- one coach per batch. Every item is scheduled with the same coach.
|
|
14
|
+
- `lesson_site_id` -- one site per batch. All sessions occur at the same physical or virtual location.
|
|
15
|
+
- `service_area_id` -- one service area per batch.
|
|
16
|
+
- `participant_id` -- one participant per batch. Composite offers at v1.3.0 are single-participant.
|
|
17
|
+
|
|
18
|
+
A request that mixes different coaches, sites, service areas, or participants across items MUST be rejected with HTTP 422 and `error: "batch_constraint_violation"` before any database access. The response body MUST include `constraint_field` naming the first field that violates the share rule.
|
|
19
|
+
|
|
20
|
+
Rationale: the eligibility re-check at §4.5 is scoped to `(coach, service_area, window_span, lesson_type_per_item)`. The shared-coach constraint is load-bearing for the composite-window eligibility check; if the batch were multi-coach, the composite-window check would require N independent eligibility reads rather than one, and the "all or none" semantics would need to span multiple coach projections simultaneously. v1.4.0 or later may relax the coach constraint if group or relay bookings become a product requirement, but that changes the eligibility re-check shape and is therefore a new minor, not a patch.
|
|
21
|
+
|
|
22
|
+
## Window rules
|
|
23
|
+
|
|
24
|
+
Item windows MUST satisfy:
|
|
25
|
+
|
|
26
|
+
1. `window.end > window.start` for each item.
|
|
27
|
+
2. No two items may have overlapping windows. Overlap is defined as `item_A.start < item_B.end && item_A.end > item_B.start`. Exact-boundary adjacency (item A ends exactly when item B starts) is not an overlap.
|
|
28
|
+
3. No window may start in the past at the time the request reaches the producer. "Past" is defined by the producer's clock; a clock skew of up to 10 seconds is tolerated to account for network transit time.
|
|
29
|
+
|
|
30
|
+
The producer validates the full window set before opening the transaction. A window violation is HTTP 422 with `error: "window_constraint_violation"` and `item_index` (zero-based) identifying the first offending item.
|
|
31
|
+
|
|
32
|
+
Note: windows need not be contiguous. A composite offer with a gap between sessions (e.g., two 30-minute sessions with a 10-minute break) is valid. The contiguity expectation is a Sales close-orchestration convention, not a contract invariant.
|
|
33
|
+
|
|
34
|
+
## Eligibility re-read scope
|
|
35
|
+
|
|
36
|
+
The eligibility re-check per §7.7 applies to the **composite window** for atomic-multi-create: `[earliest item window start, latest item window end]`. Delivery calls Coaching's eligibility-by-description endpoint once for this composite span, rather than N times per item.
|
|
37
|
+
|
|
38
|
+
Consequences:
|
|
39
|
+
|
|
40
|
+
- If a coach is eligible for each individual item's window but not over the full composite span (e.g., a lunch-hour block falls in the middle), the composite-window check catches it.
|
|
41
|
+
- If Coaching cannot verify eligibility (unreachable, misconfigured), the producer returns HTTP 503 with `error: "eligibility_unverifiable"` and does not open the transaction.
|
|
42
|
+
- The 409 on eligibility lapse means Sales re-enters close orchestration from a fresh eligibility read and fresh Revenue reservation requests. The canonical retry path is NOT to replay the same batch with the same `reservation_id` values; the reservations may have aged out. Release them and reserve fresh.
|
|
43
|
+
|
|
44
|
+
## Idempotency semantics
|
|
45
|
+
|
|
46
|
+
One `Idempotency-Key` header covers the entire batch. The key is scoped by `organization_id`, matching the existing single-hold-create discipline.
|
|
47
|
+
|
|
48
|
+
Idempotency resolution rules:
|
|
49
|
+
|
|
50
|
+
- **Replay with same key, same normalized batch payload:** producer returns the original full item array. "Normalized" means JSON keys sorted, whitespace collapsed, `offer_item_id` and `notes` included if they were in the original request.
|
|
51
|
+
- **Replay with same key, different normalized batch payload:** producer returns HTTP 409 with `conflict_reason: "idempotency_payload_mismatch"`. The consumer SHOULD NOT modify the batch payload on retry; a different payload means a different intent and warrants a new idempotency key.
|
|
52
|
+
- **Key not seen before:** normal write path.
|
|
53
|
+
|
|
54
|
+
There are no per-item idempotency keys at v1.3.0. Per-item keys would imply partial-replay semantics (replay succeeds from item N without re-running items 1..N-1), which is the inverse of the all-or-none guarantee. A partial-replay design would require the producer to detect which items in a batch already landed and skip them -- a stateful partial-commit model that conflicts with the all-or-none transaction boundary. If partial-replay becomes necessary in a future version, it is a new minor with an explicit partial-commit model declaration.
|
|
55
|
+
|
|
56
|
+
The producer retains `(organization_id, idempotency_key)` pairs for at least 24 hours from the original write per §7.5. Beyond that window the producer MAY treat a replay as a new write; consumers SHOULD NOT depend on idempotency beyond 24 hours.
|
|
57
|
+
|
|
58
|
+
## All-or-none rollback behavior
|
|
59
|
+
|
|
60
|
+
The producer wraps all item writes in a single `prisma.$transaction`. The transaction contains, in order:
|
|
61
|
+
|
|
62
|
+
1. For each item in request order: lock the `reservation_lock` row, validate state and slot availability.
|
|
63
|
+
2. Eligibility re-check on the composite window (one call to Coaching, happens before any INSERTs).
|
|
64
|
+
3. For each item in request order: INSERT lesson row, UPDATE reservation_lock to `held`, INSERT `dispatcher_event` for `delivery.lesson-hold.created`.
|
|
65
|
+
|
|
66
|
+
If step 1 fails on item N (reservation not in `reserved` state, slot taken, lock row not found), the transaction aborts with no rows written and no events emitted. The error response names the conflict reason and the zero-based `item_index` for the first failing item.
|
|
67
|
+
|
|
68
|
+
If step 2 fails (coach ineligible or unverifiable), the transaction aborts before any INSERT. Error response is 409 (ineligible) or 503 (unverifiable).
|
|
69
|
+
|
|
70
|
+
If step 3 fails on item N due to a database constraint violation (concurrent write hit the same slot between step 1 and step 3), the transaction aborts and all prior item writes in the batch are rolled back. Error response is 409 with `conflict_reason: "slot_taken_by_concurrent_write"` and the failing `item_index`.
|
|
71
|
+
|
|
72
|
+
The consumer never sees a partial batch in Delivery's storage. Either all N lessons are created in `held` state and all N `delivery.lesson-hold.created` events are emitted, or none are.
|
|
73
|
+
|
|
74
|
+
**Cross-service compensation:** Delivery does not own the compensation path if Revenue's parallel atomic-multi-create succeeds while Delivery's fails. That compensation (release the Revenue reservations) lives in Sales' close-orchestration extension. Delivery's transaction boundary ends at Delivery's storage and events; it does not span Revenue.
|
|
75
|
+
|
|
76
|
+
## Example: partial failure at item 1 (zero-indexed)
|
|
77
|
+
|
|
78
|
+
Request: batch of 3 items for sessions at 9:00, 10:00, 11:00.
|
|
79
|
+
|
|
80
|
+
1. Item 0 (9:00): reservation lock found, in `reserved` state, no slot conflict. Proceeds.
|
|
81
|
+
2. Item 1 (10:00): reservation lock found, but state is `held` (another concurrent write beat this batch to the lock). Conflict detected.
|
|
82
|
+
|
|
83
|
+
Result: transaction aborts. Item 0's lesson row and lock advance are rolled back. No events emit. Producer returns HTTP 409:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"error": "conflict",
|
|
88
|
+
"conflict_reason": "lock_not_in_requested_state",
|
|
89
|
+
"item_index": 1,
|
|
90
|
+
"current_state": {
|
|
91
|
+
"reservation_id": "crr_...",
|
|
92
|
+
"lock_state": "held"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Sales' canonical response: release the Revenue reservations for items 0 and 1 (item 2 was never touched), re-enter close orchestration with fresh eligibility and fresh reservations.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Validation: Lock state machine conformance
|
|
2
|
+
|
|
3
|
+
**Contract:** `sales-scheduling-surface` v1.0.0
|
|
4
|
+
**Producer:** Delivery (scheduling and reservation-lock service)
|
|
5
|
+
**Date:** 2026-05-07
|
|
6
|
+
|
|
7
|
+
## Why this validation exists
|
|
8
|
+
|
|
9
|
+
The Sales-scheduling-surface contract is an additional Sales-facing entry point over the credit reservation lock state machine that ADR-0006 specifies. The contract's correctness rests on the producer driving the lock through the transitions ADR-0006 permits, never through transitions ADR-0006 does not permit, and never through paths that bypass the lock state machine entirely. This file documents the per-endpoint mapping from contract operation to lock-state transition, the test surface the producer maintains to enforce conformance, and the audit gate that detects drift between this surface's writes and Revenue's ledger.
|
|
10
|
+
|
|
11
|
+
ADR-0006 is the source of truth for the lock state machine; this file is the gate that verifies this surface respects it.
|
|
12
|
+
|
|
13
|
+
## Lock state mapping per endpoint
|
|
14
|
+
|
|
15
|
+
### `POST /delivery/v1/lesson-holds` (§4.1)
|
|
16
|
+
|
|
17
|
+
Drives the lock from `requested` to `held`. The transaction:
|
|
18
|
+
|
|
19
|
+
1. SELECTs the `reservation_lock` row by `reservation_id`, scoped by `organization_id`. WITH ROW LOCK to serialize concurrent hold-creates against the same reservation.
|
|
20
|
+
2. Verifies `lock_state == 'requested'`. If not, the transaction rolls back and the producer returns HTTP 409 with `conflict_reason: lock_not_in_requested_state`.
|
|
21
|
+
3. Verifies coach eligibility against Coaching's `coach-availability` contract eligibility-by-description endpoint. If ineligible, the transaction rolls back with HTTP 409 `conflict_reason: coach_not_eligible_at_write_time`.
|
|
22
|
+
4. Verifies slot availability (no overlapping `held` or `confirmed` lesson on the same coach, same organization). If conflicted, the transaction rolls back with HTTP 409 `conflict_reason: slot_taken_by_concurrent_write`.
|
|
23
|
+
5. INSERTs the `lesson` row with `reservation_lock_id` set.
|
|
24
|
+
6. UPDATEs the `reservation_lock` row to `lock_state = 'held'`, increments `lock_version`, sets `held_at = NOW()`.
|
|
25
|
+
7. Emits `delivery.lesson-hold.created` through the dispatcher in the same Prisma transaction per ADR-0009's producer-transactional-guarantee.
|
|
26
|
+
|
|
27
|
+
A failure in any step rolls all six writes back; consumers do not see partial state. Revenue's `credit.locked` event fires in response to the lock-state advance through Revenue's existing producer; this surface does not duplicate that producer.
|
|
28
|
+
|
|
29
|
+
### `POST /delivery/v1/lesson-holds/{lesson_id}:cancel` (§4.4)
|
|
30
|
+
|
|
31
|
+
Drives the lock from `requested` to `released` or from `held` to `released`. The transaction:
|
|
32
|
+
|
|
33
|
+
1. SELECTs the `lesson` and `reservation_lock` rows by `lesson_id`, scoped by `organization_id`. WITH ROW LOCK to serialize concurrent cancels.
|
|
34
|
+
2. Verifies `lock_version` from the request matches the current value. If not, rolls back with HTTP 409 `conflict_reason: lock_version_stale`.
|
|
35
|
+
3. Verifies `lock_state IN ('requested', 'held')`. If `lock_state == 'confirmed'`, rolls back with HTTP 409 `conflict_reason: confirmed_state_requires_refund_flow` per §4.4.1 of the README. If `lock_state IN ('consumed', 'released')`, rolls back with HTTP 409 `conflict_reason: lock_already_terminal`.
|
|
36
|
+
4. UPDATEs the `lesson` row to `cancelled = true`, sets `cancelled_at = NOW()`, persists `reason_code` and `reason_notes`.
|
|
37
|
+
5. UPDATEs the `reservation_lock` row to `lock_state = 'released'`, increments `lock_version`, sets `released_at = NOW()`.
|
|
38
|
+
6. Emits `delivery.lesson-hold.cancelled` through the dispatcher in the same Prisma transaction per ADR-0009.
|
|
39
|
+
|
|
40
|
+
Revenue's `credit.released` event fires in response to the lock-state advance through Revenue's existing producer.
|
|
41
|
+
|
|
42
|
+
### `GET /delivery/v1/lesson-holds/{lesson_id}` (§4.2) and `GET /delivery/v1/lesson-holds` (§4.3)
|
|
43
|
+
|
|
44
|
+
Read-only. No lock state transition. The producer SHALL return the current `lock_state`, `lock_version`, and `lock_funding_substate` as observed in Delivery's `reservation_lock` table, which mirrors Revenue's authoritative state via the `credit.*` event subscriber per the credit-reservation-lock contract §12.3.
|
|
45
|
+
|
|
46
|
+
## Transitions this surface does not drive
|
|
47
|
+
|
|
48
|
+
`held` to `confirmed`: driven by Revenue's funding clearance through the credit-reservation-lock surface. The producer SHALL NOT advance to `confirmed` through this surface.
|
|
49
|
+
|
|
50
|
+
`confirmed` to `consumed`: driven by Delivery's lesson-day attendance reconciliation through Delivery-internal flows. The producer SHALL NOT advance to `consumed` through this surface.
|
|
51
|
+
|
|
52
|
+
`confirmed` to `released`: driven by Revenue's refund-flow contract because money has moved. The producer SHALL NOT advance to `released` from `confirmed` through this surface in v1.0.0; §9.2 of the README outlines the v1.1 path.
|
|
53
|
+
|
|
54
|
+
`requested` to `released` initiated by Revenue (reservation aged out, customer-side lead lost before close): driven by Revenue's reservation-timeout flows. This surface MAY observe the resulting state through inspect responses but does not drive the transition.
|
|
55
|
+
|
|
56
|
+
## Test surface
|
|
57
|
+
|
|
58
|
+
The producer maintains the following test coverage as a CI gate on every PR that touches the surface:
|
|
59
|
+
|
|
60
|
+
1. **Per-endpoint state-mapping tests.** For each endpoint and each starting lock state, assert the post-write lock state matches the table above or the request returns the expected HTTP 409 with the expected `conflict_reason`. Coverage MUST be exhaustive over the cross-product of endpoints and starting states.
|
|
61
|
+
2. **Optimistic-concurrency tests.** Two concurrent cancel calls on the same Lesson SHALL produce one success and one HTTP 409 with `conflict_reason: lock_version_stale`. Two concurrent hold-create calls against the same reservation SHALL produce one success and one HTTP 409 with `conflict_reason: lock_not_in_requested_state`.
|
|
62
|
+
3. **Idempotency tests.** Two writes with the same `(organization_id, idempotency_key)` SHALL produce identical responses (same lesson_id, same lock_version, same as_of), regardless of payload variance. Idempotency-key retention SHALL hold for at least 24 hours.
|
|
63
|
+
4. **Org-isolation tests.** A read or write scoped to `organization_id=A` SHALL NOT return or modify rows scoped to `organization_id=B`. The test SHALL exercise the storage-access path, not solely the response-construction path.
|
|
64
|
+
5. **Transactional-guarantee tests.** Inject failures into each of the three writes (storage, lock advance, event emit) and assert the other two roll back. Consumers SHALL NOT observe partial state under any failure scenario.
|
|
65
|
+
6. **Eligibility-re-check-at-write-time tests.** Mock Coaching's eligibility surface to return ineligible at the moment of the write, with eligibility having been valid at the consumer's read; assert the producer rejects the write with HTTP 409 `conflict_reason: coach_not_eligible_at_write_time`.
|
|
66
|
+
7. **Out-of-scope-transition rejection tests.** For each transition the surface does not drive (held to confirmed, confirmed to consumed, confirmed to released), assert the surface returns HTTP 409 or HTTP 422 rather than silently driving the transition.
|
|
67
|
+
|
|
68
|
+
## Audit gate
|
|
69
|
+
|
|
70
|
+
The producer SHALL run `/scripts/audit-locks.mjs` on a continuous schedule (operationally tuned; current cadence is hourly) against the production lock store and Revenue's ledger. The script reports any divergence between Delivery's `reservation_lock` table and Revenue's authoritative ledger reservation, broken down by lock state and funding sub-state.
|
|
71
|
+
|
|
72
|
+
A nonzero divergence is a P1 incident jointly with Revenue per the Delivery domain memo's quality bar. The producer SHALL halt write traffic on this surface until divergence is resolved; specifically, hold-create and hold-cancel return HTTP 503 with `Retry-After` header set to a Revenue-coordinated value while the divergence is investigated. Read endpoints continue to serve.
|
|
73
|
+
|
|
74
|
+
The audit gate is the trailing indicator `lock-ledger-drift-rate` on the initiative-scope memo (`2026-05-07-delivery-migration-initiative-scope`); the migration is not "complete" until the indicator holds at zero across a stable observation window.
|
|
75
|
+
|
|
76
|
+
## ADR-0006 amendment discipline
|
|
77
|
+
|
|
78
|
+
Any change to the lock state machine itself (a new state, a new transition, a new sub-state, a TTL change) is an ADR-0006 amendment with Revenue sign-off and the standard two-week deprecation window for downstream lock-state consumers per the Delivery domain memo's lock-contract change rule. This contract is a Sales-facing entry point over the existing state machine; it is not authorized to amend the state machine.
|
|
79
|
+
|
|
80
|
+
If a Sales-driven need surfaces that requires a new transition or sub-state (for example, a "soft hold" sub-state that does not subtract from coach availability for pre-quote inspection), it MUST land as an ADR-0006 amendment first, with the contract surface bumped to v1.x or v2 to expose the new transition.
|
|
81
|
+
|
|
82
|
+
## Sign-off
|
|
83
|
+
|
|
84
|
+
The producer signs off on this validation file by maintaining the test coverage in §"Test surface" as a CI gate. Test coverage failures block PR merges on the Delivery repo; absence of one of the seven test classes is a v2-class change to this contract.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Validation: Sales close orchestration
|
|
2
|
+
|
|
3
|
+
**Contract:** `sales-scheduling-surface` v1.0.1
|
|
4
|
+
**Consumer:** Sales (close orchestration, lesson hold creation and cancellation surface)
|
|
5
|
+
**Date:** 2026-05-07
|
|
6
|
+
|
|
7
|
+
## Why this validation exists
|
|
8
|
+
|
|
9
|
+
Sales is the primary consumer that the Sales-scheduling-surface contract exists to serve. The surface is worth standing up only if Sales' close-orchestration shape lands as a first-class consumer of the contract rather than a Delivery-helper that Sales happens to call. This file documents what Sales calls, when, with what inputs, and how Sales handles concurrent-operator races and stale-projection cases. The producer SHALL preserve this shape across patch and minor versions; any change that breaks Sales' shape is a v2-class change.
|
|
10
|
+
|
|
11
|
+
## Sales' surface area
|
|
12
|
+
|
|
13
|
+
Sales calls the `sales-scheduling-surface` contract at two logical points in the close orchestration: hold-create at the close, and hold-cancel for customer-driven or operator-driven cancellations before lock confirmation. Sales does not call hold-confirm; the lock advance from `held` to `confirmed` is event-driven by Revenue's funding state, not by Sales.
|
|
14
|
+
|
|
15
|
+
### Inputs Sales has at close
|
|
16
|
+
|
|
17
|
+
- `organization_id`. Resolved from the Lead's organization context.
|
|
18
|
+
- `reservation_id`. Returned by Revenue's credit-reservation-lock surface immediately before this call (Sales submits a reservation request, receives a `crr_` in Revenue lifecycle state `reserved`, then calls Delivery's hold-create surface with the `crr_` for a Delivery `requested` to `held` transition).
|
|
19
|
+
- `participant_id`. Resolved from Sales' Lead-to-Person attachment via `intake.matched`. The Participant is one Person per Organization per the amended ADR-0003.
|
|
20
|
+
- `coach_id`. Selected by the operator from Coaching's eligibility-by-description response immediately before the close. Sales has the `coa_` from the eligibility read at the moment the operator commits.
|
|
21
|
+
- `lesson_site_id`. Resolved from the operator's selection during offer construction. Sites are scoped per organization and tied to a Service Area.
|
|
22
|
+
- `service_area_id`. Resolved from the Lead's location intake (address, region, or service-area selection during qualification).
|
|
23
|
+
- `window`. The slot the operator and customer agreed on, expressed as a half-open `[start, end)` interval in the customer's timezone.
|
|
24
|
+
- `lesson_type_id`. The lesson type the offer is for; resolved from the Lead's program selection during qualification.
|
|
25
|
+
- `notes`. Optional free text up to 500 chars; operator-entered context for downstream Delivery operator-tooling display.
|
|
26
|
+
|
|
27
|
+
### Endpoints Sales calls
|
|
28
|
+
|
|
29
|
+
- `POST /delivery/v1/lesson-holds` per §4.1 at the close. One call per first lesson on the offer. Multi-lesson offers (a four-lesson package) call hold-create once per lesson; v1.0.0 does not bundle.
|
|
30
|
+
- `GET /delivery/v1/lesson-holds/{lesson_id}` per §4.2 for operator-tooling display of an active hold and to refresh the `lock_version` token before a cancel attempt.
|
|
31
|
+
- `GET /delivery/v1/lesson-holds` per §4.3 for the operator's "my booked lessons" view, filtered by `coach_id` for coach-day overlays and by `participant_id` for customer-detail overlays.
|
|
32
|
+
- `POST /delivery/v1/lesson-holds/{lesson_id}:cancel` per §4.4 for cancellations before lock confirmation.
|
|
33
|
+
|
|
34
|
+
Sales does NOT call hold-confirm; the v1.0.0 surface does not expose a Sales-driven confirm. Sales does NOT call this surface to cancel a `confirmed` Lesson; Revenue's refund-flow contract owns that path.
|
|
35
|
+
|
|
36
|
+
## Read frequency
|
|
37
|
+
|
|
38
|
+
Hold-create fires once per lesson at the moment the operator commits to the offer. Sales does NOT poll hold-create as a way to discover whether a slot is still free; eligibility-by-description on Coaching's surface is the read for that. Sales calls hold-create only after the operator has committed and Revenue's reservation request has landed.
|
|
39
|
+
|
|
40
|
+
Hold-inspect fires on operator-tooling display refreshes and immediately before any cancel attempt to read the current `lock_version`. The operator-tooling refresh interval is operationally tuned (currently 30 seconds on idle views, immediate on operator focus); the interval is not part of the contract surface.
|
|
41
|
+
|
|
42
|
+
Hold-list fires on operator-tooling navigation (opening the "my booked lessons" view, filtering by coach-day, filtering by customer). List response cursoring follows the contract's pagination rules.
|
|
43
|
+
|
|
44
|
+
Hold-cancel fires once per cancellation. Sales SHALL NOT retry hold-cancel against a successful response; the operation is idempotent over `(organization_id, idempotency_key)` and a duplicate retry returns the original result regardless.
|
|
45
|
+
|
|
46
|
+
## Conflict and concurrency handling
|
|
47
|
+
|
|
48
|
+
The surface returns HTTP 409 in three concurrent-operator scenarios on hold-create: reservation lock no longer in `requested` state (another path advanced it), coach no longer eligible (Coaching's projection moved between Sales' read and Delivery's re-check), slot taken by a concurrent write (a different operator's hold-create landed first on the same coach-and-window). Sales SHALL:
|
|
49
|
+
|
|
50
|
+
1. Treat the conflict as a normal-path race, not an error condition.
|
|
51
|
+
2. Read `conflict_reason` and `current_state` from the 409 response body to disambiguate the cause.
|
|
52
|
+
3. For "reservation lock not in requested" or "slot taken by concurrent write": release the reservation through Revenue's surface and re-enter the canonical sequence (eligibility-read against Coaching, fresh reservation-request, fresh hold-create) with the operator's revised slot selection. Do NOT retry hold-create with the same `reservation_id`; the reservation may be aged out, and the canonical retry path is fresh.
|
|
53
|
+
4. For "coach no longer eligible": surface the eligibility lapse to the operator with the next-best eligible coaches from a fresh eligibility-by-description read, then re-enter the canonical sequence.
|
|
54
|
+
|
|
55
|
+
Hold-cancel returns HTTP 409 on `lock_version` mismatch (the lock advanced between the consumer's read and the cancel call, typically because Revenue's funding cleared the lock to `confirmed`). Sales SHALL re-read the Lesson and decide based on the new lock state: if state is `held` with a fresh `lock_version`, retry the cancel; if state is `confirmed`, surface to the operator that the funding has cleared and the cancellation requires Revenue's refund-flow path.
|
|
56
|
+
|
|
57
|
+
Sales SHALL log the `as_of` value from every read so concurrent-operator races are traceable. Operator-reported "the slot was free a second ago" is a normal-path event, not a Delivery incident, but the log line is needed to confirm the lag was within the projection's freshness SLO.
|
|
58
|
+
|
|
59
|
+
## Idempotency-key discipline
|
|
60
|
+
|
|
61
|
+
Sales SHALL generate a fresh `Idempotency-Key` for each distinct write intent. A retry of a hold-create that timed out shares the same key as the original attempt; a separate hold-create for the same operator on the same Lead with the same coach is a distinct write and needs a distinct key.
|
|
62
|
+
|
|
63
|
+
Sales SHOULD scope idempotency keys to "(operator_id, lead_id, write_purpose)" so retries collide on the same key and unrelated writes do not. Specific key construction is operationally tuned and not part of the contract surface.
|
|
64
|
+
|
|
65
|
+
## Identity and comms-routing
|
|
66
|
+
|
|
67
|
+
Outbound communications about a Lesson Hold (offer letter, lesson confirmation, scheduling instructions, reschedule notices) route through Platform's Guardian-aware comms-routing endpoint per the identity contract. This is unchanged by this contract; the routing rule applies uniformly across Sales' communications regardless of which domain the substantive content originates in. Lesson-day reminders, schedule changes, and coach communications all flow through the Guardian-aware path per the Delivery domain memo's comms-routing rule.
|
|
68
|
+
|
|
69
|
+
Outbound communications mentioning the assigned Coach's name route through Platform's Person facts API for the coach's identity fields. Sales does NOT read coach identity fields from this contract; the contract returns `coa_` references, not Person fields.
|
|
70
|
+
|
|
71
|
+
## Open questions Sales reserves the right to raise
|
|
72
|
+
|
|
73
|
+
None pre-publication. The shape in §6.6 of the README is Delivery's read of Sales' close orchestration based on the Sales domain memo, the lead-lifecycle contract, and the existing customer-handoff subscriber pattern. If Sales surfaces a different orchestration shape (a different retry pattern on slot conflict, a different idempotency-key scope, a multi-lesson bundled hold-create for package offers per §9.3), it lands as a memo against `2026-05-07-delivery-sales-scheduling-surface-contract-v1-proposal` and Delivery folds the change into a patch or minor version depending on the shape.
|
|
74
|
+
|
|
75
|
+
## Sign-off
|
|
76
|
+
|
|
77
|
+
Sales signs off by responding to the Delivery proposal memo (`2026-05-07-delivery-sales-scheduling-surface-contract-v1-proposal`) on the parent thread. Sign-off promotes the contract from drafted to acknowledged-by-consumer; Sales-side integration work in the Sales repo can scaffold against the surface immediately on sign-off without waiting for further changes.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Warehouse Silver Contract
|
|
2
|
+
|
|
3
|
+
**Status:** v1.1.0
|
|
4
|
+
**Date:** 2026-05-17
|
|
5
|
+
**Owner:** platform (warehouse semantic layer)
|
|
6
|
+
**Consumers:** growth, sales, delivery, coaching, revenue, finance, portfolio
|
|
7
|
+
**Related ADRs:** ADR-0016, ADR-0015, ADR-0003, ADR-0013, ADR-0014
|
|
8
|
+
**Sub-specs (authoritative):** n/a in v1.0.0; per-face column manifests land as siblings as each silver face ships
|
|
9
|
+
**Validations:** n/a in v1.0.0; per-face conform validation notes land under `validation/` as each face ships
|
|
10
|
+
|
|
11
|
+
## 1. Purpose and scope
|
|
12
|
+
|
|
13
|
+
The Warehouse Silver Contract specifies the shape of the silver tier of the medallion warehouse layout (ADR-0016): the conforming layer that sits between the raw per-domain source databases (bronze) and the consumer-scoped application mart (gold). It defines what every silver face guarantees to the gold sections built on top of it, and it defines the conformed identity and geography spine that all faces and all gold sections join to.
|
|
14
|
+
|
|
15
|
+
Silver exists so that conform logic is written once. Without a contracted silver tier, every gold section would re-derive canonical identity resolution, type conformance, and naming conformance against source schemas it does not own, and those derivations would drift. The contract is what lets the operational domains and the Finance and Portfolio reporting domains build gold sections against a stable conformed surface rather than against raw bronze.
|
|
16
|
+
|
|
17
|
+
In scope: the tier model, the per-face conform guarantees, the identity and geography spine, the consumption rules, the versioning policy, and the source-to-silver drift hygiene. Out of scope: the bronze source schemas themselves (each domain owns its own source-of-record), the gold mart sections and their analytics scope (owned per ADR-0016 by the operational domains and, for the finance and portfolio sections, by those domains per ADR-0015), the consumer firewall mechanism (a gold-tier concern), and cross-domain composition (gold only).
|
|
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
|
+
- **Bronze**: the five per-domain operational source databases (growth, sales, delivery, coaching, revenue), surfaced into the warehouse as dbt sources, raw and untouched.
|
|
26
|
+
- **Silver face**: one conforming dbt view per warehouse. Five faces, one per bronze source.
|
|
27
|
+
- **Identity and geography spine**: the conformed reference dimension carrying the Person dimension and the Org-to-Market geography hierarchy, sourced from Platform's identity database. Per ADR-0013 (amended 2026-05-17), the spine's analytics geography dimensions are Org and Market; Service Area and Lesson Site are Platform-owned operational reference data, not spine dimensions.
|
|
28
|
+
- **Gold section**: a consumer-scoped section of the application mart, built over one or more silver objects. Not specified by this contract; see ADR-0016.
|
|
29
|
+
- **Conform**: resolve grain to the canonical `person_id`, normalize types, normalize column naming, drop source-unbacked columns, and exclude non-production records. The silver tier's only job.
|
|
30
|
+
|
|
31
|
+
## 4. Surface
|
|
32
|
+
|
|
33
|
+
### 4.1 The tier model
|
|
34
|
+
|
|
35
|
+
Silver is logical. Each silver face is a dbt view over exactly one bronze source; the spine is a dbt view over Platform's identity database (materialized where join performance requires it, per §7). Silver copies no data; it conforms in place.
|
|
36
|
+
|
|
37
|
+
There are exactly five silver faces, one per warehouse: a face for growth, sales, delivery, coaching, and revenue. Physical naming and dbt structure follow `warehouse-model-discipline` §9. A face reads only its own bronze source. A face MUST NOT read another warehouse's bronze, another silver face, or any gold section. Cross-domain composition is a gold-tier concern.
|
|
38
|
+
|
|
39
|
+
### 4.2 Per-face conform guarantees
|
|
40
|
+
|
|
41
|
+
Every silver face guarantees to its consumers:
|
|
42
|
+
|
|
43
|
+
**Canonical person grain.** Every row that represents a person-related fact carries `person_id`, the canonical Person ID per ADR-0003. The face resolves its source's local identifiers to `person_id`; consumers never resolve identity themselves. Rows that genuinely have no person association (a campaign, a lesson site) carry no `person_id`, and that absence is meaningful, not a gap.
|
|
44
|
+
|
|
45
|
+
**Type conformance.** Conformed columns use conformed types: timestamps as UTC ISO 8601, money as integer minor units (cents), canonical IDs in their prefixed string form per ADR-0002, booleans as booleans. A face MUST NOT pass through a source type that violates this (a local epoch-int timestamp, a float dollar amount).
|
|
46
|
+
|
|
47
|
+
**Naming conformance.** Conformed columns use one naming convention across all five faces: snake_case, `*_at` for timestamps, `*_id` for identifiers, no source-local abbreviations. The same fact carries the same column name on every face that has it.
|
|
48
|
+
|
|
49
|
+
**No junk.** A column appears on a silver face only if a source row backs it, per `warehouse-model-discipline` §2. A face drops source-unbacked columns rather than passing them through, and it does not synthesize.
|
|
50
|
+
|
|
51
|
+
**Production scope.** A silver face contains only production records. Non-production records are excluded before any consumer sees them. For domains that carry a row-level test flag, the face filters on that flag. For domains where test isolation is org-scoped, the face excludes rows belonging to non-production organizations by joining to Platform's production-org reference. Platform maintains the authoritative definition of which organizations are production-scope. Consumers that legitimately need non-production records SHALL read bronze directly; silver is the production-scope conform tier.
|
|
52
|
+
|
|
53
|
+
**Conform-only and wide.** A silver face conforms; it does not narrow. It does not filter rows to a domain's analytics scope, does not categorize, does not apply a discriminator, and carries no question-shaped derivation. That narrowing is the gold section's job. Silver is wide so that every gold section, including the cross-warehouse finance and portfolio sections, can read what it needs from the same face. "Wide" means wide over the production-scope row set established by the production-scope guarantee above; excluding non-production records is conform behavior, not narrowing.
|
|
54
|
+
|
|
55
|
+
### 4.3 The identity and geography spine
|
|
56
|
+
|
|
57
|
+
The spine is a first-class silver object, not a sixth face and not a gold section. It is sourced from Platform's identity database and carries:
|
|
58
|
+
|
|
59
|
+
- The conformed **Person dimension**: `person_id` plus conformed Person attributes, Guardian relationships, and tenancy.
|
|
60
|
+
- The conformed **geography hierarchy**: Organization and Market, with their parentage edges, per ADR-0013 (amended 2026-05-17). The spine's analytics geography dimensions are Org and Market. Service Area and Lesson Site are Platform-owned operational reference data; they are not exposed as spine dimensions, though Platform holds their authoritative enums and parentage. The per-Organization discipline boundary per ADR-0014 is also on the spine.
|
|
61
|
+
|
|
62
|
+
Every silver face joins to the spine to resolve `person_id`, the key. Gold sections join to the spine for Person and geography attributes. The spine is the single conformed source of those facts; no face and no gold section denormalizes Person or geography fields beyond what the spine provides, per `warehouse-model-discipline` §8.
|
|
63
|
+
|
|
64
|
+
### 4.4 What silver does not do
|
|
65
|
+
|
|
66
|
+
Silver does not narrow to a domain's analytics scope (gold). Silver does not compose across warehouses (gold). Silver does not materialize the served surface (gold). Silver does not own the consumer firewall (gold). Silver does not own or edit any source-of-record (bronze, owned by each domain) or the canonical identity and geography records (Platform's identity service, per ADR-0013 and ADR-0014).
|
|
67
|
+
|
|
68
|
+
## 5. Versioning policy
|
|
69
|
+
|
|
70
|
+
### 5.1 Semantic versioning
|
|
71
|
+
|
|
72
|
+
- **Patch** (1.0.0 to 1.0.1): editorial clarifications, typo fixes. No change to the conformed surface.
|
|
73
|
+
- **Minor** (1.x.y to 1.x+1.0): additive changes. A new conformed column, a new silver face, a new spine attribute. Consumers on older minors continue to work.
|
|
74
|
+
- **Major** (1.x.y to 2.0.0): breaking changes. Removing or renaming a conformed column, changing a conformed type, narrowing the spine.
|
|
75
|
+
|
|
76
|
+
### 5.2 Deprecation policy
|
|
77
|
+
|
|
78
|
+
On major-version publication, the previous major enters a two-week deprecation window per the org contract-currency standard. Consumers running against deprecated versions after the window contribute to drift.
|
|
79
|
+
|
|
80
|
+
### 5.3 Additive discipline within a major
|
|
81
|
+
|
|
82
|
+
New conformed columns MUST default to null or a backwards-compatible value. A new silver face is additive: it does not change existing faces. Consumers MUST treat unknown conformed columns as safe to ignore.
|
|
83
|
+
|
|
84
|
+
### 5.4 Source churn is not a contract change
|
|
85
|
+
|
|
86
|
+
A bronze source schema change does not by itself bump this contract. Silver's job is to absorb source churn. The contract bumps only when the conformed surface that consumers see changes. That insulation is the point of the tier.
|
|
87
|
+
|
|
88
|
+
## 6. Consumer responsibilities
|
|
89
|
+
|
|
90
|
+
- A gold section SHALL read silver, not bronze. Reaching into a bronze source couples the consumer to a schema it does not own and defeats the conform tier.
|
|
91
|
+
- An operational domain's gold section SHALL read exactly its own silver face. It MUST NOT read another warehouse's face; cross-warehouse reads are the finance and portfolio sections' province per ADR-0016.
|
|
92
|
+
- The finance and portfolio gold sections MAY read across silver faces and the spine; that is their purpose.
|
|
93
|
+
- Consumers SHALL join to the spine for Person and geography attributes rather than denormalizing them, per `warehouse-model-discipline` §8.
|
|
94
|
+
- Consumers SHALL treat unknown conformed columns, added in a later minor, as safe to ignore.
|
|
95
|
+
|
|
96
|
+
## 7. Producer responsibilities
|
|
97
|
+
|
|
98
|
+
- Platform SHALL keep each silver face conform-only and wide: no narrowing, no cross-warehouse reads, no question-shaped derivation.
|
|
99
|
+
- Platform SHALL exclude non-production records from each silver face, applying row-level test flags where the source provides them and org-level production filtering where test isolation is org-scoped, per §4.2.
|
|
100
|
+
- Platform SHALL absorb bronze source schema churn within silver so that a source change does not surface to consumers as a contract change unless the conformed surface genuinely changes.
|
|
101
|
+
- Platform SHALL keep the spine the single conformed source of Person and Org-to-Market geography facts.
|
|
102
|
+
- Platform SHALL provide a per-warehouse source-to-silver drift check, a `warehouse:check`-style script per `warehouse-model-discipline` §9, and SHOULD keep drift below the threshold tracked by the `source-to-silver-drift` indicator on the data access model initiative.
|
|
103
|
+
- Silver faces are logical views; Platform MAY materialize the spine where join performance requires it, but materialization decisions are Platform's and do not change the contracted surface.
|
|
104
|
+
|
|
105
|
+
## 8. Security and privacy
|
|
106
|
+
|
|
107
|
+
Silver carries PII: `person_id` and, on the spine, conformed Person attributes and Guardian relationships. Silver does not widen the PII footprint beyond what bronze and Platform's identity service already hold; it conforms and exposes the same facts. The consumer firewall, a gold-tier mechanism per ADR-0016, is what scopes which consumer sees which slice; silver itself is the conformed substrate, and access to it is Platform-controlled. Consumers SHALL apply the same log-hygiene discipline to conformed PII columns that they apply to any Person data.
|
|
108
|
+
|
|
109
|
+
## 9. Future work
|
|
110
|
+
|
|
111
|
+
- **Per-face column manifests** as authoritative sub-specs, one per silver face, landing as each face ships. v1.0.0 contracts the guarantees; the per-face manifests will contract the exact conformed column lists.
|
|
112
|
+
- **Conform validation notes** under `validation/`, one per face, proving the conform guarantees hold against live bronze traffic.
|
|
113
|
+
- **Spine materialization decision**, triggered if join performance against the logical spine degrades for the cross-warehouse finance and portfolio sections.
|
|
114
|
+
|
|
115
|
+
## 10. Change log
|
|
116
|
+
|
|
117
|
+
- **v1.1.0** (2026-05-17): Adds the production-scope guarantee to §4.2: silver faces exclude non-production records using row-level flags where available and org-level filtering where test isolation is org-scoped. Clarifies that "conform-only and wide" means wide over the production-scope row set. Updates the spine definition to Org-to-Market as the analytics geography dimensions, per ADR-0013 amendment (2026-05-17); Service Area and Lesson Site remain Platform-owned operational reference data, not spine dimensions. Updates §7 producer responsibilities accordingly.
|
|
118
|
+
- **v1.0.0** (2026-05-14): Initial publication. Establishes the silver tier model, the per-face conform guarantees, the identity and geography spine, consumption rules, and versioning policy, per ADR-0016.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Coaching Silver — Utilization Hours Column Spec
|
|
2
|
+
|
|
3
|
+
**Status:** v1.0.0
|
|
4
|
+
**Date:** 2026-05-19
|
|
5
|
+
**Owner:** coaching (source schema); platform (silver face wiring)
|
|
6
|
+
**Thread:** `2026-05-19-platform-mart-100-percent-deployment-per-domain-asks`
|
|
7
|
+
**Gates:** `coach_capacity_hours`, `coach_scheduled_hours`, `coach_idle_hours`, `coach_utilization_rate` (4 metrics in §6 Coaching mart section)
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
|
|
11
|
+
This document specifies the derivation logic for the three coaching utilization-hours columns that Platform will wire against Coaching's bronze tables when extending the coaching silver face. The fourth metric (`coach_utilization_rate`) is a derived ratio (`coach_scheduled_hours / coach_capacity_hours`) that Platform can compute from the three source columns without additional silver work.
|
|
12
|
+
|
|
13
|
+
## Source tables
|
|
14
|
+
|
|
15
|
+
All source tables live in the `coaching` schema in the sguild-domains Supabase project.
|
|
16
|
+
|
|
17
|
+
### `coaching.availability_template`
|
|
18
|
+
|
|
19
|
+
Recurring weekly supply pattern per coach. One row per (coach, day_of_week, time_window).
|
|
20
|
+
|
|
21
|
+
| Column | Type | Notes |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `coach_id` | text | FK → `coaching.coach.id` |
|
|
24
|
+
| `organization_id` | text | Tenancy key |
|
|
25
|
+
| `day_of_week` | int | 0=Sunday … 6=Saturday (ISO-style; verify against actual seeded data) |
|
|
26
|
+
| `local_start_time` | text | HH:MM:SS string in local timezone |
|
|
27
|
+
| `local_end_time` | text | HH:MM:SS string in local timezone |
|
|
28
|
+
| `timezone` | text | IANA timezone string (e.g., `America/Chicago`) |
|
|
29
|
+
| `effective_start_date` | timestamptz | Inclusive; template is active from this date |
|
|
30
|
+
| `effective_end_date` | timestamptz | Inclusive; NULL means open-ended |
|
|
31
|
+
| `status` | text | Only `active` rows contribute to capacity |
|
|
32
|
+
|
|
33
|
+
### `coaching.availability_exception`
|
|
34
|
+
|
|
35
|
+
One-off supply overrides layered on top of templates.
|
|
36
|
+
|
|
37
|
+
| Column | Type | Notes |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `coach_id` | text | FK → `coaching.coach.id` |
|
|
40
|
+
| `organization_id` | text | Tenancy key |
|
|
41
|
+
| `window_start` | timestamptz | UTC; inclusive |
|
|
42
|
+
| `window_end` | timestamptz | UTC; inclusive |
|
|
43
|
+
| `type` | text | `additional` adds hours; `unavailable` removes hours |
|
|
44
|
+
|
|
45
|
+
### `coaching.coach_lesson_booking`
|
|
46
|
+
|
|
47
|
+
Lock-aware lesson projection per (coach, lesson). One row per booked lesson.
|
|
48
|
+
|
|
49
|
+
| Column | Type | Notes |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `coach_id` | text | FK → `coaching.coach.id` |
|
|
52
|
+
| `organization_id` | text | Tenancy key |
|
|
53
|
+
| `lesson_start` | timestamptz | UTC |
|
|
54
|
+
| `lesson_end` | timestamptz | UTC |
|
|
55
|
+
| `state` | text | `reserved`, `locked`, `consumed`, `released` |
|
|
56
|
+
|
|
57
|
+
## Derivation logic
|
|
58
|
+
|
|
59
|
+
### `coach_capacity_hours` — per coach per period
|
|
60
|
+
|
|
61
|
+
1. For each `availability_template` row where `status = 'active'` and the effective date range overlaps the aggregation period:
|
|
62
|
+
- Enumerate calendar days in the period that match `day_of_week`.
|
|
63
|
+
- For each matching day, parse `local_start_time` and `local_end_time` using the row's `timezone`, convert to UTC, and compute the duration in hours.
|
|
64
|
+
- Sum across matching days.
|
|
65
|
+
2. Subtract the intersection of any `availability_exception` rows with `type = 'unavailable'` for the same coach that overlap each matching day's availability window (clip to the template window, not the full day).
|
|
66
|
+
3. Add the duration (in hours) of any `availability_exception` rows with `type = 'additional'` for the same coach whose `window_start` falls within the period.
|
|
67
|
+
|
|
68
|
+
**Edge cases:**
|
|
69
|
+
- If `effective_end_date` IS NULL, treat as open-ended (no upper bound).
|
|
70
|
+
- If a coach has no active templates for a period, capacity is 0 (not NULL).
|
|
71
|
+
- Coaching does not guarantee that `unavailable` exceptions are always bounded within a template window; Platform should clamp the subtraction at 0 per coach per day.
|
|
72
|
+
|
|
73
|
+
### `coach_scheduled_hours` — per coach per period
|
|
74
|
+
|
|
75
|
+
Sum of `(lesson_end - lesson_start)` in hours for all `coach_lesson_booking` rows where:
|
|
76
|
+
- `state IN ('locked', 'consumed')`
|
|
77
|
+
- `lesson_start` falls within the aggregation period (inclusive on both bounds)
|
|
78
|
+
- `coach_id` matches
|
|
79
|
+
|
|
80
|
+
`released` rows are excluded. `reserved` rows are excluded (reservation is not a confirmed schedule commitment).
|
|
81
|
+
|
|
82
|
+
### `coach_idle_hours` — per coach per period
|
|
83
|
+
|
|
84
|
+
`coach_capacity_hours - coach_scheduled_hours`
|
|
85
|
+
|
|
86
|
+
If the result is negative (a booking fell outside declared availability), Platform should clamp to 0 and optionally emit a data-quality signal. Coaching expects this to be rare or zero in clean data.
|
|
87
|
+
|
|
88
|
+
## Period grain
|
|
89
|
+
|
|
90
|
+
The mart section's aggregation period is determined by the compute call's `periodStart` / `periodEnd` parameters. Coaching makes no assumption about the grain (week, month, etc.). All derivations above are period-agnostic and compose correctly at any grain.
|
|
91
|
+
|
|
92
|
+
## Null posture
|
|
93
|
+
|
|
94
|
+
| Column | Null condition |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `coach_capacity_hours` | Not null; 0 if no active templates in period |
|
|
97
|
+
| `coach_scheduled_hours` | Not null; 0 if no locked/consumed bookings in period |
|
|
98
|
+
| `coach_idle_hours` | Not null; clamped to 0 if negative |
|
|
99
|
+
| `coach_utilization_rate` | NULL if `coach_capacity_hours = 0` (avoid divide-by-zero) |
|
|
100
|
+
|
|
101
|
+
## Columns NOT in scope
|
|
102
|
+
|
|
103
|
+
- `coach_idle_hours_by_capability`: no capability taxonomy exists in the Coaching schema. Emit `null:not_applicable` permanently until a capability tagging model is introduced.
|
|
104
|
+
- `coach_ramp_time_days`, `coach_tenure_distribution`, `coach_quality_score`: no `coaching.coach_employment` table. Emit `null:upstream_unavailable`.
|
|
105
|
+
- `coach_satisfaction_of_platform`: no survey table. Emit `null:upstream_unavailable`.
|