deepline 0.2.53 → 0.2.55

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.
Files changed (44) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +17 -1
  2. package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/sdk/src/types.ts +43 -1
  5. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +30 -1
  6. package/dist/bundling-sources/shared_libs/play-runtime/cell-provenance.ts +231 -0
  7. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1094 -128
  8. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +178 -9
  9. package/dist/bundling-sources/shared_libs/play-runtime/docflow-node-io.ts +634 -0
  10. package/dist/bundling-sources/shared_libs/play-runtime/docflow-observation.ts +64 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/dynamic-worker-version.ts +1 -1
  12. package/dist/bundling-sources/shared_libs/play-runtime/execution-capabilities.ts +18 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/live-state-contract.ts +33 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/log-provenance.ts +251 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/play-node-scope.ts +160 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +6 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/run-failure.ts +27 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +43 -5
  19. package/dist/bundling-sources/shared_libs/play-runtime/run-snapshot-stream.ts +12 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/local-process.ts +26 -4
  21. package/dist/bundling-sources/shared_libs/play-runtime/runtime-actions.ts +6 -1
  22. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +83 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/worker-api-types.ts +3 -0
  24. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +49 -1
  25. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +375 -29
  26. package/dist/bundling-sources/shared_libs/plays/docflow-binding-owner.ts +636 -0
  27. package/dist/bundling-sources/shared_libs/plays/docflow-binding.ts +598 -0
  28. package/dist/bundling-sources/shared_libs/plays/docflow.ts +1645 -0
  29. package/dist/bundling-sources/shared_libs/plays/play-exports.ts +202 -0
  30. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +16 -1
  31. package/dist/bundling-sources/shared_libs/plays/ts-ast.ts +48 -0
  32. package/dist/cli/index.js +994 -312
  33. package/dist/cli/index.mjs +994 -312
  34. package/dist/{compiler-manifest-Cj3--4ZJ.d.mts → compiler-manifest-Bl8kmLx9.d.mts} +118 -0
  35. package/dist/{compiler-manifest-Cj3--4ZJ.d.ts → compiler-manifest-Bl8kmLx9.d.ts} +118 -0
  36. package/dist/index.d.mts +47 -2
  37. package/dist/index.d.ts +47 -2
  38. package/dist/index.js +419 -59
  39. package/dist/index.mjs +419 -59
  40. package/dist/install-integrity.json +12 -2
  41. package/dist/plays/bundle-play-file.d.mts +2 -2
  42. package/dist/plays/bundle-play-file.d.ts +2 -2
  43. package/dist/plays/bundle-play-file.mjs +1361 -45
  44. package/package.json +1 -1
@@ -237,6 +237,12 @@ import {
237
237
  createSecretRedactionContext,
238
238
  type SecretRedactionContext,
239
239
  } from './secret-redaction';
