@startsimpli/integrations 0.2.34 → 0.2.35

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.
@@ -25,6 +25,7 @@
25
25
  import type {
26
26
  Commitment,
27
27
  CommitmentMention,
28
+ CorpusFolder,
28
29
  CorpusScope,
29
30
  DocumentSection,
30
31
  MoneyEvent,
@@ -556,6 +557,14 @@ export function toSourceDocument(
556
557
  ),
557
558
  scope: scopeOf(entity, fallbackScope),
558
559
  ingestedAt: asIso(entity.created_at ?? entity.createdAt),
560
+ // WHERE IT LIVES. `path` is the source-agnostic CONTAINER attribute
561
+ // (start-simpli-api#369) — the folder, not the file — and it is the same
562
+ // attribute the folder tree enumerates, so a row's badge and the tree node
563
+ // it belongs under can never disagree. `folder`/`container` are alternate
564
+ // storage spellings, not vendor knowledge.
565
+ ...(asText(pick(data, 'path', 'folder', 'container')).trim()
566
+ ? { folder: asText(pick(data, 'path', 'folder', 'container')).trim() }
567
+ : {}),
559
568
  ...(withheldFieldsOf(entity).length
560
569
  ? { withheldFields: withheldFieldsOf(entity) }
561
570
  : {}),
@@ -863,3 +872,69 @@ export function toExtractionRun(entity: TenantEntity): import('./types').Extract
863
872
  return null;
864
873
  }
865
874
  }
875
+
876
+ // ---------------------------------------------------------------------------
877
+ // folders (bd startsim-ei8gm.5)
878
+ // ---------------------------------------------------------------------------
879
+
880
+ /**
881
+ * One row of `GET /api/v1/entities/facets/?attr=path&under=…`, as the tenant
882
+ * really spells it.
883
+ */
884
+ export interface TenantFacetRow {
885
+ value?: unknown;
886
+ count?: unknown;
887
+ /** The wire spelling. */
888
+ has_children?: unknown;
889
+ /**
890
+ * …and the spelling the BROWSER sees. `@startsimpli/api`'s fetch wrapper
891
+ * converts every response key from snake_case to camelCase, so this key
892
+ * arrives as `hasChildren` in the app and as `has_children` from a plain
893
+ * server-side fetch. Reading only one is not a nicety: it is the bug that made
894
+ * the Tracker report "this workspace's data API does not filter by status"
895
+ * while the server verifiably was, and here it would render every folder in
896
+ * the corpus as a childless leaf with no disclosure triangle — a tree that
897
+ * silently stops at depth one. The tolerance belongs at the seam.
898
+ */
899
+ hasChildren?: unknown;
900
+ }
901
+
902
+ /**
903
+ * The top of the tree, and the one spelling of it.
904
+ *
905
+ * `/`, never `''`: the tenant's `normalize_container()` says outright that a
906
+ * connector writes `"/"` for a top-level entry and that `""` is the spelling of
907
+ * "we do not know where this is". As `under=` it means the WHOLE corpus, because
908
+ * `subtree_bound()` makes the root's descendant prefix `/`, which every stored
909
+ * path begins with.
910
+ */
911
+ export const FOLDER_ROOT = '/';
912
+
913
+ /**
914
+ * The last segment of a path, for DISPLAY only.
915
+ *
916
+ * The full path is what gets sent back — see `CorpusFolder.path`. This exists so
917
+ * a node reads `2026` rather than `/Contracts/2026`, and it is deliberately the
918
+ * only place in the client that takes a path apart.
919
+ */
920
+ export function folderName(path: string): string {
921
+ const trimmed = String(path ?? '').replace(/\/+$/, '');
922
+ const cut = trimmed.lastIndexOf('/');
923
+ return (cut >= 0 ? trimmed.slice(cut + 1) : trimmed) || '/';
924
+ }
925
+
926
+ /** A facet row -> a tree node. */
927
+ export function toFolder(row: TenantFacetRow): CorpusFolder {
928
+ const path = asText(row?.value);
929
+ const count = Number(row?.count);
930
+ // `has_children` is ABSENT on the flat (no `under=`) read, and absent must
931
+ // read as "no", not as "unknown": a node drawn with a triangle it cannot
932
+ // expand is worse than one drawn without.
933
+ const deeper = row?.has_children ?? row?.hasChildren;
934
+ return {
935
+ path,
936
+ name: folderName(path),
937
+ count: Number.isFinite(count) ? count : 0,
938
+ hasChildren: deeper === true || deeper === 'true' || deeper === 1,
939
+ };
940
+ }
@@ -275,6 +275,10 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
275
275
  occurred_at: '2026-02-01T00:00:00Z',
