@startsimpli/ui 0.4.135 → 0.4.137

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,167 @@
1
+ /**
2
+ * @startsimpli/ui/history — the shapes the shared record history panel renders
3
+ * (bd startsim-g4rwo; trail bd startsim-o1qib).
4
+ *
5
+ * Structural on purpose, exactly like `activity/types.ts`: packages/ui imports
6
+ * no API client, so these mirror one `/api/v1/entities/<id>/revisions/` row
7
+ * without depending on @startsimpli/api. The consuming app injects its own
8
+ * authed client (see {@link RevisionClient}).
9
+ *
10
+ * ── THE SPELLING TRAP, AND WHY EVERY KEY HERE IS OPTIONAL TWICE ─────────────
11
+ *
12
+ * The wire is snake_case. The shared API client
13
+ * (`packages/api/src/utils/case-transform.ts`) camelCases every response key
14
+ * RECURSIVELY and has no preserve list, so an app that fetches this route
15
+ * through `FetchWrapper` receives `actorKind`, `createdAt`, `redactedFields`
16
+ * and `valuesOmitted`. Both spellings are therefore declared and
17
+ * {@link normalizeRevision} reads either, because a panel that renders blank
18
+ * actors against a camelising client — and correctly against a raw one — is a
19
+ * bug nobody would find twice.
20
+ *
21
+ * WHAT THAT TRANSFORM BREAKS AND THIS MODULE CANNOT FIX: the keys INSIDE
22
+ * `metadata.changed` are the tenant's own declared attribute names, so a
23
+ * camelising client renames `team_verdict` to `teamVerdict` and there is no
24
+ * safe inverse — `snake_to_camel` is not injective, and guessing one would
25
+ * print a field name the tenant never declared. So field names are rendered
26
+ * EXACTLY as received and never re-cased. A consumer that wants the declared
27
+ * spellings must fetch this route without the case transform; one that cannot
28
+ * should pass `fieldLabel` to map them. Stated here rather than discovered
29
+ * later, because the same transform already corrupted declared names once.
30
+ */
31
+
32
+ /**
33
+ * The actor vocabulary lives on `ActorIdentity`, which is the component that
34
+ * RENDERS it, and is re-exported here so a consumer of this module needs one
35
+ * import rather than two. Declared once (rule #1): a second union would be a
36
+ * second place for "person | machine | unknown" to drift, and the three-state
37
+ * shape is the one thing in this epic that must not.
38
+ */
39
+ import type { ActorKind, ActorKindSource } from '../ActorIdentity';
40
+
41
+ export type { ActorKind, ActorKindSource };
42
+
43
+ /** `deleted` is declared server-side and not yet written — bd startsim-jkkn7.6. */
44
+ export type RevisionAction = 'created' | 'updated' | 'deleted';
45
+
46
+ /** Which endpoint wrote. `server` is a management command or a worker — a real
47
+ * write that must not claim to be an endpoint. */
48
+ export type RevisionSource = 'create' | 'patch' | 'put' | 'upsert' | 'server';
49
+
50
+ /** `none` always, today; `matched` arrives with the conditional write
51
+ * (bd startsim-3c2wc). */
52
+ export type RevisionPrecondition = 'none' | 'matched';
53
+
54
+ /**
55
+ * One `metadata.changed` entry, straight off the wire.
56
+ *
57
+ * THE ABSENCE OF A SIDE IS THE PAYLOAD, which is why neither key is nullable
58
+ * rather than optional-and-nullable:
59
+ *
60
+ * - a field the write CREATED has `after` and NO `before` key;
61
+ * - a field the write DELETED has `before` and NO `after` key — the server
62
+ * never writes `after: null`, because a key a person deleted is a different
63
+ * fact from a key set to `null`, and `null` IS a legal stored value;
64
+ * - a field the read gate WITHHELD is `{}` and the row's `redacted_fields`
65
+ * names it;
66
+ * - a row over the byte cap has `{}` for EVERY field and
67
+ * `metadata.values_omitted` set.
68
+ *
69
+ * So `'after' in entry` is the only correct test and `entry.after === undefined`
70
+ * is wrong. {@link normalizeFieldChange} is the one place that does it.
71
+ */
72
+ export interface RevisionFieldChange {
73
+ before?: unknown;
74
+ after?: unknown;
75
+ /** The value was too large to store. WHEN THIS IS TRUE A MISSING SIDE SAYS
76
+ * NOTHING — it was dropped, not deleted — so the delete inference above is
77
+ * invalid and a diff over this entry would show a deletion that never
78
+ * happened. */
79
+ truncated?: boolean;
80
+ }
81
+
82
+ export interface RevisionMetadata {
83
+ /** ALWAYS PRESENT, possibly `{}`. Keyed by the tenant's declared attribute
84
+ * names — see the spelling trap in this module's header. */
85
+ changed?: Record<string, RevisionFieldChange>;
86
+ /** Present only when the name COLUMN moved. Namespaced away from `changed`
87
+ * because a tenant may declare a data key literally called "name". */
88
+ name?: RevisionFieldChange;
89
+ /** Fields `human_edits.guard_machine_write` HELD BACK. A row carrying these
90
+ * is a machine ATTEMPT, not a change, and its `changed` is `{}` — which is
91
+ * correct, not a bug. */
92
+ preserved?: string[];
93
+ /** What an upsert CLAIMED it was re-authoring. The one override that
94
+ * legitimately destroys a person's text, so it is shown. */
95
+ regenerate?: string[];
96
+ /** The whole row exceeded the row cap: every field is still NAMED in
97
+ * `changed`, every value is `{}`. */
98
+ values_omitted?: boolean;
99
+ /** Camelised alias — see the header. */
100
+ valuesOmitted?: boolean;
101
+ }
102
+
103
+ /** One row of the append-only revision trail. Only `id` and `version` are
104
+ * relied on; everything else is treated as optional so a row from a tenant
105
+ * running an older build still renders. */
106
+ export interface EntityRevision {
107
+ id: string;
108
+ /** 1-based, GAPLESS 1..N per entity, DB-unique. A hole is a provably lost
109
+ * revision — which is what the unique constraint exists to make visible. */
110
+ version: number;
111
+ action?: RevisionAction | string;
112
+ source?: RevisionSource | string;
113
+ precondition?: RevisionPrecondition | string;
114
+ created_at?: string | null;
115
+ createdAt?: string | null;
116
+ /** May be `""`. */
117
+ actor_sub?: string | null;
118
+ actorSub?: string | null;
119
+ /** The actor's email, denormalised at write time. For a machine this is
120
+ * often not an address at all (`svc:n8n-ogmc`). */
121
+ actor_label?: string | null;
122
+ actorLabel?: string | null;
123
+ actor_kind?: ActorKind | string | null;
124
+ actorKind?: ActorKind | string | null;
125
+ actor_kind_source?: ActorKindSource | string | null;
126
+ actorKindSource?: ActorKindSource | string | null;
127
+ metadata?: RevisionMetadata | null;
128
+ /** PRESENT ONLY when the per-field gate withheld something on this row. */
129
+ redacted_fields?: string[] | null;
130
+ redactedFields?: string[] | null;
131
+ }
132
+
133
+ /** The DRF envelope. `redacted_fields` is the server's roll-up of every row on
134
+ * this page (`apps/api/pagination.py`), present only when something really
135
+ * was held back. */
136
+ export interface RevisionPage {
137
+ count?: number;
138
+ next?: string | null;
139
+ previous?: string | null;
140
+ results?: EntityRevision[];
141
+ redacted_fields?: string[] | null;
142
+ redactedFields?: string[] | null;
143
+ }
144
+
145
+ /** Narrowing the panel asks for. Server-side paginated (repo rule 8) — the
146
+ * route caps `page_size` at 200 and 400s a parameter it does not recognise,
147
+ * so this stays deliberately minimal. */
148
+ export interface RevisionQuery {
149
+ page?: number;
150
+ pageSize?: number;
151
+ // Index signature so this satisfies an API client's query-params type; the
152
+ // injected client is the app's own.
153
+ [key: string]: unknown;
154
+ }
155
+
156
+ /**
157
+ * The one call the panel needs. The consuming app injects its authed client
158
+ * (`{ list: (p) => api.entities.revisions(id, p) }`), which is what keeps
159
+ * @startsimpli/api out of packages/ui and the app wrapper under ten lines.
160
+ *
161
+ * A 404 from this route means the caller may not read the PARENT record — the
162
+ * route is nested and an unreadable row is simply absent. The panel surfaces
163
+ * that as "you cannot see this record's history", never as an empty trail.
164
+ */
165
+ export interface RevisionClient {
166
+ list: (query: RevisionQuery) => Promise<RevisionPage>;
167
+ }
@@ -0,0 +1,356 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useConditionalSave — every write carries the version it loaded, and a refusal
5
+ * KEEPS THE REVIEWER'S TEXT (bd startsim-jkkn7.3; server bd startsim-3c2wc).
6
+ *
7
+ * The loss this closes, as a person experiences it: two reviewers open one
8
+ * draft, the second save carries the first reviewer's field as the second page
9
+ * loaded it, the rewrite is gone and the response is 200. The server now refuses
10
+ * that second save with a 412 — and this hook is the half that decides what
11
+ * happens to the three paragraphs the reviewer had just typed.
12
+ *
13
+ * ── THE ONE INVARIANT, AND IT IS THE WHOLE BEAD ─────────────────────────────
14
+ *
15
+ * A 412 NEVER ADVANCES THE HELD VERSION, AND NEVER RETRIES.
16
+ *
17
+ * The tempting shortcut is to read `current_version` off the refusal, adopt it,
18
+ * and re-send. It makes the error go away and every happy-path test still
19
+ * passes — and it reduces the guard to a decoration, because a blind retry at
20
+ * the current version is EXACTLY the overwrite the 412 just prevented, with one
21
+ * extra round-trip. So the held version advances from one place only: a write
22
+ * that SUCCEEDED. Adopting `current_version` is a deliberate act a person takes
23
+ * ({@link ConditionalSave.keepMine} / {@link ConditionalSave.acceptTheirs}), and
24
+ * `a refusal does not advance the held version` is a test, not a comment.
25
+ *
26
+ * ── WHAT "KEEP THE USER'S TEXT" MEANS MECHANICALLY ──────────────────────────
27
+ *
28
+ * Three things, because the promise is worth nothing as an intention:
29
+ *
30
+ * 1. THIS HOOK NEVER OWNS THE DRAFT and never clears, resets or reverts it. A
31
+ * refused save touches no local state but the conflict itself, so whatever is
32
+ * in the editor stays in the editor.
33
+ * 2. THE REFUSED PAYLOAD IS RETAINED, on {@link StaleSaveConflict.refusedBody}.
34
+ * This is the part that survives the ACCIDENTAL path, which is not the user
35
+ * clicking something rash — it is the host refetching the record to show
36
+ * "what changed" and its own load-into-editor effect overwriting the text
37
+ * with the server's copy. The bytes are still here when that happens, so the
38
+ * reviewer's work is recoverable rather than merely promised.
39
+ * 3. AUTOSAVE PAUSES AND THE CONFLICT SURFACES ONCE. The draft editor debounces
40
+ * at 1,200 ms, so a 412 that neither paused nor deduplicated would repaint a
41
+ * modal on every keystroke burst — worse than no dialog at all. While
42
+ * {@link ConditionalSave.paused} holds, `save` returns without sending.
43
+ *
44
+ * DISMISSING IS NOT RESOLVING. Escape, the backdrop and the close button all
45
+ * land on {@link ConditionalSave.dismissConflict}, which closes the dialog and
46
+ * leaves the conflict and the pause in place. The reviewer keeps their text and
47
+ * can still reopen; what they cannot do is wander back into autosaving over
48
+ * somebody else's change because they pressed Escape.
49
+ */
50
+ import { useCallback, useEffect, useRef, useState } from 'react';
51
+
52
+ import {
53
+ isVersion,
54
+ parseStaleSaveRefusal,
55
+ preconditionFor,
56
+ versionFromWriteOutcome,
57
+ newestRevision,
58
+ type PreconditionSpelling,
59
+ type ResponseHeaders,
60
+ } from './conditional-write';
61
+ import { normalizeRevision, type NormalizedRevision } from './revision-history';
62
+ import type { RevisionClient } from './types';
63
+
64
+ /** What the host sends. Merge `headers` into the request headers and `body` is
65
+ * the request body — the precondition is already in whichever one the chosen
66
+ * spelling uses. */
67
+ export interface ConditionalWriteAttempt {
68
+ body: Record<string, unknown>;
69
+ headers: Record<string, string>;
70
+ }
71
+
72
+ /** What the host hands back. A plain `fetch` adapter is about eight lines. */
73
+ export interface ConditionalWriteOutcome {
74
+ status?: number;
75
+ body?: unknown;
76
+ headers?: ResponseHeaders;
77
+ }
78
+
79
+ /**
80
+ * The one call this hook needs, injected exactly like {@link RevisionClient} —
81
+ * which is what keeps @startsimpli/api out of packages/ui.
82
+ *
83
+ * It may RESOLVE with a 412 outcome or THROW one; both are understood, because
84
+ * a plain `fetch` does the first and a wrapper does the second.
85
+ */
86
+ export interface ConditionalWriteClient {
87
+ write: (attempt: ConditionalWriteAttempt) => Promise<ConditionalWriteOutcome>;
88
+ }
89
+
90
+ /** Why the dialog cannot name a person. Each reads differently to a reviewer,
91
+ * and none of them may be rendered as "a machine did this". */
92
+ export type IdentityGap = 'forbidden' | 'empty' | 'error' | 'no-client';
93
+
94
+ export interface StaleSaveConflict {
95
+ /** The version the record is at NOW, straight off the refusal. */
96
+ currentVersion?: number;
97
+ /** The server's sentence. It names both versions and is safe to show. */
98
+ detail?: string;
99
+ /** The version the editor was holding when it was refused. */
100
+ sentVersion?: number;
101
+ /**
102
+ * THE REVIEWER'S TEXT, as the server refused it. Retained so it is
103
+ * recoverable even if the host reloaded the server's copy over the editor.
104
+ */
105
+ refusedBody: Record<string, unknown>;
106
+ /** The newest row of the trail — who changed it, when, and which fields.
107
+ * Absent whenever {@link identityGap} is set. */
108
+ changedBy?: NormalizedRevision;
109
+ /** Set when the trail could not say who. The dialog still works. */
110
+ identityGap?: IdentityGap;
111
+ }
112
+
113
+ export type ConditionalSaveResult =
114
+ | { status: 'saved'; version?: number; outcome: ConditionalWriteOutcome }
115
+ | { status: 'refused'; conflict: StaleSaveConflict }
116
+ /** A conflict is already open: nothing was sent. */
117
+ | { status: 'paused' }
118
+ | { status: 'failed'; error: unknown };
119
+
120
+ export interface UseConditionalSaveOptions {
121
+ client: ConditionalWriteClient;
122
+ /** The version the record was loaded at. Seeding this is what makes the FIRST
123
+ * save guarded; without it the first write is unguarded and the trail says
124
+ * so (`precondition: "none"`). */
125
+ version?: number;
126
+ /** Reads `/api/v1/entities/<id>/revisions/` for the human-readable half. The
127
+ * 412 carries no identity by design (bd startsim-j4kx6) — without this the
128
+ * dialog still opens and simply does not name anyone. */
129
+ revisions?: RevisionClient;
130
+ /** `header` (default) sends `If-Match` and leaves the body untouched. */
131
+ spelling?: PreconditionSpelling;
132
+ }
133
+
134
+ export interface ConditionalSave {
135
+ /** The version every write will assert. `undefined` means unguarded. */
136
+ version?: number;
137
+ /** Seed or re-seed it — on load, and after the host reloads the record. */
138
+ setVersion: (version: number | undefined) => void;
139
+ /** Whether the next save will actually assert a version. */
140
+ guarded: boolean;
141
+ save: (body: Record<string, unknown>) => Promise<ConditionalSaveResult>;
142
+ conflict: StaleSaveConflict | null;
143
+ /** An unresolved conflict: autosave must not fire. Stays true across a
144
+ * dismissal, because dismissing is not resolving. */
145
+ paused: boolean;
146
+ /** Whether the dialog should be on screen. */
147
+ dialogOpen: boolean;
148
+ /** Close the dialog. KEEPS the text, the conflict and the pause. */
149
+ dismissConflict: () => void;
150
+ reopenConflict: () => void;
151
+ /** Deliberately overwrite their change with the refused text, at the version
152
+ * the server named. The only path that adopts `current_version`. */
153
+ keepMine: () => Promise<ConditionalSaveResult>;
154
+ /** Give up the local edit: clear the conflict and adopt their version so the
155
+ * host can reload. Destructive to the reviewer's text BY DESIGN, which is
156
+ * why nothing but an explicit choice reaches it. */
157
+ acceptTheirs: () => void;
158
+ /** A `keepMine` is in flight. */
159
+ resolving: boolean;
160
+ /** The last non-412 failure, since `save` reports rather than throws. */
161
+ lastError: unknown;
162
+ }
163
+
164
+ export function useConditionalSave({
165
+ client,
166
+ version: initialVersion,
167
+ revisions,
168
+ spelling = 'header',
169
+ }: UseConditionalSaveOptions): ConditionalSave {
170
+ const [version, setVersionState] = useState<number | undefined>(initialVersion);
171
+ const [conflict, setConflict] = useState<StaleSaveConflict | null>(null);
172
+ const [dismissed, setDismissed] = useState(false);
173
+ const [resolving, setResolving] = useState(false);
174
+ const [lastError, setLastError] = useState<unknown>(null);
175
+
176
+ // Read inside `save` so a stale closure cannot send a version the hook has
177
+ // already moved past, and so `paused` is evaluated against the live conflict
178
+ // rather than the one captured when the debounce was scheduled.
179
+ const versionRef = useRef(version);
180
+ versionRef.current = version;
181
+ const conflictRef = useRef(conflict);
182
+ conflictRef.current = conflict;
183
+
184
+ // The identity fetch resolves after the conflict is already on screen, so it
185
+ // must not write into a hook that has unmounted or already moved on.
186
+ const alive = useRef(true);
187
+ useEffect(() => {
188
+ alive.current = true;
189
+ return () => {
190
+ alive.current = false;
191
+ };
192
+ }, []);
193
+
194
+ const setVersion = useCallback((next: number | undefined) => {
195
+ setVersionState(isVersion(next) ? next : undefined);
196
+ }, []);
197
+
198
+ // Re-seed when the host loads a different record. Guarded on the value so a
199
+ // save's own advance is not stomped by a re-render carrying the old prop.
200
+ const seeded = useRef(initialVersion);
201
+ useEffect(() => {
202
+ if (seeded.current !== initialVersion) {
203
+ seeded.current = initialVersion;
204
+ setVersionState(isVersion(initialVersion) ? initialVersion : undefined);
205
+ }
206
+ }, [initialVersion]);
207
+
208
+ /** Fill in "who changed it" after the dialog is already up. Never blocks the
209
+ * conflict from surfacing, and never throws into the save path. */
210
+ const resolveIdentity = useCallback(
211
+ async (key: StaleSaveConflict) => {
212
+ const patch = (gap: IdentityGap) => {
213
+ if (!alive.current) return;
214
+ setConflict((current) => (current === key ? { ...current, identityGap: gap } : current));
215
+ };
216
+ if (!revisions) return patch('no-client');
217
+ try {
218
+ const page = await revisions.list({ page: 1, pageSize: 1 });
219
+ const row = newestRevision(page?.results);
220
+ if (!alive.current) return;
221
+ if (!row) return patch('empty');
222
+ setConflict((current) =>
223
+ current === key ? { ...current, changedBy: normalizeRevision(row) } : current,
224
+ );
225
+ } catch (error) {
226
+ // A 404 on this nested route means the caller may not read the PARENT
227
+ // record, so history is withheld rather than absent — a different
228
+ // sentence, and never an empty trail.
229
+ patch(parseStatus(error) === 404 ? 'forbidden' : 'error');
230
+ }
231
+ },
232
+ [revisions],
233
+ );
234
+
235
+ const refuse = useCallback(
236
+ (
237
+ refusedBody: Record<string, unknown>,
238
+ sent: number | undefined,
239
+ refusal: { currentVersion?: number; detail?: string },
240
+ ): ConditionalSaveResult => {
241
+ const next: StaleSaveConflict = {
242
+ ...refusal,
243
+ ...(sent !== undefined ? { sentVersion: sent } : {}),
244
+ refusedBody,
245
+ };
246
+ if (alive.current) {
247
+ // NOTE WHAT IS NOT HERE: setVersionState. The refusal names the current
248
+ // version and this hook deliberately declines to take it.
249
+ setConflict(next);
250
+ setDismissed(false);
251
+ setLastError(null);
252
+ void resolveIdentity(next);
253
+ }
254
+ return { status: 'refused', conflict: next };
255
+ },
256
+ [resolveIdentity],
257
+ );
258
+
259
+ const attempt = useCallback(
260
+ async (
261
+ body: Record<string, unknown>,
262
+ assertVersion: number | undefined,
263
+ ): Promise<ConditionalSaveResult> => {
264
+ const parts = preconditionFor(assertVersion, spelling);
265
+ try {
266
+ const outcome = await client.write({
267
+ body: { ...body, ...parts.body },
268
+ headers: { ...parts.headers },
269
+ });
270
+ const refusal = parseStaleSaveRefusal(outcome);
271
+ if (refusal) return refuse(body, assertVersion, refusal);
272
+ const next = versionFromWriteOutcome(outcome ?? {});
273
+ if (alive.current) {
274
+ // THE ONLY PLACE THE HELD VERSION ADVANCES.
275
+ if (next !== undefined) setVersionState(next);
276
+ setConflict(null);
277
+ setDismissed(false);
278
+ setLastError(null);
279
+ }
280
+ return { status: 'saved', ...(next !== undefined ? { version: next } : {}), outcome };
281
+ } catch (error) {
282
+ const refusal = parseStaleSaveRefusal(error);
283
+ if (refusal) return refuse(body, assertVersion, refusal);
284
+ if (alive.current) setLastError(error);
285
+ return { status: 'failed', error };
286
+ }
287
+ },
288
+ [client, refuse, spelling],
289
+ );
290
+
291
+ const save = useCallback(
292
+ async (body: Record<string, unknown>): Promise<ConditionalSaveResult> => {
293
+ // Surfaced once, not retried. An unresolved conflict means the reviewer
294
+ // still has a decision to make and their keystrokes must not re-send.
295
+ if (conflictRef.current) return { status: 'paused' };
296
+ return attempt(body, versionRef.current);
297
+ },
298
+ [attempt],
299
+ );
300
+
301
+ const keepMine = useCallback(async (): Promise<ConditionalSaveResult> => {
302
+ const current = conflictRef.current;
303
+ if (!current) return { status: 'paused' };
304
+ // Adopting `current_version` is legitimate HERE and nowhere else: a person
305
+ // has been told what changed and has chosen to replace it.
306
+ const target = isVersion(current.currentVersion) ? current.currentVersion : undefined;
307
+ // The pause is NOT lifted here. `save` stays refused for the whole of this
308
+ // round-trip, so a debounce that fires mid-resolution cannot send the same
309
+ // body a second time and race the write this call is already making.
310
+ setResolving(true);
311
+ try {
312
+ return await attempt(current.refusedBody, target);
313
+ } finally {
314
+ if (alive.current) setResolving(false);
315
+ }
316
+ }, [attempt]);
317
+
318
+ const acceptTheirs = useCallback(() => {
319
+ const current = conflictRef.current;
320
+ setVersionState(isVersion(current?.currentVersion) ? current!.currentVersion : undefined);
321
+ setConflict(null);
322
+ setDismissed(false);
323
+ }, []);
324
+
325
+ const dismissConflict = useCallback(() => setDismissed(true), []);
326
+ const reopenConflict = useCallback(() => setDismissed(false), []);
327
+
328
+ return {
329
+ ...(version !== undefined ? { version } : {}),
330
+ setVersion,
331
+ guarded: isVersion(version),
332
+ save,
333
+ conflict,
334
+ paused: conflict !== null,
335
+ dialogOpen: conflict !== null && !dismissed,
336
+ dismissConflict,
337
+ reopenConflict,
338
+ keepMine,
339
+ acceptTheirs,
340
+ resolving,
341
+ lastError,
342
+ };
343
+ }
344
+
345
+ function parseStatus(error: unknown): number | undefined {
346
+ if (!error || typeof error !== 'object') return undefined;
347
+ const candidate = error as {
348
+ status?: unknown;
349
+ statusCode?: unknown;
350
+ response?: { status?: unknown };
351
+ };
352
+ for (const raw of [candidate.status, candidate.statusCode, candidate.response?.status]) {
353
+ if (typeof raw === 'number') return raw;
354
+ }
355
+ return undefined;
356
+ }
@@ -155,7 +155,13 @@ export * from './calendar'
155
155
  // display name is never printed alone as if it were an identity
