@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.
@@ -48,6 +48,7 @@ import type {
48
48
  ConnectionTarget,
49
49
  ConnectorAction,
50
50
  ConnectorCatalogEntry,
51
+ ConnectorFieldSpec,
51
52
  ConnectionsApi,
52
53
  } from './types';
53
54
 
@@ -267,7 +268,7 @@ function CatalogCard({
267
268
  onConnected: () => void;
268
269
  }) {
269
270
  const [open, setOpen] = React.useState(false);
270
- const [values, setValues] = React.useState<Record<string, string>>({});
271
+ const [values, setValues] = React.useState<Record<string, unknown>>({});
271
272
  const [busy, setBusy] = React.useState(false);
272
273
  const [error, setError] = React.useState('');
273
274
  // Every declared capability starts ON. Not because "on" is a safe default in
@@ -296,7 +297,11 @@ function CatalogCard({
296
297
  }
297
298
  for (const spec of configFields) {
298
299
  const v = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
299
- if (v) config[spec.key] = v;
300
+ // `if (v)` DROPPED A FALSE, which for a checkbox makes "off" and
301
+ // "untouched" the same request. Harmless while every config field was a
302
+ // string; not harmless once one of them decides whether verbatim
303
+ // recordings are stored.
304
+ if (typeof v === 'boolean' || typeof v === 'number' || v) config[spec.key] = v;
300
305
  }
301
306
  await api.connect(foundrySlug, {
302
307
  connector_key: entry.key, name: entry.name, credentials, config,
@@ -428,7 +433,7 @@ function CatalogCard({
428
433
  * a secret never means re-typing the settings around it.
429
434
  *
430
435
  * Only the CREDENTIAL fields render. `config` is not a credential and is edited
431
- * through the connection's own settings; mixing them here would make a
436
+ * through `ConnectionSettingsForm` below; mixing them here would make a
432
437
  * rotation quietly a config change.
433
438
  */
434
439
  function ReplaceKeyForm({
@@ -441,7 +446,7 @@ function ReplaceKeyForm({
441
446
  classNames: typeof DEFAULTS;
442
447
  onDone: () => void;
443
448
  }) {
444
- const [values, setValues] = React.useState<Record<string, string>>({});
449
+ const [values, setValues] = React.useState<Record<string, unknown>>({});
445
450
  const [busy, setBusy] = React.useState(false);
446
451
  const [error, setError] = React.useState('');
447
452
 
@@ -514,6 +519,101 @@ function ReplaceKeyForm({
514
519
  // a connected account: discover -> wire -> confirm -> fire
515
520
  // --------------------------------------------------------------------------
516
521
 
522
+ /**
523
+ * The connection's own settings — its connector's `config_schema`, for an
524
+ * account that already exists.
525
+ *
526
+ * WHY IT IS A SEPARATE FORM FROM THE CONNECT CARD. Connecting is "here are my
527
+ * credentials, does this work"; this is "change how it behaves". Rotating a key
528
+ * must not quietly change a setting, and changing a setting must not ask for a
529
+ * key. `ReplaceKeyForm` has said as much in its own docstring since it shipped,
530
+ * pointing at a surface that did not exist.
531
+ *
532
+ * IT SENDS ONLY WHAT CHANGED, and the API merges. Two reasons, and the second is
533
+ * the one that bites: resending untouched fields turns "I ticked one box" into
534
+ * "I reassert every setting", which silently overwrites an edit made in another
535
+ * tab — and it is exactly how the retention bound gets reset to whatever this
536
+ * form happened to be showing.
537
+ *
538
+ * A FALSE IS SENT, not omitted. The connect card drops falsy values, which for a
539
+ * checkbox makes "off" indistinguishable from "untouched" — and against a
540
+ * merging API that would make the off position literally unreachable. For
541
+ * `ingest_transcripts` the off position is the whole privacy posture.
542
+ */
543
+ function ConnectionSettingsForm({
544
+ api, foundrySlug, connection, fields, classNames: cn, onDone,
545
+ }: {
546
+ api: ConnectionsApi;
547
+ foundrySlug: string;
548
+ connection: Connection;
549
+ fields: ConnectorFieldSpec[];
550
+ classNames: typeof DEFAULTS;
551
+ onDone: () => void;
552
+ }) {
553
+ const stored = React.useMemo(
554
+ () => (connection.config ?? {}) as Record<string, unknown>,
555
+ [connection.config],
556
+ );
557
+ const [edits, setEdits] = React.useState<Record<string, string | boolean>>({});
558
+ const [busy, setBusy] = React.useState(false);
559
+ const [error, setError] = React.useState('');
560
+
561
+ const values = React.useMemo(() => ({ ...stored, ...edits }), [stored, edits]);
562
+ const warnings = patternWarnings(fields, values);
563
+ const changed = Object.keys(edits);
564
+
565
+ async function save() {
566
+ setBusy(true);
567
+ setError('');
568
+ try {
569
+ // Numbers go back as numbers. A `number` input hands back a string, and a
570
+ // retention bound stored as "0" rather than 0 is a comparison that quietly
571
+ // stops being true.
572
+ const config: Record<string, unknown> = {};
573
+ for (const key of changed) {
574
+ const spec = fields.find((field) => field.key === key);
575
+ const value = edits[key];
576
+ config[key] =
577
+ spec?.type === 'number' && typeof value === 'string' && value.trim() !== ''
578
+ ? Number(value)
579
+ : value;
580
+ }
581
+ await api.updateConnection(foundrySlug, connection.id, { config });
582
+ onDone();
583
+ } catch (err) {
584
+ setError((err as { message?: string })?.message || 'Those settings were not saved.');
585
+ } finally {
586
+ setBusy(false);
587
+ }
588
+ }
589
+
590
+ return (
591
+ <div className="mt-3 space-y-3 border-t pt-3" role="group" aria-label="Connection settings">
592
+ <CredentialSchemaForm
593
+ fields={fields}
594
+ values={values}
595
+ onChange={(key, value) => setEdits((current) => ({ ...current, [key]: value }))}
596
+ warnings={warnings}
597
+ idPrefix={`settings-${connection.id}`}
598
+ />
599
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
600
+ <div className="flex items-center gap-2">
601
+ <button
602
+ type="button"
603
+ className={cn.button}
604
+ disabled={busy || changed.length === 0}
605
+ onClick={() => void save()}
606
+ >
607
+ {busy ? 'Saving…' : 'Save settings'}
608
+ </button>
609
+ {changed.length === 0 ? (
610
+ <span className={cn.muted}>Nothing changed yet.</span>
611
+ ) : null}
612
+ </div>
613
+ </div>
614
+ );
615
+ }
616
+
517
617
  function ConnectionCard({
518
618
  api, foundrySlug, connection, entry, canManage, classNames: cn, onChanged,
519
619
  }: {
@@ -533,6 +633,7 @@ function ConnectionCard({
533
633
  const [error, setError] = React.useState('');
534
634
  const [notice, setNotice] = React.useState('');
535
635
  const [replacing, setReplacing] = React.useState(false);
636
+ const [settingsOpen, setSettingsOpen] = React.useState(false);
536
637
  const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
537
638
 
538
639
  const declared = entry?.capabilities;
@@ -568,8 +669,10 @@ function ConnectionCard({
568
669
  }
569
670
  }
570
671
 
672
+ const configFields = schemaFields(entry?.config_schema);
673
+
571
674
  return (
572
- <div className={cn.card}>
675
+ <div className={cn.card} data-connection={connection.id}>
573
676
  <div className="flex items-start justify-between gap-4">
574
677
  <div>
575
678
  <p className="font-medium text-gray-900">{connection.name}</p>
@@ -624,12 +727,35 @@ function ConnectionCard({
624
727
  >
625
728
  {busy === 'discover' ? 'Loading…' : 'Workflows'}
626
729
  </button>
730
+ {/* SETTINGS, which this card has never had. A connector's
731
+ `config_schema` ships on every catalog row and was rendered
732
+ exactly once — on the connect form, before the account existed.
733
+ After that a setting could not be changed from the product at
734
+ all, which for Granola means the switch deciding whether verbatim
735
+ recordings of people's conversations are stored was reachable
736
+ only by editing the database. `ReplaceKeyForm` already pointed at
737
+ "the connection's own settings" as if this existed. */}
738
+ {configFields.length ? (
739
+ <button
740
+ type="button"
741
+ className={cn.button}
742
+ disabled={Boolean(busy) || confirmingDisconnect}
743
+ onClick={() => {
744
+ setReplacing(false);
745
+ setError('');
746
+ setSettingsOpen((v) => !v);
747
+ }}
748
+ >
749
+ {settingsOpen ? 'Close settings' : 'Settings'}
750
+ </button>
751
+ ) : null}
627
752
  <button
628
753
  type="button"
629
754
  className={cn.button}
630
755
  disabled={Boolean(busy) || confirmingDisconnect}
631
756
  onClick={() => {
632
757
  setReplacing(false);
758
+ setSettingsOpen(false);
633
759
  setError('');
634
760
  setConfirmingDisconnect(true);
635
761
  }}
@@ -640,6 +766,17 @@ function ConnectionCard({
640
766
  ) : null}
641
767
  </div>
642
768
 
769
+ {settingsOpen ? (
770
+ <ConnectionSettingsForm
771
+ api={api}
772
+ foundrySlug={foundrySlug}
773
+ connection={connection}
774
+ fields={configFields}
775
+ classNames={cn}
776
+ onDone={() => { setSettingsOpen(false); setNotice('Settings saved.'); onChanged(); }}
777
+ />
778
+ ) : null}
779
+
643
780
  {replacing ? (
644
781
  <ReplaceKeyForm
645
782
  api={api}
@@ -114,11 +114,31 @@ function relative(value: string | null | undefined, now: number): string {
114
114
  * and the row is a summary, not the document.
115
115
  */
116
116
  function preview(document: SourceDocument, length = 180): string {
117
- const text = stripMarkdown(document.body || document.sections[0]?.text || '');
117
+ const text = stripMarkdown(
118
+ summaryOf(document) || document.body || document.sections[0]?.text || '',
119
+ );
118
120
  if (!text) return '';
119
121
  return text.length > length ? `${text.slice(0, length).trim()}…` : text;
120
122
  }
121
123
 
124
+ /**
125
+ * The short form, when the source wrote one.
126
+ *
127
+ * `body` falls back to the summary on a corpus that has never delivered a full
128
+ * text, so on today's tenant these are the same string and this returns it. When
129
+ * both exist they are different, and only this one belongs under the heading
130
+ * "Summary".
131
+ */
132
+ function summaryOf(document: SourceDocument): string {
133
+ return document.summary || (document.body ?? '');
134
+ }
135
+
136
+ /** The verbatim text, when it is genuinely different from the summary. */
137
+ function fullTextOf(document: SourceDocument): string {
138
+ const body = document.body ?? '';
139
+ return body && body !== summaryOf(document) ? body : '';
140
+ }
141
+
122
142
  // ---------------------------------------------------------------------------
123
143
 
124
144
  export function LibraryPage({
@@ -589,15 +609,48 @@ function DocumentDetail({
589
609
  </div>
590
610
  ) : null}
591
611
 
592
- {document.body ? (
612
+ {/* THE SUMMARY, UNDER THE WORD SUMMARY. This used to render `document.body`
613
+ here — which was the summary only for as long as nothing had ever
614
+ delivered a full text. The moment `ingest_transcripts` lands, `body` is
615
+ forty minutes of verbatim speech, and it would have appeared on this
616
+ screen labelled "Summary" while the actual summary appeared nowhere. */}
617
+ {summaryOf(document) ? (
593
618
  <div className="mt-3">
594
619
  <p className="text-sm font-medium text-gray-900">Summary</p>
595
620
  {/* Sources write Markdown — a literal "## Decisions" on the screen
596
621
  reads as a broken product, not as the source's punctuation. */}
597
- <Markdown text={document.body} className="mt-1 text-sm text-gray-700" />
622
+ <Markdown text={summaryOf(document)} className="mt-1 text-sm text-gray-700" />
598
623
  </div>
599
624
  ) : null}
600
625
 
626
+ {/* The FULL text, collapsed, next to the source's other long-form. Behind
627
+ a disclosure and not open by default, for the same reason a transcript
628
+ is opt-in at all: verbatim speech is a different data class from a
629
+ summary, and a screen that puts it in front of whoever opened the
630
+ record has quietly decided that on the reader's behalf. */}
631
+ {fullTextOf(document) ? (
632
+ <details className="mt-3">
633
+ <summary className="cursor-pointer text-sm font-medium text-gray-900">
634
+ Full text
635
+ </summary>
636
+ <pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-xs text-gray-700">
637
+ {fullTextOf(document)}
638
+ </pre>
639
+ </details>
640
+ ) : null}
641
+
642
+ {/* WITHHELD IS NOT EMPTY (bd startsim-768w.38.33). The tenant's per-field
643
+ read gate strips a restricted attribute and names it in
644
+ `redacted_fields`; without this line the reader sees a meeting with no
645
+ transcript and concludes there wasn't one. Named rather than counted,
646
+ because "the transcript" and "the salary" are different absences. */}
647
+ {document.withheldFields?.length ? (
648
+ <p className={`mt-3 ${cn.warning}`} role="status">
649
+ Part of this record is not shared with you:{' '}
650
+ {document.withheldFields.join(', ')}. Ask an admin if you need it.
651
+ </p>
652
+ ) : null}
653
+
601
654
  {/* Long-form under the SOURCE's own name for it — "Transcript" for a
602
655
  meeting, "Extracted text" for a PDF. This component does not know
603
656
  which is which, and must not. */}
@@ -407,6 +407,23 @@ function RunSummary({
407
407
  out of the notes by {result.provider.live ? `${result.provider.provider} · ${result.provider.model}` : 'no live model'}.
408
408
  </p>
409
409
 
410
+ {/* WHAT THE CHECK THREW AWAY, on the screen.
411
+ Measured over the sixteen real meetings: the model proposes about
412
+ ninety items and roughly two thirds of them do not survive. That is not
413
+ a bug to hide — it is the number that keeps the list believable, and
414
+ hiding it would leave "the model read 30 out of the notes" looking like
415
+ the model is simply better than it is. If `verified` is ever false the
416
+ run says so rather than implying a check that did not happen. */}
417
+ {result.fromModel || result.ungrounded || result.unconfirmed ? (
418
+ <p className={cn.muted}>
419
+ {result.verified
420
+ ? `Checked against the meetings themselves: ${result.unconfirmed + result.ungrounded} more were proposed and dropped — ` +
421
+ `${result.ungrounded} quoted something the record does not say, ` +
422
+ `${result.unconfirmed} could not be confirmed as something anyone actually took on.`
423
+ : 'These were NOT checked against the meetings — the verification step did not run, so treat them as suggestions.'}
424
+ </p>
425
+ ) : null}
426
+
410
427
  {result.needsReview.length ? (
411
428
  // Weak resolution matches are shown, never applied. A to-do list that
412
429
  // quietly empties itself is the one failure with no symptom.
package/src/corpus/ask.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { moneyEventAsDocument } from './entities';
22
22
  import { retrieveForQuestion } from './retrieval';
23
+ import { documentText } from './text';
23
24
  import type {
24
25
  AnswerRequest,
25
26
  AskRequest,
@@ -74,6 +75,7 @@ function systemPrompt(mode: AskRequest['mode']): string {
74
75
  'You answer questions about a company using only that company\'s own records.',
75
76
  ...shared,
76
77
  'Be specific and short. Prefer naming the record and the date over hedging.',
78
+ 'A document may list "withheld_from_this_reader". Those parts were deliberately not shown to you: never claim something was not said or did not happen on the basis of a document that lists them — say the record is partly withheld instead.',
77
79
  ].join('\n');
78
80
  }
79
81
 
@@ -93,6 +95,7 @@ export function buildAnswerRequest(
93
95
  occurredAt: document.occurredAt,
94
96
  participants: document.participants.map((party) => party.name),
95
97
  text: documentText(document, maxChars),
98
+ ...(document.withheldFields?.length ? { withheldFields: document.withheldFields } : {}),
96
99
  }));
97
100
  return {
98
101
  question: request.question,
@@ -103,15 +106,18 @@ export function buildAnswerRequest(
103
106
  }
104
107
 
105
108
  /**
106
- * A document's text for the prompt: body first, then each named section, cut at
107
- * a budget. Sections are labelled with the source's own words, so the model sees
108
- * "Transcript" or "Extracted text" without this package knowing what either is.
109
+ * A document's text for the prompt: the summary first, then the full text, then
110
+ * each named section, cut at a budget. Sections are labelled with the source's
111
+ * own words, so the model sees "Transcript" or "Extracted text" without this
112
+ * package knowing what either is.
113
+ *
114
+ * ONE IMPLEMENTATION, in `./text`, shared with extraction. It used to be two
115
+ * near-identical private copies with different budgets, which is how the Ask
116
+ * surface came within one config flag of spending its entire 6,000-character
117
+ * per-document budget on the opening minutes of a transcript and never showing
118
+ * the model the summary at all.
109
119
  */
110
- export function documentText(document: SourceDocument, maxChars: number): string {
111
- const parts = [document.body, ...document.sections.map((s) => `## ${s.label}\n${s.text}`)];
112
- const joined = parts.filter(Boolean).join('\n\n');
113
- return joined.length > maxChars ? `${joined.slice(0, maxChars)}\n…[truncated]` : joined;
114
- }
120
+ export { documentText };
115
121
 
116
122
  /** Render the batched request as the single user message. */
117
123
  export function renderAnswerPrompt(input: AnswerRequest): string {
@@ -122,6 +128,13 @@ export function renderAnswerPrompt(input: AnswerRequest): string {
122
128
  `source: ${document.source}`,
123
129
  `occurred_at: ${document.occurredAt ?? 'unknown'}`,
124
130
  document.participants.length ? `participants: ${document.participants.join(', ')}` : '',
131
+ // The model is told what it was NOT given. Otherwise a summary-only view of
132
+ // a meeting whose transcript was withheld is indistinguishable from a
133
+ // meeting where the thing was never said, and the answer comes back as a
134
+ // confident negative about text nobody was allowed to show it.
135
+ document.withheldFields?.length
136
+ ? `withheld_from_this_reader: ${document.withheldFields.join(', ')}`
137
+ : '',
125
138
  '',
126
139
  document.text,
127
140
  '</document>',
@@ -275,6 +288,8 @@ export async function askCorpus(
275
288
  };
276
289
  }
277
290
 
291
+ const withheldFields = collectWithheld(retrieved);
292
+
278
293
  const draft = await llm.answer(
279
294
  buildAnswerRequest(request, retrieved, maxCharsPerDocument),
280
295
  );
@@ -290,11 +305,21 @@ export async function askCorpus(
290
305
  considered: retrieved,
291
306
  unresolvedCitations: unresolved,
292
307
  droppedOutOfScope,
308
+ ...(withheldFields.length ? { withheldFields } : {}),
293
309
  provider: llm.describe(),
294
310
  freshness,
295
311
  };
296
312
  }
297
313
 
314
+ /** Every field withheld on any record that reached the prompt, deduped. */
315
+ function collectWithheld(retrieved: RetrievedDocument[]): string[] {
316
+ const names = new Set<string>();
317
+ for (const item of retrieved) {
318
+ for (const name of item.document.withheldFields ?? []) names.add(name);
319
+ }
320
+ return [...names].sort();
321
+ }
322
+
298
323
  /**
299
324
  * `uncited` is a real outcome, not a failure to report. The model produced text
300
325
  * and nothing traceable; the surface renders that as a warning over the text
@@ -85,6 +85,14 @@ export interface TenantEntity {
85
85
  orgId?: string;
86
86
  owner_sub?: string;
87
87
  ownerSub?: string;
88
+ /**
89
+ * Attribute names the tenant's per-field read gate withheld from this caller
90
+ * (bd startsim-768w.38.33). The server has ALREADY removed them from `data`;
91
+ * this names them so the absence is legible rather than mistaken for a record
92
+ * that never had them.
93
+ */
94
+ redacted_fields?: string[];
95
+ redactedFields?: string[];
88
96
  }
89
97
 
90
98
  /**
@@ -111,9 +119,35 @@ export const DEFAULT_TYPE_KEYS: CorpusTypeKeys = {
111
119
  // tolerant accessors
112
120
  // ---------------------------------------------------------------------------
113
121
 
122
+ /**
123
+ * Field names the server says it withheld from this caller
124
+ * (bd startsim-768w.38.33). Both spellings, for the same reason every other
125
+ * accessor here takes both: the `@startsimpli/api` client transforms
126
+ * snake_case↔camelCase and either client can fetch a row.
127
+ */
128
+ export function withheldFieldsOf(entity: TenantEntity): string[] {
129
+ const raw = entity.redacted_fields ?? entity.redactedFields;
130
+ return Array.isArray(raw) ? raw.map((name) => String(name)).filter(Boolean) : [];
131
+ }
132
+
114
133
  function bag(entity: TenantEntity): Record<string, unknown> {
115
134
  const data = entity.data;
116
- return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
135
+ const payload = data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
136
+ // BELT AND BRACES, ON PURPOSE. The gate is enforced server-side and `data`
137
+ // arrives already stripped — so in the normal case this loop removes nothing.
138
+ // It runs anyway because `redacted_fields` is the server telling us what it
139
+ // meant to withhold, and a build where the two disagree (a half-rolled deploy,
140
+ // a response cached from an admin's session, a serializer edited without its
141
+ // gate) is exactly the case where a client that trusts `data` alone quotes a
142
+ // transcript into an answer. Honouring the server's own declaration costs one
143
+ // pass over a small object and turns that class of bug into a no-op.
144
+ const withheld = withheldFieldsOf(entity);
145
+ if (!withheld.length) return payload;
146
+ const out: Record<string, unknown> = {};
147
+ for (const [key, value] of Object.entries(payload)) {
148
+ if (!withheld.includes(key)) out[key] = value;
149
+ }
150
+ return out;
117
151
  }
118
152
 
119
153
  /**
@@ -391,6 +425,17 @@ export function toSourceDocument(
391
425
  const data = bag(entity);
392
426
  const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
393
427
 
428
+ // TWO TEXT FIELDS, NOT ONE, because the canonical schema declares two and says
429
+ // why: "Collapsing them means the transcript clobbers the summary the first
430
+ // time both arrive". This mapper collapsed them anyway —
431
+ // `pick(data,'body',…,'summary')` returns the first present key — so a
432
+ // document carrying both would have surfaced ONLY the transcript, and the
433
+ // `## Next Steps` block that the entire deterministic rules pass reads lives
434
+ // in the summary. Switching `ingest_transcripts` on would have taken a
435
+ // 95.8%-precision, 100%-owner-accuracy pass to zero, silently, on deploy.
436
+ const summary = asText(pick(data, 'summary', 'description'));
437
+ const fullText = asText(pick(data, 'body', 'text', 'content'));
438
+
394
439
  const sections = toSections(pick(data, 'sections'));
395
440
  // A source that stored its long-form under a bare attribute rather than in
396
441
  // `sections` still contributes one. Listed keys are storage spellings, not
@@ -407,7 +452,13 @@ export function toSourceDocument(
407
452
  asText(pick(data, 'title', 'subject', 'headline')) ||
408
453
  entity.name ||
409
454
  `Untitled ${source} record`,
410
- body: asText(pick(data, 'body', 'text', 'summary', 'description', 'content')),
455
+ // `body` still ANSWERS for the summary when the source has never delivered a
456
+ // full text, which is every row on the live tenant today. Every existing
457
+ // consumer of `.body` — retrieval, the Ask prompt, the Library preview —
458
+ // keeps working unchanged on a corpus of summaries; only a corpus that has
459
+ // both sees a difference, and that is the corpus this distinction is for.
460
+ body: fullText || summary,
461
+ ...(summary ? { summary } : {}),
411
462
  sections,
412
463
  occurredAt: asIso(pick(data, 'occurred_at', 'occurredAt', 'happened_at', 'date')),
413
464
  source,
@@ -432,6 +483,9 @@ export function toSourceDocument(
432
483
  ),
433
484
  scope: scopeOf(entity, fallbackScope),
434
485
  ingestedAt: asIso(entity.created_at ?? entity.createdAt),
486
+ ...(withheldFieldsOf(entity).length
487
+ ? { withheldFields: withheldFieldsOf(entity) }
488
+ : {}),
435
489
  };
436
490
  }
437
491