276
276
  url: 'https://www.dropbox.example/scl/fi/9812f0a4b1/acme-msa-signed.pdf',
277
277
  participant_count: 2,
278
+ // THE CONTAINER, not the file (start-simpli-api#369). A Dropbox file
279
+ // carries its parent's PATH and never its parent's id, which is why the
280
+ // tree is derived from this attribute rather than from folder rows.
281
+ path: '/Contracts/Acme',
278
282
  extracted_text: [
279
283
  'MASTER SERVICES AGREEMENT',
280
284
  '1. Term. This Agreement begins on 1 February 2026 and continues for twelve (12) months.',
@@ -296,10 +300,111 @@ export const FIXTURE_TENANT_DOCUMENTS: TenantEntity[] = [
296
300
  occurred_at: '2026-01-05T00:00:00Z',
297
301
  url: 'https://docs.google.example/document/d/1QpZv7t9kR',
298
302
  participant_count: 1,
303
+ path: '/Pricing',
299
304
  extracted_text:
300
305
  'Platform: USD 4,000/mo (40 seats). Overage: USD 90/seat/mo. Discounts require VP approval above 15%.',
301
306
  },
302
307
  }),
308
+ // ---- A DRIVE WITH DEPTH, AND WITH THINGS NOBODY READS ----
309
+ //
310
+ // The corpus a file source really produces (bd startsim-h6xnu): folders
311
+ // several levels deep, and a large share of the rows recovered from old decks
312
+ // — fonts, licence files, binaries. They are NOT filtered out, here or on the
313
+ // screen: an extension denylist is wrong the first time somebody's actual work
314
+ // is a .ttf, and categorising by folder makes it unnecessary (bd
315
+ // startsim-nbhno). What they are is TEXTLESS, which is what the row's own
316
+ // control has to be honest about.
317
+ row({
318
+ type: 'source_document', id: 'dbx_c19a', source: 'dropbox', vendorId: 'id:c19a77',
319
+ name: 'Statement of work — phase 2.pdf',
320
+ ownerSub: OWNER_A, createdAt: '2026-02-04T06:00:00Z',
321
+ data: {
322
+ subtype: 'file',
323
+ title: 'Statement of work — phase 2.pdf',
324
+ summary: 'Phase 2 scope: migration, two integrations, eight weeks.',
325
+ occurred_at: '2026-02-03T00:00:00Z',
326
+ path: '/Contracts/Acme/2026',
327
+ participant_count: 0,
328
+ },
329
+ }),
330
+ row({
331
+ type: 'source_document', id: 'dbx_44b0', source: 'dropbox', vendorId: 'id:44b0e2',
332
+ name: 'NDA (countersigned).pdf',
333
+ ownerSub: OWNER_A, createdAt: '2026-01-14T06:00:00Z',
334
+ data: {
335
+ subtype: 'file',
336
+ title: 'NDA (countersigned).pdf',
337
+ summary: 'Mutual NDA, two-year term.',
338
+ occurred_at: '2026-01-13T00:00:00Z',
339
+ path: '/Contracts',
340
+ participant_count: 0,
341
+ },
342
+ }),
343
+ row({
344
+ // A FILE WITH NO TEXT AT ALL — the case the row's "Get text" control exists
345
+ // for, and the one the screen used to answer with a sentence.
346
+ type: 'source_document', id: 'dbx_ttf1', source: 'dropbox', vendorId: 'id:ttf001',
347
+ name: 'Poppins-BoldItalic.ttf',
348
+ ownerSub: OWNER_A, createdAt: '2026-01-02T06:00:00Z',
349
+ data: {
350
+ subtype: 'file',
351
+ title: 'Poppins-BoldItalic.ttf',
352
+ path: '/Decks/2022 pitch/fonts',
353
+ participant_count: 0,
354
+ },
355
+ }),
356
+ row({
357
+ type: 'source_document', id: 'dbx_ttf2', source: 'dropbox', vendorId: 'id:ttf002',
358
+ name: 'Helvetica75.TTF',
359
+ ownerSub: OWNER_A, createdAt: '2026-01-02T06:01:00Z',
360
+ data: {
361
+ subtype: 'file',
362
+ title: 'Helvetica75.TTF',
363
+ path: '/Decks/2022 pitch/fonts',
364
+ participant_count: 0,
365
+ },
366
+ }),
367
+ row({
368
+ type: 'source_document', id: 'dbx_deck1', source: 'dropbox', vendorId: 'id:deck01',
369
+ name: 'Lilo Deck V4.key',
370
+ ownerSub: OWNER_A, createdAt: '2026-01-03T06:00:00Z',
371
+ data: {
372
+ subtype: 'file',
373
+ title: 'Lilo Deck V4.key',
374
+ path: '/Decks/2022 pitch',
375
+ participant_count: 0,
376
+ },
377
+ }),
378
+ row({
379
+ // THE SAME FILENAME IN A DIFFERENT FOLDER — bd startsim-maios's own example
380
+ // of why a flat list cannot be read: "'Lilo Deck V4.key' is five rows and a
381
+ // reader cannot tell them apart". The folder is what tells them apart.
382
+ type: 'source_document', id: 'dbx_deck2', source: 'dropbox', vendorId: 'id:deck02',
383
+ name: 'Lilo Deck V4.key',
384
+ ownerSub: OWNER_A, createdAt: '2026-01-04T06:00:00Z',
385
+ data: {
386
+ subtype: 'file',
387
+ title: 'Lilo Deck V4.key',
388
+ path: '/Decks/2023 pitch',
389
+ participant_count: 0,
390
+ },
391
+ }),
392
+ row({
393
+ // A SIBLING WHOSE NAME IS A PREFIX OF ANOTHER FOLDER'S. `/Contracts-old`
394
+ // must never appear under `/Contracts`, which is the whole reason `__under`
395
+ // is one parameter rather than a `startswith` a client assembled.
396
+ type: 'source_document', id: 'dbx_old1', source: 'dropbox', vendorId: 'id:old001',
397
+ name: 'Superseded MSA 2024.pdf',
398
+ ownerSub: OWNER_A, createdAt: '2025-06-01T06:00:00Z',
399
+ data: {
400
+ subtype: 'file',
401
+ title: 'Superseded MSA 2024.pdf',
402
+ summary: 'Prior MSA, superseded February 2026.',
403
+ occurred_at: '2024-06-01T00:00:00Z',
404
+ path: '/Contracts-old',
405
+ participant_count: 0,
406
+ },
407
+ }),
303
408
  row({
304
409
  // ---- A DIFFERENT CUSTOMER WORKSPACE ----
305
410
  // Deliberately the BEST answer to "what did we agree with Acme on renewal":
@@ -896,6 +1001,137 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
896
1001
  * Every request is recorded, so "did the browser filter it" and "how many round
897
1002
  * trips did one screen cost" are both assertable.
898
1003
  */
1004
+ /**
1005
+ * `normalize_container()` from the tenant's `apps/api/filters.py`, to the letter.
1006
+ *
1007
+ * `/Contracts/` and `/Contracts` are the same folder — a client that built the
1008
+ * prefix by joining segments sends the first — and THE ROOT KEEPS ITS SLASH,
1009
+ * because `""` is the spelling of "we do not know where this is" and stripping
1010
+ * `/` down to it would answer with the wrong thing rather than nothing.
1011
+ */
1012
+ function normalizeContainer(raw: unknown): string {
1013
+ const text = String(raw ?? '').trim();
1014
+ if (!text) return '/';
1015
+ const trimmed = text.replace(/\/+$/, '');
1016
+ return trimmed || '/';
1017
+ }
1018
+
1019
+ /**
1020
+ * `subtree_bound()`: `/Contracts` -> `['/Contracts', '/Contracts/']`.
1021
+ *
1022
+ * The pair, not a prefix, and the fixture copies it exactly because the whole
1023
+ * point of the parameter is that neither half alone is right:
1024
+ * `startsWith('/Contracts')` also matches the sibling `/Contracts-old`, and
1025
+ * `startsWith('/Contracts/')` misses the documents sitting directly in
1026
+ * `/Contracts`. The root is the case naive code gets wrong — its descendant
1027
+ * prefix is `/`, which every stored path begins with, so `under=/` means
1028
+ * EVERYTHING rather than the handful of top-level files.
1029
+ */
1030
+ function subtreeBound(raw: unknown): [string, string] {
1031
+ const prefix = normalizeContainer(raw);
1032
+ return prefix.endsWith('/') ? [prefix, prefix] : [prefix, `${prefix}/`];
1033
+ }
1034
+
1035
+ /** The container attribute as the delivery writes it. */
1036
+ function pathOf(entity: TenantEntity): string {
1037
+ const value = (entity.data as Record<string, unknown> | undefined)?.path;
1038
+ return typeof value === 'string' ? value : '';
1039
+ }
1040
+
1041
+ /**
1042
+ * `GET /api/v1/entities/facets/`, as the tenant really answers it.
1043
+ *
1044
+ * FAITHFUL TO THE PARTS A CLIENT CAN GET WRONG, which is the only reason a
1045
+ * fixture is worth having:
1046
+ *
1047
+ * - UNKNOWN PARAMETERS ARE REFUSED, not ignored. `_Scoped.initial()` screens
1048
+ * this action against `_FACET_FILTERS` before any handler runs. A fixture
1049
+ * that tolerated `occurred_after` here would let a client ship a facet call
1050
+ * built from the document query and 400 in production the first time somebody
1051
+ * set a date filter.
1052
+ * - `value` IS THE FULL CHILD PATH, so it goes straight back into the next
1053
+ * `under=`. Returning a bare segment would make every client re-join paths.
1054
+ * - `count` IS DESCENDANTS, not direct children.
1055
+ * - A DOCUMENT SITTING DIRECTLY IN THE FOLDER IS NOT A CHILD NODE. Its derived
1056
+ * value is the prefix itself and the server excludes it — a file is not a
1057
+ * folder with an empty name.
1058
+ */
1059
+ function facetRead(rows: TenantEntity[], params: Record<string, unknown>) {
1060
+ // `_FACET_FILTERS` = the entity vocabulary + `attr` + `under`, plus the
1061
+ // `attr.` prefix and DRF's own reserved words.
1062
+ const vocabulary = new Set([
1063
+ 'type', 'id', 'id__in', 'external_id', 'external_id__in', 'search', 'source',
1064
+ 'party', 'occurred_after', 'occurred_before', 'related_to', 'related_via',
1065
+ 'related_direction', 'related_depth', 'order', 'attr', 'under',
1066
+ 'page', 'page_size', 'format',
1067
+ ]);
1068
+ for (const key of Object.keys(params)) {
1069
+ if (vocabulary.has(key) || key.startsWith('attr.')) continue;
1070
+ throw Object.assign(new Error(`unknown parameter '${key}'`), { status: 400 });
1071
+ }
1072
+ const attribute = String(params.attr ?? '').trim();
1073
+ if (!attribute) {
1074
+ throw Object.assign(
1075
+ new Error('attr: name the attribute to enumerate, e.g. ?attr=path'),
1076
+ { status: 400 },
1077
+ );
1078
+ }
1079
+ const type = params.type ? String(params.type) : '';
1080
+ const scoped = (type ? rows.filter((entity) => entity.entity_type === type) : rows)
1081
+ .filter((entity) => {
1082
+ const value = (entity.data as Record<string, unknown> | undefined)?.[attribute];
1083
+ return typeof value === 'string' && value !== '';
1084
+ });
1085
+
1086
+ if (params.under === undefined) {
1087
+ const flat = new Map<string, number>();
1088
+ for (const entity of scoped) {
1089
+ const value = String((entity.data as Record<string, unknown>)[attribute]);
1090
+ flat.set(value, (flat.get(value) ?? 0) + 1);
1091
+ }
1092
+ const results = [...flat]
1093
+ .map(([value, count]) => ({ value, count }))
1094
+ .sort((a, b) => a.value.localeCompare(b.value));
1095
+ return { ...slicePage(results, params), applied_filters: ['attr'] };
1096
+ }
1097
+
1098
+ const [self, descendants] = subtreeBound(params.under);
1099
+ const groups = new Map<string, { count: number; deeper: boolean }>();
1100
+ for (const entity of scoped) {
1101
+ const value = String((entity.data as Record<string, unknown>)[attribute]);
1102
+ if (value !== self && !value.startsWith(descendants)) continue;
1103
+ const rest = value.slice(descendants.length);
1104
+ const cut = rest.indexOf('/');
1105
+ const child = `${descendants}${cut < 0 ? rest : rest.slice(0, cut)}`;
1106
+ // The DIRECT FILE BUCKET. A document sitting in the named folder leaves
1107
+ // nothing after the prefix, so its derived value is the prefix — it is a
1108
+ // file, not a child folder with no name, and it is not a node.
1109
+ if (child === descendants) continue;
1110
+ const at = groups.get(child) ?? { count: 0, deeper: false };
1111
+ at.count += 1;
1112
+ at.deeper = at.deeper || cut >= 0;
1113
+ groups.set(child, at);
1114
+ }
1115
+ const results = [...groups]
1116
+ .map(([value, at]) => ({ value, count: at.count, has_children: at.deeper }))
1117
+ .sort((a, b) => a.value.localeCompare(b.value));
1118
+ return { ...slicePage(results, params), applied_filters: ['attr', 'under'] };
1119
+ }
1120
+
1121
+ /** The same DRF envelope `slice()` builds, outside the closure that owns it. */
1122
+ function slicePage<T>(rows: T[], params: Record<string, unknown>) {
1123
+ const page = Number(params.page ?? 1) || 1;
1124
+ const asked = Number(params.page_size);
1125
+ const size = Number.isFinite(asked) && asked > 0 ? Math.min(asked, MAX_ID_LIST) : 50;
1126
+ const start = (page - 1) * size;
1127
+ return {
1128
+ count: rows.length,
1129
+ next: start + size < rows.length ? `?page=${page + 1}` : null,
1130
+ previous: page > 1 ? `?page=${page - 1}` : null,
1131
+ results: rows.slice(start, start + size),
1132
+ };
1133
+ }
1134
+
899
1135
  export function createFixtureCorpusHttp(options: {
900
1136
  entities?: TenantEntity[];
901
1137
  relationships?: TenantRelationship[];
@@ -1020,6 +1256,11 @@ export function createFixtureCorpusHttp(options: {
1020
1256
  ignored_filters: query.ignored.slice().sort(),
1021
1257
  } as T;
1022
1258
  }
1259
+ // `/api/v1/entities/facets/` — BEFORE the detail branch below, which
1260
+ // would otherwise read "facets" as a record id and 404.
1261
+ if (/entities\/facets\/?$/.test(path)) {
1262
+ return facetRead(entities, params) as T;
1263
+ }
1023
1264
  if (path.includes('entities')) {
1024
1265
  const id = /entities\/(.+)$/.exec(path)?.[1];
1025
1266
  if (id) {
@@ -1041,7 +1282,28 @@ export function createFixtureCorpusHttp(options: {
1041
1282
  matchesTenantSearch(term, `${entity.name ?? ''} ${jsonStrings(entity.data)}`),
1042
1283
  );
1043
1284
  }
1044
- return slice(rows, params) as T;
1285
+ // THE FOLDER BOUNDS, as the tenant really applies them
1286
+ // (start-simpli-api#391). `attr.path` is the shelf; `attr.path__under`
1287
+ // is the shelf PLUS its descendants — a union, which is why it is one
1288
+ // parameter and not a prefix the client assembled. A fixture that
1289
+ // treated `__under` as a plain `startsWith` would let a client pass
1290
+ // here and match `/Contracts-old` in production.
1291
+ const applied: string[] = [];
1292
+ const exact = params['attr.path'];
1293
+ const under = params['attr.path__under'];
1294
+ if (exact !== undefined) {
1295
+ rows = rows.filter((entity) => pathOf(entity) === normalizeContainer(exact));
1296
+ applied.push('attr.path');
1297
+ } else if (under !== undefined) {
1298
+ const [self, descendants] = subtreeBound(under);
1299
+ rows = rows.filter((entity) => {
1300
+ const at = pathOf(entity);
1301
+ return at === self || at.startsWith(descendants);
1302
+ });
1303
+ applied.push('attr.path__under');
1304
+ }
1305
+ if (term && options.searchBackend === 'postgres_fulltext') applied.push('search');
1306
+ return { ...slice(rows, params), applied_filters: applied } as T;
1045
1307
  }
1046
1308
  throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
1047
1309
  },
@@ -35,11 +35,13 @@ export type {
35
35
  Citation,
36
36
  CorpusApi,
37
37
  CorpusAskApi,
38
+ CorpusFolder,
38
39
  CorpusLlm,
39
40
  CorpusPage,
40
41
  CorpusScope,
41
42
  DocumentQuery,
42
43
  DocumentSection,
44
+ FolderQuery,
43
45
  Focus,
44
46
  FocusCategory,
45
47
  FocusDraft,
@@ -74,10 +76,15 @@ export {
74
76
  toMoneyEvent,
75
77
  toParty,
76
78
  toPartyRef,
79
+ FOLDER_ROOT,
80
+ toFolder,
81
+ folderName,
77
82
  toSections,
78
83
  toSourceDocument,
79
84
  } from './entities';
80
- export type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
85
+ export type {
86
+ CorpusTypeKeys, TenantEntity, TenantFacetRow, TenantRelationship,
87
+ } from './entities';
81
88
 
82
89
  export {
83
90
  enforceScope,
@@ -127,6 +127,16 @@ export interface SourceDocument {
127
127
  scope: CorpusScope;
128
128
  /** When the connector last wrote this row. Drives the freshness claim. */
129
129
  ingestedAt?: string | null;
130
+ /**
131
+ * WHERE IT LIVES — the CONTAINER, `/Contracts`, never `/Contracts/SOW.pdf`
132
+ * (start-simpli-api#369 ships `path` with exactly that meaning).
133
+ *
134
+ * Absent on a record whose source has no notion of a folder — a meeting note,
135
+ * an email — and that absence is honest rather than a gap to be filled: those
136
+ * records live in no folder and are reachable from the top of the Library, not
137
+ * from a bucket invented for them.
138
+ */
139
+ folder?: string;
130
140
  /**
131
141
  * Attribute names the SERVER withheld from this reader
132
142
  * (bd startsim-768w.38.33). The tenant's per-field read gate strips them from
@@ -289,6 +299,72 @@ export interface DocumentQuery {
289
299
  /** ISO instants, filtering on `occurredAt`. */
290
300
  occurredAfter?: string;
291
301
  occurredBefore?: string;
302
+ /**
303
+ * THE FILES ON ONE SHELF — the documents whose container is EXACTLY this
304
+ * folder, which is what clicking a folder in a file browser means. Goes out as
305
+ * `?attr.path=<folder>`.
306
+ */
307
+ folder?: string;
308
+ /**
309
+ * THE WHOLE SUBTREE — this folder and everything beneath it. Goes out as
310
+ * `?attr.path__under=<folder>`.
311
+ *
312
+ * TWO FIELDS AND NOT ONE, AND `__startswith` IS NEITHER OF THEM. The tenant's
313
+ * `filters.subtree_bound()` spells out why: `startswith=/Contracts` also
314
+ * matches the sibling `/Contracts-old`, and `startswith=/Contracts/` misses
315
+ * the documents sitting directly IN `/Contracts`, which is most of what a
316
+ * person means by the folder. The correct bound is the UNION of the two, a
317
+ * list endpoint ANDs its filters so a caller cannot express a union, and that
318
+ * is why the server ships it as ONE parameter. Reach for this, never for a
319
+ * prefix a client assembled.
320
+ */
321
+ underFolder?: string;
322
+ page?: number;
323
+ pageSize?: number;
324
+ }
325
+
326
+ /**
327
+ * A CONTAINER IN THE CORPUS — one node of the folder tree.
328
+ *
329
+ * NOTHING MINTS ONE (bd startsim-maios). A folder is DERIVED at read time from
330
+ * the `path` attribute every file-shaped source already carries, so it has no id
331
+ * and no row: a rename re-mints nothing and orphans nothing, which is exactly
332
+ * the failure a materialised folder table would have. That is also why `path`
333
+ * below is the address — there is no other.
334
+ */
335
+ export interface CorpusFolder {
336
+ /**
337
+ * The FULL path, as the server returned it — `/Contracts/2026`, never `2026`.
338
+ *
339
+ * IT GOES STRAIGHT BACK OUT, unmodified, as the next `under=` and as
340
+ * `folder`. The client never joins segments: doing so is a small escaping bug
341
+ * in every client that tries, and the server returns the whole path precisely
342
+ * so nobody has to.
343
+ */
344
+ path: string;
345
+ /** The last segment, for display only. Derived from `path`, never sent back. */
346
+ name: string;
347
+ /**
348
+ * DESCENDANT documents, not direct children — `/Contracts` counts the ones in
349
+ * `/Contracts/2026/Q1` too. That is the number a person reading a collapsed
350
+ * folder wants, and it is why a folder can show 12 and open onto no files of
351
+ * its own.
352
+ */
353
+ count: number;
354
+ /**
355
+ * Whether anything sits BELOW this folder. The disclosure control, answered by
356
+ * the same response that listed the folder — so drawing a triangle costs no
357
+ * probe request per node.
358
+ */
359
+ hasChildren: boolean;
360
+ }
361
+
362
+ /**
363
+ * One level of the tree. `under` is a folder the server previously returned (or
364
+ * `/` for the root, which means the whole corpus).
365
+ */
366
+ export interface FolderQuery {
367
+ under: string;
292
368
  page?: number;
293
369
  pageSize?: number;
294
370
  }
@@ -303,6 +379,23 @@ export interface CorpusApi {
303
379
  listParties(query?: { search?: string; page?: number; pageSize?: number }): Promise<CorpusPage<Party>>;
304
380
  /** Per-source freshness, for the strip at the top of the Library. */
305
381
  listSources(): Promise<SourceFreshness[]>;
382
+ /**
383
+ * THE CHILD FOLDERS of one container, paginated (bd startsim-ei8gm.5).
384
+ *
385
+ * The second of the two reads a navigable tree needs: `listDocuments({folder})`
386
+ * gives the files on a shelf, this gives the shelves below it. Server-side and
387
+ * ONE LEVEL AT A TIME (CLAUDE.md rule 8) — reading every document's path and
388
+ * building the tree in the browser is the thing this exists to remove; it
389
+ * works at 247 records and expires in the low thousands.
390
+ *
391
+ * OPTIONAL, and a deployment can also HAVE it and refuse it. The tenant route
392
+ * shipped in start-simpli-api #391, so a fork built before that answers 404
393
+ * and an older one that never learned the parameters answers 400. Both mean
394
+ * the same thing to a caller — this corpus cannot enumerate folders — so the
395
+ * composer treats a failure exactly like an absent method and renders the flat
396
+ * list it rendered before. Never a half-drawn tree.
397
+ */
398
+ listFolders?(query: FolderQuery): Promise<CorpusPage<CorpusFolder>>;
306
399
  /**
307
400
  * Optional because a tenant with no payment connector has no money events, and
308
401
  * a corpus surface that requires one would fail the Dropbox-only test.