240
+ import {
241
+ buildPlayDocflowNodeErrorPreview,
242
+ buildPlayDocflowNodeInputPreviewMap,
243
+ type PlayDocflowNodeInputCapture,
244
+ } from './docflow-node-io';
245
+ import type { DocflowObservationUpsert } from './docflow-observation';
240
246
  import {
241
247
  assertNoSecretTaint,
242
248
  assertSecretAuthUsesTls,
@@ -277,7 +283,25 @@ import type {
277
283
  RuntimeStepProgram,
278
284
  RuntimeStepProgramStep,
279
285
  RuntimeConditionalStepResolver,
286
+ PlayCellProducerAttempt,
287
+ PlayCellReadRef,
288
+ PlayCellDecision,
289
+ PlayRowReadOrder,
290
+ PlayRowMeta,
291
+ PlaySheetCellProducer,
280
292
  } from './ctx-types';
293
+ import {
294
+ MAX_CELL_READ_REFS,
295
+ MAX_ROW_READ_CELLS,
296
+ ROW_META_CELL_KEY,
297
+ cellReadCut,
298
+ hydrateCellReadRefs,
299
+ } from './cell-provenance';
300
+ import {
301
+ buildPlayNodeScope,
302
+ playNodeScopeToWire,
303
+ type PlayNodeScope,
304
+ } from './play-node-scope';
281
305
  import {
282
306
  StepProgramDatasetBuilder,
283
307
  type StepProgramDatasetColumnInput,
@@ -755,6 +779,59 @@ function finitePositiveInteger(value: unknown): number | null {
755
779
  return integer !== null && integer > 0 ? integer : null;
756
780
  }
757
781
 
782
+ /**
783
+ * Sources that answered without opening a provider request of their own: a
784
+ * completed-receipt cache hit, a worker-replay checkpoint, and a follower that
785
+ * attached to an in-flight owner. `cached` is false on the in-flight metadata
786
+ * (nothing was cached at attach time), but no request was made either, so
787
+ * classifying it as a live call disagreed with the `reused` marker the sheet
788
+ * already writes and inflated `providerRequests` against `reusedCells`.
789
+ */
790
+ const REUSED_TOOL_EXECUTION_SOURCES = new Set([
791
+ 'cache',
792
+ 'checkpoint',
793
+ 'in_flight',
794
+ ]);
795
+
796
+ /**
797
+ * Classify a settled tool-call result for the cell producer trace (ADR 0018).
798
+ * Unknown shapes settle as `completed`: the call returned, which is the fact
799
+ * the trace records.
800
+ */
801
+ function toolAttemptOutcomeForResult(
802
+ value: unknown,
803
+ ): Exclude<PlayCellProducerAttempt['outcome'], 'running'> {
804
+ if (value == null) return 'no_result';
805
+ if (typeof value !== 'object' || Array.isArray(value)) return 'completed';
806
+ const record = value as Record<string, unknown>;
807
+ const execution = (record._metadata as Record<string, unknown> | undefined)
808
+ ?.execution as Record<string, unknown> | undefined;
809
+ const reused =
810
+ execution?.cached === true ||
811
+ execution?.receiptRole === 'follower' ||
812
+ (typeof execution?.source === 'string' &&
813
+ REUSED_TOOL_EXECUTION_SOURCES.has(execution.source));
814
+ if (reused) return 'cached';
815
+ return record.status === 'no_result' ? 'no_result' : 'completed';
816
+ }
817
+
818
+ /**
819
+ * Explicit execution location for a queued row-scoped tool call (ADR 0018).
820
+ * `toolIdOverride` lets a native batch report the batch operation it actually
821
+ * sent while keeping the members' column/namespace.
822
+ */
823
+ function playNodeScopeForToolCallRequest(
824
+ request: ToolCallRequest,
825
+ toolIdOverride?: string,
826
+ ): PlayNodeScope | null {
827
+ return buildPlayNodeScope({
828
+ toolId: toolIdOverride ?? request.toolId,
829
+ callKey: request.contextKey,
830
+ column: request.fieldName,
831
+ tableNamespace: request.tableNamespace,
832
+ });
833
+ }
834
+
758
835
  type ToolExecutionApiOptions = {
759
836
  timeoutMs?: number;
760
837
  durableCallReceiptKey?: string | null;
@@ -764,6 +841,12 @@ type ToolExecutionApiOptions = {
764
841
  beforeProviderCall?: () => Promise<void> | void;
765
842
  heartbeatReceipt?: () => Promise<void> | void;
766
843
  parkProviderCall?: () => Promise<void> | void;
844
+ /**
845
+ * Explicit graph-node attribution for this provider call (ADR 0018). Carried
846
+ * to the execute route, which stamps it onto the usage event so per-node
847
+ * credits are a query instead of a log-text inference.
848
+ */
849
+ playNodeScope?: PlayNodeScope | null;
767
850
  customerDbDataset?: {
768
851
  limit: number;
769
852
  offset: number;
@@ -772,6 +855,11 @@ type ToolExecutionApiOptions = {
772
855
  };
773
856
  };
774
857
  const IN_MEMORY_STEP_RESULT_PREVIEW_LIMIT = 25;
858
+ /**
859
+ * Per-cell cap on the durable producer trace (ADR 0018). A cascade has a handful
860
+ * of legs; this only bounds pathological loops, keeping `_cell_meta` small.
861
+ */
862
+ const MAX_CELL_PRODUCER_ATTEMPTS = 12;
775
863
  const BATCH_SIZE_LOG_SAMPLE_LIMIT = 10;
776
864
  const STEP_PROGRAM_MAP_DEFINITION = Symbol('deepline.stepProgramMapDefinition');
777
865
 
@@ -1435,6 +1523,22 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
1435
1523
  }> = [];
1436
1524
  private processedRowCount = 0;
1437
1525
  private sleepBoundaryIndex = 0;
1526
+ private nextDocflowInvocationSequence = 0;
1527
+ /**
1528
+ * Whether this run captures the docflow trace at all (ADR 0019), decided at
1529
+ * admission and handed down through the signed runtime authority.
1530
+ *
1531
+ * Read at every capture SITE rather than only inside the recorders, because
1532
+ * the cost being gated is mostly in the arguments: `cellReadRefsForRow`,
1533
+ * `effectiveCellReads` and `rowInputReadColumns` each walk the row, and a
1534
+ * recorder that returns early has already paid for them. Off, an ungated org
1535
+ * writes precisely the `_cell_meta` it wrote before this feature existed —
1536
+ * no `reads`, no `decide`, no `_row` order — so nothing downstream has to
1537
+ * know two shapes.
1538
+ */
1539
+ private get docflowCaptureEnabled(): boolean {
1540
+ return this.#options.docflowEnabled === true;
1541
+ }
1438
1542
  /**
1439
1543
  * `ctx.customerDb.query` is syntactic sugar over the query_customer_db tool.
1440
1544
  * Keep generated tool ids distinct so separate imperative queries retain
@@ -1764,6 +1868,165 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
1764
1868
  });
1765
1869
  }
1766
1870
 
1871
+ /** @internal Compiler-injected authored-flow execution marker. */
1872
+ async __deeplineDocflowHit(nodeId: string): Promise<void> {
1873
+ const normalizedNodeId = nodeId.trim();
1874
+ if (!normalizedNodeId) {
1875
+ throw new Error('Docflow runtime node id must be non-empty.');
1876
+ }
1877
+ // These calls are COMPILER-INJECTED, so bundle-time gating cannot reach
1878
+ // them: a prebuilt is compiled once, with its diagram, and then runs for
1879
+ // every org. Without this check an ungated customer running a prebuilt gets
1880
+ // `docflow.node.*` events on the wire and a `step docflow:*` sequence in
1881
+ // `plays run --watch` — the vocabulary of a feature they do not have.
1882
+ // The id is still validated above: a malformed injection is a compiler bug
1883
+ // and stays loud whether or not the run is capturing.
1884
+ if (!this.docflowCaptureEnabled) return;
1885
+ this.emitExecutionEvent({
1886
+ type: 'docflow.node.hit',
1887
+ nodeId: `docflow:${normalizedNodeId}`,
1888
+ at: Date.now(),
1889
+ });
1890
+ }
1891
+
1892
+ /** @internal Compiler-injected bounded authored-node observation boundary. */
1893
+ __deeplineObserveDocflowNode<T>(
1894
+ nodeId: string,
1895
+ inputCaptures: readonly PlayDocflowNodeInputCapture[],
1896
+ outputPaths: readonly string[],
1897
+ execute: () => T | Promise<T>,
1898
+ ): T | Promise<T> {
1899
+ const normalizedNodeId = nodeId.trim();
1900
+ if (!normalizedNodeId) {
1901
+ throw new Error('Docflow runtime node id must be non-empty.');
1902
+ }
1903
+ // Ungated, this boundary is a pass-through: the wrapped work still runs and
1904
+ // still returns what it returned, but nothing is previewed, redacted, or
1905
+ // emitted. Returning before `buildPlayDocflowNodeInputPreviewMap` is the
1906
+ // point — previewing every bound node's inputs is the expensive half, and a
1907
+ // customer outside the rollout should not pay for a capture they cannot
1908
+ // read. Same reasoning as `__deeplineDocflowHit` above: injected at compile
1909
+ // time, so the run is the only place left to decide.
1910
+ if (!this.docflowCaptureEnabled) return execute();
1911
+ const attempt = this.currentRunAttempt;
1912
+ const inputs = buildPlayDocflowNodeInputPreviewMap(inputCaptures, {
1913
+ redactor: this.secretRedactor,
1914
+ });
1915
+ const invocationSeq = this.nextDocflowInvocationSequence++;
1916
+ const invocationId = `${attempt}:${invocationSeq}`;
1917
+ const observationNodeId = `docflow:${normalizedNodeId}`;
1918
+ this.emitExecutionEvent({
1919
+ type: 'docflow.node.started',
1920
+ nodeId: observationNodeId,
1921
+ attempt,
1922
+ invocationId,
1923
+ inputs: inputs.values,
1924
+ ...(inputs.truncated ? { inputsTruncated: true } : {}),
1925
+ at: Date.now(),
1926
+ });
1927
+ this.emitDocflowObservation({
1928
+ runId: this.currentGovernance.currentRunId,
1929
+ attempt,
1930
+ nodeId: observationNodeId,
1931
+ invocationSeq,
1932
+ invocationId,
1933
+ status: 'started',
1934
+ inputs: inputs.values,
1935
+ inputsTruncated: inputs.truncated,
1936
+ at: Date.now(),
1937
+ });
1938
+
1939
+ const complete = (result: T): void => {
1940
+ const outputRoots = new Set(
1941
+ outputPaths
1942
+ .map((path) => path.trim())
1943
+ .filter((path) => path && path !== '$output')
1944
+ .map((path) => path.split('.')[0]),
1945
+ );
1946
+ const outputs = buildPlayDocflowNodeInputPreviewMap(
1947
+ outputPaths
1948
+ .map((path) => path.trim())
1949
+ .filter(Boolean)
1950
+ .map((path) => ({
1951
+ path,
1952
+ readRoot: () => result,
1953
+ properties:
1954
+ path === '$output'
1955
+ ? []
1956
+ : outputRoots.size > 1
1957
+ ? path.split('.')
1958
+ : path.split('.').slice(1),
1959
+ })),
1960
+ { redactor: this.secretRedactor },
1961
+ );
1962
+ this.emitExecutionEvent({
1963
+ type: 'docflow.node.completed',
1964
+ nodeId: observationNodeId,
1965
+ attempt,
1966
+ invocationId,
1967
+ inputs: inputs.values,
1968
+ ...(inputs.truncated ? { inputsTruncated: true } : {}),
1969
+ outputs: outputs.values,
1970
+ ...(outputs.truncated ? { outputsTruncated: true } : {}),
1971
+ at: Date.now(),
1972
+ });
1973
+ this.emitDocflowObservation({
1974
+ runId: this.currentGovernance.currentRunId,
1975
+ attempt,
1976
+ nodeId: observationNodeId,
1977
+ invocationSeq,
1978
+ invocationId,
1979
+ status: 'completed',
1980
+ inputs: inputs.values,
1981
+ inputsTruncated: inputs.truncated,
1982
+ outputs: outputs.values,
1983
+ outputsTruncated: outputs.truncated,
1984
+ at: Date.now(),
1985
+ });
1986
+ };
1987
+ const fail = (error: unknown): void => {
1988
+ const errorPreview = buildPlayDocflowNodeErrorPreview(
1989
+ error,
1990
+ this.secretRedactor,
1991
+ );
1992
+ this.emitExecutionEvent({
1993
+ type: 'docflow.node.failed',
1994
+ nodeId: observationNodeId,
1995
+ attempt,
1996
+ invocationId,
1997
+ inputs: inputs.values,
1998
+ ...(inputs.truncated ? { inputsTruncated: true } : {}),
1999
+ error: errorPreview,
2000
+ at: Date.now(),
2001
+ });
2002
+ this.emitDocflowObservation({
2003
+ runId: this.currentGovernance.currentRunId,
2004
+ attempt,
2005
+ nodeId: observationNodeId,
2006
+ invocationSeq,
2007
+ invocationId,
2008
+ status: 'failed',
2009
+ inputs: inputs.values,
2010
+ inputsTruncated: inputs.truncated,
2011
+ error: errorPreview,
2012
+ at: Date.now(),
2013
+ });
2014
+ };
2015
+
2016
+ try {
2017
+ const result = execute();
2018
+ if (result instanceof Promise) {
2019
+ void result.then(complete, fail);
2020
+ return result;
2021
+ }
2022
+ complete(result);
2023
+ return result;
2024
+ } catch (error) {
2025
+ fail(error);
2026
+ throw error;
2027
+ }
2028
+ }
2029
+
1767
2030
  private durableBoundaryId(localId: string): string {
1768
2031
  // Durable boundaries live in one checkpoint for the whole root execution.
1769
2032
  // Nested plays and concurrent child calls therefore need a stable run scope
@@ -1907,6 +2170,16 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
1907
2170
  ...(existing?.cellMetaPatch ?? {}),
1908
2171
  ...(update.cellMetaPatch ?? {}),
1909
2172
  },
2173
+ // Row-grain provenance is re-stated whole on every patch, so the later
2174
+ // record always supersedes: it can only have grown (ADR 0019).
2175
+ ...((update.rowMetaPatch ?? existing?.rowMetaPatch)
2176
+ ? {
2177
+ rowMetaPatch: {
2178
+ ...(existing?.rowMetaPatch ?? {}),
2179
+ ...(update.rowMetaPatch ?? {}),
2180
+ },
2181
+ }
2182
+ : {}),
1910
2183
  };
1911
2184
  this.activeMapCheckpointUpdates.set(checkpointKey, next);
1912
2185
  }
@@ -1931,7 +2204,41 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
1931
2204
  if (!this.#options.onExecutionEvent) {
1932
2205
  return;
1933
2206
  }
1934
- void this.#options.onExecutionEvent(event);
2207
+ try {
2208
+ const pending = this.#options.onExecutionEvent(event);
2209
+ if (pending && typeof pending.then === 'function') {
2210
+ void pending.catch(() => {
2211
+ // Execution events are observability projections. Their transport
2212
+ // must never change authored play behavior or mask its result.
2213
+ });
2214
+ }
2215
+ } catch {
2216
+ // Keep synchronous observer failures outside the authored execution.
2217
+ }
2218
+ }
2219
+
2220
+ /**
2221
+ * Durable docflow observation write (ADR 0016 rule 2). The sink is present
2222
+ * only for instrumented plays and posts to the receipt gateway. It is
2223
+ * strictly fire-and-forget: a synchronous throw or a rejected promise is
2224
+ * swallowed (loudly logged by the sink) so a durable-write failure can never
2225
+ * fail or slow the authored play body. The live event emission above is the
2226
+ * authoritative low-latency path and is unaffected.
2227
+ */
2228
+ private emitDocflowObservation(observation: DocflowObservationUpsert): void {
2229
+ if (!this.#options.onDocflowObservation) {
2230
+ return;
2231
+ }
2232
+ try {
2233
+ const pending = this.#options.onDocflowObservation(observation);
2234
+ if (pending && typeof pending.then === 'function') {
2235
+ void pending.catch(() => {
2236
+ // Swallowed here; the sink is responsible for loud logging.
2237
+ });
2238
+ }
2239
+ } catch {
2240
+ // Never let an observation write escape into authored execution.
2241
+ }
1935
2242
  }
