@startsimpli/integrations 0.2.30 → 0.2.32

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": "@startsimpli/integrations",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -813,9 +813,15 @@ describe('ConnectionCard — a connected account has settings', () => {
813
813
  * — "there is no id here to substitute, no slug to swap". A client that
814
814
  * takes a slug re-introduces, in the types, the exact affordance that URL
815
815
  * space was designed not to have.
816
- * * NO PATCH. `MyConnectionDetailView` serves GET and DELETE only, so the
817
- * capability picker is informational and there is no Save beside it, and no
818
- * settings form.
816
+ * * NO GENERAL PATCH. `MyConnectionDetailView` still serves GET and DELETE
817
+ * only, so a card's NAME and what it may DO stay the foundry's and the
818
+ * capability picker is informational with no Save beside it. The one write
819
+ * verb the member surface grew is `updateConnectionConfig` (bd
820
+ * startsim-sfms2) — a personal connection's config is the member's own
821
+ * privacy boundary — and it is a separate verb precisely so opening it does
822
+ * not open the capability Save. This stub does not carry it, and this
823
+ * connector declares no config fields, so no settings control renders here;
824
+ * `member-connection-scope.test.tsx` drives the surface that does.
819
825
  * * NO targets, choices, actions or tokens. Those are foundry-level and stay
820
826
  * on the control-plane router, so the Workflows control is not rendered.
821
827
  * * THE CATALOG IS THE SERVER'S ANSWER. `me/catalog/` returns only what the
@@ -241,3 +241,228 @@ describe('the Library strip, rendered', () => {
241
241
  expect(screen.queryByText(/Dropbox/)).not.toBeInTheDocument();
242
242
  });
243
243
  });
