@company-semantics/contracts 42.0.0 → 43.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "42.0.0",
3
+ "version": "43.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -97,3 +97,47 @@ describe("resource-keys: system-scoped internalAdmin* types", () => {
97
97
  ).toBe(false);
98
98
  });
99
99
  });
100
+
101
+ describe("resource-keys: companyMdAccessRequests (per-doc identity)", () => {
102
+ const DOC_ID = "22222222-2222-4222-8222-222222222222";
103
+ const key: ResourceKey = {
104
+ type: "companyMdAccessRequests",
105
+ orgId: ORG_ID,
106
+ docId: DOC_ID,
107
+ };
108
+
109
+ it("serialises to [type, orgId, docId]", () => {
110
+ expect(toQueryKey(key)).toEqual([
111
+ "companyMdAccessRequests",
112
+ ORG_ID,
113
+ DOC_ID,
114
+ ]);
115
+ });
116
+
117
+ it("round-trips through fromQueryKey", () => {
118
+ expect(fromQueryKey(toQueryKey(key))).toEqual(key);
119
+ });
120
+
121
+ it("discriminates two keys differing only in docId", () => {
122
+ const other: ResourceKey = {
123
+ type: "companyMdAccessRequests",
124
+ orgId: ORG_ID,
125
+ docId: "33333333-3333-4333-8333-333333333333",
126
+ };
127
+ expect(matchesResourceKey(toQueryKey(key), key)).toBe(true);
128
+ expect(matchesResourceKey(toQueryKey(other), key)).toBe(false);
129
+ });
130
+
131
+ it("does NOT collide with companyMdDoc, whose id field is named `slug`", () => {
132
+ // companyMdDoc carries the stable doc id under a field named `slug`
133
+ // (ADR-BE-315 made slugs only parent-scoped unique). The two keys must stay
134
+ // distinguishable even when they carry the same document id.
135
+ const docKey: ResourceKey = {
136
+ type: "companyMdDoc",
137
+ orgId: ORG_ID,
138
+ slug: DOC_ID,
139
+ };
140
+ expect(matchesResourceKey(toQueryKey(docKey), key)).toBe(false);
141
+ expect(matchesResourceKey(toQueryKey(key), docKey)).toBe(false);
142
+ });
143
+ });
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = 'c0f32d11bf0f' as const;
3
- export const SPEC_HASH_FULL = 'c0f32d11bf0fdc59ee0a7bf84bee984f2f8109838d2659b6e66709e0198dcd4e' as const;
2
+ export const SPEC_HASH = '017f7c7f6a72' as const;
3
+ export const SPEC_HASH_FULL = '017f7c7f6a72c27413c0329be3eb9ff39547d425f575d570970d6e6920324638' as const;
@@ -3529,10 +3529,10 @@ export interface components {
3529
3529
  share: {
3530
3530
  id: string;
3531
3531
  chatId: string;
3532
- token: string;
3532
+ token?: string;
3533
3533
  /** @enum {string} */
3534
3534
  visibility: "private" | "public";
3535
- shareUrl: string;
3535
+ shareUrl?: string;
3536
3536
  messageCountAtShare: number;
3537
3537
  titleAtShare: string;
3538
3538
  /** Format: date-time */
@@ -3552,10 +3552,10 @@ export interface components {
3552
3552
  shares: {
3553
3553
  id: string;
3554
3554
  chatId: string;
3555
- token: string;
3555
+ token?: string;
3556
3556
  /** @enum {string} */
3557
3557
  visibility: "private" | "public";
3558
- shareUrl: string;
3558
+ shareUrl?: string;
3559
3559
  messageCountAtShare: number;
3560
3560
  titleAtShare: string;
3561
3561
  /** Format: date-time */
@@ -3568,10 +3568,10 @@ export interface components {
3568
3568
  share: {
3569
3569
  id: string;
3570
3570
  chatId: string;
3571
- token: string;
3571
+ token?: string;
3572
3572
  /** @enum {string} */
3573
3573
  visibility: "private" | "public";
3574
- shareUrl: string;
3574
+ shareUrl?: string;
3575
3575
  messageCountAtShare: number;
3576
3576
  titleAtShare: string;
3577
3577
  /** Format: date-time */
package/src/index.ts CHANGED
@@ -266,6 +266,36 @@ export type {
266
266
  ActionItemTargetType,
267
267
  } from "./action-items/index";
268
268
 
269
+ // User-scoped push envelope — the frames any domain may put on ONE
270
+ // authenticated user's connection without owning a route or an SSE union of its
271
+ // own. A THIRD vocabulary alongside the two above, and deliberately unlike
272
+ // both: a notification LEAVES the building, an action item is STANDING state,
273
+ // and a user event is a single unreplayable instant meaning "your cache is
274
+ // stale". This stream is a wake-up channel, NOT an ordered event log — no frame
275
+ // carries an SSE `id:` and nothing durable may be hung off it.
276
+ // See src/user-events/README.md.
277
+ export {
278
+ MAX_INVALIDATION_KEYS,
279
+ USER_EVENT_RESYNC_REASONS,
280
+ } from "./user-events/index";
281
+
282
+ export {
283
+ ResourceInvalidatedEventSchema,
284
+ UserEventBaseSchema,
285
+ UserEventConnectedSchema,
286
+ UserEventResyncReasonSchema,
287
+ UserEventResyncSchema,
288
+ UserSseEventSchema,
289
+ } from "./user-events/index";
290
+
291
+ export type {
292
+ ResourceInvalidatedEvent,
293
+ UserEventConnected,
294
+ UserEventResync,
295
+ UserEventResyncReason,
296
+ UserSseEvent,
297
+ } from "./user-events/index";
298
+
269
299
  // Chat domain types
270
300
  // @see PRD-00142 for share chat design rationale
271
301
  export type {
@@ -859,6 +889,7 @@ export {
859
889
  toQueryKey,
860
890
  fromQueryKey,
861
891
  matchesResourceKey,
892
+ isResourceKeyShape,
862
893
  } from "./resource-keys";
863
894
 
864
895
  // Resource response wrapper (typed versioning for cache invalidation)
@@ -20,6 +20,15 @@ export type ResourceKey =
20
20
  | { type: "chat"; orgId: string; chatId: string }
21
21
  | { type: "companyMdDoc"; orgId: string; slug: string }
22
22
  | { type: "companyMdContextBank"; orgId: string; slug: string }
23
+ // The access requests filed against ONE document — the owner's inbox, read by
24
+ // the Share dialog.
25
+ //
26
+ // Discriminated by `docId`, NOT `slug`, and the difference is load-bearing.
27
+ // `companyMdDoc` above carries the stable doc id under a field named `slug`
28
+ // for historical reasons; ADR-BE-315 made slugs only PARENT-SCOPED unique, so
29
+ // a bare slug does not identify a document. That field name is a wart to be
30
+ // contained, not propagated — a new key gets the honest name.
31
+ | { type: "companyMdAccessRequests"; orgId: string; docId: string }
23
32
  | { type: "workspace"; orgId: string }
24
33
  | { type: "workspaceDomains"; orgId: string }
25
34
  | { type: "authSettings"; orgId: string }
@@ -151,6 +160,8 @@ export function toQueryKey(key: ResourceKey): readonly string[] {
151
160
  case "companyMdDoc":
152
161
  case "companyMdContextBank":
153
162
  return [key.type, key.orgId, key.slug] as const;
163
+ case "companyMdAccessRequests":
164
+ return [key.type, key.orgId, key.docId] as const;
154
165
 
155
166
  // OrgUnit identity keys (ADR-BE-120)
156
167
  case "orgUnit":
@@ -228,6 +239,7 @@ export function fromQueryKey(queryKey: readonly string[]): ResourceKey {
228
239
  chat: "chatId",
229
240
  companyMdDoc: "slug",
230
241
  companyMdContextBank: "slug",
242
+ companyMdAccessRequests: "docId",
231
243
  orgUnit: "unitId",
232
244
  orgUnitChildren: "unitId",
233
245
  orgUnitAncestors: "unitId",
@@ -327,6 +339,12 @@ export function matchesResourceKey(
327
339
  return false;
328
340
  if ("slug" in parsed && "slug" in targetKey && parsed.slug !== targetKey.slug)
329
341
  return false;
342
+ if (
343
+ "docId" in parsed &&
344
+ "docId" in targetKey &&
345
+ parsed.docId !== targetKey.docId
346
+ )
347
+ return false;
330
348
  if (
331
349
  "unitId" in parsed &&
332
350
  "unitId" in targetKey &&
@@ -343,6 +361,37 @@ export function matchesResourceKey(
343
361
  return true;
344
362
  }
345
363
 
364
+ /**
365
+ * Is this the SHAPE of a ResourceKey?
366
+ *
367
+ * A wire-boundary predicate, deliberately NOT a vocabulary check: it asserts a
368
+ * non-empty `type` and exactly one scope discriminator, and says nothing about
369
+ * whether `type` is a member of the union.
370
+ *
371
+ * The asymmetry is the point. `ResourceKey` is a large union whose authority is
372
+ * `toQueryKey`'s exhaustive switch; a Zod mirror of it would be a second source
373
+ * of truth that drifts. Worse, a vocabulary-strict gate would REJECT AN ENTIRE
374
+ * FRAME when a newer backend names a key this client's contracts version has
375
+ * not learned yet — today an unknown key simply matches no query and the rest
376
+ * of the frame still lands, which is the behaviour worth keeping.
377
+ * `matchesResourceKey` is already lenient about unknown types at runtime, so a
378
+ * stricter gate here would be the only place in the pipeline that hard-fails on
379
+ * a key it merely does not recognise.
380
+ *
381
+ * Used by the user-event wire schema (`../user-events`) to validate
382
+ * `resource.invalidated` payloads without importing the union's vocabulary.
383
+ */
384
+ export function isResourceKeyShape(value: unknown): value is ResourceKey {
385
+ if (typeof value !== "object" || value === null) return false;
386
+ const candidate = value as Record<string, unknown>;
387
+ if (typeof candidate.type !== "string" || candidate.type.length === 0)
388
+ return false;
389
+ const scopeCount = ["orgId", "userId", "scope"].filter(
390
+ (field) => typeof candidate[field] === "string",
391
+ ).length;
392
+ return scopeCount === 1;
393
+ }
394
+
346
395
  function isReadonlyArray(value: unknown): value is readonly unknown[] {
347
396
  return Array.isArray(value);
348
397
  }
@@ -61,28 +61,14 @@ export const AUTH_TOKEN_POLICY_REQUIREMENTS = {
61
61
  hashAlgorithm: "SHA-256",
62
62
  storageFormat: "hashed",
63
63
  contextPatterns: ["session"],
64
- conformance: {
65
- status: "unmet",
66
- gap:
67
- "Issuance now conforms: minted by issueCapabilityToken at 256 bits and " +
68
- "stored as a sha256:v1: digest (ADR-BE-460 deploy B). Still UNMET because " +
69
- "the dual-read window is open — sessions.token retains the raw value so a " +
70
- "rollback keeps working, and every lookup carries a plaintext fallback arm. " +
71
- "Conformance is reached when migration C retires the column and deploy D " +
72
- "removes the fallbacks.",
73
- },
64
+ conformance: { status: "enforced" },
74
65
  },
75
66
  ChatShareToken: {
76
67
  minEntropyBits: 256,
77
68
  hashAlgorithm: "SHA-256",
78
69
  storageFormat: "hashed",
79
70
  contextPatterns: ["share"],
80
- conformance: {
81
- status: "unmet",
82
- gap:
83
- "Minted with randomBytes(32) and persisted verbatim: chat_shares.token holds " +
84
- "the share-URL value itself, and lookup is raw equality against it.",
85
- },
71
+ conformance: { status: "enforced" },
86
72
  },
87
73
  InviteToken: {
88
74
  minEntropyBits: 256,
@@ -0,0 +1,66 @@
1
+ # user-events/
2
+
3
+ ## Purpose
4
+
5
+ The published wire contract for the **user-scoped push stream** — the frames any
6
+ domain may send to one authenticated user without owning a route or an SSE union
7
+ of its own.
8
+
9
+ ## Invariants
10
+
11
+ - **This stream is a wake-up channel plus ephemeral cache hints. It is NOT an
12
+ ordered event log.** Publishing a user event grants no replay guarantee, and
13
+ no durable effect may be hung off it.
14
+ - **No frame carries an SSE `id:` line.** An id implies a resumable position;
15
+ there is no log behind this stream, so an id would be a lie. Contrast
16
+ `ExecutionSseEvent`, whose `eventSequence` legitimately doubles as
17
+ `Last-Event-ID` because it projects a durable row (ADR-CONT-066).
18
+ - **Delivery is edge-triggered only.** A missed frame may leave caches stale
19
+ until the next authoritative refresh. Acceptable because no correctness
20
+ depends on invalidations — every frame means "re-read this", and the read is
21
+ the authority.
22
+ - **`resource.invalidated.keys` validates shape, not vocabulary.** A
23
+ vocabulary-strict gate would reject an entire frame when a newer server names
24
+ a key this client has not learned. Today an unknown key matches no query and
25
+ the rest of the frame still lands.
26
+ - **`version` is an entity timestamp, never request time.** A request-time value
27
+ silently re-opens the race the client's version gate exists to close.
28
+ - **`v`/`timestamp` are optional on `resource.invalidated`, `resync` and
29
+ `connected`, and required nowhere else.** The invalidation frame was already
30
+ on the wire without them; requiring them would make promotion a breaking
31
+ change dressed as a tidy-up.
32
+
33
+ ## Public API
34
+
35
+ | Export | Description |
36
+ | ------------------------------------- | ------------------------------------------------- |
37
+ | `UserSseEventSchema` / `UserSseEvent` | The union. OpenAPI component `UserSseEvent`. |
38
+ | `ResourceInvalidatedEventSchema` | Cache-staleness signal carrying `ResourceKey[]` |
39
+ | `UserEventResyncSchema` | "Could not name what changed; re-read everything" |
40
+ | `UserEventConnectedSchema` | Transport-level open frame |
41
+ | `MAX_INVALIDATION_KEYS` | Per-frame key cap (`16`) |
42
+ | `USER_EVENT_RESYNC_REASONS` | `listen-recovered` \| `payload-over-cap` |
43
+
44
+ ## Dependencies
45
+
46
+ - `zod` — schemas are canonical, types are inferred.
47
+ - `../resource-keys` — `ResourceKey`, `isResourceKeyShape`.
48
+
49
+ Nothing else. In particular: no `notifications/`, no `action-items/`.
50
+
51
+ ## How this differs from the two adjacent vocabularies
52
+
53
+ Three things in this package can look like "a notification". They are distinct,
54
+ and ADR-CONT-104 forbids collapsing them:
55
+
56
+ - **`NotificationKind`** (`../notifications`) names a message that **leaves the
57
+ building** to a durable address, composed by a render pipeline into prose
58
+ carrying a brand and a year.
59
+ - **`ActionItemKind`** (`../action-items`) names **standing state** — a decision
60
+ you owe — re-derived from source rows on every read, which stops existing when
61
+ someone resolves it.
62
+ - **A user event** is neither. It is a single instant on a socket, unreplayable
63
+ and unaddressable, whose entire meaning is "your cached view is stale".
64
+
65
+ A domain that needs durability emits the durable thing **and** a user event —
66
+ never a user event alone.
@@ -0,0 +1,43 @@
1
+ # user-events/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Locks the claims `../README.md` and ADR-CONT-107 make that the compiler cannot.
6
+
7
+ - `user-events.test.ts` — four things a type signature does not say:
8
+ 1. **Promotion did not change the wire.** The load-bearing test of the whole
9
+ module. `resource.invalidated` was already deployed, emitted by the backend
10
+ and re-declared structurally in the app, carrying neither `v` nor
11
+ `timestamp`. The schema is pinned against that exact object. If it fails,
12
+ publishing the shape broke every client already running.
13
+ 2. **An unknown key type is ACCEPTED.** The reason `keys` validates shape and
14
+ not vocabulary. A newer server naming a key this contracts version has not
15
+ learned must cost that one key, never the whole frame. A strict gate here
16
+ would be the only place in the pipeline that hard-fails on version skew.
17
+ 3. **An unmodeled frame `type` is REJECTED.** So the client logs it instead of
18
+ silently dropping it. The two rules pull in opposite directions on purpose:
19
+ lenient about payload vocabulary, strict about frame identity.
20
+ 4. **`resource.invalidated` rejects an `ActionItem`-shaped payload.** What
21
+ stops the future "just reuse ActionItem on the stream" change. An action
22
+ item is re-derived on every read and resolves when someone acts; it cannot
23
+ be delivered once and forgotten.
24
+
25
+ ## Invariants
26
+
27
+ - These tests assert the WIRE, never behaviour — there is no behaviour here.
28
+ Anything needing a socket, a dispatcher or a database belongs in backend's
29
+ `src/user-events/__tests__/`, not here.
30
+ - The disjointness test reads `ACTION_ITEM_KINDS` rather than restating the
31
+ kinds. A hand-copied list would drift and start passing vacuously.
32
+ - Negative cases assert `.success === false` on a frame that is otherwise
33
+ well-formed, mutating one field. A negative test that hand-builds a broken
34
+ object can pass for the wrong reason.
35
+
36
+ ## Public API
37
+
38
+ None — test-only.
39
+
40
+ ## Dependencies
41
+
42
+ `vitest`, the sibling module under test, `../../action-items` (for the real kind
43
+ list), and `../../resource-keys` (for `isResourceKeyShape`).
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The user-event wire contract's invariants, as tests rather than prose.
3
+ *
4
+ * These lock the claims the README makes that a compiler cannot: that promoting
5
+ * `resource.invalidated` did not change the wire, that key validation is
6
+ * forward-compatible across version skew, and that this vocabulary is separate
7
+ * from the two adjacent ones (ADR-CONT-104).
8
+ */
9
+ import { describe, it, expect } from "vitest";
10
+ import {
11
+ MAX_INVALIDATION_KEYS,
12
+ ResourceInvalidatedEventSchema,
13
+ USER_EVENT_RESYNC_REASONS,
14
+ UserEventConnectedSchema,
15
+ UserEventResyncSchema,
16
+ UserSseEventSchema,
17
+ } from "../schemas.js";
18
+ import { ACTION_ITEM_KINDS } from "../../action-items/index.js";
19
+ import { isResourceKeyShape } from "../../resource-keys.js";
20
+
21
+ const ORG_ID = "11111111-1111-4111-8111-111111111111";
22
+ const DOC_ID = "22222222-2222-4222-8222-222222222222";
23
+
24
+ describe("resource.invalidated — promotion must not change the wire", () => {
25
+ it("parses the EXACT object the backend emits today", () => {
26
+ // Verbatim shape of `ResourceInvalidationEvent` in backend
27
+ // src/chat/execution/resource-invalidation.ts: no `v`, no `timestamp`.
28
+ // If this fails, promoting the interface into contracts broke the wire and
29
+ // every already-deployed client stops receiving invalidations.
30
+ const onTheWireToday = {
31
+ type: "resource.invalidated",
32
+ keys: [{ type: "actionItems", orgId: ORG_ID }],
33
+ traceId: "trace-1",
34
+ version: 1785312000000,
35
+ };
36
+
37
+ expect(
38
+ ResourceInvalidatedEventSchema.safeParse(onTheWireToday).success,
39
+ ).toBe(true);
40
+ });
41
+
42
+ it("accepts a frame with neither traceId nor the base fields", () => {
43
+ expect(
44
+ ResourceInvalidatedEventSchema.safeParse({
45
+ type: "resource.invalidated",
46
+ keys: [
47
+ { type: "companyMdAccessRequests", orgId: ORG_ID, docId: DOC_ID },
48
+ ],
49
+ version: 0,
50
+ }).success,
51
+ ).toBe(true);
52
+ });
53
+
54
+ it("rejects a request-time version that is not an epoch integer", () => {
55
+ const result = ResourceInvalidatedEventSchema.safeParse({
56
+ type: "resource.invalidated",
57
+ keys: [{ type: "actionItems", orgId: ORG_ID }],
58
+ version: 1.5,
59
+ });
60
+ expect(result.success).toBe(false);
61
+ });
62
+
63
+ it("rejects an empty key list — a frame naming nothing is a bug, not a no-op", () => {
64
+ expect(
65
+ ResourceInvalidatedEventSchema.safeParse({
66
+ type: "resource.invalidated",
67
+ keys: [],
68
+ version: 1,
69
+ }).success,
70
+ ).toBe(false);
71
+ });
72
+
73
+ it("caps keys so the envelope cannot approach the NOTIFY payload limit", () => {
74
+ const overCap = Array.from({ length: MAX_INVALIDATION_KEYS + 1 }, () => ({
75
+ type: "actionItems",
76
+ orgId: ORG_ID,
77
+ }));
78
+ expect(
79
+ ResourceInvalidatedEventSchema.safeParse({
80
+ type: "resource.invalidated",
81
+ keys: overCap,
82
+ version: 1,
83
+ }).success,
84
+ ).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe("key validation is forward-compatible across version skew", () => {
89
+ it("ACCEPTS a key type this contracts version has never heard of", () => {
90
+ // The whole reason `keys` validates shape and not vocabulary. A newer
91
+ // server naming a key we do not know must not cost us the ENTIRE frame —
92
+ // the unknown key matches no query, the known ones still land.
93
+ const result = ResourceInvalidatedEventSchema.safeParse({
94
+ type: "resource.invalidated",
95
+ keys: [
96
+ { type: "somethingShippedAfterThisClient", orgId: ORG_ID },
97
+ { type: "actionItems", orgId: ORG_ID },
98
+ ],
99
+ version: 1,
100
+ });
101
+ expect(result.success).toBe(true);
102
+ });
103
+
104
+ it("rejects a key with no scope discriminator", () => {
105
+ expect(isResourceKeyShape({ type: "actionItems" })).toBe(false);
106
+ });
107
+
108
+ it("rejects a key with two scope discriminators", () => {
109
+ expect(
110
+ isResourceKeyShape({ type: "actionItems", orgId: ORG_ID, userId: "u1" }),
111
+ ).toBe(false);
112
+ });
113
+
114
+ it("rejects non-objects and an empty type", () => {
115
+ expect(isResourceKeyShape(null)).toBe(false);
116
+ expect(isResourceKeyShape("actionItems")).toBe(false);
117
+ expect(isResourceKeyShape({ type: "", orgId: ORG_ID })).toBe(false);
118
+ });
119
+ });
120
+
121
+ describe("resync", () => {
122
+ it("accepts every declared reason", () => {
123
+ for (const reason of USER_EVENT_RESYNC_REASONS) {
124
+ expect(
125
+ UserEventResyncSchema.safeParse({ type: "resync", reason }).success,
126
+ ).toBe(true);
127
+ }
128
+ });
129
+
130
+ it("rejects an undeclared reason", () => {
131
+ expect(
132
+ UserEventResyncSchema.safeParse({ type: "resync", reason: "because" })
133
+ .success,
134
+ ).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe("the union", () => {
139
+ it("discriminates every member on `type`", () => {
140
+ const frames = [
141
+ {
142
+ type: "resource.invalidated",
143
+ keys: [{ type: "actionItems", orgId: ORG_ID }],
144
+ version: 1,
145
+ },
146
+ { type: "resync", reason: "listen-recovered" },
147
+ { type: "connected" },
148
+ ];
149
+ for (const frame of frames) {
150
+ expect(UserSseEventSchema.safeParse(frame).success).toBe(true);
151
+ }
152
+ });
153
+
154
+ it("rejects an unmodeled type so the client LOGS it rather than silently dropping", () => {
155
+ expect(
156
+ UserSseEventSchema.safeParse({ type: "notification.created", id: "n1" })
157
+ .success,
158
+ ).toBe(false);
159
+ });
160
+
161
+ it("models `connected` at all — an unmodeled open frame would log a parse error on every connect", () => {
162
+ expect(
163
+ UserEventConnectedSchema.safeParse({ type: "connected" }).success,
164
+ ).toBe(true);
165
+ });
166
+ });
167
+
168
+ describe("boundary with the two adjacent vocabularies (ADR-CONT-104)", () => {
169
+ it("shares no member name with ActionItemKind", () => {
170
+ // Not symmetry policing — a shared string would make one of the two
171
+ // meanings lie, since an action item is standing state and a user event is
172
+ // a single unreplayable instant.
173
+ const frameTypes = new Set(["resource.invalidated", "resync", "connected"]);
174
+ for (const kind of ACTION_ITEM_KINDS) {
175
+ expect(frameTypes.has(kind)).toBe(false);
176
+ }
177
+ });
178
+
179
+ it("REJECTS an ActionItem-shaped payload", () => {
180
+ // The test that stops the future "just reuse ActionItem on the stream" PR.
181
+ // An action item is re-derived on every read and resolves when someone
182
+ // acts; it cannot be delivered once and forgotten.
183
+ const actionItemShaped = {
184
+ type: "resource.invalidated",
185
+ id: "companyMd.access_request_pending:req-1",
186
+ kind: "companyMd.access_request_pending",
187
+ target: { type: "company_md", id: DOC_ID },
188
+ unitPath: "acme.sales",
189
+ title: "Maya Chen wants access",
190
+ href: "/@acme/md/doc-1?share=1",
191
+ };
192
+ expect(
193
+ ResourceInvalidatedEventSchema.safeParse(actionItemShaped).success,
194
+ ).toBe(false);
195
+ });
196
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * user-events/ — the frames any domain may put on ONE user's connection.
3
+ *
4
+ * See ./README.md for the domain and for why this is neither
5
+ * `NotificationKind` nor `ActionItemKind`.
6
+ */
7
+
8
+ export {
9
+ MAX_INVALIDATION_KEYS,
10
+ ResourceInvalidatedEventSchema,
11
+ USER_EVENT_RESYNC_REASONS,
12
+ UserEventBaseSchema,
13
+ UserEventConnectedSchema,
14
+ UserEventResyncReasonSchema,
15
+ UserEventResyncSchema,
16
+ UserSseEventSchema,
17
+ } from "./schemas";
18
+
19
+ export type {
20
+ ResourceInvalidatedEvent,
21
+ UserEventConnected,
22
+ UserEventResync,
23
+ UserEventResyncReason,
24
+ UserSseEvent,
25
+ } from "./schemas";
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The user-scoped push envelope — every frame the server sends to ONE
3
+ * authenticated user on a long-lived connection, regardless of domain.
4
+ *
5
+ * WHY THIS IS NOT A FIFTH `<Domain>SseEvent`. `ChatSseEvent`,
6
+ * `ExecutionSseEvent`, `CompanyMdCollabSseEvent` and `ImpersonationSseEvent`
7
+ * each describe ONE domain's traffic on ONE route. This union describes the
8
+ * frames any domain may put on the user's connection WITHOUT owning a route or
9
+ * a union of its own — which is exactly what `resource.invalidated` has been
10
+ * doing unpublished (backend `src/chat/execution/resource-invalidation.ts`,
11
+ * redeclared structurally in app `src/hooks/useChatEvents.ts`) since before
12
+ * there was a place to put it.
13
+ *
14
+ * WHAT THIS STREAM IS, STATED SO NOBODY ASSUMES MORE:
15
+ *
16
+ * It is a wake-up channel plus ephemeral cache hints. It is NOT a general
17
+ * ordered event log. Publishing a user event grants NO replay guarantee.
18
+ *
19
+ * Two rules encode that, and both are load-bearing:
20
+ *
21
+ * - **No frame carries an SSE `id:` line.** An id implies a resumable position;
22
+ * there is no log behind this stream, so an id would be a lie. (Contrast
23
+ * `ExecutionSseEvent`, whose `eventSequence` doubles as `Last-Event-ID`
24
+ * because it projects a durable `execution_events` row — ADR-CONT-066.)
25
+ * - **Delivery is edge-triggered only.** Missing a frame may leave caches stale
26
+ * until the next authoritative refresh. That is acceptable because NO
27
+ * CORRECTNESS DEPENDS ON INVALIDATIONS — every frame means "re-read this",
28
+ * and the read is the authority. Nothing durable may ever be hung off this
29
+ * stream without first giving it a real commit-ordered log.
30
+ */
31
+ import { z } from "zod";
32
+ import type { ResourceKey } from "../resource-keys";
33
+ import { isResourceKeyShape } from "../resource-keys";
34
+
35
+ /**
36
+ * Local, not chat's. `chat/schemas.ts` owns a structurally identical helper;
37
+ * importing it would make a chat frame change a user-events breaking change.
38
+ */
39
+ const IsoDateString = z.string().datetime();
40
+
41
+ /**
42
+ * Fields every frame carries.
43
+ *
44
+ * Structurally identical to chat's `BaseEventSchema` on purpose, so folding
45
+ * chat onto this envelope later is a no-op — but deliberately NOT the same
46
+ * object, for the reason above.
47
+ */
48
+ export const UserEventBaseSchema = z.object({
49
+ v: z.literal(1),
50
+ timestamp: IsoDateString,
51
+ });
52
+
53
+ // =============================================================================
54
+ // resource.invalidated — promoted verbatim from the ad-hoc backend interface
55
+ // =============================================================================
56
+
57
+ /** Upper bound on keys per frame. Keeps the envelope far under NOTIFY's limit. */
58
+ export const MAX_INVALIDATION_KEYS = 16;
59
+
60
+ /**
61
+ * A cache-staleness signal. The client refreshes the named keys and shows the
62
+ * user nothing.
63
+ *
64
+ * `keys` validates SHAPE, not VOCABULARY — see `isResourceKeyShape` for why a
65
+ * vocabulary-strict gate here would reject whole frames on version skew.
66
+ *
67
+ * `v` and `timestamp` are OPTIONAL HERE AND NOWHERE ELSE. The frame already on
68
+ * the wire carries neither; requiring them would make promotion a breaking
69
+ * change dressed up as a tidy-up. Same carve-out, same reason, as
70
+ * `CompanyMdCollabConnectedEventSchema`.
71
+ */
72
+ export const ResourceInvalidatedEventSchema = UserEventBaseSchema.partial({
73
+ v: true,
74
+ timestamp: true,
75
+ }).extend({
76
+ type: z.literal("resource.invalidated"),
77
+ keys: z
78
+ .array(z.custom<ResourceKey>(isResourceKeyShape))
79
+ .min(1)
80
+ .max(MAX_INVALIDATION_KEYS),
81
+ /** Correlates the frame with the mutation that caused it. */
82
+ traceId: z.string().optional(),
83
+ /**
84
+ * Entity version — MUST be the entity's own `updatedAt`/`resolvedAt` as epoch
85
+ * ms, NEVER request time. The client's version gate compares it against the
86
+ * cached payload's `version`; a request-time value silently re-opens the race
87
+ * the gate exists to close. (The invariant the backend module already states,
88
+ * published here so it binds the wire rather than one emitter.)
89
+ */
90
+ version: z.number().int().nonnegative(),
91
+ });
92
+ export type ResourceInvalidatedEvent = z.infer<
93
+ typeof ResourceInvalidatedEventSchema
94
+ >;
95
+
96
+ // =============================================================================
97
+ // resync — freshness when precision is unavailable
98
+ // =============================================================================
99
+
100
+ /**
101
+ * Why the server could not name what changed.
102
+ *
103
+ * - `listen-recovered` — the LISTEN connection was down and frames were missed.
104
+ * - `payload-over-cap` — a single frame exceeded the NOTIFY payload limit.
105
+ */
106
+ export const USER_EVENT_RESYNC_REASONS = [
107
+ "listen-recovered",
108
+ "payload-over-cap",
109
+ ] as const;
110
+ export const UserEventResyncReasonSchema = z.enum(USER_EVENT_RESYNC_REASONS);
111
+ export type UserEventResyncReason = z.infer<typeof UserEventResyncReasonSchema>;
112
+
113
+ /**
114
+ * "I could not tell you precisely what changed; re-read everything."
115
+ *
116
+ * The client invalidates every resource it subscribes to. One frame type covers
117
+ * both reasons because they mean the same thing to a client, and because the
118
+ * alternative in each case is worse:
119
+ *
120
+ * - Truncating an over-cap frame yields a SHORTENED `keys` array — a stale
121
+ * cache that looks fresh, which is undetectable downstream.
122
+ * - Dropping it instead leaves that user stale until their next reconnect,
123
+ * which may be hours away.
124
+ *
125
+ * **Precision is expendable; freshness is not.** A resync costs one refetch
126
+ * wave and is always correct.
127
+ */
128
+ export const UserEventResyncSchema = UserEventBaseSchema.partial({
129
+ v: true,
130
+ timestamp: true,
131
+ }).extend({
132
+ type: z.literal("resync"),
133
+ reason: UserEventResyncReasonSchema,
134
+ });
135
+ export type UserEventResync = z.infer<typeof UserEventResyncSchema>;
136
+
137
+ // =============================================================================
138
+ // connected — transport-level
139
+ // =============================================================================
140
+
141
+ /**
142
+ * The stream is open.
143
+ *
144
+ * Modeled for the same reason `CompanyMdCollabConnectedEventSchema` is: without
145
+ * it, a client that validates EVERY frame logs a parse error on a perfectly
146
+ * normal connect.
147
+ */
148
+ export const UserEventConnectedSchema = UserEventBaseSchema.partial({
149
+ v: true,
150
+ timestamp: true,
151
+ }).extend({ type: z.literal("connected") });
152
+ export type UserEventConnected = z.infer<typeof UserEventConnectedSchema>;
153
+
154
+ // =============================================================================
155
+ // The union
156
+ // =============================================================================
157
+
158
+ /**
159
+ * Every frame the user-scoped stream emits.
160
+ *
161
+ * Registered as the OpenAPI component `UserSseEvent`, per the `ChatSseEvent` /
162
+ * `ExecutionSseEvent` / `CompanyMdCollabSseEvent` precedent.
163
+ *
164
+ * `server_drain` is deliberately NOT modeled: the stream already emits it under
165
+ * its own SSE `event:` name and the client binds a dedicated out-of-band
166
+ * listener for it. Adding it here would create two handling paths for one frame.
167
+ */
168
+ export const UserSseEventSchema = z.discriminatedUnion("type", [
169
+ ResourceInvalidatedEventSchema,
170
+ UserEventResyncSchema,
171
+ UserEventConnectedSchema,
172
+ ]);
173
+ export type UserSseEvent = z.infer<typeof UserSseEventSchema>;