@startsimpli/integrations 0.2.25 → 0.2.27

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.
@@ -39,6 +39,14 @@ import {
39
39
  toSourceDocument,
40
40
  } from './entities';
41
41
  import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
42
+ import {
43
+ FOCUS_TYPE,
44
+ IN_FOCUS,
45
+ SUB_FOCUS,
46
+ authoredItemToEntity,
47
+ focusToEntity,
48
+ toFocus,
49
+ } from './planning';
42
50
  import type {
43
51
  Commitment,
44
52
  CommitmentApi,
@@ -49,9 +57,15 @@ import type {
49
57
  DocumentQuery,
50
58
  ExtractionRun,
51
59
  ExtractionRunStore,
60
+ AuthoredItem,
61
+ Focus,
62
+ FocusDraft,
63
+ FocusQuery,
64
+ FocusStanding,
52
65
  MoneyEvent,
53
66
  Party,
54
67
  PartyRef,
68
+ PlanningApi,
55
69
  SourceFreshness,
56
70
  } from './types';
57
71
 
@@ -66,6 +80,18 @@ import type {
66
80
  export interface CorpusHttp {
67
81
  get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
68
82
  post?<T>(path: string, body?: unknown, options?: { params?: Record<string, unknown> }): Promise<T>;
83
+ /**
84
+ * OPTIONAL, and needed by exactly one operation: UNFILING an item.
85
+ *
86
+ * Every other write in this file is a shallow-merging upsert, which is the
87
+ * only tenant verb that preserves keys the caller did not send. Removing an
88
+ * `in_focus` edge cannot be expressed that way — an edge is a row, not a
89
+ * field — and marking it dead instead would leave a tombstone every other
90
+ * reader has to know to ignore. A client without this can file and re-file;
91
+ * `fileCommitment(item, null)` reports that it cannot unfile rather than
92
+ * appearing to succeed.
93
+ */
94
+ delete?<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
69
95
  }
70
96
 
71
97
  export interface CreateCorpusApiOptions {
@@ -82,7 +108,21 @@ export interface CreateCorpusApiOptions {
82
108
  */
83
109
  supportedFilters?: Array<keyof DocumentQuery | keyof CommitmentQuery>;
84
110
  /** Endpoint bases, without leading/trailing slashes (the client convention). */
85
- paths?: { entities?: string; connections?: string; relationships?: string };
111
+ paths?: {
112
+ entities?: string;
113
+ connections?: string;
114
+ relationships?: string;
115
+ /**
116
+ * The fork's own same-origin roster, for the owner picker.
117
+ *
118
+ * Threaded through here rather than hardcoded because `listMembers` fails
119
+ * SOFT: a fork whose roster lives elsewhere would swallow the 404 into "no
120
+ * members", the picker would quietly disappear, and owners would fall back
121
+ * to free text — which is precisely the gap (bd startsim-768w.38.29) the
122
+ * picker exists to close, reappearing as an empty dropdown with no error.
123
+ */
124
+ members?: string;
125
+ };
86
126
  /** How old a source's last sync may be before the Library calls it stale. */
87
127
  staleAfterHours?: number;
88
128
  /**
@@ -181,7 +221,18 @@ function commitmentParams(query: CommitmentQuery, type: string): Record<string,
181
221
  if (query.owner) out['attr.owner_name__icontains'] = query.owner.slice(0, TEXT_FILTER_MAX);
182
222
  if (query.dueBefore) out['attr.due_at__lte'] = query.dueBefore;
183
223
  if (query.changedAfter) out['attr.last_seen_at__gte'] = query.changedAfter;
224
+ if (query.origin) out['attr._origin'] = query.origin;
184
225
  if (query.search) out.search = query.search.slice(0, TEXT_FILTER_MAX);
226
+ if (query.focus) {
227
+ // FILING IS AN EDGE, so "the items in this focus" is an edge walk and not an
228
+ // attribute match. `related_direction` describes the RETURNED ROW's end of
229
+ // the edge (apps/api/filters.py::_one_hop) and an item is the SOURCE of its
230
+ // own `in_focus` edge — so `out` is correct here, and `in` would silently
231
+ // return an empty page rather than an error.
232
+ out.related_to = query.focus;
233
+ out.related_via = IN_FOCUS;
234
+ out.related_direction = 'out';
235
+ }
185
236
  return out;
186
237
  }
187
238
 
@@ -192,6 +243,14 @@ const FILTER_PARAMS: Array<[keyof CommitmentQuery, string[]]> = [
192
243
  ['owner', ['attr.owner_name__icontains']],
193
244
  ['dueBefore', ['attr.due_at__lte']],
194
245
  ['changedAfter', ['attr.last_seen_at__gte']],
246
+ ['origin', ['attr._origin']],
247
+ // THE CLIENT ASKS FOR `focus`; THE SERVER CONFIRMS `related_to`. Without this
248
+ // row the reconciliation below never matches, and the screen announces that
249
+ // this workspace's data API "did not filter by focus" while the server
250
+ // verifiably did — the same lie, from the same cause, as reading only one
251
+ // spelling of `applied_filters`. `partyId -> related_to` on the document side
252
+ // is the same shape, and the precedent for it.
253
+ ['focus', ['related_to']],
195
254
  ['search', ['search']],
196
255
  ];
197
256
 
@@ -261,6 +320,38 @@ function appliedFilters(
261
320
  return requested.filter((key) => supported.includes(key));
262
321
  }
263
322
 
323
+ /**
324
+ * Is this response the ROW that was asked for, or something else wearing a 200?
325
+ *
326
+ * Three things are refused, and each of them has been seen answering a
327
+ * single-record request somewhere: a DRF page (what an ignored `?id=` returns —
328
+ * verified on the live tenant, 200 with count 180), a body with no id at all,
329
+ * and a row whose id is somebody else's.
330
+ */
331
+ function isRecord(value: unknown, id: string): value is TenantEntity {
332
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
333
+ // A page is the shape a LIST answers with. It is never one record, whatever
334
+ // its count happens to be.
335
+ if (Array.isArray((value as { results?: unknown }).results)) return false;
336
+ const own = (value as TenantEntity).id;
337
+ if (own === undefined || own === null || String(own).trim() === '') return false;
338
+ // Case-insensitively, because a UUID has two legal spellings and the server
339
+ // answers in its own. Anything else different is a DIFFERENT RECORD, which is
340
+ // the entire reason this function exists.
341
+ return String(own).trim().toLowerCase() === String(id).trim().toLowerCase();
342
+ }
343
+
344
+ /**
345
+ * The tenant's own word for a row that is not there.
346
+ *
347
+ * 404 and not a bespoke error, because the tenant's read gate makes a row this
348
+ * principal may not see ABSENT rather than refused — so callers get one status
349
+ * to branch on, and "not found" is already the honest word for both.
350
+ */
351
+ function notFound(id: string): Error & { status: number } {
352
+ return Object.assign(new Error(`No record ${id} in this workspace.`), { status: 404 });
353
+ }
354
+
264
355
  export function createCorpusApi(
265
356
  options: CreateCorpusApiOptions,
266
357
  ): CorpusApi & CommitmentApi & ExtractionRunStore {
@@ -364,11 +455,34 @@ export function createCorpusApi(
364
455
  };
365
456
  },
366
457
 
458
+ /**
459
+ * ONE record, by the DETAIL ROUTE — never by a filter (bd startsim-2lt8y).
460
+ *
461
+ * `/api/v1/entities/<id>/` is a real route on the tenant and it answers the
462
+ * one row. The entity LIST has no `id` filter: `EntityQuery._note_unknown`
463
+ * sweeps every unrecognised parameter into `ignored_filters` and returns the
464
+ * page exactly as wide as it was, so `?id=<uuid>` comes back 200 with the
465
+ * whole corpus. A lookup spelled that way cannot fail — it just answers with
466
+ * somebody else's record.
467
+ *
468
+ * WHICH IS WHY THE RESPONSE IS CHECKED. Asking the right route is not the
469
+ * same as being answered by it: a proxy that drops the path segment, a
470
+ * deployment whose detail route lists, an `?id=` that got ignored upstream —
471
+ * all hand back a DRF page where a row was expected, and `toSourceDocument`
472
+ * will map it without complaint into a document with the id `undefined` and
473
+ * the title "Untitled unknown record". That renders as a blank panel that
474
+ * claims to be the record, which is worse than an error because nobody
475
+ * downstream can tell it is wrong. So a response that is not the row that
476
+ * was asked for is a 404, reported the same way the tenant reports an absent
477
+ * row — the read gate makes a forbidden row ABSENT rather than refused, so
478
+ * "not found" is already the honest word for both.
479
+ */
367
480
  async getDocument(id) {
368
481
  const [entity, byRecord] = await Promise.all([
369
482
  http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`),
370
483
  participantGraph(),
371
484
  ]);
485
+ if (!isRecord(entity, id)) throw notFound(id);
372
486
  return toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []);
373
487
  },
374
488
 
@@ -581,3 +695,311 @@ export function createCorpusApi(
581
695
  },
582
696
  };
583
697
  }
698
+
699
+ /**
700
+ * `data` key -> the `FocusDraft` key that carries it.
701
+ *
702
+ * THE POINT IS PARTIAL UPDATES. `focusToEntity` is a CREATE mapper: it always
703
+ * emits a `status` (defaulting to `active`) and always stamps `_origin`, because
704
+ * a new row needs both. Merging that whole object on every edit would mean
705
+ * renaming a focus silently resurrected a `cancelled` one — the upsert
706
+ * shallow-merges, so every key present in the body is a write.
707
+ *
708
+ * So an update sends a key only when the DRAFT field behind it was actually in
709
+ * the patch. The two names differ (`time_frame` vs `timeFrame`), which is the
710
+ * whole reason this is a map rather than a list, and `_origin` is deliberately
711
+ * absent from it so an edit can never relabel where a row came from.
712
+ */
713
+ const FOCUS_PATCH_KEYS: Record<string, keyof FocusDraft> = {
714
+ detail: 'detail',
715
+ status: 'status',
716
+ category: 'category',
717
+ time_frame: 'timeFrame',
718
+ year: 'year',
719
+ start_date: 'startDate',
720
+ end_date: 'endDate',
721
+ target_value: 'targetValue',
722
+ current_value: 'currentValue',
723
+ unit: 'unit',
724
+ owner_sub: 'ownerSub',
725
+ owner_name: 'ownerName',
726
+ };
727
+
728
+ // ---------------------------------------------------------------------------
729
+ // the planning surface (bd startsim-8l6ef)
730
+ // ---------------------------------------------------------------------------
731
+
732
+ /**
733
+ * The tenant client for focuses and items.
734
+ *
735
+ * COMPOSED FROM `createCorpusApi`, not written beside it: an item IS a
736
+ * commitment, so `listCommitments` and — more importantly — its applied-filter
737
+ * reconciliation are reused wholesale rather than reimplemented against the same
738
+ * endpoint. What is added here is the focus type, the two edge writes, the
739
+ * authoring path and counting.
740
+ *
741
+ * EVERY WRITE IS THE SHALLOW-MERGING UPSERT, keyed on `external_id`. That is the
742
+ * only tenant verb that preserves keys the caller did not send, which matters
743
+ * more here than anywhere else in the package: the extractor and a person write
744
+ * the SAME rows, and a full-object PUT from an edit dialog would silently drop
745
+ * whatever the last extraction pass had just added.
746
+ *
747
+ * NOTHING AGGREGATES SERVER-SIDE, because the tenant plane deliberately offers
748
+ * no group_by. `focusStanding` asks for `page_size=1` three times and reads the
749
+ * paginator's own `count`, so a focus card costs three empty pages rather than
750
+ * three pages of bodies.
751
+ */
752
+ export function createPlanningApi(options: CreateCorpusApiOptions): PlanningApi {
753
+ const base = createCorpusApi(options);
754
+ const { http, scope } = options;
755
+ const sourceLabels = options.sourceLabels ?? {};
756
+ const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
757
+ const entities = options.paths?.entities ?? 'api/v1/entities';
758
+ const relationships = options.paths?.relationships ?? 'api/v1/relationships';
759
+ // tenant-starter/config/urls.py: path("api/v1/org/members/", OrgMembersView…)
760
+ const membersPath = options.paths?.members ?? 'api/v1/org/members';
761
+
762
+ function writer(): NonNullable<CorpusHttp['post']> {
763
+ if (!http.post) {
764
+ throw new Error('This planning client is read-only — no write method was supplied.');
765
+ }
766
+ return http.post.bind(http);
767
+ }
768
+
769
+ function focusParams(query: FocusQuery): Record<string, unknown> {
770
+ const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
771
+ const out: Record<string, unknown> = {
772
+ type: FOCUS_TYPE,
773
+ page: query.page ?? 1,
774
+ ...(query.pageSize ? { page_size: Math.min(query.pageSize, 200) } : {}),
775
+ // Worst-off first by default. A STORED `progress` is what makes this a
776
+ // server order at all — see the field's note for why nothing recomputes
777
+ // it on the way past.
778
+ order: ({
779
+ progress: 'attr.progress',
780
+ '-progress': '-attr.progress',
781
+ sortOrder: 'attr.sort_order',
782
+ title: 'attr.title',
783
+ } as const)[query.orderBy ?? 'progress'],
784
+ };
785
+ if (statuses.length === 1) out['attr.status'] = statuses[0];
786
+ else if (statuses.length > 1) out['attr.status__in'] = statuses.join(',');
787
+ if (query.category) out['attr.category'] = query.category;
788
+ if (query.timeFrame) out['attr.time_frame'] = query.timeFrame;
789
+ if (query.year !== undefined) out['attr.year'] = query.year;
790
+ if (query.owner) out['attr.owner_sub'] = query.owner;
791
+ if (query.search) out.search = query.search.slice(0, TEXT_FILTER_MAX);
792
+ return out;
793
+ }
794
+
795
+ async function focusPage(params: Record<string, unknown>): Promise<CorpusPage<Focus>> {
796
+ const response = await http.get<DrfPage<TenantEntity> & ServerFilterReport>(
797
+ entities, { params });
798
+ return {
799
+ count: response.count ?? response.results?.length ?? 0,
800
+ next: response.next ?? null,
801
+ previous: response.previous ?? null,
802
+ results: (response.results ?? []).map((entity) => toFocus(entity, scope)),
803
+ appliedFilters: response.applied_filters ?? response.appliedFilters ?? [],
804
+ };
805
+ }
806
+
807
+ /** A count with no rows: ask for one, read `count`, throw the row away. */
808
+ async function countOnly(params: Record<string, unknown>): Promise<number> {
809
+ const response = await http.get<DrfPage<TenantEntity>>(entities, {
810
+ params: { ...params, page_size: 1 },
811
+ });
812
+ return response.count ?? response.results?.length ?? 0;
813
+ }
814
+
815
+ /** The `in_focus` scope for one focus, as query params. */
816
+ function inFocus(focusId: string): Record<string, unknown> {
817
+ return {
818
+ type: types.commitment,
819
+ related_to: focusId,
820
+ related_via: IN_FOCUS,
821
+ // The direction names the RETURNED ROW's end of the edge, and an item is
822
+ // the SOURCE of its own `in_focus` edge. `in` here returns an empty page
823
+ // rather than an error, which is why it is spelled out.
824
+ related_direction: 'out',
825
+ };
826
+ }
827
+
828
+ return {
829
+ ...base,
830
+
831
+ listFocuses: (query: FocusQuery = {}) => focusPage(focusParams(query)),
832
+
833
+ /**
834
+ * The children of a focus, one level.
835
+ *
836
+ * A level at a time because the query layer caps a traversal at
837
+ * `related_depth=2`; a deep tree is several cheap reads rather than one
838
+ * request that cannot be expressed.
839
+ */
840
+ listChildFocuses: (focusId: string) => focusPage({
841
+ type: FOCUS_TYPE,
842
+ related_to: focusId,
843
+ related_via: SUB_FOCUS,
844
+ related_direction: 'out',
845
+ order: 'attr.sort_order',
846
+ }),
847
+
848
+ async createFocus(draft: FocusDraft): Promise<Focus> {
849
+ const post = writer();
850
+ const created = await post<TenantEntity>(`${entities}/upsert`, focusToEntity(draft));
851
+ const focus = toFocus(created, scope);
852
+ // Nesting is an EDGE, so it is a second write with no transaction around
853
+ // it. Done after the row exists rather than smuggled into its payload: a
854
+ // failure here leaves a real, visible, unparented focus instead of half a
855
+ // record and no way to tell which half.
856
+ if (draft.parentId) {
857
+ await post(relationships, {
858
+ rel_type: SUB_FOCUS, source: focus.id, target: draft.parentId,
859
+ });
860
+ return { ...focus, parentId: draft.parentId };
861
+ }
862
+ return focus;
863
+ },
864
+
865
+ async updateFocus(focus: Focus, patch: Partial<FocusDraft>): Promise<Focus> {
866
+ const post = writer();
867
+ // Only the keys the caller actually sent. `focusToEntity` would helpfully
868
+ // default `status` to 'active', and merging that in would resurrect a
869
+ // cancelled focus every time somebody edited its title.
870
+ const { data } = focusToEntity({ ...patch, title: patch.title ?? focus.title });
871
+ const sent: Record<string, unknown> = { title: patch.title ?? focus.title };
872
+ for (const [key, value] of Object.entries(data)) {
873
+ if (key === 'title') continue;
874
+ if (key in FOCUS_PATCH_KEYS && FOCUS_PATCH_KEYS[key] in patch) sent[key] = value;
875
+ }
876
+ const updated = await post<TenantEntity>(`${entities}/upsert`, {
877
+ entity_type: FOCUS_TYPE,
878
+ external_id: focus.externalId,
879
+ ...(patch.title ? { name: patch.title.slice(0, 200) } : {}),
880
+ data: sent,
881
+ });
882
+ return toFocus(updated, scope);
883
+ },
884
+
885
+ async createCommitment(draft: AuthoredItem): Promise<Commitment> {
886
+ const post = writer();
887
+ const created = await post<TenantEntity>(
888
+ `${entities}/upsert`,
889
+ authoredItemToEntity(draft, { itemType: types.commitment }),
890
+ );
891
+ const item = toCommitment(created, scope, sourceLabels);
892
+ if (draft.focusId) {
893
+ await post(relationships, {
894
+ rel_type: IN_FOCUS, source: item.id, target: draft.focusId,
895
+ });
896
+ return { ...item, focusId: draft.focusId };
897
+ }
898
+ return item;
899
+ },
900
+
901
+ /**
902
+ * A person's edit, merged over whatever the extractor last wrote.
903
+ *
904
+ * Sends ONLY the keys in `patch`. The upsert shallow-merges, so an edit to
905
+ * an owner cannot drop a due date the last extraction pass added — and
906
+ * `last_seen_at` is deliberately NOT touched, because a person editing a row
907
+ * is not a meeting mentioning it, and pretending otherwise would move the
908
+ * item into "changed this week" for the wrong reason.
909
+ */
910
+ async updateCommitment(
911
+ item: Commitment,
912
+ patch: Parameters<NonNullable<CommitmentApi['updateCommitment']>>[1],
913
+ ): Promise<Commitment> {
914
+ const post = writer();
915
+ const wire: Record<string, unknown> = {};
916
+ if (patch.title !== undefined) wire.title = patch.title;
917
+ if (patch.detail !== undefined) wire.detail = patch.detail;
918
+ if (patch.status !== undefined) wire.status = patch.status;
919
+ if (patch.statusReason !== undefined) wire.status_reason = patch.statusReason;
920
+ if (patch.ownerName !== undefined) wire.owner_name = patch.ownerName;
921
+ if (patch.ownerSub !== undefined) wire.owner_sub = patch.ownerSub;
922
+ if (patch.dueAt !== undefined) wire.due_at = patch.dueAt;
923
+ if (patch.subtype !== undefined) wire.subtype = patch.subtype;
924
+ if (patch.priority !== undefined) wire.priority = patch.priority;
925
+ const updated = await post<TenantEntity>(`${entities}/upsert`, {
926
+ entity_type: types.commitment,
927
+ external_id: item.externalId,
928
+ ...(patch.title !== undefined ? { name: patch.title.slice(0, 200) } : {}),
929
+ data: wire,
930
+ });
931
+ return toCommitment(updated, scope, sourceLabels);
932
+ },
933
+
934
+ async fileCommitment(item: Commitment, focusId: string | null): Promise<void> {
935
+ const post = writer();
936
+ if (focusId !== null) {
937
+ await post(relationships, { rel_type: IN_FOCUS, source: item.id, target: focusId });
938
+ return;
939
+ }
940
+ if (!http.delete) {
941
+ // Said out loud rather than resolving. A control that appears to unfile
942
+ // and does not is worse than one that is disabled.
943
+ throw new Error('This planning client cannot unfile — no delete method was supplied.');
944
+ }
945
+ const del = http.delete.bind(http);
946
+ const page = await http.get<DrfPage<TenantRelationship>>(relationships, {
947
+ params: { rel_type: IN_FOCUS, source: item.id },
948
+ });
949
+ for (const edge of page.results ?? []) {
950
+ await del(`${relationships}/${edge.id}`);
951
+ }
952
+ },
953
+
954
+ /**
955
+ * "12 of 40 done, 3 overdue" for one focus — three counts, no row bodies.
956
+ *
957
+ * `now` is a PARAMETER and not a `Date.now()` read inside, for the reason
958
+ * `TrackerPageProps.now` records: a clock read on the way past is a new
959
+ * value every call, which makes it a new memo dependency, which is how a
960
+ * screen ends up issuing 917 requests in ten minutes on a live tenant.
961
+ */
962
+ async focusStanding(focusId: string, now?: number): Promise<FocusStanding> {
963
+ const scopeParams = inFocus(focusId);
964
+ const asOf = new Date(now ?? Date.now()).toISOString();
965
+ const [total, done, overdue] = await Promise.all([
966
+ countOnly(scopeParams),
967
+ countOnly({ ...scopeParams, 'attr.status': 'done' }),
968
+ countOnly({ ...scopeParams, 'attr.status': 'open', 'attr.due_at__lte': asOf }),
969
+ ]);
970
+ return { focusId, total, done, overdue };
971
+ },
972
+
973
+ /**
974
+ * The tenant's real members, for the owner picker — what closes the
975
+ * free-text-owner gap (bd startsim-768w.38.29).
976
+ *
977
+ * Read through the fork's OWN same-origin roster, never central directly:
978
+ * pointing a browser at api.startsimpli.com with a bearer from a foreign
979
+ * apex is the cross-origin/refresh-cookie trap the proxy convention exists
980
+ * to avoid.
981
+ *
982
+ * FAILS SOFT TO AN EMPTY ROSTER. A fork with no central wiring has no
983
+ * members to offer, and the picker degrades to the free-text owner name
984
+ * rather than the screen erroring — which is exactly why `ownerName` survives
985
+ * alongside `ownerSub` instead of being replaced by it.
986
+ */
987
+ async listMembers(): Promise<Array<{ sub: string; email: string; name?: string }>> {
988
+ try {
989
+ const page = await http.get<DrfPage<Record<string, unknown>>>(membersPath, {});
990
+ return (page.results ?? [])
991
+ .map((row) => {
992
+ const user = (row.user ?? {}) as Record<string, unknown>;
993
+ return {
994
+ sub: String(user.sub ?? row.sub ?? ''),
995
+ email: String(user.email ?? row.email ?? ''),
996
+ ...(user.name ? { name: String(user.name) } : {}),
997
+ };
998
+ })
999
+ .filter((member) => member.sub);
1000
+ } catch {
1001
+ return [];
1002
+ }
1003
+ },
1004
+ };
1005
+ }
@@ -189,6 +189,25 @@ function asIso(value: unknown): string | null {
189
189
  return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
190
190
  }
191
191
 
192
+ /**
193
+ * A finite number, or `undefined`.
194
+ *
195
+ * `undefined` RATHER THAN 0, and that distinction is the whole reason this is
196
+ * not a one-liner: an unset `target_value` means "this focus is qualitative"
197
+ * (`Goal.is_measurable`) and an unset `priority` means "nobody has triaged this
198
+ * yet". Coercing either to 0 turns a missing judgement into a confident one —
199
+ * every qualitative focus becomes fully achieved, and every untriaged item
200
+ * becomes critical.
201
+ */
202
+ function asNumber(value: unknown): number | undefined {
203
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
204
+ if (typeof value === 'string' && value.trim()) {
205
+ const parsed = Number(value);
206
+ return Number.isFinite(parsed) ? parsed : undefined;
207
+ }
208
+ return undefined;
209
+ }
210
+
192
211
  function asList(value: unknown): unknown[] {
193
212
  if (Array.isArray(value)) return value;
194
213
  if (value === undefined || value === null || value === '') return [];
@@ -593,6 +612,33 @@ export function moneyEventAsDocument(event: MoneyEvent): SourceDocument {
593
612
  * interesting; the thirty-first quote is not, and the row has to stay small. */
594
613
  const MAX_MENTIONS = 20;
595
614
 
615
+ /**
616
+ * WHERE A ROW CAME FROM, under the key the platform already uses.
617
+ *
618
+ * THE LEADING UNDERSCORE IS LOAD-BEARING. `_origin` is written as a flat `data`
619
+ * key by three writers — the central corpus delivery, the foundry-tenant
620
+ * destination handler and the extractor below — and the tenant declares an
621
+ * `AttributeDef` of exactly that name, so it is the only spelling with an index
622
+ * behind it. A field called `origin` would snake_case to `origin`, a DIFFERENT
623
+ * key that nothing has ever written and nothing indexes: every filter over it
624
+ * would return an empty page, with no error to explain why.
625
+ *
626
+ * READS ARRIVE UNDER `Origin`. `@startsimpli/api`'s `transformKeys` camelCases
627
+ * response keys recursively into `data`, and `snakeToCamelKey('_origin')` is
628
+ * `Origin` — while a plain server-side `fetch` sees `_origin` untouched. So the
629
+ * write side emits the literal key and the read side accepts both, the same
630
+ * tolerance `toCommitment` already applies to `status_reason`/`statusReason`.
631
+ */
632
+ export const ORIGIN_KEY = '_origin';
633
+ export const ORIGIN = {
634
+ /** Read out of a meeting by the tenant-side extractor. */
635
+ extracted: 'tenant-extraction',
636
+ /** Typed by a person on the planning surface. */
637
+ authored: 'human',
638
+ /** Delivered by the central corpus pipeline. */
639
+ central: 'central-pipeline',
640
+ } as const;
641
+
596
642
  export function toCommitment(
597
643
  entity: TenantEntity,
598
644
  fallbackScope: CorpusScope,
@@ -624,6 +670,15 @@ export function toCommitment(
624
670
  subjectKey: asText(pick(data, 'subject_key', 'subjectKey')),
625
671
  actionKey: asText(pick(data, 'action_key', 'actionKey')),
626
672
  dueAt: asIso(pick(data, 'due_at', 'dueAt')),
673
+ ...(asNumber(pick(data, 'priority')) !== undefined
674
+ ? { priority: asNumber(pick(data, 'priority')) } : {}),
675
+ ...(pick(data, 'owner_sub', 'ownerSub')
676
+ ? { ownerSub: asText(pick(data, 'owner_sub', 'ownerSub')) } : {}),
677
+ // Both spellings, for the reason spelled out on ORIGIN_KEY above: the
678
+ // browser client camelCases `_origin` into `Origin` on the way in, and a
679
+ // server-side fetch does not.
680
+ ...(pick(data, ORIGIN_KEY, 'Origin')
681
+ ? { origin: asText(pick(data, ORIGIN_KEY, 'Origin')) } : {}),
627
682
  mentions,
628
683
  firstSeenAt: asIso(pick(data, 'first_seen_at', 'firstSeenAt')),
629
684
  lastSeenAt: asIso(pick(data, 'last_seen_at', 'lastSeenAt')),
@@ -675,6 +730,8 @@ export function commitmentToEntity(commitment: Commitment): {
675
730
  action_key: commitment.actionKey,
676
731
  dedupe_key: commitment.dedupeKey,
677
732
  ...(commitment.dueAt ? { due_at: commitment.dueAt } : {}),
733
+ ...(commitment.priority !== undefined ? { priority: commitment.priority } : {}),
734
+ ...(commitment.ownerSub ? { owner_sub: commitment.ownerSub } : {}),
678
735
  first_seen_at: commitment.firstSeenAt,
679
736
  last_seen_at: commitment.lastSeenAt,
680
737
  mention_count: commitment.mentions.length,
@@ -690,7 +747,12 @@ export function commitmentToEntity(commitment: Commitment): {
690
747
  // Marks the row as pipeline-written, the same way the corpus delivery
691
748
  // marks its own — so a human-created item is distinguishable from a
692
749
  // derived one, and a re-derivation knows what it is allowed to rewrite.
693
- _origin: 'tenant-extraction',
750
+ //
751
+ // PRESERVED WHEN THE ROW ALREADY HAS ONE. A human-authored item that
752
+ // passes back through the extractor's bulk upsert must not be relabelled
753
+ // as extracted — that would quietly claim a meeting said something nobody
754
+ // said, and the row has no mention to contradict it.
755
+ [ORIGIN_KEY]: commitment.origin || ORIGIN.extracted,
694
756
  },
695
757
  };
696
758
  }
@@ -9,6 +9,7 @@ export type {
9
9
  AnswerDraft,
10
10
  AnswerRequest,
11
11
  AskMode,
12
+ AuthoredItem,
12
13
  Commitment,
13
14
  CommitmentApi,
14
15
  CommitmentDraft,
@@ -39,10 +40,18 @@ export type {
39
40
  CorpusScope,
40
41
  DocumentQuery,
41
42
  DocumentSection,
43
+ Focus,
44
+ FocusCategory,
45
+ FocusDraft,
46
+ FocusQuery,
47
+ FocusStanding,
48
+ FocusStatus,
49
+ FocusTimeFrame,
42
50
  LlmDescription,
43
51
  MoneyEvent,
44
52
  Party,
45
53
  PartyRef,
54
+ PlanningApi,
46
55
  PromptDocument,
47
56
  RetrievedDocument,
48
57
  SourceDocument,
@@ -136,10 +145,26 @@ export type {
136
145
  ResolutionResult,
137
146
  } from './commitments';
138
147
 
148
+ // Focuses and authored items — the planning toolset (bd startsim-8l6ef).
149
+ export {
150
+ FOCUS_TYPE,
151
+ IN_FOCUS,
152
+ SUB_FOCUS,
153
+ authoredId,
154
+ authoredItemToEntity,
155
+ displayProgress,
156
+ focusProgress,
157
+ focusSlug,
158
+ focusToEntity,
159
+ isAuthored,
160
+ toFocus,
161
+ } from './planning';
162
+ export { ORIGIN, ORIGIN_KEY } from './entities';
163
+
139
164
  export { jaccard, outcomeSections, splitSections, stem, words } from './text';
140
165
  export type { TextSection } from './text';
141
166
 
142
- export { createCorpusApi } from './client';
167
+ export { createCorpusApi, createPlanningApi } from './client';
143
168
  export type { CorpusHttp, CreateCorpusApiOptions } from './client';
144
169
 
145
170
  export {