244
+
245
+ /**
246
+ * THE COUNT ON THE CARD IS A CORPUS FACT, AND IT WAS NEVER READ
247
+ * (bd startsim-jevtu RED, first filed as startsim-o0o6o.1).
248
+ *
249
+ * REPORTED ON THE LIVE TANDEM TENANT, 2026-08-26: Dropbox, Fathom, Granola and
250
+ * n8n each read "0 records" while the list directly beneath the strip read
251
+ * "208 records · page 1 of 11". `listSources()` computed the number as
252
+ * `connection.document_count ?? connection.documentCount ?? 0` and NO
253
+ * connections endpoint sends either spelling — `SubtreeConnectionsView`
254
+ * serialises id, connector_key, connector_name, name, status, account_label,
255
+ * the three timestamps, consecutive_failures and check_error, and no count. So
256
+ * every card has read `0` for every foundry since the strip shipped.
257
+ *
258
+ * WHY THE NUMBER COMES FROM THE TENANT CORPUS AND NOT FROM CENTRAL.
259
+ *
260
+ * 1. Central does not have the rows. The corpus lives in the fork; central
261
+ * delivers INTO it (`corpus_delivery.py`) and keeps no copy. A count
262
+ * annotated onto the connection would be a count of records central
263
+ * believes it shipped — a different number from the one in the list under
264
+ * the strip the moment an upsert collapses two records, a retention rule
265
+ * trims, or a delivery half-lands. A strip that disagrees with the list
266
+ * beneath it is the exact fault class this bead is about; a delivery
267
+ * counter would re-open it in a new spelling.
268
+ *
269
+ * 2. Every other fact on the card already comes from central's connection
270
+ * row — status, the three timestamps, the failure streak. The corpus count
271
+ * is the ONLY corpus-side fact on a strip that sits on top of the corpus,
272
+ * and that is what makes it worth its round trip: "central says it synced
273
+ * an hour ago, the corpus holds nothing from it" is the delivery-hop
274
+ * failure, and no connection field can say it.
275
+ *
276
+ * WHY THIS IS NOT A SCAN. The tenant's own `/api/v1/capabilities/` declares
277
+ * `"counts": "page-count"` and says in as many words that a focus card's
278
+ * "12 of 40 done" is "two `page_size=1` reads off the paginator's own `count` —
279
+ * said here so a client does not go looking for a group_by endpoint that
280
+ * deliberately does not exist". One narrowed request per source, asking for one
281
+ * row, reading the envelope's `count`: one SQL COUNT with the filter pushed
282
+ * down, which is the sanctioned idiom on this platform and not eleven pages.
283
+ *
284
+ * WHY IT IS UNBLOCKED. bd startsim-768w.38.12 shipped `?source=` and verified it
285
+ * on this very tenant ("source=granola 16 / stripe 0"). The vocabulary lines up
286
+ * by construction, not by luck: central serialises `connector_key` as
287
+ * `connection.connector.key`, `corpus_delivery` stamps `source_key =
288
+ * connector_cls.key` onto every projected record, and the catalog reconciler
289
+ * creates each `DestinationProvider` row AT the connector class's own key. The
290
+ * canonical mapping says it outright — "`source` is the connector key, so
291
+ * 'everything from Granola' is one filter".
292
+ *
293
+ * AND WHY `null` HAS TO EXIST. `0` today means "we never asked". After this it
294
+ * must mean "we asked and the answer is none" — otherwise the fix replaces a
295
+ * lie with a lie that happens to be right some of the time. A build that cannot
296
+ * narrow by source returns the WHOLE corpus for every card, which would print
297
+ * "208 records" four times over; the server's own `applied_filters` is what
298
+ * tells the two apart, and a count that cannot be trusted is not printed.
299
+ */
300
+
301
+ /** Per-source totals as a tenant that really does honour `?source=` reports them. */
302
+ function corpusOver(
303
+ rows: Array<Record<string, unknown>>,
304
+ counts: Record<string, number>,
305
+ options: { reports?: 'snake' | 'camel' | 'none' } = {},
306
+ ): Recorder {
307
+ const reports = options.reports ?? 'snake';
308
+ const whole = Object.values(counts).reduce((sum, n) => sum + n, 0);
309
+ const calls: Array<{ path: string; params?: Record<string, unknown> }> = [];
310
+ return {
311
+ calls,
312
+ http: {
313
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
314
+ const params = init?.params ?? {};
315
+ calls.push({ path, params });
316
+ if (path.includes('connections')) {
317
+ return { count: rows.length, next: null, previous: null, results: rows } as T;
318
+ }
319
+ const source = params.source === undefined ? '' : String(params.source);
320
+ // A build with no `?source=` narrows NOTHING and hands back the whole
321
+ // corpus — the failure mode that would print 208 on every card.
322
+ const narrowed = reports !== 'none' && Boolean(source);
323
+ const count = narrowed ? (counts[source] ?? 0) : whole;
324
+ const applied = narrowed ? ['type', 'source'] : ['type'];
325
+ return {
326
+ count,
327
+ next: null,
328
+ previous: null,
329
+ // Faithful to the wire: a `page_size=1` read over a non-empty match
330
+ // returns a row. A count read must not need it.
331
+ results: count > 0 ? [{ id: 'row', entity_type: 'source_document', data: {} }] : [],
332
+ ...(reports === 'snake' ? { applied_filters: applied } : {}),
333
+ ...(reports === 'camel' ? { appliedFilters: applied } : {}),
334
+ } as T;
335
+ },
336
+ },
337
+ };
338
+ }
339
+
340
+ /** A second shared connection, one that has delivered nothing — as tandem has. */
341
+ const SHARED_N8N = {
342
+ id: 'shared-n8n',
343
+ connector_key: 'n8n',
344
+ connector_name: 'n8n',
345
+ status: 'connected',
346
+ check_error: '',
347
+ last_synced_at: AN_HOUR_AGO,
348
+ last_attempt_at: AN_HOUR_AGO,
349
+ last_checked_at: AN_HOUR_AGO,
350
+ consecutive_failures: 0,
351
+ };
352
+
353
+ const TANDEM_COUNTS = { granola: 208, n8n: 0 };
354
+
355
+ function countedSources(options: { reports?: 'snake' | 'camel' | 'none' } = {}) {
356
+ const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS, options);
357
+ return createCorpusApi({ http, scope: SCOPE }).listSources();
358
+ }
359
+
360
+ describe('listSources — the record count is counted, not read off the connection', () => {
361
+ it('reports what each source actually put in the corpus', async () => {
362
+ const sources = await countedSources();
363
+ const byKey = Object.fromEntries(sources.map((source) => [source.source, source.documentCount]));
364
+
365
+ // The whole bug in one assertion: Granola holds 208 records and the card
366
+ // said 0, because it was reading a field central has never sent.
367
+ expect(byKey).toEqual({ granola: 208, n8n: 0 });
368
+ });
369
+
370
+ it('counts DOCUMENTS, so the strip agrees with the list beneath it', async () => {
371
+ // A count over every entity type would sweep in parties, money events and
372
+ // commitments and overshoot the list it sits above. Same type key, same
373
+ // read gate, same server — the two agree by construction or not at all.
374
+ const recorder = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
375
+ await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
376
+
377
+ const reads = recorder.calls.filter((call) => call.path.includes('entities'));
378
+ expect(reads.length).toBeGreaterThan(0);
379
+ for (const call of reads) expect(call.params?.type).toBe('source_document');
380
+ });
381
+
382
+ it('asks for ONE row per source and never pages the corpus', async () => {
383
+ // "Eleven pages per card" is the same wrong answer with worse latency. The
384
+ // count comes off the paginator's own envelope, which costs one row.
385
+ const recorder = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
386
+ await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
387
+
388
+ const reads = recorder.calls.filter((call) => call.path.includes('entities'));
389
+ expect(reads).toHaveLength(2);
390
+ expect(reads.map((call) => call.params?.source).sort()).toEqual(['granola', 'n8n']);
391
+ for (const call of reads) expect(call.params?.page_size).toBe(1);
392
+ });
393
+
394
+ it('counts a vendor once when two connections name it', async () => {
395
+ // A card is per CONNECTION, a count is per SOURCE. Central's page unions the
396
+ // foundry's shared connections with the member's own, so one vendor can
397
+ // arrive twice — and whose account it is does not narrow the corpus. Two
398
+ // reads for one answer, and both cards must report that answer rather than
399
+ // half of it each.
400
+ const MY_GRANOLA = { ...SHARED_GRANOLA, id: 'my-granola', owner: 'qosha@debugg.ai' };
401
+ const recorder = corpusOver([SHARED_GRANOLA, MY_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
402
+ const sources = await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
403
+
404
+ const reads = recorder.calls.filter((call) => call.path.includes('entities'));
405
+ expect(reads.filter((call) => call.params?.source === 'granola')).toHaveLength(1);
406
+ expect(
407
+ sources.filter((source) => source.source === 'granola').map((s) => s.documentCount),
408
+ ).toEqual([208, 208]);
409
+ });
410
+
411
+ it('says 0 because the corpus answered 0, not because nobody asked', async () => {
412
+ const n8n = (await countedSources()).find((source) => source.source === 'n8n');
413
+
414
+ // n8n declares no corpus mapping at all (`corpus_delivery` returns early on
415
+ // it), so zero is the true answer — and it has to be distinguishable from
416
+ // the count nobody went and got.
417
+ expect(n8n?.documentCount).toBe(0);
418
+ });
419
+
420
+ it('reads the server’s report in either spelling', async () => {
421
+ // `@startsimpli/api` camelCases response keys in the browser and a plain
422
+ // server-side fetch does not. Reading one spelling made the deployed
423
+ // Tracker report the server was not filtering while it verifiably was; the
424
+ // same seam decides here whether a real count is thrown away.
425
+ const sources = await countedSources({ reports: 'camel' });
426
+
427
+ expect(sources.find((source) => source.source === 'granola')?.documentCount).toBe(208);
428
+ });
429
+
430
+ it('refuses to print the whole corpus when the server could not narrow by source', async () => {
431
+ // The regression this fix could otherwise introduce: an older fork ignores
432
+ // `?source=`, hands back the total, and every card reads "208 records". The
433
+ // server says what it applied; a count it did not narrow is not a count.
434
+ const sources = await countedSources({ reports: 'none' });
435
+
436
+ expect(sources.map((source) => source.documentCount)).toEqual([null, null]);
437
+ });
438
+ });
439
+
440
+ describe('the Library strip, counting', () => {
441
+ const strip = async () =>
442
+ within(await screen.findByRole('region', { name: 'Source freshness' }));
443
+
444
+ it('prints the count the corpus actually holds', async () => {
445
+ const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
446
+ render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
447
+
448
+ expect((await strip()).getByText(/208 records/)).toBeInTheDocument();
449
+ });
450
+
451
+ it('says a source has delivered nothing, in words', async () => {
452
+ const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS);
453
+ render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
454
+
455
+ expect((await strip()).getByText(/No records yet/)).toBeInTheDocument();
456
+ });
457
+
458
+ it('prints no number at all when the count is unknown', async () => {
459
+ const { http } = corpusOver([SHARED_GRANOLA, SHARED_N8N], TANDEM_COUNTS, { reports: 'none' });
460
+ render(<LibraryPage api={createCorpusApi({ http, scope: SCOPE })} scope={SCOPE} />);
461
+
462
+ const shown = await strip();
463
+ // The freshness half of the card is unaffected — a count it could not get
464
+ // costs the reader the count, not the sentence.
465
+ expect(shown.getAllByText(/synced/).length).toBeGreaterThan(0);
466
+ expect(shown.queryByText(/record/)).not.toBeInTheDocument();
467
+ });
468
+ });
@@ -0,0 +1,314 @@
1
+ /**
2
+ * RED: a member's own scope is theirs to set, on screen (bd startsim-sirp6).
3
+ *
4
+ * Quinn, 2026-08-27: "if people connect their dropbox, they will expect to be
5
+ * able to configure what they have access to. some may want root which would be
6
+ * everything, some may want to configure a subfolder. but our system should
7
+ * enable and enforce either / both."
8
+ *
9
+ * `connections.test.tsx` next door pins that personal mode has no PATCH, and
10
+ * that was right about what the router served. It is not right about a PERSONAL
11
+ * connection's config, which is not a pipeline setting: it decides which of the
12
+ * MEMBER's own files this platform reads. So the member surface grows exactly
13
+ * ONE write verb — `PATCH connections/me/<id>/config/`, beside `credential/`
14
+ * rather than on the detail route — and this file pins what that must look like
15
+ * from the browser.
16
+ *
17
+ * THREE THINGS IT IS EASY TO SHIP WITHOUT.
18
+ *
19
+ * 1. THE CONTROL HAS TO RENDER. `ConnectionCard` gates Settings on a verb
20
+ * EXISTING on the bound api. A member write endpoint that the composer never
21
+ * reaches is a change every backend test passes and no member can use, so the
22
+ * first assertion here is positive: the control is on the card.
23
+ * 2. THE CAPABILITY SAVE MUST STAY ABSENT. The narrow verb is narrow. What a
24
+ * member's account may DO is still the foundry's, and the picker stays
25
+ * informational — which is why the composer must not gate both controls on
26
+ * one verb.
27
+ * 3. THE MEMBER MUST BE TOLD THE WALK RESTARTS, and the SERVER must be what says
28
+ * it. This composer refuses to name a vendor and cannot know which fields
29
+ * bear the scope; only the connector knows. So the sentence arrives in the
30
+ * response and is rendered verbatim.
31
+ */
32
+ import * as React from 'react';
33
+ import { render, screen, waitFor, within } from '@testing-library/react';
34
+ import userEvent from '@testing-library/user-event';
35
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
36
+
37
+ import { ConnectionsPage } from '../components/pages/ConnectionsPage';
38
+ import { CredentialSchemaForm } from '../components/CredentialSchemaForm';
39
+ import type { MemberConnectionsApi } from '../components/pages/types';
40
+ import { connectionsMe } from '../api/connections';
41
+ import { setApiBase, setAuthTokenProvider } from '../api/_fetch';
42
+
43
+ /**
44
+ * A connector whose config IS somebody's privacy boundary: a folder, a switch
45
+ * that widens it, and a claim on the widest setting of all.
46
+ *
47
+ * Not Dropbox. This composer knows about FIELDS and nothing about vendors, so a
48
+ * fixture that named one would be asserting Dropbox rather than the framework.
49
+ */
50
+ const SCOPE_FIELDS = [
51
+ {
52
+ key: 'folder', label: 'Folder', type: 'string', required: false,
53
+ help: 'Leave blank for everything', placeholder: '', pattern: null, options: null,
54
+ default: '', write_only: false, mask: 'none', docs_url: '',
55
+ },
56
+ {
57
+ key: 'recursive', label: 'Include sub-folders', type: 'boolean', required: false,
58
+ help: 'Off by default', placeholder: '', pattern: null, options: null,
59
+ default: false, write_only: false, mask: 'none', docs_url: '',
60
+ },
61
+ {
62
+ key: 'whole_account_acknowledged_by', label: 'Whole-account read claimed by',
63
+ type: 'acknowledgement', required: false, help: 'Required to read everything',
64
+ placeholder: '', pattern: null, options: null, default: null,
65
+ write_only: false, mask: 'none', docs_url: '',
66
+ },
67
+ ];
68
+
69
+ const MEMBER_CATALOG = {
70
+ results: [
71
+ {
72
+ key: 'filesvendor', name: 'File Vendor', description: 'Files', logo_url: '',
73
+ docs_url: '', capabilities: ['poll'], auth_mode: 'api_key',
74
+ credential_schema: {
75
+ version: 1,
76
+ fields: [{
77
+ key: 'api_key', label: 'API key', type: 'secret', required: true, help: '',
78
+ placeholder: '', pattern: null, options: null, default: null,
79
+ write_only: true, mask: 'last4', docs_url: '',
80
+ }],
81
+ },
82
+ config_schema: { version: 1, fields: SCOPE_FIELDS },
83
+ record_types: [], supports_webhooks: false,
84
+ availability: { available: true, reason: '' },
85
+ },
86
+ ],
87
+ };
88
+
89
+ const MY_CONNECTIONS = {
90
+ results: [
91
+ {
92
+ id: 'm1', public_id: 'mpub1', name: 'My files', connector_key: 'filesvendor',
93
+ connector_name: 'File Vendor', external_account_id: '',
94
+ account_label: 'me@example.com', capabilities_enabled: ['poll'],
95
+ config: { folder: '/Contracts', recursive: false }, status: 'connected',
96
+ health: 'ok', auth_mode: 'api_key',
97
+ },
98
+ ],
99
+ permissions: { view: true, manage: true, issue_tokens: false },
100
+ };
101
+
102
+ function stubMemberApi(overrides: Partial<MemberConnectionsApi> = {}): MemberConnectionsApi {
103
+ return {
104
+ connectorCatalog: vi.fn().mockResolvedValue(MEMBER_CATALOG),
105
+ connections: vi.fn().mockResolvedValue(MY_CONNECTIONS),
106
+ connect: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
107
+ checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
108
+ rotateCredential: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
109
+ disconnect: vi.fn().mockResolvedValue(undefined),
110
+ connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
111
+ updateConnectionConfig: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
112
+ ...overrides,
113
+ } as MemberConnectionsApi;
114
+ }
115
+
116
+ async function myCard(api: MemberConnectionsApi = stubMemberApi()) {
117
+ render(<ConnectionsPage mode="personal" api={api} />);
118
+ const card = await screen.findByText('My files');
119
+ return (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
120
+ }
121
+
122
+ // --------------------------------------------------------------------------
123
+ // the control exists, and it is the narrow one
124
+ // --------------------------------------------------------------------------
125
+
126
+ describe('the member card can change its own scope', () => {
127
+ it('offers Settings on a connector that declares config fields', async () => {
128
+ const section = await myCard();
129
+ expect(
130
+ within(section).getByRole('button', { name: /Settings/i }),
131
+ ).toBeInTheDocument();
132
+ });
133
+
134
+ it('still offers no Save beside the capability picker', async () => {
135
+ // The verb is narrow ON PURPOSE. What a member's account may DO stays the
136
+ // foundry's, so gating both controls on one verb would hand back exactly the
137
+ // decision this change did not make.
138
+ const section = await myCard();
139
+ expect(within(section).queryByRole('button', { name: /^Save$/i })).toBeNull();
140
+ expect(
141
+ within(section).getByText(/Ask an admin to change what this account may do/i),
142
+ ).toBeInTheDocument();
143
+ });
144
+
145
+ it('saves the fields the member changed, and nothing else', async () => {
146
+ const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
147
+ const section = await myCard(stubMemberApi({ updateConnectionConfig }));
148
+
149
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
150
+ await userEvent.clear(within(section).getByLabelText(/^Folder$/i));
151
+ await userEvent.type(within(section).getByLabelText(/^Folder$/i), '/Invoices');
152
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
153
+
154
+ await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
155
+ expect(updateConnectionConfig.mock.calls[0]).toEqual(['m1', { folder: '/Invoices' }]);
156
+ });
157
+
158
+ it('sends a real boolean when a switch is turned ON', async () => {
159
+ // `bool("false")` is `true`. A switch that travels as a string is the whole
160
+ // reason `type` exists on a field spec.
161
+ const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
162
+ const section = await myCard(stubMemberApi({ updateConnectionConfig }));
163
+
164
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
165
+ await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
166
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
167
+
168
+ await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
169
+ expect(updateConnectionConfig.mock.calls[0][1]).toEqual({ recursive: true });
170
+ });
171
+
172
+ it('sends a real boolean when a switch is turned OFF', async () => {
173
+ // Against a MERGING api, a client that drops falsy values makes the off
174
+ // position literally unreachable — for a scope switch that is the narrow
175
+ // setting nobody can get back to.
176
+ const updateConnectionConfig = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
177
+ const api = stubMemberApi({
178
+ updateConnectionConfig,
179
+ connections: vi.fn().mockResolvedValue({
180
+ ...MY_CONNECTIONS,
181
+ results: [{
182
+ ...MY_CONNECTIONS.results[0],
183
+ config: { folder: '/Contracts', recursive: true },
184
+ }],
185
+ }),
186
+ });
187
+ const section = await myCard(api);
188
+
189
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
190
+ await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
191
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
192
+
193
+ await waitFor(() => expect(updateConnectionConfig).toHaveBeenCalled());
194
+ expect(updateConnectionConfig.mock.calls[0][1]).toEqual({ recursive: false });
195
+ });
196
+ });
197
+
198
+ // --------------------------------------------------------------------------
199
+ // what the server says, the member reads
200
+ // --------------------------------------------------------------------------
201
+
202
+ describe('the member is told what the change costs', () => {
203
+ it('renders the notices the SERVER returned, verbatim', async () => {
204
+ const sentence =
205
+ 'Changing what this connection covers restarts the walk: the next run '
206
+ + 're-reads this account from the beginning.';
207
+ const updateConnectionConfig = vi.fn().mockResolvedValue({
208
+ ...MY_CONNECTIONS.results[0], notices: [sentence],
209
+ });
210
+ const section = await myCard(stubMemberApi({ updateConnectionConfig }));
211
+
212
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
213
+ await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
214
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
215
+
216
+ await waitFor(() => expect(screen.getByText(sentence)).toBeInTheDocument());
217
+ });
218
+
219
+ it('says nothing extra when the server said nothing', async () => {
220
+ const section = await myCard();
221
+ await userEvent.click(within(section).getByRole('button', { name: /Settings/i }));
222
+ await userEvent.click(within(section).getByLabelText(/Include sub-folders/i));
223
+ await userEvent.click(within(section).getByRole('button', { name: /Save settings/i }));
224
+ await waitFor(() =>
225
+ expect(within(section).getByText(/Settings saved/i)).toBeInTheDocument());
226
+ expect(within(section).queryByText(/from the beginning/i)).toBeNull();
227
+ });
228
+ });
229
+
230
+ // --------------------------------------------------------------------------
231
+ // an acknowledgement is a control, and it shows who claimed it
232
+ // --------------------------------------------------------------------------
233
+
234
+ describe('CredentialSchemaForm renders an acknowledgement as consent', () => {
235
+ function renderAck(stored: unknown, onChange = vi.fn()) {
236
+ render(
237
+ <CredentialSchemaForm
238
+ fields={SCOPE_FIELDS}
239
+ values={{ whole_account_acknowledged_by: stored }}
240
+ onChange={onChange}
241
+ />,
242
+ );
243
+ return onChange;
244
+ }
245
+
246
+ it('is a checkbox, not a box to type somebody’s name into', () => {
247
+ renderAck('');
248
+ const control = screen.getByLabelText(/Whole-account read claimed by/i);
249
+ expect(control).toHaveAttribute('type', 'checkbox');
250
+ expect((control as HTMLInputElement).checked).toBe(false);
251
+ });
252
+
253
+ it('emits a boolean, because the SERVER writes who and when', () => {
254
+ const onChange = renderAck('');
255
+ return userEvent.click(screen.getByLabelText(/Whole-account read claimed by/i)).then(() => {
256
+ expect(onChange).toHaveBeenCalledWith('whole_account_acknowledged_by', true);
257
+ });
258
+ });
259
+
260
+ it('shows the recorded attribution once somebody has claimed it', () => {
261
+ // A control that records nothing would be worse than the text box it
262
+ // replaced. The record has to be READABLE, or nobody can check it.
263
+ renderAck('dana.reed@example.com on 2026-08-27T09:00:00Z');
264
+ const control = screen.getByLabelText(/Whole-account read claimed by/i) as HTMLInputElement;
265
+ expect(control.checked).toBe(true);
266
+ expect(screen.getByText(/dana\.reed@example\.com/)).toBeInTheDocument();
267
+ expect(screen.getByText(/2026-08-27T09:00:00Z/)).toBeInTheDocument();
268
+ });
269
+ });
270
+
271
+ // --------------------------------------------------------------------------
272
+ // and the client lands on the narrow route
273
+ // --------------------------------------------------------------------------
274
+
275
+ describe('connectionsMe.updateConnectionConfig', () => {
276
+ interface Call { url: string; method: string; body: unknown }
277
+ let calls: Call[] = [];
278
+
279
+ beforeEach(() => {
280
+ calls = [];
281
+ setApiBase('/api/v1');
282
+ setAuthTokenProvider(null);
283
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit = {}) => {
284
+ calls.push({
285
+ url: String(url),
286
+ method: init.method ?? 'GET',
287
+ body: init.body ? JSON.parse(String(init.body)) : undefined,
288
+ });
289
+ return new Response(JSON.stringify({ id: 'm1' }), {
290
+ status: 200, headers: { 'Content-Type': 'application/json' },
291
+ });
292
+ }));
293
+ });
294
+
295
+ afterEach(() => { vi.unstubAllGlobals(); });
296
+
297
+ it('PATCHes the CONFIG sub-route, not the detail route', async () => {
298
+ // Beside `credential/`, and for the same reason it is: a scope change has to
299
+ // pass the connector's own refusal before it replaces a working setting.
300
+ // The detail route keeps having no PATCH at all.
301
+ await connectionsMe.updateConnectionConfig!('abc', { folder: '/Invoices' });
302
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/config/');
303
+ expect(calls[0].method).toBe('PATCH');
304
+ expect(calls[0].body).toEqual({ config: { folder: '/Invoices' } });
305
+ });
306
+
307
+ it('names no slug, no foundry and no user', async () => {
308
+ await connectionsMe.updateConnectionConfig!('abc', { folder: '/Invoices' });
309
+ expect(calls[0].url.startsWith('/api/v1/connections/me/')).toBe(true);
310
+ expect(calls[0].url).not.toMatch(/[?&]/);
311
+ expect(JSON.stringify(calls[0].body)).not.toMatch(
312
+ /slug|foundry|tenant|company|owner|user_id/i);
313
+ });
314
+ });
@@ -18,11 +18,18 @@
18
18
  * token in flight decides which foundry answers, and nothing here can override
