@odla-ai/chapter 0.51.0 → 0.52.0

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 CHANGED
@@ -185,6 +185,25 @@ This carries the compiler's complete light map, derived dark map, and invert
185
185
  map into Chapter without adding an `@odla-ai/brand` runtime dependency to
186
186
  Chapter itself.
187
187
 
188
+ #### Packaged workspaces are styled by Chapter, through `@odla-ai/ui`
189
+
190
+ Chapter owns the styling of every workspace it ships (Admission, Builder
191
+ Library, People, Settings, and the rest) and does it only through `@odla-ai/ui`
192
+ form components and the `--ui-*` tokens the active theme scope supplies. A
193
+ packaged picker is always the `@odla-ai/ui` `Select`, a native select carrying
194
+ the `select` class, so it takes the host's theme without any host CSS, keeps
195
+ its accessible name through its `Field` label, and keeps native keyboard
196
+ behaviour. A guard test in the package fails if a bare select tag reappears in
197
+ a Chapter UI source.
198
+
199
+ The boundary runs the other way too. A host must not patch package-owned
200
+ markup with its own CSS: if a packaged control looks browser-default, the theme
201
+ layer is missing (see the banner above) or the package has a bug to report,
202
+ never a selector to override. Any custom picker a host adds beside a packaged
203
+ workspace should be the same `@odla-ai/ui` `Select` (with `Field` for its
204
+ label) rather than a bare element, so host and package controls read as one
205
+ surface.
206
+
188
207
  ## API quick start
189
208
 
