@modelprofile.com/flexharness 9.4.0 → 9.6.0

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.
package/ts/errors.ts CHANGED
@@ -44,6 +44,17 @@ export class FlexHarnessSessionBusyError extends FlexHarnessError {
44
44
  }
45
45
  }
46
46
 
47
+ /** An operation would change the history or model context of an imported, read-only session. */
48
+ export class FlexHarnessImportedSessionError extends FlexHarnessError {
49
+ public readonly sessionId: string;
50
+
51
+ constructor(sessionId: string) {
52
+ super('FLEX_SESSION_READ_ONLY', `Session "${sessionId}" is an imported conversation and is read-only.`);
53
+ this.name = 'FlexHarnessImportedSessionError';
54
+ this.sessionId = sessionId;
55
+ }
56
+ }
57
+
47
58
  export class FlexHarnessSlashCommandUnavailableError extends FlexHarnessError {
48
59
  public readonly commandName: string;
49
60
  public readonly unavailableReason: string;
package/ts/interfaces.ts CHANGED
@@ -169,8 +169,129 @@ export interface IFlexSession {
169
169
  parentToolCallId?: string;
170
170
  agent?: string;
171
171
  depth?: number;
172
+ /** Set only on a session the staged session import created; such a session is read-only. */
173
+ origin?: IFlexSessionOrigin;
172
174
  }
173
175
 
176
+ /** The position of an imported session when one source conversation was split into several sessions. */
177
+ export interface IFlexSessionImportPart {
178
+ /** Zero-based position of this session among the parts. */
179
+ index: number;
180
+ /** The number of parts the source was split into. */
181
+ count: number;
182
+ }
183
+
184
+ /** What an import caller states about the source of the history it imports. */
185
+ export interface IFlexSessionImportSource {
186
+ /** The kind of source, for example `claude-code-transcript`. */
187
+ source: string;
188
+ /** The source's own identifier of the conversation. */
189
+ sourceId: string;
190
+ /** The caller's digest of the source content; a changed source has a changed digest. */
191
+ digest: string;
192
+ part?: IFlexSessionImportPart;
193
+ /** Source-specific metadata, for example the source working directory or what the conversion lost. */
194
+ details?: IJsonObject;
195
+ }
196
+
197
+ /** Where an imported session's history came from. */
198
+ export interface IFlexSessionOrigin extends IFlexSessionImportSource {
199
+ kind: 'import';
200
+ /** When FlexHarness committed the import. */
201
+ importedAt: string;
202
+ }
203
+
204
+ /** The imported parent session, run and tool call an imported child session belongs to. */
205
+ export interface IFlexSessionImportParent {
206
+ sessionId: string;
207
+ runId: string;
208
+ toolCallId: string;
209
+ agent: string;
210
+ }
211
+
212
+ export interface IFlexBeginSessionImportOptions {
213
+ /** Caller-chosen and deterministic, so a repeated import finds the earlier one. */
214
+ sessionId: string;
215
+ title?: string;
216
+ /** When the source conversation started, as `Date.prototype.toISOString()` renders it. */
217
+ createdAt: string;
218
+ /** When the source conversation last changed; defaults to `createdAt`. */
219
+ updatedAt?: string;
220
+ origin: IFlexSessionImportSource;
221
+ parent?: IFlexSessionImportParent;
222
+ }
223
+
224
+ export interface IFlexSessionImportHandle {
225
+ importId: string;
226
+ scopeId: string;
227
+ sessionId: string;
228
+ }
229
+
230
+ export interface IFlexSessionImportStarted {
231
+ status: 'started';
232
+ handle: IFlexSessionImportHandle;
233
+ }
234
+
235
+ /**
236
+ * A session with the same source, source id and part already exists. `already_imported` means its
237
+ * digest equals the requested one; `digest_mismatch` means the source changed since that import.
238
+ */
239
+ export interface IFlexSessionImportExisting {
240
+ status: 'already_imported' | 'digest_mismatch';
241
+ session: IFlexSession;
242
+ }
243
+
244
+ export type TFlexBeginSessionImportResult = IFlexSessionImportStarted | IFlexSessionImportExisting;
245
+
246
+ /** One imported run: a user message and the assistant message that answered it. */
247
+ export interface IFlexImportedRun {
248
+ user: IFlexMessage;
249
+ assistant: IFlexMessage;
250
+ }
251
+
252
+ export interface IFlexSessionImportProgress {
253
+ runs: number;
254
+ messages: number;
255
+ bytes: number;
256
+ }
257
+
258
+ export interface IFlexSessionImportLimits {
259
+ /** Staged imports open at once in one FlexHarness. */
260
+ maxOpenImports?: number;
261
+ /** Messages in one imported session, two per run. */
262
+ maxMessagesPerSession?: number;
263
+ /** JSON bytes of all messages of one imported session. */
264
+ maxSessionBytes?: number;
265
+ /** JSON bytes of the runs one `appendImportedRuns()` call carries. */
266
+ maxPageBytes?: number;
267
+ /**
268
+ * Milliseconds a staged import may stay without an `appendImportedRuns()` call. An expired
269
+ * import is discarded as if it were aborted, which frees its slot and its staged memory.
270
+ */
271
+ stagedImportIdleTimeoutMs?: number;
272
+ }
273
+
274
+ /** JSON bytes of one imported message; a larger message could not be read back whole. */
275
+ export const FLEX_SESSION_IMPORT_MAX_MESSAGE_BYTES = 480 * 1024;
276
+ export const FLEX_SESSION_IMPORT_MAX_DETAILS_BYTES = 16 * 1024;
277
+ export const FLEX_SESSION_IMPORT_MAX_DEPTH = 8;
278
+
279
+ export const FLEX_SESSION_IMPORT_DEFAULT_LIMITS = Object.freeze({
280
+ maxOpenImports: 2,
281
+ maxMessagesPerSession: 2048,
282
+ maxSessionBytes: 64 * 1024 * 1024,
283
+ maxPageBytes: 1024 * 1024,
284
+ stagedImportIdleTimeoutMs: 5 * 60 * 1000,
285
+ });
286
+
287
+ export const FLEX_SESSION_IMPORT_MAXIMUM_LIMITS = Object.freeze({
288
+ maxOpenImports: 64,
289
+ maxMessagesPerSession: 20_000,
290
+ maxSessionBytes: 256 * 1024 * 1024,
291
+ maxPageBytes: 1536 * 1024,
292
+ stagedImportIdleTimeoutMs: 60 * 60 * 1000,
293
+ });
294
+
174
295
  export interface IFlexCreateSessionOptions {
175
296
  sessionId?: string;
176
297
  sessionGenerationId?: string;
@@ -349,6 +470,12 @@ export interface IFlexMessagePageOptions {
349
470
 
350
471
  export interface IFlexMessagePage {
351
472
  messages: IFlexMessage[];
473
+ /**
474
+ * Zero-based index of `messages[0]` in the session's visible history; for an empty page, the
475
+ * index the page ends at. Appending messages leaves it unchanged, while a branch after an undo
476
+ * reuses the positions of the history it replaces (`session.history.changed`).
477
+ */
478
+ startIndex: number;
352
479
  nextCursor?: string;
353
480
  }
354
481
 
@@ -460,6 +587,8 @@ export interface IFlexSessionTombstone {
460
587
  depth?: number;
461
588
  parentSessionId?: string;
462
589
  subagent?: IFlexSubagentProvenance;
590
+ /** Set when the session was imported; its deletion then writes no project-management tombstone. */
591
+ imported?: true;
463
592
  }
464
593
 
465
594
  export interface IFlexScopeSnapshot {
@@ -1134,6 +1263,7 @@ export interface IFlexHarnessOptions<TScope> {
1134
1263
  callbackLimits?: IFlexCallbackLimits;
1135
1264
  promptQueueLimits?: IFlexPromptQueueLimits;
1136
1265
  reversionLimits?: IFlexReversionLimits;
1266
+ sessionImportLimits?: IFlexSessionImportLimits;
1137
1267
  /**
1138
1268
  * Bounds each turn-reversion maintenance operation. Falls back to
1139
1269
  * agentSessionPolicy.generationLeaseCleanupTimeoutMs, then 30 seconds.
package/ts/readme.md CHANGED
@@ -235,6 +235,13 @@ const harness = new FlexHarness<IProjectScope>({
235
235
  maxExcludedRunIds: 1000,
236
236
  maxPendingReversionReleases: 1000,
237
237
  },
238
+ sessionImportLimits: {
239
+ maxOpenImports: 2,
240
+ maxMessagesPerSession: 2048,
241
+ maxSessionBytes: 64 * 1024 * 1024,
242
+ maxPageBytes: 1024 * 1024,
243
+ stagedImportIdleTimeoutMs: 5 * 60 * 1000,
244
+ },
238
245
  subagents: [
239
246
  {
240
247
  name: 'researcher',
@@ -601,9 +608,9 @@ Queue payloads, status records, and `prompt.*` queue events are process-local. T
601
608
 
602
609
  The reservation save is the admission point. A save failure produces no start events or active audit. If disposal begins while that save is in flight and the save commits, admission still resolves and its completion settles as cancelled; disposal waits for terminal finalization.
603
610
 
604
- `listMessagePage()` returns the newest contiguous page in chronological order. `limit` must be an integer from 1 through 50 and defaults to 50. `nextCursor` is opaque, limited to 4096 UTF-8 bytes, bound to the resolved storage namespace and session, and remains stable when newer messages are appended. Mismatched and stale cursors fail validation. `getMessage()` performs an exact lookup. Transfer identifiers are limited to 512 bytes, text and reasoning parts to 96 KiB, complete messages to 480 KiB, and complete page envelopes to 512 KiB. A page may therefore contain fewer messages than requested. Oversized text is truncated and an otherwise oversized parts collection is replaced with an explicit elision marker; metadata that still cannot fit fails validation. Canonical private Agent events are unchanged.
611
+ `listMessagePage()` returns the newest contiguous page in chronological order. `limit` must be an integer from 1 through 50 and defaults to 50. `nextCursor` is opaque, limited to 4096 UTF-8 bytes, bound to the resolved storage namespace and session, and remains stable when newer messages are appended. Mismatched and stale cursors fail validation. Each page carries `startIndex`, the zero-based index of its first message in the session's visible history (for an empty page, the index the page ends at), so a consumer can order and page by absolute position. Appending messages leaves every index unchanged; a branch after an undo reuses the positions of the history it replaces, so a consumer re-reads its pages after `session.history.changed`. `getMessage()` performs an exact lookup. Transfer identifiers are limited to 512 bytes, text and reasoning parts to 96 KiB, complete messages to 480 KiB, and complete page envelopes to 512 KiB. A page may therefore contain fewer messages than requested. Oversized text is truncated and an otherwise oversized parts collection is replaced with an explicit elision marker; metadata that still cannot fit fails validation. Canonical private Agent events are unchanged.
605
612
 
606
- `updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
613
+ `updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. An imported session can hold no project-management state, so its deletion writes no project tombstone. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
607
614
 
608
615
  `deleteSessionGenerationCohort()` is the generation-fenced destructive form for controllers. Its `root` and every `authorizedCohort` entry use the exact `{ sessionId, sessionGenerationId, sessionGenerationSequence }` shape. The cohort contains unique entries in strictly ascending `sessionId` order and is limited by `FLEX_SESSION_GENERATION_COHORT_MAX_ENTRIES` (2048). FlexHarness atomically validates the matching root, its complete live subtree, and every retained descendant tombstone group before reserving deletion. A missing or different root generation returns `{ matched: false }` without touching a newer generation; a missing or mismatched cascade entry throws before new tombstoning or cleanup. Extra valid entries do not expand the cascade. A matched partial cleanup remains retryable with the same authority and resolves to `{ matched: true }` when cleanup completes. The existing `deleteSession()` method remains available for callers that intentionally own the complete reusable-ID namespace.
609
616
 
@@ -783,7 +790,7 @@ Workspace reversion is generic and application-defined. It does not reverse netw
783
790
 
784
791
  Every configured value must be an integer from 1 through its hard maximum. Pruning removes complete prefixes rather than splitting an undo unit. Excluded run IDs prevent a committed branch from re-entering model context, while pending releases retain provider ownership until acknowledgement.
785
792
 
786
- `retireScope()` stops runtime ownership for the complete resolved storage namespace without deleting its durable snapshot. It does not load a namespace that has no cached or in-flight state. For loaded state, it preserves and waits for persistence that has already started, while later queued reads, writes, and run admissions reject with `FlexHarnessAbortError`. It cancels queued prompts and cancellable runs, rejects pending permissions, emits queue terminal events, waits for committing runs, terminal persistence, queue drains, tool-handle closure, and detached tool-provider cleanup, then purges queue status and clears and evicts the cached state. Failed cleanup ownership remains cached so a later `retireScope()` or `dispose()` call can retry it. Calls through storage-key aliases share the same retirement drain. A later call can load the durable namespace again after successful retirement if the application still resolves it.
793
+ `retireScope()` stops runtime ownership for the complete resolved storage namespace without deleting its durable snapshot. It does not load a namespace that has no cached or in-flight state. For loaded state, it preserves and waits for persistence that has already started, while later queued reads, writes, and run admissions reject with `FlexHarnessAbortError`. It cancels queued prompts and cancellable runs, rejects pending permissions, emits queue terminal events, waits for committing runs, terminal persistence, queue drains, tool-handle closure, and detached tool-provider cleanup, then purges queue status and clears and evicts the cached state. Failed cleanup ownership remains cached so a later `retireScope()` or `dispose()` call can retry it. Calls through storage-key aliases share the same retirement drain. A later call can load the durable namespace again after successful retirement if the application still resolves it. A loaded namespace keeps every session's messages in memory, so `retireScope()` is also the supported way for a host to evict a scope it has stopped using, such as an opened archive of imported sessions. Retirement releases every staged session import and cancels queued prompts and runs, so a host retires only a scope with no open import and no work it still wants.
787
794
 
788
795
  Normal retirement-induced cancellation does not make `retireScope()` reject. Unexpected failures observed through run finalization or scoped cleanup are surfaced without dropping the resources that still require cleanup. One such failure is thrown directly; multiple failures are reported through `FlexHarnessRunError`. Calling retirement or disposal again retries retained cleanup ownership.
789
796
 
@@ -805,6 +812,82 @@ Public audit history is safe to send to controllers: attachment parts contain so
805
812
 
806
813
  Sessions expose `idle`, `scheduled`, `running`, `waiting_permission`, `failed`, and `cancelled` status. Persisted non-terminal activity is repaired from canonical Agent generation outcomes after process restart; incomplete messages and parts normalize to `cancelled` unless an accepted hidden terminal stage can be promoted.
807
814
 
815
+ ## Conversation Import
816
+
817
+ A finished conversation from another tool can be imported as an archived, read-only session. The import is staged in three steps, so a large conversation crosses a process boundary in bounded pages. Turning a source format into Flex messages is the importer's job; the core validates, bounds, persists and protects the result.
818
+
819
+ ```typescript
820
+ const started = await harness.beginSessionImport('project:billing', {
821
+ sessionId: 'claude-7f3c2a',
822
+ title: 'Fix the billing export',
823
+ createdAt: '2026-09-01T09:00:00.000Z',
824
+ updatedAt: '2026-09-01T11:00:00.000Z',
825
+ origin: {
826
+ source: 'claude-code-transcript',
827
+ sourceId: '7f3c2a',
828
+ digest: 'sha256:9b1d…',
829
+ details: { cwd: '/workspace' },
830
+ },
831
+ });
832
+
833
+ if (started.status === 'started') {
834
+ try {
835
+ for (const page of pages) {
836
+ await harness.appendImportedRuns(started.handle, page);
837
+ }
838
+ const session = await harness.commitSessionImport(started.handle);
839
+ } finally {
840
+ await harness.abortSessionImport(started.handle);
841
+ }
842
+ }
843
+ ```
844
+
845
+ - **`beginSessionImport(scopeId, options)`** validates the options and reserves the caller-chosen `sessionId`. Choose a deterministic id, derived from the source. The result is one of three:
846
+ - `started`, with a handle `{ importId, scopeId, sessionId }`;
847
+ - `already_imported`, with the existing session;
848
+ - `digest_mismatch`, with the existing session.
849
+
850
+ A scope holds at most one import per `origin.source`, `origin.sourceId` and `origin.part.index`, whatever its session id. `already_imported` means that session has the same `origin.digest`, so the caller skips it. `digest_mismatch` means the source changed since; the caller deletes that session and imports again. A staged import of the same session id or source identity throws `FlexHarnessSessionBusyError`. Until the commit completes, `listSessions()` omits the session and `createSession()` refuses its id. While the import is staged, `getSession()` throws `FlexHarnessSessionBusyError`. While its commit runs, `getSession()` throws `FlexHarnessNotFoundError`, because a deletion tombstone reserves the id.
851
+ - **`appendImportedRuns(handle, runs)`** stages one page of runs, in order. A run is `{ user, assistant }`, two messages of the exact `IFlexMessage` shape. Both belong to the imported session, share one `runId` and one terminal status (`completed`, `failed` or `cancelled`), and have no `running` reasoning or tool part. Timestamps are ISO 8601 as `Date.prototype.toISOString()` renders them. Run ids and message ids are unique within the import. A page that breaks a rule or a bound stages nothing, and the import stays open. The result reports the staged `runs`, `messages` and `bytes`.
852
+ - **`commitSessionImport(handle)`** persists the runs and publishes the session, with one `session.created` event:
853
+ - `origin` is `{ kind: 'import', source, sourceId, digest, part?, details?, importedAt }`, and `archivedAt` equals `importedAt`;
854
+ - `createdAt` and `updatedAt` are the ones passed to `beginSessionImport()`, and the status is `idle`.
855
+
856
+ The commit first persists a deletion tombstone for the id, then writes the projection, then replaces the tombstone with the session in one scope save. A failed commit removes what it persisted through the normal tombstone cleanup. After a crash, or a save whose outcome is uncertain, the next load of the namespace resolves it: a surviving tombstone is cleaned up, and a published session is kept. A commit needs at least one run.
857
+ - **`abortSessionImport(handle)`** discards a staged import. Nothing of it was persisted. An unknown or finished handle is ignored, so the call is safe in a `finally`. A staged import that receives no `appendImportedRuns()` call for `stagedImportIdleTimeoutMs` expires and is discarded the same way; its handle then reports `FlexHarnessNotFoundError`.
858
+ - **Child sessions.** `parent: { sessionId, runId, toolCallId, agent }` imports a child session, such as a subagent's conversation. The parent must be a committed imported session in the same scope. Its assistant message of `runId` must hold the tool call `toolCallId` whose `childSessionId` names the child. No other session may claim that tool call. The child gets `depth` one below its parent, at most `FLEX_SESSION_IMPORT_MAX_DEPTH` (8). Deleting the parent deletes its children.
859
+
860
+ **Read-only.** An imported session never runs, and nothing writes model context for it: it has no Agent events and no tool jobs. Every operation that would change its history or model context throws `FlexHarnessImportedSessionError` (code `FLEX_SESSION_READ_ONLY`):
861
+
862
+ - `prompt`, `startPrompt`, `enqueuePrompt` and `schedulePrompt`;
863
+ - `executeSlashCommand` and `compactSession`;
864
+ - `undoSession` and `redoSession`;
865
+ - `pushRuntimeEvent`, `reconcileToolExecution` and `archiveSessionEvents`;
866
+ - the project-management writes: tasks, goal and scratchpad.
867
+
868
+ `listSlashCommands()` reports every command unavailable with the reason `Session is an imported conversation and is read-only.` Reads work: sessions, messages, message pages, reversion info and project state. So do `updateSession()` (title and archived flag), `deleteSession()` and `deleteSessionGenerationCohort()`. A load never repairs an imported session's runs from Agent generation outcomes, because it has none.
869
+
870
+ **Bounds.** `sessionImportLimits` sets the bounds; each must be an integer from 1 through its maximum:
871
+
872
+ | Limit | Default | Maximum |
873
+ |---|---|---|
874
+ | `maxOpenImports` (staged imports at once, per harness) | 2 | 64 |
875
+ | `maxMessagesPerSession` | 2048 | 20000 |
876
+ | `maxSessionBytes` (JSON bytes of all messages) | 64 MiB | 256 MiB |
877
+ | `maxPageBytes` (JSON bytes of one `appendImportedRuns()` page) | 1 MiB | 1.5 MiB |
878
+ | `stagedImportIdleTimeoutMs` (idle time before a staged import expires) | 5 minutes | 1 hour |
879
+
880
+ Some bounds are fixed:
881
+
882
+ - one message is at most `FLEX_SESSION_IMPORT_MAX_MESSAGE_BYTES` (480 KiB), and must read back through the message transfer limits;
883
+ - `origin.details` is a JSON object of at most `FLEX_SESSION_IMPORT_MAX_DETAILS_BYTES` (16 KiB);
884
+ - `origin.source` is at most 128 UTF-8 bytes, `origin.sourceId` 512 and `origin.digest` 256, none with control characters;
885
+ - `origin.part` has a `count` from 1 through 1024 and a zero-based `index` below it.
886
+
887
+ Staged runs are held in memory until the commit, so the memory of open imports is bounded by `maxOpenImports` × `maxSessionBytes`. Retiring the scope, disposing the harness or the idle expiry discards staged imports. The expiry timer does not keep the process alive.
888
+
889
+ **Stores.** The import adds no store method. Scope snapshots carry the optional session `origin`, and a store that validates session keys exactly must accept it. The commit writes the whole projection in one save at revision 1. FlexHarness versions before 9.5.0 reject the new key under their strict reader, so a downgrade over imported sessions is unsupported. The tombstone that reserves an import, or that deletes an imported session, carries the optional `imported: true`, and a store that validates tombstone keys exactly must accept it. FlexHarness 9.5.x rejects a scope snapshot that holds such a tombstone; one exists only while an import commits or an imported session is deleted, or after either was interrupted.
890
+
808
891
  ## Permissions
809
892
 
810
893
  Tools request permission through the run-scoped provider context:
package/ts/utils.json.ts CHANGED
@@ -494,10 +494,12 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
494
494
  'parentToolCallId',
495
495
  'agent',
496
496
  'depth',
497
+ 'origin',
497
498
  ],
498
499
  path,
499
500
  );
500
501
  requireString(session.scopeId, `${path}.scopeId`);
502
+ if (session.origin !== undefined) validateSessionOrigin(session.origin, `${path}.origin`);
501
503
  const sessionId = requireString(session.sessionId, `${path}.sessionId`);
502
504
  const generation = validateOptionalSessionGeneration(session, path);
503
505
  requireOptionalString(session.title, `${path}.title`);
@@ -555,6 +557,41 @@ function validateSession(value: unknown, path: string): IValidatedSessionIdentit
555
557
  };
556
558
  }
557
559
 
560
+ function validateSessionOrigin(value: unknown, path: string): void {
561
+ const origin = requireRecord(value, path);
562
+ requireOnlyKeys(
563
+ origin,
564
+ ['kind', 'source', 'sourceId', 'digest', 'importedAt', 'part', 'details'],
565
+ path,
566
+ );
567
+ if (origin.kind !== 'import') {
568
+ throw new FlexHarnessStoreFormatError(`${path}.kind is invalid.`);
569
+ }
570
+ requireString(origin.source, `${path}.source`);
571
+ requireString(origin.sourceId, `${path}.sourceId`);
572
+ requireString(origin.digest, `${path}.digest`);
573
+ requireString(origin.importedAt, `${path}.importedAt`);
574
+ if (origin.part !== undefined) {
575
+ const part = requireRecord(origin.part, `${path}.part`);
576
+ requireOnlyKeys(part, ['index', 'count'], `${path}.part`);
577
+ requireNonNegativeInteger(part.index, `${path}.part.index`);
578
+ requireNonNegativeInteger(part.count, `${path}.part.count`);
579
+ if (Number(part.index) >= Number(part.count)) {
580
+ throw new FlexHarnessStoreFormatError(`${path}.part.index must be below part.count.`);
581
+ }
582
+ }
583
+ if (origin.details !== undefined) requireRecord(origin.details, `${path}.details`);
584
+ }
585
+
586
+ /**
587
+ * Asserts the exact stored shape of one public message: its keys, part kinds and field types.
588
+ * Throws `FlexHarnessStoreFormatError`.
589
+ */
590
+ export function assertFlexMessageRecord(value: unknown, path: string): void {
591
+ assertJsonSerializable(value, path);
592
+ validateMessage(value, path);
593
+ }
594
+
558
595
  function validateSubagentProvenance(
559
596
  value: unknown,
560
597
  path: string,
@@ -905,6 +942,7 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
905
942
  'depth',
906
943
  'parentSessionId',
907
944
  'subagent',
945
+ 'imported',
908
946
  ],
909
947
  path,
910
948
  );
@@ -930,6 +968,9 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
930
968
  `${path}.subagent does not match its parentSessionId.`,
931
969
  );
932
970
  }
971
+ if (tombstone.imported !== undefined && tombstone.imported !== true) {
972
+ throw new FlexHarnessStoreFormatError(`${path}.imported must be true when present.`);
973
+ }
933
974
  const hasRoot = tombstone.rootSessionId !== undefined;
934
975
  const hasDepth = tombstone.depth !== undefined;
935
976
  if (hasRoot !== hasDepth) {