19
19
  * that.
20
20
  *
21
- * WHAT IS ABSENT IS ALSO THE CONTRACT. There is no `updateConnection` because
22
- * `MyConnectionDetailView` serves GET and DELETE only; no choices, targets,
23
- * actions or `ConnectionToken`s because those are foundry-level and stay
24
- * owner-exclusive on the control-plane router. Adding one here would be a client
25
- * for a route that does not exist.
21
+ * WHAT IS ABSENT IS ALSO THE CONTRACT. There is still no `updateConnection`:
22
+ * `MyConnectionDetailView` serves GET and DELETE only, so a card's name and what
23
+ * it may DO stay with the foundry. No choices, targets, actions or
24
+ * `ConnectionToken`s either — those are foundry-level and owner-exclusive on the
25
+ * control-plane router. Adding one here would be a client for a route that does
26
+ * not exist.
27
+ *
28
+ * `updateConnectionConfig` is the ONE write verb that was added (bd
29
+ * startsim-sfms2), and it points at `me/<id>/config/` rather than at the detail
30
+ * route for the same reason `rotateCredential` points at `credential/`: a change
31
+ * that has to pass a gate gets its own route instead of a field on a general
32
+ * PATCH.
26
33
  *
27
34
  * WHERE IT POINTS. Through `request()`, so `setApiBase` and
