@company-semantics/contracts 58.0.0 → 58.2.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.
Files changed (55) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/resource-keys.test.ts +30 -0
  3. package/src/api/generated-spec-hash.ts +2 -2
  4. package/src/api/generated.ts +33 -1
  5. package/src/chat/README.md +15 -4
  6. package/src/chat/__tests__/proactive-kind.test.ts +51 -0
  7. package/src/chat/index.ts +9 -0
  8. package/src/chat/proactive-kind.ts +51 -0
  9. package/src/chat/schemas.ts +92 -1
  10. package/src/chat/types.ts +19 -1
  11. package/src/index.ts +110 -0
  12. package/src/message-parts/README.md +5 -0
  13. package/src/message-parts/__tests__/suggested-replies.test.ts +52 -0
  14. package/src/message-parts/__tests__/wire.test.ts +48 -0
  15. package/src/message-parts/index.ts +8 -0
  16. package/src/message-parts/suggested-replies.ts +48 -0
  17. package/src/message-parts/types.ts +7 -1
  18. package/src/message-parts/wire.ts +26 -0
  19. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +1 -0
  20. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +1 -0
  21. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +207 -0
  22. package/src/notifications/__tests__/fixtures.ts +9 -0
  23. package/src/notifications/__tests__/org-invite.test.ts +75 -0
  24. package/src/notifications/__tests__/render-snapshot.test.ts +8 -0
  25. package/src/notifications/kinds/org-invite.ts +27 -12
  26. package/src/notifications/payloads.ts +7 -0
  27. package/src/org/README.md +38 -0
  28. package/src/org/__tests__/canonical-facts.test.ts +118 -0
  29. package/src/org/__tests__/structure-inference.test.ts +392 -0
  30. package/src/org/__tests__/structure-provenance.test.ts +187 -0
  31. package/src/org/canonical-facts.ts +94 -1
  32. package/src/org/index.ts +54 -0
  33. package/src/org/schemas.ts +23 -0
  34. package/src/org/structure-inference.ts +521 -0
  35. package/src/proactive/README.md +125 -0
  36. package/src/proactive/__tests__/README.md +56 -0
  37. package/src/proactive/__tests__/chat-templates.test.ts +167 -0
  38. package/src/proactive/__tests__/compile-fixtures.ts +110 -0
  39. package/src/proactive/__tests__/vocabulary.test.ts +279 -0
  40. package/src/proactive/classes.ts +125 -0
  41. package/src/proactive/composer.ts +104 -0
  42. package/src/proactive/facts.ts +87 -0
  43. package/src/proactive/index.ts +52 -0
  44. package/src/proactive/kinds.ts +127 -0
  45. package/src/proactive/plan.ts +79 -0
  46. package/src/proactive/registry.ts +71 -0
  47. package/src/proactive/surfaces.ts +59 -0
  48. package/src/proactive/templates/README.md +58 -0
  49. package/src/proactive/templates/index.ts +32 -0
  50. package/src/proactive/templates/morning-brief.ts +77 -0
  51. package/src/proactive/templates/org-became-shared.ts +54 -0
  52. package/src/resource-key-types.ts +9 -0
  53. package/src/resource-keys.ts +2 -0
  54. package/src/user-notifications/README.md +10 -0
  55. package/src/user-notifications/kinds.ts +28 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Presentation classes — the TOTAL FUNCTION from "how loud is this" to "which
