@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.
- package/package.json +1 -1
- package/src/__tests__/resource-keys.test.ts +30 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +21 -1
- package/src/chat/README.md +15 -4
- package/src/chat/__tests__/proactive-kind.test.ts +51 -0
- package/src/chat/index.ts +9 -0
- package/src/chat/proactive-kind.ts +51 -0
- package/src/chat/schemas.ts +92 -1
- package/src/chat/types.ts +19 -1
- package/src/index.ts +58 -0
- package/src/message-parts/README.md +5 -0
- package/src/message-parts/__tests__/suggested-replies.test.ts +52 -0
- package/src/message-parts/__tests__/wire.test.ts +48 -0
- package/src/message-parts/index.ts +8 -0
- package/src/message-parts/suggested-replies.ts +48 -0
- package/src/message-parts/types.ts +7 -1
- package/src/message-parts/wire.ts +26 -0
- package/src/proactive/README.md +125 -0
- package/src/proactive/__tests__/README.md +56 -0
- package/src/proactive/__tests__/chat-templates.test.ts +167 -0
- package/src/proactive/__tests__/compile-fixtures.ts +110 -0
- package/src/proactive/__tests__/vocabulary.test.ts +279 -0
- package/src/proactive/classes.ts +125 -0
- package/src/proactive/composer.ts +104 -0
- package/src/proactive/facts.ts +87 -0
- package/src/proactive/index.ts +52 -0
- package/src/proactive/kinds.ts +127 -0
- package/src/proactive/plan.ts +79 -0
- package/src/proactive/registry.ts +71 -0
- package/src/proactive/surfaces.ts +59 -0
- package/src/proactive/templates/README.md +58 -0
- package/src/proactive/templates/index.ts +32 -0
- package/src/proactive/templates/morning-brief.ts +77 -0
- package/src/proactive/templates/org-became-shared.ts +54 -0
- package/src/resource-key-types.ts +9 -0
- package/src/resource-keys.ts +2 -0
- package/src/user-notifications/README.md +10 -0
- package/src/user-notifications/kinds.ts +28 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proactive vocabulary's invariants, as tests rather than prose
|
|
3
|
+
* (ADR-CONTRACTS-142).
|
|
4
|
+
*
|
|
5
|
+
* THE load-bearing test is `plan surfaces agree with the class table`. A kind
|
|
6
|
+
* carries ids and its surfaces come from its class, and nothing in the type
|
|
7
|
+
* system can check that the ids PRESENT on a plan match the surfaces its class
|
|
8
|
+
* DECLARES in `CLASS_SURFACES`. The union in `../plan` fixes that per member;
|
|
9
|
+
* this file checks the members against the table itself.
|
|
10
|
+
*
|
|
11
|
+
* The negative typecheck fixtures — the `@ts-expect-error` directives proving
|
|
12
|
+
* the four compile-error claims — live in `./compile-fixtures.ts`, a COMPILED
|
|
13
|
+
* module: `src/tsconfig.json` excludes `*.test.ts`, so a directive written here
|
|
14
|
+
* would never be checked, and an unused one would fail nothing. They are
|
|
15
|
+
* imported below so the runtime check is shown to reject exactly the plans the
|
|
16
|
+
* compiler rejects.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
19
|
+
import {
|
|
20
|
+
CLASS_SURFACES,
|
|
21
|
+
PROACTIVE_PRESENTATION_CLASSES,
|
|
22
|
+
bornRead,
|
|
23
|
+
} from "../classes";
|
|
24
|
+
import type { ClassSurfacePlan, ProactivePresentationClass } from "../classes";
|
|
25
|
+
import type { ProactiveChatMessage } from "../composer";
|
|
26
|
+
import type { ProactiveChatFacts } from "../facts";
|
|
27
|
+
import { PROACTIVE_EVENT_KIND_IDS } from "../kinds";
|
|
28
|
+
import type { ProactiveEventDefinition } from "../plan";
|
|
29
|
+
import { PROACTIVE_EVENT_KINDS } from "../registry";
|
|
30
|
+
import { PROACTIVE_CHAT_TEMPLATES } from "../surfaces";
|
|
31
|
+
import type { ProactiveChatTemplate } from "../surfaces";
|
|
32
|
+
import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
|
|
33
|
+
import { USER_NOTIFICATION_KINDS } from "../../user-notifications/kinds";
|
|
34
|
+
import {
|
|
35
|
+
PLAN_FIXTURES_THE_COMPILER_REJECTS,
|
|
36
|
+
REGISTRY_FIXTURES_THE_COMPILER_REJECTS,
|
|
37
|
+
} from "./compile-fixtures";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The registry, read as the union it satisfies rather than as its literal
|
|
41
|
+
* shape. Every member of `ProactiveEventDefinition` carries every id field
|
|
42
|
+
* (the absent ones as optional `never`), so a plan can be asked for an id it
|
|
43
|
+
* does not have and answer `undefined` — which is what the checks below need.
|
|
44
|
+
*/
|
|
45
|
+
const REGISTRY_ENTRIES: ReadonlyArray<
|
|
46
|
+
readonly [kind: string, plan: ProactiveEventDefinition]
|
|
47
|
+
> = Object.entries(PROACTIVE_EVENT_KINDS);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The ONE check the compiler cannot do, as a predicate: the ids present on a
|
|
51
|
+
* plan agree with the surfaces its class earns, and the inbox id is always
|
|
52
|
+
* there. Applied positively to the real registry and negatively to the plans
|
|
53
|
+
* the compiler rejects, so the two halves are shown to agree.
|
|
54
|
+
*/
|
|
55
|
+
function planAgreesWithClassTable(plan: ProactiveEventDefinition): boolean {
|
|
56
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[plan.class];
|
|
57
|
+
return (
|
|
58
|
+
(plan.bannerType !== undefined) === surfaces.banner &&
|
|
59
|
+
(plan.chatTemplate !== undefined) === surfaces.chat &&
|
|
60
|
+
plan.inboxKind !== undefined &&
|
|
61
|
+
surfaces.inbox
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One facts factory per template, total over `ProactiveChatTemplate`. A
|
|
67
|
+
* FACTORY rather than a value so the purity test can hand a composer two
|
|
68
|
+
* structurally-equal but distinct objects — a composer that keyed anything on
|
|
69
|
+
* object identity would show up as a difference.
|
|
70
|
+
*/
|
|
71
|
+
const FRESH_FACTS: {
|
|
72
|
+
readonly [T in ProactiveChatTemplate]: () => ProactiveChatFacts[T];
|
|
73
|
+
} = {
|
|
74
|
+
orgBecameShared: () => ({ orgName: "Acme", joinerDisplayName: "Ada" }),
|
|
75
|
+
morningBrief: () => ({
|
|
76
|
+
recipientDisplayName: "Ada",
|
|
77
|
+
orgName: "Acme",
|
|
78
|
+
orgLocalDateLabel: "Tuesday 25 August",
|
|
79
|
+
pendingActionItemCount: 2,
|
|
80
|
+
unreadNotificationCount: 5,
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compose a template with fresh facts. The registry is a union of composers
|
|
86
|
+
* whose `compose` parameters differ per template, and TypeScript cannot
|
|
87
|
+
* correlate `PROACTIVE_CHAT_COMPOSERS[t]` with `FRESH_FACTS[t]` through a
|
|
88
|
+
* variable `t` — the cast is the one place the correlation is asserted, and
|
|
89
|
+
* the total factory record above is what keeps it honest.
|
|
90
|
+
*/
|
|
91
|
+
function composeFresh(template: ProactiveChatTemplate): ProactiveChatMessage {
|
|
92
|
+
const compose = PROACTIVE_CHAT_COMPOSERS[template].compose as (
|
|
93
|
+
facts: ProactiveChatFacts[ProactiveChatTemplate],
|
|
94
|
+
) => ProactiveChatMessage;
|
|
95
|
+
return compose(FRESH_FACTS[template]());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("the class table", () => {
|
|
99
|
+
it("is total over PROACTIVE_PRESENTATION_CLASSES in practice", () => {
|
|
100
|
+
// `satisfies Record<...>` proves totality at compile time; this proves no
|
|
101
|
+
// entry is an accidental `undefined` and that no key is a non-class.
|
|
102
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
103
|
+
expect(CLASS_SURFACES[cls], cls).toBeDefined();
|
|
104
|
+
}
|
|
105
|
+
expect(Object.keys(CLASS_SURFACES).sort()).toEqual(
|
|
106
|
+
[...PROACTIVE_PRESENTATION_CLASSES].sort(),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("every badge names a surface its class actually has", () => {
|
|
111
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
112
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[cls];
|
|
113
|
+
// Every class writes a durable row — `inbox` is literal true throughout.
|
|
114
|
+
expect(surfaces.inbox, `${cls} inbox`).toBe(true);
|
|
115
|
+
// The badge carrier must be a surface the class earns; the union makes
|
|
116
|
+
// the wrong pairing a compile error, and this is the same claim in
|
|
117
|
+
// practice.
|
|
118
|
+
expect(surfaces[surfaces.badge], `${cls} badge=${surfaces.badge}`).toBe(
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("states the born-read rule once, and it matches the doctrine table", () => {
|
|
125
|
+
// The table in `../classes`: the badge carrier is the attention carrier,
|
|
126
|
+
// every other surface the class HAS is the paper trail. Pinned as the
|
|
127
|
+
// concrete rows the doctrine names rather than as a restatement of the
|
|
128
|
+
// helper's body, so a rewrite of `bornRead` that changed a row fails here.
|
|
129
|
+
// A surface the class does not earn has no row to be born into — `chat`
|
|
130
|
+
// is absent for `notice` — so it is not asked.
|
|
131
|
+
const expected: Record<
|
|
132
|
+
ProactivePresentationClass,
|
|
133
|
+
{ readonly chat?: boolean; readonly inbox: boolean }
|
|
134
|
+
> = {
|
|
135
|
+
announcement: { chat: true, inbox: true },
|
|
136
|
+
explained: { chat: false, inbox: true },
|
|
137
|
+
briefing: { chat: false, inbox: true },
|
|
138
|
+
notice: { inbox: false },
|
|
139
|
+
};
|
|
140
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
141
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[cls];
|
|
142
|
+
const row = expected[cls];
|
|
143
|
+
expect(surfaces.chat, `${cls} has a chat`).toBe(row.chat !== undefined);
|
|
144
|
+
if (row.chat !== undefined) {
|
|
145
|
+
expect(bornRead(surfaces, "chat"), `${cls} chat`).toBe(row.chat);
|
|
146
|
+
}
|
|
147
|
+
expect(bornRead(surfaces, "inbox"), `${cls} inbox`).toBe(row.inbox);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("the kind registry", () => {
|
|
153
|
+
it("is total over PROACTIVE_EVENT_KIND_IDS in practice", () => {
|
|
154
|
+
for (const kind of PROACTIVE_EVENT_KIND_IDS) {
|
|
155
|
+
expect(PROACTIVE_EVENT_KINDS[kind], kind).toBeDefined();
|
|
156
|
+
}
|
|
157
|
+
expect(Object.keys(PROACTIVE_EVENT_KINDS).sort()).toEqual(
|
|
158
|
+
[...PROACTIVE_EVENT_KIND_IDS].sort(),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* THE load-bearing test. A kind carries ids; its surfaces come from its
|
|
164
|
+
* class. Nothing in the type system can check that the ids present match the
|
|
165
|
+
* surfaces the class declares, so it is checked here.
|
|
166
|
+
*/
|
|
167
|
+
it("plan surfaces agree with the class table", () => {
|
|
168
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
169
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[plan.class];
|
|
170
|
+
expect(plan.bannerType !== undefined, `${kind} bannerType`).toBe(
|
|
171
|
+
surfaces.banner,
|
|
172
|
+
);
|
|
173
|
+
expect(plan.chatTemplate !== undefined, `${kind} chatTemplate`).toBe(
|
|
174
|
+
surfaces.chat,
|
|
175
|
+
);
|
|
176
|
+
expect(plan.inboxKind, `${kind} inboxKind`).toBeDefined();
|
|
177
|
+
expect(planAgreesWithClassTable(plan), kind).toBe(true);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("every inbox kind is a real user notification kind", () => {
|
|
182
|
+
// Every class writes a durable row, so every plan names a row kind — and
|
|
183
|
+
// it must be one the inbox vocabulary actually has, or the backend writes
|
|
184
|
+
// a row no renderer owns.
|
|
185
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
186
|
+
expect(USER_NOTIFICATION_KINDS, kind).toContain(plan.inboxKind);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("names its inbox row in the proactive domain, never by the kind string", () => {
|
|
191
|
+
// Identical names across two unions is how one quietly becomes derived
|
|
192
|
+
// from the other (ADR-CONT-108). The producing domain is `proactive` —
|
|
193
|
+
// `src/proactive/` in the backend owns the renderer — matching the
|
|
194
|
+
// `companyMd.*` / `comment.*` convention.
|
|
195
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
196
|
+
expect(plan.inboxKind, kind).not.toBe(kind);
|
|
197
|
+
expect(plan.inboxKind, kind).toMatch(/^proactive\./);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("names kinds in {domain}.{type} form, matching the three unions", () => {
|
|
202
|
+
for (const kind of PROACTIVE_EVENT_KIND_IDS) {
|
|
203
|
+
expect(kind).toMatch(/^[a-z][a-zA-Z0-9]*\.[a-z][a-z0-9_]*$/);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("every chat template has a composer", () => {
|
|
208
|
+
// The composer registry is total over PROACTIVE_CHAT_TEMPLATES at compile
|
|
209
|
+
// time; this proves the id a KIND names reaches a real composer whose
|
|
210
|
+
// `template` is that id, so the backend cannot write an empty message.
|
|
211
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
212
|
+
if (plan.chatTemplate === undefined) continue;
|
|
213
|
+
const composer = PROACTIVE_CHAT_COMPOSERS[plan.chatTemplate];
|
|
214
|
+
expect(composer, kind).toBeDefined();
|
|
215
|
+
expect(composer.template, kind).toBe(plan.chatTemplate);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("sets no emailKind in this wave", () => {
|
|
220
|
+
// notify/'s INV-1: every notification needs an explicit trigger; nothing
|
|
221
|
+
// sends autonomously. A kind that also emailed needs INV-PROACTIVE-TRIGGER
|
|
222
|
+
// reconciled with INV-1 in an ADR — lifting this assertion IS that ADR's
|
|
223
|
+
// job, not a tidy-up.
|
|
224
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
225
|
+
expect(plan.emailKind, kind).toBeUndefined();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("the chat composers", () => {
|
|
231
|
+
it("composers are pure", () => {
|
|
232
|
+
// Same facts in, byte-identical message out — across two calls handed two
|
|
233
|
+
// distinct-but-equal facts objects. A clock, an env read, a counter or an
|
|
234
|
+
// identity-keyed cache would all surface as a difference here.
|
|
235
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
236
|
+
const first = composeFresh(template);
|
|
237
|
+
const second = composeFresh(template);
|
|
238
|
+
expect(JSON.stringify(first), template).toBe(JSON.stringify(second));
|
|
239
|
+
expect(first, template).toEqual(second);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("what the compiler rejects, the class-table check rejects too", () => {
|
|
245
|
+
it("rejects inconsistent plans at compile time", () => {
|
|
246
|
+
// The `@ts-expect-error` directives sit in ./compile-fixtures.ts, where
|
|
247
|
+
// `tsc -b` checks them. Here the same plans go through the runtime check:
|
|
248
|
+
// a notice naming a bannerType, an announcement omitting one, and an
|
|
249
|
+
// explained or a briefing naming one must all disagree with the class
|
|
250
|
+
// table.
|
|
251
|
+
const names = Object.keys(PLAN_FIXTURES_THE_COMPILER_REJECTS);
|
|
252
|
+
expect(names).toEqual([
|
|
253
|
+
"noticeNamingBanner",
|
|
254
|
+
"announcementOmittingBanner",
|
|
255
|
+
"explainedNamingBanner",
|
|
256
|
+
"briefingNamingBanner",
|
|
257
|
+
]);
|
|
258
|
+
for (const [name, plan] of Object.entries(
|
|
259
|
+
PLAN_FIXTURES_THE_COMPILER_REJECTS,
|
|
260
|
+
)) {
|
|
261
|
+
expect(planAgreesWithClassTable(plan), name).toBe(false);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("rejects a registry that is not total at compile time", () => {
|
|
266
|
+
// The fourth claim, both directions: a kind absent from the registry, and
|
|
267
|
+
// a plan for a string that is not a kind. The real registry passes the
|
|
268
|
+
// same two checks in `is total over PROACTIVE_EVENT_KIND_IDS in practice`.
|
|
269
|
+
const { registryMissingKind, registryNamingNonKind } =
|
|
270
|
+
REGISTRY_FIXTURES_THE_COMPILER_REJECTS;
|
|
271
|
+
const ids = new Set<string>(PROACTIVE_EVENT_KIND_IDS);
|
|
272
|
+
expect(
|
|
273
|
+
PROACTIVE_EVENT_KIND_IDS.some((kind) => !(kind in registryMissingKind)),
|
|
274
|
+
).toBe(true);
|
|
275
|
+
expect(
|
|
276
|
+
Object.keys(registryNamingNonKind).some((key) => !ids.has(key)),
|
|
277
|
+
).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -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";
|