@company-semantics/contracts 58.1.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 (39) hide show
  1. package/package.json +1 -1
  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 +21 -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 +58 -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/proactive/README.md +125 -0
  20. package/src/proactive/__tests__/README.md +56 -0
  21. package/src/proactive/__tests__/chat-templates.test.ts +167 -0
  22. package/src/proactive/__tests__/compile-fixtures.ts +110 -0
  23. package/src/proactive/__tests__/vocabulary.test.ts +279 -0
  24. package/src/proactive/classes.ts +125 -0
  25. package/src/proactive/composer.ts +104 -0
  26. package/src/proactive/facts.ts +87 -0
  27. package/src/proactive/index.ts +52 -0
  28. package/src/proactive/kinds.ts +127 -0
  29. package/src/proactive/plan.ts +79 -0
  30. package/src/proactive/registry.ts +71 -0
  31. package/src/proactive/surfaces.ts +59 -0
  32. package/src/proactive/templates/README.md +58 -0
  33. package/src/proactive/templates/index.ts +32 -0
  34. package/src/proactive/templates/morning-brief.ts +77 -0
  35. package/src/proactive/templates/org-became-shared.ts +54 -0
  36. package/src/resource-key-types.ts +9 -0
  37. package/src/resource-keys.ts +2 -0
  38. package/src/user-notifications/README.md +10 -0
  39. package/src/user-notifications/kinds.ts +28 -0
