@tiangong-lca/cli 0.0.29 → 0.0.30

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.
@@ -1,3 +1,4 @@
1
+ import { closeSync, chmodSync, fsyncSync, mkdirSync, openSync, writeFileSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import * as tidasSdk from '@tiangong-lca/tidas-sdk';
3
4
  import { writeJsonArtifact, writeJsonLinesArtifact } from './artifacts.js';
@@ -11,6 +12,8 @@ import { buildDatasetCommandTransport } from './dataset-command.js';
11
12
  import { createSupabaseDataClient, requireSupabaseRestRuntime, runSupabaseArrayQuery, } from './supabase-client.js';
12
13
  import { createSupabaseDataRuntime } from './supabase-session.js';
13
14
  import { collectRemoteReferences, lookupRemoteDataset, } from './dataset-remote-verify.js';
15
+ import { readJsonFile, readJsonLinesIfPresent, sha256Json, stableJsonText, } from './dataset-maintenance-contract.js';
16
+ import { resolveFlowIdentityApprovalClaimRoot } from './dataset-maintenance-flow-identity-approval-claim.js';
14
17
  const DEFAULT_TIMEOUT_MS = 10_000;
15
18
  function normalizeValidationIssue(issue) {
16
19
  return {
@@ -67,6 +70,279 @@ const REFERENCE_ONLY_SAVE_DRAFT_TYPES = new Set([
67
70
  'unitgroup',
68
71
  'flowproperty',
69
72
  ]);
73
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
74
+ function executionContractError(message) {
75
+ throw new CliError(message, {
76
+ code: 'DATASET_SAVE_DRAFT_EXECUTION_CONTRACT_INVALID',
77
+ exitCode: 2,
78
+ });
79
+ }
80
+ function requireExecutionToken(value, label) {
81
+ const normalized = trimToken(value);
82
+ return normalized ?? executionContractError(`${label} must be a non-empty string.`);
83
+ }
84
+ function requireExecutionSha(value, label) {
85
+ const normalized = requireExecutionToken(value, label);
86
+ return SHA256_PATTERN.test(normalized)
87
+ ? normalized
88
+ : executionContractError(`${label} must be a lowercase SHA-256 digest.`);
89
+ }
90
+ function parseExecutionContract(value) {
91
+ if (!isRecord(value)) {
92
+ executionContractError('Execution contract must be a JSON object.');
93
+ }
94
+ if (value.schema_version !== 'dataset-save-draft-execution-contract.v1' ||
95
+ value.target_mode !== 'owner_draft' ||
96
+ !isRecord(value.owner) ||
97
+ value.owner.state_code !== 0 ||
98
+ !Array.isArray(value.actions) ||
99
+ value.actions.length === 0) {
100
+ executionContractError('Execution contract header is invalid.');
101
+ }
102
+ const executionId = requireExecutionToken(value.execution_id, 'execution_id');
103
+ const projectRef = requireExecutionToken(value.project_ref, 'project_ref');
104
+ const ownerUserId = requireExecutionToken(value.owner.user_id, 'owner.user_id');
105
+ const ownerEmail = requireExecutionToken(value.owner.email, 'owner.email').toLowerCase();
106
+ const actions = [];
107
+ const seen = new Set();
108
+ for (const [index, rawAction] of value.actions.entries()) {
109
+ if (!isRecord(rawAction) || !Array.isArray(rawAction.dependency_action_ids)) {
110
+ executionContractError(`actions[${index}] is invalid.`);
111
+ }
112
+ const actionId = requireExecutionToken(rawAction.action_id, `actions[${index}].action_id`);
113
+ if (seen.has(actionId)) {
114
+ executionContractError(`Duplicate action_id: ${actionId}`);
115
+ }
116
+ const table = requireExecutionToken(rawAction.table, `actions[${index}].table`);
117
+ if (!Object.values(DATASET_CONFIGS).some((config) => config.table === table)) {
118
+ executionContractError(`actions[${index}].table is unsupported.`);
119
+ }
120
+ const expectedOperation = rawAction.expected_operation;
121
+ if (expectedOperation !== 'insert' && expectedOperation !== 'save_draft') {
122
+ executionContractError(`actions[${index}].expected_operation is invalid.`);
123
+ }
124
+ const beforeSha = rawAction.before_sha256 === null
125
+ ? null
126
+ : requireExecutionSha(rawAction.before_sha256, `actions[${index}].before_sha256`);
127
+ if ((expectedOperation === 'insert' && beforeSha !== null) ||
128
+ (expectedOperation === 'save_draft' && beforeSha === null)) {
129
+ executionContractError(`actions[${index}].before_sha256 contradicts expected_operation.`);
130
+ }
131
+ const dependencies = rawAction.dependency_action_ids.map((dependency, dependencyIndex) => requireExecutionToken(dependency, `actions[${index}].dependency_action_ids[${dependencyIndex}]`));
132
+ if (new Set(dependencies).size !== dependencies.length ||
133
+ dependencies.some((id) => !seen.has(id))) {
134
+ executionContractError(`actions[${index}].dependency_action_ids must be unique earlier actions.`);
135
+ }
136
+ actions.push({
137
+ action_id: actionId,
138
+ desired_sha256: requireExecutionSha(rawAction.desired_sha256, `actions[${index}].desired_sha256`),
139
+ expected_operation: expectedOperation,
140
+ table: table,
141
+ id: requireExecutionToken(rawAction.id, `actions[${index}].id`),
142
+ version: requireExecutionToken(rawAction.version, `actions[${index}].version`),
143
+ before_sha256: beforeSha,
144
+ dependency_action_ids: dependencies,
145
+ });
146
+ seen.add(actionId);
147
+ }
148
+ return {
149
+ schema_version: 'dataset-save-draft-execution-contract.v1',
150
+ execution_id: executionId,
151
+ project_ref: projectRef,
152
+ target_mode: 'owner_draft',
153
+ owner: { user_id: ownerUserId, email: ownerEmail, state_code: 0 },
154
+ actions,
155
+ };
156
+ }
157
+ function bindExecutionContractRows(contract, rows) {
158
+ if (contract.actions.length !== rows.length) {
159
+ executionContractError('Execution contract action count does not match selected rows.');
160
+ }
161
+ contract.actions.forEach((action, index) => {
162
+ const row = rows[index];
163
+ const payloadIdentity = row?.config
164
+ ? extractIdentity(row.payload, {}, row.config)
165
+ : { id: null, version: null };
166
+ if (!row ||
167
+ row.config?.table !== action.table ||
168
+ row.id !== action.id ||
169
+ row.version !== action.version ||
170
+ payloadIdentity.id !== action.id ||
171
+ payloadIdentity.version !== action.version ||
172
+ sha256Json(row.payload) !== action.desired_sha256) {
173
+ executionContractError(`Execution contract action ${action.action_id} does not bind row ${index}.`);
174
+ }
175
+ });
176
+ }
177
+ function executionActionBindingSha256(action) {
178
+ return sha256Json({
179
+ schema_version: 'dataset-save-draft-action-binding.v1',
180
+ action_id: action.action_id,
181
+ desired_sha256: action.desired_sha256,
182
+ expected_operation: action.expected_operation,
183
+ table: action.table,
184
+ id: action.id,
185
+ version: action.version,
186
+ before_sha256: action.before_sha256,
187
+ });
188
+ }
189
+ function executionLedgerRoot(env, contract) {
190
+ const ownerScopeSha256 = sha256Json({
191
+ schema_version: 'dataset-save-draft-owner-scope.v1',
192
+ project_ref: contract.project_ref,
193
+ owner: contract.owner,
194
+ });
195
+ return path.join(resolveFlowIdentityApprovalClaimRoot({ env }), 'execution-ledgers', 'dataset-save-draft', 'v1', ownerScopeSha256);
196
+ }
197
+ function executionLedgerPath(ledgerRoot, action) {
198
+ const actionIdentitySha256 = sha256Json({
199
+ schema_version: 'dataset-save-draft-action-identity.v1',
200
+ action_id: action.action_id,
201
+ desired_sha256: action.desired_sha256,
202
+ });
203
+ return path.join(path.resolve(ledgerRoot), `${actionIdentitySha256}.events.jsonl`);
204
+ }
205
+ function eventWithoutSha(event) {
206
+ const core = { ...event };
207
+ delete core.event_sha256;
208
+ return core;
209
+ }
210
+ function parseLedgerEvent(value, index) {
211
+ if (!isRecord(value)) {
212
+ executionContractError(`Execution ledger event ${index} is not an object.`);
213
+ }
214
+ const event = value;
215
+ if (event.schema_version !== 'dataset-save-draft-execution-event.v1' ||
216
+ event.sequence !== index + 1 ||
217
+ !SHA256_PATTERN.test(event.contract_sha256) ||
218
+ !trimToken(event.action_id) ||
219
+ !SHA256_PATTERN.test(event.desired_sha256) ||
220
+ !SHA256_PATTERN.test(event.action_binding_sha256) ||
221
+ !['attempt_emitted', 'outcome'].includes(event.event_type) ||
222
+ !['insert', 'save_draft'].includes(event.operation) ||
223
+ !['executed', 'unknown', null].includes(event.outcome) ||
224
+ typeof event.recovered !== 'boolean' ||
225
+ !trimToken(event.recorded_at_utc) ||
226
+ !(event.previous_event_sha256 === null || SHA256_PATTERN.test(event.previous_event_sha256)) ||
227
+ !SHA256_PATTERN.test(event.event_sha256)) {
228
+ executionContractError(`Execution ledger event ${index} has an invalid shape.`);
229
+ }
230
+ if ((event.event_type === 'attempt_emitted' && event.outcome !== null) ||
231
+ (event.event_type === 'outcome' && event.outcome === null)) {
232
+ executionContractError(`Execution ledger event ${index} has an invalid outcome.`);
233
+ }
234
+ return event;
235
+ }
236
+ function loadExecutionLedger(ledgerRoot, contract) {
237
+ const events = new Map();
238
+ const attempts = new Map();
239
+ const outcomes = new Map();
240
+ for (const action of contract.actions) {
241
+ const actionEvents = [];
242
+ const rawEvents = readJsonLinesIfPresent(executionLedgerPath(ledgerRoot, action));
243
+ for (const [index, value] of rawEvents.entries()) {
244
+ const event = parseLedgerEvent(value, index);
245
+ const previous = actionEvents.at(-1) ?? null;
246
+ if (event.action_id !== action.action_id ||
247
+ event.desired_sha256 !== action.desired_sha256 ||
248
+ event.action_binding_sha256 !== executionActionBindingSha256(action) ||
249
+ event.operation !== action.expected_operation ||
250
+ event.previous_event_sha256 !== (previous?.event_sha256 ?? null) ||
251
+ event.event_sha256 !== sha256Json(eventWithoutSha(event))) {
252
+ executionContractError(`Execution ledger event ${index} failed its hash or action binding for ${action.action_id}.`);
253
+ }
254
+ if (event.event_type === 'attempt_emitted') {
255
+ if (attempts.has(event.action_id) || outcomes.has(event.action_id)) {
256
+ executionContractError(`Execution ledger repeats attempt for ${event.action_id}.`);
257
+ }
258
+ attempts.set(event.action_id, event);
259
+ }
260
+ else {
261
+ if (!attempts.has(event.action_id) || outcomes.has(event.action_id)) {
262
+ executionContractError(`Execution ledger outcome ordering is invalid for ${event.action_id}.`);
263
+ }
264
+ outcomes.set(event.action_id, event);
265
+ }
266
+ actionEvents.push(event);
267
+ }
268
+ events.set(action.action_id, actionEvents);
269
+ }
270
+ return { events, attempts, outcomes };
271
+ }
272
+ function appendExecutionEvent(options) {
273
+ const actionEvents = options.ledger.events.get(options.action.action_id);
274
+ const core = {
275
+ schema_version: 'dataset-save-draft-execution-event.v1',
276
+ sequence: actionEvents.length + 1,
277
+ contract_sha256: options.contractSha256,
278
+ action_id: options.action.action_id,
279
+ desired_sha256: options.action.desired_sha256,
280
+ action_binding_sha256: executionActionBindingSha256(options.action),
281
+ event_type: options.eventType,
282
+ operation: options.action.expected_operation,
283
+ outcome: options.outcome,
284
+ recovered: options.recovered,
285
+ recorded_at_utc: options.recordedAtUtc,
286
+ previous_event_sha256: actionEvents.at(-1)?.event_sha256 ?? null,
287
+ };
288
+ const event = { ...core, event_sha256: sha256Json(core) };
289
+ const ledgerPath = executionLedgerPath(options.ledgerRoot, options.action);
290
+ mkdirSync(path.dirname(ledgerPath), { recursive: true, mode: 0o700 });
291
+ chmodSync(path.dirname(ledgerPath), 0o700);
292
+ if (event.event_type === 'attempt_emitted') {
293
+ const createDescriptor = openSync(ledgerPath, 'wx', 0o600);
294
+ try {
295
+ writeFileSync(createDescriptor, `${stableJsonText(event)}\n`, 'utf8');
296
+ fsyncSync(createDescriptor);
297
+ }
298
+ finally {
299
+ closeSync(createDescriptor);
300
+ }
301
+ }
302
+ else {
303
+ const appendDescriptor = openSync(ledgerPath, 'a', 0o600);
304
+ try {
305
+ writeFileSync(appendDescriptor, `${stableJsonText(event)}\n`, 'utf8');
306
+ fsyncSync(appendDescriptor);
307
+ }
308
+ finally {
309
+ closeSync(appendDescriptor);
310
+ }
311
+ }
312
+ chmodSync(ledgerPath, 0o600);
313
+ actionEvents.push(event);
314
+ options.ledger.events.set(event.action_id, actionEvents);
315
+ if (event.event_type === 'attempt_emitted') {
316
+ options.ledger.attempts.set(event.action_id, event);
317
+ }
318
+ else {
319
+ options.ledger.outcomes.set(event.action_id, event);
320
+ }
321
+ return event;
322
+ }
323
+ function projectRefFromApiBaseUrl(apiBaseUrl) {
324
+ return new URL(apiBaseUrl).hostname.split('.')[0];
325
+ }
326
+ function decodeExecutionActor(accessToken) {
327
+ const payload = accessToken.split('.')[1];
328
+ if (!payload) {
329
+ executionContractError('Owner-session access token is not a JWT.');
330
+ }
331
+ let decoded;
332
+ try {
333
+ decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
334
+ }
335
+ catch {
336
+ executionContractError('Owner-session access token JWT payload is invalid.');
337
+ }
338
+ if (!isRecord(decoded)) {
339
+ executionContractError('Owner-session access token JWT payload is not an object.');
340
+ }
341
+ return {
342
+ user_id: requireExecutionToken(decoded.sub, 'owner-session sub'),
343
+ email: requireExecutionToken(decoded.email, 'owner-session email').toLowerCase(),
344
+ };
345
+ }
70
346
  function isRecord(value) {
71
347
  return typeof value === 'object' && value !== null && !Array.isArray(value);
72
348
  }
@@ -531,6 +807,355 @@ async function exactVisibleRows(options) {
531
807
  .eq('version', options.version), url);
532
808
  return parseVisibleRows(payload, url);
533
809
  }
810
+ function parseExecutionRows(payload, url) {
811
+ const visible = parseVisibleRows(payload, url);
812
+ return visible.map((row, index) => {
813
+ const raw = payload[index];
814
+ const jsonOrdered = isRecord(raw) && isRecord(raw.json_ordered) ? raw.json_ordered : null;
815
+ return { ...row, json_ordered: jsonOrdered };
816
+ });
817
+ }
818
+ async function exactExecutionRows(options) {
819
+ const url = new URL(buildVisibleRowsUrl(options.restBaseUrl, options.table, options.id, options.version));
820
+ url.searchParams.set('select', 'id,version,user_id,state_code,json_ordered');
821
+ const payload = await runSupabaseArrayQuery(options.client
822
+ .from(options.table)
823
+ .select('id,version,user_id,state_code,json_ordered')
824
+ .eq('id', options.id)
825
+ .eq('version', options.version), url.toString());
826
+ return parseExecutionRows(payload, url.toString());
827
+ }
828
+ function exactDesiredReadback(options) {
829
+ const row = options.rows[0];
830
+ return Boolean(options.rows.length === 1 &&
831
+ row &&
832
+ row.id === options.action.id &&
833
+ row.version === options.action.version &&
834
+ row.user_id === options.contract.owner.user_id &&
835
+ row.state_code === 0 &&
836
+ row.json_ordered &&
837
+ sha256Json(row.json_ordered) === options.action.desired_sha256);
838
+ }
839
+ function contractRowReport(options) {
840
+ return {
841
+ index: options.row.index,
842
+ id: options.row.id,
843
+ version: options.row.version,
844
+ type: options.row.type,
845
+ table: options.action.table,
846
+ status: options.status,
847
+ operation: options.operation,
848
+ validation: options.row.validation,
849
+ action_id: options.action.action_id,
850
+ desired_sha256: options.action.desired_sha256,
851
+ attempt_consumed: options.attemptConsumed,
852
+ replayed: false,
853
+ readback: options.readback,
854
+ ...(options.error ? { error: options.error } : {}),
855
+ };
856
+ }
857
+ async function finalizeAttemptedAction(options) {
858
+ const desiredExact = await readbackIsDesiredExact(options);
859
+ appendExecutionEvent({
860
+ ledgerRoot: options.ledgerRoot,
861
+ ledger: options.ledger,
862
+ contractSha256: options.contractSha256,
863
+ action: options.action,
864
+ eventType: 'outcome',
865
+ outcome: desiredExact ? 'executed' : 'unknown',
866
+ recovered: options.recovered,
867
+ recordedAtUtc: options.now(),
868
+ });
869
+ return contractRowReport({
870
+ row: options.row,
871
+ action: options.action,
872
+ status: desiredExact ? 'executed' : 'unknown',
873
+ operation: desiredExact && options.recovered
874
+ ? 'recovered_exact_readback'
875
+ : options.action.expected_operation,
876
+ attemptConsumed: true,
877
+ readback: desiredExact ? 'desired_exact' : 'not_desired',
878
+ ...(desiredExact
879
+ ? {}
880
+ : {
881
+ error: {
882
+ message: 'A prior protected request was emitted without a terminal desired-exact readback; the action is UNKNOWN and will never be replayed.',
883
+ },
884
+ }),
885
+ });
886
+ }
887
+ async function readbackIsDesiredExact(options) {
888
+ try {
889
+ return exactDesiredReadback({
890
+ rows: await exactExecutionRows({
891
+ client: options.client,
892
+ restBaseUrl: options.restBaseUrl,
893
+ table: options.action.table,
894
+ id: options.action.id,
895
+ version: options.action.version,
896
+ }),
897
+ action: options.action,
898
+ contract: options.contract,
899
+ });
900
+ }
901
+ catch {
902
+ return false;
903
+ }
904
+ }
905
+ async function runExecutionContractBatch(options) {
906
+ const contractSha256 = sha256Json(options.contract);
907
+ const ledgerRoot = executionLedgerRoot(options.env, options.contract);
908
+ const ledger = loadExecutionLedger(ledgerRoot, options.contract);
909
+ const actor = decodeExecutionActor(options.commandTransport.accessToken);
910
+ if (projectRefFromApiBaseUrl(options.runtime.apiBaseUrl) !== options.contract.project_ref ||
911
+ actor.user_id !== options.contract.owner.user_id ||
912
+ actor.email !== options.contract.owner.email) {
913
+ executionContractError('Owner session or project does not match the execution contract.');
914
+ }
915
+ options.files.execution_ledger = ledgerRoot;
916
+ const reports = [];
917
+ const statuses = new Map();
918
+ const referenceOnlySupportCache = new Map();
919
+ for (const [index, action] of options.contract.actions.entries()) {
920
+ const row = options.preparedRows[index];
921
+ const preparedFailure = buildPreparedFailure(row, options.allowReferenceOnlySupport);
922
+ if (preparedFailure) {
923
+ const report = {
924
+ ...preparedFailure,
925
+ action_id: action.action_id,
926
+ desired_sha256: action.desired_sha256,
927
+ attempt_consumed: false,
928
+ replayed: false,
929
+ readback: 'not_performed',
930
+ };
931
+ reports.push(report);
932
+ statuses.set(action.action_id, report.status);
933
+ continue;
934
+ }
935
+ const priorOutcome = ledger.outcomes.get(action.action_id);
936
+ if (priorOutcome) {
937
+ const desiredStillExact = priorOutcome.outcome === 'executed' &&
938
+ (await readbackIsDesiredExact({
939
+ client: options.dataClient.client,
940
+ restBaseUrl: options.dataClient.restBaseUrl,
941
+ action,
942
+ contract: options.contract,
943
+ }));
944
+ const status = desiredStillExact ? 'executed' : 'unknown';
945
+ const report = contractRowReport({
946
+ row,
947
+ action,
948
+ status,
949
+ operation: action.expected_operation,
950
+ attemptConsumed: true,
951
+ readback: desiredStillExact ? 'desired_exact' : 'not_desired',
952
+ ...(status === 'unknown'
953
+ ? {
954
+ error: {
955
+ message: priorOutcome.outcome === 'unknown'
956
+ ? 'Terminal UNKNOWN action retained; replay is forbidden.'
957
+ : 'A terminal success no longer has exact desired owner readback; replay is forbidden.',
958
+ },
959
+ }
960
+ : {}),
961
+ });
962
+ reports.push(report);
963
+ statuses.set(action.action_id, status);
964
+ continue;
965
+ }
966
+ if (ledger.attempts.has(action.action_id)) {
967
+ const report = await finalizeAttemptedAction({
968
+ row,
969
+ action,
970
+ contract: options.contract,
971
+ contractSha256,
972
+ ledgerRoot,
973
+ ledger,
974
+ client: options.dataClient.client,
975
+ restBaseUrl: options.dataClient.restBaseUrl,
976
+ now: options.now,
977
+ recovered: true,
978
+ });
979
+ reports.push(report);
980
+ statuses.set(action.action_id, report.status);
981
+ continue;
982
+ }
983
+ const blockingDependencies = action.dependency_action_ids.filter((dependency) => statuses.get(dependency) !== 'executed');
984
+ if (blockingDependencies.length > 0) {
985
+ const report = contractRowReport({
986
+ row,
987
+ action,
988
+ status: 'blocked',
989
+ operation: 'blocked_dependency',
990
+ attemptConsumed: false,
991
+ readback: 'not_performed',
992
+ error: {
993
+ message: 'Action dependencies are not terminal successes; no request was emitted.',
994
+ details: { dependency_action_ids: blockingDependencies },
995
+ },
996
+ });
997
+ reports.push(report);
998
+ statuses.set(action.action_id, report.status);
999
+ continue;
1000
+ }
1001
+ let beforeRows;
1002
+ let transportFailed = false;
1003
+ try {
1004
+ beforeRows = await exactExecutionRows({
1005
+ client: options.dataClient.client,
1006
+ restBaseUrl: options.dataClient.restBaseUrl,
1007
+ table: action.table,
1008
+ id: action.id,
1009
+ version: action.version,
1010
+ });
1011
+ const before = beforeRows[0];
1012
+ const observedOperation = beforeRows.length === 0 ? 'insert' : 'save_draft';
1013
+ const beforeExact = Boolean(beforeRows.length === 1 &&
1014
+ before &&
1015
+ before.id === action.id &&
1016
+ before.version === action.version &&
1017
+ before.user_id === options.contract.owner.user_id &&
1018
+ before.state_code === 0 &&
1019
+ before.json_ordered &&
1020
+ sha256Json(before.json_ordered) === action.before_sha256);
1021
+ if (beforeRows.length > 1 ||
1022
+ observedOperation !== action.expected_operation ||
1023
+ (observedOperation === 'save_draft' && !beforeExact)) {
1024
+ throw new CliError('Execution action before-state or expected operation drifted.', {
1025
+ code: 'DATASET_SAVE_DRAFT_EXECUTION_BEFORE_DRIFT',
1026
+ exitCode: 1,
1027
+ });
1028
+ }
1029
+ if (row.type === 'flow') {
1030
+ const unresolvedReferences = await missingFlowRemoteReferences({
1031
+ runtime: options.runtime,
1032
+ fetchImpl: options.fetchImpl,
1033
+ timeoutMs: options.timeoutMs,
1034
+ cache: referenceOnlySupportCache,
1035
+ payload: row.payload,
1036
+ });
1037
+ if (unresolvedReferences.length > 0) {
1038
+ throw new CliError('Flow execution action has unresolved remote references.', {
1039
+ code: 'DATASET_SAVE_DRAFT_REMOTE_REFERENCE_UNRESOLVED',
1040
+ exitCode: 1,
1041
+ details: { references: unresolvedReferences },
1042
+ });
1043
+ }
1044
+ }
1045
+ }
1046
+ catch (error) {
1047
+ const report = contractRowReport({
1048
+ row,
1049
+ action,
1050
+ status: 'failed',
1051
+ operation: action.expected_operation,
1052
+ attemptConsumed: false,
1053
+ readback: 'not_performed',
1054
+ error: serializeError(error),
1055
+ });
1056
+ reports.push(report);
1057
+ statuses.set(action.action_id, report.status);
1058
+ continue;
1059
+ }
1060
+ try {
1061
+ const beforeDispatch = () => {
1062
+ appendExecutionEvent({
1063
+ ledgerRoot,
1064
+ ledger,
1065
+ contractSha256,
1066
+ action,
1067
+ eventType: 'attempt_emitted',
1068
+ outcome: null,
1069
+ recovered: false,
1070
+ recordedAtUtc: options.now(),
1071
+ });
1072
+ };
1073
+ if (action.expected_operation === 'insert') {
1074
+ await createDatasetRecord({
1075
+ transport: options.commandTransport,
1076
+ table: action.table,
1077
+ id: action.id,
1078
+ payload: row.payload,
1079
+ extraData: { ruleVerification: true },
1080
+ beforeDispatch,
1081
+ });
1082
+ }
1083
+ else {
1084
+ await saveDraftDatasetRecord({
1085
+ transport: options.commandTransport,
1086
+ table: action.table,
1087
+ id: action.id,
1088
+ version: action.version,
1089
+ payload: row.payload,
1090
+ extraData: { ruleVerification: true },
1091
+ beforeDispatch,
1092
+ });
1093
+ }
1094
+ }
1095
+ catch (error) {
1096
+ if (error instanceof CliError && error.code === 'DATASET_COMMAND_BEFORE_DISPATCH_FAILED') {
1097
+ throw error;
1098
+ }
1099
+ // Once emission is durably recorded, transport outcomes are resolved by readback only.
1100
+ transportFailed = true;
1101
+ }
1102
+ const report = await finalizeAttemptedAction({
1103
+ row,
1104
+ action,
1105
+ contract: options.contract,
1106
+ contractSha256,
1107
+ ledgerRoot,
1108
+ ledger,
1109
+ client: options.dataClient.client,
1110
+ restBaseUrl: options.dataClient.restBaseUrl,
1111
+ now: options.now,
1112
+ recovered: transportFailed,
1113
+ });
1114
+ reports.push(report);
1115
+ statuses.set(action.action_id, report.status);
1116
+ }
1117
+ const failures = reports.filter((row) => ['failed', 'unknown', 'blocked'].includes(row.status));
1118
+ writeJsonLinesArtifact(options.files.progress_jsonl, reports);
1119
+ writeJsonLinesArtifact(options.files.failures_jsonl, failures);
1120
+ const unknown = reports.filter((row) => row.status === 'unknown').length;
1121
+ const failed = reports.filter((row) => row.status === 'failed').length;
1122
+ const blocked = reports.filter((row) => row.status === 'blocked').length;
1123
+ const report = {
1124
+ schema_version: 2,
1125
+ generated_at_utc: options.now(),
1126
+ input_path: options.inputPath,
1127
+ requested_type: options.requestedType,
1128
+ out_dir: options.outDir,
1129
+ commit: true,
1130
+ mode: 'commit',
1131
+ status: unknown > 0
1132
+ ? 'completed_with_unknowns'
1133
+ : failed + blocked > 0
1134
+ ? 'completed_with_failures'
1135
+ : 'completed',
1136
+ counts: {
1137
+ selected: options.preparedRows.length,
1138
+ prepared: 0,
1139
+ executed: reports.filter((row) => row.status === 'executed').length,
1140
+ failed,
1141
+ unknown,
1142
+ blocked,
1143
+ attempts_consumed: ledger.attempts.size,
1144
+ by_table: byTable(options.preparedRows),
1145
+ operations: operationCount(reports),
1146
+ },
1147
+ files: options.files,
1148
+ rows: reports,
1149
+ execution_contract: {
1150
+ path: path.resolve(options.contractPath),
1151
+ sha256: contractSha256,
1152
+ execution_id: options.contract.execution_id,
1153
+ target_mode: 'owner_draft',
1154
+ },
1155
+ };
1156
+ writeJsonArtifact(options.files.summary_json, report);
1157
+ return report;
1158
+ }
534
1159
  export async function runDatasetSaveDraft(options) {
535
1160
  const now = options.now ?? new Date();
536
1161
  const inputPath = path.resolve(options.inputPath);
@@ -542,6 +1167,18 @@ export async function runDatasetSaveDraft(options) {
542
1167
  const files = buildFiles(outDir);
543
1168
  const preparedRows = prepareRows(inputPath, options.rawInput, requestedType);
544
1169
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1170
+ const executionContractPath = options.executionContractPath
1171
+ ? path.resolve(options.executionContractPath)
1172
+ : null;
1173
+ const executionContract = executionContractPath
1174
+ ? parseExecutionContract(readJsonFile(executionContractPath, 'Dataset save-draft execution contract'))
1175
+ : null;
1176
+ if (executionContract && !commit) {
1177
+ executionContractError('Execution contract mode requires --commit.');
1178
+ }
1179
+ if (executionContract) {
1180
+ bindExecutionContractRows(executionContract, preparedRows);
1181
+ }
545
1182
  if (commit && (!options.env || !options.fetchImpl)) {
546
1183
  throw new CliError('Dataset save-draft commit requires env and fetch runtime bindings.', {
547
1184
  code: 'DATASET_SAVE_DRAFT_RUNTIME_REQUIRED',
@@ -567,6 +1204,25 @@ export async function runDatasetSaveDraft(options) {
567
1204
  ? createSupabaseDataClient(runtime, options.fetchImpl, timeoutMs)
568
1205
  : null;
569
1206
  const referenceOnlySupportCache = new Map();
1207
+ if (executionContract && executionContractPath) {
1208
+ return runExecutionContractBatch({
1209
+ contractPath: executionContractPath,
1210
+ contract: executionContract,
1211
+ preparedRows,
1212
+ allowReferenceOnlySupport,
1213
+ files,
1214
+ inputPath,
1215
+ requestedType,
1216
+ outDir,
1217
+ env: options.env,
1218
+ runtime: runtime,
1219
+ commandTransport: commandTransport,
1220
+ dataClient: dataClient,
1221
+ fetchImpl: options.fetchImpl,
1222
+ timeoutMs,
1223
+ now: () => now.toISOString(),
1224
+ });
1225
+ }
570
1226
  const reports = [];
571
1227
  for (const row of preparedRows) {
572
1228
  const preparedFailure = buildPreparedFailure(row, allowReferenceOnlySupport);
@@ -712,6 +1368,13 @@ export const __testInternals = {
712
1368
  compareVersions,
713
1369
  defaultOutDir,
714
1370
  detectType,
1371
+ decodeExecutionActor,
1372
+ bindExecutionContractRows,
1373
+ executionLedgerRoot,
1374
+ executionLedgerPath,
1375
+ executionActionBindingSha256,
1376
+ loadExecutionLedger,
1377
+ exactDesiredReadback,
715
1378
  extractIdentity,
716
1379
  flowType,
717
1380
  isElementaryFlowPayload,
@@ -721,6 +1384,10 @@ export const __testInternals = {
721
1384
  normalizeType,
722
1385
  operationCount,
723
1386
  parseVisibleRows,
1387
+ parseExecutionContract,
1388
+ parseExecutionRows,
1389
+ parseLedgerEvent,
1390
+ projectRefFromApiBaseUrl,
724
1391
  prepareRows,
725
1392
  remoteReferenceFallbackKey,
726
1393
  selectedRow,