190
209
  ```ts
@@ -932,6 +951,32 @@ removing it changes no customer-facing behavior. The BNF frontend may be down
932
951
  while the webhook continues to ingest. Registering a Stripe endpoint and
933
952
  enabling production remain separate reviewed operations.
934
953
 
954
+ A second delivery of a retained event id is a replay when its digest matches, or
955
+ when its canonical fact (id, type, created, and the allowlisted `data.object`
956
+ fields) equals the retained fact however the delivery envelope changed. A
957
+ canonical replay also resolves an open collision for that event, recording a
958
+ `paymentRecoveryReviews` row with the system as actor, unless the collision
959
+ recorded a different fact. Only a delivery whose fact differs registers a
960
+ collision, and it stays active through rebuilds until an operator decides:
961
+ `POST` with `{ command: "resolve", eventId, mutationId, reason }` records the
962
+ actor and reason, dismisses the colliding delivery so the same one replays
963
+ quietly, and keeps the retained fact as the evidence of record; it refuses
964
+ without a valid `mutationId` and a reason. `POST` with
965
+ `{ command: "recover-event", eventId }` instead trusts an independent read of
966
+ the provider Event and rewrites the retained digest when every fact matches.
967
+
968
+ `/api/admin/payment-recovery` is the drain on both sides. A chapter lists and
969
+ retries its quarantined Stripe receipts; a hub that owns the read model lists
970
+ every active commercial exception (`count` plus provider ids, no PII) and
971
+ retries one through the same ingest the signed webhook uses after an
972
+ independent provider read, with a review row per retry.
973
+
974
+ Both Workers raise an error-level operator alert when a receipt enters
975
+ quarantine or a commercial exception becomes active, carrying the current count
976
+ and provider event ids only. Pass `recordError` from `@odla-ai/o11y` as
977
+ `chapterWorker({ chapter, recordError })` to deliver those alerts to o11y; the
978
+ default is one structured `console.error` line per alert.
979
+
935
980
  `POST` with `{ command: "reconcile-chapters" }` is a separate, two-sided opt-in
936
981
  comparison. A hub target and the matching follower reader must both set
937
982
  `commercialParity: true`; neither record browsing nor Stripe shadow mode grants
@@ -1107,10 +1152,15 @@ name is public configuration, not a credential. If a configured binding is
1107
1152
  missing at runtime, the target fails explicitly instead of falling back to a
1108
1153
  potentially misrouted public fetch.
1109
1154
 
1110
- Vault the same random value as `network_share_secret` in the follower and as
1111
- `network_share_example_chapter` in the leader (or set a different
1112
- `secretName`). No secret is placed in source, browser data, or provisioning
1113
- config. The standard collection drawer discovers the target and calls the
1155
+ Pair the edge by vaulting one generated value as `network_share_secret` in the
1156
+ follower and as `network_share_example_chapter` in the leader (or the target's
1157
+ `secretName`), never printed. The integration descriptor declares both slots,
1158
+ so provisioning tooling can hold them against the vault, and until both hold a
1159
+ value every leader-side surface reports the edge as `code: "edge_unpaired"`
1160
+ with a `hint` naming the CLI pairing command (`chapter edge pair`, which
1161
+ writes both slots from one generated value and ships with the next
1162
+ `@odla-ai/cli` release). No secret is placed in source, browser data, or
1163
+ provisioning config. The standard collection drawer discovers the target and calls the
1114
1164
  admin-gated push route. A follower must declare the receiving CRM type/fields;
1115
1165
  otherwise it rejects the record cleanly instead of dropping fields.
1116
1166
 
@@ -1234,8 +1284,11 @@ Use distinct development tenants and follower origins for the first delivery:
1234
1284
 
1235
1285
  1. Confirm every target `id` exactly equals the follower's `chapter.id`. For
1236
1286
  Cloudflare Workers, confirm its `binding` exists in the leader's development
1237
- Wrangler environment. Vault one random value as `network_share_secret` in
1238
- the follower and under the target's resolved `secretName` in the leader.
1287
+ Wrangler environment. Pair the edge: vault one generated value as
1288
+ `network_share_secret` in the follower and under the target's resolved
1289
+ `secretName` in the leader (the CLI's `chapter edge pair` command does both
1290
+ writes from the next `@odla-ai/cli` release on). The leader rollup reports
1291
+ `edge_unpaired` until both slots hold a value.
1239
1292
  2. Confirm `GET /api/admin/network/targets` lists the development follower with
1240
1293
  the expected compatible record types.
1241
1294
  3. Share one test person and one test business from the leader's record drawer.
@@ -1464,13 +1517,38 @@ These bite silently — a smoke test won't catch them:
1464
1517
  | Template | Fires from | Configurable |
1465
1518
  |---|---|---|
1466
1519
  | `adminNotification` | `POST /api/applications` (submit) — or the Stripe webhook (first payment) | `sends.adminNotification: "submit" \| "payment" \| "never"` (default `"submit"`) |
1467
- | `prepEmail` | `POST /api/schedule/book` | template `enabled` flag |
1520
+ | `submitConfirmation` | `POST /api/applications` (submit) — a free tier always; a paid tier only when `paymentConfirmation` is disabled, so a paid applicant is confirmed once | template `enabled` flag |
1521
+ | `prepEmail` | `POST /api/schedule/book` — once per application; a reschedule sends no second one | template `enabled` flag |
1468
1522
  | `paymentConfirmation` | Stripe webhook, first successful invoice | template `enabled` flag |
1523
+ | `seatAccepted` | `POST /api/named-seat/accept` — to the seat's purchaser, once per seat, naming the recipient and tier | template `enabled` flag; the seeded default copy applies until the group row carries its own `emailTemplates.seatAccepted` |
1469
1524
 
1470
1525
  ```ts
1471
1526
  defineChapter({ /* … */ sends: { adminNotification: "payment" } });
