@synapsor/runner 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runner.mjs CHANGED
@@ -440,7 +440,7 @@ function normalizeRecord(value) {
440
440
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
441
441
  }
442
442
  function sleep(ms) {
443
- return new Promise((resolve) => setTimeout(resolve, ms));
443
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
444
444
  }
445
445
 
446
446
  // packages/config/src/index.ts
@@ -479,6 +479,7 @@ var CAPABILITY_KEYS = /* @__PURE__ */ new Set([
479
479
  "transition_guards",
480
480
  "conflict_guard",
481
481
  "approval",
482
+ "writeback",
482
483
  "single_tenant_dev_ack"
483
484
  ]);
484
485
  var TARGET_KEYS = /* @__PURE__ */ new Set(["schema", "table", "primary_key", "tenant_key", "single_tenant_dev"]);
@@ -489,6 +490,8 @@ var NUMERIC_BOUND_KEYS = /* @__PURE__ */ new Set(["minimum", "maximum"]);
489
490
  var TRANSITION_GUARD_KEYS = /* @__PURE__ */ new Set(["from_column", "allowed"]);
490
491
  var CONFLICT_GUARD_KEYS = /* @__PURE__ */ new Set(["column", "weak_guard_ack"]);
491
492
  var APPROVAL_KEYS = /* @__PURE__ */ new Set(["mode", "required_role"]);