3
+ * surfaces does it earn" (control ADR slug
4
+ * `proactive-delivery-occurrence-audience-projection`, ADR-CONTRACTS-142).
5
+ *
6
+ * A proactive event kind carries its `class` plus the IDS each surface needs.
7
+ * It never restates the surface booleans; those are derived from this table.
8
+ * If a kind ever duplicated them, the compiler would be left having to prove
9
+ * two declarations agree — and it cannot, which is the guarantee this module
10
+ * exists to provide.
11
+ *
12
+ * INVARIANTS:
13
+ * - TOTAL in both directions. `as const satisfies Record<...>` means a class
14
+ * with no entry fails to compile, and an entry for a non-class fails too.
15
+ * - EXACTLY ONE badge carrier per class. Never zero, never two — the badge is
16
+ * the attention count, and a second carrier would double-count it.
17
+ * - EVERY class writes a durable inbox row. What varies is whether that row
18
+ * is born read, and `bornRead` is the one place that rule is stated.
19
+ * - This is a LOUDNESS axis, not a severity axis. `error|warning|info` exists
20
+ * in this package scoped to org-transformation findings and must not be
21
+ * overloaded here.
22
+ */
23
+
24
+ // =============================================================================
25
+ // ProactivePresentationClass
26
+ // =============================================================================
27
+
28
+ /**
29
+ * The presentation class of a proactive occurrence — how LOUD it is and which
30
+ * surfaces it earns. Deliberately NOT a severity axis: `error|warning|info`
31
+ * exists in this package scoped to org-transformation findings and must not be
32
+ * overloaded.
33
+ *
34
+ * | class | banner | chat | inbox row | badge carrier |
35
+ * | -------------- | --------- | ---- | --------- | ------------- |
36
+ * | `announcement` | ack-gated | yes | born read | banner |
37
+ * | `explained` | no | yes | born read | chat |
38
+ * | `briefing` | no | yes | born read | chat |
39
+ * | `notice` | no | no | unread | inbox |
40
+ */
41
+ export const PROACTIVE_PRESENTATION_CLASSES = [
42
+ "announcement",
43
+ "explained",
44
+ "briefing",
45
+ "notice",
46
+ ] as const;
47
+ export type ProactivePresentationClass =
48
+ (typeof PROACTIVE_PRESENTATION_CLASSES)[number];
49
+
50
+ // =============================================================================
51
+ // ClassSurfacePlan
52
+ // =============================================================================
53
+
54
+ /** Exactly one surface owns the unread badge. Never zero, never two. */
55
+ export type BadgeCarrier = "banner" | "chat" | "inbox";
56
+
57
+ /**
58
+ * A UNION, not a flat interface, so "the badge names a surface this class does
59
+ * not have" is a COMPILE error rather than a runtime surprise. Each member
60
+ * fixes the surfaces its badge carrier presupposes: a `banner` badge fixes
61
+ * `banner: true`; a `chat` badge fixes `banner: false, chat: true`; an `inbox`
62
+ * badge fixes both false.
63
+ *
64
+ * `inbox` is literal-true in every member: EVERY proactive event writes a
65
+ * durable row. What varies is whether that row is born read.
66
+ */
67
+ export type ClassSurfacePlan =
68
+ | {
69
+ readonly badge: "banner";
70
+ readonly banner: true;
71
+ readonly chat: boolean;
72
+ readonly inbox: true;
73
+ }
74
+ | {
75
+ readonly badge: "chat";
76
+ readonly banner: false;
77
+ readonly chat: true;
78
+ readonly inbox: true;
79
+ }
80
+ | {
81
+ readonly badge: "inbox";
82
+ readonly banner: false;
83
+ readonly chat: false;
84
+ readonly inbox: true;
85
+ };
86
+
87
+ // =============================================================================
88
+ // CLASS_SURFACES
89
+ // =============================================================================
90
+
91
+ /**
92
+ * The class table. Totality is checked in BOTH directions by
93
+ * `satisfies Record<ProactivePresentationClass, ClassSurfacePlan>`: a new
94
+ * class with no entry fails, and an entry for a non-class fails. `as const`
95
+ * keeps each entry's literal shape, so `CLASS_SURFACES.notice.chat` is the
96
+ * type `false`, not `boolean`.
97
+ */
98
+ export const CLASS_SURFACES = {
99
+ announcement: { banner: true, chat: true, inbox: true, badge: "banner" },
100
+ explained: { banner: false, chat: true, inbox: true, badge: "chat" },
101
+ briefing: { banner: false, chat: true, inbox: true, badge: "chat" },
102
+ notice: { banner: false, chat: false, inbox: true, badge: "inbox" },
103
+ } as const satisfies Record<ProactivePresentationClass, ClassSurfacePlan>;
104
+
105
+ // =============================================================================
106
+ // bornRead
107
+ // =============================================================================
108
+
109
+ /**
110
+ * "Born read whenever a louder surface owns the badge" — stated ONCE.
111
+ *
112
+ * A backend that re-derives this per call site is how the two halves drift.
113
+ * The badge carrier is the attention carrier; every other surface is the paper
114
+ * trail (the rule generalised from the backend ADR slug
115
+ * `access-requested-inbox-born-read`).
116
+ *
117
+ * PURE: no clock, no env, no I/O. Only `chat` and `inbox` can be asked — the
118
+ * banner is ack-gated per recipient and has no read state to be born into.
119
+ */
120
+ export function bornRead(
121
+ surfaces: ClassSurfacePlan,
122
+ surface: "chat" | "inbox",
123
+ ): boolean {
124
+ return surfaces.badge !== surface;
125
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The pushed-chat composer port — what one chat template SAYS, given its facts
3
+ * (ADR-CONTRACTS-142).
4
+ *
5
+ * Copy lives here, in contracts, and not in the backend domain that writes the
6
+ * chat. That is the precedent notify/'s README states for outbound
7
+ * notifications — there is no templates/ directory in the sending domain;
8
+ * copy belongs to contracts' `compose` — applied to the third surface. The
9
+ * backend hands a composer facts and writes what comes back; it never owns a
10
+ * sentence.
11
+ *
12
+ * Members are ARROW PROPERTIES, never method shorthand — the contracts
13
+ * vocabulary guard reads a method signature as behaviour smuggled into the
14
+ * vocabulary. Same rule, same reason, as `NotificationDefinition.compose`.
15
+ *
16
+ * INVARIANTS — three inherited from `NotificationDefinition`, one of its own:
17
+ *
18
+ * - PURE. `(facts)` in, message out. No clock, no environment, no I/O, no
19
+ * ambient constant of its own. The same facts produce a byte-identical
20
+ * message every time, which is what lets a test prove purity.
21
+ * - CONTENT ONLY. No markup, no styling, no channel names, no hrefs the app
22
+ * owns. If a value is a formatting choice a renderer could reasonably make
23
+ * differently, it is the renderer's.
24
+ * - TOTAL REGISTRY. `PROACTIVE_CHAT_COMPOSERS` in `./templates/index` is
25
+ * checked in both directions over `PROACTIVE_CHAT_TEMPLATES`: a template
26
+ * with no composer fails to compile, and a composer for a non-template
27
+ * fails too.
28
+ * - FROZEN AT WRITE TIME (INV-PROACTIVE-CONTENT). The message is written ONCE
29
+ * into `chat_messages.content` and never re-renders. It may therefore only
30
+ * interpolate facts the recipient was entitled to at compose time. This is
31
+ * the deliberate OPPOSITE of the durable-inbox rule — an inbox row holds
32
+ * identifiers and is rendered at read time under the reader's CURRENT
33
+ * authority — and both are correct: a transcript that changed under the
34
+ * reader would be a lie about the conversation.
35
+ */
36
+
37
+ import type { SuggestedReply } from "../message-parts/suggested-replies";
38
+ import type { ProactiveChatFacts } from "./facts";
39
+ import type { ProactiveChatTemplate } from "./surfaces";
40
+
41
+ // =============================================================================
42
+ // ProactiveChatMessage
43
+ // =============================================================================
44
+
45
+ /**
46
+ * At most this many chips under a pushed message. More than four is a menu,
47
+ * not a chip row.
48
+ */
49
+ export const PROACTIVE_CHAT_MAX_REPLIES = 4;
50
+
51
+ /**
52
+ * What a composer returns: the chat's title, the assistant turn's prose, and
53
+ * the chips beneath it.
54
+ *
55
+ * `replies` reuses the `SuggestedReply` vocabulary from `../message-parts`
56
+ * rather than redeclaring it — the backend writes these straight into a
57
+ * `suggested-replies` part, and one name for one shape keeps the two halves
58
+ * from drifting. A chip carries a `label` and an optional `prompt` because a
59
+ * short chip should be able to fire a full question.
60
+ */
61
+ export interface ProactiveChatMessage {
62
+ /** The chat's `title` column. */
63
+ readonly title: string;
64
+ /** The assistant turn's prose — also `chat_messages.content`. */
65
+ readonly text: string;
66
+ /** 0..`PROACTIVE_CHAT_MAX_REPLIES` chips. */
67
+ readonly replies: readonly SuggestedReply[];
68
+ }
69
+
70
+ // =============================================================================
71
+ // ProactiveChatComposer
72
+ // =============================================================================
73
+
74
+ /**
75
+ * One template's composer, parameterised by the template so `compose`
76
+ * receives that template's facts precisely rather than a union of every
77
+ * template's facts.
78
+ *
79
+ * A DISTRIBUTIVE conditional type rather than a generic object type with a
80
+ * union default, and the difference is load-bearing: `compose` is an arrow
81
+ * property, so it is checked contravariantly, and a composer that takes ONE
82
+ * template's facts is not assignable to one that takes the union of all facts.
83
+ * Distributing over `T` makes the bare `ProactiveChatComposer` a union of
84
+ * per-template composers, which is what `satisfies Record<ProactiveChatTemplate,
85
+ * ProactiveChatComposer>` in `./templates/index` needs to stay total once a
86
+ * second template with different facts joins the registry.
87
+ *
88
+ * `ProactiveChatFacts[T]` is the structural lock: a template without facts in
89
+ * `./facts` cannot be given a composer.
90
+ */
91
+ export type ProactiveChatComposer<
92
+ T extends ProactiveChatTemplate = ProactiveChatTemplate,
93
+ > = T extends unknown
94
+ ? {
95
+ /** The template this composes. MUST match its registry key. */
96
+ readonly template: T;
97
+ /**
98
+ * Say what this pushed chat says. Pure: `(facts)` in, message out —
99
+ * and frozen at write time, so only facts the recipient was entitled
100
+ * to at compose time (INV-PROACTIVE-CONTENT).
101
+ */
102
+ readonly compose: (facts: ProactiveChatFacts[T]) => ProactiveChatMessage;
103
+ }
104
+ : never;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The FACTS each pushed-chat template composes from, keyed by template
3
+ * (ADR-CONTRACTS-142).
4
+ *
5
+ * Facts only — no copy, no formatted values. The composer in `./composer`
6
+ * turns these into prose; anything that is a phrasing choice belongs there,
7
+ * and anything that is a channel's formatting choice belongs in no vocabulary
8
+ * at all.
9
+ *
10
+ * Keyed by `ProactiveChatTemplate` for the same reason `NotificationPayloads`
11
+ * is keyed by `NotificationKind`: `ProactiveChatFacts[T]` is the structural
12
+ * lock between the two vocabularies. A template added to
13
+ * `PROACTIVE_CHAT_TEMPLATES` without facts here cannot be given a composer,
14
+ * and so cannot reach the registry.
15
+ *
16
+ * INVARIANTS:
17
+ * - Every fact is something the RECIPIENT was entitled to see at compose
18
+ * time. The message is frozen into the transcript (INV-PROACTIVE-CONTENT in
19
+ * `./composer`), so a fact that could later be revoked must not be
20
+ * interpolated at all — there is no re-render to take it back.
21
+ * - Per-recipient personalisation lives HERE, in the facts, never in
22
+ * occurrence identity. Two people in one org share an occurrence and
23
+ * receive different prose because they were handed different facts.
24
+ */
25
+
26
+ // =============================================================================
27
+ // Per-template facts
28
+ // =============================================================================
29
+
30
+ /**
31
+ * The org just flipped from personal to shared: the first invited person
32
+ * accepted. Addressed to the owner who sent the invite.
33
+ */
34
+ export interface OrgBecameSharedFacts {
35
+ /** The org's display name, as the owner knows it. */
36
+ readonly orgName: string;
37
+ /**
38
+ * Who accepted. A display name the owner already sees in the members list —
39
+ * the owner invited this person, so naming them discloses nothing new.
40
+ */
41
+ readonly joinerDisplayName: string;
42
+ }
43
+
44
+ /**
45
+ * The morning brief, for ONE recipient. This is where personalisation lives:
46
+ * two people in one org on one morning share an occurrence and are handed
47
+ * different facts, so they receive different prose.
48
+ *
49
+ * Every field is the recipient's own or the org's, as they see it — nothing
50
+ * here is a fact ABOUT another person. The counts are the recipient's own
51
+ * standing state and inbox, which they are entitled to at any moment; the
52
+ * message is frozen (INV-PROACTIVE-CONTENT), so a fact that could later be
53
+ * revoked would have no re-render to take it back, and none is included.
54
+ */
55
+ export interface MorningBriefFacts {
56
+ /** The recipient's own display name, for the greeting. */
57
+ readonly recipientDisplayName: string;
58
+ /** The org's display name, as the recipient knows it. */
59
+ readonly orgName: string;
60
+ /**
61
+ * The org-local calendar day this brief is FOR, already rendered as the
62
+ * label the prose states ("Tuesday 25 August"). The ONE deliberate
63
+ * exception to "no formatted values": the day is an occurrence fact, but
64
+ * rendering it needs a locale and a time zone, and both are environment a
65
+ * pure composer must not read. The backend renders it once and hands it
66
+ * over as a fact.
67
+ */
68
+ readonly orgLocalDateLabel: string;
69
+ /** Decisions waiting on the recipient — THEIR action items, at compose time. */
70
+ readonly pendingActionItemCount: number;
71
+ /** Unread rows in the recipient's own inbox, at compose time. */
72
+ readonly unreadNotificationCount: number;
73
+ }
74
+
75
+ // =============================================================================
76
+ // ProactiveChatFacts
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Facts keyed by template. The index type `ProactiveChatFacts[T]` in
81
+ * `./composer` is what makes this total over `PROACTIVE_CHAT_TEMPLATES`: a
82
+ * template with no entry here fails at the composer's signature.
83
+ */
84
+ export interface ProactiveChatFacts {
85
+ readonly orgBecameShared: OrgBecameSharedFacts;
86
+ readonly morningBrief: MorningBriefFacts;
87
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * proactive/ — the vocabulary of proactive delivery: a presentation class that
3
+ * is a total function from class to surfaces, and kinds that carry ids, never
4
+ * surface booleans.
5
+ *
6
+ * See ./README.md for the domain, and ADR-CONTRACTS-142 for why this is a
7
+ * composer above the three notification vocabularies rather than a fourth peer.
8
+ */
9
+
10
+ // The class table and the born-read rule
11
+ export {
12
+ PROACTIVE_PRESENTATION_CLASSES,
13
+ CLASS_SURFACES,
14
+ bornRead,
15
+ } from "./classes";
16
+ export type {
17
+ ProactivePresentationClass,
18
+ BadgeCarrier,
19
+ ClassSurfacePlan,
20
+ } from "./classes";
21
+
22
+ // The ids a surface needs
23
+ export { ORG_SYSTEM_EVENT_TYPES, PROACTIVE_CHAT_TEMPLATES } from "./surfaces";
24
+ export type { OrgSystemEventType, ProactiveChatTemplate } from "./surfaces";
25
+
26
+ // The kinds and the occurrence vocabulary
27
+ export {
28
+ PROACTIVE_EVENT_KIND_IDS,
29
+ PROACTIVE_RECIPIENT_REASONS,
30
+ PROACTIVE_PROJECTIONS,
31
+ } from "./kinds";
32
+ export type {
33
+ ProactiveEventKind,
34
+ ProactiveEventId,
35
+ ProactiveRecipientReason,
36
+ ProactiveProjection,
37
+ } from "./kinds";
38
+
39
+ // The plan a kind carries, and the registry of every kind's plan
40
+ export type { ProactiveEventDefinition } from "./plan";
41
+ export { PROACTIVE_EVENT_KINDS } from "./registry";
42
+
43
+ // The pushed-chat composer port, its facts, and the registry of every
44
+ // template's prose
45
+ export type {
46
+ OrgBecameSharedFacts,
47
+ MorningBriefFacts,
48
+ ProactiveChatFacts,
49
+ } from "./facts";
50
+ export { PROACTIVE_CHAT_MAX_REPLIES } from "./composer";
51
+ export type { ProactiveChatComposer, ProactiveChatMessage } from "./composer";
52
+ export { PROACTIVE_CHAT_COMPOSERS } from "./templates/index";
@@ -0,0 +1,127 @@
1
+ /**
2
+ * What kinds of proactive event exist, and the vocabulary of ONE OCCURRENCE:
3
+ * its id, why each recipient was addressed, and what was materialized for them
4
+ * (control ADR slug `proactive-delivery-occurrence-audience-projection`,
5
+ * ADR-CONTRACTS-142).
6
+ *
7
+ * A proactive event is the system speaking first. One OCCURRENCE is recorded
8
+ * with a frozen audience, and projected onto the surfaces its presentation
9
+ * class earns (`./classes`). The kind names the class and the ids; this module
10
+ * names the kinds and the occurrence vocabulary around them.
11
+ *
12
+ * INVARIANTS:
13
+ * - `{domain}.{type}` dot notation, matching the three notification unions.
14
+ * These strings go on the wire (`proactiveKind` on a chat summary) and into
15
+ * a database column. Renaming one is a migration, not a tidy-up.
16
+ * - A kind's string is DISTINCT from its inbox kind's string
17
+ * (`org.became_shared` here, `proactive.org_became_shared` in
18
+ * `../user-notifications`; `brief.morning` here, `proactive.brief_morning`
19
+ * there). Identical names across two unions is how one
20
+ * union quietly becomes derived from the other, which the ADR with slug
21
+ * `user-notification-inbox-and-merged-feed` forbids.
22
+ * - Every kind has a plan in `./registry`. Enforced by the compiler, not by
23
+ * this comment — the registry `satisfies Record<ProactiveEventKind, …>`.
24
+ */
25
+
26
+ // =============================================================================
27
+ // ProactiveEventKind
28
+ // =============================================================================
29
+
30
+ /**
31
+ * New kinds MUST be added to:
32
+ * 1. This array
33
+ * 2. `PROACTIVE_EVENT_KINDS` in `./registry`, with a plan whose class fixes
34
+ * which ids the plan must and must not name
35
+ * 3. `USER_NOTIFICATION_KINDS`, because every class writes a durable row
36
+ *
37
+ * Step 2 is compiler-enforced; the array will not type-check without it.
38
+ */
39
+ export const PROACTIVE_EVENT_KIND_IDS = [
40
+ /**
41
+ * The org stopped being personal: its first non-owner member joined. An
42
+ * `announcement` — it names the EXISTING `first_member_joined` banner, a
43
+ * pushed chat that explains the relocation of the settings sections, and a
44
+ * born-read inbox row.
45
+ */
46
+ "org.became_shared",
47
+ /**
48
+ * The morning brief. A `briefing` — a pushed chat that carries the badge
49
+ * and a born-read inbox row, and NO banner: the plan union in `./plan`
50
+ * types `bannerType` as `never` for this class, so the kind needs no new
51
+ * machinery to be kept out of the banner strip.
52
+ *
53
+ * ONE occurrence per org per org-local day, with N recipients. What differs
54
+ * between two people in the same org on the same morning is the FACTS each
55
+ * is handed (`MorningBriefFacts` in `./facts`), never the occurrence.
56
+ */
57
+ "brief.morning",
58
+ ] as const;
59
+ export type ProactiveEventKind = (typeof PROACTIVE_EVENT_KIND_IDS)[number];
60
+
61
+ // =============================================================================
62
+ // ProactiveEventId
63
+ // =============================================================================
64
+
65
+ declare const ProactiveEventIdBrand: unique symbol;
66
+
67
+ /**
68
+ * The id of ONE OCCURRENCE. Branded so a raw string cannot be passed where an
69
+ * occurrence id is required — the chat interaction key is derived from it, and
70
+ * a bare string there is how "one chat per kind per user forever" comes back.
71
+ *
72
+ * At runtime this is a plain string; the brand exists only in the type system
73
+ * (same idiom as `TraceId` in `../tracing`).
74
+ */
75
+ export type ProactiveEventId = string & {
76
+ readonly [ProactiveEventIdBrand]: true;
77
+ };
78
+
79
+ // =============================================================================
80
+ // ProactiveRecipientReason
81
+ // =============================================================================
82
+
83
+ /**
84
+ * WHY this occurrence was addressed to this person. A CLOSED set, not free
85
+ * text: `proactive_event_recipients` is relied on as the frozen audit record of
86
+ * the intended audience, and an unconstrained string would make that record
87
+ * unqueryable and let every new kind invent its own spelling.
88
+ */
89
+ export const PROACTIVE_RECIPIENT_REASONS = [
90
+ /** Addressed because they own the org. */
91
+ "org_owner",
92
+ /** Addressed because they administer the org. */
93
+ "org_admin",
94
+ /** Addressed because they are a member of the org. */
95
+ "org_member",
96
+ /** Addressed because the occurrence is ABOUT them. */
97
+ "subject",
98
+ /** Addressed because they took part in the thing that happened. */
99
+ "participant",
100
+ ] as const;
101
+ export type ProactiveRecipientReason =
102
+ (typeof PROACTIVE_RECIPIENT_REASONS)[number];
103
+
104
+ // =============================================================================
105
+ // ProactiveProjection
106
+ // =============================================================================
107
+
108
+ /**
109
+ * What was materialized for one recipient. Deliberately broader than the three
110
+ * presentation surfaces: an occurrence has zero or more PROJECTIONS, and the
111
+ * two orthogonal axes are projections too. For `notification_handoff` what is
112
+ * recorded is the successful handoff to notify/ and its correlation — email
113
+ * delivery itself remains notify/'s business.
114
+ */
115
+ export const PROACTIVE_PROJECTIONS = [
116
+ /** An ack-gated banner row (an `org_system_event`). */
117
+ "banner",
118
+ /** A pushed chat, frozen into the transcript at write time. */
119
+ "chat",
120
+ /** A durable inbox row (a `user_notification`). */
121
+ "inbox",
122
+ /** Standing work state — a projection, NOT a loudness tier. */
123
+ "action_item",
124
+ /** The handoff to notify/ and its correlation id; not the delivery. */
125
+ "notification_handoff",
126
+ ] as const;
127
+ export type ProactiveProjection = (typeof PROACTIVE_PROJECTIONS)[number];
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The plan a proactive kind carries: its presentation class plus the IDS each
3
+ * surface needs — never the surface booleans (ADR-CONTRACTS-142).
4
+ *
5
+ * `ProactiveEventDefinition` is a discriminated union on `class`, and each
6
+ * member fixes what the class in `./classes` says it earns:
7
+ *
8
+ * | class | bannerType | chatTemplate | inboxKind |
9
+ * | ---------------------- | ---------- | ------------ | --------- |
10
+ * | `announcement` | REQUIRED | REQUIRED | REQUIRED |
11
+ * | `explained` `briefing` | `never` | REQUIRED | REQUIRED |
12
+ * | `notice` | `never` | `never` | REQUIRED |
13
+ *
14
+ * `never` is load-bearing: naming a banner a class does not have is a COMPILE
15
+ * error, not a field the backend has to remember to ignore. Restating the
16
+ * booleans on the kind would leave the compiler having to prove two
17
+ * declarations agree — this shape is what makes that unnecessary.
18
+ *
19
+ * The ONE consistency the compiler cannot express — that this union's members
20
+ * agree with `CLASS_SURFACES` — is pinned by the vocabulary test.
21
+ *
22
+ * INVARIANTS:
23
+ * - `inboxKind` is ALWAYS present. Every class writes a durable row; what
24
+ * varies is whether it is born read (`bornRead` in `./classes`).
25
+ * - `emailKind` and `actionItemKind` are ORTHOGONAL axes, orthogonal for
26
+ * DIFFERENT reasons. Email is off-platform delivery governed by notify/'s
27
+ * policy (its INV-1: an explicit trigger, never autonomous sending). An
28
+ * action item is a work-state projection — standing state you can resolve —
29
+ * and not a loudness tier. Neither is derived from the class.
30
+ * - No kind sets `emailKind` in this wave. A scheduled briefing that also
31
+ * emailed would need `INV-PROACTIVE-TRIGGER` reconciled with notify/'s INV-1
32
+ * in an ADR, not silently.
33
+ */
34
+
35
+ import type { ActionItemKind } from "../action-items/kinds";
36
+ import type { NotificationKind } from "../notifications/kinds";
37
+ import type { UserNotificationKind } from "../user-notifications/kinds";
38
+ import type { OrgSystemEventType, ProactiveChatTemplate } from "./surfaces";
39
+
40
+ // =============================================================================
41
+ // ProactiveEventDefinition
42
+ // =============================================================================
43
+
44
+ /** The fields every class carries, whatever its loudness. */
45
+ interface ProactivePlanCommon {
46
+ /** ALWAYS present — every proactive event writes a durable inbox row. */
47
+ readonly inboxKind: UserNotificationKind;
48
+ /** ORTHOGONAL axis: off-platform delivery. Absent = no email. */
49
+ readonly emailKind?: NotificationKind;
50
+ /** ORTHOGONAL axis: standing work state, NOT a loudness tier. */
51
+ readonly actionItemKind?: ActionItemKind;
52
+ }
53
+
54
+ /**
55
+ * A kind's plan, discriminated on `class`. Each member names exactly the ids
56
+ * its class's surfaces need and types the rest as `never`.
57
+ */
58
+ export type ProactiveEventDefinition =
59
+ | (ProactivePlanCommon & {
60
+ readonly class: "announcement";
61
+ /** REQUIRED — the class claims a banner, so it MUST name one. */
62
+ readonly bannerType: OrgSystemEventType;
63
+ /** REQUIRED — the class claims a chat, so it MUST name its prose. */
64
+ readonly chatTemplate: ProactiveChatTemplate;
65
+ })
66
+ | (ProactivePlanCommon & {
67
+ readonly class: "explained" | "briefing";
68
+ /** `never` — naming a banner a class does not have is a COMPILE error. */
69
+ readonly bannerType?: never;
70
+ /** REQUIRED — the chat is this class's badge carrier. */
71
+ readonly chatTemplate: ProactiveChatTemplate;
72
+ })
73
+ | (ProactivePlanCommon & {
74
+ readonly class: "notice";
75
+ /** `never` — a notice has no banner. */
76
+ readonly bannerType?: never;
77
+ /** `never` — a notice has no chat; the inbox row carries the badge. */
78
+ readonly chatTemplate?: never;
79
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The proactive kind registry — every kind and its plan (ADR-CONTRACTS-142).
3
+ *
4
+ * A kind carries `class` plus the IDS each surface needs. It NEVER restates the
5
+ * surface booleans — those are derived from `CLASS_SURFACES` in `./classes`.
6
+ * Duplicating them would leave the compiler having to prove two declarations
7
+ * agree, which is the guarantee this shape exists to provide.
8
+ *
9
+ * INVARIANTS:
10
+ * - TOTAL in both directions. `as const satisfies Record<...>` means a kind
11
+ * with no plan fails to compile, and a plan for a non-kind fails too.
12
+ * - `as const` keeps each plan's literal shape, so
13
+ * `PROACTIVE_EVENT_KINDS["org.became_shared"].class` is the type
14
+ * `"announcement"` and a consumer can index `CLASS_SURFACES` with it
15
+ * precisely.
16
+ * - Each plan's ids agree with its class's surfaces. The union in `./plan`
17
+ * enforces that per member; the vocabulary test pins the cross-check
18
+ * against `CLASS_SURFACES` the compiler cannot see.
19
+ */
20
+
21
+ import type { ProactiveEventKind } from "./kinds";
22
+ import type { ProactiveEventDefinition } from "./plan";
23
+
24
+ // =============================================================================
25
+ // PROACTIVE_EVENT_KINDS
26
+ // =============================================================================
27
+
28
+ /**
29
+ * Every proactive kind and its plan.
30
+ *
31
+ * `org.became_shared` names the EXISTING `first_member_joined` banner type. It
32
+ * fires at exactly the personal-to-shared flip, so minting a second
33
+ * `org_system_event_type` would put two banners on one transition. Its inbox
34
+ * row is `proactive.org_became_shared` — a DISTINCT string from the kind, so
35
+ * neither union can be read as derived from the other — and it is born read,
36
+ * because the banner holds the badge.
37
+ *
38
+ * To add a kind:
39
+ * 1. Add it to `PROACTIVE_EVENT_KIND_IDS` in `./kinds`
40
+ * 2. Add its inbox kind to `USER_NOTIFICATION_KINDS`
41
+ * 3. Add its plan here; the class decides which ids it must and must not name
42
+ *
43
+ * Step 3 is compiler-enforced; step 1 alone will not type-check.
44
+ */
45
+ export const PROACTIVE_EVENT_KINDS = {
46
+ "org.became_shared": {
47
+ class: "announcement",
48
+ bannerType: "first_member_joined",
49
+ chatTemplate: "orgBecameShared",
50
+ inboxKind: "proactive.org_became_shared",
51
+ },
52
+ /**
53
+ * The morning brief.
54
+ *
55
+ * class `briefing` => a chat (which carries the badge) + a BORN-READ inbox
56
+ * row, and NO banner. The plan union already types `bannerType` as `never`
57
+ * for this class, which is the compile-time proof the class table is doing
58
+ * its job: adding this kind needed no new machinery at all.
59
+ *
60
+ * ONE ORG OCCURRENCE PER ORG-LOCAL DAY, with N recipients. Personalisation
61
+ * lives in the per-recipient FACTS (`MorningBriefFacts`), never in
62
+ * occurrence identity — modelling it per user would mint one event row per
63
+ * person per day and give the system two shapes of occurrence to reason
64
+ * about.
65
+ */
66
+ "brief.morning": {
67
+ class: "briefing",
68
+ chatTemplate: "morningBrief",
69
+ inboxKind: "proactive.brief_morning",
70
+ },
71
+ } as const satisfies Record<ProactiveEventKind, ProactiveEventDefinition>;