1472
1527
  ```
1473
1528
 
1529
+ **Template variables.** Every value comes from stored data — the frozen
1530
+ checkout the applicant consented to, the tier catalog, the booked meeting,
1531
+ the request origin — never from a price or a name written into code. An
1532
+ unknown placeholder renders empty, so copy can use any subset.
1533
+
1534
+ | Variable | Templates | Value |
1535
+ |---|---|---|
1536
+ | `firstName`, `lastName`, `email`, `phone`, `state` | all | the application row |
1537
+ | `tier`, `tierName` | `adminNotification`, `submitConfirmation`, `prepEmail` | the frozen checkout quote's tier name, else the catalog name for `applications.tierId` |
1538
+ | `paymentAmount`, `paymentAmountSummary` | `submitConfirmation`, `paymentConfirmation`, `prepEmail` | `$900.00` and ` of $900.00` from the frozen checkout; both blank for a free tier or before checkout |
1539
+ | `submitNextStep` | `submitConfirmation` | "Next, schedule your introduction call." / "Next, complete your membership payment." / "Your membership is approved." |
1540
+ | `paymentNextStep` | `paymentConfirmation` | the post-payment step (schedule, waived, or approved) |
1541
+ | `startAt`, `endAt`, `timezone` | `prepEmail` | the booked call rendered in the group's `scheduling.timezone`, e.g. `Wednesday, October 7, 2026 at 10:00 AM EDT` |
1542
+ | `meetUrl`, `htmlLink` | `prepEmail` | the booked meeting's Google Meet and calendar links (blank when the provider returned none) |
1543
+ | `meetingTime`, `meetingLink` | `prepEmail` | aliases of `startAt` and `meetUrl` (falling back to `htmlLink`) for copy written before those existed |
1544
+ | `adminUrl`, `membersUrl` | `adminNotification`, `submitConfirmation`, `onboardingInvite` | `<origin>/admin/` and `<origin>/members/` from the request that fired the send |
1545
+ | `refundPolicyText`, `commitmentText`, `normsText` | all | the group row's policy copy |
1546
+
1547
+ **From display name.** `emails.fromName` (seeded to the group row, editable
1548
+ in Settings → Email) turns the From header into `{ name, email }` —
1549
+ `Tori Horton <tori@…>` in an inbox — while the address stays the Worker's
1550
+ `EMAIL_FROM`. A group with no name sends the bare address exactly as before.
1551
+
1474
1552
  **Re-check this on every chapter upgrade.** Wiring a send changes a site's
1475
1553
  outbound mail with no local diff — release notes call out send changes
1476
1554
  explicitly for that reason.
@@ -1634,7 +1712,11 @@ These bite silently — a smoke test won't catch them:
1634
1712
  `drainMembershipEffects` recovers pending provider work and cancels unpaid
1635
1713
  seats within one exact configured runtime. It never refunds an unaccepted
1636
1714
  seat: a purchased seat waits for its recipient for the whole paid term, and a
1637
- refund is a super admin's decision on request. Refunding the recipient's own
1715
+ refund is a super admin's decision on request. When the recipient accepts,
1716
+ the chapter emails the purchaser one `seatAccepted` notice with the
1717
+ recipient's name and tier, deduplicated per seat through the email log; a
1718
+ chapter edits or disables it on its group row, and the default copy applies
1719
+ until then. Refunding the recipient's own
1638
1720
  application (`POST /api/admin/applications/:id/refund`) requires a super
1639
1721
  admin when it carries a gifted seat. On a chapter,
1640
1722
  `GET /api/admin/applications/:id/named-seat` shows a purchaser's seat and
@@ -1680,6 +1762,18 @@ These bite silently — a smoke test won't catch them:
1680
1762
  read the same field instead of hard-coding a founding price. The issued offer
1681
1763
  at checkout remains the only promise.
1682
1764
 
1765
+ On the network owner, `GET /api/admin/membership-policy-versions` (super
1766
+ admin; `?chapterId=` narrows it to one chapter) lists every offering and
1767
+ discount policy version the authority holds, newest first, with the state
1768
+ issuance would give it now (active, scheduled, superseded with the date it
1769
+ was passed over, draft, retired), its effective or qualifying window, the
1770
+ provider mechanism a discount uses, and whatever `source`, `actor` and
1771
+ `recordedAt` the writing host stored on the row. `MembershipPolicyVersions`
1772
+ from `@odla-ai/chapter/ui/admin` renders it read-only beside
1773
+ `NetworkSeatBilling`, so an operator can see why a card shows a list price
1774
+ and a payable price without reading the database; nothing on it edits a
1775
+ version.
1776
+
1683
1777
  On a chapter, a person record with no linked application — someone invited or
1684
1778
  a candidate — is managed by the chapter's own admins the way the network
1685
1779
  owner manages it: the record's Invitation tab sets or revokes the local