493
+ var WRITEBACK_KEYS = /* @__PURE__ */ new Set(["mode", "executor"]);
494
+ var WRITEBACK_MODES = /* @__PURE__ */ new Set(["direct_sql", "app_handler", "cloud_worker", "none"]);
492
495
  var MODEL_CONTROLLED_TRUST_FIELDS = /* @__PURE__ */ new Set([
493
496
  "tenant_id",
494
497
  "tenantId",
@@ -653,9 +656,7 @@ function validateWritebackReadiness(sources, capabilities, mode, errors, warning
653
656
  const sourceName = isNonEmptyString(capability.source) ? capability.source : void 0;
654
657
  const source = sourceName ? sources[sourceName] : void 0;
655
658
  if (!sourceName || !isRecord(source)) return;
656
- const executor = isNonEmptyString(capability.executor) ? capability.executor : "sql_update";
657
- const directSql = executor === "sql_update";
658
- if (!directSql) return;
659
+ if (capabilityWritebackMode(capability) !== "direct_sql") return;
659
660
  if (source.read_only === true) {
660
661
  errors.push({
661
662
  path: `$.capabilities[${index}].executor`,
@@ -673,6 +674,13 @@ function validateWritebackReadiness(sources, capabilities, mode, errors, warning
673
674
  }
674
675
  });
675
676
  }
677
+ function capabilityWritebackMode(capability) {
678
+ if (isRecord(capability.writeback) && typeof capability.writeback.mode === "string" && WRITEBACK_MODES.has(capability.writeback.mode)) {
679
+ return capability.writeback.mode;
680
+ }
681
+ if (isNonEmptyString(capability.executor) && capability.executor !== "sql_update") return "app_handler";
682
+ return "direct_sql";
683
+ }
676
684
  function validateCloud(value, mode, strict, errors) {
677
685
  if (mode !== "cloud") {
678
686
  if (value !== void 0) {
@@ -882,6 +890,7 @@ function validateExecutorAuth(value, path4, strict, errors) {
882
890
  function validateCapabilities(value, sources, contexts, executors, mode, strict, errors, warnings, hasContracts = false) {
883
891
  if (mode === "cloud" && value === void 0) return;
884
892
  if (hasContracts && value === void 0) return;
893
+ if (hasContracts && Array.isArray(value) && value.length === 0) return;
885
894
  if (!Array.isArray(value) || value.length === 0) {
886
895
  errors.push({ path: "$.capabilities", code: "CAPABILITIES_REQUIRED", message: "At least one capability is required." });
887
896
  return;
@@ -889,7 +898,21 @@ function validateCapabilities(value, sources, contexts, executors, mode, strict,
889
898
  const sourceNames = isRecord(sources) ? new Set(Object.keys(sources)) : /* @__PURE__ */ new Set();
890
899
  const contextNames = isRecord(contexts) ? new Set(Object.keys(contexts)) : /* @__PURE__ */ new Set();
891
900
  const executorNames = isRecord(executors) ? new Set(Object.keys(executors)) : /* @__PURE__ */ new Set();
901
+ const capabilityNames = /* @__PURE__ */ new Map();
892
902
  value.forEach((capability, index) => validateCapability(capability, index, sourceNames, contextNames, executorNames, strict, errors, warnings));
903
+ value.forEach((capability, index) => {
904
+ if (!isRecord(capability) || !isQualifiedName(capability.name)) return;
905
+ const previous = capabilityNames.get(capability.name);
906
+ if (previous !== void 0) {
907
+ errors.push({
908
+ path: `$.capabilities[${index}].name`,
909
+ code: "DUPLICATE_CAPABILITY_NAME",
910
+ message: `Capability ${capability.name} is already defined at $.capabilities[${previous}]. Capability names must be unique.`
911
+ });
912
+ return;
913
+ }
914
+ capabilityNames.set(capability.name, index);
915
+ });
893
916
  }
894
917
  function validateCapability(value, index, sourceNames, contextNames, executorNames, strict, errors, warnings) {
895
918
  const path4 = `$.capabilities[${index}]`;
@@ -923,6 +946,7 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
923
946
  errors.push({ path: `${path4}.executor`, code: "UNKNOWN_EXECUTOR", message: "Capability executor must be sql_update or reference a configured executor." });
924
947
  }
925
948
  }
949
+ validateCapabilityWriteback(value, path4, executorNames, strict, errors);
926
950
  validateTarget(value.target, `${path4}.target`, strict, errors, warnings);
927
951
  validateArgs(value.args, `${path4}.args`, strict, errors);
928
952
  validateLookup(value.lookup, `${path4}.lookup`, strict, errors);
@@ -932,6 +956,40 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
932
956
  }
933
957
  if (value.kind === "proposal") {
934
958
  validateProposalCapability(value, path4, strict, errors);
959
+ } else if (value.writeback !== void 0) {
960
+ errors.push({ path: `${path4}.writeback`, code: "WRITEBACK_ONLY_FOR_PROPOSAL", message: "writeback is only valid on proposal capabilities." });
961
+ }
962
+ }
963
+ function validateCapabilityWriteback(capability, path4, executorNames, strict, errors) {
964
+ if (capability.writeback === void 0) return;
965
+ if (!isRecord(capability.writeback)) {
966
+ errors.push({ path: `${path4}.writeback`, code: "WRITEBACK_NOT_OBJECT", message: "writeback must be an object." });
967
+ return;
968
+ }
969
+ if (strict) checkUnknownKeys(capability.writeback, WRITEBACK_KEYS, `${path4}.writeback`, errors);
970
+ if (!WRITEBACK_MODES.has(String(capability.writeback.mode))) {
971
+ errors.push({ path: `${path4}.writeback.mode`, code: "INVALID_WRITEBACK_MODE", message: "writeback.mode must be direct_sql, app_handler, cloud_worker, or none." });
972
+ return;
973
+ }
974
+ const mode = String(capability.writeback.mode);
975
+ const executor = isNonEmptyString(capability.writeback.executor) ? capability.writeback.executor : isNonEmptyString(capability.executor) ? capability.executor : void 0;
976
+ if (mode === "direct_sql") {
977
+ if (executor !== void 0 && executor !== "sql_update") {
978
+ errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_MISMATCH", message: "direct_sql writeback cannot name an app-owned executor." });
979
+ }
980
+ return;
981
+ }
982
+ if (mode === "none") {
983
+ if (executor !== void 0) {
984
+ errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_MISMATCH", message: "WRITEBACK NONE must not name an executor." });
985
+ }
986
+ return;
987
+ }
988
+ if (mode === "cloud_worker") return;
989
+ if (!executor || executor === "sql_update") {
990
+ errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_REQUIRED", message: "app_handler writeback must name a configured executor." });
991
+ } else if (!executorNames.has(executor)) {
992
+ errors.push({ path: `${path4}.writeback.executor`, code: "UNKNOWN_EXECUTOR", message: "app_handler writeback executor must reference a configured executor." });
935
993
  }
936
994
  }
937
995
  function validateTarget(value, path4, strict, errors, warnings) {
@@ -1250,6 +1308,8 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
1250
1308
 
1251
1309
  // packages/proposal-store/src/index.ts
1252
1310
  import { DatabaseSync } from "node:sqlite";
1311
+ import { mkdirSync } from "node:fs";
1312
+ import { dirname, resolve } from "node:path";
1253
1313
  var ProposalStoreError = class extends Error {
1254
1314
  constructor(code, message) {
1255
1315
  super(message);
@@ -1263,6 +1323,9 @@ var ProposalStore = class {
1263
1323
  path;
1264
1324
  constructor(path4 = ":memory:") {
1265
1325
  this.path = path4;
1326
+ if (path4 !== ":memory:") {
1327
+ mkdirSync(dirname(resolve(path4)), { recursive: true });
1328
+ }
1266
1329
  this.db = new DatabaseSync(path4);
1267
1330
  this.migrate();
1268
1331
  }
@@ -1952,6 +2015,10 @@ var ProposalStore = class {
1952
2015
  if (changeSet.writeback.mode !== "trusted_worker_required") {
1953
2016
  throw new ProposalStoreError("WRITEBACK_NOT_REQUIRED", `proposal ${proposalId} uses ${changeSet.writeback.mode}`);
1954
2017
  }
2018
+ const writebackExecutor = changeSet.writeback.executor;
2019
+ if (typeof writebackExecutor === "string" && writebackExecutor !== "sql_update" && writebackExecutor !== "trusted_worker_required") {
2020
+ throw new ProposalStoreError("WRITEBACK_NOT_DIRECT_SQL", `proposal ${proposalId} uses app-owned or non-local writeback executor ${writebackExecutor}`);
2021
+ }
1955
2022
  if (changeSet.source.kind !== "external_postgres" && changeSet.source.kind !== "external_mysql") {
1956
2023
  throw new ProposalStoreError("WRITEBACK_TARGET_NOT_EXTERNAL", `proposal ${proposalId} targets ${changeSet.source.kind}`);
1957
2024
  }
@@ -2746,16 +2813,18 @@ var METADATA_KEYS = /* @__PURE__ */ new Set(["name", "description", "version", "
2746
2813
  var RESOURCE_KEYS = /* @__PURE__ */ new Set(["name", "engine", "schema", "table", "type", "primary_key", "tenant_key", "conflict_key", "single_tenant_dev"]);
2747
2814
  var CONTEXT_KEYS2 = /* @__PURE__ */ new Set(["name", "description", "bindings", "tenant_binding", "principal_binding"]);
2748
2815
  var BINDING_KEYS = /* @__PURE__ */ new Set(["name", "source", "key", "required"]);
2749
- var CAPABILITY_KEYS2 = /* @__PURE__ */ new Set(["name", "description", "kind", "context", "source", "subject", "args", "lookup", "visible_fields", "kept_out_fields", "evidence", "max_rows", "proposal"]);
2816
+ var CAPABILITY_KEYS2 = /* @__PURE__ */ new Set(["name", "description", "returns_hint", "kind", "context", "source", "subject", "args", "lookup", "visible_fields", "kept_out_fields", "evidence", "max_rows", "proposal"]);
2750
2817
  var SUBJECT_KEYS = /* @__PURE__ */ new Set(["resource", "schema", "table", "primary_key", "tenant_key", "conflict_key", "single_tenant_dev"]);
2751
2818
  var ARG_KEYS2 = /* @__PURE__ */ new Set(["type", "description", "required", "max_length", "minimum", "maximum", "enum"]);
2752
2819
  var LOOKUP_KEYS2 = /* @__PURE__ */ new Set(["id_from_arg"]);
2753
2820
  var EVIDENCE_KEYS = /* @__PURE__ */ new Set(["required", "sources", "query_audit", "handle_prefix"]);
2754
- var PROPOSAL_KEYS = /* @__PURE__ */ new Set(["action", "allowed_fields", "patch", "conflict_guard", "approval", "writeback"]);
2821
+ var PROPOSAL_KEYS = /* @__PURE__ */ new Set(["action", "allowed_fields", "patch", "numeric_bounds", "transition_guards", "conflict_guard", "approval", "writeback"]);
2755
2822
  var PATCH_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
2823
+ var NUMERIC_BOUND_KEYS2 = /* @__PURE__ */ new Set(["minimum", "maximum"]);
2824
+ var TRANSITION_GUARD_KEYS2 = /* @__PURE__ */ new Set(["from_column", "allowed"]);
2756
2825
  var CONFLICT_GUARD_KEYS2 = /* @__PURE__ */ new Set(["column", "weak_guard_ack"]);
2757
2826
  var APPROVAL_KEYS2 = /* @__PURE__ */ new Set(["mode", "required_role"]);
2758
- var WRITEBACK_KEYS = /* @__PURE__ */ new Set(["mode", "executor", "idempotency_key"]);
2827
+ var WRITEBACK_KEYS2 = /* @__PURE__ */ new Set(["mode", "executor", "idempotency_key"]);
2759
2828
  var WORKFLOW_KEYS = /* @__PURE__ */ new Set(["name", "description", "context", "allowed_capabilities", "required_evidence", "approval", "settlement", "replay"]);
2760
2829
  var POLICY_KEYS = /* @__PURE__ */ new Set(["name", "kind", "mode", "rules"]);
2761
2830
  var EVIDENCE_RECORD_KEYS = /* @__PURE__ */ new Set(["handle", "capability", "query_fingerprint", "items"]);
@@ -2931,6 +3000,8 @@ function validateCapabilities2(value, contextNames, resourceNames, errors, warni
2931
3000
  errors.push({ path: `${path4}.name`, code: "INVALID_CAPABILITY_NAME", message: "capability name must be namespace.name." });
2932
3001
  else
2933
3002
  addUnique(names, capability.name, `${path4}.name`, "DUPLICATE_CAPABILITY_NAME", errors);
3003
+ if (capability.returns_hint !== void 0 && !isNonEmptyString2(capability.returns_hint))
3004
+ errors.push({ path: `${path4}.returns_hint`, code: "INVALID_RETURNS_HINT", message: "returns_hint must be a non-empty string." });
2934
3005
  if (!["read", "proposal", "external_action", "answer_with_evidence"].includes(String(capability.kind)))
2935
3006
  errors.push({ path: `${path4}.kind`, code: "INVALID_CAPABILITY_KIND", message: "kind must be read, proposal, external_action, or answer_with_evidence." });
2936
3007
  if (!isNonEmptyString2(capability.context) || !contextNames.has(capability.context))
@@ -2993,6 +3064,20 @@ function validateArgs2(value, path4, errors) {
2993
3064
  checkUnknownKeys2(arg, ARG_KEYS2, argPath, errors);
2994
3065
  if (!["string", "number", "boolean"].includes(String(arg.type)))
2995
3066
  errors.push({ path: `${argPath}.type`, code: "INVALID_ARG_TYPE", message: "arg type must be string, number, or boolean." });
3067
+ if (arg.description !== void 0 && !isNonEmptyString2(arg.description))
3068
+ errors.push({ path: `${argPath}.description`, code: "INVALID_ARG_DESCRIPTION", message: "arg description must be a non-empty string." });
3069
+ if (arg.max_length !== void 0 && (!Number.isInteger(arg.max_length) || Number(arg.max_length) <= 0))
3070
+ errors.push({ path: `${argPath}.max_length`, code: "INVALID_MAX_LENGTH", message: "max_length must be a positive integer." });
3071
+ if ((arg.minimum !== void 0 || arg.maximum !== void 0) && arg.type !== "number")
3072
+ errors.push({ path: argPath, code: "NUMERIC_BOUNDS_REQUIRE_NUMBER", message: "minimum/maximum can only be used with number args." });
3073
+ if (arg.minimum !== void 0 && !isFiniteNumber2(arg.minimum))
3074
+ errors.push({ path: `${argPath}.minimum`, code: "INVALID_MINIMUM", message: "minimum must be a finite number." });
3075
+ if (arg.maximum !== void 0 && !isFiniteNumber2(arg.maximum))
3076
+ errors.push({ path: `${argPath}.maximum`, code: "INVALID_MAXIMUM", message: "maximum must be a finite number." });
3077
+ if (isFiniteNumber2(arg.minimum) && isFiniteNumber2(arg.maximum) && Number(arg.minimum) > Number(arg.maximum))
3078
+ errors.push({ path: argPath, code: "INVALID_ARG_NUMERIC_RANGE", message: "minimum must be less than or equal to maximum." });
3079
+ if (arg.enum !== void 0 && (!Array.isArray(arg.enum) || arg.enum.length === 0))
3080
+ errors.push({ path: `${argPath}.enum`, code: "INVALID_ARG_ENUM", message: "enum must be a non-empty array when provided." });
2996
3081
  }
2997
3082
  }
2998
3083
  function validateLookup2(value, path4, args, errors) {
@@ -3043,6 +3128,8 @@ function validateProposalAction(value, path4, errors) {
3043
3128
  errors.push({ path: patchPath, code: "PATCH_BINDING_REQUIRED", message: "patch binding must include fixed or from_arg." });
3044
3129
  }
3045
3130
  }
3131
+ validateNumericBounds2(value.numeric_bounds, value.patch, `${path4}.numeric_bounds`, errors);
3132
+ validateTransitionGuards2(value.transition_guards, value.patch, `${path4}.transition_guards`, errors);
3046
3133
  if (value.conflict_guard !== void 0) {
3047
3134
  if (!isRecord3(value.conflict_guard))
3048
3135
  errors.push({ path: `${path4}.conflict_guard`, code: "CONFLICT_GUARD_NOT_OBJECT", message: "conflict_guard must be an object." });
@@ -3057,11 +3144,79 @@ function validateProposalAction(value, path4, errors) {
3057
3144
  if (value.approval !== void 0)
3058
3145
  validateNestedObject(value.approval, APPROVAL_KEYS2, `${path4}.approval`, errors);
3059
3146
  if (value.writeback !== void 0) {
3060
- validateNestedObject(value.writeback, WRITEBACK_KEYS, `${path4}.writeback`, errors);
3147
+ validateNestedObject(value.writeback, WRITEBACK_KEYS2, `${path4}.writeback`, errors);
3061
3148
  if (isRecord3(value.writeback) && !["direct_sql", "app_handler", "cloud_worker", "none"].includes(String(value.writeback.mode)))
3062
3149
  errors.push({ path: `${path4}.writeback.mode`, code: "INVALID_WRITEBACK_MODE", message: "writeback.mode must be direct_sql, app_handler, cloud_worker, or none." });
3063
3150
  }
3064
3151
  }
3152
+ function validateNumericBounds2(value, patch, path4, errors) {
3153
+ if (value === void 0)
3154
+ return;
3155
+ if (!isRecord3(value)) {
3156
+ errors.push({ path: path4, code: "NUMERIC_BOUNDS_NOT_OBJECT", message: "numeric_bounds must map patch fields to numeric bounds." });
3157
+ return;
3158
+ }
3159
+ const patchFields = isRecord3(patch) ? new Set(Object.keys(patch)) : /* @__PURE__ */ new Set();
3160
+ for (const [field, bounds] of Object.entries(value)) {
3161
+ const boundPath = `${path4}.${field}`;
3162
+ if (!isSafeIdentifier2(field))
3163
+ errors.push({ path: boundPath, code: "INVALID_NUMERIC_BOUND_FIELD", message: "numeric_bounds keys must be safe patch fields." });
3164
+ if (!patchFields.has(field))
3165
+ errors.push({ path: boundPath, code: "NUMERIC_BOUND_PATCH_FIELD_REQUIRED", message: "numeric_bounds can only constrain fields in proposal.patch." });
3166
+ if (!isRecord3(bounds)) {
3167
+ errors.push({ path: boundPath, code: "NUMERIC_BOUND_NOT_OBJECT", message: "numeric bound must be an object." });
3168
+ continue;
3169
+ }
3170
+ checkUnknownKeys2(bounds, NUMERIC_BOUND_KEYS2, boundPath, errors);
3171
+ const hasMinimum = bounds.minimum !== void 0;
3172
+ const hasMaximum = bounds.maximum !== void 0;
3173
+ if (!hasMinimum && !hasMaximum)
3174
+ errors.push({ path: boundPath, code: "NUMERIC_BOUND_EMPTY", message: "numeric bound must define minimum, maximum, or both." });
3175
+ if (hasMinimum && !isFiniteNumber2(bounds.minimum))
3176
+ errors.push({ path: `${boundPath}.minimum`, code: "INVALID_MINIMUM", message: "minimum must be a finite number." });
3177
+ if (hasMaximum && !isFiniteNumber2(bounds.maximum))
3178
+ errors.push({ path: `${boundPath}.maximum`, code: "INVALID_MAXIMUM", message: "maximum must be a finite number." });
3179
+ if (isFiniteNumber2(bounds.minimum) && isFiniteNumber2(bounds.maximum) && Number(bounds.minimum) > Number(bounds.maximum)) {
3180
+ errors.push({ path: boundPath, code: "INVALID_NUMERIC_RANGE", message: "minimum must be less than or equal to maximum." });
3181
+ }
3182
+ }
3183
+ }
3184
+ function validateTransitionGuards2(value, patch, path4, errors) {
3185
+ if (value === void 0)
3186
+ return;
3187
+ if (!isRecord3(value)) {
3188
+ errors.push({ path: path4, code: "TRANSITION_GUARDS_NOT_OBJECT", message: "transition_guards must map patch fields to allowed state transitions." });
3189
+ return;
3190
+ }
3191
+ const patchFields = isRecord3(patch) ? new Set(Object.keys(patch)) : /* @__PURE__ */ new Set();
3192
+ for (const [field, guard] of Object.entries(value)) {
3193
+ const guardPath = `${path4}.${field}`;
3194
+ if (!isSafeIdentifier2(field))
3195
+ errors.push({ path: guardPath, code: "INVALID_TRANSITION_GUARD_FIELD", message: "transition_guards keys must be safe patch fields." });
3196
+ if (!patchFields.has(field))
3197
+ errors.push({ path: guardPath, code: "TRANSITION_GUARD_PATCH_FIELD_REQUIRED", message: "transition_guards can only constrain fields in proposal.patch." });
3198
+ if (!isRecord3(guard)) {
3199
+ errors.push({ path: guardPath, code: "TRANSITION_GUARD_NOT_OBJECT", message: "transition guard must be an object." });
3200
+ continue;
3201
+ }
3202
+ checkUnknownKeys2(guard, TRANSITION_GUARD_KEYS2, guardPath, errors);
3203
+ if (guard.from_column !== void 0 && !isSafeIdentifier2(guard.from_column)) {
3204
+ errors.push({ path: `${guardPath}.from_column`, code: "INVALID_TRANSITION_FROM_COLUMN", message: "from_column must be a safe identifier." });
3205
+ }
3206
+ if (!isRecord3(guard.allowed) || Object.keys(guard.allowed).length === 0) {
3207
+ errors.push({ path: `${guardPath}.allowed`, code: "TRANSITION_ALLOWED_REQUIRED", message: "transition guard must define allowed transitions." });
3208
+ continue;
3209
+ }
3210
+ for (const [from, toValues] of Object.entries(guard.allowed)) {
3211
+ const allowedPath = `${guardPath}.allowed.${from}`;
3212
+ if (!isNonEmptyString2(from))
3213
+ errors.push({ path: allowedPath, code: "TRANSITION_FROM_REQUIRED", message: "transition source state must be a non-empty string." });
3214
+ if (!Array.isArray(toValues) || toValues.length === 0 || toValues.some((item) => !isNonEmptyString2(item))) {
3215
+ errors.push({ path: allowedPath, code: "TRANSITION_TO_VALUES_REQUIRED", message: "transition target states must be non-empty strings." });
3216
+ }
3217
+ }
3218
+ }
3219
+ }
3065
3220
  function validateWorkflows(value, contextNames, capabilityNames, errors) {
3066
3221
  if (value === void 0)
3067
3222
  return;
@@ -3245,6 +3400,9 @@ function isQualifiedOrSafeName(value) {
3245
3400
  function isPositiveInteger2(value) {
3246
3401
  return Number.isInteger(value) && Number(value) > 0;
3247
3402
  }
3403
+ function isFiniteNumber2(value) {
3404
+ return typeof value === "number" && Number.isFinite(value);
3405
+ }
3248
3406
 
3249
3407
  // packages/spec/dist/normalize.js
3250
3408
  function normalizeContract(input) {
@@ -3296,20 +3454,31 @@ function loadRuntimeConfigFromFile(configPath = process.env.SYNAPSOR_MCP_CONFIG
3296
3454
  }
3297
3455
  function resolveRuntimeConfig(config, baseDir = process.cwd()) {
3298
3456
  if (!Array.isArray(config.contracts) || config.contracts.length === 0) return config;
3457
+ const seenCapabilities = /* @__PURE__ */ new Map();
3458
+ for (const [index, capability] of (config.capabilities ?? []).entries()) {
3459
+ rememberCapabilityName(seenCapabilities, capability.name, `embedded capabilities[${index}]`);
3460
+ }
3299
3461
  const resolved = {
3300
3462
  ...config,
3301
3463
  contexts: { ...config.contexts ?? {} },
3302
3464
  capabilities: [...config.capabilities ?? []]
3303
3465
  };
3304
- for (const contractPath of config.contracts) {
3466
+ for (const [contractIndex, contractPath] of config.contracts.entries()) {
3305
3467
  const fullPath = path.resolve(baseDir, contractPath);
3306
3468
  const contract = normalizeContract(JSON.parse(fs.readFileSync(fullPath, "utf8")));
3307
- mergeContractIntoRuntimeConfig(resolved, contract);
3469
+ mergeContractIntoRuntimeConfig(resolved, contract, `contracts[${contractIndex}] ${contractPath}`, seenCapabilities);
3308
3470
  }
3309
3471
  delete resolved.contracts;
3310
3472
  return resolved;
3311
3473
  }
3312
- function mergeContractIntoRuntimeConfig(config, contract) {
3474
+ function rememberCapabilityName(seen, name, origin) {
3475
+ const previous = seen.get(name);
3476
+ if (previous) {
3477
+ throw new Error(`Duplicate capability ${name}: ${origin} conflicts with ${previous}. Capability names must be unique across embedded runner config and referenced contracts.`);
3478
+ }
3479
+ seen.set(name, origin);
3480
+ }
3481
+ function mergeContractIntoRuntimeConfig(config, contract, origin, seenCapabilities) {
3313
3482
  const resources = new Map((contract.resources ?? []).map((resource) => [resource.name, resource]));
3314
3483
  for (const context of contract.contexts) {
3315
3484
  if (!config.contexts) config.contexts = {};
@@ -3320,7 +3489,8 @@ function mergeContractIntoRuntimeConfig(config, contract) {
3320
3489
  if (context) config.trusted_context = runtimeContextFromSpec(context);
3321
3490
  }
3322
3491
  if (!config.capabilities) config.capabilities = [];
3323
- for (const capability of contract.capabilities) {
3492
+ for (const [capabilityIndex, capability] of contract.capabilities.entries()) {
3493
+ rememberCapabilityName(seenCapabilities, capability.name, `${origin} capabilities[${capabilityIndex}]`);
3324
3494
  config.capabilities.push(runtimeCapabilityFromSpec(capability, resources, config));
3325
3495
  }
3326
3496
  }
@@ -3350,6 +3520,7 @@ function runtimeCapabilityFromSpec(capability, resources, config) {
3350
3520
  name: capability.name,
3351
3521
  kind: capability.kind === "proposal" ? "proposal" : "read",
3352
3522
  ...capability.description ? { description: capability.description } : {},
3523
+ ...capability.returns_hint ? { returns_hint: capability.returns_hint } : {},
3353
3524
  source,
3354
3525
  context: capability.context,
3355
3526
  target: target2,
@@ -3362,9 +3533,15 @@ function runtimeCapabilityFromSpec(capability, resources, config) {
3362
3533
  if (capability.kind === "proposal" && capability.proposal) {
3363
3534
  runtime.patch = capability.proposal.patch;
3364
3535
  runtime.allowed_columns = capability.proposal.allowed_fields;
3536
+ runtime.numeric_bounds = capability.proposal.numeric_bounds;
3537
+ runtime.transition_guards = capability.proposal.transition_guards;
3365
3538
  runtime.conflict_guard = capability.proposal.conflict_guard;
3366
3539
  runtime.approval = capability.proposal.approval;
3367
- if (capability.proposal.writeback?.mode && capability.proposal.writeback.mode !== "direct_sql" && capability.proposal.writeback.executor) {
3540
+ runtime.writeback = {
3541
+ mode: capability.proposal.writeback?.mode ?? "direct_sql",
3542
+ ...capability.proposal.writeback?.executor ? { executor: capability.proposal.writeback.executor } : {}
3543
+ };
3544
+ if (capability.proposal.writeback?.executor) {
3368
3545
  runtime.executor = capability.proposal.writeback.executor;
3369
3546
  }
3370
3547
  }
@@ -3522,11 +3699,11 @@ async function serveStdio(options = {}) {
3522
3699
  const transport = new StdioServerTransport();
3523
3700
  await server.connect(transport);
3524
3701
  process.stderr.write("synapsor-runner MCP stdio server ready. Waiting for an MCP client on stdio; logs stay on stderr.\n");
3525
- await new Promise((resolve) => {
3702
+ await new Promise((resolve2) => {
3526
3703
  const previousOnClose = transport.onclose;
3527
3704
  const close = () => {
3528
3705
  runtime.close();
3529
- resolve();
3706
+ resolve2();
3530
3707
  };
3531
3708
  transport.onclose = () => {
3532
3709
  previousOnClose?.();
@@ -3545,7 +3722,7 @@ async function startHttpMcpServer(options = {}) {
3545
3722
  if (devNoAuth && !isLoopbackHost(host)) {
3546
3723
  throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
3547
3724
  }
3548
- const authToken = devNoAuth ? void 0 : env[authTokenEnv];
3725
+ const authToken = devNoAuth ? void 0 : envValue(env, authTokenEnv);
3549
3726
  if (!devNoAuth && !authToken) {
3550
3727
  throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. HTTP MCP requires bearer auth by default.`);
3551
3728
  }
@@ -3569,11 +3746,11 @@ async function startHttpMcpServer(options = {}) {
3569
3746
  });
3570
3747
  });
3571
3748
  try {
3572
- await new Promise((resolve, reject) => {
3749
+ await new Promise((resolve2, reject) => {
3573
3750
  server.once("error", reject);
3574
3751
  server.listen(port, host, () => {
3575
3752
  server.off("error", reject);
3576
- resolve();
3753
+ resolve2();
3577
3754
  });
3578
3755
  });
3579
3756
  } catch (error) {
@@ -3611,7 +3788,7 @@ async function startStreamableHttpMcpServer(options = {}) {
3611
3788
  if (devNoAuth && !isLoopbackHost(host)) {
3612
3789
  throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
3613
3790
  }
3614
- const authToken = devNoAuth ? void 0 : env[authTokenEnv];
3791
+ const authToken = devNoAuth ? void 0 : envValue(env, authTokenEnv);
3615
3792
  if (!devNoAuth && !authToken) {
3616
3793
  throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. Streamable HTTP MCP requires bearer auth by default.`);
3617
3794
  }
@@ -3638,11 +3815,11 @@ async function startStreamableHttpMcpServer(options = {}) {
3638
3815
  });
3639
3816
  });
3640
3817
  try {
3641
- await new Promise((resolve, reject) => {
3818
+ await new Promise((resolve2, reject) => {
3642
3819
  server.once("error", reject);
3643
3820
  server.listen(port, host, () => {
3644
3821
  server.off("error", reject);
3645
- resolve();
3822
+ resolve2();
3646
3823
  });
3647
3824
  });
3648
3825
  } catch (error) {
@@ -3966,20 +4143,20 @@ function isLoopbackHost(host) {
3966
4143
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
3967
4144
  }
3968
4145
  async function closeHttpServer(server, runtime) {
3969
- await new Promise((resolve, reject) => {
4146
+ await new Promise((resolve2, reject) => {
3970
4147
  server.close((error) => {
3971
4148
  if (error) reject(error);
3972
- else resolve();
4149
+ else resolve2();
3973
4150
  });
3974
4151
  }).finally(() => {
3975
4152
  runtime.close();
3976
4153
  });
3977
4154
  }
3978
4155
  async function closeStreamableHttpServer(server, sessions) {
3979
- await new Promise((resolve, reject) => {
4156
+ await new Promise((resolve2, reject) => {
3980
4157
  server.close((error) => {
3981
4158
  if (error) reject(error);
3982
- else resolve();
4159
+ else resolve2();
3983
4160
  });
3984
4161
  }).finally(() => closeStreamableSessions(sessions));
3985
4162
  }
@@ -4023,8 +4200,8 @@ function listedLocalCapabilities(config) {
4023
4200
  }
4024
4201
  function createCloudClient(config, env) {
4025
4202
  const cloud2 = requireCloudConfig(config);
4026
- const baseUrl = env[cloud2.base_url_env];
4027
- const runnerToken = env[cloud2.runner_token_env];
4203
+ const baseUrl = envValue(env, cloud2.base_url_env);
4204
+ const runnerToken = envValue(env, cloud2.runner_token_env);
4028
4205
  if (!baseUrl) throw new McpRuntimeError("CLOUD_BASE_URL_MISSING", `${cloud2.base_url_env} is not set.`);
4029
4206
  if (!runnerToken) throw new McpRuntimeError("CLOUD_RUNNER_TOKEN_MISSING", `${cloud2.runner_token_env} is not set.`);
4030
4207
  return new ControlPlaneClient({
@@ -4106,6 +4283,9 @@ async function callConfiguredTool(input) {
4106
4283
  if (capability.kind === "proposal" && input.config.mode === "read_only") {
4107
4284
  throw new McpRuntimeError("PROPOSALS_DISABLED", "This runner is in read_only mode; proposal tools are disabled.");
4108
4285
  }
4286
+ if (capability.kind === "proposal" && input.config.mode === "review") {
4287
+ assertProposalWritebackResolvable(input.config, capability);
4288
+ }
4109
4289
  const source = input.config.sources?.[capability.source];
4110
4290
  if (!source) throw new McpRuntimeError("SOURCE_NOT_FOUND", `Unknown source: ${capability.source}`);
4111
4291
  const context = resolveTrustedContext(input.config, input.env, capability);
@@ -4275,7 +4455,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
4275
4455
  const targetType = typeof target2?.type === "string" ? target2.type : capability?.target.table ?? "object";
4276
4456
  const targetId = target2?.id !== void 0 ? String(target2.id) : "unknown";
4277
4457
  const executor = writebackExecutorName(legacy.writeback);
4278
- const writebackMode = executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
4458
+ const writebackMode = executor === "read_only" || executor === "none" ? "proposal_only" : executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
4279
4459
  return {
4280
4460
  ok: true,
4281
4461
  summary: `Created proposal ${proposalId} for ${targetType} ${targetId}. Source database changed: no.`,
@@ -4330,6 +4510,44 @@ function writebackExecutorName(value) {
4330
4510
  if (!isRecord4(value)) return void 0;
4331
4511
  return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
4332
4512
  }
4513
+ function capabilityWritebackMode2(capability) {
4514
+ const mode = capability.writeback?.mode;
4515
+ if (mode === "direct_sql" || mode === "app_handler" || mode === "cloud_worker" || mode === "none") return mode;
4516
+ if (capability.executor && capability.executor !== "sql_update") return "app_handler";
4517
+ return "direct_sql";
4518
+ }
4519
+ function capabilityWritebackExecutor(capability) {
4520
+ return capability.writeback?.executor ?? capability.executor;
4521
+ }
4522
+ function assertProposalWritebackResolvable(config, capability) {
4523
+ if (capability.kind !== "proposal") return;
4524
+ const mode = capabilityWritebackMode2(capability);
4525
+ if (mode === "none" || mode === "cloud_worker") return;
4526
+ if (mode === "direct_sql") {
4527
+ const source = config.sources?.[capability.source];
4528
+ if (!source) {
4529
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} is not configured.`);
4530
+ }
4531
+ if (source.read_only === true) {
4532
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} is read-only.`);
4533
+ }
4534
+ if (!source.write_url_env) {
4535
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} has no write_url_env.`);
4536
+ }
4537
+ return;
4538
+ }
4539
+ const executorName = capabilityWritebackExecutor(capability);
4540
+ if (!executorName || executorName === "sql_update") {
4541
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but has no executor name.`);
4542
+ }
4543
+ const executor = config.executors?.[executorName];
4544
+ if (!isRecord4(executor)) {
4545
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not configured.`);
4546
+ }
4547
+ if (executor.type !== "http_handler" && executor.type !== "command_handler") {
4548
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not an app-owned handler.`);
4549
+ }
4550
+ }
4333
4551
  function evidenceHandle(bundleId) {
4334
4552
  return {
4335
4553
  bundle_id: bundleId,
@@ -4389,6 +4607,9 @@ function buildChangeSet(input) {
4389
4607
  enforcePatchGuards(input.capability, before, patch);
4390
4608
  const after = { ...before, ...patch };
4391
4609
  const guard = expectedVersionGuard(input.capability, before);
4610
+ const writebackMode = capabilityWritebackMode2(input.capability);
4611
+ const changeSetWritebackMode = writebackMode === "none" ? "read_only" : "trusted_worker_required";
4612
+ const writebackExecutor = writebackMode === "none" ? "none" : writebackMode === "cloud_worker" ? "cloud_worker" : writebackMode === "direct_sql" ? "sql_update" : capabilityWritebackExecutor(input.capability);
4392
4613
  const proposalCore = {
4393
4614
  schema_version: protocolVersions.changeSet,
4394
4615
  proposal_id: stableId("wrp", {
@@ -4436,8 +4657,8 @@ function buildChangeSet(input) {
4436
4657
  },
4437
4658
  writeback: {
4438
4659
  status: "not_applied",
4439
- mode: "trusted_worker_required",
4440
- executor: input.capability.executor ?? "sql_update"
4660
+ mode: changeSetWritebackMode,
4661
+ executor: writebackExecutor
4441
4662
  },
4442
4663
  source_database_mutated: false,
4443
4664
  created_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -4460,7 +4681,7 @@ async function readCurrentRow(input) {
4460
4681
  return readMysqlRow(input);
4461
4682
  }
4462
4683
  async function readPostgresRow(input) {
4463
- const connectionString = input.env[input.source.read_url_env];
4684
+ const connectionString = envValue(input.env, input.source.read_url_env);
4464
4685
  if (!connectionString) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
4465
4686
  const pool = new Pool({ connectionString });
4466
4687
  const client = await pool.connect();
@@ -4482,7 +4703,7 @@ async function readPostgresRow(input) {
4482
4703
  }
4483
4704
  }
4484
4705
  async function readMysqlRow(input) {
4485
- const uri = input.env[input.source.read_url_env];
4706
+ const uri = envValue(input.env, input.source.read_url_env);
4486
4707
  if (!uri) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
4487
4708
  const connection = await mysql.createConnection({ uri, dateStrings: true });
4488
4709
  try {
@@ -4537,8 +4758,8 @@ function resolveTrustedContext(config, env, capability) {
4537
4758
  if (provider === "environment") {
4538
4759
  const tenantEnv = String(values.tenant_id_env ?? "SYNAPSOR_TENANT_ID");
4539
4760
  const principalEnv = String(values.principal_env ?? "SYNAPSOR_PRINCIPAL");
4540
- const tenant = env[tenantEnv];
4541
- const principal = env[principalEnv];
4761
+ const tenant = envValue(env, tenantEnv);
4762
+ const principal = envValue(env, principalEnv);
4542
4763
  if (!tenant || !principal) throw new McpRuntimeError("TRUSTED_BINDING_MISSING", `${tenantEnv} and ${principalEnv} must be set.`);
4543
4764
  return { tenant_id: tenant, principal, provenance: "environment" };
4544
4765
  }
@@ -4551,8 +4772,19 @@ function resolveTrustedContext(config, env, capability) {
4551
4772
  throw new McpRuntimeError("TRUSTED_CONTEXT_UNSUPPORTED", `${provider} trusted context is not available in local stdio mode.`);
4552
4773
  }
4553
4774
  function valueFromEnvOrLiteral(envName, literal, env) {
4554
- if (typeof envName === "string" && env[envName]) return env[envName];
4555
- return typeof literal === "string" && literal.length > 0 ? literal : void 0;
4775
+ if (typeof envName === "string") {
4776
+ const value2 = envValue(env, envName);
4777
+ if (value2) return value2;
4778
+ }
4779
+ if (typeof literal !== "string") return void 0;
4780
+ const value = literal.trim();
4781
+ return value.length > 0 ? value : void 0;
4782
+ }
4783
+ function envValue(env, name) {
4784
+ const value = env[name];
4785
+ if (value === void 0) return void 0;
4786
+ const trimmed = value.trim();
4787
+ return trimmed.length > 0 ? trimmed : void 0;
4556
4788
  }
4557
4789
  function validateToolArgs(capability, args) {
4558
4790
  for (const [name, spec] of Object.entries(capability.args)) {
@@ -6421,7 +6653,7 @@ async function startPolling(config, adapters2, signal) {
6421
6653
  } catch (error) {
6422
6654
  logger.error("runner loop failed", { error: error instanceof Error ? error.message : String(error) });
6423
6655
  }
6424
- await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs));
6656
+ await new Promise((resolve2) => setTimeout(resolve2, config.pollIntervalMs));
6425
6657
  }
6426
6658
  }
6427
6659
 
@@ -6453,7 +6685,7 @@ function parseAgentDsl(source) {
6453
6685
  }
6454
6686
  return { contexts, capabilities, workflows };
6455
6687
  }
6456
- function compileAgentDsl(source) {
6688
+ function compileAgentDslWithWarnings(source) {
6457
6689
  const ast = parseAgentDsl(source);
6458
6690
  const contexts = ast.contexts.map((context) => ({
6459
6691
  name: context.name,
@@ -6465,6 +6697,8 @@ function compileAgentDsl(source) {
6465
6697
  const spec = {
6466
6698
  name: capability.name,
6467
6699
  kind: capability.kind,
6700
+ ...capability.description ? { description: capability.description } : {},
6701
+ ...capability.returnsHint ? { returns_hint: capability.returnsHint } : {},
6468
6702
  context: capability.context,
6469
6703
  ...capability.source ? { source: capability.source } : {},
6470
6704
  subject: {
@@ -6474,7 +6708,7 @@ function compileAgentDsl(source) {
6474
6708
  ...capability.tenantKey ? { tenant_key: capability.tenantKey } : {},
6475
6709
  ...capability.conflictKey ? { conflict_key: capability.conflictKey } : {}
6476
6710
  },
6477
- args: capability.args,
6711
+ args: specArgsFromDsl(capability.args),
6478
6712
  ...capability.lookup ? { lookup: { id_from_arg: capability.lookup.arg } } : {},
6479
6713
  visible_fields: capability.visibleFields,
6480
6714
  ...capability.keptOutFields.length ? { kept_out_fields: capability.keptOutFields } : {},
@@ -6486,6 +6720,8 @@ function compileAgentDsl(source) {
6486
6720
  action: capability.proposal.action,
6487
6721
  allowed_fields: capability.proposal.allowedFields,
6488
6722
  patch: capability.proposal.patch,
6723
+ ...capability.proposal.numericBounds ? { numeric_bounds: capability.proposal.numericBounds } : {},
6724
+ ...capability.proposal.transitionGuards ? { transition_guards: capability.proposal.transitionGuards } : {},
6489
6725
  conflict_guard: capability.conflictKey ? { column: capability.conflictKey } : { weak_guard_ack: true },
6490
6726
  approval: { mode: "human", required_role: capability.proposal.approvalRole ?? "local_reviewer" },
6491
6727
  writeback: {
@@ -6512,12 +6748,12 @@ function compileAgentDsl(source) {
6512
6748
  ...workflows.length ? { workflows } : {}
6513
6749
  };
6514
6750
  assertValidContract(contract);
6515
- return normalizeContract(contract);
6751
+ return { contract: normalizeContract(contract), warnings: collectDslWarnings(ast) };
6516
6752
  }
6517
6753
  function validateAgentDsl(source) {
6518
6754
  try {
6519
- compileAgentDsl(source);
6520
- return { ok: true, errors: [], warnings: [] };
6755
+ const result = compileAgentDslWithWarnings(source);
6756
+ return { ok: true, errors: [], warnings: result.warnings };
6521
6757
  } catch (error) {
6522
6758
  if (error instanceof AgentDslError) {
6523
6759
  return {
@@ -6602,6 +6838,7 @@ function parseContextBlock(block) {
6602
6838
  function parseCapabilityBlock(block) {
6603
6839
  const capability = {
6604
6840
  name: block.name,
6841
+ line: block.line,
6605
6842
  kind: "read",
6606
6843
  context: "",
6607
6844
  schema: "",
@@ -6612,6 +6849,16 @@ function parseCapabilityBlock(block) {
6612
6849
  keptOutFields: []
6613
6850
  };
6614
6851
  for (const item of block.body) {
6852
+ const description = item.text.match(/^DESCRIPTION\s+'(.*)'$/i);
6853
+ if (description) {
6854
+ capability.description = description[1] ?? "";
6855
+ continue;
6856
+ }
6857
+ const returnsHint = item.text.match(/^RETURNS\s+HINT\s+'(.*)'$/i);
6858
+ if (returnsHint) {
6859
+ capability.returnsHint = returnsHint[1] ?? "";
6860
+ continue;
6861
+ }
6615
6862
  const context = item.text.match(/^USING\s+CONTEXT\s+([A-Za-z_][A-Za-z0-9_.]*)$/i);
6616
6863
  if (context?.[1]) {
6617
6864
  capability.context = context[1];
@@ -6643,13 +6890,9 @@ function parseCapabilityBlock(block) {
6643
6890
  capability.conflictKey = conflict3[1];
6644
6891
  continue;
6645
6892
  }
6646
- const arg = item.text.match(/^ARG\s+([A-Za-z_][A-Za-z0-9_]*)\s+(STRING|TEXT|NUMBER|BOOLEAN|BOOL)(?:\s+REQUIRED)?(?:\s+MAX\s+(\d+))?$/i);
6893
+ const arg = item.text.match(/^ARG\s+([A-Za-z_][A-Za-z0-9_]*)\s+(STRING|TEXT|NUMBER|BOOLEAN|BOOL)\b(.*)$/i);
6647
6894
  if (arg?.[1] && arg[2]) {
6648
- capability.args[arg[1]] = {
6649
- type: normalizeArgType(arg[2]),
6650
- ...item.text.match(/\sREQUIRED(?:\s|$)/i) ? { required: true } : {},
6651
- ...arg[3] ? { max_length: Number(arg[3]) } : {}
6652
- };
6895
+ capability.args[arg[1]] = parseArgSpec(arg[1], arg[2], arg[3] ?? "", item.line);
6653
6896
  continue;
6654
6897
  }
6655
6898
  const lookup = item.text.match(/^LOOKUP\s+([A-Za-z_][A-Za-z0-9_]*)\s+BY\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
@@ -6699,6 +6942,30 @@ function parseCapabilityBlock(block) {
6699
6942
  capability.proposal.allowedFields.push(patch[1]);
6700
6943
  continue;
6701
6944
  }
6945
+ const bound = item.text.match(/^BOUND\s+([A-Za-z_][A-Za-z0-9_]*)\s+(-?\d+(?:\.\d+)?)?\s*\.\.\s*(-?\d+(?:\.\d+)?)?$/i);
6946
+ if (bound?.[1]) {
6947
+ ensureProposal(capability, item);
6948
+ const minimum = bound[2] !== void 0 ? Number(bound[2]) : void 0;
6949
+ const maximum = bound[3] !== void 0 ? Number(bound[3]) : void 0;
6950
+ if (minimum === void 0 && maximum === void 0)
6951
+ throw dslError(item.line, 1, "BOUND_RANGE_REQUIRED", "BOUND requires minimum, maximum, or both, such as 1..2500");
6952
+ capability.proposal.numericBounds ??= {};
6953
+ capability.proposal.numericBounds[bound[1]] = {
6954
+ ...minimum !== void 0 ? { minimum } : {},
6955
+ ...maximum !== void 0 ? { maximum } : {}
6956
+ };
6957
+ continue;
6958
+ }
6959
+ const transition = item.text.match(/^TRANSITION\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*))?\s+ALLOW\s+(.+)$/i);
6960
+ if (transition?.[1] && transition[3]) {
6961
+ ensureProposal(capability, item);
6962
+ capability.proposal.transitionGuards ??= {};
6963
+ capability.proposal.transitionGuards[transition[1]] = {
6964
+ ...transition[2] ? { from_column: transition[2] } : {},
6965
+ allowed: parseTransitionAllowed(transition[3], item.line)
6966
+ };
6967
+ continue;
6968
+ }
6702
6969
  const approval = item.text.match(/^APPROVAL\s+ROLE\s+([A-Za-z_][A-Za-z0-9_.-]*)$/i);
6703
6970
  if (approval?.[1]) {
6704
6971
  ensureProposal(capability, item);
@@ -6731,6 +6998,40 @@ function parseCapabilityBlock(block) {
6731
6998
  throw dslError(block.line, 1, "PROPOSAL_PATCH_REQUIRED", `${block.name} proposal requires at least one PATCH line`);
6732
6999
  return capability;
6733
7000
  }
7001
+ function specArgsFromDsl(args) {
7002
+ return Object.fromEntries(Object.entries(args).map(([name, arg]) => {
7003
+ const { line: _line, ...spec } = arg;
7004
+ return [name, spec];
7005
+ }));
7006
+ }
7007
+ function collectDslWarnings(ast) {
7008
+ const warnings = [];
7009
+ for (const capability of ast.capabilities) {
7010
+ if (capability.kind !== "proposal" || !capability.proposal)
7011
+ continue;
7012
+ const line = capability.line ?? 1;
7013
+ if (!capability.description) {
7014
+ warnings.push({ line, column: 1, code: "DESCRIPTION_RECOMMENDED", message: `${capability.name} is a proposal capability without DESCRIPTION.` });
7015
+ }
7016
+ if (!capability.returnsHint) {
7017
+ warnings.push({ line, column: 1, code: "RETURNS_HINT_RECOMMENDED", message: `${capability.name} is a proposal capability without RETURNS HINT.` });
7018
+ }
7019
+ for (const [column, binding] of Object.entries(capability.proposal.patch)) {
7020
+ if (!binding.from_arg)
7021
+ continue;
7022
+ const arg = capability.args[binding.from_arg];
7023
+ if (arg?.type === "number" && arg.minimum === void 0 && arg.maximum === void 0 && capability.proposal.numericBounds?.[column] === void 0) {
7024
+ warnings.push({
7025
+ line: arg.line ?? line,
7026
+ column: 1,
7027
+ code: "NUMERIC_PATCH_BOUND_RECOMMENDED",
7028
+ message: `${capability.name} patches ${column} from numeric arg ${binding.from_arg} without ARG MIN/MAX or BOUND ${column}.`
7029
+ });
7030
+ }
7031
+ }
7032
+ }
7033
+ return warnings;
7034
+ }
6734
7035
  function parseWorkflowBlock(block) {
6735
7036
  const workflow = { name: block.name, context: "", allowedCapabilities: [] };
6736
7037
  for (const item of block.body) {
@@ -6788,6 +7089,76 @@ function parsePatchBinding(raw) {
6788
7089
  return { fixed: quoted[1] ?? "" };
6789
7090
  return { fixed: trimmed };
6790
7091
  }
7092
+ function parseArgSpec(name, rawType, rawOptions, line) {
7093
+ const type = normalizeArgType(rawType);
7094
+ let rest = rawOptions.trim();
7095
+ let description;
7096
+ const descriptionMatch = rest.match(/\bDESCRIPTION\s+'([^']*)'/i);
7097
+ if (descriptionMatch) {
7098
+ description = descriptionMatch[1] ?? "";
7099
+ rest = `${rest.slice(0, descriptionMatch.index)} ${rest.slice((descriptionMatch.index ?? 0) + descriptionMatch[0].length)}`.trim();
7100
+ }
7101
+ const required = /\bREQUIRED\b/i.test(rest);
7102
+ rest = rest.replace(/\bREQUIRED\b/ig, " ").trim();
7103
+ const maxLengthMatch = rest.match(/\bMAX\s+LENGTH\s+(\d+)\b/i);
7104
+ const maxLength = maxLengthMatch?.[1] ? Number(maxLengthMatch[1]) : void 0;
7105
+ if (maxLengthMatch)
7106
+ rest = `${rest.slice(0, maxLengthMatch.index)} ${rest.slice((maxLengthMatch.index ?? 0) + maxLengthMatch[0].length)}`.trim();
7107
+ const minMatch = rest.match(/\bMIN\s+(-?\d+(?:\.\d+)?)\b/i);
7108
+ const minimum = minMatch?.[1] ? Number(minMatch[1]) : void 0;
7109
+ if (minMatch)
7110
+ rest = `${rest.slice(0, minMatch.index)} ${rest.slice((minMatch.index ?? 0) + minMatch[0].length)}`.trim();
7111
+ const maxMatch = rest.match(/\bMAX\s+(-?\d+(?:\.\d+)?)\b/i);
7112
+ const maximumOrLegacyLength = maxMatch?.[1] ? Number(maxMatch[1]) : void 0;
7113
+ if (maxMatch)
7114
+ rest = `${rest.slice(0, maxMatch.index)} ${rest.slice((maxMatch.index ?? 0) + maxMatch[0].length)}`.trim();
7115
+ if (rest.trim().length > 0) {
7116
+ throw dslError(line, 1, "ARG_OPTIONS_UNSUPPORTED", `unsupported ARG options for ${name}: ${rest.trim()}`);
7117
+ }
7118
+ if (type !== "number" && minimum !== void 0) {
7119
+ throw dslError(line, 1, "ARG_MIN_REQUIRES_NUMBER", `ARG ${name} uses MIN, but MIN is only valid for NUMBER args`);
7120
+ }
7121
+ if (type === "boolean" && (maxLength !== void 0 || maximumOrLegacyLength !== void 0)) {
7122
+ throw dslError(line, 1, "ARG_MAX_INVALID_FOR_BOOLEAN", `ARG ${name} cannot use MAX or MAX LENGTH because BOOLEAN has no numeric or length bound`);
7123
+ }
7124
+ if (type === "number" && maxLength !== void 0) {
7125
+ throw dslError(line, 1, "ARG_MAX_LENGTH_REQUIRES_TEXT", `ARG ${name} uses MAX LENGTH, but MAX LENGTH is only valid for STRING/TEXT args`);
7126
+ }
7127
+ return {
7128
+ type,
7129
+ line,
7130
+ ...required ? { required: true } : {},
7131
+ ...description !== void 0 ? { description } : {},
7132
+ ...type === "number" && minimum !== void 0 ? { minimum } : {},
7133
+ ...type === "number" && maximumOrLegacyLength !== void 0 ? { maximum: maximumOrLegacyLength } : {},
7134
+ ...type !== "number" && maxLength !== void 0 ? { max_length: maxLength } : {},
7135
+ ...type !== "number" && maxLength === void 0 && maximumOrLegacyLength !== void 0 ? { max_length: maximumOrLegacyLength } : {}
7136
+ };
7137
+ }
7138
+ function parseTransitionAllowed(raw, line) {
7139
+ const allowed = {};
7140
+ for (const item of raw.split(",").map((part) => part.trim()).filter(Boolean)) {
7141
+ const match = item.match(/^(.+?)\s*->\s*(.+)$/);
7142
+ if (!match?.[1] || !match[2]) {
7143
+ throw dslError(line, 1, "TRANSITION_RULE_INVALID", `transition rule must use from -> to syntax: ${item}`);
7144
+ }
7145
+ const from = parseStateValue(match[1]);
7146
+ const toValues = match[2].split("|").map(parseStateValue).filter(Boolean);
7147
+ if (!from || toValues.length === 0) {
7148
+ throw dslError(line, 1, "TRANSITION_RULE_INVALID", `transition rule must name non-empty states: ${item}`);
7149
+ }
7150
+ allowed[from] = toValues;
7151
+ }
7152
+ if (Object.keys(allowed).length === 0) {
7153
+ throw dslError(line, 1, "TRANSITION_ALLOWED_REQUIRED", "TRANSITION requires at least one from -> to rule");
7154
+ }
7155
+ return allowed;
7156
+ }
7157
+ function parseStateValue(raw) {
7158
+ const trimmed = raw.trim();
7159
+ const quoted = trimmed.match(/^'(.*)'$/);
7160
+ return (quoted?.[1] ?? trimmed).trim();
7161
+ }
6791
7162
  function normalizeBindingSource(source) {
6792
7163
  const normalized = source.toUpperCase();
6793
7164
  if (normalized === "ENV")
@@ -6857,11 +7228,11 @@ async function startLocalUiServer(options = {}) {
6857
7228
  });
6858
7229
  }
6859
7230
  });
6860
- await new Promise((resolve, reject) => {
7231
+ await new Promise((resolve2, reject) => {
6861
7232
  server.once("error", reject);
6862
7233
  server.listen(options.port ?? 0, host, () => {
6863
7234
  server.off("error", reject);
6864
- resolve();
7235
+ resolve2();
6865
7236
  });
6866
7237
  });
6867
7238
  const address = server.address();
@@ -6874,10 +7245,10 @@ async function startLocalUiServer(options = {}) {
6874
7245
  port,
6875
7246
  token,
6876
7247
  csrfToken,
6877
- close: () => new Promise((resolve, reject) => {
7248
+ close: () => new Promise((resolve2, reject) => {
6878
7249
  server.close((error) => {
6879
7250
  if (error) reject(error);
6880
- else resolve();
7251
+ else resolve2();
6881
7252
  });
6882
7253
  })
6883
7254
  };
@@ -8720,7 +9091,7 @@ async function writeGeneratedSmokeInputFile(lookupArg, objectId, force) {
8720
9091
  `, force);
8721
9092
  }
8722
9093
  async function maybeRunGeneratedSmokeCall(input) {
8723
- const required = uniqueStrings([input.readUrlEnv, input.tenantEnv, input.principalEnv]).filter((envName) => !input.env[envName]);
9094
+ const required = uniqueStrings([input.readUrlEnv, input.tenantEnv, input.principalEnv]).filter((envName) => !envValue2(input.env, envName));
8724
9095
  if (required.length > 0) {
8725
9096
  return [
8726
9097
  "Smoke call not run yet.",
@@ -9194,12 +9565,15 @@ async function dslValidate(args) {
9194
9565
  const target2 = firstPositional(args);
9195
9566
  if (!target2) throw new Error("dsl validate requires <contract.synapsor>");
9196
9567
  const source = await fs3.readFile(target2, "utf8");
9568
+ const strict = args.includes("--strict");
9197
9569
  const result = validateAgentDsl(source);
9198
9570
  if (args.includes("--json")) {
9199
9571
  process2.stdout.write(`${JSON.stringify(result, null, 2)}
9200
9572
  `);
9201
9573
  } else if (result.ok) {
9202
9574
  process2.stdout.write(`dsl valid: ${target2}
9575
+ `);
9576
+ for (const warning of result.warnings) process2.stdout.write(`warning ${warning.line}:${warning.column} ${warning.code}: ${warning.message}
9203
9577
  `);
9204
9578
  } else {
9205
9579
  process2.stdout.write(`dsl invalid: ${target2}
@@ -9207,13 +9581,22 @@ async function dslValidate(args) {
9207
9581
  for (const error of result.errors) process2.stdout.write(`error ${error.line}:${error.column} ${error.code}: ${error.message}
9208
9582
  `);
9209
9583
  }
9210
- return result.ok ? 0 : 1;
9584
+ return result.ok && (!strict || result.warnings.length === 0) ? 0 : 1;
9211
9585
  }
9212
9586
  async function dslCompile(args) {
9213
9587
  const target2 = firstPositional(args);
9214
9588
  if (!target2) throw new Error("dsl compile requires <contract.synapsor>");
9215
9589
  const source = await fs3.readFile(target2, "utf8");
9216
- const contract = compileAgentDsl(source);
9590
+ const strict = args.includes("--strict");
9591
+ const result = compileAgentDslWithWarnings(source);
9592
+ if (strict && result.warnings.length > 0) {
9593
+ process2.stdout.write(`dsl warnings treated as errors: ${target2}
9594
+ `);
9595
+ for (const warning of result.warnings) process2.stdout.write(`warning ${warning.line}:${warning.column} ${warning.code}: ${warning.message}
9596
+ `);
9597
+ return 1;
9598
+ }
9599
+ const contract = result.contract;
9217
9600
  const output = outputArg(args);
9218
9601
  const text = `${JSON.stringify(contract, null, 2)}
9219
9602
  `;
@@ -9224,6 +9607,8 @@ async function dslCompile(args) {
9224
9607
  } else {
9225
9608
  process2.stdout.write(text);
9226
9609
  }
9610
+ for (const warning of result.warnings) process2.stderr.write(`warning ${warning.line}:${warning.column} ${warning.code}: ${warning.message}
9611
+ `);
9227
9612
  return 0;
9228
9613
  }
9229
9614
  async function contractValidate(args) {
@@ -9353,8 +9738,7 @@ function bundleReadme(contract) {
9353
9738
  }
9354
9739
  async function configValidate(args) {
9355
9740
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
9356
- const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
9357
- const result = validateRunnerCapabilityConfig(parsed);
9741
+ const result = await validateConfigFile(configPath);
9358
9742
  if (args.includes("--json")) {
9359
9743
  process2.stdout.write(`${JSON.stringify(result, null, 2)}
9360
9744
  `);
@@ -9375,6 +9759,30 @@ async function configValidate(args) {
9375
9759
  }
9376
9760
  return result.ok ? 0 : 1;
9377
9761
  }
9762
+ async function validateConfigFile(configPath) {
9763
+ const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
9764
+ const raw = validateRunnerCapabilityConfig(parsed);
9765
+ if (!raw.ok) return raw;
9766
+ try {
9767
+ const resolved = resolveRuntimeConfig(parsed, path3.dirname(path3.resolve(configPath)));
9768
+ const resolvedValidation = validateRunnerCapabilityConfig(resolved);
9769
+ return {
9770
+ ok: resolvedValidation.ok,
9771
+ errors: resolvedValidation.errors,
9772
+ warnings: [...raw.warnings, ...resolvedValidation.warnings]
9773
+ };
9774
+ } catch (error) {
9775
+ return {
9776
+ ok: false,
9777
+ errors: [{
9778
+ path: "$.contracts",
9779
+ code: "CONTRACT_RESOLUTION_FAILED",
9780
+ message: error instanceof Error ? error.message : String(error)
9781
+ }],
9782
+ warnings: raw.warnings
9783
+ };
9784
+ }
9785
+ }
9378
9786
  async function configShow(args) {
9379
9787
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
9380
9788
  const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
@@ -9438,13 +9846,49 @@ function trustedContextsForDoctor(config) {
9438
9846
  return contexts;
9439
9847
  }
9440
9848
  function envPresenceCheck(envName, message) {
9849
+ const value = envValue2(process2.env, envName);
9441
9850
  return {
9442
9851
  name: `env:${envName}`,
9443
- ok: Boolean(process2.env[envName]),
9444
- level: process2.env[envName] ? "pass" : "fail",
9445
- message: process2.env[envName] ? `${envName} is set.` : message
9852
+ ok: Boolean(value),
9853
+ level: value ? "pass" : "fail",
9854
+ message: value ? `${envName} is set.` : message
9446
9855
  };
9447
9856
  }
9857
+ function proposalWritebackResolutionDoctorCheck(config, capability) {
9858
+ const mode = capabilityWritebackMode2(capability);
9859
+ if (mode === "none") {
9860
+ return {
9861
+ name: `capability:${capability.name}:writeback-resolution`,
9862
+ ok: true,
9863
+ level: "pass",
9864
+ message: "Capability explicitly declares no local writeback; proposals are review records only."
9865
+ };
9866
+ }
9867
+ if (mode === "cloud_worker") {
9868
+ return {
9869
+ name: `capability:${capability.name}:writeback-resolution`,
9870
+ ok: true,
9871
+ level: "pass",
9872
+ message: "Capability declares cloud-worker writeback; local apply is intentionally unavailable."
9873
+ };
9874
+ }
9875
+ try {
9876
+ assertProposalWritebackResolvable(config, capability);
9877
+ return {
9878
+ name: `capability:${capability.name}:writeback-resolution`,
9879
+ ok: true,
9880
+ level: "pass",
9881
+ message: mode === "direct_sql" ? "Direct SQL writeback definition resolves to a source and writer env var name." : `App-owned handler writeback resolves to executor ${capabilityWritebackExecutor(capability)}.`
9882
+ };
9883
+ } catch (error) {
9884
+ return {
9885
+ name: `capability:${capability.name}:writeback-resolution`,
9886
+ ok: false,
9887
+ level: "fail",
9888
+ message: error instanceof Error ? error.message : String(error)
9889
+ };
9890
+ }
9891
+ }
9448
9892
  async function httpHandlerReachabilityCheck(executorName, rawUrl, timeoutMs) {
9449
9893
  try {
9450
9894
  const url = new URL(rawUrl);
@@ -9494,7 +9938,7 @@ function safeReachabilityError(error) {
9494
9938
  return "connection failed";
9495
9939
  }
9496
9940
  async function inspectConfiguredSource(input) {
9497
- if (!process2.env[input.source.read_url_env]) return;
9941
+ if (!envValue2(process2.env, input.source.read_url_env)) return;
9498
9942
  const capabilities = (input.config.capabilities ?? []).filter((capability) => capability.source === input.sourceName);
9499
9943
  const schemas = Array.from(new Set(capabilities.map((capability) => capability.target.schema)));
9500
9944
  for (const schema of schemas.length ? schemas : [void 0]) {
@@ -9837,15 +10281,15 @@ function firstRunConfigEnvChecks(config) {
9837
10281
  String(values.tenant_id_env ?? "SYNAPSOR_TENANT_ID"),
9838
10282
  String(values.principal_env ?? "SYNAPSOR_PRINCIPAL")
9839
10283
  ]) {
9840
- checks.push(process2.env[envName] ? pass(`env-${envName}`, `${envName} is set for ${contextName}.`, "Trusted tenant/principal values must come from the launcher, not the model.", "No action needed.") : warn(`env-${envName}`, `${envName} is not set for ${contextName}.`, "Trusted tenant/principal values must come from the launcher, not the model.", `Set ${envName}, or use the generated .env.example as a template.`));
10284
+ checks.push(envValue2(process2.env, envName) ? pass(`env-${envName}`, `${envName} is set for ${contextName}.`, "Trusted tenant/principal values must come from the launcher, not the model.", "No action needed.") : warn(`env-${envName}`, `${envName} is not set for ${contextName}.`, "Trusted tenant/principal values must come from the launcher, not the model.", `Set ${envName}, or use the generated .env.example as a template.`));
9841
10285
  }
9842
10286
  }
9843
10287
  for (const [sourceName, source] of Object.entries(config.sources ?? {})) {
9844
- checks.push(process2.env[source.read_url_env] ? pass(`env-${source.read_url_env}`, `${source.read_url_env} is set for ${sourceName}.`, "Configured capabilities need a read credential env var to inspect/propose against your DB.", "No action needed.") : warn(`env-${source.read_url_env}`, `${source.read_url_env} is not set for ${sourceName}.`, "Configured capabilities need a read credential env var to inspect/propose against your DB.", `Set ${source.read_url_env} before running doctor, tools preview, or mcp serve against your own database.`));
10288
+ checks.push(envValue2(process2.env, source.read_url_env) ? pass(`env-${source.read_url_env}`, `${source.read_url_env} is set for ${sourceName}.`, "Configured capabilities need a read credential env var to inspect/propose against your DB.", "No action needed.") : warn(`env-${source.read_url_env}`, `${source.read_url_env} is not set for ${sourceName}.`, "Configured capabilities need a read credential env var to inspect/propose against your DB.", `Set ${source.read_url_env} before running doctor, tools preview, or mcp serve against your own database.`));
9845
10289
  if (source.write_url_env) {
9846
- checks.push(process2.env[source.write_url_env] ? pass(`env-${source.write_url_env}`, `${source.write_url_env} is set for ${sourceName}.`, "Trusted writeback needs a separate writer credential outside the MCP client.", "No action needed.") : warn(`env-${source.write_url_env}`, `${source.write_url_env} is not set for ${sourceName}.`, "Trusted writeback needs a separate writer credential outside the MCP client.", `Set ${source.write_url_env} only when you are ready to apply an approved writeback job.`));
9847
- const readValue = process2.env[source.read_url_env];
9848
- const writeValue = process2.env[source.write_url_env];
10290
+ checks.push(envValue2(process2.env, source.write_url_env) ? pass(`env-${source.write_url_env}`, `${source.write_url_env} is set for ${sourceName}.`, "Trusted writeback needs a separate writer credential outside the MCP client.", "No action needed.") : warn(`env-${source.write_url_env}`, `${source.write_url_env} is not set for ${sourceName}.`, "Trusted writeback needs a separate writer credential outside the MCP client.", `Set ${source.write_url_env} only when you are ready to apply an approved writeback job.`));
10291
+ const readValue = envValue2(process2.env, source.read_url_env);
10292
+ const writeValue = envValue2(process2.env, source.write_url_env);
9849
10293
  if (readValue && writeValue && readValue === writeValue) {
9850
10294
  checks.push(fail(`credential-split-${sourceName}`, `Read and write env vars resolve to the same credential for ${sourceName}.`, "Read/proposal authority and writeback authority must be separated.", "Use a read-only credential for MCP reads and a separate writer credential only for trusted apply."));
9851
10295
  }
@@ -9900,11 +10344,11 @@ function fail(name, checked, why, fix) {
9900
10344
  return { name, status: "fail", checked, why, fix };
9901
10345
  }
9902
10346
  async function isPortAvailable(port) {
9903
- return new Promise((resolve) => {
10347
+ return new Promise((resolve2) => {
9904
10348
  const server = net.createServer();
9905
- server.once("error", () => resolve(false));
10349
+ server.once("error", () => resolve2(false));
9906
10350
  server.listen(port, "127.0.0.1", () => {
9907
- server.close(() => resolve(true));
10351
+ server.close(() => resolve2(true));
9908
10352
  });
9909
10353
  });
9910
10354
  }
@@ -9924,9 +10368,10 @@ async function localDoctor(args) {
9924
10368
  const allowSharedCredential = args.includes("--allow-shared-credential");
9925
10369
  const checkHandlers = args.includes("--check-handlers");
9926
10370
  const checkWriteback = args.includes("--check-writeback") || args.includes("--check-db");
9927
- const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
10371
+ const rawConfig = JSON.parse(await fs3.readFile(configPath, "utf8"));
10372
+ let parsed = rawConfig;
9928
10373
  const checks = [];
9929
- const validation = validateRunnerCapabilityConfig(parsed);
10374
+ const validation = await validateConfigFile(configPath);
9930
10375
  checks.push({
9931
10376
  name: "config-valid",
9932
10377
  ok: validation.ok,
@@ -9936,6 +10381,9 @@ async function localDoctor(args) {
9936
10381
  for (const warning of validation.warnings) {
9937
10382
  checks.push({ name: `config-warning:${warning.code}`, ok: true, level: "warn", message: warning.message });
9938
10383
  }
10384
+ if (validation.ok) {
10385
+ parsed = await readRuntimeConfig(configPath);
10386
+ }
9939
10387
  const contextsToCheck = trustedContextsForDoctor(parsed);
9940
10388
  for (const [contextName, contextValues] of contextsToCheck) {
9941
10389
  const tenantEnv = String(contextValues.tenant_id_env ?? "SYNAPSOR_TENANT_ID");
@@ -9945,14 +10393,19 @@ async function localDoctor(args) {
9945
10393
  }
9946
10394
  }
9947
10395
  const sources = parsed.sources ?? {};
10396
+ if (parsed.mode === "review") {
10397
+ for (const capability of (parsed.capabilities ?? []).filter((item) => item.kind === "proposal")) {
10398
+ checks.push(proposalWritebackResolutionDoctorCheck(parsed, capability));
10399
+ }
10400
+ }
9948
10401
  for (const [sourceName, source] of Object.entries(sources)) {
9949
10402
  checks.push(envPresenceCheck(source.read_url_env, `${source.read_url_env} is required for ${sourceName} reads.`));
9950
10403
  if (parsed.mode === "review") {
9951
10404
  if (sourceNeedsSqlWriteback(parsed, sourceName)) {
9952
10405
  if (source.write_url_env) {
9953
10406
  checks.push(envPresenceCheck(source.write_url_env, `${source.write_url_env} is required for trusted writeback in review mode.`));
9954
- const readValue = process2.env[source.read_url_env];
9955
- const writeValue = process2.env[source.write_url_env];
10407
+ const readValue = envValue2(process2.env, source.read_url_env);
10408
+ const writeValue = envValue2(process2.env, source.write_url_env);
9956
10409
  if (readValue && writeValue && readValue === writeValue) {
9957
10410
  checks.push({
9958
10411
  name: `source:${sourceName}:credential-separation`,
@@ -9966,7 +10419,7 @@ async function localDoctor(args) {
9966
10419
  } else {
9967
10420
  checks.push({ name: `source:${sourceName}:write-url-env`, ok: false, level: "fail", message: "SQL writeback proposal capabilities require write_url_env for trusted writeback." });
9968
10421
  }
9969
- const writeUrl = source.write_url_env ? process2.env[source.write_url_env] : void 0;
10422
+ const writeUrl = source.write_url_env ? envValue2(process2.env, source.write_url_env) : void 0;
9970
10423
  if (checkWriteback && writeUrl) {
9971
10424
  checks.push(...await directSqlWritebackDoctorChecks(parsed, sourceName, source, writeUrl));
9972
10425
  } else if (checkWriteback) {
@@ -9994,7 +10447,7 @@ async function localDoctor(args) {
9994
10447
  const urlEnv = String(executor.url_env ?? "");
9995
10448
  if (urlEnv) {
9996
10449
  checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
9997
- const handlerUrl = process2.env[urlEnv];
10450
+ const handlerUrl = envValue2(process2.env, urlEnv);
9998
10451
  if (checkHandlers && handlerUrl) {
9999
10452
  checks.push(await httpHandlerReachabilityCheck(executorName, handlerUrl, Number(executor.timeout_ms ?? 3e3)));
10000
10453
  } else if (!checkHandlers) {
@@ -10108,7 +10561,7 @@ async function directSqlWritebackDoctorChecks(config, sourceName, source, writeU
10108
10561
  function directSqlProposalCapabilities(config, sourceName) {
10109
10562
  return (config.capabilities ?? []).filter((capability) => {
10110
10563
  if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
10111
- return (capability.executor ?? "sql_update") === "sql_update";
10564
+ return capabilityWritebackMode2(capability) === "direct_sql";
10112
10565
  });
10113
10566
  }
10114
10567
  async function rollbackOnlyTargetProbe(engine, databaseUrl, capability) {
@@ -10299,7 +10752,7 @@ async function applyProposal(args, proposalId) {
10299
10752
  const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
10300
10753
  const dryRun = args.includes("--dry-run") || process2.env.SYNAPSOR_DRY_RUN === "true";
10301
10754
  const runnerId = optionalArg(args, "--runner") ?? process2.env.SYNAPSOR_RUNNER_ID ?? "local_runner";
10302
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
10755
+ const config = await readRuntimeConfig(configPath);
10303
10756
  const resolvedProposalId = await resolveProposalId(proposalId, storePath);
10304
10757
  const validation = validateRunnerCapabilityConfig(config);
10305
10758
  if (!validation.ok) {
@@ -10316,6 +10769,9 @@ async function applyProposal(args, proposalId) {
10316
10769
  const proposal = requireLocalProposal(store, resolvedProposalId);
10317
10770
  const capability = findProposalCapability(config, proposal);
10318
10771
  const executorName = proposalExecutorName(proposal, capability);
10772
+ if (executorName === "none" || executorName === "cloud_worker") {
10773
+ throw new Error(`proposal ${resolvedProposalId} is not locally applyable; capability ${capability.name} declares ${executorName === "none" ? "no local writeback" : "cloud-worker writeback"}.`);
10774
+ }
10319
10775
  if (executorName === "sql_update") {
10320
10776
  const job = store.createWritebackJobFromProposal(resolvedProposalId, {
10321
10777
  project_id: optionalArg(args, "--project") ?? "local",
@@ -10373,16 +10829,19 @@ async function applySqlJob(job, configPath, storePath, dryRun, env = process2.en
10373
10829
  return result;
10374
10830
  }
10375
10831
  async function resolveSqlWriteDatabaseUrl(job, configPath, env) {
10376
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
10832
+ const config = await readRuntimeConfig(configPath);
10377
10833
  const source = config.sources?.[job.source_id];
10378
10834
  const writeUrlEnv = source?.write_url_env;
10379
- if (writeUrlEnv && env[writeUrlEnv]) return env[writeUrlEnv] ?? "";
10380
- return env.SYNAPSOR_DATABASE_URL || "";
10835
+ if (writeUrlEnv) {
10836
+ const value = envValue2(env, writeUrlEnv);
10837
+ if (value) return value;
10838
+ }
10839
+ return envValue2(env, "SYNAPSOR_DATABASE_URL") || "";
10381
10840
  }
10382
10841
  function sourceNeedsSqlWriteback(config, sourceName) {
10383
10842
  return (config.capabilities ?? []).some((capability) => {
10384
10843
  if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
10385
- return (capability.executor ?? "sql_update") === "sql_update";
10844
+ return capabilityWritebackMode2(capability) === "direct_sql";
10386
10845
  });
10387
10846
  }
10388
10847
  function findProposalCapability(config, proposal) {
@@ -10401,8 +10860,12 @@ function findProposalCapability(config, proposal) {
10401
10860
  return capability;
10402
10861
  }
10403
10862
  function proposalExecutorName(proposal, capability) {
10863
+ const mode = capabilityWritebackMode2(capability);
10864
+ if (mode === "none") return "none";
10865
+ if (mode === "cloud_worker") return "cloud_worker";
10866
+ if (mode === "direct_sql") return "sql_update";
10404
10867
  const writeback2 = proposal.change_set.writeback;
10405
- return capability.executor ?? (typeof writeback2.executor === "string" ? writeback2.executor : void 0) ?? "sql_update";
10868
+ return capabilityWritebackExecutor(capability) ?? (typeof writeback2.executor === "string" ? writeback2.executor : void 0) ?? "sql_update";
10406
10869
  }
10407
10870
  function executorConfig(config, executorName) {
10408
10871
  const raw = config.executors?.[executorName];
@@ -10427,7 +10890,7 @@ async function applyHttpHandlerProposal(input) {
10427
10890
  executor: input.executorName,
10428
10891
  request: prepared.request
10429
10892
  });
10430
- const url = input.env[input.executor.url_env];
10893
+ const url = envValue2(input.env, input.executor.url_env);
10431
10894
  if (!url) throw new Error(`${input.executor.url_env} is not set`);
10432
10895
  const headers = {
10433
10896
  accept: "application/json",
@@ -10435,7 +10898,7 @@ async function applyHttpHandlerProposal(input) {
10435
10898
  "idempotency-key": prepared.request.idempotency_key
10436
10899
  };
10437
10900
  if (input.executor.auth) {
10438
- const token = input.env[input.executor.auth.token_env];
10901
+ const token = envValue2(input.env, input.executor.auth.token_env);
10439
10902
  if (!token) throw new Error(`${input.executor.auth.token_env} is not set`);
10440
10903
  headers.authorization = `Bearer ${token}`;
10441
10904
  }
@@ -10450,7 +10913,7 @@ async function applyHttpHandlerProposal(input) {
10450
10913
  headers["x-synapsor-issued-at"] = issuedAt;
10451
10914
  headers["x-synapsor-proposal-id"] = prepared.proposal.proposal_id;
10452
10915
  if (input.executor.signing_secret_env) {
10453
- const signingSecret = input.env[input.executor.signing_secret_env];
10916
+ const signingSecret = envValue2(input.env, input.executor.signing_secret_env);
10454
10917
  if (!signingSecret) throw new Error(`${input.executor.signing_secret_env} is not set`);
10455
10918
  headers["x-synapsor-signature"] = signHandlerRequestBody(requestBody, signingSecret);
10456
10919
  }
@@ -10488,7 +10951,7 @@ async function applyCommandHandlerProposal(input) {
10488
10951
  executor: input.executorName,
10489
10952
  request: prepared.request
10490
10953
  });
10491
- const commandText = input.env[input.executor.command_env];
10954
+ const commandText = envValue2(input.env, input.executor.command_env);
10492
10955
  if (!commandText) throw new Error(`${input.executor.command_env} is not set`);
10493
10956
  const [command, ...commandArgs] = splitCommand(commandText);
10494
10957
  if (!command) throw new Error(`${input.executor.command_env} did not contain a command`);
@@ -10738,7 +11201,7 @@ function safeHandlerDetails(value) {
10738
11201
  return redactConfig(value);
10739
11202
  }
10740
11203
  async function runCommandHandler(command, args, request, timeoutMs) {
10741
- return new Promise((resolve) => {
11204
+ return new Promise((resolve2) => {
10742
11205
  const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: process2.env });
10743
11206
  let stdout = "";
10744
11207
  let stderr = "";
@@ -10747,7 +11210,7 @@ async function runCommandHandler(command, args, request, timeoutMs) {
10747
11210
  if (settled) return;
10748
11211
  settled = true;
10749
11212
  clearTimeout(timeout);
10750
- resolve(body);
11213
+ resolve2(body);
10751
11214
  };
10752
11215
  const timeout = setTimeout(() => {
10753
11216
  child.kill("SIGTERM");
@@ -10772,7 +11235,7 @@ async function runCommandHandler(command, args, request, timeoutMs) {
10772
11235
  });
10773
11236
  }
10774
11237
  async function verifyLocalWritebackAuthority(job, configPath, storePath) {
10775
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
11238
+ const config = await readRuntimeConfig(configPath);
10776
11239
  const validation = validateRunnerCapabilityConfig(config);
10777
11240
  if (!validation.ok) {
10778
11241
  throw new Error(`cannot apply writeback with invalid local config: ${validation.errors.map((error) => error.code).join(", ")}`);
@@ -10790,7 +11253,7 @@ async function verifyLocalWritebackAuthority(job, configPath, storePath) {
10790
11253
  if (Date.parse(String(job.lease_expires_at)) < Date.now()) {
10791
11254
  throw new Error("writeback job lease has expired");
10792
11255
  }
10793
- const proposalCapabilities = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal" && capability.source === job.source_id);
11256
+ const proposalCapabilities = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal" && capability.source === job.source_id && capabilityWritebackMode2(capability) === "direct_sql");
10794
11257
  const matching = proposalCapabilities.find((capability) => capabilityMatchesJob(capability, job));
10795
11258
  if (!matching) {
10796
11259
  throw new Error("writeback job does not match any reviewed proposal capability in local config");
@@ -10979,7 +11442,7 @@ function formatReviewModeUp(input) {
10979
11442
  } else {
10980
11443
  lines.push(
10981
11444
  ` Streamable HTTP endpoint: http://${input.host}:${input.port}/mcp`,
10982
- ` Auth token env: ${input.authTokenEnv} (${process2.env[input.authTokenEnv] ? "set" : "missing"})`,
11445
+ ` Auth token env: ${input.authTokenEnv} (${envValue2(process2.env, input.authTokenEnv) ? "set" : "missing"})`,
10983
11446
  input.serveRequested ? input.dryRun ? " Status: dry run only; server not started." : " Status: starting after this checklist." : ` Start command: ${cliCommandName()} up --serve --config ${input.configPath} --store ${input.storePath} --port ${input.port} --auth-token-env ${input.authTokenEnv} --alias-mode ${input.aliasMode}`
10984
11447
  );
10985
11448
  }
@@ -10998,12 +11461,19 @@ function formatUpWritebackLines(config) {
10998
11461
  const proposals2 = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal");
10999
11462
  if (proposals2.length === 0) return [" - no proposal capabilities; this config is read-only from Runner's perspective"];
11000
11463
  return proposals2.map((capability) => {
11001
- const executorName = capability.executor ?? "sql_update";
11002
- if (executorName === "sql_update") {
11464
+ const mode = capabilityWritebackMode2(capability);
11465
+ if (mode === "none") {
11466
+ return ` - ${capability.name}: proposal-only; no local writeback`;
11467
+ }
11468
+ if (mode === "cloud_worker") {
11469
+ return ` - ${capability.name}: cloud-worker writeback; local apply disabled`;
11470
+ }
11471
+ if (mode === "direct_sql") {
11003
11472
  const source = config.sources?.[capability.source];
11004
11473
  const envName = source?.write_url_env ?? "SYNAPSOR_DATABASE_URL";
11005
- return ` - ${capability.name}: direct guarded one-row UPDATE via ${envName} (${process2.env[envName] ? "set" : "missing"})`;
11474
+ return ` - ${capability.name}: direct guarded one-row UPDATE via ${envName} (${envValue2(process2.env, envName) ? "set" : "missing"})`;
11006
11475
  }
11476
+ const executorName = capabilityWritebackExecutor(capability) ?? "missing_executor";
11007
11477
  const executor = config.executors?.[executorName];
11008
11478
  return ` - ${capability.name}: app-owned ${String(executor?.type ?? "executor")} ${executorName}`;
11009
11479
  });
@@ -11018,14 +11488,14 @@ function formatUpHandlerLines(config) {
11018
11488
  const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
11019
11489
  const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
11020
11490
  lines.push(` - ${name}: http_handler`);
11021
- if (urlEnv) lines.push(` url env: ${urlEnv} (${process2.env[urlEnv] ? "set" : "missing"})`);
11022
- if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${process2.env[tokenEnv] ? "set" : "missing"})`);
11023
- if (signingSecretEnv) lines.push(` signing secret env: ${signingSecretEnv} (${process2.env[signingSecretEnv] ? "set" : "missing"})`);
11491
+ if (urlEnv) lines.push(` url env: ${urlEnv} (${envValue2(process2.env, urlEnv) ? "set" : "missing"})`);
11492
+ if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${envValue2(process2.env, tokenEnv) ? "set" : "missing"})`);
11493
+ if (signingSecretEnv) lines.push(` signing secret env: ${signingSecretEnv} (${envValue2(process2.env, signingSecretEnv) ? "set" : "missing"})`);
11024
11494
  if (!signingSecretEnv) lines.push(" signing secret env: not configured (recommended unless loopback-only)");
11025
11495
  } else if (executor.type === "command_handler") {
11026
11496
  const commandEnv = typeof executor.command_env === "string" ? executor.command_env : "";
11027
11497
  lines.push(` - ${name}: command_handler`);
11028
- if (commandEnv) lines.push(` command env: ${commandEnv} (${process2.env[commandEnv] ? "set" : "missing"})`);
11498
+ if (commandEnv) lines.push(` command env: ${commandEnv} (${envValue2(process2.env, commandEnv) ? "set" : "missing"})`);
11029
11499
  }
11030
11500
  }
11031
11501
  return lines;
@@ -11069,19 +11539,33 @@ async function cloudPush(args) {
11069
11539
  if (!target2) throw new Error("cloud push requires <synapsor.contract.json>");
11070
11540
  const parsed = JSON.parse(await fs3.readFile(target2, "utf8"));
11071
11541
  const contract = normalizeContract(parsed);
11542
+ const workspace = (optionalArg(args, "--workspace") ?? optionalArg(args, "--project") ?? process2.env.SYNAPSOR_WORKSPACE_ID ?? process2.env.SYNAPSOR_PROJECT_ID ?? "").trim();
11543
+ const name = (optionalArg(args, "--name") ?? contract.metadata?.name ?? "").trim();
11544
+ const description = (optionalArg(args, "--description") ?? contract.metadata?.description ?? "").trim();
11545
+ const idempotencyKey = optionalArg(args, "--idempotency-key");
11072
11546
  const payload = {
11073
11547
  schema_version: "synapsor.cloud-contract-push.v0.1",
11074
11548
  contract,
11075
11549
  summary: contractSummary(contract),
11076
- workspace: optionalArg(args, "--workspace") ?? process2.env.SYNAPSOR_WORKSPACE_ID,
11077
- name: optionalArg(args, "--name") ?? contract.metadata?.name,
11550
+ workspace,
11551
+ name,
11552
+ description,
11553
+ source: "runner",
11554
+ source_versions: {
11555
+ "@synapsor/runner": process2.env.npm_package_version ?? "unknown"
11556
+ },
11557
+ activate: args.includes("--activate"),
11558
+ idempotency_key: idempotencyKey,
11078
11559
  pushed_at: (/* @__PURE__ */ new Date()).toISOString()
11079
11560
  };
11080
11561
  const dryRun = args.includes("--dry-run");
11081
- if (args.includes("--json")) {
11562
+ const json = args.includes("--json");
11563
+ if (dryRun && json) {
11082
11564
  process2.stdout.write(`${JSON.stringify({ ok: dryRun, dry_run: dryRun, payload }, null, 2)}
11083
11565
  `);
11084
- } else {
11566
+ return 0;
11567
+ }
11568
+ if (dryRun) {
11085
11569
  process2.stdout.write("Synapsor Cloud contract push preview\n");
11086
11570
  process2.stdout.write(`Contract: ${target2}
11087
11571
  `);
@@ -11095,17 +11579,48 @@ async function cloudPush(args) {
11095
11579
  `);
11096
11580
  process2.stdout.write(`Kept-out fields: ${payload.summary.kept_out_fields}
11097
11581
  `);
11098
- }
11099
- if (dryRun) {
11100
- if (!args.includes("--json")) process2.stdout.write("Dry run only. No Cloud upload attempted.\n");
11582
+ process2.stdout.write("Dry run only. No Cloud upload attempted.\n");
11101
11583
  return 0;
11102
11584
  }
11103
- const apiUrl = optionalArg(args, "--api-url") ?? process2.env.SYNAPSOR_CLOUD_BASE_URL;
11104
- const token = optionalArg(args, "--token") ?? process2.env.SYNAPSOR_CLOUD_TOKEN ?? process2.env.SYNAPSOR_RUNNER_TOKEN;
11585
+ const apiUrl = (optionalArg(args, "--api-url") ?? process2.env.SYNAPSOR_CLOUD_BASE_URL ?? "").trim();
11586
+ const token = (optionalArg(args, "--token") ?? process2.env.SYNAPSOR_CLOUD_TOKEN ?? process2.env.SYNAPSOR_RUNNER_TOKEN ?? "").trim();
11587
+ if (!workspace) {
11588
+ throw new Error("cloud push upload requires --workspace <project_id> or SYNAPSOR_WORKSPACE_ID/SYNAPSOR_PROJECT_ID.");
11589
+ }
11105
11590
  if (!apiUrl || !token) {
11106
- throw new Error("cloud push upload requires --dry-run, or real --api-url plus --token/SYNAPSOR_CLOUD_TOKEN after the Cloud registry endpoint is available.");
11591
+ throw new Error("cloud push upload requires --api-url plus --token/SYNAPSOR_CLOUD_TOKEN. Use --dry-run for local validation without a network call.");
11592
+ }
11593
+ const response = await postCloudContractPush({
11594
+ apiUrl,
11595
+ token,
11596
+ workspace,
11597
+ payload,
11598
+ idempotencyKey
11599
+ });
11600
+ if (json) {
11601
+ process2.stdout.write(`${JSON.stringify(response, null, 2)}
11602
+ `);
11603
+ return 0;
11107
11604
  }
11108
- throw new Error("cloud push upload is not wired to a Cloud registry endpoint yet. Use --dry-run for local validation; do not treat this as uploaded.");
11605
+ const contractId = cloudStringField(response, "contract_id") || cloudStringField(response.contract, "contract_id");
11606
+ const versionId = cloudStringField(response, "contract_version_id") || cloudStringField(response.version, "contract_version_id");
11607
+ const digest = cloudStringField(response, "digest") || cloudStringField(response.version, "digest");
11608
+ const status = cloudStringField(response, "status") || cloudStringField(response.version, "status") || "stored";
11609
+ process2.stdout.write("Synapsor Cloud contract push complete\n");
11610
+ process2.stdout.write(`Workspace: ${workspace}
11611
+ `);
11612
+ if (contractId) process2.stdout.write(`Contract id: ${contractId}
11613
+ `);
11614
+ if (versionId) process2.stdout.write(`Version id: ${versionId}
11615
+ `);
11616
+ if (digest) process2.stdout.write(`Digest: ${digest}
11617
+ `);
11618
+ process2.stdout.write(`Status: ${status}
11619
+ `);
11620
+ const registryUrl = cloudStringField(response, "registry_url");
11621
+ if (registryUrl) process2.stdout.write(`Registry: ${registryUrl}
11622
+ `);
11623
+ return 0;
11109
11624
  }
11110
11625
  function contractSummary(contract) {
11111
11626
  return {
@@ -11116,6 +11631,81 @@ function contractSummary(contract) {
11116
11631
  kept_out_fields: contract.capabilities.reduce((count, capability) => count + (capability.kept_out_fields?.length ?? 0), 0)
11117
11632
  };
11118
11633
  }
11634
+ async function postCloudContractPush(input) {
11635
+ let endpoint;
11636
+ try {
11637
+ endpoint = new URL(`/v1/control/projects/${encodeURIComponent(input.workspace)}/agent-contracts`, input.apiUrl.endsWith("/") ? input.apiUrl : `${input.apiUrl}/`);
11638
+ } catch {
11639
+ throw new Error("cloud push upload failed: --api-url/SYNAPSOR_CLOUD_BASE_URL is not a valid URL.");
11640
+ }
11641
+ const headers = {
11642
+ accept: "application/json",
11643
+ authorization: `Bearer ${input.token}`,
11644
+ "content-type": "application/json",
11645
+ "user-agent": "synapsor-runner-cloud-push"
11646
+ };
11647
+ if (input.idempotencyKey) headers["idempotency-key"] = input.idempotencyKey;
11648
+ const controller = new AbortController();
11649
+ const timeout = setTimeout(() => controller.abort(), 15e3);
11650
+ let response;
11651
+ try {
11652
+ response = await fetch(endpoint, {
11653
+ method: "POST",
11654
+ headers,
11655
+ body: JSON.stringify(input.payload),
11656
+ signal: controller.signal
11657
+ });
11658
+ } catch (error) {
11659
+ const reason = error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError") ? "request timed out" : "network request failed";
11660
+ throw new Error(`cloud push upload failed: ${reason}. Check --api-url/SYNAPSOR_CLOUD_BASE_URL and network connectivity.`);
11661
+ } finally {
11662
+ clearTimeout(timeout);
11663
+ }
11664
+ const text = await response.text();
11665
+ const body = parseCloudResponseJson(text);
11666
+ if (!response.ok || body.ok === false) {
11667
+ throw new Error(cloudPushErrorMessage(response.status, body));
11668
+ }
11669
+ return body;
11670
+ }
11671
+ function parseCloudResponseJson(text) {
11672
+ if (!text.trim()) return {};
11673
+ try {
11674
+ const parsed = JSON.parse(text);
11675
+ return isRecord7(parsed) ? parsed : { value: parsed };
11676
+ } catch {
11677
+ return { raw: text.slice(0, 500) };
11678
+ }
11679
+ }
11680
+ function cloudPushErrorMessage(status, body) {
11681
+ const code = typeof body.error === "string" && body.error ? body.error : `http_${status}`;
11682
+ const detail = cloudValidationDetail(body);
11683
+ if (status === 401) return `cloud push upload failed: token is missing, invalid, or expired (${code}).`;
11684
+ if (status === 403) return `cloud push upload failed: token does not have permission to write this workspace (${code}).`;
11685
+ if (status === 404) return `cloud push upload failed: Cloud API URL or workspace was not found (${code}).`;
11686
+ if (status === 409) return `cloud push upload failed: registry conflict (${code}).${detail}`;
11687
+ if (status === 422) return `cloud push upload failed: Cloud rejected the contract (${code}).${detail}`;
11688
+ if (status >= 500) return `cloud push upload failed: Cloud returned HTTP ${status} (${code}).`;
11689
+ return `cloud push upload failed: Cloud returned HTTP ${status} (${code}).${detail}`;
11690
+ }
11691
+ function cloudValidationDetail(body) {
11692
+ const errors = Array.isArray(body.errors) ? body.errors : [];
11693
+ if (!errors.length) {
11694
+ const message = typeof body.message === "string" ? body.message : "";
11695
+ return message ? ` ${message}` : "";
11696
+ }
11697
+ const rendered = errors.slice(0, 3).map((issue) => {
11698
+ if (!isRecord7(issue)) return String(issue);
11699
+ const path4 = typeof issue.path === "string" ? issue.path : "$";
11700
+ const code = typeof issue.code === "string" ? issue.code : "validation_error";
11701
+ const message = typeof issue.message === "string" ? issue.message : "";
11702
+ return `${path4} ${code}${message ? `: ${message}` : ""}`;
11703
+ }).join("; ");
11704
+ return ` ${rendered}`;
11705
+ }
11706
+ function cloudStringField(value, key) {
11707
+ return isRecord7(value) && typeof value[key] === "string" ? value[key] : "";
11708
+ }
11119
11709
  async function cloudConnect(args) {
11120
11710
  const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG ?? "synapsor.cloud.json";
11121
11711
  const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
@@ -11126,8 +11716,8 @@ async function cloudConnect(args) {
11126
11716
  }
11127
11717
  const baseUrlEnv = parsed.cloud.base_url_env || "SYNAPSOR_CLOUD_BASE_URL";
11128
11718
  const tokenEnv = parsed.cloud.runner_token_env || "SYNAPSOR_RUNNER_TOKEN";
11129
- const baseUrl = process2.env[baseUrlEnv];
11130
- const runnerToken = process2.env[tokenEnv];
11719
+ const baseUrl = envValue2(process2.env, baseUrlEnv);
11720
+ const runnerToken = envValue2(process2.env, tokenEnv);
11131
11721
  const missing = [baseUrl ? "" : baseUrlEnv, runnerToken ? "" : tokenEnv].filter(Boolean);
11132
11722
  if (missing.length > 0) {
11133
11723
  process2.stdout.write(`missing environment variables: ${missing.join(", ")}
@@ -11303,7 +11893,7 @@ async function writebackDoctor(args) {
11303
11893
  let ok = true;
11304
11894
  for (const [sourceName, source] of sqlSources) {
11305
11895
  const writeEnv = source.write_url_env;
11306
- const writeUrl = writeEnv ? process2.env[writeEnv] : void 0;
11896
+ const writeUrl = writeEnv ? envValue2(process2.env, writeEnv) : void 0;
11307
11897
  lines.push(`Source: ${sourceName}`);
11308
11898
  lines.push(` engine: ${source.engine}`);
11309
11899
  lines.push(` writer env: ${writeEnv ?? "(missing write_url_env)"}`);
@@ -11929,13 +12519,13 @@ async function mcpServeHttp(args) {
11929
12519
  throw error;
11930
12520
  }
11931
12521
  process2.stderr.write("Press Ctrl+C to stop.\n");
11932
- await new Promise((resolve) => {
12522
+ await new Promise((resolve2) => {
11933
12523
  const stop = async () => {
11934
12524
  process2.off("SIGINT", stop);
11935
12525
  process2.off("SIGTERM", stop);
11936
12526
  await server.close();
11937
12527
  await releaseLease();
11938
- resolve();
12528
+ resolve2();
11939
12529
  };
11940
12530
  process2.once("SIGINT", stop);
11941
12531
  process2.once("SIGTERM", stop);
@@ -11977,13 +12567,13 @@ async function mcpServeStreamableHttp(args) {
11977
12567
  throw error;
11978
12568
  }
11979
12569
  process2.stderr.write("Press Ctrl+C to stop.\n");
11980
- await new Promise((resolve) => {
12570
+ await new Promise((resolve2) => {
11981
12571
  const stop = async () => {
11982
12572
  process2.off("SIGINT", stop);
11983
12573
  process2.off("SIGTERM", stop);
11984
12574
  await server.close();
11985
12575
  await releaseLease();
11986
- resolve();
12576
+ resolve2();
11987
12577
  };
11988
12578
  process2.once("SIGINT", stop);
11989
12579
  process2.once("SIGTERM", stop);
@@ -12989,7 +13579,7 @@ function isRunnerConfigLike(value) {
12989
13579
  }
12990
13580
  async function fetchRemoteMcpTools(target2, args, timeoutMs) {
12991
13581
  const bearerEnv = optionalArg(args, "--bearer-env") ?? "SYNAPSOR_MCP_AUDIT_BEARER";
12992
- const bearer = process2.env[bearerEnv];
13582
+ const bearer = envValue2(process2.env, bearerEnv);
12993
13583
  const controller = new AbortController();
12994
13584
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
12995
13585
  try {
@@ -13015,7 +13605,7 @@ async function fetchRemoteMcpTools(target2, args, timeoutMs) {
13015
13605
  async function fetchStdioMcpTools(commandText, timeoutMs) {
13016
13606
  const [command, ...commandArgs] = splitCommand(commandText);
13017
13607
  if (!command) throw new Error("mcp audit stdio target requires a command");
13018
- return new Promise((resolve, reject) => {
13608
+ return new Promise((resolve2, reject) => {
13019
13609
  const child = spawn(command, commandArgs, {
13020
13610
  env: process2.env,
13021
13611
  stdio: ["pipe", "pipe", "pipe"]
@@ -13053,7 +13643,7 @@ async function fetchStdioMcpTools(commandText, timeoutMs) {
13053
13643
  reject(new Error(`mcp audit stdio tools/list response not found${stderr ? `: ${stderr.slice(0, 240)}` : ""}`));
13054
13644
  return;
13055
13645
  }
13056
- resolve(response);
13646
+ resolve2(response);
13057
13647
  });
13058
13648
  child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "synapsor-mcp-audit", version: "0.1.0" } } })}
13059
13649
  `);
@@ -13152,12 +13742,12 @@ async function ui(args) {
13152
13742
  process2.stdout.write("Opening the local review UI in your browser when a desktop opener is available.\n");
13153
13743
  }
13154
13744
  process2.stdout.write("Approval and rejection actions require the per-run local session plus CSRF token. Press Ctrl+C to stop.\n");
13155
- await new Promise((resolve) => {
13745
+ await new Promise((resolve2) => {
13156
13746
  const stop = async () => {
13157
13747
  process2.off("SIGINT", stop);
13158
13748
  process2.off("SIGTERM", stop);
13159
13749
  await server.close();
13160
- resolve();
13750
+ resolve2();
13161
13751
  };
13162
13752
  process2.once("SIGINT", stop);
13163
13753
  process2.once("SIGTERM", stop);
@@ -13670,7 +14260,7 @@ async function eventsTail(args) {
13670
14260
  }
13671
14261
  const seen = /* @__PURE__ */ new Set();
13672
14262
  await printOnce(seen);
13673
- await new Promise((resolve) => {
14263
+ await new Promise((resolve2) => {
13674
14264
  const timer = setInterval(() => {
13675
14265
  void printOnce(seen).catch((error) => {
13676
14266
  process2.stderr.write(`events tail error: ${safeErrorMessage(error)}
@@ -13681,7 +14271,7 @@ async function eventsTail(args) {
13681
14271
  clearInterval(timer);
13682
14272
  process2.off("SIGINT", stop);
13683
14273
  process2.off("SIGTERM", stop);
13684
- resolve();
14274
+ resolve2();
13685
14275
  };
13686
14276
  process2.once("SIGINT", stop);
13687
14277
  process2.once("SIGTERM", stop);
@@ -13690,7 +14280,7 @@ async function eventsTail(args) {
13690
14280
  }
13691
14281
  async function eventsWebhook(args) {
13692
14282
  assertKnownOptions(args, eventWebhookAllowedOptions, "events webhook");
13693
- const url = optionalArg(args, "--url") ?? envValue(optionalArg(args, "--url-env"));
14283
+ const url = optionalArg(args, "--url") ?? envValue2(optionalArg(args, "--url-env"));
13694
14284
  if (!url) throw new Error("events webhook requires --url <https://...> or --url-env <ENV>");
13695
14285
  const endpoint = new URL(url);
13696
14286
  if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("events webhook URL must use http or https");
@@ -13704,7 +14294,7 @@ async function eventsWebhook(args) {
13704
14294
  const sinceEventId = optionalPositiveIntegerArg(args, "--since-event-id");
13705
14295
  if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
13706
14296
  if (!Number.isFinite(timeoutMs) || timeoutMs < 250) throw new Error("--timeout-ms must be at least 250");
13707
- const token = envValue(optionalArg(args, "--auth-token-env"));
14297
+ const token = envValue2(optionalArg(args, "--auth-token-env"));
13708
14298
  const filters = eventFiltersFromArgs(args);
13709
14299
  const seen = /* @__PURE__ */ new Set();
13710
14300
  const pushOnce = async () => {
@@ -13739,7 +14329,7 @@ async function eventsWebhook(args) {
13739
14329
  `);
13740
14330
  }
13741
14331
  if (!follow) return 0;
13742
- await new Promise((resolve) => {
14332
+ await new Promise((resolve2) => {
13743
14333
  const timer = setInterval(() => {
13744
14334
  void pushOnce().catch((error) => {
13745
14335
  process2.stderr.write(`events webhook error: ${safeErrorMessage(error)}
@@ -13750,7 +14340,7 @@ async function eventsWebhook(args) {
13750
14340
  clearInterval(timer);
13751
14341
  process2.off("SIGINT", stop);
13752
14342
  process2.off("SIGTERM", stop);
13753
- resolve();
14343
+ resolve2();
13754
14344
  };
13755
14345
  process2.once("SIGINT", stop);
13756
14346
  process2.once("SIGTERM", stop);
@@ -14124,9 +14714,19 @@ function optionalPositiveIntegerArg(args, flag) {
14124
14714
  if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`);
14125
14715
  return parsed;
14126
14716
  }
14127
- function envValue(name) {
14128
- if (!name) return void 0;
14129
- return process2.env[name];
14717
+ function envValue2(first, second) {
14718
+ if (typeof first === "string" || first === void 0) {
14719
+ if (!first) return void 0;
14720
+ return trimmedEnvValue(process2.env, first);
14721
+ }
14722
+ if (!second) return void 0;
14723
+ return trimmedEnvValue(first, second);
14724
+ }
14725
+ function trimmedEnvValue(env, name) {
14726
+ const value = env[name];
14727
+ if (value === void 0) return void 0;
14728
+ const trimmed = value.trim();
14729
+ return trimmed.length > 0 ? trimmed : void 0;
14130
14730
  }
14131
14731
  function linkedProposalFilter(args, store, options = {}) {
14132
14732
  const noLinkedProposal = "__synapsor_no_linked_proposal__";
@@ -14346,11 +14946,11 @@ async function waitForReferenceDemoDatabase() {
14346
14946
  stdio: "pipe"
14347
14947
  });
14348
14948
  if (result.status === 0) {
14349
- await new Promise((resolve) => setTimeout(resolve, 500));
14949
+ await new Promise((resolve2) => setTimeout(resolve2, 500));
14350
14950
  return;
14351
14951
  }
14352
14952
  last = result.stderr || result.stdout || `exit ${result.status}`;
14353
- await new Promise((resolve) => setTimeout(resolve, 1e3));
14953
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
14354
14954
  }
14355
14955
  throw new Error(`demo database did not become ready: ${last}`);
14356
14956
  }
@@ -14416,6 +15016,7 @@ function firstPositional(args) {
14416
15016
  const flagsWithValues = /* @__PURE__ */ new Set([
14417
15017
  "--allowed-columns",
14418
15018
  "--approval-role",
15019
+ "--api-url",
14419
15020
  "--actor",
14420
15021
  "--action",
14421
15022
  "--auth-token-env",
@@ -14426,6 +15027,7 @@ function firstPositional(args) {
14426
15027
  "--conflict-column",
14427
15028
  "--cors-origin",
14428
15029
  "--database-url-env",
15030
+ "--description",
14429
15031
  "--destination",
14430
15032
  "--engine",
14431
15033
  "--evidence",
@@ -14481,10 +15083,12 @@ function firstPositional(args) {
14481
15083
  "--tenant-env",
14482
15084
  "--tenant-key",
14483
15085
  "--timeout-ms",
15086
+ "--token",
14484
15087
  "--to",
14485
15088
  "--transition-guard",
14486
15089
  "--url",
14487
15090
  "--visible-columns",
15091
+ "--workspace",
14488
15092
  "--writeback-job",
14489
15093
  "--write-url-env"
14490
15094
  ]);