156
156
  // (startsim-768w.19.8.1).
157
157
  export { ActorIdentity, actorInitials, actorLabel, actorHandle } from './ActorIdentity'
158
- export type { ActorIdentityProps, ActorIdentityValue } from './ActorIdentity'
158
+ export { actorKindTitle } from './ActorIdentity'
159
+ export type {
160
+ ActorIdentityProps,
161
+ ActorIdentityValue,
162
+ ActorKind,
163
+ ActorKindSource,
164
+ } from './ActorIdentity'
159
165
 
160
166
  // Activity components (timeline, quick log, log dialog)
161
167
  export { ActivityTimeline, dayHeading } from './ActivityTimeline'
@@ -168,6 +174,10 @@ export type {
168
174
  // The shared, filterable activity feed: filter bar + page-level composer over
169
175
  // any append-only event stream (startsim-768w.19.5).
170
176
  export * from './activity'
177
+
178
+ // The shared record history panel: who changed which field, from what, to
179
+ // what, and whether a machine did it (bd startsim-g4rwo).
180
+ export * from './history'
171
181
  export { QuickLogButtons } from './QuickLogButtons'
172
182
  export type { QuickLogButtonsProps, QuickLogAction } from './QuickLogButtons'
173
183
  export { LogActivityDialog } from './LogActivityDialog'