@startsimpli/integrations 0.2.13 → 0.2.16

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.
@@ -0,0 +1,476 @@
1
+ /**
2
+ * TRANSCRIPT-BACKED EXTRACTION (bd startsim-768w.38.31).
3
+ *
4
+ * THE MEASUREMENT THAT PRODUCED THIS FILE. Extraction recall on the live tandem
5
+ * tenant is capped around 60%, and it is not the extractor's fault: every one of
6
+ * the sixteen real `source_document` rows carries a `summary` and NOTHING else —
7
+ * 54,534 characters of Granola's own AI summary, zero characters of body. The
8
+ * commitments the evaluation scores as "missed" were discarded by the summariser
9
+ * before this package ever saw the record. No prompt recovers text that was
10
+ * never fetched.
11
+ *
12
+ * The transcript is at the vendor and the connector already knows how to ask for
13
+ * it (`GET /v1/notes/{id}?include=transcript`, gated on
14
+ * `SourceInstance.config["ingest_transcripts"]`, default false by the Q4 privacy
15
+ * posture). This file is the DOWNSTREAM half: everything that has to be true
16
+ * before that flag is worth flipping.
17
+ *
18
+ * WHAT IT PINS, in the order the pipeline runs:
19
+ *
20
+ * 1. THE SUMMARY SURVIVES THE TRANSCRIPT. The canonical schema declares
21
+ * `summary` and `body` as two attributes precisely so "the transcript
22
+ * clobbers the summary the first time both arrive" cannot happen — and the
23
+ * mapper collapsed them anyway. That single `pick()` would have deleted the
24
+ * 95.8%-precision rules pass on the day transcripts were switched on.
25
+ * 2. THE MODEL PASS ACTUALLY READS THE VERBATIM TEXT, and is told what the
26
+ * rules pass already found so it reports only the remainder.
27
+ * 3. TRIMMING KEEPS THE END OF THE MEETING. Commitments are made in the last
28
+ * five minutes; a head-first truncation deletes exactly the half worth
29
+ * reading.
30
+ * 4. VERIFICATION. More text raises recall and drops precision — measured, 65.9%
31
+ * recall at 67.5% precision on summaries alone, and a transcript is ten times
32
+ * the text. Every model candidate has to be confirmed against the source with
33
+ * a supporting quote, in ONE batched call, and anything unconfirmed is
34
+ * dropped rather than shipped to someone's to-do list.
35
+ */
36
+ import { describe, expect, it, vi } from 'vitest';
37
+
38
+ import { toSourceDocument } from '../corpus/entities';
39
+ import type { TenantEntity } from '../corpus/entities';
40
+ import { documentText, extractCommitments, rulesExtract } from '../corpus/extract';
41
+ import type {
42
+ CommitmentDraft,
43
+ CorpusExtractorLlm,
44
+ CorpusScope,
45
+ ExtractionDraft,
46
+ SourceDocument,
47
+ VerificationRequest,
48
+ } from '../corpus/types';
49
+
50
+ const SCOPE: CorpusScope = { orgId: 'tandem' };
51
+
52
+ /**
53
+ * The summariser's own output, in the shape all sixteen real records have: `##`
54
+ * headings per topic, the last of them an outcome block of bolded imperatives
55
+ * with the owner in parentheses.
56
+ */
57
+ const SUMMARY = [
58
+ '## Discussion',
59
+ 'We went through the Rexford paperwork and the parking lot quote.',
60
+ '',
61
+ '## Next Steps',
62
+ '- **Send the signed Rexford lease to Michelle** (Kate)',
63
+ '- **Get a second quote for the Hadley lot** (Jonathan)',
64
+ ].join('\n');
65
+
66
+ /**
67
+ * The same meeting as the vendor's ASR heard it. Two commitments in here never
68
+ * reached the summary — which is the entire finding this file exists for.
69
+ */
70
+ const TRANSCRIPT = [
71
+ 'Jonathan: Alright, Rexford. Kate, the lease came back signed this morning.',
72
+ 'Kate: I saw it. I will send it over to Michelle today so she can file it.',
73
+ 'Jonathan: Good. On the Hadley lot, the number they gave us is high.',
74
+ 'Kate: Get a second quote before you agree to anything.',
75
+ 'Jonathan: I will. Separately — I still owe Rae the insurance certificate for the Lamar building.',
76
+ 'Kate: Right, and somebody has to cancel the old Pigoso QuickBooks seat before we get billed again.',
77
+ 'Jonathan: I will take the certificate. Put the QuickBooks seat on Megan.',
78
+ ].join('\n');
79
+
80
+ /** The raw tenant row exactly as the corpus delivery writes it. */
81
+ function row(data: Record<string, unknown>): TenantEntity {
82
+ return {
83
+ id: 'doc_rexford',
84
+ entity_type: 'source_document',
85
+ external_id: 'granola:not_6IARYTobc9AwLI',
86
+ name: 'Rexford paperwork and Hadley parking lot',
87
+ data: {
88
+ subtype: 'meeting',
89
+ title: 'Rexford paperwork and Hadley parking lot',
90
+ source: 'granola',
91
+ source_ref: 'not_6IARYTobc9AwLI',
92
+ occurred_at: '2026-08-05T15:00:00Z',
93
+ ...data,
94
+ },
95
+ created_at: '2026-08-05T16:00:00Z',
96
+ org_id: 'tandem',
97
+ };
98
+ }
99
+
100
+ const summaryOnly = () => toSourceDocument(row({ summary: SUMMARY }), SCOPE);
101
+ /**
102
+ * WHAT THE CONNECTOR REALLY WRITES once `ingest_transcripts` is on: the
103
+ * `meeting_transcript` record `merge_into`s the note's own document and fills the
104
+ * canonical `body` attribute with the verbatim text
105
+ * (`granola.declare_canonical()`), alongside the `summary` that was already
106
+ * there. Not a `transcript` key — the fixture corpus used one of those, which is
107
+ * exactly why no existing test caught the collapse below.
108
+ */
109
+ const withTranscript = () => toSourceDocument(row({ summary: SUMMARY, body: TRANSCRIPT }), SCOPE);
110
+
111
+ describe('a transcript must not delete the summary', () => {
112
+ it('keeps the summary as its own field when the verbatim body arrives', () => {
113
+ const document = withTranscript();
114
+ expect(document.summary).toContain('## Next Steps');
115
+ expect(document.body).toContain('Jonathan: Alright, Rexford');
116
+ });
117
+
118
+ it('still parses the vendor outcome block when a transcript is present', () => {
119
+ // THE REGRESSION THIS FILE WAS WRITTEN FOR. `pick(data,'body',...,'summary')`
120
+ // returns the FIRST present key, so a document carrying both returned only
121
+ // the transcript, `outcomeSections` found no `## Next Steps` in it, and the
122
+ // rules pass — 95.8% precision, 100% owner accuracy — produced zero.
123
+ const before = rulesExtract(summaryOnly());
124
+ const after = rulesExtract(withTranscript());
125
+ expect(before).toHaveLength(2);
126
+ expect(after.map((draft) => draft.title)).toEqual(before.map((draft) => draft.title));
127
+ expect(after.map((draft) => draft.ownerName)).toEqual(['Kate', 'Jonathan']);
128
+ });
129
+
130
+ it('reads the summary on a corpus that has no body at all', () => {
131
+ // Every row on the live tenant today. `body` has to keep answering for the
132
+ // summary or every existing consumer of it goes blank on the same deploy.
133
+ const document = summaryOnly();
134
+ expect(document.body).toContain('## Next Steps');
135
+ expect(document.summary).toContain('## Next Steps');
136
+ });
137
+ });
138
+
139
+ describe('the text handed to the model', () => {
140
+ it('carries the verbatim transcript, not just the summary', () => {
141
+ const text = documentText(withTranscript(), 12_000);
142
+ expect(text).toContain('## Next Steps');
143
+ expect(text).toContain('insurance certificate for the Lamar building');
144
+ });
145
+
146
+ it('does not repeat the summary twice when body fell back to it', () => {
147
+ const text = documentText(summaryOnly(), 12_000);
148
+ expect(text.match(/## Next Steps/g)).toHaveLength(1);
149
+ });
150
+
151
+ it('keeps the END of a long transcript, where the commitments are', () => {
152
+ // A head-first `slice(0, maxChars)` deletes the last five minutes of the
153
+ // meeting, which is the only part where anyone says who is doing what.
154
+ const filler = Array.from({ length: 400 }, (_, index) => `Speaker: filler line ${index}`).join('\n');
155
+ const long = toSourceDocument(row({ summary: SUMMARY, body: `${filler}\n${TRANSCRIPT}` }), SCOPE);
156
+ const text = documentText(long, 4_000);
157
+ expect(text.length).toBeLessThanOrEqual(6_000);
158
+ // The summary is small and structured: it is never the thing that gets cut.
159
+ expect(text).toContain('## Next Steps');
160
+ expect(text).toContain('Put the QuickBooks seat on Megan');
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // verification
166
+ // ---------------------------------------------------------------------------
167
+
168
+ interface StubOptions {
169
+ items?: ExtractionDraft['items'];
170
+ verdicts?: Array<{ id: string; confirmed: boolean; quote?: string }>;
171
+ onVerify?: (input: VerificationRequest) => void;
172
+ verifyThrows?: boolean;
173
+ omitVerify?: boolean;
174
+ }
175
+
176
+ function stubExtractor(options: StubOptions = {}): CorpusExtractorLlm {
177
+ const verify = async (input: VerificationRequest) => {
178
+ options.onVerify?.(input);
179
+ if (options.verifyThrows) throw new Error('provider unreachable');
180
+ return {
181
+ verdicts: (options.verdicts ?? input.candidates.map((candidate) => ({
182
+ id: candidate.id,
183
+ confirmed: true,
184
+ quote: candidate.quote,
185
+ }))),
186
+ };
187
+ };
188
+ return {
189
+ async extract() {
190
+ return { items: options.items ?? [], resolutions: [] };
191
+ },
192
+ ...(options.omitVerify ? {} : { verify }),
193
+ describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
194
+ };
195
+ }
196
+
197
+ /** A corpus of exactly one document, with a capture of what got written. */
198
+ function stubCorpus(document: SourceDocument, saved: CommitmentDraft[][] = []) {
199
+ return {
200
+ listDocuments: async () => ({ count: 1, next: null, previous: null, results: [document] }),
201
+ getDocument: async () => document,
202
+ listParties: async () => ({ count: 0, next: null, previous: null, results: [] }),
203
+ listSources: async () => [],
204
+ listCommitments: async () => ({ count: 0, next: null, previous: null, results: [] }),
205
+ saveCommitments: async (commitments: unknown[]) => {
206
+ saved.push(commitments as CommitmentDraft[]);
207
+ },
208
+ };
209
+ }
210
+
211
+ const modelItem = (title: string, quote: string) => ({
212
+ documentId: 'doc_rexford',
213
+ subtype: 'action',
214
+ title,
215
+ detail: '',
216
+ status: 'open',
217
+ quote,
218
+ });
219
+
220
+ describe('the verification pass', () => {
221
+ it('drops a candidate the verifier refuses to confirm', async () => {
222
+ const document = withTranscript();
223
+ const saved: CommitmentDraft[][] = [];
224
+ const result = await extractCommitments(
225
+ { scope: SCOPE },
226
+ {
227
+ corpus: stubCorpus(document, saved),
228
+ extractor: stubExtractor({
229
+ items: [
230
+ modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate'),
231
+ modelItem('Buy the Hadley lot outright this quarter', 'the number they gave us is high'),
232
+ ],
233
+ verdicts: [
234
+ { id: 'c1', confirmed: true, quote: 'I still owe Rae the insurance certificate' },
235
+ { id: 'c2', confirmed: false },
236
+ ],
237
+ }),
238
+ },
239
+ );
240
+ expect(result.fromModel).toBe(1);
241
+ expect(result.unconfirmed).toBe(1);
242
+ expect(result.verified).toBe(true);
243
+ const titles = saved.flat().map((item) => (item as unknown as { title: string }).title);
244
+ expect(titles).toContain('Send Rae the Lamar insurance certificate');
245
+ expect(titles).not.toContain('Buy the Hadley lot outright this quarter');
246
+ });
247
+
248
+ it('drops a candidate whose quote is not in the source, with no model call at all', async () => {
249
+ // The free half of verification. A quote that does not appear in the record
250
+ // is the signature of an invented commitment, and catching it costs nothing.
251
+ const document = withTranscript();
252
+ const result = await extractCommitments(
253
+ { scope: SCOPE },
254
+ {
255
+ corpus: stubCorpus(document),
256
+ extractor: stubExtractor({
257
+ items: [modelItem('Wire the deposit to the title company', 'Kate confirmed the wire went out')],
258
+ }),
259
+ },
260
+ );
261
+ expect(result.fromModel).toBe(0);
262
+ expect(result.ungrounded).toBe(1);
263
+ });
264
+
265
+ it('verifies every candidate in ONE call, with echoed ids', async () => {
266
+ const document = withTranscript();
267
+ const calls: VerificationRequest[] = [];
268
+ await extractCommitments(
269
+ { scope: SCOPE },
270
+ {
271
+ corpus: stubCorpus(document),
272
+ extractor: stubExtractor({
273
+ items: [
274
+ modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate'),
275
+ modelItem('Cancel the old Pigoso QuickBooks seat', 'cancel the old Pigoso QuickBooks seat'),
276
+ ],
277
+ onVerify: (input) => calls.push(input),
278
+ }),
279
+ },
280
+ );
281
+ expect(calls).toHaveLength(1);
282
+ expect(calls[0].candidates).toHaveLength(2);
283
+ expect(new Set(calls[0].candidates.map((candidate) => candidate.id)).size).toBe(2);
284
+ });
285
+
286
+ it('never spends a verification call on the rules pass', async () => {
287
+ // The rules pass quotes the vendor's own bullet verbatim. Paying a model to
288
+ // confirm a deterministic transcription of a line we can print is money for
289
+ // a chance to lose a correct item.
290
+ const document = withTranscript();
291
+ const calls: VerificationRequest[] = [];
292
+ await extractCommitments(
293
+ { scope: SCOPE },
294
+ { corpus: stubCorpus(document), extractor: stubExtractor({ onVerify: (input) => calls.push(input) }) },
295
+ );
296
+ expect(calls).toHaveLength(0);
297
+ });
298
+
299
+ it('drops the model half rather than shipping it unverified when the verifier fails', async () => {
300
+ const document = withTranscript();
301
+ const result = await extractCommitments(
302
+ { scope: SCOPE },
303
+ {
304
+ corpus: stubCorpus(document),
305
+ extractor: stubExtractor({
306
+ items: [modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate')],
307
+ verifyThrows: true,
308
+ }),
309
+ },
310
+ );
311
+ expect(result.fromModel).toBe(0);
312
+ expect(result.verified).toBe(false);
313
+ // The deterministic half is untouched by a provider outage, one level up
314
+ // from `extract` being wrapped for the same reason.
315
+ expect(result.fromRules).toBe(2);
316
+ expect(result.created).toBe(2);
317
+ });
318
+
319
+ it('says so rather than claiming verification when the extractor cannot verify', async () => {
320
+ const document = withTranscript();
321
+ const result = await extractCommitments(
322
+ { scope: SCOPE },
323
+ {
324
+ corpus: stubCorpus(document),
325
+ extractor: stubExtractor({
326
+ omitVerify: true,
327
+ items: [modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate')],
328
+ }),
329
+ },
330
+ );
331
+ expect(result.verified).toBe(false);
332
+ // Grounded, so it is not invented — but nothing confirmed it is a commitment.
333
+ expect(result.fromModel).toBe(1);
334
+ });
335
+ });
336
+
337
+ describe('the two passes have different jobs', () => {
338
+ it('tells the model what the rules pass already found, per document', async () => {
339
+ const document = withTranscript();
340
+ const seen: string[][] = [];
341
+ const extractor: CorpusExtractorLlm = {
342
+ async extract(input) {
343
+ seen.push(input.documents[0].alreadyFound);
344
+ return { items: [], resolutions: [] };
345
+ },
346
+ describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
347
+ };
348
+ await extractCommitments({ scope: SCOPE }, { corpus: stubCorpus(document), extractor });
349
+ expect(seen[0]).toEqual([
350
+ 'Send the signed Rexford lease to Michelle',
351
+ 'Get a second quote for the Hadley lot',
352
+ ]);
353
+ });
354
+
355
+ it('sends the transcript to the model, not the summary the rules pass already read', async () => {
356
+ const document = withTranscript();
357
+ const texts: string[] = [];
358
+ const extractor: CorpusExtractorLlm = {
359
+ async extract(input) {
360
+ texts.push(input.documents[0].text);
361
+ return { items: [], resolutions: [] };
362
+ },
363
+ describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
364
+ };
365
+ await extractCommitments({ scope: SCOPE }, { corpus: stubCorpus(document), extractor });
366
+ expect(texts[0]).toContain('Put the QuickBooks seat on Megan');
367
+ });
368
+
369
+ it('does not regress the rules pass when there is no transcript', async () => {
370
+ const document = summaryOnly();
371
+ const result = await extractCommitments(
372
+ { scope: SCOPE },
373
+ { corpus: stubCorpus(document), extractor: stubExtractor() },
374
+ );
375
+ expect(result.fromRules).toBe(2);
376
+ expect(result.created).toBe(2);
377
+ });
378
+ });
379
+
380
+ describe('one run stays inside a request', () => {
381
+ it('processes fewer documents per call when they carry transcripts', async () => {
382
+ // bd startsim-768w.38.28: the tenant gateway closes a request at 60 seconds
383
+ // and a transcript is roughly ten times the text of a summary. A fixed
384
+ // document count per call is the wrong axis; a TEXT budget is the right one.
385
+ const long = (id: string) =>
386
+ toSourceDocument(
387
+ { ...row({ summary: SUMMARY, body: TRANSCRIPT.repeat(60) }), id },
388
+ SCOPE,
389
+ );
390
+ const documents = ['d1', 'd2', 'd3', 'd4'].map(long);
391
+ const seen: number[] = [];
392
+ const extractor: CorpusExtractorLlm = {
393
+ async extract(input) {
394
+ seen.push(input.documents.length);
395
+ return { items: [], resolutions: [] };
396
+ },
397
+ describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
398
+ };
399
+ const result = await extractCommitments(
400
+ { scope: SCOPE },
401
+ {
402
+ corpus: {
403
+ ...stubCorpus(documents[0]),
404
+ listDocuments: async () => ({ count: 4, next: null, previous: null, results: documents }),
405
+ },
406
+ extractor,
407
+ },
408
+ { maxCharsPerRun: 20_000 },
409
+ );
410
+ expect(seen).toHaveLength(1);
411
+ expect(seen[0]).toBeLessThan(4);
412
+ // And it SAYS it did not finish, so the caller resumes rather than believing
413
+ // a partial sweep was the whole corpus.
414
+ expect(result.nextOffset).not.toBeNull();
415
+ expect(result.totalDocuments).toBe(4);
416
+ });
417
+
418
+ it('still does the whole corpus in one call when the documents are summaries', async () => {
419
+ const documents = ['d1', 'd2', 'd3', 'd4'].map((id) =>
420
+ toSourceDocument({ ...row({ summary: SUMMARY }), id }, SCOPE),
421
+ );
422
+ const seen: number[] = [];
423
+ const extractor: CorpusExtractorLlm = {
424
+ async extract(input) {
425
+ seen.push(input.documents.length);
426
+ return { items: [], resolutions: [] };
427
+ },
428
+ describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
429
+ };
430
+ const result = await extractCommitments(
431
+ { scope: SCOPE },
432
+ {
433
+ corpus: {
434
+ ...stubCorpus(documents[0]),
435
+ listDocuments: async () => ({ count: 4, next: null, previous: null, results: documents }),
436
+ },
437
+ extractor,
438
+ },
439
+ );
440
+ expect(seen).toEqual([4]);
441
+ expect(result.nextOffset).toBeNull();
442
+ });
443
+ });
444
+
445
+ describe('the extraction result reports the shape of the run', () => {
446
+ it('counts what each stage kept and dropped', async () => {
447
+ const document = withTranscript();
448
+ const result = await extractCommitments(
449
+ { scope: SCOPE },
450
+ {
451
+ corpus: stubCorpus(document),
452
+ extractor: stubExtractor({
453
+ items: [
454
+ modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate'),
455
+ modelItem('Wire the deposit to the title company', 'a quote that is not in the record'),
456
+ modelItem('Cancel the old Pigoso QuickBooks seat', 'cancel the old Pigoso QuickBooks seat'),
457
+ ],
458
+ verdicts: [
459
+ { id: 'c1', confirmed: true },
460
+ { id: 'c3', confirmed: false },
461
+ ],
462
+ }),
463
+ },
464
+ );
465
+ expect(result.fromRules).toBe(2);
466
+ expect(result.fromModel).toBe(1);
467
+ expect(result.ungrounded).toBe(1);
468
+ expect(result.unconfirmed).toBe(1);
469
+ expect(result.verified).toBe(true);
470
+ });
471
+ });
472
+
473
+ /** Kept honest: nothing in this file may reach a real provider. */
474
+ it('never constructs a provider client', () => {
475
+ expect(vi.isMockFunction(globalThis.fetch)).toBe(false);
476
+ });
@@ -23,9 +23,17 @@ import type { ConnectorFieldSpec, ConnectorSchemaLike } from './pages/types';
23
23
  export interface CredentialSchemaFormProps {
24
24
  /** The catalog's own schema, rendered in DECLARED ORDER. */
25
25
  fields: ConnectorFieldSpec[];
26
- /** Current values, keyed by field key. Secrets are never read back from here. */
27
- values: Record<string, string>;
28
- onChange: (key: string, value: string) => void;
26
+ /**
27
+ * Current values, keyed by field key. Secrets are never read back from here.
28
+ *
29
+ * `unknown` and not `string`, because a config schema has booleans and numbers
30
+ * in it. This was `Record<string, string>` with a `string` `onChange`, which
31
+ * made it structurally impossible for the component to emit a JSON boolean —
32
+ * so a checkbox could only ever have produced the string "true"/"false", and
33
+ * `bool("false")` is `true`.
34
+ */
35
+ values: Record<string, unknown>;
36
+ onChange: (key: string, value: string | boolean) => void;
29
37
  /** Per-field messages. A `pattern` miss WARNS; only `required` blocks submit. */
30
38
  errors?: Record<string, string>;
31
39
  warnings?: Record<string, string>;
@@ -43,6 +51,32 @@ export interface CredentialSchemaFormProps {
43
51
  /** Field types this build renders natively. Anything else becomes text. */
44
52
  const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'textarea']);
45
53
 
54
+ /**
55
+ * A boolean is a CHECKBOX, and this is not a cosmetic preference.
56
+ *
57
+ * `boolean` has been in the server's `FIELD_TYPES` since the field grammar was
58
+ * written, and this component has never rendered one — it fell through to a text
59
+ * input. The most consequential setting the platform has is Granola's
60
+ * `ingest_transcripts`, which decides whether verbatim recordings of people's
61
+ * conversations are stored, and in a text box the way you turn it off is to type
62
+ * the word "false" — which `bool()` reads as ON.
63
+ */
64
+ const BOOLEAN_TYPES = new Set(['boolean', 'checkbox', 'bool']);
65
+
66
+ export function isBoolean(spec: ConnectorFieldSpec): boolean {
67
+ return !isSecret(spec) && BOOLEAN_TYPES.has(String(spec.type || '').toLowerCase());
68
+ }
69
+
70
+ /** The spellings of "off" that can arrive from a stored config or a form. */
71
+ export function asBoolean(value: unknown): boolean {
72
+ if (typeof value === 'string') {
73
+ return !['', '0', 'false', 'no', 'off', 'none', 'null', 'undefined'].includes(
74
+ value.trim().toLowerCase(),
75
+ );
76
+ }
77
+ return Boolean(value);
78
+ }
79
+
46
80
  /**
47
81
  * Is this field a secret? `type: "secret"` is the server's spelling and
48
82
  * `write_only` is the flag it enforces alongside it; `secret: true` is accepted
@@ -91,9 +125,14 @@ export function CredentialSchemaForm({
91
125
  // a value for it somehow reached this component. A masked input holding
92
126
  // the real key is one devtools panel away from being the leak the whole
93
127
  // credential surface is built to prevent.
128
+ const stored = values[spec.key];
129
+ // The DECLARED DEFAULT fills an unset field, so an untouched form shows
130
+ // what will actually happen rather than an empty box the admin has to
131
+ // guess at. `??` and not `||`: a stored `false` or `0` is a value.
94
132
  const value = isSecret(spec)
95
- ? (values[`${spec.key}__draft`] ?? '')
96
- : (values[spec.key] ?? spec.default ?? '');
133
+ ? String(values[`${spec.key}__draft`] ?? '')
134
+ : String(stored ?? spec.default ?? '');
135
+ const checked = asBoolean(stored ?? spec.default ?? false);
97
136
  const error = errors[spec.key];
98
137
  const warning = warnings[spec.key];
99
138
 
@@ -104,7 +143,15 @@ export function CredentialSchemaForm({
104
143
  {spec.required ? <span aria-hidden="true"> *</span> : null}
105
144
  </label>
106
145
 
107
- {type === 'select' ? (
146
+ {isBoolean(spec) ? (
147
+ <input
148
+ id={id}
149
+ type="checkbox"
150
+ checked={checked}
151
+ onChange={(e) => onChange(spec.key, e.target.checked)}
152
+ className={classNames.input ?? 'mt-1 h-4 w-4 rounded border-gray-300'}
153
+ />
154
+ ) : type === 'select' ? (
108
155
  <select
109
156
  id={id}
110
157
  value={value}
@@ -164,7 +211,7 @@ export function CredentialSchemaForm({
164
211
  */
165
212
  export function missingRequired(
166
213
  fields: ConnectorFieldSpec[],
167
- values: Record<string, string>,
214
+ values: Record<string, unknown>,
168
215
  ): string[] {
169
216
  return fields
170
217
  .filter((spec) => spec.required)
@@ -175,7 +222,7 @@ export function missingRequired(
175
222
  /** Fields whose value does not match a declared `pattern`. WARNS, never blocks. */
176
223
  export function patternWarnings(
177
224
  fields: ConnectorFieldSpec[],
178
- values: Record<string, string>,
225
+ values: Record<string, unknown>,
179
226
  ): Record<string, string> {
180
227
  const out: Record<string, string> = {};
181
228
  for (const spec of fields) {
@@ -260,6 +260,17 @@ function AnswerPanel({
260
260
  </p>
261
261
  ) : null}
262
262
 
263
+ {/* An answer built from a partly-withheld record has to say so
264
+ (bd startsim-768w.38.33) — otherwise "your corpus has nothing about
265
+ that" and "you were not shown the part that does" are the same
266
+ sentence, and the second one is the dangerous one to believe. */}
267
+ {result.withheldFields?.length ? (
268
+ <p className={cn.warning} role="status">
269
+ Some records were only partly shared with you ({result.withheldFields.join(', ')}),
270
+ so this answer may be incomplete.
271
+ </p>
272
+ ) : null}
273
+
263
274
  {/* A model asked for prose answers in Markdown. Rendering it as literal
264
275
  text puts "**$4,800**" on the screen, which reads as the product
265
276
  failing rather than as the model emphasising the number. */}