@@ -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>;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The IDS a surface needs — the vocabularies a proactive kind names INSTEAD of
3
+ * surface booleans (ADR-CONTRACTS-142).
4
+ *
5
+ * A kind never says `banner: true`; it says WHICH banner. This module holds
6
+ * the two id vocabularies the plan union in `./plan` draws on: the banner's
7
+ * `org_system_event_type` and the pushed-chat template. The durable inbox id
8
+ * is `UserNotificationKind` and lives in `../user-notifications`.
9
+ *
10
+ * INVARIANTS:
11
+ * - `ORG_SYSTEM_EVENT_TYPES` MIRRORS the backend `org_system_event_type`
12
+ * pgEnum exactly. Contracts cannot import the schema, so the mirror is the
13
+ * lock: a value here that the enum lacks is a banner nobody can write, and
14
+ * a value the enum has that is absent here is a banner no kind can name.
15
+ * Change both together.
16
+ * - A chat template earns membership by having a composer. The composer
17
+ * registry (a later module) is total over this array, so adding a template
18
+ * without prose is a compile error rather than an empty message.
19
+ */
20
+
21
+ // =============================================================================
22
+ // OrgSystemEventType
23
+ // =============================================================================
24
+
25
+ /**
26
+ * The banner types the org-level system-event table can record. Mirrors the
27
+ * backend `org_system_event_type` pgEnum value for value.
28
+ *
29
+ * `first_member_joined` is written AT MOST ONCE per org — a partial unique
30
+ * index makes the second write impossible — which is exactly why
31
+ * `org.became_shared` NAMES it rather than minting a third value. That kind
32
+ * fires at the personal-to-shared flip and nowhere else; a second value would
33
+ * put two banners on one transition.
34
+ */
35
+ export const ORG_SYSTEM_EVENT_TYPES = [
36
+ /** Ownership of the org moved to a new owner. */
37
+ "ownership_transferred",
38
+ /** The first non-owner member joined — the org stopped being personal. */
39
+ "first_member_joined",
40
+ ] as const;
41
+ export type OrgSystemEventType = (typeof ORG_SYSTEM_EVENT_TYPES)[number];
42
+
43
+ // =============================================================================
44
+ // ProactiveChatTemplate
45
+ // =============================================================================
46
+
47
+ /**
48
+ * Which prose composes a kind's pushed chat. Template ids are camelCase, unlike
49
+ * the dotted kind strings, because a template is a NAME FOR PROSE and not a
50
+ * `{domain}.{type}` fact — one kind names one template, but the template's
51
+ * facts are its own and are not the kind's payload.
52
+ */
53
+ export const PROACTIVE_CHAT_TEMPLATES = [
54
+ /** "Your org just became shared — here is what moved and why." */
55
+ "orgBecameShared",
56
+ /** "Good morning — here is where things stand for you today." */
57
+ "morningBrief",
58
+ ] as const;
59
+ export type ProactiveChatTemplate = (typeof PROACTIVE_CHAT_TEMPLATES)[number];
@@ -0,0 +1,58 @@
1
+ # proactive/templates/
2
+
3
+ ## Purpose
4
+
5
+ One module per pushed-chat template (ADR-CONTRACTS-142). Each exports a single
6
+ `ProactiveChatComposer<T>` whose `compose(facts)` says what that pushed chat
7
+ says — its title, its prose and its chips — as content, never markup.
8
+
9
+ Copy lives here, in contracts, and not in the backend domain that writes the
10
+ chat. That is the precedent notify/'s README states for outbound notifications
11
+ (no templates/ directory in the sending domain; copy belongs to contracts'
12
+ `compose`), applied to the third surface.
13
+
14
+ `index.ts` is the registry: `PROACTIVE_CHAT_COMPOSERS`, total over
15
+ `PROACTIVE_CHAT_TEMPLATES` in both directions.
16
+
17
+ ## Invariants
18
+
19
+ - `compose` is PURE and total: `(facts)` in, message out. No clock, no
20
+ environment, no I/O. The same facts produce a byte-identical message.
21
+ - `compose` emits CONTENT — never markup, styling, hrefs the app owns or a
22
+ channel name.
23
+ - **FROZEN AT WRITE TIME (INV-PROACTIVE-CONTENT).** The message is written once
24
+ into `chat_messages.content` and never re-renders, so a template may only
25
+ interpolate facts the recipient was entitled to at compose time. This is the
26
+ deliberate opposite of the durable-inbox rule, and both are correct: a
27
+ transcript that changed under the reader would be a lie about the
28
+ conversation.
29
+ - A template's `template` field equals its registry key. Enforced per entry by
30
+ the type and in practice by `../__tests__/chat-templates.test.ts`.
31
+ - At most `PROACTIVE_CHAT_MAX_REPLIES` chips. More is a menu, not a chip row.
32
+ - A file per template, named after it in kebab-case. Templates do not share a
33
+ module — the point is that a template is self-contained.
34
+ - `org-became-shared.ts` NAMES THE RELOCATION: the five sections that leave My
35
+ settings for Org settings. Unannounced, that reads as things going missing
36
+ (the app ADR with slug `nav-posture-opens-on-invite-send`).
37
+ - `morning-brief.ts` PERSONALISES THROUGH THE FACTS, never the occurrence. One
38
+ `brief.morning` occurrence per org per org-local day is shared by every
39
+ recipient; two people's briefs differ only because the `MorningBriefFacts`
40
+ they were handed differ. The composer does not know who it is composing for.
41
+ The org-local day arrives already rendered (`orgLocalDateLabel`) because
42
+ rendering it needs a locale and a time zone, and a pure composer reads
43
+ neither.
44
+
45
+ ## Public API
46
+
47
+ | Export | Description |
48
+ | -------------------------- | --------------------------------------------------------------- |
49
+ | `PROACTIVE_CHAT_COMPOSERS` | The total template → composer registry |
50
+ | `orgBecameSharedComposer` | Prose for the personal-to-shared flip; names the relocation |
51
+ | `morningBriefComposer` | Prose for one recipient's morning brief; personal via its facts |
52
+
53
+ ## Dependencies
54
+
55
+ Type-only imports of `ProactiveChatComposer` (`../composer`),
56
+ `ProactiveChatTemplate` (`../surfaces`) and the per-template facts
57
+ (`../facts`). No `zod`, no runtime imports beyond the sibling template
58
+ modules.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The pushed-chat composer registry — every template and its prose
3
+ * (ADR-CONTRACTS-142).
4
+ *
5
+ * INVARIANTS:
6
+ * - TOTAL in both directions over `PROACTIVE_CHAT_TEMPLATES`. `as const
7
+ * satisfies Record<...>` means a template with no composer fails to compile,
8
+ * and a composer for a non-template fails too. A kind can therefore name a
9
+ * `chatTemplate` in `../registry` and be sure prose exists for it.
10
+ * - Each composer's `template` equals the key it sits under. The type says so
11
+ * per entry; the chat-templates test pins it in practice.
12
+ * - `as const` keeps each entry's literal shape, so
13
+ * `PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose` takes exactly
14
+ * `OrgBecameSharedFacts` rather than a union of every template's facts.
15
+ *
16
+ * To add a template:
17
+ * 1. Add it to `PROACTIVE_CHAT_TEMPLATES` in `../surfaces`
18
+ * 2. Add its facts to `ProactiveChatFacts` in `../facts`
19
+ * 3. Add a `<template>Composer` module beside this one, and register it here
20
+ *
21
+ * Step 1 alone will not type-check; that is the point.
22
+ */
23
+
24
+ import type { ProactiveChatComposer } from "../composer";
25
+ import type { ProactiveChatTemplate } from "../surfaces";
26
+ import { morningBriefComposer } from "./morning-brief";
27
+ import { orgBecameSharedComposer } from "./org-became-shared";
28
+
29
+ export const PROACTIVE_CHAT_COMPOSERS = {
30
+ orgBecameShared: orgBecameSharedComposer,
31
+ morningBrief: morningBriefComposer,
32
+ } as const satisfies Record<ProactiveChatTemplate, ProactiveChatComposer>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The morning-brief pushed chat — where things stand for ONE recipient on one
3
+ * org-local morning (ADR-CONTRACTS-142).
4
+ *
5
+ * The prose is PERSONAL and the occurrence is not: one `brief.morning`
6
+ * occurrence per org per day is shared by every recipient, and what makes two
7
+ * people's briefs differ is the `MorningBriefFacts` each was handed. Nothing
8
+ * in this module knows or cares which recipient it is composing for — that is
9
+ * exactly what keeps it pure.
10
+ *
11
+ * PURE. `(facts)` in, message out — no clock, no locale, no environment. The
12
+ * org-local day arrives already rendered (`orgLocalDateLabel`) for that reason.
13
+ *
14
+ * FROZEN AT WRITE TIME (INV-PROACTIVE-CONTENT). This prose is written once
15
+ * into the transcript and NEVER re-renders, so it may only interpolate facts
16
+ * the recipient was entitled to at compose time. Every fact it uses is the
17
+ * recipient's own — their name, their org's name, their counts — so there is
18
+ * nothing here a later revocation would have to take back.
19
+ */
20
+
21
+ import type { ProactiveChatComposer } from "../composer";
22
+ import type { MorningBriefFacts } from "../facts";
23
+
24
+ /** "1 decision" / "3 decisions" — the one pluralisation the brief needs. */
25
+ function counted(n: number, singular: string, plural: string): string {
26
+ return `${n} ${n === 1 ? singular : plural}`;
27
+ }
28
+
29
+ /**
30
+ * The sentence about what is waiting on the recipient. Only the non-zero
31
+ * counts are mentioned; a brief that says "0 decisions" is reading a table
32
+ * aloud, not briefing anyone.
33
+ */
34
+ function standingLine(facts: MorningBriefFacts): string {
35
+ const parts: string[] = [];
36
+ if (facts.pendingActionItemCount > 0) {
37
+ parts.push(
38
+ `${counted(facts.pendingActionItemCount, "decision", "decisions")} waiting on you`,
39
+ );
40
+ }
41
+ if (facts.unreadNotificationCount > 0) {
42
+ parts.push(
43
+ counted(
44
+ facts.unreadNotificationCount,
45
+ "unread notification",
46
+ "unread notifications",
47
+ ),
48
+ );
49
+ }
50
+ if (parts.length === 0) {
51
+ return "Nothing is waiting on you, and your inbox is clear.";
52
+ }
53
+ return `You have ${parts.join(" and ")}.`;
54
+ }
55
+
56
+ export const morningBriefComposer: ProactiveChatComposer<"morningBrief"> = {
57
+ template: "morningBrief",
58
+ compose: (facts) => ({
59
+ title: `Your ${facts.orgLocalDateLabel} brief`,
60
+ text:
61
+ `Good morning, ${facts.recipientDisplayName}. Here is where ` +
62
+ `${facts.orgName} stands on ${facts.orgLocalDateLabel}.\n\n` +
63
+ standingLine(facts),
64
+ replies: [
65
+ {
66
+ id: "whats-changed",
67
+ label: "What changed since yesterday?",
68
+ prompt: "What changed in my workspace since yesterday?",
69
+ },
70
+ {
71
+ id: "needs-me",
72
+ label: "What needs me today?",
73
+ prompt: "What needs a decision from me today?",
74
+ },
75
+ ],
76
+ }),
77
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The org-became-shared pushed chat — the first invited person accepted, so
3
+ * the owner's personal workspace is now a shared one (ADR-CONTRACTS-142).
4
+ *
5
+ * The prose NAMES THE RELOCATION, and that is its job. The app ADR with slug
6
+ * `nav-posture-opens-on-invite-send` established that conversion is a
7
+ * relocation, not an addition: five sections leave My settings (Integrations,
8
+ * Invite team members, AI Usage, Budget, Billing) and reappear under Org
9
+ * settings. Unannounced, that reads as things going missing — and a banner
10
+ * can say "something moved" but not walk the owner through what and why.
11
+ * This chat is where that explanation lives, and the chips let the owner ask
12
+ * the question the banner cannot answer.
13
+ *
14
+ * FROZEN AT WRITE TIME (INV-PROACTIVE-CONTENT). Both interpolated facts are
15
+ * things the owner was entitled to at compose time: they named the org, and
16
+ * they invited the person who accepted. Nothing here can be revoked later in
17
+ * a way the transcript would have to take back.
18
+ */
19
+
20
+ import type { ProactiveChatComposer } from "../composer";
21
+
22
+ export const orgBecameSharedComposer: ProactiveChatComposer<"orgBecameShared"> =
23
+ {
24
+ template: "orgBecameShared",
25
+ compose: (facts) => ({
26
+ title: "Your workspace is now shared",
27
+ text:
28
+ `${facts.joinerDisplayName} accepted your invite, so ${facts.orgName} ` +
29
+ `moved from a personal workspace to a shared one.\n\n` +
30
+ `This is a relocation, not an addition: Integrations, Invite team ` +
31
+ `members, AI Usage, Budget and Billing have moved out of My settings ` +
32
+ `and into Org settings. Nothing was removed — the five sections now ` +
33
+ `belong to the workspace rather than to you.`,
34
+ replies: [
35
+ {
36
+ id: "plain-english",
37
+ label: "Explain in plain English",
38
+ prompt:
39
+ "Explain in plain English what changed when my workspace became shared.",
40
+ },
41
+ {
42
+ id: "what-first",
43
+ label: "What should I set up first?",
44
+ prompt:
45
+ "Now that my workspace is shared, what should I set up first?",
46
+ },
47
+ {
48
+ id: "who-can-see",
49
+ label: "Who can see what now?",
50
+ prompt: "Who can see what in my workspace now that it is shared?",
51
+ },
52
+ ],
53
+ }),
54
+ };
@@ -150,6 +150,15 @@ export type ResourceKey =
150
150
  // every badge in the shell, and the two have different invalidation triggers
151
151
  // (a notification being READ changes the feed and not the bucket).
152
152
  | { type: "feed"; orgId: string }
153
+ /**
154
+ * Org system events (the banner strip).
155
+ *
156
+ * Lives HERE and not in the app's `AppOnlyKey` union, whose doc bar is "the
157
+ * server can never need to name this key" — here the server is the publisher.
158
+ * Once the banner is an asynchronous projection, an unsubscribed hook means a
159
+ * banner that only appears on next mount or reload.
160
+ */
161
+ | { type: "orgSystemEvents"; orgId: string }
153
162
  // User-scoped
154
163
  | { type: "dismissedBanners"; userId: string }
155
164
  | { type: "userOrgs"; userId: string }
@@ -147,6 +147,7 @@ const ORG_SCOPED_TYPES = [
147
147
  "orgUnitOwners",
148
148
  "actionItems",
149
149
  "feed",
150
+ "orgSystemEvents",
150
151
  ] as const satisfies readonly OrgScopedType[];
151
152
 
152
153
  const USER_SCOPED_TYPES = [
@@ -285,6 +286,7 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
285
286
  case "orgUnitOwners":
286
287
  case "actionItems":
287
288
  case "feed":
289
+ case "orgSystemEvents":
288
290
  return [key.type, key.orgId] as const;
289
291
 
290
292
  // System-scoped (ADR-CONTRACTS-052) — tenant-less super-admin resources
@@ -33,6 +33,16 @@ that renders them alongside action items at the top of `/me/work`.
33
33
  - **The cursor encodes `(createdAt, id)`.** The id breaks ties so two rows
34
34
  written in one transaction cannot straddle a page boundary and be served twice
35
35
  or skipped.
36
+ - **A row can be BORN READ.** Every proactive kind writes a durable row here
37
+ as its paper trail, but only the class whose badge carrier is `inbox` writes
38
+ it unread; `proactive.org_became_shared` is an `announcement`, so the banner
39
+ holds the badge and the row arrives read, and `proactive.brief_morning` is a
40
+ `briefing`, so the pushed chat holds the badge and the row arrives read. That
41
+ rule is stated once, in `bornRead` (`../proactive/classes`), never re-derived
42
+ per call site (ADR-CONTRACTS-142). The kind strings are `proactive.*` — the
43
+ producing domain owns the renderer — and are deliberately not the
44
+ `ProactiveEventKind` strings `org.became_shared` / `brief.morning`, so
45
+ neither union can quietly become derived from the other.
36
46
 
37
47
  ## Public API
38
48
 
@@ -76,6 +76,34 @@ export const USER_NOTIFICATION_KINDS = [
76
76
  "comment.mention",
77
77
  /** A thread you took part in got a new comment. Never emitted for an edit. */
78
78
  "comment.reply",
79
+ /**
80
+ * Your org stopped being personal — its first member joined
81
+ * (ADR-CONTRACTS-142). The durable row of the `org.became_shared` proactive
82
+ * kind.
83
+ *
84
+ * BORN READ: the banner holds the badge for an `announcement`, so this row
85
+ * is the paper trail and never the attention carrier (`bornRead` in
86
+ * `../proactive/classes` is the one place that rule is stated). The
87
+ * producing domain in the string is `proactive` — `src/proactive/` in the
88
+ * backend owns the renderer, matching the `companyMd.*` / `comment.*`
89
+ * convention. The string is deliberately DISTINCT from the proactive kind's
90
+ * `org.became_shared`: identical names across two unions is how one quietly
91
+ * becomes derived from the other, which ADR-CONT-108 forbids.
92
+ */
93
+ "proactive.org_became_shared",
94
+ /**
95
+ * Your morning brief was written (ADR-CONTRACTS-142). The durable row of
96
+ * the `brief.morning` proactive kind.
97
+ *
98
+ * BORN READ: the pushed chat holds the badge for a `briefing`, so this row
99
+ * is the paper trail and never the attention carrier (`bornRead` in
100
+ * `../proactive/classes` is the one place that rule is stated). One row per
101
+ * recipient per org-local day, pointing at that recipient's OWN chat — the
102
+ * occurrence is shared, the row and the chat are not. The string is
103
+ * deliberately DISTINCT from the proactive kind's `brief.morning`, for the
104
+ * reason stated on the member above.
105
+ */
106
+ "proactive.brief_morning",
79
107
  ] as const;
80
108
 
81
109
  export type UserNotificationKind = (typeof USER_NOTIFICATION_KINDS)[number];