28
35
  * `setAuthTokenProvider` apply: a tenant app points the base at its own
@@ -155,6 +162,37 @@ export const connectionsMe: MemberConnectionsApi = {
155
162
  });
156
163
  },
157
164
 
165
+ /**
166
+ * CHANGE THE SETTINGS OF MY OWN CARD (bd startsim-sfms2).
167
+ *
168
+ * The folder a Dropbox connection reads, and whether it recurses. On a
169
+ * personal connection that is not a pipeline setting — it is which of the
170
+ * member's own files this platform is allowed to see — so it is theirs to set,
171
+ * and the alternative was disconnect + re-consent, which throws a working
172
+ * token away to change a string.
173
+ *
174
+ * A SUB-ROUTE, not a PATCH on the detail view. `credential/` is the precedent
175
+ * one door down: rotation is not on PATCH either, because a new key has to
176
+ * pass `check()` before it replaces a working one. A scope change has the same
177
+ * shape — it must pass the connector's own refusal, and it restarts the walk.
178
+ *
179
+ * PARTIAL and MERGING, exactly like `rotateCredential`: send the fields that
180
+ * changed and the stored values fill in the rest. `null` unsets one. The server
181
+ * refuses any key the connector does not declare, and refuses any TOP-LEVEL key
182
+ * that is not `config` — a body naming `capabilities_enabled` is a 400 and not
183
+ * a silent no-op.
184
+ *
185
+ * The response may carry `notices`: sentences the server wrote about what the
186
+ * change costs. Rendered verbatim, never composed in the browser.
187
+ */
188
+ updateConnectionConfig(
189
+ id: string, config: Record<string, unknown>,
190
+ ): Promise<Connection> {
191
+ return request<Connection>(`${BASE}/${id}/config/`, {
192
+ method: 'PATCH', body: { config },
193
+ });
194
+ },
195
+
158
196
  /**
159
197
  * DISCONNECT, not delete. One transaction stops the scheduled fetches, revokes
160
198
  * every token issued for the account and deletes the stored credential; the
@@ -51,6 +51,9 @@ export interface CredentialSchemaFormProps {
51
51
  /** Field types this build renders natively. Anything else becomes text. */