1936
2243
 
1937
2244
  private async resolveSecretAuth(auth: SecretAuth | undefined) {
@@ -3346,6 +3653,418 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
3346
3653
  return this.currentExecutionScope.receipt.ownerAttempt;
3347
3654
  }
3348
3655
 
3656
+ /**
3657
+ * Explicit tool-node lifecycle for non-row-scoped calls (ADR 0018). Replaces
3658
+ * the stdout-regex inference the progress reporter used to run.
3659
+ */
3660
+ private emitToolCallLifecycle(
3661
+ phase: 'started' | 'settled',
3662
+ toolId: string,
3663
+ callKey: string | null,
3664
+ settlement?: {
3665
+ outcome: 'completed' | 'no_result' | 'failed' | 'cached';
3666
+ durationMs?: number | null;
3667
+ error?: string | null;
3668
+ },
3669
+ ): void {
3670
+ if (phase === 'started') {
3671
+ this.emitExecutionEvent({
3672
+ type: 'tool.call.started',
3673
+ toolId,
3674
+ callKey,
3675
+ at: Date.now(),
3676
+ });
3677
+ return;
3678
+ }
3679
+ if (!settlement) return;
3680
+ this.emitExecutionEvent({
3681
+ type: 'tool.call.settled',
3682
+ toolId,
3683
+ callKey,
3684
+ outcome: settlement.outcome,
3685
+ ...(settlement.durationMs !== undefined
3686
+ ? { durationMs: settlement.durationMs }
3687
+ : {}),
3688
+ ...(settlement.error ? { error: settlement.error } : {}),
3689
+ at: Date.now(),
3690
+ });
3691
+ }
3692
+
3693
+ /**
3694
+ * Direct (non-row-scoped) provider call with explicit node lifecycle. The
3695
+ * failure branch must emit `tool.call.settled` before rethrowing, otherwise a
3696
+ * failed tool node would stay `running` in the snapshot forever.
3697
+ */
3698
+ private async executeDirectToolCall(input: {
3699
+ toolId: string;
3700
+ callKey: string;
3701
+ startedAt: number;
3702
+ input: Record<string, unknown>;
3703
+ options: ToolExecutionApiOptions;
3704
+ }): Promise<ParsedToolExecuteResponse> {
3705
+ // The caller owns settlement: it pairs started/settled in a finally so a
3706
+ // throw anywhere in the direct path — transport, result wrapping, receipt
3707
+ // ownership — settles the node exactly once instead of stranding it.
3708
+ return await this.callToolExecutionAPI(
3709
+ input.toolId,
3710
+ input.input,
3711
+ input.options,
3712
+ );
3713
+ }
3714
+
3715
+ /**
3716
+ * Bounded per-cell producer trace (ADR 0018).
3717
+ *
3718
+ * Returns the cell's accumulated attempts, with `attempt` appended when a new
3719
+ * one starts. Attempt records are returned by reference so a caller can
3720
+ * settle `outcome`/`durationMs` in place before the next patch re-states the
3721
+ * array. Storage is `activeMapCellMeta`, which is scoped to the running map
3722
+ * and released with it, so this adds no run-lifetime structure.
3723
+ */
3724
+ /** Last recorded producer for a cell, so a later patch cannot erase it. */
3725
+ private resolveCellProducer(
3726
+ key: string | null,
3727
+ fieldName: string,
3728
+ ): PlaySheetCellProducer | undefined {
3729
+ const existingCell = key
3730
+ ? this.activeMapCellMeta?.get(key)?.[fieldName]
3731
+ : undefined;
3732
+ const producer =
3733
+ existingCell &&
3734
+ typeof existingCell === 'object' &&
3735
+ !Array.isArray(existingCell)
3736
+ ? (existingCell as { producer?: unknown }).producer
3737
+ : undefined;
3738
+ return producer as PlaySheetCellProducer | undefined;
3739
+ }
3740
+
3741
+ private resolveCellProducers(
3742
+ key: string | null,
3743
+ fieldName: string,
3744
+ attempt?: PlayCellProducerAttempt,
3745
+ ): {
3746
+ producers: PlayCellProducerAttempt[] | undefined;
3747
+ attemptsDropped: number;
3748
+ } {
3749
+ if (!key) {
3750
+ return {
3751
+ producers: attempt ? [attempt] : undefined,
3752
+ attemptsDropped: 0,
3753
+ };
3754
+ }
3755
+ const existingCell = this.activeCellMetaRecord(key, fieldName);
3756
+ const existingProducers = existingCell?.producers;
3757
+ const current = Array.isArray(existingProducers)
3758
+ ? (existingProducers as PlayCellProducerAttempt[])
3759
+ : [];
3760
+ const droppedSoFar =
3761
+ typeof existingCell?.attemptsDropped === 'number'
3762
+ ? existingCell.attemptsDropped
3763
+ : 0;
3764
+ if (!attempt) {
3765
+ return {
3766
+ producers: current.length > 0 ? current : undefined,
3767
+ attemptsDropped: droppedSoFar,
3768
+ };
3769
+ }
3770
+ const next = [...current, attempt];
3771
+ if (next.length <= MAX_CELL_PRODUCER_ATTEMPTS) {
3772
+ return { producers: next, attemptsDropped: droppedSoFar };
3773
+ }
3774
+ // Keep the newest attempts — a cascade's decisive legs are its last ones —
3775
+ // but count what fell off. Without the count a capped cell's logicalCalls
3776
+ // reads as a complete total when it is a floor.
3777
+ const dropped = next.length - MAX_CELL_PRODUCER_ATTEMPTS;
3778
+ return {
3779
+ producers: next.slice(dropped),
3780
+ attemptsDropped: droppedSoFar + dropped,
3781
+ };
3782
+ }
3783
+
3784
+ /**
3785
+ * Carry this run's durable producer trace into a re-executed row (ADR 0018).
3786
+ *
3787
+ * `activeMapCellMeta` starts empty on a worker replay, and the durable jsonb
3788
+ * merge replaces a cell's `producers` array wholesale, so a re-executed cell
3789
+ * would erase the attempts the earlier execution recorded — silently
3790
+ * under-counting `logicalCalls` and losing the only record a failed leg ever
3791
+ * gets. The row already arrives carrying its durable cell meta (it is what
3792
+ * `previousCell` is built from), so seeding costs no extra round trip.
3793
+ *
3794
+ * Only cells stamped with the CURRENT run are seeded. A previous run's
3795
+ * attempts are that run's facts; appending them here would inflate this run.
3796
+ * Seeding read-modify-writes nothing durable: the append still lands through
3797
+ * the attempt-fenced write, so a fenced-out attempt cannot corrupt the trace.
3798
+ */
3799
+ private seedActiveCellProducersFromDurableRow(
3800
+ rowKey: string | null,
3801
+ baseRow: Record<string, unknown>,
3802
+ ): void {
3803
+ if (!rowKey || !this.activeMapCellMeta) return;
3804
+ const durable = baseRow[DEEPLINE_CELL_META_FIELD];
3805
+ if (!durable || typeof durable !== 'object' || Array.isArray(durable)) {
3806
+ return;
3807
+ }
3808
+ for (const [fieldName, rawCell] of Object.entries(
3809
+ durable as Record<string, unknown>,
3810
+ )) {
3811
+ if (!rawCell || typeof rawCell !== 'object' || Array.isArray(rawCell)) {
3812
+ continue;
3813
+ }
3814
+ const cell = rawCell as Record<string, unknown>;
3815
+ if (cell.runId !== this.currentRunId) continue;
3816
+ if (!Array.isArray(cell.producers) || cell.producers.length === 0) {
3817
+ continue;
3818
+ }
3819
+ const existingRow = this.activeMapCellMeta.get(rowKey);
3820
+ const existingCell = this.activeCellMetaRecord(rowKey, fieldName);
3821
+ if (existingCell?.producers !== undefined) continue;
3822
+ this.activeMapCellMeta.set(rowKey, {
3823
+ ...(existingRow ?? {}),
3824
+ [fieldName]: {
3825
+ ...(existingCell ?? {}),
3826
+ producers: cell.producers,
3827
+ ...(typeof cell.attemptsDropped === 'number'
3828
+ ? { attemptsDropped: cell.attemptsDropped }
3829
+ : {}),
3830
+ },
3831
+ });
3832
+ }
3833
+ }
3834
+
3835
+ /** Existing `_cell_meta` record for a cell inside the running map, if any. */
3836
+ private activeCellMetaRecord(
3837
+ key: string | null,
3838
+ fieldName: string,
3839
+ ): Record<string, unknown> | null {
3840
+ const existingCell = key
3841
+ ? this.activeMapCellMeta?.get(key)?.[fieldName]
3842
+ : undefined;
3843
+ return existingCell &&
3844
+ typeof existingCell === 'object' &&
3845
+ !Array.isArray(existingCell)
3846
+ ? (existingCell as Record<string, unknown>)
3847
+ : null;
3848
+ }
3849
+
3850
+ /**
3851
+ * Provenance capture is a field on writes that already happen (ADR 0019), so
3852
+ * a record made *before* the cell's next patch is seeded into the running
3853
+ * map's cell meta and re-stated by that patch. No extra emit, no extra round
3854
+ * trip, and the record is released with the map like the producer trace.
3855
+ */
3856
+ private recordCellProvenance(
3857
+ key: string | null,
3858
+ fieldName: string,
3859
+ record: { reads?: PlayCellReadRef[]; decide?: PlayCellDecision },
3860
+ ): void {
3861
+ if (!key || !this.activeMapCellMeta) return;
3862
+ if (record.reads === undefined && record.decide === undefined) return;
3863
+ const existingRow = this.activeMapCellMeta.get(key);
3864
+ const existingCell = this.activeCellMetaRecord(key, fieldName);
3865
+ this.activeMapCellMeta.set(key, {
3866
+ ...(existingRow ?? {}),
3867
+ [fieldName]: {
3868
+ ...(existingCell ?? {}),
3869
+ ...(record.reads !== undefined ? { reads: record.reads } : {}),
3870
+ ...(record.decide !== undefined ? { decide: record.decide } : {}),
3871
+ },
3872
+ });
3873
+ }
3874
+
3875
+ /** Row-grain read order for the running map's row, if one was recorded. */
3876
+ private resolveRowReadOrder(
3877
+ key: string | null,
3878
+ ): PlayRowReadOrder | undefined {
3879
+ const rowMeta = this.activeCellMetaRecord(key, ROW_META_CELL_KEY);
3880
+ const reads = rowMeta?.reads;
3881
+ return reads && typeof reads === 'object' && !Array.isArray(reads)
3882
+ ? (reads as PlayRowReadOrder)
3883
+ : undefined;
3884
+ }
3885
+
3886
+ /**
3887
+ * Open one cell against the row's read order (ADR 0019).
3888
+ *
3889
+ * The order lives once per row under the reserved `_row` key instead of once
3890
+ * per cell. A written column is appended before its own cell runs, so its
3891
+ * position in the list *is* its cut point — everything before it was
3892
+ * available to it, itself and everything after it was not. That makes the
3893
+ * common case a single string append with no per-cell record at all. Cells
3894
+ * the list cannot place (non-persisted fields, and anything past the column
3895
+ * cap) fall back to an explicit cut, and cells past the cell cap are counted
3896
+ * as dropped rather than silently reading as having read nothing.
3897
+ */
3898
+ private recordRowReadCell(
3899
+ key: string | null,
3900
+ fieldName: string,
3901
+ persisted: boolean,
3902
+ ): void {
3903
+ if (!key || !this.activeMapCellMeta) return;
3904
+ const order = this.resolveRowReadOrder(key);
3905
+ if (!order || order.columns.length === 0) return;
3906
+ if (cellReadCut(order, fieldName) !== null) return;
3907
+ if (persisted && order.columns.length < MAX_CELL_READ_REFS) {
3908
+ this.mergeRowMeta(key, {
3909
+ reads: { ...order, columns: [...order.columns, fieldName] },
3910
+ });
3911
+ return;
3912
+ }
3913
+ const upto = order.upto ?? {};
3914
+ if (Object.keys(upto).length >= MAX_ROW_READ_CELLS) {
3915
+ this.mergeRowMeta(key, {
3916
+ reads: { ...order, droppedCells: (order.droppedCells ?? 0) + 1 },
3917
+ });
3918
+ return;
3919
+ }
3920
+ this.mergeRowMeta(key, {
3921
+ reads: { ...order, upto: { ...upto, [fieldName]: order.columns.length } },
3922
+ });
3923
+ }
3924
+
3925
+ /** Seed the row's read order with the columns the row arrived carrying. */
3926
+ private recordRowReadColumns(
3927
+ key: string | null,
3928
+ columns: readonly string[],
3929
+ ): void {
3930
+ if (!key || !this.activeMapCellMeta || columns.length === 0) return;
3931
+ const order = this.resolveRowReadOrder(key);
3932
+ const merged = order ? [...order.columns] : [];
3933
+ for (const column of columns) {
3934
+ if (merged.length >= MAX_CELL_READ_REFS) break;
3935
+ if (merged.includes(column)) continue;
3936
+ merged.push(column);
3937
+ }
3938
+ if (order && merged.length === order.columns.length) return;
3939
+ this.mergeRowMeta(key, {
3940
+ reads: { ...(order ?? {}), columns: merged },
3941
+ });
3942
+ }
3943
+
3944
+ /** Merge a row-grain record under the reserved `_row` cell-meta key. */
3945
+ private mergeRowMeta(key: string, record: PlayRowMeta): void {
3946
+ if (!this.activeMapCellMeta) return;
3947
+ const existingRow = this.activeMapCellMeta.get(key);
3948
+ const existingMeta = this.activeCellMetaRecord(key, ROW_META_CELL_KEY);
3949
+ this.activeMapCellMeta.set(key, {
3950
+ ...(existingRow ?? {}),
3951
+ [ROW_META_CELL_KEY]: { ...(existingMeta ?? {}), ...record },
3952
+ });
3953
+ }
3954
+
3955
+ /**
3956
+ * Explicit per-cell read array, re-stated so a later patch cannot erase it.
3957
+ *
3958
+ * Only nested step-program cells carry one: their read-set includes sibling
3959
+ * steps, which are not part of the row's column order, so the row-grain cut
3960
+ * point cannot express it. Dataset columns resolve through the row order.
3961
+ */
3962
+ private resolveCellReads(
3963
+ key: string | null,
3964
+ fieldName: string,
3965
+ ): PlayCellReadRef[] | undefined {
3966
+ const reads = this.activeCellMetaRecord(key, fieldName)?.reads;
3967
+ return Array.isArray(reads) ? (reads as PlayCellReadRef[]) : undefined;
3968
+ }
3969
+
3970
+ /** The read-set a cell saw, from its explicit array or the row order. */
3971
+ private effectiveCellReads(
3972
+ key: string | null,
3973
+ fieldName: string,
3974
+ ): PlayCellReadRef[] {
3975
+ return (
3976
+ this.resolveCellReads(key, fieldName) ??
3977
+ hydrateCellReadRefs(this.resolveRowReadOrder(key), fieldName, '')?.map(
3978
+ (ref) => ({ column: ref.column }),
3979
+ ) ??
3980
+ []
3981
+ );
3982
+ }
3983
+
3984
+ /** Last recorded branch decision for a cell, re-stated on every patch. */
3985
+ private resolveCellDecision(
3986
+ key: string | null,
3987
+ fieldName: string,
3988
+ ): PlayCellDecision | undefined {
3989
+ const decide = this.activeCellMetaRecord(key, fieldName)?.decide;
3990
+ return decide && typeof decide === 'object' && !Array.isArray(decide)
3991
+ ? (decide as PlayCellDecision)
3992
+ : undefined;
3993
+ }
3994
+
3995
+ /**
3996
+ * Bounded read-set for one cell (ADR 0019).
3997
+ *
3998
+ * The runtime hands a column resolver the whole row, so the read-set it
3999
+ * witnesses is the row's columns at eval time — not a per-property access
4000
+ * trace. Recording the resolved column list as-is is the honest form: it is a
4001
+ * superset of what the resolver touched, and it is an observation rather than
4002
+ * a declaration. Per-property precision needs sandbox proxy tracking, which
4003
+ * is deliberately out of this wave.
4004
+ *
4005
+ * Only enumerable columns are recorded. CSV alias projections are
4006
+ * non-enumerable by construction, so they read as absent rather than as a
4007
+ * column the runtime can name.
4008
+ *
4009
+ * Refs carry no `table` or `rowKey` because every read here is same-row and
4010
+ * same-table; the reader fills both in from the cell's own address. Writing
4011
+ * the row key into each ref put a mapped row over the retained-row memory
4012
+ * budget, and the budget is not the thing that moves.
4013
+ */
4014
+ private cellReadRefsForRow(
4015
+ rowKey: string | null,
4016
+ rowSources: readonly Record<string, unknown>[],
4017
+ targetFieldName: string,
4018
+ /**
4019
+ * Columns this map will write but has not written yet in this run. On a
4020
+ * rerun the row arrives carrying their previous values, which is
4021
+ * `previousCell` — the same fact the self-exclusion below rejects, and
4022
+ * recording it would manufacture a cycle between two columns that each
4023
+ * saw the other's stale value.
4024
+ */
4025
+ pendingOutputColumns?: ReadonlySet<string>,
4026
+ ): PlayCellReadRef[] | undefined {
4027
+ if (!rowKey) return undefined;
4028
+ const refs: PlayCellReadRef[] = [];
4029
+ const seen = new Set<string>();
4030
+ for (const source of rowSources) {
4031
+ for (const column of Object.keys(source)) {
4032
+ if (refs.length >= MAX_CELL_READ_REFS) break;
4033
+ // A cell never reads itself: on a rerun the target column is present in
4034
+ // the row carrying the *previous* run's value, which is a different
4035
+ // fact (`previousCell`), not an input to this computation.
4036
+ if (column === targetFieldName) continue;
4037
+ if (pendingOutputColumns?.has(column)) continue;
4038
+ if (!shouldPersistMapCellField(column)) continue;
4039
+ if (column.startsWith('__deepline')) continue;
4040
+ if (seen.has(column)) continue;
4041
+ seen.add(column);
4042
+ refs.push({ column });
4043
+ }
4044
+ }
4045
+ return refs.length > 0 ? refs : undefined;
4046
+ }
4047
+
4048
+ /**
4049
+ * The row's input columns — everything the first cell of the row could read.
4050
+ * Columns this map writes are excluded even when the row already carries a
4051
+ * value for them, because on a rerun that value is the previous run's.
4052
+ */
4053
+ private rowInputReadColumns(
4054
+ baseRow: Record<string, unknown>,
4055
+ datasetColumns: ReadonlySet<string>,
4056
+ ): string[] {
4057
+ const columns: string[] = [];
4058
+ for (const column of Object.keys(baseRow)) {
4059
+ if (columns.length >= MAX_CELL_READ_REFS) break;
4060
+ if (datasetColumns.has(column)) continue;
4061
+ if (!shouldPersistMapCellField(column)) continue;
4062
+ if (column.startsWith('__deepline')) continue;
4063
+ columns.push(column);
4064
+ }
4065
+ return columns;
4066
+ }
4067
+
3349
4068
  private emitScopedFieldMetaUpdate(input: {
3350
4069
  rowId: number;
3351
4070
  key: string | null;
@@ -3375,6 +4094,12 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
3375
4094
  toolId?: string | null;
3376
4095
  runId?: string | null;
3377
4096
  } | null;
4097
+ /**
4098
+ * A newly started producer attempt for this cell (ADR 0018). The record is
4099
+ * appended to the cell's bounded `producers` trace and kept by reference so
4100
+ * the caller can settle its outcome/duration in place.
4101
+ */
4102
+ producerAttempt?: PlayCellProducerAttempt;
3378
4103
  dataPatch?: Record<string, unknown>;
3379
4104
  }): void {
3380
4105
  if (!input.fieldName) {
@@ -3389,6 +4114,46 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
3389
4114
  return;
3390
4115
  }
3391
4116
 
4117
+ // Every patch for a cell re-states its producer trace. The in-memory cell
4118
+ // patch merge replaces a field wholesale, so carrying the array forward is
4119
+ // what keeps a waterfall's losing legs alive through the cell's completion
4120
+ // patch (which deliberately omits `producer`).
4121
+ //
4122
+ // Everything from here to `rowReads` is the attribution capture the docflow
4123
+ // campaign added (ADR 0018 producers, ADR 0019 reads/decide). Resolved as a
4124
+ // block behind the run's rollout answer so that an ungated run emits the
4125
+ // cell patch main emitted, key for key — including OMITTING `producer` when
4126
+ // the caller passed none, which is the one pre-existing key whose behavior
4127
+ // the re-statement rule changed.
4128
+ const capture = this.docflowCaptureEnabled;
4129
+ const { producers, attemptsDropped } = capture
4130
+ ? this.resolveCellProducers(
4131
+ input.key,
4132
+ input.fieldName,
4133
+ input.producerAttempt,
4134
+ )
4135
+ : { producers: undefined, attemptsDropped: 0 };
4136
+ // The durable jsonb merge preserves keys a patch omits; the in-memory cell
4137
+ // patch merge replaces the field wholesale. Re-state the winning producer
4138
+ // so the cell's terminal write does not erase it (ADR 0018).
4139
+ const producer =
4140
+ input.producer !== undefined
4141
+ ? input.producer
4142
+ : capture
4143
+ ? this.resolveCellProducer(input.key, input.fieldName)
4144
+ : undefined;
4145
+ // Same re-statement discipline for the provenance capture (ADR 0019): both
4146
+ // were seeded before the cell resolved, and the wholesale field replace
4147
+ // would otherwise drop them on the cell's terminal patch.
4148
+ const reads = capture
4149
+ ? this.resolveCellReads(input.key, input.fieldName)
4150
+ : undefined;
4151
+ const decide = capture
4152
+ ? this.resolveCellDecision(input.key, input.fieldName)
4153
+ : undefined;
4154
+ // The row's read order is row-grain, so it rides the patch once under the
4155
+ // reserved key rather than being copied onto every cell.
4156
+ const rowReads = capture ? this.resolveRowReadOrder(input.key) : undefined;
3392
4157
  this.emitScopedRowUpdate(input.key, input.tableNamespace, {
3393
4158
  rowId: input.rowId,
3394
4159
  status: input.rowStatus,
@@ -3396,13 +4161,27 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
3396
4161
  provider: input.provider ?? null,
3397
4162
  error: input.error ?? null,
3398
4163
  dataPatch: input.dataPatch ?? {},
4164
+ ...(rowReads ? { rowMetaPatch: { reads: rowReads } } : {}),
3399
4165
  cellMetaPatch: {
3400
4166
  [input.fieldName]: {
3401
4167
  status: input.status,
3402
4168
  stage: input.stage ?? null,
3403
4169
  provider: input.provider ?? null,
3404
4170
  error: input.error ?? null,
3405
- ...(input.producer !== undefined ? { producer: input.producer } : {}),
4171
+ ...(producer !== undefined ? { producer } : {}),
4172
+ // Only attribution-bearing cells carry the run stamp and the trace.
4173
+ // Both ride the retained-row payload, so paying for them on every
4174
+ // status-only cell patch would move the map memory budget.
4175
+ ...(producers !== undefined
4176
+ ? {
4177
+ runId: this.currentRunId,
4178
+ producers,
4179
+ // Loud under-count: a capped trace states what it lost.
4180
+ ...(attemptsDropped > 0 ? { attemptsDropped } : {}),
4181
+ }
4182
+ : {}),
4183
+ ...(reads !== undefined ? { reads } : {}),
4184
+ ...(decide !== undefined ? { decide } : {}),
3406
4185
  ...(input.reused !== undefined ? { reused: input.reused } : {}),
3407
4186
  ...(input.completedAt !== undefined
3408
4187
  ? { completedAt: input.completedAt }
@@ -3416,53 +4195,6 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
3416
4195
  });
3417
4196
  }
3418
4197
 
3419
- private emitCellUpdate(input: {
3420
- rowId: number;
3421
- key: string | null;
3422
- tableNamespace: string | null;
3423
- columnName: string;
3424
- status:
3425
- | 'queued'
3426
- | 'running'
3427
- | 'completed'
3428
- | 'failed'
3429
- | 'cached'
3430
- | 'missed'
3431
- | 'skipped';
3432
- rowStatus?: PlayRowUpdate['status'];
3433
- stage?: string | null;
3434
- provider?: string | null;
3435
- error?: string | null;
3436
- producer?: {
3437
- kind: 'play' | 'tool' | 'code';
3438
- id?: string | null;
3439
- displayName?: string | null;
3440
- playId?: string | null;
3441
- toolId?: string | null;
3442
- runId?: string | null;
3443
- } | null;
3444
- value?: unknown;
3445
- }): void {
3446
- this.emitScopedRowUpdate(input.key, input.tableNamespace, {
3447
- rowId: input.rowId,
3448
- status: input.rowStatus,
3449
- stage: input.stage ?? null,
3450
- provider: input.provider ?? null,
3451
- error: input.error ?? null,
3452
- dataPatch:
3453
- input.value === undefined ? {} : { [input.columnName]: input.value },
3454
- cellMetaPatch: {
3455
- [input.columnName]: {
3456
- status: input.status,
3457
- stage: input.stage ?? null,
3458
- provider: input.provider ?? null,
3459
- error: input.error ?? null,
3460
- ...(input.producer !== undefined ? { producer: input.producer } : {}),
3461
- },
3462
- },
3463
- });
3464
- }
3465
-
3466
4198
  private isCompletedFieldValue(value: unknown): boolean {
3467
4199
  return (
3468
4200
  value !== null &&
@@ -4327,6 +5059,10 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
4327
5059
  options.onRowError,
4328
5060
  );
4329
5061
  validateOptionalPlayAuthoringField('ctx.dataset.run.mode', options.mode);
5062
+ validateOptionalPlayAuthoringField(
5063
+ 'ctx.dataset.run.undrawnColumns',
5064
+ options.undrawnColumns,
5065
+ );
4330
5066
  if (options.key !== undefined) {
4331
5067
  validatePlayAuthoringField('ctx.dataset.run.key', options.key);
4332
5068
  }
@@ -5732,6 +6468,8 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
5732
6468
  ): Promise<FieldMapRunResult> {
5733
6469
  const fieldEntries = Object.entries(definition);
5734
6470
  const datasetColumnNames = fieldEntries.map(([fieldName]) => fieldName);
6471
+ /** Columns this map writes; excluded from a row's input read order. */
6472
+ const datasetColumnSet = new Set(datasetColumnNames);
5735
6473
  const visibleFields = fieldEntries
5736
6474
  .map(([fieldName]) => fieldName)
5737
6475
  .filter((fieldName) => shouldPersistMapCellField(fieldName));
@@ -6046,6 +6784,19 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6046
6784
  const rowIndex = executionRowIndex(idx);
6047
6785
  const computedFields: Record<string, unknown> = {};
6048
6786
  let activeFieldName: string | null = null;
6787
+ // ADR 0019: the row's read order, computed once. Every cell reads a
6788
+ // prefix of it — the inputs, then each column this run wrote — so this
6789
+ // loop appends rather than rebuilding a per-cell set (which was
6790
+ // O(columns squared) in both time and allocations).
6791
+ if (this.docflowCaptureEnabled) {
6792
+ this.recordRowReadColumns(
6793
+ rowKey,
6794
+ this.rowInputReadColumns(baseRow, datasetColumnSet),
6795
+ );
6796
+ }
6797
+ if (this.docflowCaptureEnabled) {
6798
+ this.seedActiveCellProducersFromDurableRow(rowKey, baseRow);
6799
+ }
6049
6800
 
6050
6801
  // Global row slot keeps concurrent maps in the same run under rowMax. The
6051
6802
  // worker pool below enforces this map's requested/default concurrency.
@@ -6055,6 +6806,13 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6055
6806
  try {
6056
6807
  for (const [fieldName, resolver] of fieldEntries) {
6057
6808
  activeFieldName = fieldName;
6809
+ // Recorded before the first patch for the cell, so every later patch
6810
+ // (tool legs, completion) re-states the row order for free.
6811
+ this.recordRowReadCell(
6812
+ rowKey,
6813
+ fieldName,
6814
+ shouldPersistMapCellField(fieldName),
6815
+ );
6058
6816
  this.emitScopedFieldMetaUpdate({
6059
6817
  rowId: idx,
6060
6818
  key: rowKey,
@@ -6495,8 +7253,27 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6495
7253
  ): Promise<unknown> {
6496
7254
  let currentRow = cloneCsvAliasedRow(row);
6497
7255
  const produced: Record<string, unknown> = {};
7256
+ // Steps this program has not run yet. Hoisted per program, not rebuilt per
7257
+ // step, and shrunk as each step produces.
7258
+ const pendingStepNames = new Set(program.steps.map((step) => step.name));
6498
7259
  for (const step of program.steps) {
6499
7260
  const stepPath = [...path, step.name];
7261
+ // ADR 0019: a step's read-set is the row as it stands *now*, which
7262
+ // includes every earlier step's output. Sibling steps are not part of the
7263
+ // row's column order, so a step cell keeps an explicit array where a
7264
+ // dataset column resolves through the row-grain cut point. Recorded
7265
+ // before the step runs so the step's own cell patches re-state it.
7266
+ const stepRowStore = rowContext.getStore();
7267
+ if (this.docflowCaptureEnabled && stepRowStore?.rowKey) {
7268
+ this.recordCellProvenance(stepRowStore.rowKey, stepPath.join('.'), {
7269
+ reads: this.cellReadRefsForRow(
7270
+ stepRowStore.rowKey,
7271
+ [currentRow],
7272
+ stepPath.join('.'),
7273
+ pendingStepNames,
7274
+ ),
7275
+ });
7276
+ }
6500
7277
  const runStep = async () =>
6501
7278
  options?.checkpointSteps
6502
7279
  ? await this.step(
@@ -6554,10 +7331,12 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6554
7331
  });
6555
7332
  }
6556
7333
  produced[step.name] = value;
7334
+ pendingStepNames.delete(step.name);
6557
7335
  currentRow = cloneCsvAliasedRow(currentRow, { [step.name]: value });
6558
7336
  continue;
6559
7337
  }
6560
7338
  produced[step.name] = value;
7339
+ pendingStepNames.delete(step.name);
6561
7340
  const rowStore = rowContext.getStore();
6562
7341
  const fieldName = stepPath.join('.');
6563
7342
  const patchFieldName = runtimeSheetPatchFieldName(fieldName);
@@ -6615,6 +7394,22 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6615
7394
 
6616
7395
  if (isRuntimeConditionalStepResolver(resolver)) {
6617
7396
  const shouldRun = await resolver.when(currentRow, index);
7397
+ // ADR 0019: the per-row branch this evaluator selected, written onto the
7398
+ // cell the decision governs. `run`/`else` is the runtime's own vocabulary
7399
+ // for a `runIf` conditional — the only per-row branch construct the
7400
+ // runtime evaluates. A branch inside authored code is invisible here and
7401
+ // degrades to the transform's read-set, which is the honest floor.
7402
+ if (this.docflowCaptureEnabled && store?.rowKey) {
7403
+ // The evaluator was handed the same row the resolver would get, so the
7404
+ // decision's read-set is the cell's read-set. Reusing the seeded trace
7405
+ // keeps the two consistent and costs no second pass over the row.
7406
+ this.recordCellProvenance(store.rowKey, nestedFieldName, {
7407
+ decide: {
7408
+ branch: shouldRun ? 'run' : 'else',
7409
+ reads: this.effectiveCellReads(store.rowKey, nestedFieldName),
7410
+ },
7411
+ });
7412
+ }
6618
7413
  if (!shouldRun) {
6619
7414
  const elseValue = Object.prototype.hasOwnProperty.call(
6620
7415
  resolver,
@@ -6680,6 +7475,9 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6680
7475
  rowIdentity: (row: Record<string, unknown>, index: number) => string,
6681
7476
  ): Promise<Array<Record<string, unknown>>> {
6682
7477
  const results: Array<Record<string, unknown>> = [];
7478
+ const pureDatasetColumnSet = new Set(
7479
+ fieldEntries.map(([fieldName]) => fieldName),
7480
+ );
6683
7481
  this.pureMapExecutionActive = true;
6684
7482
 
6685
7483
  try {
@@ -6688,6 +7486,10 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6688
7486
  const baseRow = this.toOutputRow(item);
6689
7487
  const computedFields: Record<string, unknown> = {};
6690
7488
  let activeFieldName: string | null = null;
7489
+ // The pure path emits one consolidated patch per row, so the read
7490
+ // trace is stamped straight onto that patch (ADR 0019) instead of
7491
+ // riding the seed-and-re-state path the row loop uses.
7492
+ const pureRowKey = rowIdentity(baseRow, index);
6691
7493
 
6692
7494
  // Pure rows complete in microseconds, so interim per-field updates
6693
7495
  // carry no live value. Accumulate every field's data/meta patch and
@@ -6698,9 +7500,39 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6698
7500
  const rowDataPatch: Record<string, unknown> = {};
6699
7501
  const rowCellMetaPatch: NonNullable<PlayRowUpdate['cellMetaPatch']> =
6700
7502
  {};
7503
+ // ADR 0019 row-grain read order, built once per row on the consolidated
7504
+ // patch this path already emits.
7505
+ //
7506
+ // Ungated this stays empty, which is the gate: the per-field branch
7507
+ // below already treats an empty order as "nothing was available to
7508
+ // read" and does no work, and the two `length > 0` guards on the
7509
+ // consolidated patches omit `rowMetaPatch` entirely. This path never
7510
+ // touches `activeMapCellMeta`, so it has to be stopped here or not at
7511
+ // all.
7512
+ const pureReadColumns = this.docflowCaptureEnabled
7513
+ ? this.rowInputReadColumns(baseRow, pureDatasetColumnSet)
7514
+ : [];
7515
+ const pureReadUpto: Record<string, number> = {};
6701
7516
  try {
6702
7517
  for (const [fieldName, resolver] of fieldEntries) {
6703
7518
  activeFieldName = fieldName;
7519
+ // A written column joins the order before its own cell runs, so
7520
+ // its position is its cut; anything the list cannot place states
7521
+ // an explicit one.
7522
+ if (pureReadColumns.length === 0) {
7523
+ // Nothing was available to read.
7524
+ } else if (
7525
+ shouldPersistMapCellField(fieldName) &&
7526
+ pureReadColumns.length < MAX_CELL_READ_REFS &&
7527
+ !pureReadColumns.includes(fieldName)
7528
+ ) {
7529
+ pureReadColumns.push(fieldName);
7530
+ } else if (
7531
+ Object.keys(pureReadUpto).length < MAX_ROW_READ_CELLS &&
7532
+ !pureReadColumns.includes(fieldName)
7533
+ ) {
7534
+ pureReadUpto[fieldName] = pureReadColumns.length;
7535
+ }
6704
7536
  const value = await this.resolveMapFieldValue(
6705
7537
  resolver,
6706
7538
  item,
@@ -6725,20 +7557,30 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6725
7557
  this.toPublicOutputRow(cloneCsvAliasedRow(baseRow, computedFields)),
6726
7558
  );
6727
7559
 
6728
- const rowKey = rowIdentity(baseRow, index);
6729
7560
  activeFieldName = null;
6730
- this.emitScopedRowUpdate(rowKey, tableNamespace, {
7561
+ this.emitScopedRowUpdate(pureRowKey, tableNamespace, {
6731
7562
  rowId: index,
6732
7563
  status: 'completed',
6733
7564
  stage: 'completed',
6734
7565
  provider: null,
6735
7566
  error: null,
6736
7567
  dataPatch: rowDataPatch,
7568
+ ...(pureReadColumns.length > 0
7569
+ ? {
7570
+ rowMetaPatch: {
7571
+ reads: {
7572
+ columns: pureReadColumns,
7573
+ ...(Object.keys(pureReadUpto).length > 0
7574
+ ? { upto: pureReadUpto }
7575
+ : {}),
7576
+ },
7577
+ },
7578
+ }
7579
+ : {}),
6737
7580
  cellMetaPatch: rowCellMetaPatch,
6738
7581
  });
6739
7582
  } catch (error) {
6740
- const rowKey = rowIdentity(baseRow, index);
6741
- this.emitScopedRowUpdate(rowKey, tableNamespace, {
7583
+ this.emitScopedRowUpdate(pureRowKey, tableNamespace, {
6742
7584
  rowId: index,
6743
7585
  status: 'failed',
6744
7586
  stage: 'failed',
@@ -6747,6 +7589,18 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
6747
7589
  // Carry the cells that completed before the failure — previously
6748
7590
  // they had already been emitted as individual field updates.
6749
7591
  dataPatch: rowDataPatch,
7592
+ ...(pureReadColumns.length > 0
7593
+ ? {
7594
+ rowMetaPatch: {
7595
+ reads: {
7596
+ columns: pureReadColumns,
7597
+ ...(Object.keys(pureReadUpto).length > 0
7598
+ ? { upto: pureReadUpto }
7599
+ : {}),
7600
+ },
7601
+ },
7602
+ }
7603
+ : {}),
6750
7604
  cellMetaPatch: {
6751
7605
  ...rowCellMetaPatch,
6752
7606
  [String(activeFieldName ?? '__unknown')]: {
@@ -7239,89 +8093,150 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
7239
8093
  );
7240
8094
  if (cached) {
7241
8095
  this.log(`Calling tool: ${toolId} recovered from checkpoint`);
7242
- return await this.wrapToolExecutionResult({
7243
- toolId,
7244
- status: cached.result.result == null ? 'no_result' : 'completed',
7245
- result: cached.result.result,
7246
- requestInput: input,
7247
- execution: toolExecutionMetadataForOutcome({
7248
- kind: 'checkpoint',
7249
- cacheKey: cached.cacheKey,
7250
- }),
7251
- });
8096
+ // Started and settled are paired in a finally: an uncovered throw
8097
+ // between them strands the tool node `running` forever in the
8098
+ // snapshot, which is exactly the lossy state ADR 0018 removed.
8099
+ this.emitToolCallLifecycle('started', toolId, normalizedKey);
8100
+ let settled = false;
8101
+ try {
8102
+ const recovered = await this.wrapToolExecutionResult({
8103
+ toolId,
8104
+ status: cached.result.result == null ? 'no_result' : 'completed',
8105
+ result: cached.result.result,
8106
+ requestInput: input,
8107
+ execution: toolExecutionMetadataForOutcome({
8108
+ kind: 'checkpoint',
8109
+ cacheKey: cached.cacheKey,
8110
+ }),
8111
+ });
8112
+ settled = true;
8113
+ this.emitToolCallLifecycle('settled', toolId, normalizedKey, {
8114
+ outcome: 'cached',
8115
+ durationMs: 0,
8116
+ });
8117
+ return recovered;
8118
+ } finally {
8119
+ if (!settled) {
8120
+ this.emitToolCallLifecycle('settled', toolId, normalizedKey, {
8121
+ outcome: 'failed',
8122
+ durationMs: 0,
8123
+ });
8124
+ }
8125
+ }
7252
8126
  }
7253
8127
  this.log(
7254
8128
  toolCachePolicy.force
7255
8129
  ? `Calling tool: ${toolId} (force)`
7256
8130
  : `Calling tool: ${toolId}`,
7257
8131
  );
7258
- const directReceiptLeaseId = context?.leaseId?.trim() || null;
7259
- if (!logicalCallId) {
7260
- throw new Error(
7261
- 'Direct tool execution requires a stable logical call identity.',
7262
- );
7263
- }
7264
- const physicalDirectKey = cacheableToolResult
7265
- ? directCacheKey
7266
- : `${buildDurableToolReceiptPrefix({
7267
- orgId: this.#options.orgId ?? 'unknown-org',
7268
- toolId,
7269
- })}${stableDigest(`${this.currentRunId}:${logicalCallId}:always-fresh`)}`;
7270
- const execution = await this.callToolExecutionAPI(toolId, input, {
7271
- durableCallReceiptKey: physicalDirectKey,
7272
- executionAuthScopeDigest,
7273
- providerIdempotencyKey: cacheableToolResult
7274
- ? this.providerIdempotencyKeyForToolCall({
7275
- cacheKey: directCacheKey,
7276
- force: toolCachePolicy.force,
7277
- leaseId: directReceiptLeaseId,
7278
- logicalCallId,
7279
- })
7280
- : physicalDirectKey,
7281
- timeoutMs: resolveToolRuntimeTimeoutMs(
8132
+ // ADR 0018: the tool node's lifecycle is an explicit typed event, not a
8133
+ // regex over this log line.
8134
+ const directCallStartedAt = Date.now();
8135
+ this.emitToolCallLifecycle('started', toolId, normalizedKey);
8136
+ // Settled exactly once, including on paths that throw before the
8137
+ // explicit settle below (result wrapping, receipt-ownership failures,
8138
+ // authoring validation). `executeDirectToolCall` settles its own
8139
+ // transport failure and marks this handled.
8140
+ let directSettled = false;
8141
+ const settleDirectCall = (
8142
+ outcome: 'completed' | 'no_result' | 'failed' | 'cached',
8143
+ error?: unknown,
8144
+ ) => {
8145
+ if (directSettled) return;
8146
+ directSettled = true;
8147
+ this.emitToolCallLifecycle('settled', toolId, normalizedKey, {
8148
+ outcome,
8149
+ durationMs: Date.now() - directCallStartedAt,
8150
+ ...(error !== undefined
8151
+ ? { error: this.formatRuntimeError(error) }
8152
+ : {}),
8153
+ });
8154
+ };
8155
+ try {
8156
+ const directReceiptLeaseId = context?.leaseId?.trim() || null;
8157
+ if (!logicalCallId) {
8158
+ throw new Error(
8159
+ 'Direct tool execution requires a stable logical call identity.',
8160
+ );
8161
+ }
8162
+ const physicalDirectKey = cacheableToolResult
8163
+ ? directCacheKey
8164
+ : `${buildDurableToolReceiptPrefix({
8165
+ orgId: this.#options.orgId ?? 'unknown-org',
8166
+ toolId,
8167
+ })}${stableDigest(`${this.currentRunId}:${logicalCallId}:always-fresh`)}`;
8168
+ const execution = await this.executeDirectToolCall({
7282
8169
  toolId,
7283
- options?.timeoutMs,
7284
- this.currentAuthoringContractEdition,
7285
- ),
7286
- ...(directReceiptLeaseId &&
7287
- (this.#options.heartbeatRuntimeStepReceipts ||
7288
- this.#options.getRuntimeStepReceipt ||
7289
- this.#options.getRuntimeStepReceipts)
7290
- ? {
7291
- beforeProviderCall: async () => {
7292
- await this.assertRuntimeToolReceiptOwnership([
7293
- {
7294
- callId: 'direct',
7295
- cacheKey: directCacheKey,
7296
- receiptKey: directCacheKey,
7297
- receiptLeaseId: directReceiptLeaseId,
7298
- rowId: -1,
7299
- toolId,
7300
- input,
8170
+ callKey: normalizedKey,
8171
+ startedAt: directCallStartedAt,
8172
+ input,
8173
+ options: {
8174
+ durableCallReceiptKey: physicalDirectKey,
8175
+ playNodeScope: buildPlayNodeScope({
8176
+ toolId,
8177
+ callKey: normalizedKey,
8178
+ }),
8179
+ executionAuthScopeDigest,
8180
+ providerIdempotencyKey: cacheableToolResult
8181
+ ? this.providerIdempotencyKeyForToolCall({
8182
+ cacheKey: directCacheKey,
8183
+ force: toolCachePolicy.force,
8184
+ leaseId: directReceiptLeaseId,
8185
+ logicalCallId,
8186
+ })
8187
+ : physicalDirectKey,
8188
+ timeoutMs: resolveToolRuntimeTimeoutMs(
8189
+ toolId,
8190
+ options?.timeoutMs,
8191
+ this.currentAuthoringContractEdition,
8192
+ ),
8193
+ ...(directReceiptLeaseId &&
8194
+ (this.#options.heartbeatRuntimeStepReceipts ||
8195
+ this.#options.getRuntimeStepReceipt ||
8196
+ this.#options.getRuntimeStepReceipts)
8197
+ ? {
8198
+ beforeProviderCall: async () => {
8199
+ await this.assertRuntimeToolReceiptOwnership([
8200
+ {
8201
+ callId: 'direct',
8202
+ cacheKey: directCacheKey,
8203
+ receiptKey: directCacheKey,
8204
+ receiptLeaseId: directReceiptLeaseId,
8205
+ rowId: -1,
8206
+ toolId,
8207
+ input,
8208
+ },
8209
+ ]);
7301
8210
  },
7302
- ]);
7303
- },
7304
- }
7305
- : {}),
7306
- });
7307
- const wrapped = await this.wrapToolExecutionResult({
7308
- toolId,
7309
- status: execution.status,
7310
- jobId: execution.jobId,
7311
- result: execution.result,
7312
- metadata: execution.metadata,
7313
- meta: execution.meta,
7314
- requestInput: input,
7315
- execution: toolExecutionMetadataForOutcome({
7316
- kind: 'live',
7317
- cacheKey: directCacheKey,
7318
- }),
7319
- });
7320
- if (cacheableToolResult) {
7321
- this.cacheToolResult(toolId, directCacheKey, wrapped, 'direct');
7322
- this.#options.onBatchComplete?.(this.checkpoint);
8211
+ }
8212
+ : {}),
8213
+ },
8214
+ });
8215
+ const wrapped = await this.wrapToolExecutionResult({
8216
+ toolId,
8217
+ status: execution.status,
8218
+ jobId: execution.jobId,
8219
+ result: execution.result,
8220
+ metadata: execution.metadata,
8221
+ meta: execution.meta,
8222
+ requestInput: input,
8223
+ execution: toolExecutionMetadataForOutcome({
8224
+ kind: 'live',
8225
+ cacheKey: directCacheKey,
8226
+ }),
8227
+ });
8228
+ settleDirectCall(toolAttemptOutcomeForResult(wrapped));
8229
+ if (cacheableToolResult) {
8230
+ this.cacheToolResult(toolId, directCacheKey, wrapped, 'direct');
8231
+ this.#options.onBatchComplete?.(this.checkpoint);
8232
+ }
8233
+ return wrapped;
8234
+ } catch (error) {
8235
+ settleDirectCall('failed', error);
8236
+ throw error;
8237
+ } finally {
8238
+ settleDirectCall('failed');
7323
8239
  }
7324
- return wrapped;
7325
8240
  }
7326
8241
 
7327
8242
  const rowId = store.rowId;
@@ -7361,7 +8276,27 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
7361
8276
  });
7362
8277
  }
7363
8278
 
7364
- return new Promise((resolve, reject) => {
8279
+ // ADR 0018: record this leg before it runs. `producer` still names the
8280
+ // last leg (the usual cascade winner); `producers` keeps every leg, which
8281
+ // is the only durable record a losing leg ever gets — a failed tool call
8282
+ // writes no receipt at all.
8283
+ const producerAttemptStartedAt = Date.now();
8284
+ // Deliberately minimal: `displayName` is derivable from `toolId`, and
8285
+ // this record is retained per row until the map's persistence barrier.
8286
+ const producerAttempt: PlayCellProducerAttempt = {
8287
+ kind: 'tool',
8288
+ id: normalizedKey,
8289
+ toolId,
8290
+ at: producerAttemptStartedAt,
8291
+ outcome: 'running',
8292
+ };
8293
+ const settleProducerAttempt = (
8294
+ outcome: PlayCellProducerAttempt['outcome'],
8295
+ ) => {
8296
+ producerAttempt.outcome = outcome;
8297
+ producerAttempt.durationMs = Date.now() - producerAttemptStartedAt;
8298
+ };
8299
+ const pendingToolCall = new Promise((resolve, reject) => {
7365
8300
  this.toolCallResolvers.set(callId, { resolve, reject });
7366
8301
  this.emitScopedFieldMetaUpdate({
7367
8302
  rowId,
@@ -7379,6 +8314,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
7379
8314
  toolId,
7380
8315
  displayName: displayNameFromProducerId(toolId),
7381
8316
  },
8317
+ producerAttempt,
7382
8318
  dataPatch: {},
7383
8319
  });
7384
8320
  const timeoutMs = resolveToolRuntimeTimeoutMs(
@@ -7396,6 +8332,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
7396
8332
  forceFailedRefresh: toolCachePolicy.forceFailedRefresh,
7397
8333
  rowId,
7398
8334
  fieldName,
8335
+ contextKey: normalizedKey,
7399
8336
  toolId,
7400
8337
  input,
7401
8338
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
@@ -7407,6 +8344,16 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
7407
8344
  description: normalizeStepDescription(options?.description),
7408
8345
  });
7409
8346
  });
8347
+ return await pendingToolCall.then(
8348
+ (value) => {
8349
+ settleProducerAttempt(toolAttemptOutcomeForResult(value));
8350
+ return value;
8351
+ },
8352
+ (error: unknown) => {
8353
+ settleProducerAttempt('failed');
8354
+ throw error;
8355
+ },
8356
+ );
7410
8357
  };
7411
8358
 
7412
8359
  if (store || !cacheableToolResult) {
@@ -8845,6 +9792,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
8845
9792
  beforeProviderCall: () =>
8846
9793
  this.assertRuntimeToolReceiptOwnership([owner]),
8847
9794
  durableCallReceiptKey: receiptKey,
9795
+ playNodeScope: playNodeScopeForToolCallRequest(owner),
8848
9796
  executionAuthScopeDigest: owner.executionAuthScopeDigest,
8849
9797
  receiptLeaseExpiresAt: owner.receiptLeaseExpiresAt,
8850
9798
  heartbeatReceipt: () =>
@@ -9096,6 +10044,15 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9096
10044
  batch.batchPayload,
9097
10045
  {
9098
10046
  durableCallReceiptKey: aggregateReceiptKey,
10047
+ // A native batch is one physical provider request for
10048
+ // many rows of the same column, so the batch's node scope
10049
+ // is its members' shared scope.
10050
+ playNodeScope: batch.memberRequests[0]
10051
+ ? playNodeScopeForToolCallRequest(
10052
+ batch.memberRequests[0],
10053
+ batch.batchOperation,
10054
+ )
10055
+ : null,
9099
10056
  executionAuthScopeDigest:
9100
10057
  batch.memberRequests[0]?.executionAuthScopeDigest ??
9101
10058
  null,
@@ -9284,6 +10241,7 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9284
10241
  {
9285
10242
  beforeProviderCall: () =>
9286
10243
  this.assertRuntimeToolReceiptOwnership([request]),
10244
+ playNodeScope: playNodeScopeForToolCallRequest(request),
9287
10245
  ...(request.receiptKey
9288
10246
  ? {
9289
10247
  durableCallReceiptKey: request.receiptKey,
@@ -9588,6 +10546,11 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9588
10546
  const deeplineRequestId = providerIdempotencyKey
9589
10547
  ? `ctx-tool-${stableDigest(providerIdempotencyKey).slice(0, 32)}`
9590
10548
  : `ctx-tool-${crypto.randomUUID()}`;
10549
+ // Every runtime provider call carries at least a tool-scoped
10550
+ // attribution. Callers that know the execution location (row-scoped
10551
+ // map cells, batched owners) supply the fuller scope.
10552
+ const playNodeScope =
10553
+ options?.playNodeScope ?? buildPlayNodeScope({ toolId });
9591
10554
  const serializedRequestBody = (invocationAttempt: number) =>
9592
10555
  JSON.stringify({
9593
10556
  payload: toolInputSnapshot,
@@ -9631,6 +10594,9 @@ export class PlayContextImpl implements ScalarPlayAuthoringRuntimeContext {
9631
10594
  ...(providerIdempotencyKey
9632
10595
  ? { provider_idempotency_key: providerIdempotencyKey }
9633
10596
  : {}),
10597
+ ...(playNodeScope
10598
+ ? { play_node_scope: playNodeScopeToWire(playNodeScope) }
10599
+ : {}),
9634
10600
  },
9635
10601
  ...(this.#options.integrationMode
9636
10602
  ? { integration_mode: this.#options.integrationMode }