@company-semantics/contracts 39.2.0 → 39.3.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": "39.2.0",
3
+ "version": "39.3.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -108,6 +108,7 @@
108
108
  "generate:spec-hash": "tsx scripts/generate-spec-hash.ts",
109
109
  "generate:spec-hash:check": "tsx scripts/generate-spec-hash.ts --check",
110
110
  "generate:api": "pnpm generate:api-types && pnpm generate:spec-hash && pnpm generate:openapi-routes",
111
+ "generate:api:check": "pnpm generate:api-types:check && pnpm generate:spec-hash:check && pnpm generate:openapi-routes:check",
111
112
  "generate:api-types": "openapi-typescript openapi/backend.yaml -o src/api/generated.ts",
112
113
  "generate:openapi-routes": "tsx scripts/generate-openapi-routes.ts",
113
114
  "generate:openapi-routes:check": "tsx scripts/generate-openapi-routes.ts --check",
@@ -138,13 +139,22 @@
138
139
  "prettier": "^3.9.6",
139
140
  "tsx": "^4.23.1",
140
141
  "typescript": "^5.8.3",
142
+ "vite": "^7.3.6",
141
143
  "vitest": "^4.1.10",
142
144
  "yaml": "^2.9.0"
143
145
  },
144
146
  "pnpm": {
145
147
  "overrides": {
146
- "picomatch": ">=4.0.4",
147
- "brace-expansion@<5.0.8": ">=5.0.8"
148
+ "brace-expansion@<5.0.8": ">=5.0.8",
149
+ "minimatch@<10.2.3": ">=10.2.3",
150
+ "rollup@<4.59.0": ">=4.59.0",
151
+ "picomatch@<4.0.4": ">=4.0.4",
152
+ "esbuild@<0.28.1": ">=0.28.1",
153
+ "linkify-it@<5.0.2": ">=5.0.2",
154
+ "postcss@<8.5.18": ">=8.5.18",
155
+ "markdown-it@<14.2.0": ">=14.2.0",
156
+ "js-yaml@>=4.0.0 <4.3.0": ">=4.3.0 <5.0.0",
157
+ "js-yaml@>=5.0.0 <5.2.2": ">=5.2.2"
148
158
  }
149
159
  },
150
160
  "lint-staged": {
@@ -152,5 +162,5 @@
152
162
  "*.md": "markdownlint-cli2",
153
163
  "package.json": "node -e \"JSON.parse(require('fs').readFileSync('package.json'))\""
154
164
  },
155
- "securityRequirementsVersion": "8320e7a5612c6877c2959c127ffa78e693b9d1e95805340a5e4af11a01c4a669"
165
+ "securityRequirementsVersion": "e88ddf247895bafc36dc5d878bb1045dc522a2375683fe08e1b59a10f99e8766"
156
166
  }
package/src/api/README.md CHANGED
@@ -12,7 +12,7 @@ Provides reusable functions that encode API response shapes shared between the c
12
12
  - Response shapes must match the types defined in `src/mcp/` (e.g., `ToolDiscoveryResponse`)
13
13
  - Capability graph inclusion is opt-in via query parameter convention (`?include=graph`)
14
14
  - `generated.ts` contains only type exports — zero runtime imports, zero const/let/var/function declarations
15
- - `generated.ts` must stay in sync with `openapi/backend.yaml` — run `pnpm generate:api-types:check` to verify
15
+ - `generated.ts` must stay in sync with `openapi/backend.yaml` — run `pnpm generate:api:check` to verify (it checks all three spec-derived artifacts, not just this one)
16
16
 
17
17
  ## Generated Types (`generated.ts`)
18
18
 
@@ -22,7 +22,7 @@ Auto-generated by `openapi-typescript` from `openapi/backend.yaml`. Exports thre
22
22
  - `components` — All schema types (MeResponse, WorkspaceOverview, etc.)
23
23
  - `operations` — Operation-level types keyed by operationId
24
24
 
25
- Regenerate with `pnpm generate:api-types`. Check for staleness with `pnpm generate:api-types:check`.
25
+ Regenerate with `pnpm generate:api` — never `pnpm generate:api-types` alone. Three artifacts derive from `openapi/backend.yaml` (this file, `generated-spec-hash.ts`, and `src/generated/openapi-routes.ts`); regenerating one leaves the others stale. Check for staleness with `pnpm generate:api:check`.
26
26
 
27
27
  The file is committed to the repo (not .gitignored) so consumers can import types without running the generator, and type changes are visible in code review diffs.
28
28
 
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '4669379f3c9c' as const;
3
- export const SPEC_HASH_FULL = '4669379f3c9c9733dea3f5bf53f2c1c0d1f0e496fddbf874e8d1f1951e805d7c' as const;
2
+ export const SPEC_HASH = '578211b8b148' as const;
3
+ export const SPEC_HASH_FULL = '578211b8b1489b1d26938629c14b09a94ee3bf636085a5f058f1b573747f8fce' as const;
@@ -1457,6 +1457,77 @@ export interface paths {
1457
1457
  patch?: never;
1458
1458
  trace?: never;
1459
1459
  };