52
52
  const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'textarea']);
53
53
 
54
+ /** The server's spelling for a consent record. See `isAcknowledgement`. */
55
+ const ACKNOWLEDGEMENT = 'acknowledgement';
56
+
54
57
  /**
55
58
  * A boolean is a CHECKBOX, and this is not a cosmetic preference.
56
59
  *
@@ -61,12 +64,37 @@ const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'texta
61
64
  * conversations are stored, and in a text box the way you turn it off is to type
62
65
  * the word "false" — which `bool()` reads as ON.
63
66
  */
64
- const BOOLEAN_TYPES = new Set(['boolean', 'checkbox', 'bool']);
67
+ const BOOLEAN_TYPES = new Set(['boolean', 'checkbox', 'bool', ACKNOWLEDGEMENT]);
65
68
 
66
69
  export function isBoolean(spec: ConnectorFieldSpec): boolean {
67
70
  return !isSecret(spec) && BOOLEAN_TYPES.has(String(spec.type || '').toLowerCase());
68
71
  }
69
72
 
73
+ /**
74
+ * An ACKNOWLEDGEMENT is a consent record, not a setting (bd startsim-sfms2).
75
+ *
76
+ * It marks the fields that say a named human claimed a disclosure that is legal,
77
+ * large, and much bigger than most people mean — Dropbox's
78
+ * `whole_account_acknowledged_by`, which gates reading metadata for every file
79
+ * in somebody's account.
80
+ *
81
+ * Two things follow, and both are why it is not merely `boolean`. What the
82
+ * person DOES is tick a claim, so this emits `true`/`false` and the SERVER
83
+ * writes `<who> on <when>` from the authenticated caller — there is no box to
84
+ * type somebody else's name into, and no request body that can attribute a claim
85
+ * to anyone but its sender. And what the field HOLDS is that attribution, so it
86
+ * is rendered beside the control: a claim nobody can read is a record in name
87
+ * only, which is worse than the text box it replaced.
88
+ */
89
+ export function isAcknowledgement(spec: ConnectorFieldSpec): boolean {
90
+ return !isSecret(spec) && String(spec.type || '').toLowerCase() === ACKNOWLEDGEMENT;
91
+ }
92
+
93
+ /** What the server wrote into an acknowledgement, or '' when nobody has claimed it. */
94
+ export function acknowledgedBy(value: unknown): string {
95
+ return typeof value === 'string' && asBoolean(value) ? value.trim() : '';
96
+ }
97
+
70
98
  /** The spellings of "off" that can arrive from a stored config or a form. */
