@startsimpli/integrations 0.1.5 → 0.2.1

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.
@@ -26,7 +26,24 @@
26
26
  * `tandem` question BETTER than anything `tandem` owns. That is deliberate: it
27
27
  * is the shape of the leak that matters — not a random foreign row, but the one
28
28
  * a ranker would happily pick if nothing stopped it.
29
+ *
30
+ * THE FIXTURES START AT THE STORAGE, NOT AT THE DOMAIN OBJECT. `FIXTURE_TENANT_*`
31
+ * are raw tenant `Entity` and `Relationship` rows in the shape the corpus
32
+ * delivery really writes; `FIXTURE_DOCUMENTS` and friends are DERIVED from them
33
+ * by the real mapper in `./entities`. That inversion is the whole lesson of bd
34
+ * startsim-768w.38.22: hand-written domain fixtures carried an inline
35
+ * `participants` array the pipeline never produces, so a green suite proved
36
+ * nothing about the corpus the product actually renders. If the mapper stops
37
+ * agreeing with the pipeline, everything downstream of here goes red.
29
38
  */
39
+ import {
40
+ indexParticipants,
41
+ toMoneyEvent,
42
+ toParty,
43
+ toSourceDocument,
44
+ } from './entities';
45
+ import type { TenantEntity, TenantRelationship } from './entities';
46
+ import { splitSections } from './text';
30
47
  import type {
31
48
  CorpusApi,
32
49
  CorpusPage,
@@ -34,6 +51,7 @@ import type {
34
51
  DocumentQuery,
35
52
  MoneyEvent,
36
53
  Party,
54
+ PartyRef,
37
55
  SourceDocument,
38
56
  SourceFreshness,
39
57
  } from './types';
@@ -48,19 +66,81 @@ const OWNER_B = 'usr_sam';
48
66
 
49
67
  const scope = (orgId: string, ownerSub: string): CorpusScope => ({ orgId, ownerSub });
50
68
 
51
- const DANA = { id: 'party_dana', name: 'Dana Reed', kind: 'person', email: 'dana.reed@example.com' };
52
- const SAM = { id: 'party_sam', name: 'Sam Ortiz', kind: 'person', email: 'sam.ortiz@example.com' };
53
- const KIM = { id: 'party_kim', name: 'Kim Li', kind: 'person', email: 'kim.li@example.com' };
54
- const ACME = { id: 'party_acme', name: 'Acme Robotics', kind: 'company', email: 'ada@example.com' };
55
- const BETA = { id: 'party_beta', name: 'Beta Foods', kind: 'company', email: 'bo@example.com' };
56
-
57
- export const FIXTURE_PARTIES: Party[] = [
58
- { ...DANA, source: 'granola', sourceLabel: 'Granola', sourceRef: 'dana.reed@example.com', scope: scope(FIXTURE_ORG, OWNER_A) },
59
- { ...SAM, source: 'granola', sourceLabel: 'Granola', sourceRef: 'sam.ortiz@example.com', scope: scope(FIXTURE_ORG, OWNER_A) },
60
- { ...KIM, source: 'granola', sourceLabel: 'Granola', sourceRef: 'kim.li@example.com', scope: scope(FIXTURE_ORG, OWNER_A) },
61
- { ...ACME, source: 'stripe', sourceLabel: 'Stripe', sourceRef: 'cus_RedSuite01Acme', scope: scope(FIXTURE_ORG, OWNER_A) },
62
- { ...BETA, source: 'stripe', sourceLabel: 'Stripe', sourceRef: 'cus_RedSuite02Beta', scope: scope(FIXTURE_ORG, OWNER_A) },
63
- ];
69
+ /** Human names per source key, as an app reads them off the connector catalog. */
70
+ export const FIXTURE_SOURCE_LABELS: Record<string, string> = {
71
+ granola: 'Granola',
72
+ dropbox: 'Dropbox',
73
+ gdrive: 'Google Drive',
74
+ stripe: 'Stripe',
75
+ };
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // THE TENANT'S OWN STORAGE — the shape the pipeline really writes
79
+ // ---------------------------------------------------------------------------
80
+ //
81
+ // EVERYTHING BELOW THIS LINE IS RAW, and everything above the derivation at the
82
+ // bottom is produced from it by the REAL mapper (`./entities`). That is the
83
+ // whole point of this section, and it was added because the previous fixtures
84
+ // were domain objects handed straight to the composers: they carried an inline
85
+ // `participants` array that `declare_canonical()` never writes, so every test
86
+ // passed against a corpus shape production does not produce, and the Library
87
+ // shipped with an empty Parties block on every document the pipeline had ever
88
+ // delivered (bd startsim-768w.38.22).
89
+ //
90
+ // WHAT "REAL" MEANS HERE, checked against the live rehearsal
91
+ // (bd startsim-768w.38.10) and against `startsimpli_connectors.canonical`:
92
+ //
93
+ // • `data` holds the CANONICAL ATTRIBUTES and nothing else, plus `_origin`.
94
+ // A document carries `participant_count` — a NUMBER — and no names.
95
+ // • people are typed EDGES: `authored_by` and `involves` from the document to
96
+ // a `party`, `billed_to` from a money event to a `party`.
97
+ // • `external_id` is always `<connector_key>:<vendor id>`.
98
+ // • a `derived_from` edge exists and points at another DOCUMENT — anything
99
+ // that treats every edge as a person will put a document in the Parties row.
100
+ //
101
+ // TWO KNOWN DEPARTURES, stated rather than hidden. Entity ids here are readable
102
+ // (`not_1d3tmYTlCICgjy`) where a tenant mints UUIDs — the id FORMAT is opaque to
103
+ // every consumer and readable ids make a failing assertion legible. And these
104
+ // rows carry `org_id`/`owner_sub`, which `EntitySerializer` does not echo today
105
+ // (`tenant-starter/apps/api/views.py`), so on a real tenant `scopeOf` falls back
106
+ // to the caller's scope; keeping them here is what lets the scope-guard suite
107
+ // model a server that lies.
108
+
109
+ /** The marker the corpus delivery stamps on every pipeline-written row. */
110
+ const ORIGIN = 'central-connector';
111
+
112
+ /** `<connector_key>:<vendor id>`, exactly as `canonical_external_id()` builds it. */
113
+ const ref = (source: string, vendorId: string) => `${source}:${vendorId}`;
114
+
115
+ function row(options: {
116
+ type: string;
117
+ id: string;
118
+ source: string;
119
+ vendorId: string;
120
+ name?: string;
121
+ data: Record<string, unknown>;
122
+ ownerSub: string;
123
+ createdAt?: string;
124
+ orgId?: string;
125
+ }): TenantEntity {
126
+ return {
127
+ id: options.id,
128
+ entity_type: options.type,
129
+ external_id: ref(options.source, options.vendorId),
130
+ name: options.name ?? '',
131
+ data: { ...options.data, source: options.source, source_ref: options.vendorId, _origin: ORIGIN },
132
+ created_at: options.createdAt ?? null,
133
+ org_id: options.orgId ?? FIXTURE_ORG,
134
+ owner_sub: options.ownerSub,
135
+ };
136
+ }
137
+
138
+ const edge = (relType: string, source: string, target: string): TenantRelationship => ({
139
+ id: `rel_${relType}_${source}_${target}`,
140
+ rel_type: relType,
141
+ source,
142
+ target,
143
+ });
64
144
 
65
145
  const GRANOLA_TRANSCRIPT = [
66
146
  'Dana Reed: Morning — let’s start with the connector.',
@@ -71,186 +151,379 @@ const GRANOLA_TRANSCRIPT = [
71
151
  'Sam Ortiz: Then we hold at four thousand a month and cap the seats at forty.',
72
152
  ].join('\n');
73
153
 
74
- export const FIXTURE_DOCUMENTS: SourceDocument[] = [
75
- {
76
- id: 'not_1d3tmYTlCICgjy',
77
- title: 'Acme <> StartSimpli weekly sync',
78
- body: 'Agreed to ship the connector RED suite first. Acme asked to renew at the same rate for another twelve months.',
79
- sections: [{ key: 'transcript', label: 'Transcript', text: GRANOLA_TRANSCRIPT }],
80
- occurredAt: '2026-01-27T15:30:00Z',
81
- source: 'granola',
82
- sourceLabel: 'Granola',
83
- sourceRef: 'not_1d3tmYTlCICgjy',
84
- url: 'https://notes.granola.ai/d/9f1c2b7e-2a44-4f3e-9a0d-6c1b8e5d4a21',
85
- participants: [DANA, SAM, KIM, ACME],
86
- scope: scope(FIXTURE_ORG, OWNER_A),
87
- ingestedAt: '2026-01-27T16:10:00Z',
88
- },
89
- {
90
- id: 'not_7Kq2ZbVnRt4aXm',
91
- title: 'Design review',
92
- body: 'Reviewed the two-entity split. Note and transcript become separate records so a page of fifty stays a page.',
93
- sections: [],
94
- occurredAt: '2026-01-26T11:00:00Z',
95
- source: 'granola',
96
- sourceLabel: 'Granola',
97
- sourceRef: 'not_7Kq2ZbVnRt4aXm',
98
- url: 'https://notes.granola.ai/d/2b7d41ce-8f05-4a19-b3d2-77e4c0a9f118',
99
- participants: [DANA, SAM],
100
- scope: scope(FIXTURE_ORG, OWNER_A),
101
- ingestedAt: '2026-01-26T11:52:00Z',
102
- },
103
- {
104
- // The upstream fixture has `title: null` and no calendar event. A row with
105
- // no title is a real row and must still render — see `note_detail_no_calendar`.
106
- id: 'not_Pd9wLs3EhY6cQz',
107
- title: 'Untitled granola record',
108
- body: 'Ad-hoc call, no calendar invite.',
109
- sections: [],
110
- occurredAt: '2026-01-25T09:15:00Z',
111
- source: 'granola',
112
- sourceLabel: 'Granola',
113
- sourceRef: 'not_Pd9wLs3EhY6cQz',
114
- participants: [DANA],
115
- scope: scope(FIXTURE_ORG, OWNER_B),
116
- ingestedAt: '2026-01-25T09:40:00Z',
117
- },
118
- {
119
- // `note_detail_no_transcript.json`: the transcript was auto-deleted upstream
120
- // and the summary is empty. A document with no readable text is the emptiest
121
- // real row the Library will ever be handed.
122
- id: 'not_Xa4mB8vTuN2kRe',
123
- title: 'Board prep (transcript auto-deleted upstream)',
124
- body: '',
125
- sections: [],
126
- occurredAt: '2026-01-20T13:00:00Z',
127
- source: 'granola',
128
- sourceLabel: 'Granola',
129
- sourceRef: 'not_Xa4mB8vTuN2kRe',
130
- participants: [DANA],
131
- scope: scope(FIXTURE_ORG, OWNER_A),
132
- ingestedAt: '2026-01-24T08:05:00Z',
133
- },
134
- {
135
- // Dropbox: the second source John named, and the one the generic test is
136
- // written against. Same type, same badge, same detail view, no new code.
137
- id: 'dbx_9812f0',
138
- title: 'Acme Robotics — Master Services Agreement (signed)',
139
- body: 'Signed MSA between StartSimpli and Acme Robotics, effective 1 February 2026, initial term twelve months.',
140
- sections: [
141
- {
142
- key: 'extracted_text',
143
- label: 'Extracted text',
144
- text: [
145
- 'MASTER SERVICES AGREEMENT',
146
- '1. Term. This Agreement begins on 1 February 2026 and continues for twelve (12) months.',
147
- '2. Fees. Customer shall pay USD 4,000 per month, invoiced monthly in advance.',
148
- '3. Seats. The subscription covers up to forty (40) named seats.',
149
- '4. Renewal. This Agreement renews for successive twelve-month terms unless either party gives sixty (60) days notice.',
150
- '5. Termination for convenience requires ninety (90) days written notice.',
151
- ].join('\n'),
152
- },
153
- ],
154
- occurredAt: '2026-02-01T00:00:00Z',
155
- source: 'dropbox',
156
- sourceLabel: 'Dropbox',
157
- sourceRef: 'id:9812f0a4b1',
158
- url: 'https://www.dropbox.example/scl/fi/9812f0a4b1/acme-msa-signed.pdf',
159
- participants: [ACME, DANA],
160
- scope: scope(FIXTURE_ORG, OWNER_A),
161
- ingestedAt: '2026-02-02T06:00:00Z',
162
- },
163
- {
164
- id: 'gdrive_1QpZ',
165
- title: 'Rate card 2026',
166
- body: 'Standard pricing for 2026. Platform tier USD 4,000 per month up to forty seats; overage USD 90 per seat per month.',
167
- sections: [
168
- {
169
- key: 'extracted_text',
170
- label: 'Extracted text',
171
- text: 'Platform: USD 4,000/mo (40 seats). Overage: USD 90/seat/mo. Discounts require VP approval above 15%.',
172
- },
173
- ],
174
- occurredAt: '2026-01-05T00:00:00Z',
175
- source: 'gdrive',
176
- sourceLabel: 'Google Drive',
177
- sourceRef: '1QpZv7t9kR',
178
- url: 'https://docs.google.example/document/d/1QpZv7t9kR',
179
- participants: [DANA],
180
- scope: scope(FIXTURE_ORG, OWNER_A),
181
- ingestedAt: '2026-01-06T07:30:00Z',
182
- },
183
- {
154
+ /**
155
+ * `party` rows. `display_name` rather than `name` inside `data`, because that is
156
+ * the attribute the canonical party schema declares.
157
+ */
158
+ export const FIXTURE_TENANT_PARTIES: TenantEntity[] = [
159
+ row({
160
+ type: 'party', id: 'party_dana', source: 'granola', vendorId: 'dana.reed@example.com',
161
+ name: 'Dana Reed', ownerSub: OWNER_A,
162
+ data: { subtype: 'person', display_name: 'Dana Reed', email: 'dana.reed@example.com', domain: 'example.com' },
163
+ }),
164
+ row({
165
+ type: 'party', id: 'party_sam', source: 'granola', vendorId: 'sam.ortiz@example.com',
166
+ name: 'Sam Ortiz', ownerSub: OWNER_A,
167
+ data: { subtype: 'person', display_name: 'Sam Ortiz', email: 'sam.ortiz@example.com', domain: 'example.com' },
168
+ }),
169
+ row({
170
+ type: 'party', id: 'party_kim', source: 'granola', vendorId: 'kim.li@example.com',
171
+ name: 'Kim Li', ownerSub: OWNER_A,
172
+ data: { subtype: 'person', display_name: 'Kim Li', email: 'kim.li@example.com', domain: 'example.com' },
173
+ }),
174
+ row({
175
+ type: 'party', id: 'party_acme', source: 'stripe', vendorId: 'cus_RedSuite01Acme',
176
+ name: 'Acme Robotics', ownerSub: OWNER_A,
177
+ data: { subtype: 'company', display_name: 'Acme Robotics', email: 'ada@example.com', domain: 'example.com' },
178
+ }),
179
+ row({
180
+ type: 'party', id: 'party_beta', source: 'stripe', vendorId: 'cus_RedSuite02Beta',
181
+ name: 'Beta Foods', ownerSub: OWNER_A,
182
+ data: { subtype: 'company', display_name: 'Beta Foods', email: 'bo@example.com', domain: 'example.com' },
183
+ }),
184
+ ];
185
+
186
+ /**
187
+ * `source_document` rows.
188
+ *
189
+ * The Dropbox and Drive rows are not decoration. bd startsim-768w.38.6's
190
+ * standing test is "would this row, badge, filter or empty state still make
191
+ * sense if the only connected source were Dropbox?", and a fixture set of
192
+ * meeting notes cannot answer it — so a signed contract and a pricing sheet are
193
+ * here, with their long-form text under the key their own connector would use.
194
+ *
195
+ * The `northwind` row is the SCOPE test: it answers a `tandem` question BETTER
196
+ * than anything `tandem` owns, which is the shape of the leak that matters.
197
+ */
198
+ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
199
+ row({
200
+ type: 'source_document', id: 'not_1d3tmYTlCICgjy', source: 'granola',
201
+ vendorId: 'not_1d3tmYTlCICgjy', name: 'Acme <> StartSimpli weekly sync',
202
+ ownerSub: OWNER_A, createdAt: '2026-01-27T16:10:00Z',
203
+ data: {
204
+ subtype: 'meeting',
205
+ title: 'Acme <> StartSimpli weekly sync',
206
+ summary:
207
+ 'Agreed to ship the connector RED suite first. Acme asked to renew at the same rate for another twelve months.',
208
+ occurred_at: '2026-01-27T15:30:00Z',
209
+ url: 'https://notes.granola.ai/d/9f1c2b7e-2a44-4f3e-9a0d-6c1b8e5d4a21',
210
+ // A COUNT, and no names. This is the whole of what the document knows
211
+ // about who was there; everyone else is an edge.
212
+ participant_count: 4,
213
+ transcript: GRANOLA_TRANSCRIPT,
214
+ },
215
+ }),
216
+ row({
217
+ type: 'source_document', id: 'not_7Kq2ZbVnRt4aXm', source: 'granola',
218
+ vendorId: 'not_7Kq2ZbVnRt4aXm', name: 'Design review',
219
+ ownerSub: OWNER_A, createdAt: '2026-01-26T11:52:00Z',
220
+ data: {
221
+ subtype: 'meeting',
222
+ title: 'Design review',
223
+ summary:
224
+ 'Reviewed the two-entity split. Note and transcript become separate records so a page of fifty stays a page.',
225
+ occurred_at: '2026-01-26T11:00:00Z',
226
+ url: 'https://notes.granola.ai/d/2b7d41ce-8f05-4a19-b3d2-77e4c0a9f118',
227
+ participant_count: 2,
228
+ },
229
+ }),
230
+ row({
231
+ // The upstream fixture has `title: null` and no calendar event, so the
232
+ // canonical row carries NEITHER a title attribute nor a name. A row with no
233
+ // title is a real row and must still render.
234
+ type: 'source_document', id: 'not_Pd9wLs3EhY6cQz', source: 'granola',
235
+ vendorId: 'not_Pd9wLs3EhY6cQz', ownerSub: OWNER_B, createdAt: '2026-01-25T09:40:00Z',
236
+ data: {
237
+ subtype: 'meeting',
238
+ summary: 'Ad-hoc call, no calendar invite.',
239
+ occurred_at: '2026-01-25T09:15:00Z',
240
+ participant_count: 1,
241
+ },
242
+ }),
243
+ row({
244
+ // The transcript was auto-deleted upstream and the summary is empty. A
245
+ // document with no readable text is the emptiest real row the Library will
246
+ // ever be handed.
247
+ type: 'source_document', id: 'not_Xa4mB8vTuN2kRe', source: 'granola',
248
+ vendorId: 'not_Xa4mB8vTuN2kRe', name: 'Board prep (transcript auto-deleted upstream)',
249
+ ownerSub: OWNER_A, createdAt: '2026-01-24T08:05:00Z',
250
+ data: {
251
+ subtype: 'meeting',
252
+ title: 'Board prep (transcript auto-deleted upstream)',
253
+ occurred_at: '2026-01-20T13:00:00Z',
254
+ participant_count: 1,
255
+ },
256
+ }),
257
+ row({
258
+ type: 'source_document', id: 'dbx_9812f0', source: 'dropbox', vendorId: 'id:9812f0a4b1',
259
+ name: 'Acme Robotics — Master Services Agreement (signed)',
260
+ ownerSub: OWNER_A, createdAt: '2026-02-02T06:00:00Z',
261
+ data: {
262
+ subtype: 'file',
263
+ title: 'Acme Robotics — Master Services Agreement (signed)',
264
+ summary:
265
+ 'Signed MSA between StartSimpli and Acme Robotics, effective 1 February 2026, initial term twelve months.',
266
+ occurred_at: '2026-02-01T00:00:00Z',
267
+ url: 'https://www.dropbox.example/scl/fi/9812f0a4b1/acme-msa-signed.pdf',
268
+ participant_count: 2,
269
+ extracted_text: [
270
+ 'MASTER SERVICES AGREEMENT',
271
+ '1. Term. This Agreement begins on 1 February 2026 and continues for twelve (12) months.',
272
+ '2. Fees. Customer shall pay USD 4,000 per month, invoiced monthly in advance.',
273
+ '3. Seats. The subscription covers up to forty (40) named seats.',
274
+ '4. Renewal. This Agreement renews for successive twelve-month terms unless either party gives sixty (60) days notice.',
275
+ '5. Termination for convenience requires ninety (90) days written notice.',
276
+ ].join('\n'),
277
+ },
278
+ }),
279
+ row({
280
+ type: 'source_document', id: 'gdrive_1QpZ', source: 'gdrive', vendorId: '1QpZv7t9kR',
281
+ name: 'Rate card 2026', ownerSub: OWNER_A, createdAt: '2026-01-06T07:30:00Z',
282
+ data: {
283
+ subtype: 'file',
284
+ title: 'Rate card 2026',
285
+ summary:
286
+ 'Standard pricing for 2026. Platform tier USD 4,000 per month up to forty seats; overage USD 90 per seat per month.',
287
+ occurred_at: '2026-01-05T00:00:00Z',
288
+ url: 'https://docs.google.example/document/d/1QpZv7t9kR',
289
+ participant_count: 1,
290
+ extracted_text:
291
+ 'Platform: USD 4,000/mo (40 seats). Overage: USD 90/seat/mo. Discounts require VP approval above 15%.',
292
+ },
293
+ }),
294
+ row({
184
295
  // ---- A DIFFERENT CUSTOMER WORKSPACE ----
185
296
  // Deliberately the BEST answer to "what did we agree with Acme on renewal":
186
297
  // more specific, more recent, and full of exactly the terms a ranker loves.
187
- // Nothing but the scope guard stands between it and a very convincing leak.
188
- id: 'nw_contract_44',
189
- title: 'Acme Robotics renewal — Northwind terms (CONFIDENTIAL)',
190
- body: 'Northwind renewed Acme Robotics at USD 2,750 per month with unlimited seats and a ninety-day out. Do not share outside Northwind.',
191
- sections: [
192
- {
193
- key: 'extracted_text',
194
- label: 'Extracted text',
195
- text: 'Renewal rate USD 2,750/mo. Unlimited seats. Ninety (90) day termination for convenience. Acme renewal signed 2026-02-10.',
196
- },
197
- ],
198
- occurredAt: '2026-02-10T00:00:00Z',
199
- source: 'dropbox',
200
- sourceLabel: 'Dropbox',
201
- sourceRef: 'id:nw44a2',
202
- participants: [ACME],
203
- scope: scope(FIXTURE_OTHER_ORG, 'usr_northwind'),
204
- ingestedAt: '2026-02-10T09:00:00Z',
205
- },
298
+ // Nothing but the scope guard stands between it and a convincing leak.
299
+ type: 'source_document', id: 'nw_contract_44', source: 'dropbox', vendorId: 'id:nw44a2',
300
+ name: 'Acme Robotics renewal — Northwind terms (CONFIDENTIAL)',
301
+ ownerSub: 'usr_northwind', orgId: FIXTURE_OTHER_ORG, createdAt: '2026-02-10T09:00:00Z',
302
+ data: {
303
+ subtype: 'file',
304
+ title: 'Acme Robotics renewal — Northwind terms (CONFIDENTIAL)',
305
+ summary:
306
+ 'Northwind renewed Acme Robotics at USD 2,750 per month with unlimited seats and a ninety-day out. Do not share outside Northwind.',
307
+ occurred_at: '2026-02-10T00:00:00Z',
308
+ participant_count: 1,
309
+ extracted_text:
310
+ 'Renewal rate USD 2,750/mo. Unlimited seats. Ninety (90) day termination for convenience. Acme renewal signed 2026-02-10.',
311
+ },
312
+ }),
206
313
  ];
207
314
 
208
- export const FIXTURE_MONEY_EVENTS: MoneyEvent[] = [
209
- {
210
- id: 'in_RedSuiteUsd0001',
211
- subtype: 'invoice',
212
- amount: 500,
213
- currency: 'usd',
214
- status: 'paid',
215
- occurredAt: '2026-08-01T00:00:00Z',
216
- source: 'stripe',
217
- sourceLabel: 'Stripe',
218
- sourceRef: 'in_RedSuiteUsd0001',
219
- url: 'https://invoice.stripe.example/i/in_RedSuiteUsd0001',
220
- description: 'Subscription update proration',
221
- party: BETA,
222
- scope: scope(FIXTURE_ORG, OWNER_A),
223
- },
224
- {
225
- id: 'ch_RedSuiteJpy0001',
226
- subtype: 'charge',
227
- amount: 1500,
228
- currency: 'jpy',
229
- status: 'refunded',
230
- occurredAt: '2026-07-31T00:01:00Z',
231
- source: 'stripe',
232
- sourceLabel: 'Stripe',
233
- sourceRef: 'ch_RedSuiteJpy0001',
234
- description: 'Invoice REDSUITE-0001 — refunded in full',
235
- party: ACME,
236
- scope: scope(FIXTURE_ORG, OWNER_A),
237
- },
238
- {
239
- id: 'sub_RedSuite01Acme',
240
- subtype: 'subscription',
241
- amount: 400000,
242
- currency: 'usd',
243
- status: 'active',
244
- occurredAt: '2026-07-31T00:00:00Z',
245
- source: 'stripe',
246
- sourceLabel: 'Stripe',
247
- sourceRef: 'sub_RedSuite01Acme',
248
- description: 'Platform tier, monthly, forty seats',
249
- party: ACME,
250
- scope: scope(FIXTURE_ORG, OWNER_A),
251
- },
315
+ /**
316
+ * `source_document` rows in the shape THE TRACKED CORPUS REALLY HAS
317
+ * (bd startsim-768w.38.25).
318
+ *
319
+ * These exist because the rows above, faithful as they are to the canonical
320
+ * attributes, are not faithful to the TEXT. On the live tandem tenant every one
321
+ * of the sixteen real records carries its whole content as MARKDOWN under
322
+ * `data.summary` — `##` headings, one per topic of the meeting, and a final
323
+ * `## Next Steps` block of bolded imperatives with the owner in parentheses.
324
+ * There is no `body`, there are no `sections`, and there is no transcript.
325
+ *
326
+ * That structure is not incidental: it is the input the rules pass reads, and a
327
+ * fixture that omitted it would let the extractor pass its tests against a shape
328
+ * production has never once produced — which is the exact failure bd
329
+ * startsim-768w.38.22 was filed for. The content below is invented; the SHAPE is
330
+ * copied from the wire.
331
+ *
332
+ * Three documents, arranged so the hard cases are present rather than assumed:
333
+ * • `mtg_a` and `mtg_b` both raise the SAME commitment in different words —
334
+ * that is the cross-meeting dedupe;
335
+ * • `mtg_c` states that one of them came back — that is the resolution;
336
+ * • two different matters carry near-identical wording, which is the merge
337
+ * that must NOT happen.
338
+ */
339
+ export const FIXTURE_TENANT_TRACKED: TenantEntity[] = [
340
+ row({
341
+ type: 'source_document', id: 'mtg_a', source: 'granola', vendorId: 'not_TrackA0001',
342
+ name: 'Matter review — 2148, 2198, billing numbers',
343
+ ownerSub: OWNER_A, createdAt: '2026-08-01T16:10:00Z',
344
+ data: {
345
+ subtype: 'meeting',
346
+ title: 'Matter review — 2148, 2198, billing numbers',
347
+ occurred_at: '2026-08-01T15:00:00Z',
348
+ participant_count: 3,
349
+ summary: [
350
+ '## Matter Status',
351
+ '',
352
+ '- **2148:** waiting on comments from the primary inventor before drafting continues',
353
+ '- **2198:** substantial feedback received in several rounds; nearly ready to return',
354
+ '',
355
+ '## Billing Numbers',
356
+ '',
357
+ '- Client asked for matter numbers on every report; no field exists for them today',
358
+ '- Proposal is a custom field plus a one-time backfill',
359
+ '',
360
+ '## Next Steps',
361
+ '',
362
+ '- **Chase the primary inventor for comments on 2148** (Rae)',
363
+ '',
364
+ ' Status in the system reads "comments received", which is misleading.',
365
+ '- **Add a billing number field and backfill it once** (Ola)',
366
+ '',
367
+ ' One-time manual entry, automated after that.',
368
+ ].join('\n'),
369
+ },
370
+ }),
371
+ row({
372
+ type: 'source_document', id: 'mtg_b', source: 'granola', vendorId: 'not_TrackB0002',
373
+ name: 'Weekly catch-up — outstanding items',
374
+ ownerSub: OWNER_A, createdAt: '2026-08-04T16:10:00Z',
375
+ data: {
376
+ subtype: 'meeting',
377
+ title: 'Weekly catch-up — outstanding items',
378
+ occurred_at: '2026-08-04T15:00:00Z',
379
+ participant_count: 2,
380
+ summary: [
381
+ '## Open Threads',
382
+ '',
383
+ '- 2148 is still blocked on the inventor; nothing has come back yet',
384
+ '- The parking lot quote arrived with a phased payment breakdown',
385
+ '',
386
+ '## Next Steps',
387
+ '',
388
+ // Same commitment as mtg_a's first bullet, in different words. The
389
+ // dedupe has to see through the rewording or the list doubles.
390
+ '- **Comments on 2148 still need chasing with the inventor** (Rae)',
391
+ '',
392
+ ' Second week running.',
393
+ // Same VERB, different matter. The dedupe must NOT collapse these.
394
+ '- **Chase the primary inventor for comments on 2198**',
395
+ '',
396
+ ' Different matter, same person.',
397
+ ].join('\n'),
398
+ },
399
+ }),
400
+ row({
401
+ type: 'source_document', id: 'mtg_c', source: 'granola', vendorId: 'not_TrackC0003',
402
+ name: 'Matter review — 2148 back from the inventor',
403
+ ownerSub: OWNER_A, createdAt: '2026-08-07T16:10:00Z',
404
+ data: {
405
+ subtype: 'meeting',
406
+ title: 'Matter review — 2148 back from the inventor',
407
+ occurred_at: '2026-08-07T15:00:00Z',
408
+ participant_count: 3,
409
+ summary: [
410
+ '## Matter Status',
411
+ '',
412
+ '- 2148: comments came back from the primary inventor, drafting resumes this week',
413
+ '- 2198: no change',
414
+ '',
415
+ '## Next Steps',
416
+ '',
417
+ '- **Resume drafting on 2148 now that comments are in** (Rae)',
418
+ '',
419
+ ' Target a first pass by the end of the week.',
420
+ ].join('\n'),
421
+ },
422
+ }),
423
+ ];
424
+
425
+ /** `money_event` rows — amounts in MINOR units, under the declared attribute. */
426
+ export const FIXTURE_TENANT_MONEY_EVENTS: TenantEntity[] = [
427
+ row({
428
+ type: 'money_event', id: 'in_RedSuiteUsd0001', source: 'stripe', vendorId: 'in_RedSuiteUsd0001',
429
+ name: 'Invoice in_RedSuiteUsd0001', ownerSub: OWNER_A,
430
+ data: {
431
+ subtype: 'invoice', amount_minor: 500, currency: 'usd', status: 'paid',
432
+ occurred_at: '2026-08-01T00:00:00Z',
433
+ url: 'https://invoice.stripe.example/i/in_RedSuiteUsd0001',
434
+ description: 'Subscription update proration',
435
+ },
436
+ }),
437
+ row({
438
+ type: 'money_event', id: 'ch_RedSuiteJpy0001', source: 'stripe', vendorId: 'ch_RedSuiteJpy0001',
439
+ name: 'Charge ch_RedSuiteJpy0001', ownerSub: OWNER_A,
440
+ data: {
441
+ subtype: 'charge', amount_minor: 1500, currency: 'jpy', status: 'refunded',
442
+ occurred_at: '2026-07-31T00:01:00Z',
443
+ description: 'Invoice REDSUITE-0001 — refunded in full',
444
+ },
445
+ }),
446
+ row({
447
+ type: 'money_event', id: 'sub_RedSuite01Acme', source: 'stripe', vendorId: 'sub_RedSuite01Acme',
448
+ name: 'Subscription sub_RedSuite01Acme', ownerSub: OWNER_A,
449
+ data: {
450
+ subtype: 'subscription', amount_minor: 400000, currency: 'usd', status: 'active',
451
+ occurred_at: '2026-07-31T00:00:00Z',
452
+ description: 'Platform tier, monthly, forty seats',
453
+ },
454
+ }),
252
455
  ];
253
456
 
457
+ /**
458
+ * The graph. WHERE THE PEOPLE ACTUALLY LIVE.
459
+ *
460
+ * The `derived_from` row is deliberate: it is a real canonical edge whose target
461
+ * is a DOCUMENT, so anything that treats every edge as a person puts a meeting
462
+ * note in the Parties block and fails here rather than in a browser.
463
+ */
464
+ export const FIXTURE_TENANT_RELATIONSHIPS: TenantRelationship[] = [
465
+ edge('authored_by', 'not_1d3tmYTlCICgjy', 'party_dana'),
466
+ edge('involves', 'not_1d3tmYTlCICgjy', 'party_sam'),
467
+ edge('involves', 'not_1d3tmYTlCICgjy', 'party_kim'),
468
+ edge('involves', 'not_1d3tmYTlCICgjy', 'party_acme'),
469
+ edge('authored_by', 'not_7Kq2ZbVnRt4aXm', 'party_dana'),
470
+ edge('involves', 'not_7Kq2ZbVnRt4aXm', 'party_sam'),
471
+ edge('authored_by', 'not_Pd9wLs3EhY6cQz', 'party_dana'),
472
+ edge('authored_by', 'not_Xa4mB8vTuN2kRe', 'party_dana'),
473
+ edge('involves', 'dbx_9812f0', 'party_acme'),
474
+ edge('involves', 'dbx_9812f0', 'party_dana'),
475
+ edge('involves', 'gdrive_1QpZ', 'party_dana'),
476
+ edge('involves', 'nw_contract_44', 'party_acme'),
477
+ edge('derived_from', 'not_1d3tmYTlCICgjy', 'not_7Kq2ZbVnRt4aXm'),
478
+ edge('billed_to', 'in_RedSuiteUsd0001', 'party_beta'),
479
+ edge('billed_to', 'ch_RedSuiteJpy0001', 'party_acme'),
480
+ edge('billed_to', 'sub_RedSuite01Acme', 'party_acme'),
481
+ ];
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // DERIVED THROUGH THE REAL MAPPER
485
+ // ---------------------------------------------------------------------------
486
+ //
487
+ // Not hand-written domain objects. If `./entities` stops reading the storage the
488
+ // pipeline writes, these go wrong and every suite that consumes them goes red —
489
+ // which is the property that was missing when the Parties block shipped empty.
490
+
491
+ const FALLBACK_SCOPE = scope(FIXTURE_ORG, OWNER_A);
492
+
493
+ export const FIXTURE_PARTIES: Party[] = FIXTURE_TENANT_PARTIES.map((entity) =>
494
+ toParty(entity, FALLBACK_SCOPE),
495
+ );
496
+
497
+ /** documentId / moneyEventId -> the people on it, resolved from the edges. */
498
+ export const FIXTURE_PARTICIPANTS: Map<string, PartyRef[]> = indexParticipants(
499
+ FIXTURE_TENANT_RELATIONSHIPS,
500
+ FIXTURE_PARTIES,
501
+ );
502
+
503
+ export const FIXTURE_DOCUMENTS: SourceDocument[] = FIXTURE_TENANT_DOCUMENTS.map((entity) =>
504
+ toSourceDocument(
505
+ entity,
506
+ FALLBACK_SCOPE,
507
+ FIXTURE_SOURCE_LABELS,
508
+ FIXTURE_PARTICIPANTS.get(String(entity.id)) ?? [],
509
+ ),
510
+ );
511
+
512
+ /** Derived through the SAME mapper, so the markdown arrives as `body` exactly the
513
+ * way `data.summary` does on the wire. */
514
+ export const FIXTURE_TRACKED_DOCUMENTS: SourceDocument[] = FIXTURE_TENANT_TRACKED.map((entity) =>
515
+ toSourceDocument(entity, FALLBACK_SCOPE, FIXTURE_SOURCE_LABELS, []),
516
+ );
517
+
518
+ export const FIXTURE_MONEY_EVENTS: MoneyEvent[] = FIXTURE_TENANT_MONEY_EVENTS.map((entity) =>
519
+ toMoneyEvent(
520
+ entity,
521
+ FALLBACK_SCOPE,
522
+ FIXTURE_SOURCE_LABELS,
523
+ FIXTURE_PARTICIPANTS.get(String(entity.id)) ?? [],
524
+ ),
525
+ );
526
+
254
527
  /**
255
528
  * One row per connected source, including one that is broken and one that has
256
529
  * never delivered — because a freshness strip that only ever says "ok" has never
@@ -449,6 +722,90 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
449
722
  };
450
723
  }
451
724
 
725
+ // ---------------------------------------------------------------------------
726
+ // a fake TENANT, one level lower
727
+ // ---------------------------------------------------------------------------
728
+
729
+ /**
730
+ * A `CorpusHttp` that answers like the tenant data plane itself.
731
+ *
732
+ * `createFixtureCorpusApi` above fakes the CONTRACT, which is the right thing to
733
+ * hand a composer. This fakes the WIRE, which is the only way to test the
734
+ * adapter that turns storage into the contract — the seam where the Parties
735
+ * block was lost (bd startsim-768w.38.22). It reproduces the tenant's real
736
+ * behaviour, including the parts that are inconvenient:
737
+ *
738
+ * • `?type=` is the ONLY filter honoured — `search`, `source` and `party` are
739
+ * accepted and ignored, exactly as `EntityViewSet` does today;
740
+ * • `page_size` is IGNORED (`PageNumberPagination` with no
741
+ * `page_size_query_param`), so a caller asking for 200 gets `pageSize`;
742
+ * • `/api/v1/relationships/` returns every edge in the org, unfiltered,
743
+ * because the tenant has no `?source=` on it either.
744
+ *
745
+ * Every request is recorded, so "did the browser filter it" and "how many round
746
+ * trips did one screen cost" are both assertable.
747
+ */
748
+ export function createFixtureCorpusHttp(options: {
749
+ entities?: TenantEntity[];
750
+ relationships?: TenantRelationship[];
751
+ /** Rows per page, matching the tenant's own `PAGE_SIZE`. */
752
+ pageSize?: number;
753
+ /** Endpoints to fail, so the fail-soft paths are exercised. */
754
+ missing?: string[];
755
+ onRequest?: (path: string, params: Record<string, unknown>) => void;
756
+ } = {}) {
757
+ const entities = options.entities ?? [
758
+ ...FIXTURE_TENANT_DOCUMENTS,
759
+ ...FIXTURE_TENANT_PARTIES,
760
+ ...FIXTURE_TENANT_MONEY_EVENTS,
761
+ ];
762
+ const relationships = options.relationships ?? FIXTURE_TENANT_RELATIONSHIPS;
763
+ const pageSize = options.pageSize ?? 50;
764
+ const missing = options.missing ?? [];
765
+ const requests: Array<{ path: string; params: Record<string, unknown> }> = [];
766
+
767
+ function slice<T>(rows: T[], params: Record<string, unknown>) {
768
+ const page = Number(params.page ?? 1) || 1;
769
+ const start = (page - 1) * pageSize;
770
+ return {
771
+ count: rows.length,
772
+ next: start + pageSize < rows.length ? `?page=${page + 1}` : null,
773
+ previous: page > 1 ? `?page=${page - 1}` : null,
774
+ results: rows.slice(start, start + pageSize),
775
+ };
776
+ }
777
+
778
+ const http = {
779
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
780
+ const params = init?.params ?? {};
781
+ requests.push({ path, params });
782
+ options.onRequest?.(path, params);
783
+
784
+ if (missing.some((fragment) => path.includes(fragment))) {
785
+ throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
786
+ }
787
+ if (path.includes('relationships')) {
788
+ return slice(relationships, params) as T;
789
+ }
790
+ if (path.includes('entities')) {
791
+ const id = /entities\/(.+)$/.exec(path)?.[1];
792
+ if (id) {
793
+ const found = entities.find((entity) => String(entity.id) === decodeURIComponent(id));
794
+ if (!found) throw Object.assign(new Error('not found'), { status: 404 });
795
+ return found as T;
796
+ }
797
+ const type = params.type ? String(params.type) : '';
798
+ // `search`/`source`/`party` are deliberately NOT applied.
799
+ const rows = type ? entities.filter((entity) => entity.entity_type === type) : entities;
800
+ return slice(rows, params) as T;
801
+ }
802
+ throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
803
+ },
804
+ };
805
+
806
+ return { http, requests };
807
+ }
808
+
452
809
  /**
453
810
  * A `CorpusLlm` that answers WITHOUT a provider, by quoting the documents it was
454
811
  * handed and citing every one it quoted.
@@ -458,6 +815,65 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
458
815
  * `live: false` from `describe()`, and every surface renders that, so a run
459
816
  * powered by this can never be mistaken on screen for a run powered by a model.
460
817
  */
818
+ /**
819
+ * A `CorpusExtractorLlm` that extracts WITHOUT a provider, deterministically.
820
+ *
821
+ * It stands in for the model's actual job — reading the parts of a document the
822
+ * summariser did not promote into its own Next Steps list — by taking the first
823
+ * substantial bullet under each non-outcome heading. That is not what a model
824
+ * does, and it is not meant to be: what it has to be is DETERMINISTIC and to
825
+ * echo the ids it was given, so the identity, dedupe, resolution and scope
826
+ * machinery can be driven end to end in vitest with no API key and no variance.
827
+ *
828
+ * It reports `live: false`, and every surface renders that, so a run powered by
829
+ * this can never be mistaken on screen for a run powered by a model.
830
+ */
831
+ export function createFixtureExtractor(): import('./types').CorpusExtractorLlm {
832
+ return {
833
+ describe: () => ({ provider: 'fixture', model: 'fixture-rules', live: false }),
834
+ async extract(input) {
835
+ const items: import('./types').ExtractionDraft['items'] = [];
836
+ const resolutions: NonNullable<import('./types').ExtractionDraft['resolutions']> = [];
837
+
838
+ for (const document of input.documents) {
839
+ const already = new Set(input.documents.flatMap((entry) => entry.alreadyFound));
840
+ for (const section of splitSections(document.text)) {
841
+ if (!section.heading || /next\s*steps?|action\s*items?|follow/i.test(section.heading)) continue;
842
+ const bullet = section.body
843
+ .split('\n')
844
+ .map((line) => line.replace(/^\s{0,3}[-*]\s+/, '').replace(/\*\*/g, '').trim())
845
+ .find((line) => line.split(/\s+/).length >= 5 && !already.has(line));
846
+ if (!bullet) continue;
847
+
848
+ // A statement that something has come back is a RESOLUTION, not a new
849
+ // item — which is the one distinction the model exists to make.
850
+ if (/came back|now (?:in|complete|done)|has landed/i.test(bullet)) {
851
+ resolutions.push({
852
+ documentId: document.id,
853
+ subject: /\b\d{4}(?:-\d{2})?\b/.exec(bullet)?.[0] ?? section.heading,
854
+ statement: bullet,
855
+ quote: bullet,
856
+ outcome: 'done',
857
+ });
858
+ continue;
859
+ }
860
+
861
+ items.push({
862
+ documentId: document.id,
863
+ subtype: 'decision',
864
+ title: bullet.slice(0, 120),
865
+ detail: '',
866
+ status: 'open',
867
+ subject: /\b\d{4}(?:-\d{2})?\b/.exec(bullet)?.[0] ?? section.heading,
868
+ quote: bullet,
869
+ });
870
+ }
871
+ }
872
+ return { items, resolutions };
873
+ },
874
+ };
875
+ }
876
+
461
877
  export function createFixtureLlm(): import('./types').CorpusLlm {
462
878
  return {
463
879
  describe: () => ({ provider: 'fixture', model: 'fixture-echo', live: false }),