1460
+ "/api/company-md/docs/{id}/collab/sync": {
1461
+ parameters: {
1462
+ query?: never;
1463
+ header?: never;
1464
+ path?: never;
1465
+ cookie?: never;
1466
+ };
1467
+ /** Get merged collaboration state for a company.md document body */
1468
+ get: operations["getCompanyMdDocCollabSync"];
1469
+ put?: never;
1470
+ post?: never;
1471
+ delete?: never;
1472
+ options?: never;
1473
+ head?: never;
1474
+ patch?: never;
1475
+ trace?: never;
1476
+ };
1477
+ "/api/company-md/docs/{id}/collab/updates": {
1478
+ parameters: {
1479
+ query?: never;
1480
+ header?: never;
1481
+ path?: never;
1482
+ cookie?: never;
1483
+ };
1484
+ get?: never;
1485
+ put?: never;
1486
+ /** Append one Yjs update to a company.md document’s collaboration log */
1487
+ post: operations["appendCompanyMdDocCollabUpdate"];
1488
+ delete?: never;
1489
+ options?: never;
1490
+ head?: never;
1491
+ patch?: never;
1492
+ trace?: never;
1493
+ };
1494
+ "/api/company-md/docs/{id}/collab/presence": {
1495
+ parameters: {
1496
+ query?: never;
1497
+ header?: never;
1498
+ path?: never;
1499
+ cookie?: never;
1500
+ };
1501
+ get?: never;
1502
+ put?: never;
1503
+ /** Signal presence (cursor position, status, departure) on a company.md document */
1504
+ post: operations["postCompanyMdDocCollabPresence"];
1505
+ delete?: never;
1506
+ options?: never;
1507
+ head?: never;
1508
+ patch?: never;
1509
+ trace?: never;
1510
+ };
1511
+ "/api/company-md/docs/{id}/collab/stream": {
1512
+ parameters: {
1513
+ query?: never;
1514
+ header?: never;
1515
+ path?: never;
1516
+ cookie?: never;
1517
+ };
1518
+ /**
1519
+ * Stream company.md collaboration updates via SSE
1520
+ * @description Server-Sent Events stream of collaboration updates for a company.md document body. x-streaming: true. Durable frames are `event: collab-update` with `id: <seq>` (decimal string); resume via `?after=<seq>&epoch=<n>` from /collab/sync, or the Last-Event-ID header on reconnect. An unusable cursor gets `event: collab-reset` and the client refetches /collab/sync. See ADR-BE-439.
1521
+ */
1522
+ get: operations["streamCompanyMdDocCollab"];
1523
+ put?: never;
1524
+ post?: never;
1525
+ delete?: never;
1526
+ options?: never;
1527
+ head?: never;
1528
+ patch?: never;
1529
+ trace?: never;
1530
+ };
1460
1531
  "/api/company-md/extract": {
1461
1532
  parameters: {
1462
1533
  query?: never;
@@ -4578,6 +4649,48 @@ export interface components {
4578
4649
  UpdateCompanyMdTitleRequest: {
4579
4650
  title: string;
4580
4651
  };
4652
+ /** @description Merged collaboration state plus the cursor the stream resumes from. */
4653
+ CollabSyncResponse: {
4654
+ epoch: number;
4655
+ seq: string;
4656
+ update: string;
4657
+ reset: boolean;
4658
+ editable: boolean;
4659
+ limits: components["schemas"]["CollabSyncLimits"];
4660
+ };
4661
+ /** @description The position the update was assigned, and whether it was a replayed retry. */
4662
+ CollabAppendUpdateResponse: {
4663
+ seq: string;
4664
+ duplicate: boolean;
4665
+ };
4666
+ /** @description One Yjs update plus the generation and idempotency pair it belongs to. */
4667
+ CollabAppendUpdateRequest: {
4668
+ epoch: number;
4669
+ /** Format: uuid */
4670
+ clientKey: string;
4671
+ /** Format: uuid */
4672
+ clientUpdateId: string;
4673
+ update: string;
4674
+ };
4675
+ /** @description One presence signal: position blobs and a status for the caller’s own editor instance. Identity is stamped server-side. */
4676
+ CollabPresenceRequest: {
4677
+ /** Format: uuid */
4678
+ clientKey: string;
4679
+ /** @enum {string} */
4680
+ status: "editing" | "viewing";
4681
+ anchor?: string;
4682
+ head?: string;
4683
+ /** @constant */
4684
+ gone?: true;
4685
+ };
4686
+ /** @description One durable collaboration update, as streamed over SSE. */
4687
+ CollabStreamUpdateFrame: {
4688
+ /** @constant */
4689
+ v: 1;
4690
+ epoch: number;
4691
+ seq: string;
4692
+ update: string;
4693
+ };
4581
4694
  CompanyMdContextBankResponse: {
4582
4695
  items: {
4583
4696
  id: string;
@@ -5882,6 +5995,11 @@ export interface components {
5882
5995
  userAgent: string;
5883
5996
  } | null;
5884
5997
  };
5998
+ /** @description Server-enforced collaboration size limits, published so clients can pre-check. */
5999
+ CollabSyncLimits: {
6000
+ maxUpdateBytes: number;
6001
+ maxDocTextBytes: number;
6002
+ };
5885
6003
  /** @description An ACL-admitted context-doc discovery hit. */
5886
6004
  ContextDocDiscoveryHit: {
5887
6005
  id: string;
@@ -8117,6 +8235,183 @@ export interface operations {
8117
8235
  };
8118
8236
  };
8119
8237
  };
8238
+ getCompanyMdDocCollabSync: {
8239
+ parameters: {
8240
+ query?: {
8241
+ since?: string;
8242
+ epoch?: number;
8243
+ };
8244
+ header?: never;
8245
+ path: {
8246
+ id: string;
8247
+ };
8248
+ cookie?: never;
8249
+ };
8250
+ requestBody?: never;
8251
+ responses: {
8252
+ /** @description Merged collaboration state plus the cursor the stream resumes from */
8253
+ 200: {
8254
+ headers: {
8255
+ [name: string]: unknown;
8256
+ };
8257
+ content: {
8258
+ "application/json": components["schemas"]["CollabSyncResponse"];
8259
+ };
8260
+ };
8261
+ /** @description Document not found, or the caller may not read its body */
8262
+ 404: {
8263
+ headers: {
8264
+ [name: string]: unknown;
8265
+ };
8266
+ content?: never;
8267
+ };
8268
+ /** @description The supplied cursor is ahead of this document’s log; resync */
8269
+ 409: {
8270
+ headers: {
8271
+ [name: string]: unknown;
8272
+ };
8273
+ content?: never;
8274
+ };
8275
+ };
8276
+ };
8277
+ appendCompanyMdDocCollabUpdate: {
8278
+ parameters: {
8279
+ query?: never;
8280
+ header?: never;
8281
+ path: {
8282
+ id: string;
8283
+ };
8284
+ cookie?: never;
8285
+ };
8286
+ requestBody: {
8287
+ content: {
8288
+ "application/json": components["schemas"]["CollabAppendUpdateRequest"];
8289
+ };
8290
+ };
8291
+ responses: {
8292
+ /** @description The position the update was assigned, and whether it was a replayed retry */
8293
+ 200: {
8294
+ headers: {
8295
+ [name: string]: unknown;
8296
+ };
8297
+ content: {
8298
+ "application/json": components["schemas"]["CollabAppendUpdateResponse"];
8299
+ };
8300
+ };
8301
+ /** @description Malformed body, or a payload that does not decode as a Yjs update */
8302
+ 400: {
8303
+ headers: {
8304
+ [name: string]: unknown;
8305
+ };
8306
+ content?: never;
8307
+ };
8308
+ /** @description Document not found, or the caller may not edit it */
8309
+ 404: {
8310
+ headers: {
8311
+ [name: string]: unknown;
8312
+ };
8313
+ content?: never;
8314
+ };
8315
+ /** @description Epoch mismatch, or the document was never activated for collaboration */
8316
+ 409: {
8317
+ headers: {
8318
+ [name: string]: unknown;
8319
+ };
8320
+ content?: never;
8321
+ };
8322
+ /** @description Applying the update would exceed the document text cap; not logged */
8323
+ 413: {
8324
+ headers: {
8325
+ [name: string]: unknown;
8326
+ };
8327
+ content?: never;
8328
+ };
8329
+ /** @description Too many uncompacted updates pending; retry after backoff */
8330
+ 429: {
8331
+ headers: {
8332
+ [name: string]: unknown;
8333
+ };
8334
+ content?: never;
8335
+ };
8336
+ };
8337
+ };
8338
+ postCompanyMdDocCollabPresence: {
8339
+ parameters: {
8340
+ query?: never;
8341
+ header?: never;
8342
+ path: {
8343
+ id: string;
8344
+ };
8345
+ cookie?: never;
8346
+ };
8347
+ requestBody: {
8348
+ content: {
8349
+ "application/json": components["schemas"]["CollabPresenceRequest"];
8350
+ };
8351
+ };
8352
+ responses: {
8353
+ /** @description Presence signal accepted (ephemeral; nothing durable written) */
8354
+ 204: {
8355
+ headers: {
8356
+ [name: string]: unknown;
8357
+ };
8358
+ content?: never;
8359
+ };
8360
+ /** @description Malformed body (bad clientKey, status, or oversized position blob) */
8361
+ 400: {
8362
+ headers: {
8363
+ [name: string]: unknown;
8364
+ };
8365
+ content?: never;
8366
+ };
8367
+ /** @description The clientKey is bound to a different user on this document */
8368
+ 403: {
8369
+ headers: {
8370
+ [name: string]: unknown;
8371
+ };
8372
+ content?: never;
8373
+ };
8374
+ /** @description Document not found, or the caller may not read its body */
8375
+ 404: {
8376
+ headers: {
8377
+ [name: string]: unknown;
8378
+ };
8379
+ content?: never;
8380
+ };
8381
+ };
8382
+ };
8383
+ streamCompanyMdDocCollab: {
8384
+ parameters: {
8385
+ query?: {
8386
+ after?: string;
8387
+ epoch?: number;
8388
+ };
8389
+ header?: never;
8390
+ path: {
8391
+ id: string;
8392
+ };
8393
+ cookie?: never;
8394
+ };
8395
+ requestBody?: never;
8396
+ responses: {
8397
+ /** @description SSE event stream (text/event-stream). Durable frame payload: CollabStreamUpdateFrame. */
8398
+ 200: {
8399
+ headers: {
8400
+ [name: string]: unknown;
8401
+ };
8402
+ content: {
8403
+ "text/event-stream": components["schemas"]["CollabStreamUpdateFrame"];
8404
+ };
8405
+ };
8406
+ /** @description Document not found, or the caller may not read its body */
8407
+ 404: {
8408
+ headers: {
8409
+ [name: string]: unknown;
8410
+ };
8411
+ content?: never;
8412
+ };
8413
+ };
8414
+ };
8120
8415
  extractCompanyMd: {
8121
8416
  parameters: {
8122
8417
  query?: never;
@@ -30,6 +30,10 @@ export const openApiRoutes = {
30
30
  '/api/company-md/docs/:id/context-bank/suggestions': ['GET'],
31
31
  '/api/company-md/docs/{id}': ['GET'],
32
32
  '/api/company-md/docs/{id}/access-requests': ['GET', 'POST'],
33
+ '/api/company-md/docs/{id}/collab/presence': ['POST'],
34
+ '/api/company-md/docs/{id}/collab/stream': ['GET'],
35
+ '/api/company-md/docs/{id}/collab/sync': ['GET'],
36
+ '/api/company-md/docs/{id}/collab/updates': ['POST'],
33
37
  '/api/company-md/docs/{id}/content': ['PUT'],
34
38
  '/api/company-md/docs/{id}/context-bank': ['GET'],
35
39
  '/api/company-md/docs/{id}/context-bank/associate': ['POST'],
package/src/index.ts CHANGED
@@ -418,6 +418,38 @@ export type {
418
418
  export { SourceAuthoritySchema } from "./org/index";
419
419
  export type { SourceAuthority } from "./org/index";
420
420
 
421
+ // Company.md real-time collaboration wire contract: sync/update-accepted
422
+ // responses, the SSE frame union, the Y.Doc shape constant, and the published
423
+ // size limits. Sequences are ALWAYS decimal strings. (ADR-CONT-102)
424
+ export {
425
+ COMPANY_MD_COLLAB_TEXT_KEY,
426
+ COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS,
427
+ COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES,
428
+ COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS,
429
+ CompanyMdCollabSyncLimitsSchema,
430
+ CompanyMdCollabSyncResponseSchema,
431
+ CompanyMdCollabUpdateAcceptedSchema,
432
+ CompanyMdCollabUpdateEventSchema,
433
+ CompanyMdCollabPresenceEventSchema,
434
+ CompanyMdCollabResetEventSchema,
435
+ CompanyMdCollabAccessRevokedEventSchema,
436
+ CompanyMdCollabConnectedEventSchema,
437
+ CompanyMdCollabServerDrainEventSchema,
438
+ CompanyMdCollabSseEventSchema,
439
+ } from "./org/index";
440
+ export type {
441
+ CompanyMdCollabSyncLimits,
442
+ CompanyMdCollabSyncResponse,
443
+ CompanyMdCollabUpdateAccepted,
444
+ CompanyMdCollabUpdateEvent,
445
+ CompanyMdCollabPresenceEvent,
446
+ CompanyMdCollabResetEvent,
447
+ CompanyMdCollabAccessRevokedEvent,
448
+ CompanyMdCollabConnectedEvent,
449
+ CompanyMdCollabServerDrainEvent,
450
+ CompanyMdCollabSseEvent,
451
+ } from "./org/index";
452
+
421
453
  // Sync run summary: the outcome record of one adapter sync run (counts +
422
454
  // cursor watermark). A flat report shape, not a structure fact. (ADR-CONT-085)
423
455
  export { SyncRunSummarySchema } from "./org/index";
package/src/org/README.md CHANGED
@@ -37,6 +37,10 @@ Shared type vocabulary for organization ownership, type classification, and tran
37
37
  - `AuthoritySource` _(type)_
38
38
  - `AuthoritySourceSchema` — Engine-internal authority source.
39
39
  - `AuthorizableView` _(type)_ — Type for views that can be checked against VIEWSCOPEMAP.
40
+ - `COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES` — Cap on the MATERIALIZED document text, in UTF-8 bytes of the expanded result.
41
+ - `COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS` — Cap on ONE presence position blob (`anchor` or `head`), in base64 chars.
42
+ - `COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS` — Cap on ONE encoded update, measured on the base64 payload as it is sent.
43
+ - `COMPANY_MD_COLLAB_TEXT_KEY` — The Y.Doc root key holding the document body — a CONTRACT, not a detail.
40
44
  - `CanonicalFacts` _(type)_
41
45
  - `CanonicalFactsSchema` — The full normalized batch an external-source adapter emits in one pass: every person, org unit, position…
42
46
  - `CanonicalOccupancy` _(type)_
@@ -55,6 +59,26 @@ Shared type vocabulary for organization ownership, type classification, and tran
55
59
  - `ChangeMemberRoleResponse` _(type)_
56
60
  - `ChangeMemberRoleResponseSchema`
57
61
  - `CompanyMdClassification` _(type)_ — Information classification for a Company.md node — what the document is, for governance, NOT permissions.
62
+ - `CompanyMdCollabAccessRevokedEvent` _(type)_
63
+ - `CompanyMdCollabAccessRevokedEventSchema` — `event: access-revoked` — the actor's read grant went away mid-stream.
64
+ - `CompanyMdCollabConnectedEvent` _(type)_
65
+ - `CompanyMdCollabConnectedEventSchema` — `event: connected` — the stream is open.
66
+ - `CompanyMdCollabPresenceEvent` _(type)_
67
+ - `CompanyMdCollabPresenceEventSchema` — `event: collab-presence` — one participant's presence signal, either live or replayed from the roster right…
68
+ - `CompanyMdCollabResetEvent` _(type)_
69
+ - `CompanyMdCollabResetEventSchema` — `event: collab-reset` — the client's cursor was unusable (a superseded generation, older than what compaction…
70
+ - `CompanyMdCollabServerDrainEvent` _(type)_
71
+ - `CompanyMdCollabServerDrainEventSchema` — `event: server_drain` — the server is shutting the stream down deliberately (a rolling restart, not a…
72
+ - `CompanyMdCollabSseEvent` _(type)_
73
+ - `CompanyMdCollabSseEventSchema` — Every frame the collaboration stream emits, discriminated on the `type` the client stamps from the SSE…
74
+ - `CompanyMdCollabSyncLimits` _(type)_
75
+ - `CompanyMdCollabSyncLimitsSchema` — Protocol limits the client needs BEFORE it sends anything.
76
+ - `CompanyMdCollabSyncResponse` _(type)_
77
+ - `CompanyMdCollabSyncResponseSchema` — `GET /collab/sync` response body — merged collaboration state plus the cursor the stream resumes from.
78
+ - `CompanyMdCollabUpdateAccepted` _(type)_
79
+ - `CompanyMdCollabUpdateAcceptedSchema` — `POST /collab/updates` response body.
80
+ - `CompanyMdCollabUpdateEvent` _(type)_
81
+ - `CompanyMdCollabUpdateEventSchema` — `event: collab-update` — one durable collaboration update.
58
82
  - `CompanyMdContextBankItem` _(type)_ — A context bank item — a company.md doc associated with one or more parent nodes.
59
83
  - `CompanyMdDependency` _(type)_
60
84
  - `CompanyMdDiscoverabilityPolicy` _(type)_ — Discoverability policy for a Company.md node — who knows it exists, orthogonal to who may read it (authority…
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import {
4
+ COMPANY_MD_COLLAB_TEXT_KEY,
5
+ COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS,
6
+ COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES,
7
+ COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS,
8
+ CompanyMdCollabSyncResponseSchema,
9
+ CompanyMdCollabUpdateAcceptedSchema,
10
+ CompanyMdCollabSseEventSchema,
11
+ } from "../company-md-collab.js";
12
+
13
+ const DOC_ID = "9f1c7b62-2b3e-4d21-9a11-6a0c0b5e7c31";
14
+ const USER_ID = "3d0a2f18-5c44-4a90-8b7a-1d2e3f4a5b6c";
15
+ const CLIENT_KEY = "7c9e6679-7425-40de-944b-e07fc1f90ae7";
16
+
17
+ /**
18
+ * A sequence past 2^53. This is the whole reason seq is a string: as a JSON
19
+ * number it would round, and a rounded cursor resumes at the wrong position
20
+ * without failing.
21
+ */
22
+ const BIG_SEQ = "9007199254740993";
23
+
24
+ describe("CompanyMdCollabSyncResponseSchema", () => {
25
+ const base = {
26
+ epoch: 3,
27
+ seq: "42",
28
+ update: "AQIDBA==",
29
+ editable: true,
30
+ reset: false,
31
+ limits: { maxUpdateBytes: 262144, maxDocTextBytes: 1000000 },
32
+ };
33
+
34
+ it("accepts the sync response the backend emits", () => {
35
+ expect(CompanyMdCollabSyncResponseSchema.parse(base)).toEqual(base);
36
+ });
37
+
38
+ it("accepts a seq beyond the JS safe-integer range", () => {
39
+ const parsed = CompanyMdCollabSyncResponseSchema.parse({
40
+ ...base,
41
+ seq: BIG_SEQ,
42
+ });
43
+ expect(parsed.seq).toBe(BIG_SEQ);
44
+ });
45
+
46
+ it("accepts a response without reset (the field is additive-optional)", () => {
47
+ const { reset: _reset, ...withoutReset } = base;
48
+ expect(() =>
49
+ CompanyMdCollabSyncResponseSchema.parse(withoutReset),
50
+ ).not.toThrow();
51
+ });
52
+
53
+ it("rejects a numeric seq", () => {
54
+ expect(() =>
55
+ CompanyMdCollabSyncResponseSchema.parse({ ...base, seq: 42 }),
56
+ ).toThrow();
57
+ });
58
+
59
+ it("rejects a non-digit seq", () => {
60
+ expect(() =>
61
+ CompanyMdCollabSyncResponseSchema.parse({ ...base, seq: "42n" }),
62
+ ).toThrow();
63
+ });
64
+
65
+ it("rejects a zero epoch", () => {
66
+ expect(() =>
67
+ CompanyMdCollabSyncResponseSchema.parse({ ...base, epoch: 0 }),
68
+ ).toThrow();
69
+ });
70
+
71
+ it("rejects a response missing limits", () => {
72
+ const { limits: _limits, ...withoutLimits } = base;
73
+ expect(() =>
74
+ CompanyMdCollabSyncResponseSchema.parse(withoutLimits),
75
+ ).toThrow();
76
+ });
77
+ });
78
+
79
+ describe("CompanyMdCollabUpdateAcceptedSchema", () => {
80
+ it("accepts the append response the backend emits", () => {
81
+ const frame = { seq: "1024", duplicate: false };
82
+ expect(CompanyMdCollabUpdateAcceptedSchema.parse(frame)).toEqual(frame);
83
+ });
84
+
85
+ it("rejects a numeric seq", () => {
86
+ expect(() =>
87
+ CompanyMdCollabUpdateAcceptedSchema.parse({
88
+ seq: 1024,
89
+ duplicate: false,
90
+ }),
91
+ ).toThrow();
92
+ });
93
+
94
+ it("rejects a missing duplicate flag", () => {
95
+ expect(() =>
96
+ CompanyMdCollabUpdateAcceptedSchema.parse({ seq: "1024" }),
97
+ ).toThrow();
98
+ });
99
+ });
100
+
101
+ /**
102
+ * The frames below are the exact `data:` bodies the backend writes, with `type`
103
+ * stamped from the SSE `event:` name — which is how a client feeds the union.
104
+ * If one of these stops parsing, the published contract has drifted from the
105
+ * stream.
106
+ */
107
+ describe("CompanyMdCollabSseEventSchema — raw backend frames", () => {
108
+ it("parses a collab-update frame", () => {
109
+ const parsed = CompanyMdCollabSseEventSchema.parse({
110
+ type: "collab-update",
111
+ v: 1,
112
+ epoch: 3,
113
+ seq: BIG_SEQ,
114
+ update: "AQIDBA==",
115
+ });
116
+ expect(parsed.type).toBe("collab-update");
117
+ });
118
+
119
+ it("parses a collab-presence frame with selection blobs", () => {
120
+ const parsed = CompanyMdCollabSseEventSchema.parse({
121
+ type: "collab-presence",
122
+ v: 1,
123
+ userId: USER_ID,
124
+ clientKey: CLIENT_KEY,
125
+ status: "editing",
126
+ anchor: "AQAB",
127
+ head: "AQAC",
128
+ });
129
+ expect(parsed.type).toBe("collab-presence");
130
+ });
131
+
132
+ it("parses a collab-presence departure frame", () => {
133
+ expect(() =>
134
+ CompanyMdCollabSseEventSchema.parse({
135
+ type: "collab-presence",
136
+ v: 1,
137
+ userId: USER_ID,
138
+ clientKey: CLIENT_KEY,
139
+ status: "viewing",
140
+ gone: true,
141
+ }),
142
+ ).not.toThrow();
143
+ });
144
+
145
+ it("parses a collab-reset frame for each reason the backend sends", () => {
146
+ for (const reason of ["epoch-mismatch", "compacted", "cursor-ahead"]) {
147
+ expect(() =>
148
+ CompanyMdCollabSseEventSchema.parse({
149
+ type: "collab-reset",
150
+ v: 1,
151
+ reason,
152
+ epoch: 4,
153
+ resumeSeq: "77",
154
+ }),
155
+ ).not.toThrow();
156
+ }
157
+ });
158
+
159
+ it("parses an access-revoked frame with its empty body", () => {
160
+ const parsed = CompanyMdCollabSseEventSchema.parse({
161
+ type: "access-revoked",
162
+ });
163
+ expect(parsed.type).toBe("access-revoked");
164
+ });
165
+
166
+ it("parses a connected frame with its empty body", () => {
167
+ expect(() =>
168
+ CompanyMdCollabSseEventSchema.parse({ type: "connected" }),
169
+ ).not.toThrow();
170
+ });
171
+
172
+ it("parses a server_drain frame", () => {
173
+ expect(() =>
174
+ CompanyMdCollabSseEventSchema.parse({
175
+ type: "server_drain",
176
+ reason: "shutting_down",
177
+ }),
178
+ ).not.toThrow();
179
+ });
180
+ });
181
+
182
+ describe("CompanyMdCollabSseEventSchema — optional attribution fields", () => {
183
+ it("accepts a collab-update carrying docId, author and origin", () => {
184
+ expect(() =>
185
+ CompanyMdCollabSseEventSchema.parse({
186
+ type: "collab-update",
187
+ v: 1,
188
+ epoch: 3,
189
+ seq: "9",
190
+ update: "AQIDBA==",
191
+ docId: DOC_ID,
192
+ authorUserId: USER_ID,
193
+ origin: "client",
194
+ }),
195
+ ).not.toThrow();
196
+ });
197
+
198
+ it("accepts a server-originated collab-update with a null author", () => {
199
+ expect(() =>
200
+ CompanyMdCollabSseEventSchema.parse({
201
+ type: "collab-update",
202
+ v: 1,
203
+ epoch: 3,
204
+ seq: "9",
205
+ update: "AQIDBA==",
206
+ authorUserId: null,
207
+ origin: "bridge",
208
+ }),
209
+ ).not.toThrow();
210
+ });
211
+ });
212
+
213
+ describe("CompanyMdCollabSseEventSchema — rejections", () => {
214
+ it("rejects an unknown event type", () => {
215
+ expect(() =>
216
+ CompanyMdCollabSseEventSchema.parse({ type: "collab-yolo", v: 1 }),
217
+ ).toThrow();
218
+ });
219
+
220
+ it("rejects a frame with no type stamped on it", () => {
221
+ expect(() =>
222
+ CompanyMdCollabSseEventSchema.parse({
223
+ v: 1,
224
+ epoch: 3,
225
+ seq: "9",
226
+ update: "AQIDBA==",
227
+ }),
228
+ ).toThrow();
229
+ });
230
+
231
+ it("rejects a numeric seq on collab-update", () => {
232
+ expect(() =>
233
+ CompanyMdCollabSseEventSchema.parse({
234
+ type: "collab-update",
235
+ v: 1,
236
+ epoch: 3,
237
+ seq: 9,
238
+ update: "AQIDBA==",
239
+ }),
240
+ ).toThrow();
241
+ });
242
+
243
+ it("rejects a numeric resumeSeq on collab-reset", () => {
244
+ expect(() =>
245
+ CompanyMdCollabSseEventSchema.parse({
246
+ type: "collab-reset",
247
+ v: 1,
248
+ reason: "compacted",
249
+ epoch: 4,
250
+ resumeSeq: 77,
251
+ }),
252
+ ).toThrow();
253
+ });
254
+
255
+ it("rejects an unknown presence status", () => {
256
+ expect(() =>
257
+ CompanyMdCollabSseEventSchema.parse({
258
+ type: "collab-presence",
259
+ v: 1,
260
+ userId: USER_ID,
261
+ clientKey: CLIENT_KEY,
262
+ status: "idle",
263
+ }),
264
+ ).toThrow();
265
+ });
266
+
267
+ it("rejects a future frame version", () => {
268
+ expect(() =>
269
+ CompanyMdCollabSseEventSchema.parse({
270
+ type: "collab-update",
271
+ v: 2,
272
+ epoch: 3,
273
+ seq: "9",
274
+ update: "AQIDBA==",
275
+ }),
276
+ ).toThrow();
277
+ });
278
+ });
279
+
280
+ describe("collaboration protocol constants", () => {
281
+ it("names the Y.Doc root key the backend materializes text from", () => {
282
+ expect(COMPANY_MD_COLLAB_TEXT_KEY).toBe("content");
283
+ });
284
+
285
+ it("publishes the size limits the server enforces", () => {
286
+ expect(COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS).toBe(256 * 1024);
287
+ expect(COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES).toBe(1_000_000);
288
+ expect(COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS).toBe(512);
289
+ });
290
+ });
@@ -0,0 +1,337 @@
1
+ /**
2
+ * company.md real-time collaboration — the published wire contract.
3
+ *
4
+ * The response and event vocabulary of the four `/api/company-md/docs/{id}/collab/*`
5
+ * routes. Promoted here because both the app (the editor and its transport) and
6
+ * the backend (the routes and the Y.Doc bridge) must agree on these shapes, and
7
+ * a client that guesses one of them fails silently rather than loudly. Request
8
+ * bodies deliberately stay backend-side per ADR-CONT-029 — only responses and
9
+ * broadcast events are the published promise.
10
+ *
11
+ * THE ONE RULE — every sequence value is a DECIMAL STRING, never a JSON number.
12
+ * Sequences are Postgres bigints; past 2^53 `JSON.parse` rounds them, and a
13
+ * rounded cursor does not fail, it silently resumes at the wrong position. So
14
+ * `seq` and `resumeSeq` are `z.string().regex(/^[0-9]+$/)` and a numeric value
15
+ * is rejected at the boundary.
16
+ *
17
+ * ON THE SSE UNION'S DISCRIMINATOR — the stream's frames do NOT carry a `type`
18
+ * field. The discriminator is the SSE `event:` NAME, and the `data:` body is the
19
+ * bare payload. A client therefore stamps `type` from the frame's event name
20
+ * before parsing:
21
+ *
22
+ * CompanyMdCollabSseEventSchema.parse({ type: ev.type, ...JSON.parse(ev.data) })
23
+ *
24
+ * The union is modeled on `type` anyway (the ChatSseEvent precedent) because
25
+ * that is the shape a consumer actually switches on. Fields the server does not
26
+ * put on the wire today are optional, so a stamped raw frame parses as-is.
27
+ *
28
+ * @see ADR-CONT-102 for what is promoted here and what deliberately is not.
29
+ * @see the backend protocol ADR (slug `company-md-collab-protocol`) for the
30
+ * authoritative protocol semantics these shapes describe.
31
+ */
32
+
33
+ import { z } from "zod";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Protocol constants
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * The Y.Doc root key holding the document body — a CONTRACT, not a detail.
41
+ *
42
+ * The server materializes text by reading this key and the bridge mutates the
43
+ * same type. A client that writes its body under a different root key gets its
44
+ * updates durably logged and permanently ignored: the log is correct, the
45
+ * stored `content` stays empty, and nothing errors anywhere. Title is
46
+ * deliberately NOT in the CRDT document.
47
+ */
48
+ export const COMPANY_MD_COLLAB_TEXT_KEY = "content" as const;
49
+
50
+ /**
51
+ * Cap on ONE encoded update, measured on the base64 payload as it is sent.
52
+ * Published so an editor can refuse an oversized paste locally instead of
53
+ * discovering the cap on a rejected POST. Prefer the `limits` block on a live
54
+ * {@link CompanyMdCollabSyncResponseSchema} when one is in hand — this constant
55
+ * is the compile-time default for a client that has not synced yet.
56
+ */
57
+ export const COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS = 262144 as const;
58
+
59
+ /**
60
+ * Cap on the MATERIALIZED document text, in UTF-8 bytes of the expanded result.
61
+ *
62
+ * Not redundant with {@link COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS}: a CRDT
63
+ * update is a DELTA, so a small delta can expand the document arbitrarily and a
64
+ * stream of individually-tiny updates can grow state without any single request
65
+ * looking suspicious. Only a measurement on the expanded text catches that.
66
+ */
67
+ export const COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES = 1_000_000 as const;
68
+
69
+ /**
70
+ * Cap on ONE presence position blob (`anchor` or `head`), in base64 chars.
71
+ * These are encoded Y.RelativePosition values — opaque POSITION data, a few
72
+ * dozen bytes in practice; the cap keeps a full presence envelope well under
73
+ * the transport's payload ceiling so an envelope is never truncated.
74
+ */
75
+ export const COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS = 512 as const;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Sequences
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * A sequence number on the wire: decimal digits only, arbitrary precision.
83
+ * Module-private on purpose — consumers reach it through the field that uses
84
+ * it, so there is one place the rule can change.
85
+ */
86
+ const CollabSeqString = z
87
+ .string()
88
+ .regex(
89
+ /^[0-9]+$/,
90
+ "seq must be a decimal string (bigint values are never JSON numbers)",
91
+ );
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Response shapes
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Protocol limits the client needs BEFORE it sends anything. Published from the
99
+ * same constants the server enforces — a published limit that disagrees with
100
+ * the enforced one is worse than none, because the editor lets the user type
101
+ * and the server then rejects the result.
102
+ */
103
+ export const CompanyMdCollabSyncLimitsSchema = z.object({
104
+ /** Max size of ONE base64-encoded update the server will accept, in bytes. */
105
+ maxUpdateBytes: z.number().int(),
106
+ /** Max size of the MATERIALIZED document text, in UTF-8 bytes. */
107
+ maxDocTextBytes: z.number().int(),
108
+ });
109
+ export type CompanyMdCollabSyncLimits = z.infer<
110
+ typeof CompanyMdCollabSyncLimitsSchema
111
+ >;
112
+
113
+ /**
114
+ * `GET /collab/sync` response body — merged collaboration state plus the cursor
115
+ * the stream resumes from.
116
+ *
117
+ * `seq` is handed straight back to the stream as `?after=`, which is what closes
118
+ * the race between "fetched state" and "receiving live updates": the server
119
+ * serves everything strictly after that seq, so an update committed inside the
120
+ * window is re-sent rather than lost.
121
+ */
122
+ export const CompanyMdCollabSyncResponseSchema = z.object({
123
+ /** The document's current collaboration generation. */
124
+ epoch: z.number().int().positive(),
125
+ /** Cursor this payload advances the client to — pass verbatim as `after=`. */
126
+ seq: CollabSeqString,
127
+ /** base64 Yjs bytes: a full snapshot when `reset`, otherwise a delta. */
128
+ update: z.string(),
129
+ /**
130
+ * Whether this actor may edit the body — surfaced so a client can mount a
131
+ * read-only editor instead of walking into a doomed first write. Signal only;
132
+ * it never affects admittance to the stream.
133
+ */
134
+ editable: z.boolean(),
135
+ /**
136
+ * True when `update` is a full snapshot because the client's cursor was
137
+ * unusable (absent, from another generation, or older than what compaction
138
+ * still holds). Applying it is identical either way — Yjs merges losslessly —
139
+ * but anything the client queued against the old cursor is void.
140
+ *
141
+ * Optional on the published contract though the server always sends it: a
142
+ * reader that treats an absent value as `false` behaves correctly against
143
+ * every server that omits it, and this keeps the field addable to sibling
144
+ * responses later without a major.
145
+ */
146
+ reset: z.boolean().optional(),
147
+ limits: CompanyMdCollabSyncLimitsSchema,
148
+ });
149
+ export type CompanyMdCollabSyncResponse = z.infer<
150
+ typeof CompanyMdCollabSyncResponseSchema
151
+ >;
152
+
153
+ /**
154
+ * `POST /collab/updates` response body.
155
+ *
156
+ * `duplicate` is not decoration: on a retry the server returns the ORIGINAL seq
157
+ * and writes nothing, so a client that treats the response as proof of a fresh
158
+ * append would double-count its own edit.
159
+ */
160
+ export const CompanyMdCollabUpdateAcceptedSchema = z.object({
161
+ /** The position this update occupies in the doc's log. */
162
+ seq: CollabSeqString,
163
+ /** True when this exact `(clientKey, clientUpdateId)` was already appended. */
164
+ duplicate: z.boolean(),
165
+ });
166
+ export type CompanyMdCollabUpdateAccepted = z.infer<
167
+ typeof CompanyMdCollabUpdateAcceptedSchema
168
+ >;
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // SSE frames
172
+ //
173
+ // Each member's `type` is the SSE `event:` name, stamped by the client onto the
174
+ // parsed `data:` body (see the module note). Fields the server does not
175
+ // currently emit are optional so a stamped raw frame parses unchanged.
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * `event: collab-update` — one durable collaboration update.
180
+ *
181
+ * The only frame that carries an SSE `id:` line (equal to `seq`), which is what
182
+ * makes a browser's `Last-Event-ID` resume land on a durable cursor. Every other
183
+ * frame is deliberately id-less so it can never advance that cursor.
184
+ */
185
+ export const CompanyMdCollabUpdateEventSchema = z.object({
186
+ type: z.literal("collab-update"),
187
+ /** Frame format version. */
188
+ v: z.literal(1),
189
+ /** The generation this update belongs to. */
190
+ epoch: z.number().int(),
191
+ /** The update's position in the doc's log — mirrors the frame's `id:`. */
192
+ seq: CollabSeqString,
193
+ /** base64 Yjs update bytes. Opaque; the server never interprets them. */
194
+ update: z.string(),
195
+ /** The document. Absent on a single-doc stream, where the URL already names it. */
196
+ docId: z.string().uuid().optional(),
197
+ /** Who produced the update; `null` for server-originated writes. Absent when not attributed. */
198
+ authorUserId: z.string().uuid().nullable().optional(),
199
+ /** Where the update came from. Absent when the server does not attribute origin. */
200
+ origin: z.enum(["client", "bridge", "bootstrap"]).optional(),
201
+ });
202
+ export type CompanyMdCollabUpdateEvent = z.infer<
203
+ typeof CompanyMdCollabUpdateEventSchema
204
+ >;
205
+
206
+ /**
207
+ * `event: collab-presence` — one participant's presence signal, either live or
208
+ * replayed from the roster right after connect (so a joining client's avatar
209
+ * stack fills instantly).
210
+ *
211
+ * `userId` is trustworthy: the server stamps it from the posting session and
212
+ * never takes it from a client body. That is why no client-supplied identity
213
+ * type is published — receiving clients resolve name/avatar/colour from
214
+ * `userId` themselves.
215
+ */
216
+ export const CompanyMdCollabPresenceEventSchema = z.object({
217
+ type: z.literal("collab-presence"),
218
+ /** Frame format version. */
219
+ v: z.literal(1),
220
+ /** The participant — session-stamped server-side; resolve identity from this. */
221
+ userId: z.string().uuid(),
222
+ /** The participant's editor instance (one user may hold several). */
223
+ clientKey: z.string().uuid(),
224
+ /** What that editor instance is doing. */
225
+ status: z.enum(["editing", "viewing"]),
226
+ /** Selection anchor — an encoded Y.RelativePosition blob. */
227
+ anchor: z.string().optional(),
228
+ /** Selection head — an encoded Y.RelativePosition blob. */
229
+ head: z.string().optional(),
230
+ /** Present (`true`) when this clientKey left — explicitly or by TTL expiry. */
231
+ gone: z.literal(true).optional(),
232
+ /** The document. Absent on a single-doc stream, where the URL already names it. */
233
+ docId: z.string().uuid().optional(),
234
+ /** The generation. Absent because presence is not a log position. */
235
+ epoch: z.number().int().optional(),
236
+ });
237
+ export type CompanyMdCollabPresenceEvent = z.infer<
238
+ typeof CompanyMdCollabPresenceEventSchema
239
+ >;
240
+
241
+ /**
242
+ * `event: collab-reset` — the client's cursor was unusable (a superseded
243
+ * generation, older than what compaction still holds, or ahead of anything this
244
+ * generation ever issued). The stream keeps serving from the current tail; the
245
+ * client refetches `/sync` and its local Y.Doc merges the snapshot losslessly,
246
+ * so nothing typed meanwhile is lost.
247
+ */
248
+ export const CompanyMdCollabResetEventSchema = z.object({
249
+ type: z.literal("collab-reset"),
250
+ /** Frame format version. */
251
+ v: z.literal(1),
252
+ /**
253
+ * Why the cursor could not be honored. Currently one of `epoch-mismatch`,
254
+ * `compacted`, `cursor-ahead` — published as an open string, not an enum, so
255
+ * a server that grows a fourth reason does not turn every existing client's
256
+ * reset into a parse failure. The client's response is the same regardless:
257
+ * refetch `/sync`.
258
+ */
259
+ reason: z.string(),
260
+ /** The document's CURRENT generation — what `/sync` will answer from. */
261
+ epoch: z.number().int(),
262
+ /** The tail the stream is serving from while the client re-syncs. */
263
+ resumeSeq: CollabSeqString,
264
+ });
265
+ export type CompanyMdCollabResetEvent = z.infer<
266
+ typeof CompanyMdCollabResetEventSchema
267
+ >;
268
+
269
+ /**
270
+ * `event: access-revoked` — the actor's read grant went away mid-stream. The
271
+ * server closes immediately after; the client must tear down its editor rather
272
+ * than reconnect.
273
+ *
274
+ * Carries an EMPTY body on the wire, so `v` is optional here even though every
275
+ * other frame requires it. Requiring `v` would reject the real frame.
276
+ */
277
+ export const CompanyMdCollabAccessRevokedEventSchema = z.object({
278
+ type: z.literal("access-revoked"),
279
+ /** Frame format version; absent on the wire today. */
280
+ v: z.literal(1).optional(),
281
+ });
282
+ export type CompanyMdCollabAccessRevokedEvent = z.infer<
283
+ typeof CompanyMdCollabAccessRevokedEventSchema
284
+ >;
285
+
286
+ /**
287
+ * `event: connected` — the stream is open. Transport-level, with an empty body;
288
+ * modeled so a client that routes EVERY frame through this union does not treat
289
+ * a normal connect as an unknown event.
290
+ */
291
+ export const CompanyMdCollabConnectedEventSchema = z.object({
292
+ type: z.literal("connected"),
293
+ /** Frame format version; absent on the wire today. */
294
+ v: z.literal(1).optional(),
295
+ });
296
+ export type CompanyMdCollabConnectedEvent = z.infer<
297
+ typeof CompanyMdCollabConnectedEventSchema
298
+ >;
299
+
300
+ /**
301
+ * `event: server_drain` — the server is shutting the stream down deliberately
302
+ * (a rolling restart, not a permission change). The client should reconnect;
303
+ * unlike `access-revoked` this is not terminal.
304
+ *
305
+ * Modeled for the same reason as `connected`: without it, every rolling restart
306
+ * looks like an unknown event to a client that validates all frames. Note the
307
+ * snake_case name — it predates the kebab-case collab frames.
308
+ */
309
+ export const CompanyMdCollabServerDrainEventSchema = z.object({
310
+ type: z.literal("server_drain"),
311
+ /** Why the server is draining; `shutting_down` today. Open string for forward compatibility. */
312
+ reason: z.string(),
313
+ /** Frame format version; absent on the wire today. */
314
+ v: z.literal(1).optional(),
315
+ });
316
+ export type CompanyMdCollabServerDrainEvent = z.infer<
317
+ typeof CompanyMdCollabServerDrainEventSchema
318
+ >;
319
+
320
+ /**
321
+ * Every frame the collaboration stream emits, discriminated on the `type` the
322
+ * client stamps from the SSE `event:` name.
323
+ *
324
+ * Registered as the OpenAPI component `CompanyMdCollabSseEvent`, per the
325
+ * `ChatSseEvent` / `ExecutionSseEvent` precedent.
326
+ */
327
+ export const CompanyMdCollabSseEventSchema = z.discriminatedUnion("type", [
328
+ CompanyMdCollabUpdateEventSchema,
329
+ CompanyMdCollabPresenceEventSchema,
330
+ CompanyMdCollabResetEventSchema,
331
+ CompanyMdCollabAccessRevokedEventSchema,
332
+ CompanyMdCollabConnectedEventSchema,
333
+ CompanyMdCollabServerDrainEventSchema,
334
+ ]);
335
+ export type CompanyMdCollabSseEvent = z.infer<
336
+ typeof CompanyMdCollabSseEventSchema
337
+ >;
@@ -218,6 +218,18 @@ export interface CompanyMdDocCore extends CompanyMdNodeIdentity {
218
218
  * recipient to an `owners` node may still be allowed to request).
219
219
  */
220
220
  readonly canRequestAccess?: boolean;
221
+ /**
222
+ * Whether this doc is served by the real-time collaboration protocol
223
+ * (ADR-CONT-102). When `true` the client may open `/collab/sync` + the collab
224
+ * stream; otherwise it uses the legacy save path.
225
+ *
226
+ * Optional so the app deploy and the contract bump stay order-independent in
227
+ * BOTH directions: an old client reading a new API ignores the field, and a
228
+ * new client reading an old API sees it absent — and an absent value means
229
+ * "not collaborative", so both degrade to the legacy editing path rather than
230
+ * to a broken one.
231
+ */
232
+ readonly collabEnabled?: boolean;
221
233
  }
222
234
 
223
235
  export interface CompanyMdDocCollaborators {
package/src/org/index.ts CHANGED
@@ -227,6 +227,38 @@ export type {
227
227
  CompanyMdContextBankItem,
228
228
  } from "./company-md";
229
229
 
230
+ // Company.md real-time collaboration wire contract: sync/update-accepted
231
+ // responses, the SSE frame union, the Y.Doc shape constant, and the published
232
+ // size limits. Sequences are ALWAYS decimal strings. (ADR-CONT-102)
233
+ export {
234
+ COMPANY_MD_COLLAB_TEXT_KEY,
235
+ COMPANY_MD_COLLAB_MAX_UPDATE_B64_CHARS,
236
+ COMPANY_MD_COLLAB_MAX_DOC_TEXT_BYTES,
237
+ COMPANY_MD_COLLAB_MAX_PRESENCE_POSITION_CHARS,
238
+ CompanyMdCollabSyncLimitsSchema,
239
+ CompanyMdCollabSyncResponseSchema,
240
+ CompanyMdCollabUpdateAcceptedSchema,
241
+ CompanyMdCollabUpdateEventSchema,
242
+ CompanyMdCollabPresenceEventSchema,
243
+ CompanyMdCollabResetEventSchema,
244
+ CompanyMdCollabAccessRevokedEventSchema,
245
+ CompanyMdCollabConnectedEventSchema,
246
+ CompanyMdCollabServerDrainEventSchema,
247
+ CompanyMdCollabSseEventSchema,
248
+ } from "./company-md-collab";
249
+ export type {
250
+ CompanyMdCollabSyncLimits,
251
+ CompanyMdCollabSyncResponse,
252
+ CompanyMdCollabUpdateAccepted,
253
+ CompanyMdCollabUpdateEvent,
254
+ CompanyMdCollabPresenceEvent,
255
+ CompanyMdCollabResetEvent,
256
+ CompanyMdCollabAccessRevokedEvent,
257
+ CompanyMdCollabConnectedEvent,
258
+ CompanyMdCollabServerDrainEvent,
259
+ CompanyMdCollabSseEvent,
260
+ } from "./company-md-collab";
261
+
230
262
  // Sharing and ACL types (PRD-00306). The legacy AccessSource / AccessReason /
231
263
  // EffectiveAccess / EvaluationStep / AccessExplanation were removed with
232
264
  // CompanyMdAccessEvaluator (ADR-BE-392); ShareState.effectiveAccess now carries