71
99
  export function asBoolean(value: unknown): boolean {
72
100
  if (typeof value === 'string') {
@@ -185,6 +213,15 @@ export function CredentialSchemaForm({
185
213
  />
186
214
  )}
187
215
 
216
+ {/* THE RECORD, beside the control that made it. `who` and `when`
217
+ are written by the server; nothing here can author or edit them.
218
+ Rendered because a consent record nobody can read is not one. */}
219
+ {isAcknowledgement(spec) && acknowledgedBy(stored) ? (
220
+ <p className={classNames.help ?? 'mt-1 text-xs text-gray-500'}>
221
+ Claimed by {acknowledgedBy(stored)}
222
+ </p>
223
+ ) : null}
224
+
188
225
  {spec.help ? (
189
226
  <p className={classNames.help ?? 'mt-1 text-xs text-gray-500'}>{spec.help}</p>
190
227
  ) : null}
@@ -111,6 +111,20 @@ interface BoundApi {
111
111
  updateConnection?(id: string, input: {
112
112
  name?: string; capabilities_enabled?: string[]; config?: Record<string, unknown>;
113
113
  }): Promise<Connection>;
114
+ /**
115
+ * SETTINGS ALONE, and separate from `updateConnection` on purpose
116
+ * (bd startsim-sfms2).
117
+ *
118
+ * Both surfaces have it; only the control plane has `updateConnection`. A
119
+ * personal connection's config is the MEMBER's privacy boundary — which of
120
+ * their own files this platform reads — while its name and what it may DO are
121
+ * the foundry's. One verb carrying all three would have made the Settings
122
+ * control and the capability Save appear together, so opening the first would
123
+ * have opened the second by accident.
124
+ */
125
+ updateConnectionConfig?(
126
+ id: string, config: Record<string, unknown>,
127
+ ): Promise<Connection>;
114
128
  connectionChoices?(id: string, params?: { kind?: string; cursor?: string }):
115
129
  Promise<ConnectionChoicePage>;
116
130
  wireTarget?(id: string, input: {
@@ -177,6 +191,12 @@ function bindApi(props: ConnectionsPageProps): BoundApi {
177
191
  rotateCredential: (id, credentials) => api.rotateCredential(slug, id, credentials),
178
192
  disconnect: (id) => api.disconnect(slug, id),
179
193
  updateConnection: (id, input) => api.updateConnection(slug, id, input),
194
+ // The control plane reaches settings through the same PATCH it reaches
195
+ // everything else with; the narrow verb is a property of the MEMBER router,
196
+ // not of the form. Both modes therefore satisfy the same bound signature and
197
+ // the settings form never learns which surface it is on.
198
+ updateConnectionConfig: (id, config) =>
199
+ api.updateConnection(slug, id, { config }),
180
200
  connectionChoices: (id, params) => api.connectionChoices(slug, id, params),
181
201
  wireTarget: (id, input) => api.wireTarget(slug, id, input),
182
202
  confirmTarget: (id, destinationId) => api.confirmTarget(slug, id, destinationId),
@@ -971,7 +991,8 @@ function ConnectionSettingsForm({
971
991
  connection: Connection;
972
992
  fields: ConnectorFieldSpec[];
973
993
  classNames: typeof DEFAULTS;
974
- onDone: () => void;
994
+ /** Handed whatever the SERVER said about the change, so the card can say it. */
995
+ onDone: (notices: string[]) => void;
975
996
  }) {
976
997
  const stored = React.useMemo(
977
998
  () => (connection.config ?? {}) as Record<string, unknown>,
@@ -1001,8 +1022,15 @@ function ConnectionSettingsForm({
1001
1022
  ? Number(value)
1002
1023
  : value;
1003
1024
  }
1004
- await api.updateConnection!(connection.id, { config });
1005
- onDone();
1025
+ const saved = await api.updateConnectionConfig!(connection.id, config);
1026
+ // WHAT THE SERVER WANTS THEM TO KNOW, handed up rather than rendered here.
1027
+ // The live case is a scope change restarting the walk, and this file
1028
+ // cannot work that out: it names no vendor and therefore cannot know which
1029
+ // fields bear the scope. A heuristic on field names would be both a vendor
1030
+ // branch and untestable at the level that matters. Handed to the CARD
1031
+ // because saving closes this panel, and a sentence that unmounts with the
1032
+ // form is one nobody reads.
1033
+ onDone(saved?.notices ?? []);
1006
1034
  } catch (err) {
1007
1035
  setError((err as { message?: string })?.message || 'Those settings were not saved.');
1008
1036
  } finally {
@@ -1055,6 +1083,8 @@ function ConnectionCard({
1055
1083
  const [busy, setBusy] = React.useState('');
1056
1084
  const [error, setError] = React.useState('');
1057
1085
  const [notice, setNotice] = React.useState('');
1086
+ /** Sentences the SERVER wrote about the last settings save. Never composed here. */
1087
+ const [notices, setNotices] = React.useState<string[]>([]);
1058
1088
  const [replacing, setReplacing] = React.useState(false);
1059
1089
  const [settingsOpen, setSettingsOpen] = React.useState(false);
1060
1090
  const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
@@ -1164,8 +1194,16 @@ function ConnectionCard({
1164
1194
  all, which for Granola means the switch deciding whether verbatim
1165
1195
  recordings of people's conversations are stored was reachable
1166
1196
  only by editing the database. `ReplaceKeyForm` already pointed at
1167
- "the connection's own settings" as if this existed. */}
1168
- {configFields.length && api.updateConnection ? (
1197
+ "the connection's own settings" as if this existed.
1198
+
1199
+ GATED ON THE SETTINGS VERB, not on the general one (bd
1200
+ startsim-sfms2). The member surface has `updateConnectionConfig`
1201
+ and deliberately not `updateConnection`, because a personal
1202
+ connection's config is the MEMBER's privacy boundary while its
1203
+ name and what it may do stay the foundry's. One gate for both
1204
+ would have made opening this control open the capability Save
1205
+ beside it, which is a decision this change did not make. */}
1206
+ {configFields.length && api.updateConnectionConfig ? (
1169
1207
  <button
1170
1208
  type="button"
1171
1209
  className={cn.button}
@@ -1202,7 +1240,12 @@ function ConnectionCard({
1202
1240
  connection={connection}
1203
1241
  fields={configFields}
1204
1242
  classNames={cn}
1205
- onDone={() => { setSettingsOpen(false); setNotice('Settings saved.'); onChanged(); }}
1243
+ onDone={(said) => {
1244
+ setSettingsOpen(false);
1245
+ setNotice('Settings saved.');
1246
+ setNotices(said);
1247
+ onChanged();
1248
+ }}
1206
1249
  />
1207
1250
  ) : null}
1208
1251
 
@@ -1308,6 +1351,11 @@ function ConnectionCard({
1308
1351
  </CapabilityPicker>
1309
1352
 
1310
1353
  {notice ? <p className={cn.muted}>{notice}</p> : null}
1354
+ {/* WHAT THE CHANGE COSTS, in the server's words. Rendered verbatim: this
1355
+ file names no vendor and cannot know which settings restart a walk. */}
1356
+ {notices.map((said) => (
1357
+ <p key={said} className={cn.muted}>{said}</p>
1358
+ ))}
1311
1359
  {error ? <p className={cn.error} role="alert">{error}</p> : null}
1312
1360
 
1313
1361
  {choices ? (
@@ -519,6 +519,31 @@ const FRESHNESS_WORDS: Record<SourceFreshness['status'], string> = {
519
519
  never: 'Nothing yet',
520
520
  };
521
521
 
522
+ /**
523
+ * The count clause of a source card, or nothing at all (bd startsim-jevtu).
524
+ *
525
+ * THREE STATES, NOT TWO. `null` is "this workspace's API could not narrow the
526
+ * corpus by source, so the only number available describes every source at
527
+ * once" — printing it would put the same total on every card, which is the
528
+ * failure this replaced wearing a bigger number. Saying nothing is the honest
529
+ * option, and it costs the reader the count rather than the card.
530
+ *
531
+ * `0` is spelled out in words rather than left as a bare digit because "0
532
+ * records" is exactly what the broken version printed for years; a source that
533
+ * has genuinely delivered nothing deserves a sentence that could not be
534
+ * produced by not asking.
535
+ *
536
+ * NOTE THE SCOPE. This is the source's WHOLE contribution, unfiltered — the
537
+ * strip describes sources, while the list beneath it answers the current query.
538
+ * A card reading "208 records" above a list reading "3 records" under an active
539
+ * search is the two questions being different, not a disagreement.
540
+ */
541
+ function countClause(count: SourceFreshness['documentCount']): string {
542
+ if (count === null || count === undefined) return '';
543
+ if (count === 0) return 'No records yet · ';
544
+ return `${count} record${count === 1 ? '' : 's'} · `;
545
+ }
546
+
522
547
  function FreshnessStrip({
523
548
  sources, classNames: cn, now,
524
549
  }: { sources: SourceFreshness[]; classNames: Record<LibrarySlot, string>; now: number }) {
@@ -537,8 +562,7 @@ function FreshnessStrip({
537
562
  </span>
538
563
  </div>
539
564
  <p className={cn.muted}>
540
- {source.documentCount} record{source.documentCount === 1 ? '' : 's'} · synced{' '}
541
- {relative(source.lastSyncedAt, now)}
565
+ {countClause(source.documentCount)}synced {relative(source.lastSyncedAt, now)}
542
566
  </p>
543
567
  {/* A source that is failing says so HERE, next to the corpus it is
544
568
  supposed to be filling — not on a settings page nobody opens. */}
@@ -29,7 +29,12 @@ export interface ConnectorFieldSpec {
29
29
  help?: string;
30
30
  placeholder?: string;
31
31
  options?: Array<{ value: string; label: string }> | null;
32
- default?: string | null;
32
+ /**
33
+ * What happens if nobody touches this field. `unknown`, not `string`: a config
34
+ * schema declares booleans and numbers, and a `default: false` typed as a
35
+ * string is the same class of lie as a checkbox rendered as a text box.
36
+ */
37
+ default?: unknown;
33
38
  pattern?: string | null;
34
39
  docs_url?: string;
35
40
  }
@@ -148,6 +153,16 @@ export interface Connection {
148
153
  auth_mode: string;
149
154
  check_error?: string;
150
155
  last_checked_at?: string | null;
156
+ /**
157
+ * WHAT THE SERVER WANTS THE PERSON WHO JUST SAVED TO KNOW. Present on the
158
+ * response to a config write and NOWHERE else — never stored, never on a GET.
159
+ *
160
+ * The live case is a scope change: it restarts the walk, because the connector
161
+ * discards a sync cursor whose scope no longer matches. This composer refuses
162
+ * to name a vendor and cannot know which fields bear the scope; only the
163
+ * connector does, so only the server can say it. Rendered verbatim.
164
+ */
165
+ notices?: string[];
151
166
  }
152
167
 
153
168
  /** What to RENDER, never what to allow. The server refuses on its own. */
@@ -389,11 +404,37 @@ export interface MemberConnectionsApi {
389
404
  config?: Record<string, unknown>;
390
405
  /**
391
406
  * Chosen once, at connect time, and not editable afterwards on this
392
- * surface — there is no PATCH. The picker on an existing card is therefore
393
- * informational, and saying so is the honest rendering of both halves.
407
+ * surface — the detail route has no PATCH and `config/` accepts nothing
408
+ * else. The picker on an existing card is therefore informational, and
409
+ * saying so is the honest rendering of both halves.
394
410
  */
395
411
  capabilities_enabled?: string[];
396
412
  }): Promise<Connection>;
413
+ /**
414
+ * CHANGE THE SETTINGS OF A CONNECTION I OWN (bd startsim-sfms2).
415
+ *
416
+ * The one write verb this surface grew, and it is narrow on purpose. A
417
+ * personal connection's config is not a pipeline setting: it names the folder
418
+ * of somebody's own account this platform may read. Routing that through an
419
+ * admin makes another person the author of that member's privacy boundary,
420
+ * and the only re-scope available without it was disconnect + re-consent —
421
+ * throwing away a working token to change a string.
422
+ *
423
+ * NOT `updateConnection`, and the difference is load-bearing: the foundry
424
+ * verb of that name also carries `name` and `capabilities_enabled`, which stay
425
+ * the foundry's. A composer that gated both controls on one verb would hand
426
+ * back a decision this change did not make.
427
+ *
428
+ * PARTIAL and MERGING: send the fields that changed. `null` unsets one. The
429
+ * server refuses any key the connector does not declare.
430
+ *
431
+ * Optional so an app pinned to an older `@startsimpli/api` keeps
432
+ * type-checking; the card renders the Settings control only when the verb is
433
+ * present, so a stale client degrades to no control rather than a dead button.
434
+ */
435
+ updateConnectionConfig?(
436
+ id: string, config: Record<string, unknown>,
437
+ ): Promise<Connection>;
397
438
  checkConnection(id: string): Promise<{
398
439
  ok: boolean; account_label: string; detail: string;
399
440
  }>;
@@ -11,11 +11,16 @@
11
11
  * there must not be: it produces a list whose count disagrees with its rows and
12
12
  * that stops working at exactly the corpus size where it starts to matter.
13
13
  *
14
- * WHAT THE BACKEND HONOURS TODAY: `tenant-starter`'s `EntityViewSet` filters on
15
- * `?type=` and nothing else — no `search`, no `source`, no date window (see
16
- * `apps/api/views.py`). So `supportedFilters` defaults to `[]` and the composer
17
- * says so on screen rather than pretending. Pass the real list once the tenant
18
- * API gains them; the request already sends every parameter.
14
+ * WHAT THE BACKEND HONOURS IS THE BACKEND'S TO SAY, NOT THIS FILE'S. It once
15
+ * said here that `tenant-starter`'s `EntityViewSet` filters on `?type=` and
16
+ * nothing else; bd startsim-768w.38.12 shipped `search`, `source`, `party`, the
17
+ * date window and `order`, and the comment stayed behind — which is exactly why
18
+ * a hardcoded list is the wrong mechanism. Every list response carries the
19
+ * server's own `applied_filters`/`ignored_filters`, `/api/v1/capabilities/`
20
+ * describes the deployed build, and both are read in preference to anything
21
+ * declared client-side. `supportedFilters` remains only as the fallback for a
22
+ * fork too old to report, and a deployment that passes a stale one is corrected
23
+ * by the server rather than believed.
19
24
  *
20
25
  * PEOPLE ARE A SECOND READ, NOT A SECOND COPY (bd startsim-768w.38.22). The
21
26
  * corpus delivery writes participants as typed `authored_by` / `involves` edges
@@ -430,6 +435,42 @@ export function createCorpusApi(
430
435
  };
431
436
  }
432
437
 
438
+ /**
439
+ * How many DOCUMENTS one source has put in the corpus, or `null` when the
440
+ * server did not confirm it narrowed by source.
441
+ *
442
+ * ONE ROW, AND THE ENVELOPE'S OWN `count`. `page_size=1` is not a sample: the
443
+ * DRF paginator counts the filtered queryset in SQL and reports it beside the
444
+ * page, so this is one COUNT with `?source=` pushed down, not eleven pages
445
+ * added up. `types.document` is passed for the same reason the list below the
446
+ * strip passes it — a count over every entity type would sweep in parties,
447
+ * money events and commitments and stop agreeing with the list it sits above.
448
+ *
449
+ * THE GUARD IS THE SERVER'S OWN REPORT, in both spellings for the reason
450
+ * `ServerFilterReport` documents. A fork that predates bd startsim-768w.38.12
451
+ * accepts `?source=` and ignores it, so the honest-looking `count` that comes
452
+ * back is the whole corpus — the same number on every card. `applied_filters`
453
+ * naming `source` is what separates "none from this source" from "I could not
454
+ * ask", and only the first of those is a number worth printing.
455
+ */
456
+ async function countDocumentsFrom(source: string): Promise<number | null> {
457
+ if (!source || source === 'unknown') return null;
458
+ try {
459
+ const response = await http.get<DrfPage<TenantEntity> & ServerFilterReport>(entities, {
460
+ params: { type: types.document, source, page_size: 1 },
461
+ });
462
+ const applied = response.applied_filters ?? response.appliedFilters;
463
+ if (!applied?.includes('source')) return null;
464
+ const count = Number(response.count);
465
+ return Number.isFinite(count) ? count : null;
466
+ } catch {
467
+ // A count is an ornament on a card whose point is freshness. Losing it
468
+ // costs the reader a number; failing the whole strip would cost the four
469
+ // facts the card exists for.
470
+ return null;
471
+ }
472
+ }
473
+
433
474
  /** Every page of a DRF list, up to the cap. Stops on the first failure. */
434
475
  async function readAll<T>(path: string, extra: Record<string, unknown>): Promise<T[]> {
435
476
  const rows: T[] = [];
@@ -670,26 +711,72 @@ export function createCorpusApi(
670
711
  * exactly the lie bd startsim-1cow removed. Rows are read from one endpoint,
671
712
  * as handed.
672
713
  *
673
- * `documentCount` is whatever the connections payload says and is therefore
674
- * `0` today: no connections endpoint sends `document_count`, and a per-source
675
- * count from the corpus needs the `?source=` filter bd startsim-768w.38.12
676
- * is still open on. Tracked as bd startsim-o0o6o.1.
714
+ * `documentCount` IS THE ONE CORPUS-SIDE FACT ON THIS CARD, and it is
715
+ * counted here rather than read off the connection (bd startsim-jevtu, first
716
+ * filed as startsim-o0o6o.1). It used to be
717
+ * `connection.document_count ?? connection.documentCount ?? 0` over a
718
+ * payload that has never carried either spelling, so every card on every
719
+ * foundry read "0 records" — on tandem, four of them, directly above a list
720
+ * reading "208 records".
721
+ *
722
+ * CENTRAL CANNOT ANSWER IT. The corpus lives in the fork; central delivers
723
+ * INTO it and keeps no copy, so a count annotated onto the connection would
724
+ * be a count of what central believes it shipped — a different number from
725
+ * the list under the strip as soon as an upsert collapses two records or a
726
+ * delivery half-lands. A strip that disagrees with its own list is the fault
727
+ * being fixed, not a fix for it. And the disagreement is the POINT: every
728
+ * other field on this card comes from central's connection row, so "central
729
+ * says it synced an hour ago, the corpus holds nothing from it" is the
730
+ * delivery-hop failure made visible, which no connection field can say.
731
+ *
732
+ * ONE COUNT PER SOURCE, NOT A SCAN. `page_size=1` and the paginator's own
733
+ * `count` — one SQL COUNT with `?source=` pushed down. That is the idiom the
734
+ * tenant's `/api/v1/capabilities/` declares (`"counts": "page-count"`, and
735
+ * "a focus card's '12 of 40 done' is two `page_size=1` reads … so a client
736
+ * does not go looking for a group_by endpoint that deliberately does not
737
+ * exist"). Paging the corpus per card would be the same wrong answer with
738
+ * worse latency.
739
+ *
740
+ * `null`, NEVER A GUESSED `0`. A fork that predates the `?source=` filter
741
+ * (bd startsim-768w.38.12) narrows nothing and hands back the WHOLE corpus,
742
+ * which would print the same total on every card. The server's own
743
+ * `applied_filters` is the only thing that tells a narrowed count from an
744
+ * unnarrowed one, so a count it did not confirm is not reported at all —
745
+ * and `0` is left free to mean what it says.
677
746
  */
678
747
  async listSources(): Promise<SourceFreshness[]> {
679
- const [connectionPage, documentPage] = await Promise.all([
680
- http
681
- .get<DrfPage<Record<string, unknown>>>(connections)
682
- .catch(() => EMPTY_PAGE as DrfPage<Record<string, unknown>>),
683
- http
684
- .get<DrfPage<TenantEntity>>(entities, { params: { type: types.document, page_size: 1 } })
685
- .catch(() => EMPTY_PAGE as DrfPage<TenantEntity>),
686
- ]);
748
+ const connectionPage = await http
749
+ .get<DrfPage<Record<string, unknown>>>(connections)
750
+ .catch(() => EMPTY_PAGE as DrfPage<Record<string, unknown>>);
687
751
 
688
- void documentPage; // total count is per-source below; kept for the empty-corpus case
752
+ const live = (connectionPage.results ?? []).filter(isALiveSource).map((connection) => ({
753
+ connection,
754
+ key: String(connection.connector_key ?? connection.connectorKey ?? 'unknown'),
755
+ }));
756
+ // ONE READ PER DISTINCT SOURCE KEY, in parallel — the cards are
757
+ // independent and a slow source must not hold up the rest of the strip.
758
+ //
759
+ // Distinct, because a card is per CONNECTION and a count is per SOURCE,
760
+ // and those are not the same thing. Central's page unions the foundry's
761
+ // shared connections with the member's own (bd startsim-768w.22.25.11),
762
+ // so a shared Granola and a personal Granola arrive as two rows naming one
763
+ // vendor. Whose account it is belongs on the card; it does not narrow the
764
+ // corpus, so counting it twice would be two identical queries for one
765
+ // answer. Both cards then report the same total, which is the true one:
766
+ // the corpus does not record which of two accounts a document came in
767
+ // through.
768
+ const pending = new Map<string, Promise<number | null>>();
769
+ for (const { key } of live) {
770
+ if (!pending.has(key)) pending.set(key, countDocumentsFrom(key));
771
+ }
772
+ const counts = new Map(
773
+ await Promise.all(
774
+ [...pending].map(async ([key, count]) => [key, await count] as const),
775
+ ),
776
+ );
689
777
 
690
778
  const now = Date.now();
691
- return (connectionPage.results ?? []).filter(isALiveSource).map((connection) => {
692
- const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
779
+ return live.map(({ connection, key }) => {
693
780
  // THREE FACTS, READ SEPARATELY (bd startsim-1cow). This was one `??`
694
781
  // chain ending in `last_checked_at`, and central's own view says of that
695
782
  // column that it "is a far more optimistic fact than either and is never
@@ -709,7 +796,7 @@ export function createCorpusApi(
709
796
  const lastCheckedAt = at(connection.last_checked_at ?? connection.lastCheckedAt);
710
797
  const failures =
711
798
  Number(connection.consecutive_failures ?? connection.consecutiveFailures ?? 0) || 0;
712
- const documentCount = Number(connection.document_count ?? connection.documentCount ?? 0);
799
+ const documentCount = counts.get(key) ?? null;
713
800
  const error = String(connection.check_error ?? connection.checkError ?? '');
714
801
  const status = connection.status ?? '';
715
802
 
@@ -869,7 +869,12 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
869
869
  * search since this was written (bd startsim-768w.38.12) and a retrieval
870
870
  * test must be written against the real semantics, but the suites that
871
871
  * predate it are still testing what they were written to test. `source` and
872
- * `party` are still accepted and ignored, exactly as `EntityViewSet` does;
872
+ * `party` are accepted and ignored HERE, and this fixture no longer claims
873
+ * that is what `EntityViewSet` does: 768w.38.12 shipped both, along with the
874
+ * `applied_filters` report that names what the server narrowed by. So this
875
+ * stands in for an OLDER fork — which is a useful thing to have a stand-in
876
+ * for, since that is the fork `listSources` refuses to print a count for
877
+ * (bd startsim-jevtu), but it is a lagging stub and not the contract;
873
878
  * • `page_size` is IGNORED (`PageNumberPagination` with no
874
879
  * `page_size_query_param`), so a caller asking for 200 gets `pageSize`;
875
880
  * • `/api/v1/relationships/` returns every edge in the org, unfiltered,
@@ -172,8 +172,22 @@ export interface MoneyEvent {
172
172
  export interface SourceFreshness {
173
173
  source: SourceKey;
174
174
  label: string;
175
- /** How many documents this source has contributed to the corpus. */
176
- documentCount: number;
175
+ /**
176
+ * How many documents this source has contributed to the corpus — counted OUT
177
+ * OF THE CORPUS, per source, and the only fact on a freshness card that is
178
+ * not central's connection bookkeeping (bd startsim-jevtu). That is what
179
+ * makes it worth its round trip: a source central reports as freshly synced
180
+ * that holds nothing here is the delivery hop failing, and no timestamp on
181
+ * the connection can say so.
182
+ *
183
+ * `null` means NOT COUNTED — the server did not confirm it narrowed by
184
+ * source, so the number it returned describes the whole corpus rather than
185
+ * this card. A screen renders nothing at all for `null`. `0` therefore means
186
+ * exactly what it says, which it did not before: the field was read off a
187
+ * connections payload that has never carried it, so every card everywhere
188
+ * reported `0` by accident.
189
+ */
190
+ documentCount: number | null;
177
191
  /**
178
192
  * THREE FACTS, AND CONFLATING ANY TWO OF THEM IS HOW A FRESHNESS STRIP LIES
179
193
  * (bd startsim-1cow). Central's `SubtreeConnectionsView` computes them