@synapsor/runner 0.1.6 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@
4
4
 
5
5
  No unreleased changes yet.
6
6
 
7
+ ## 0.1.7
8
+
9
+ ### Contract Writeback Resolution
10
+
11
+ - Fixes contract-authored proposal capabilities loaded through `contracts: []`
12
+ so `apply` resolves the same reviewed capability catalog used by serve,
13
+ tools, propose, and doctor.
14
+ - Rejects duplicate capability names across embedded runner config and
15
+ referenced contracts instead of silently shadowing a safety contract.
16
+ - Preserves canonical contract writeback modes, including direct SQL,
17
+ app-owned handler, cloud-worker, and proposal-only/no-local-writeback
18
+ semantics.
19
+ - Fails broken applyable writeback definitions at propose/doctor time before a
20
+ human approves a proposal.
21
+ - Creates local store parent directories automatically and trims env-derived
22
+ URLs, tokens, and trusted context values before use.
23
+
7
24
  ## 0.1.5
8
25
 
9
26
  ### Contract Authoring Front Door
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAYA,OAAO,EAAqG,KAAK,WAAW,EAA2F,MAAM,6BAA6B,CAAC;AAoB3P,OAAO,EAA6G,KAAK,YAAY,EAAwB,MAAM,2BAA2B,CAAC;AAE/L,OAAO,EAOL,KAAK,gBAAgB,EAEtB,MAAM,mCAAmC,CAAC;AA6U3C,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAqD1D;AA+DD,KAAK,SAAS,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE9E,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE;IACP,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;CACvC,GACL,OAAO,CAAC,MAAM,CAAC,CA2TjB;AA8mED,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAM/H"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAYA,OAAO,EAAoN,KAAK,WAAW,EAA2F,MAAM,6BAA6B,CAAC;AAoB1W,OAAO,EAA6G,KAAK,YAAY,EAAwB,MAAM,2BAA2B,CAAC;AAE/L,OAAO,EAOL,KAAK,gBAAgB,EAEtB,MAAM,mCAAmC,CAAC;AA6U3C,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAqD1D;AA+DD,KAAK,SAAS,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE9E,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE;IACP,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;CACvC,GACL,OAAO,CAAC,MAAM,CAAC,CA2TjB;AAyrED,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAS/H"}
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) {
@@ -889,7 +897,21 @@ function validateCapabilities(value, sources, contexts, executors, mode, strict,
889
897
  const sourceNames = isRecord(sources) ? new Set(Object.keys(sources)) : /* @__PURE__ */ new Set();
890
898
  const contextNames = isRecord(contexts) ? new Set(Object.keys(contexts)) : /* @__PURE__ */ new Set();
891
899
  const executorNames = isRecord(executors) ? new Set(Object.keys(executors)) : /* @__PURE__ */ new Set();
900
+ const capabilityNames = /* @__PURE__ */ new Map();
892
901
  value.forEach((capability, index) => validateCapability(capability, index, sourceNames, contextNames, executorNames, strict, errors, warnings));
902
+ value.forEach((capability, index) => {
903
+ if (!isRecord(capability) || !isQualifiedName(capability.name)) return;
904
+ const previous = capabilityNames.get(capability.name);
905
+ if (previous !== void 0) {
906
+ errors.push({
907
+ path: `$.capabilities[${index}].name`,
908
+ code: "DUPLICATE_CAPABILITY_NAME",
909
+ message: `Capability ${capability.name} is already defined at $.capabilities[${previous}]. Capability names must be unique.`
910
+ });
911
+ return;
912
+ }
913
+ capabilityNames.set(capability.name, index);
914
+ });
893
915
  }
894
916
  function validateCapability(value, index, sourceNames, contextNames, executorNames, strict, errors, warnings) {
895
917
  const path4 = `$.capabilities[${index}]`;
@@ -923,6 +945,7 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
923
945
  errors.push({ path: `${path4}.executor`, code: "UNKNOWN_EXECUTOR", message: "Capability executor must be sql_update or reference a configured executor." });
924
946
  }
925
947
  }
948
+ validateCapabilityWriteback(value, path4, executorNames, strict, errors);
926
949
  validateTarget(value.target, `${path4}.target`, strict, errors, warnings);
927
950
  validateArgs(value.args, `${path4}.args`, strict, errors);
928
951
  validateLookup(value.lookup, `${path4}.lookup`, strict, errors);
@@ -932,6 +955,40 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
932
955
  }
933
956
  if (value.kind === "proposal") {
934
957
  validateProposalCapability(value, path4, strict, errors);
958
+ } else if (value.writeback !== void 0) {
959
+ errors.push({ path: `${path4}.writeback`, code: "WRITEBACK_ONLY_FOR_PROPOSAL", message: "writeback is only valid on proposal capabilities." });
960
+ }
961
+ }
962
+ function validateCapabilityWriteback(capability, path4, executorNames, strict, errors) {
963
+ if (capability.writeback === void 0) return;
964
+ if (!isRecord(capability.writeback)) {
965
+ errors.push({ path: `${path4}.writeback`, code: "WRITEBACK_NOT_OBJECT", message: "writeback must be an object." });
966
+ return;
967
+ }
968
+ if (strict) checkUnknownKeys(capability.writeback, WRITEBACK_KEYS, `${path4}.writeback`, errors);
969
+ if (!WRITEBACK_MODES.has(String(capability.writeback.mode))) {
970
+ errors.push({ path: `${path4}.writeback.mode`, code: "INVALID_WRITEBACK_MODE", message: "writeback.mode must be direct_sql, app_handler, cloud_worker, or none." });
971
+ return;
972
+ }
973
+ const mode = String(capability.writeback.mode);
974
+ const executor = isNonEmptyString(capability.writeback.executor) ? capability.writeback.executor : isNonEmptyString(capability.executor) ? capability.executor : void 0;
975
+ if (mode === "direct_sql") {
976
+ if (executor !== void 0 && executor !== "sql_update") {
977
+ errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_MISMATCH", message: "direct_sql writeback cannot name an app-owned executor." });
978
+ }
979
+ return;
980
+ }
981
+ if (mode === "none") {
982
+ if (executor !== void 0) {
983
+ errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_MISMATCH", message: "WRITEBACK NONE must not name an executor." });
984
+ }
985
+ return;
986
+ }
987
+ if (mode === "cloud_worker") return;
988
+ if (!executor || executor === "sql_update") {
989
+ errors.push({ path: `${path4}.writeback.executor`, code: "WRITEBACK_EXECUTOR_REQUIRED", message: "app_handler writeback must name a configured executor." });
990
+ } else if (!executorNames.has(executor)) {
991
+ errors.push({ path: `${path4}.writeback.executor`, code: "UNKNOWN_EXECUTOR", message: "app_handler writeback executor must reference a configured executor." });
935
992
  }
936
993
  }
937
994
  function validateTarget(value, path4, strict, errors, warnings) {
@@ -1250,6 +1307,8 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
1250
1307
 
1251
1308
  // packages/proposal-store/src/index.ts
1252
1309
  import { DatabaseSync } from "node:sqlite";
1310
+ import { mkdirSync } from "node:fs";
1311
+ import { dirname, resolve } from "node:path";
1253
1312
  var ProposalStoreError = class extends Error {
1254
1313
  constructor(code, message) {
1255
1314
  super(message);
@@ -1263,6 +1322,9 @@ var ProposalStore = class {
1263
1322
  path;
1264
1323
  constructor(path4 = ":memory:") {
1265
1324
  this.path = path4;
1325
+ if (path4 !== ":memory:") {
1326
+ mkdirSync(dirname(resolve(path4)), { recursive: true });
1327
+ }
1266
1328
  this.db = new DatabaseSync(path4);
1267
1329
  this.migrate();
1268
1330
  }
@@ -1952,6 +2014,10 @@ var ProposalStore = class {
1952
2014
  if (changeSet.writeback.mode !== "trusted_worker_required") {
1953
2015
  throw new ProposalStoreError("WRITEBACK_NOT_REQUIRED", `proposal ${proposalId} uses ${changeSet.writeback.mode}`);
1954
2016
  }
2017
+ const writebackExecutor = changeSet.writeback.executor;
2018
+ if (typeof writebackExecutor === "string" && writebackExecutor !== "sql_update" && writebackExecutor !== "trusted_worker_required") {
2019
+ throw new ProposalStoreError("WRITEBACK_NOT_DIRECT_SQL", `proposal ${proposalId} uses app-owned or non-local writeback executor ${writebackExecutor}`);
2020
+ }
1955
2021
  if (changeSet.source.kind !== "external_postgres" && changeSet.source.kind !== "external_mysql") {
1956
2022
  throw new ProposalStoreError("WRITEBACK_TARGET_NOT_EXTERNAL", `proposal ${proposalId} targets ${changeSet.source.kind}`);
1957
2023
  }
@@ -2755,7 +2821,7 @@ var PROPOSAL_KEYS = /* @__PURE__ */ new Set(["action", "allowed_fields", "patch"
2755
2821
  var PATCH_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
2756
2822
  var CONFLICT_GUARD_KEYS2 = /* @__PURE__ */ new Set(["column", "weak_guard_ack"]);
2757
2823
  var APPROVAL_KEYS2 = /* @__PURE__ */ new Set(["mode", "required_role"]);
2758
- var WRITEBACK_KEYS = /* @__PURE__ */ new Set(["mode", "executor", "idempotency_key"]);
2824
+ var WRITEBACK_KEYS2 = /* @__PURE__ */ new Set(["mode", "executor", "idempotency_key"]);
2759
2825
  var WORKFLOW_KEYS = /* @__PURE__ */ new Set(["name", "description", "context", "allowed_capabilities", "required_evidence", "approval", "settlement", "replay"]);
2760
2826
  var POLICY_KEYS = /* @__PURE__ */ new Set(["name", "kind", "mode", "rules"]);
2761
2827
  var EVIDENCE_RECORD_KEYS = /* @__PURE__ */ new Set(["handle", "capability", "query_fingerprint", "items"]);
@@ -3057,7 +3123,7 @@ function validateProposalAction(value, path4, errors) {
3057
3123
  if (value.approval !== void 0)
3058
3124
  validateNestedObject(value.approval, APPROVAL_KEYS2, `${path4}.approval`, errors);
3059
3125
  if (value.writeback !== void 0) {
3060
- validateNestedObject(value.writeback, WRITEBACK_KEYS, `${path4}.writeback`, errors);
3126
+ validateNestedObject(value.writeback, WRITEBACK_KEYS2, `${path4}.writeback`, errors);
3061
3127
  if (isRecord3(value.writeback) && !["direct_sql", "app_handler", "cloud_worker", "none"].includes(String(value.writeback.mode)))
3062
3128
  errors.push({ path: `${path4}.writeback.mode`, code: "INVALID_WRITEBACK_MODE", message: "writeback.mode must be direct_sql, app_handler, cloud_worker, or none." });
3063
3129
  }
@@ -3296,20 +3362,31 @@ function loadRuntimeConfigFromFile(configPath = process.env.SYNAPSOR_MCP_CONFIG
3296
3362
  }
3297
3363
  function resolveRuntimeConfig(config, baseDir = process.cwd()) {
3298
3364
  if (!Array.isArray(config.contracts) || config.contracts.length === 0) return config;
3365
+ const seenCapabilities = /* @__PURE__ */ new Map();
3366
+ for (const [index, capability] of (config.capabilities ?? []).entries()) {
3367
+ rememberCapabilityName(seenCapabilities, capability.name, `embedded capabilities[${index}]`);
3368
+ }
3299
3369
  const resolved = {
3300
3370
  ...config,
3301
3371
  contexts: { ...config.contexts ?? {} },
3302
3372
  capabilities: [...config.capabilities ?? []]
3303
3373
  };
3304
- for (const contractPath of config.contracts) {
3374
+ for (const [contractIndex, contractPath] of config.contracts.entries()) {
3305
3375
  const fullPath = path.resolve(baseDir, contractPath);
3306
3376
  const contract = normalizeContract(JSON.parse(fs.readFileSync(fullPath, "utf8")));
3307
- mergeContractIntoRuntimeConfig(resolved, contract);
3377
+ mergeContractIntoRuntimeConfig(resolved, contract, `contracts[${contractIndex}] ${contractPath}`, seenCapabilities);
3308
3378
  }
3309
3379
  delete resolved.contracts;
3310
3380
  return resolved;
3311
3381
  }
3312
- function mergeContractIntoRuntimeConfig(config, contract) {
3382
+ function rememberCapabilityName(seen, name, origin) {
3383
+ const previous = seen.get(name);
3384
+ if (previous) {
3385
+ throw new Error(`Duplicate capability ${name}: ${origin} conflicts with ${previous}. Capability names must be unique across embedded runner config and referenced contracts.`);
3386
+ }
3387
+ seen.set(name, origin);
3388
+ }
3389
+ function mergeContractIntoRuntimeConfig(config, contract, origin, seenCapabilities) {
3313
3390
  const resources = new Map((contract.resources ?? []).map((resource) => [resource.name, resource]));
3314
3391
  for (const context of contract.contexts) {
3315
3392
  if (!config.contexts) config.contexts = {};
@@ -3320,7 +3397,8 @@ function mergeContractIntoRuntimeConfig(config, contract) {
3320
3397
  if (context) config.trusted_context = runtimeContextFromSpec(context);
3321
3398
  }
3322
3399
  if (!config.capabilities) config.capabilities = [];
3323
- for (const capability of contract.capabilities) {
3400
+ for (const [capabilityIndex, capability] of contract.capabilities.entries()) {
3401
+ rememberCapabilityName(seenCapabilities, capability.name, `${origin} capabilities[${capabilityIndex}]`);
3324
3402
  config.capabilities.push(runtimeCapabilityFromSpec(capability, resources, config));
3325
3403
  }
3326
3404
  }
@@ -3364,7 +3442,11 @@ function runtimeCapabilityFromSpec(capability, resources, config) {
3364
3442
  runtime.allowed_columns = capability.proposal.allowed_fields;
3365
3443
  runtime.conflict_guard = capability.proposal.conflict_guard;
3366
3444
  runtime.approval = capability.proposal.approval;
3367
- if (capability.proposal.writeback?.mode && capability.proposal.writeback.mode !== "direct_sql" && capability.proposal.writeback.executor) {
3445
+ runtime.writeback = {
3446
+ mode: capability.proposal.writeback?.mode ?? "direct_sql",
3447
+ ...capability.proposal.writeback?.executor ? { executor: capability.proposal.writeback.executor } : {}
3448
+ };
3449
+ if (capability.proposal.writeback?.executor) {
3368
3450
  runtime.executor = capability.proposal.writeback.executor;
3369
3451
  }
3370
3452
  }
@@ -3522,11 +3604,11 @@ async function serveStdio(options = {}) {
3522
3604
  const transport = new StdioServerTransport();
3523
3605
  await server.connect(transport);
3524
3606
  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) => {
3607
+ await new Promise((resolve2) => {
3526
3608
  const previousOnClose = transport.onclose;
3527
3609
  const close = () => {
3528
3610
  runtime.close();
3529
- resolve();
3611
+ resolve2();
3530
3612
  };
3531
3613
  transport.onclose = () => {
3532
3614
  previousOnClose?.();
@@ -3545,7 +3627,7 @@ async function startHttpMcpServer(options = {}) {
3545
3627
  if (devNoAuth && !isLoopbackHost(host)) {
3546
3628
  throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
3547
3629
  }
3548
- const authToken = devNoAuth ? void 0 : env[authTokenEnv];
3630
+ const authToken = devNoAuth ? void 0 : envValue(env, authTokenEnv);
3549
3631
  if (!devNoAuth && !authToken) {
3550
3632
  throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. HTTP MCP requires bearer auth by default.`);
3551
3633
  }
@@ -3569,11 +3651,11 @@ async function startHttpMcpServer(options = {}) {
3569
3651
  });
3570
3652
  });
3571
3653
  try {
3572
- await new Promise((resolve, reject) => {
3654
+ await new Promise((resolve2, reject) => {
3573
3655
  server.once("error", reject);
3574
3656
  server.listen(port, host, () => {
3575
3657
  server.off("error", reject);
3576
- resolve();
3658
+ resolve2();
3577
3659
  });
3578
3660
  });
3579
3661
  } catch (error) {
@@ -3611,7 +3693,7 @@ async function startStreamableHttpMcpServer(options = {}) {
3611
3693
  if (devNoAuth && !isLoopbackHost(host)) {
3612
3694
  throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
3613
3695
  }
3614
- const authToken = devNoAuth ? void 0 : env[authTokenEnv];
3696
+ const authToken = devNoAuth ? void 0 : envValue(env, authTokenEnv);
3615
3697
  if (!devNoAuth && !authToken) {
3616
3698
  throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. Streamable HTTP MCP requires bearer auth by default.`);
3617
3699
  }
@@ -3638,11 +3720,11 @@ async function startStreamableHttpMcpServer(options = {}) {
3638
3720
  });
3639
3721
  });
3640
3722
  try {
3641
- await new Promise((resolve, reject) => {
3723
+ await new Promise((resolve2, reject) => {
3642
3724
  server.once("error", reject);
3643
3725
  server.listen(port, host, () => {
3644
3726
  server.off("error", reject);
3645
- resolve();
3727
+ resolve2();
3646
3728
  });
3647
3729
  });
3648
3730
  } catch (error) {
@@ -3966,20 +4048,20 @@ function isLoopbackHost(host) {
3966
4048
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
3967
4049
  }
3968
4050
  async function closeHttpServer(server, runtime) {
3969
- await new Promise((resolve, reject) => {
4051
+ await new Promise((resolve2, reject) => {
3970
4052
  server.close((error) => {
3971
4053
  if (error) reject(error);
3972
- else resolve();
4054
+ else resolve2();
3973
4055
  });
3974
4056
  }).finally(() => {
3975
4057
  runtime.close();
3976
4058
  });
3977
4059
  }
3978
4060
  async function closeStreamableHttpServer(server, sessions) {
3979
- await new Promise((resolve, reject) => {
4061
+ await new Promise((resolve2, reject) => {
3980
4062
  server.close((error) => {
3981
4063
  if (error) reject(error);
3982
- else resolve();
4064
+ else resolve2();
3983
4065
  });
3984
4066
  }).finally(() => closeStreamableSessions(sessions));
3985
4067
  }
@@ -4023,8 +4105,8 @@ function listedLocalCapabilities(config) {
4023
4105
  }
4024
4106
  function createCloudClient(config, env) {
4025
4107
  const cloud2 = requireCloudConfig(config);
4026
- const baseUrl = env[cloud2.base_url_env];
4027
- const runnerToken = env[cloud2.runner_token_env];
4108
+ const baseUrl = envValue(env, cloud2.base_url_env);
4109
+ const runnerToken = envValue(env, cloud2.runner_token_env);
4028
4110
  if (!baseUrl) throw new McpRuntimeError("CLOUD_BASE_URL_MISSING", `${cloud2.base_url_env} is not set.`);
4029
4111
  if (!runnerToken) throw new McpRuntimeError("CLOUD_RUNNER_TOKEN_MISSING", `${cloud2.runner_token_env} is not set.`);
4030
4112
  return new ControlPlaneClient({
@@ -4106,6 +4188,9 @@ async function callConfiguredTool(input) {
4106
4188
  if (capability.kind === "proposal" && input.config.mode === "read_only") {
4107
4189
  throw new McpRuntimeError("PROPOSALS_DISABLED", "This runner is in read_only mode; proposal tools are disabled.");
4108
4190
  }
4191
+ if (capability.kind === "proposal" && input.config.mode === "review") {
4192
+ assertProposalWritebackResolvable(input.config, capability);
4193
+ }
4109
4194
  const source = input.config.sources?.[capability.source];
4110
4195
  if (!source) throw new McpRuntimeError("SOURCE_NOT_FOUND", `Unknown source: ${capability.source}`);
4111
4196
  const context = resolveTrustedContext(input.config, input.env, capability);
@@ -4275,7 +4360,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
4275
4360
  const targetType = typeof target2?.type === "string" ? target2.type : capability?.target.table ?? "object";
4276
4361
  const targetId = target2?.id !== void 0 ? String(target2.id) : "unknown";
4277
4362
  const executor = writebackExecutorName(legacy.writeback);
4278
- const writebackMode = executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
4363
+ const writebackMode = executor === "read_only" || executor === "none" ? "proposal_only" : executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
4279
4364
  return {
4280
4365
  ok: true,
4281
4366
  summary: `Created proposal ${proposalId} for ${targetType} ${targetId}. Source database changed: no.`,
@@ -4330,6 +4415,44 @@ function writebackExecutorName(value) {
4330
4415
  if (!isRecord4(value)) return void 0;
4331
4416
  return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
4332
4417
  }
4418
+ function capabilityWritebackMode2(capability) {
4419
+ const mode = capability.writeback?.mode;
4420
+ if (mode === "direct_sql" || mode === "app_handler" || mode === "cloud_worker" || mode === "none") return mode;
4421
+ if (capability.executor && capability.executor !== "sql_update") return "app_handler";
4422
+ return "direct_sql";
4423
+ }
4424
+ function capabilityWritebackExecutor(capability) {
4425
+ return capability.writeback?.executor ?? capability.executor;
4426
+ }
4427
+ function assertProposalWritebackResolvable(config, capability) {
4428
+ if (capability.kind !== "proposal") return;
4429
+ const mode = capabilityWritebackMode2(capability);
4430
+ if (mode === "none" || mode === "cloud_worker") return;
4431
+ if (mode === "direct_sql") {
4432
+ const source = config.sources?.[capability.source];
4433
+ if (!source) {
4434
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} is not configured.`);
4435
+ }
4436
+ if (source.read_only === true) {
4437
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} is read-only.`);
4438
+ }
4439
+ if (!source.write_url_env) {
4440
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares DIRECT SQL writeback but source ${capability.source} has no write_url_env.`);
4441
+ }
4442
+ return;
4443
+ }
4444
+ const executorName = capabilityWritebackExecutor(capability);
4445
+ if (!executorName || executorName === "sql_update") {
4446
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but has no executor name.`);
4447
+ }
4448
+ const executor = config.executors?.[executorName];
4449
+ if (!isRecord4(executor)) {
4450
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not configured.`);
4451
+ }
4452
+ if (executor.type !== "http_handler" && executor.type !== "command_handler") {
4453
+ throw new McpRuntimeError("WRITEBACK_UNRESOLVED", `capability ${capability.name} declares HANDLER writeback but executor ${executorName} is not an app-owned handler.`);
4454
+ }
4455
+ }
4333
4456
  function evidenceHandle(bundleId) {
4334
4457
  return {
4335
4458
  bundle_id: bundleId,
@@ -4389,6 +4512,9 @@ function buildChangeSet(input) {
4389
4512
  enforcePatchGuards(input.capability, before, patch);
4390
4513
  const after = { ...before, ...patch };
4391
4514
  const guard = expectedVersionGuard(input.capability, before);
4515
+ const writebackMode = capabilityWritebackMode2(input.capability);
4516
+ const changeSetWritebackMode = writebackMode === "none" ? "read_only" : "trusted_worker_required";
4517
+ const writebackExecutor = writebackMode === "none" ? "none" : writebackMode === "cloud_worker" ? "cloud_worker" : writebackMode === "direct_sql" ? "sql_update" : capabilityWritebackExecutor(input.capability);
4392
4518
  const proposalCore = {
4393
4519
  schema_version: protocolVersions.changeSet,
4394
4520
  proposal_id: stableId("wrp", {
@@ -4436,8 +4562,8 @@ function buildChangeSet(input) {
4436
4562
  },
4437
4563
  writeback: {
4438
4564
  status: "not_applied",
4439
- mode: "trusted_worker_required",
4440
- executor: input.capability.executor ?? "sql_update"
4565
+ mode: changeSetWritebackMode,
4566
+ executor: writebackExecutor
4441
4567
  },
4442
4568
  source_database_mutated: false,
4443
4569
  created_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -4460,7 +4586,7 @@ async function readCurrentRow(input) {
4460
4586
  return readMysqlRow(input);
4461
4587
  }
4462
4588
  async function readPostgresRow(input) {
4463
- const connectionString = input.env[input.source.read_url_env];
4589
+ const connectionString = envValue(input.env, input.source.read_url_env);
4464
4590
  if (!connectionString) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
4465
4591
  const pool = new Pool({ connectionString });
4466
4592
  const client = await pool.connect();
@@ -4482,7 +4608,7 @@ async function readPostgresRow(input) {
4482
4608
  }
4483
4609
  }
4484
4610
  async function readMysqlRow(input) {
4485
- const uri = input.env[input.source.read_url_env];
4611
+ const uri = envValue(input.env, input.source.read_url_env);
4486
4612
  if (!uri) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
4487
4613
  const connection = await mysql.createConnection({ uri, dateStrings: true });
4488
4614
  try {
@@ -4537,8 +4663,8 @@ function resolveTrustedContext(config, env, capability) {
4537
4663
  if (provider === "environment") {
4538
4664
  const tenantEnv = String(values.tenant_id_env ?? "SYNAPSOR_TENANT_ID");
4539
4665
  const principalEnv = String(values.principal_env ?? "SYNAPSOR_PRINCIPAL");
4540
- const tenant = env[tenantEnv];
4541
- const principal = env[principalEnv];
4666
+ const tenant = envValue(env, tenantEnv);
4667
+ const principal = envValue(env, principalEnv);
4542
4668
  if (!tenant || !principal) throw new McpRuntimeError("TRUSTED_BINDING_MISSING", `${tenantEnv} and ${principalEnv} must be set.`);
4543
4669
  return { tenant_id: tenant, principal, provenance: "environment" };
4544
4670
  }
@@ -4551,8 +4677,19 @@ function resolveTrustedContext(config, env, capability) {
4551
4677
  throw new McpRuntimeError("TRUSTED_CONTEXT_UNSUPPORTED", `${provider} trusted context is not available in local stdio mode.`);
4552
4678
  }
4553
4679
  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;
4680
+ if (typeof envName === "string") {
4681
+ const value2 = envValue(env, envName);
4682
+ if (value2) return value2;
4683
+ }
4684
+ if (typeof literal !== "string") return void 0;
4685
+ const value = literal.trim();
4686
+ return value.length > 0 ? value : void 0;
4687
+ }
4688
+ function envValue(env, name) {
4689
+ const value = env[name];
4690
+ if (value === void 0) return void 0;
4691
+ const trimmed = value.trim();
4692
+ return trimmed.length > 0 ? trimmed : void 0;
4556
4693
  }
4557
4694
  function validateToolArgs(capability, args) {
4558
4695
  for (const [name, spec] of Object.entries(capability.args)) {
@@ -6421,7 +6558,7 @@ async function startPolling(config, adapters2, signal) {
6421
6558
  } catch (error) {
6422
6559
  logger.error("runner loop failed", { error: error instanceof Error ? error.message : String(error) });
6423
6560
  }
6424
- await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs));
6561
+ await new Promise((resolve2) => setTimeout(resolve2, config.pollIntervalMs));
6425
6562
  }
6426
6563
  }
6427
6564
 
@@ -6857,11 +6994,11 @@ async function startLocalUiServer(options = {}) {
6857
6994
  });
6858
6995
  }
6859
6996
  });
6860
- await new Promise((resolve, reject) => {
6997
+ await new Promise((resolve2, reject) => {
6861
6998
  server.once("error", reject);
6862
6999
  server.listen(options.port ?? 0, host, () => {
6863
7000
  server.off("error", reject);
6864
- resolve();
7001
+ resolve2();
6865
7002
  });
6866
7003
  });
6867
7004
  const address = server.address();
@@ -6874,10 +7011,10 @@ async function startLocalUiServer(options = {}) {
6874
7011
  port,
6875
7012
  token,
6876
7013
  csrfToken,
6877
- close: () => new Promise((resolve, reject) => {
7014
+ close: () => new Promise((resolve2, reject) => {
6878
7015
  server.close((error) => {
6879
7016
  if (error) reject(error);
6880
- else resolve();
7017
+ else resolve2();
6881
7018
  });
6882
7019
  })
6883
7020
  };
@@ -8720,7 +8857,7 @@ async function writeGeneratedSmokeInputFile(lookupArg, objectId, force) {
8720
8857
  `, force);
8721
8858
  }
8722
8859
  async function maybeRunGeneratedSmokeCall(input) {
8723
- const required = uniqueStrings([input.readUrlEnv, input.tenantEnv, input.principalEnv]).filter((envName) => !input.env[envName]);
8860
+ const required = uniqueStrings([input.readUrlEnv, input.tenantEnv, input.principalEnv]).filter((envName) => !envValue2(input.env, envName));
8724
8861
  if (required.length > 0) {
8725
8862
  return [
8726
8863
  "Smoke call not run yet.",
@@ -9353,8 +9490,7 @@ function bundleReadme(contract) {
9353
9490
  }
9354
9491
  async function configValidate(args) {
9355
9492
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
9356
- const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
9357
- const result = validateRunnerCapabilityConfig(parsed);
9493
+ const result = await validateConfigFile(configPath);
9358
9494
  if (args.includes("--json")) {
9359
9495
  process2.stdout.write(`${JSON.stringify(result, null, 2)}
9360
9496
  `);
@@ -9375,6 +9511,30 @@ async function configValidate(args) {
9375
9511
  }
9376
9512
  return result.ok ? 0 : 1;
9377
9513
  }
9514
+ async function validateConfigFile(configPath) {
9515
+ const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
9516
+ const raw = validateRunnerCapabilityConfig(parsed);
9517
+ if (!raw.ok) return raw;
9518
+ try {
9519
+ const resolved = resolveRuntimeConfig(parsed, path3.dirname(path3.resolve(configPath)));
9520
+ const resolvedValidation = validateRunnerCapabilityConfig(resolved);
9521
+ return {
9522
+ ok: resolvedValidation.ok,
9523
+ errors: resolvedValidation.errors,
9524
+ warnings: [...raw.warnings, ...resolvedValidation.warnings]
9525
+ };
9526
+ } catch (error) {
9527
+ return {
9528
+ ok: false,
9529
+ errors: [{
9530
+ path: "$.contracts",
9531
+ code: "CONTRACT_RESOLUTION_FAILED",
9532
+ message: error instanceof Error ? error.message : String(error)
9533
+ }],
9534
+ warnings: raw.warnings
9535
+ };
9536
+ }
9537
+ }
9378
9538
  async function configShow(args) {
9379
9539
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
9380
9540
  const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
@@ -9438,13 +9598,49 @@ function trustedContextsForDoctor(config) {
9438
9598
  return contexts;
9439
9599
  }
9440
9600
  function envPresenceCheck(envName, message) {
9601
+ const value = envValue2(process2.env, envName);
9441
9602
  return {
9442
9603
  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
9604
+ ok: Boolean(value),
9605
+ level: value ? "pass" : "fail",
9606
+ message: value ? `${envName} is set.` : message
9446
9607
  };
9447
9608
  }
9609
+ function proposalWritebackResolutionDoctorCheck(config, capability) {
9610
+ const mode = capabilityWritebackMode2(capability);
9611
+ if (mode === "none") {
9612
+ return {
9613
+ name: `capability:${capability.name}:writeback-resolution`,
9614
+ ok: true,
9615
+ level: "pass",
9616
+ message: "Capability explicitly declares no local writeback; proposals are review records only."
9617
+ };
9618
+ }
9619
+ if (mode === "cloud_worker") {
9620
+ return {
9621
+ name: `capability:${capability.name}:writeback-resolution`,
9622
+ ok: true,
9623
+ level: "pass",
9624
+ message: "Capability declares cloud-worker writeback; local apply is intentionally unavailable."
9625
+ };
9626
+ }
9627
+ try {
9628
+ assertProposalWritebackResolvable(config, capability);
9629
+ return {
9630
+ name: `capability:${capability.name}:writeback-resolution`,
9631
+ ok: true,
9632
+ level: "pass",
9633
+ 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)}.`
9634
+ };
9635
+ } catch (error) {
9636
+ return {
9637
+ name: `capability:${capability.name}:writeback-resolution`,
9638
+ ok: false,
9639
+ level: "fail",
9640
+ message: error instanceof Error ? error.message : String(error)
9641
+ };
9642
+ }
9643
+ }
9448
9644
  async function httpHandlerReachabilityCheck(executorName, rawUrl, timeoutMs) {
9449
9645
  try {
9450
9646
  const url = new URL(rawUrl);
@@ -9494,7 +9690,7 @@ function safeReachabilityError(error) {
9494
9690
  return "connection failed";
9495
9691
  }
9496
9692
  async function inspectConfiguredSource(input) {
9497
- if (!process2.env[input.source.read_url_env]) return;
9693
+ if (!envValue2(process2.env, input.source.read_url_env)) return;
9498
9694
  const capabilities = (input.config.capabilities ?? []).filter((capability) => capability.source === input.sourceName);
9499
9695
  const schemas = Array.from(new Set(capabilities.map((capability) => capability.target.schema)));
9500
9696
  for (const schema of schemas.length ? schemas : [void 0]) {
@@ -9837,15 +10033,15 @@ function firstRunConfigEnvChecks(config) {
9837
10033
  String(values.tenant_id_env ?? "SYNAPSOR_TENANT_ID"),
9838
10034
  String(values.principal_env ?? "SYNAPSOR_PRINCIPAL")
9839
10035
  ]) {
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.`));
10036
+ 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
10037
  }
9842
10038
  }
9843
10039
  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.`));
10040
+ 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
10041
  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];
10042
+ 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.`));
10043
+ const readValue = envValue2(process2.env, source.read_url_env);
10044
+ const writeValue = envValue2(process2.env, source.write_url_env);
9849
10045
  if (readValue && writeValue && readValue === writeValue) {
9850
10046
  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
10047
  }
@@ -9900,11 +10096,11 @@ function fail(name, checked, why, fix) {
9900
10096
  return { name, status: "fail", checked, why, fix };
9901
10097
  }
9902
10098
  async function isPortAvailable(port) {
9903
- return new Promise((resolve) => {
10099
+ return new Promise((resolve2) => {
9904
10100
  const server = net.createServer();
9905
- server.once("error", () => resolve(false));
10101
+ server.once("error", () => resolve2(false));
9906
10102
  server.listen(port, "127.0.0.1", () => {
9907
- server.close(() => resolve(true));
10103
+ server.close(() => resolve2(true));
9908
10104
  });
9909
10105
  });
9910
10106
  }
@@ -9924,9 +10120,10 @@ async function localDoctor(args) {
9924
10120
  const allowSharedCredential = args.includes("--allow-shared-credential");
9925
10121
  const checkHandlers = args.includes("--check-handlers");
9926
10122
  const checkWriteback = args.includes("--check-writeback") || args.includes("--check-db");
9927
- const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
10123
+ const rawConfig = JSON.parse(await fs3.readFile(configPath, "utf8"));
10124
+ let parsed = rawConfig;
9928
10125
  const checks = [];
9929
- const validation = validateRunnerCapabilityConfig(parsed);
10126
+ const validation = await validateConfigFile(configPath);
9930
10127
  checks.push({
9931
10128
  name: "config-valid",
9932
10129
  ok: validation.ok,
@@ -9936,6 +10133,9 @@ async function localDoctor(args) {
9936
10133
  for (const warning of validation.warnings) {
9937
10134
  checks.push({ name: `config-warning:${warning.code}`, ok: true, level: "warn", message: warning.message });
9938
10135
  }
10136
+ if (validation.ok) {
10137
+ parsed = await readRuntimeConfig(configPath);
10138
+ }
9939
10139
  const contextsToCheck = trustedContextsForDoctor(parsed);
9940
10140
  for (const [contextName, contextValues] of contextsToCheck) {
9941
10141
  const tenantEnv = String(contextValues.tenant_id_env ?? "SYNAPSOR_TENANT_ID");
@@ -9945,14 +10145,19 @@ async function localDoctor(args) {
9945
10145
  }
9946
10146
  }
9947
10147
  const sources = parsed.sources ?? {};
10148
+ if (parsed.mode === "review") {
10149
+ for (const capability of (parsed.capabilities ?? []).filter((item) => item.kind === "proposal")) {
10150
+ checks.push(proposalWritebackResolutionDoctorCheck(parsed, capability));
10151
+ }
10152
+ }
9948
10153
  for (const [sourceName, source] of Object.entries(sources)) {
9949
10154
  checks.push(envPresenceCheck(source.read_url_env, `${source.read_url_env} is required for ${sourceName} reads.`));
9950
10155
  if (parsed.mode === "review") {
9951
10156
  if (sourceNeedsSqlWriteback(parsed, sourceName)) {
9952
10157
  if (source.write_url_env) {
9953
10158
  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];
10159
+ const readValue = envValue2(process2.env, source.read_url_env);
10160
+ const writeValue = envValue2(process2.env, source.write_url_env);
9956
10161
  if (readValue && writeValue && readValue === writeValue) {
9957
10162
  checks.push({
9958
10163
  name: `source:${sourceName}:credential-separation`,
@@ -9966,7 +10171,7 @@ async function localDoctor(args) {
9966
10171
  } else {
9967
10172
  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
10173
  }
9969
- const writeUrl = source.write_url_env ? process2.env[source.write_url_env] : void 0;
10174
+ const writeUrl = source.write_url_env ? envValue2(process2.env, source.write_url_env) : void 0;
9970
10175
  if (checkWriteback && writeUrl) {
9971
10176
  checks.push(...await directSqlWritebackDoctorChecks(parsed, sourceName, source, writeUrl));
9972
10177
  } else if (checkWriteback) {
@@ -9994,7 +10199,7 @@ async function localDoctor(args) {
9994
10199
  const urlEnv = String(executor.url_env ?? "");
9995
10200
  if (urlEnv) {
9996
10201
  checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
9997
- const handlerUrl = process2.env[urlEnv];
10202
+ const handlerUrl = envValue2(process2.env, urlEnv);
9998
10203
  if (checkHandlers && handlerUrl) {
9999
10204
  checks.push(await httpHandlerReachabilityCheck(executorName, handlerUrl, Number(executor.timeout_ms ?? 3e3)));
10000
10205
  } else if (!checkHandlers) {
@@ -10108,7 +10313,7 @@ async function directSqlWritebackDoctorChecks(config, sourceName, source, writeU
10108
10313
  function directSqlProposalCapabilities(config, sourceName) {
10109
10314
  return (config.capabilities ?? []).filter((capability) => {
10110
10315
  if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
10111
- return (capability.executor ?? "sql_update") === "sql_update";
10316
+ return capabilityWritebackMode2(capability) === "direct_sql";
10112
10317
  });
10113
10318
  }
10114
10319
  async function rollbackOnlyTargetProbe(engine, databaseUrl, capability) {
@@ -10299,7 +10504,7 @@ async function applyProposal(args, proposalId) {
10299
10504
  const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
10300
10505
  const dryRun = args.includes("--dry-run") || process2.env.SYNAPSOR_DRY_RUN === "true";
10301
10506
  const runnerId = optionalArg(args, "--runner") ?? process2.env.SYNAPSOR_RUNNER_ID ?? "local_runner";
10302
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
10507
+ const config = await readRuntimeConfig(configPath);
10303
10508
  const resolvedProposalId = await resolveProposalId(proposalId, storePath);
10304
10509
  const validation = validateRunnerCapabilityConfig(config);
10305
10510
  if (!validation.ok) {
@@ -10316,6 +10521,9 @@ async function applyProposal(args, proposalId) {
10316
10521
  const proposal = requireLocalProposal(store, resolvedProposalId);
10317
10522
  const capability = findProposalCapability(config, proposal);
10318
10523
  const executorName = proposalExecutorName(proposal, capability);
10524
+ if (executorName === "none" || executorName === "cloud_worker") {
10525
+ throw new Error(`proposal ${resolvedProposalId} is not locally applyable; capability ${capability.name} declares ${executorName === "none" ? "no local writeback" : "cloud-worker writeback"}.`);
10526
+ }
10319
10527
  if (executorName === "sql_update") {
10320
10528
  const job = store.createWritebackJobFromProposal(resolvedProposalId, {
10321
10529
  project_id: optionalArg(args, "--project") ?? "local",
@@ -10373,16 +10581,19 @@ async function applySqlJob(job, configPath, storePath, dryRun, env = process2.en
10373
10581
  return result;
10374
10582
  }
10375
10583
  async function resolveSqlWriteDatabaseUrl(job, configPath, env) {
10376
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
10584
+ const config = await readRuntimeConfig(configPath);
10377
10585
  const source = config.sources?.[job.source_id];
10378
10586
  const writeUrlEnv = source?.write_url_env;
10379
- if (writeUrlEnv && env[writeUrlEnv]) return env[writeUrlEnv] ?? "";
10380
- return env.SYNAPSOR_DATABASE_URL || "";
10587
+ if (writeUrlEnv) {
10588
+ const value = envValue2(env, writeUrlEnv);
10589
+ if (value) return value;
10590
+ }
10591
+ return envValue2(env, "SYNAPSOR_DATABASE_URL") || "";
10381
10592
  }
10382
10593
  function sourceNeedsSqlWriteback(config, sourceName) {
10383
10594
  return (config.capabilities ?? []).some((capability) => {
10384
10595
  if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
10385
- return (capability.executor ?? "sql_update") === "sql_update";
10596
+ return capabilityWritebackMode2(capability) === "direct_sql";
10386
10597
  });
10387
10598
  }
10388
10599
  function findProposalCapability(config, proposal) {
@@ -10401,8 +10612,12 @@ function findProposalCapability(config, proposal) {
10401
10612
  return capability;
10402
10613
  }
10403
10614
  function proposalExecutorName(proposal, capability) {
10615
+ const mode = capabilityWritebackMode2(capability);
10616
+ if (mode === "none") return "none";
10617
+ if (mode === "cloud_worker") return "cloud_worker";
10618
+ if (mode === "direct_sql") return "sql_update";
10404
10619
  const writeback2 = proposal.change_set.writeback;
10405
- return capability.executor ?? (typeof writeback2.executor === "string" ? writeback2.executor : void 0) ?? "sql_update";
10620
+ return capabilityWritebackExecutor(capability) ?? (typeof writeback2.executor === "string" ? writeback2.executor : void 0) ?? "sql_update";
10406
10621
  }
10407
10622
  function executorConfig(config, executorName) {
10408
10623
  const raw = config.executors?.[executorName];
@@ -10427,7 +10642,7 @@ async function applyHttpHandlerProposal(input) {
10427
10642
  executor: input.executorName,
10428
10643
  request: prepared.request
10429
10644
  });
10430
- const url = input.env[input.executor.url_env];
10645
+ const url = envValue2(input.env, input.executor.url_env);
10431
10646
  if (!url) throw new Error(`${input.executor.url_env} is not set`);
10432
10647
  const headers = {
10433
10648
  accept: "application/json",
@@ -10435,7 +10650,7 @@ async function applyHttpHandlerProposal(input) {
10435
10650
  "idempotency-key": prepared.request.idempotency_key
10436
10651
  };
10437
10652
  if (input.executor.auth) {
10438
- const token = input.env[input.executor.auth.token_env];
10653
+ const token = envValue2(input.env, input.executor.auth.token_env);
10439
10654
  if (!token) throw new Error(`${input.executor.auth.token_env} is not set`);
10440
10655
  headers.authorization = `Bearer ${token}`;
10441
10656
  }
@@ -10450,7 +10665,7 @@ async function applyHttpHandlerProposal(input) {
10450
10665
  headers["x-synapsor-issued-at"] = issuedAt;
10451
10666
  headers["x-synapsor-proposal-id"] = prepared.proposal.proposal_id;
10452
10667
  if (input.executor.signing_secret_env) {
10453
- const signingSecret = input.env[input.executor.signing_secret_env];
10668
+ const signingSecret = envValue2(input.env, input.executor.signing_secret_env);
10454
10669
  if (!signingSecret) throw new Error(`${input.executor.signing_secret_env} is not set`);
10455
10670
  headers["x-synapsor-signature"] = signHandlerRequestBody(requestBody, signingSecret);
10456
10671
  }
@@ -10488,7 +10703,7 @@ async function applyCommandHandlerProposal(input) {
10488
10703
  executor: input.executorName,
10489
10704
  request: prepared.request
10490
10705
  });
10491
- const commandText = input.env[input.executor.command_env];
10706
+ const commandText = envValue2(input.env, input.executor.command_env);
10492
10707
  if (!commandText) throw new Error(`${input.executor.command_env} is not set`);
10493
10708
  const [command, ...commandArgs] = splitCommand(commandText);
10494
10709
  if (!command) throw new Error(`${input.executor.command_env} did not contain a command`);
@@ -10738,7 +10953,7 @@ function safeHandlerDetails(value) {
10738
10953
  return redactConfig(value);
10739
10954
  }
10740
10955
  async function runCommandHandler(command, args, request, timeoutMs) {
10741
- return new Promise((resolve) => {
10956
+ return new Promise((resolve2) => {
10742
10957
  const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: process2.env });
10743
10958
  let stdout = "";
10744
10959
  let stderr = "";
@@ -10747,7 +10962,7 @@ async function runCommandHandler(command, args, request, timeoutMs) {
10747
10962
  if (settled) return;
10748
10963
  settled = true;
10749
10964
  clearTimeout(timeout);
10750
- resolve(body);
10965
+ resolve2(body);
10751
10966
  };
10752
10967
  const timeout = setTimeout(() => {
10753
10968
  child.kill("SIGTERM");
@@ -10772,7 +10987,7 @@ async function runCommandHandler(command, args, request, timeoutMs) {
10772
10987
  });
10773
10988
  }
10774
10989
  async function verifyLocalWritebackAuthority(job, configPath, storePath) {
10775
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
10990
+ const config = await readRuntimeConfig(configPath);
10776
10991
  const validation = validateRunnerCapabilityConfig(config);
10777
10992
  if (!validation.ok) {
10778
10993
  throw new Error(`cannot apply writeback with invalid local config: ${validation.errors.map((error) => error.code).join(", ")}`);
@@ -10790,7 +11005,7 @@ async function verifyLocalWritebackAuthority(job, configPath, storePath) {
10790
11005
  if (Date.parse(String(job.lease_expires_at)) < Date.now()) {
10791
11006
  throw new Error("writeback job lease has expired");
10792
11007
  }
10793
- const proposalCapabilities = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal" && capability.source === job.source_id);
11008
+ const proposalCapabilities = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal" && capability.source === job.source_id && capabilityWritebackMode2(capability) === "direct_sql");
10794
11009
  const matching = proposalCapabilities.find((capability) => capabilityMatchesJob(capability, job));
10795
11010
  if (!matching) {
10796
11011
  throw new Error("writeback job does not match any reviewed proposal capability in local config");
@@ -10979,7 +11194,7 @@ function formatReviewModeUp(input) {
10979
11194
  } else {
10980
11195
  lines.push(
10981
11196
  ` Streamable HTTP endpoint: http://${input.host}:${input.port}/mcp`,
10982
- ` Auth token env: ${input.authTokenEnv} (${process2.env[input.authTokenEnv] ? "set" : "missing"})`,
11197
+ ` Auth token env: ${input.authTokenEnv} (${envValue2(process2.env, input.authTokenEnv) ? "set" : "missing"})`,
10983
11198
  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
11199
  );
10985
11200
  }
@@ -10998,12 +11213,19 @@ function formatUpWritebackLines(config) {
10998
11213
  const proposals2 = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal");
10999
11214
  if (proposals2.length === 0) return [" - no proposal capabilities; this config is read-only from Runner's perspective"];
11000
11215
  return proposals2.map((capability) => {
11001
- const executorName = capability.executor ?? "sql_update";
11002
- if (executorName === "sql_update") {
11216
+ const mode = capabilityWritebackMode2(capability);
11217
+ if (mode === "none") {
11218
+ return ` - ${capability.name}: proposal-only; no local writeback`;
11219
+ }
11220
+ if (mode === "cloud_worker") {
11221
+ return ` - ${capability.name}: cloud-worker writeback; local apply disabled`;
11222
+ }
11223
+ if (mode === "direct_sql") {
11003
11224
  const source = config.sources?.[capability.source];
11004
11225
  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"})`;
11226
+ return ` - ${capability.name}: direct guarded one-row UPDATE via ${envName} (${envValue2(process2.env, envName) ? "set" : "missing"})`;
11006
11227
  }
11228
+ const executorName = capabilityWritebackExecutor(capability) ?? "missing_executor";
11007
11229
  const executor = config.executors?.[executorName];
11008
11230
  return ` - ${capability.name}: app-owned ${String(executor?.type ?? "executor")} ${executorName}`;
11009
11231
  });
@@ -11018,14 +11240,14 @@ function formatUpHandlerLines(config) {
11018
11240
  const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
11019
11241
  const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
11020
11242
  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"})`);
11243
+ if (urlEnv) lines.push(` url env: ${urlEnv} (${envValue2(process2.env, urlEnv) ? "set" : "missing"})`);
11244
+ if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${envValue2(process2.env, tokenEnv) ? "set" : "missing"})`);
11245
+ if (signingSecretEnv) lines.push(` signing secret env: ${signingSecretEnv} (${envValue2(process2.env, signingSecretEnv) ? "set" : "missing"})`);
11024
11246
  if (!signingSecretEnv) lines.push(" signing secret env: not configured (recommended unless loopback-only)");
11025
11247
  } else if (executor.type === "command_handler") {
11026
11248
  const commandEnv = typeof executor.command_env === "string" ? executor.command_env : "";
11027
11249
  lines.push(` - ${name}: command_handler`);
11028
- if (commandEnv) lines.push(` command env: ${commandEnv} (${process2.env[commandEnv] ? "set" : "missing"})`);
11250
+ if (commandEnv) lines.push(` command env: ${commandEnv} (${envValue2(process2.env, commandEnv) ? "set" : "missing"})`);
11029
11251
  }
11030
11252
  }
11031
11253
  return lines;
@@ -11126,8 +11348,8 @@ async function cloudConnect(args) {
11126
11348
  }
11127
11349
  const baseUrlEnv = parsed.cloud.base_url_env || "SYNAPSOR_CLOUD_BASE_URL";
11128
11350
  const tokenEnv = parsed.cloud.runner_token_env || "SYNAPSOR_RUNNER_TOKEN";
11129
- const baseUrl = process2.env[baseUrlEnv];
11130
- const runnerToken = process2.env[tokenEnv];
11351
+ const baseUrl = envValue2(process2.env, baseUrlEnv);
11352
+ const runnerToken = envValue2(process2.env, tokenEnv);
11131
11353
  const missing = [baseUrl ? "" : baseUrlEnv, runnerToken ? "" : tokenEnv].filter(Boolean);
11132
11354
  if (missing.length > 0) {
11133
11355
  process2.stdout.write(`missing environment variables: ${missing.join(", ")}
@@ -11303,7 +11525,7 @@ async function writebackDoctor(args) {
11303
11525
  let ok = true;
11304
11526
  for (const [sourceName, source] of sqlSources) {
11305
11527
  const writeEnv = source.write_url_env;
11306
- const writeUrl = writeEnv ? process2.env[writeEnv] : void 0;
11528
+ const writeUrl = writeEnv ? envValue2(process2.env, writeEnv) : void 0;
11307
11529
  lines.push(`Source: ${sourceName}`);
11308
11530
  lines.push(` engine: ${source.engine}`);
11309
11531
  lines.push(` writer env: ${writeEnv ?? "(missing write_url_env)"}`);
@@ -11929,13 +12151,13 @@ async function mcpServeHttp(args) {
11929
12151
  throw error;
11930
12152
  }
11931
12153
  process2.stderr.write("Press Ctrl+C to stop.\n");
11932
- await new Promise((resolve) => {
12154
+ await new Promise((resolve2) => {
11933
12155
  const stop = async () => {
11934
12156
  process2.off("SIGINT", stop);
11935
12157
  process2.off("SIGTERM", stop);
11936
12158
  await server.close();
11937
12159
  await releaseLease();
11938
- resolve();
12160
+ resolve2();
11939
12161
  };
11940
12162
  process2.once("SIGINT", stop);
11941
12163
  process2.once("SIGTERM", stop);
@@ -11977,13 +12199,13 @@ async function mcpServeStreamableHttp(args) {
11977
12199
  throw error;
11978
12200
  }
11979
12201
  process2.stderr.write("Press Ctrl+C to stop.\n");
11980
- await new Promise((resolve) => {
12202
+ await new Promise((resolve2) => {
11981
12203
  const stop = async () => {
11982
12204
  process2.off("SIGINT", stop);
11983
12205
  process2.off("SIGTERM", stop);
11984
12206
  await server.close();
11985
12207
  await releaseLease();
11986
- resolve();
12208
+ resolve2();
11987
12209
  };
11988
12210
  process2.once("SIGINT", stop);
11989
12211
  process2.once("SIGTERM", stop);
@@ -12989,7 +13211,7 @@ function isRunnerConfigLike(value) {
12989
13211
  }
12990
13212
  async function fetchRemoteMcpTools(target2, args, timeoutMs) {
12991
13213
  const bearerEnv = optionalArg(args, "--bearer-env") ?? "SYNAPSOR_MCP_AUDIT_BEARER";
12992
- const bearer = process2.env[bearerEnv];
13214
+ const bearer = envValue2(process2.env, bearerEnv);
12993
13215
  const controller = new AbortController();
12994
13216
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
12995
13217
  try {
@@ -13015,7 +13237,7 @@ async function fetchRemoteMcpTools(target2, args, timeoutMs) {
13015
13237
  async function fetchStdioMcpTools(commandText, timeoutMs) {
13016
13238
  const [command, ...commandArgs] = splitCommand(commandText);
13017
13239
  if (!command) throw new Error("mcp audit stdio target requires a command");
13018
- return new Promise((resolve, reject) => {
13240
+ return new Promise((resolve2, reject) => {
13019
13241
  const child = spawn(command, commandArgs, {
13020
13242
  env: process2.env,
13021
13243
  stdio: ["pipe", "pipe", "pipe"]
@@ -13053,7 +13275,7 @@ async function fetchStdioMcpTools(commandText, timeoutMs) {
13053
13275
  reject(new Error(`mcp audit stdio tools/list response not found${stderr ? `: ${stderr.slice(0, 240)}` : ""}`));
13054
13276
  return;
13055
13277
  }
13056
- resolve(response);
13278
+ resolve2(response);
13057
13279
  });
13058
13280
  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
13281
  `);
@@ -13152,12 +13374,12 @@ async function ui(args) {
13152
13374
  process2.stdout.write("Opening the local review UI in your browser when a desktop opener is available.\n");
13153
13375
  }
13154
13376
  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) => {
13377
+ await new Promise((resolve2) => {
13156
13378
  const stop = async () => {
13157
13379
  process2.off("SIGINT", stop);
13158
13380
  process2.off("SIGTERM", stop);
13159
13381
  await server.close();
13160
- resolve();
13382
+ resolve2();
13161
13383
  };
13162
13384
  process2.once("SIGINT", stop);
13163
13385
  process2.once("SIGTERM", stop);
@@ -13670,7 +13892,7 @@ async function eventsTail(args) {
13670
13892
  }
13671
13893
  const seen = /* @__PURE__ */ new Set();
13672
13894
  await printOnce(seen);
13673
- await new Promise((resolve) => {
13895
+ await new Promise((resolve2) => {
13674
13896
  const timer = setInterval(() => {
13675
13897
  void printOnce(seen).catch((error) => {
13676
13898
  process2.stderr.write(`events tail error: ${safeErrorMessage(error)}
@@ -13681,7 +13903,7 @@ async function eventsTail(args) {
13681
13903
  clearInterval(timer);
13682
13904
  process2.off("SIGINT", stop);
13683
13905
  process2.off("SIGTERM", stop);
13684
- resolve();
13906
+ resolve2();
13685
13907
  };
13686
13908
  process2.once("SIGINT", stop);
13687
13909
  process2.once("SIGTERM", stop);
@@ -13690,7 +13912,7 @@ async function eventsTail(args) {
13690
13912
  }
13691
13913
  async function eventsWebhook(args) {
13692
13914
  assertKnownOptions(args, eventWebhookAllowedOptions, "events webhook");
13693
- const url = optionalArg(args, "--url") ?? envValue(optionalArg(args, "--url-env"));
13915
+ const url = optionalArg(args, "--url") ?? envValue2(optionalArg(args, "--url-env"));
13694
13916
  if (!url) throw new Error("events webhook requires --url <https://...> or --url-env <ENV>");
13695
13917
  const endpoint = new URL(url);
13696
13918
  if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("events webhook URL must use http or https");
@@ -13704,7 +13926,7 @@ async function eventsWebhook(args) {
13704
13926
  const sinceEventId = optionalPositiveIntegerArg(args, "--since-event-id");
13705
13927
  if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
13706
13928
  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"));
13929
+ const token = envValue2(optionalArg(args, "--auth-token-env"));
13708
13930
  const filters = eventFiltersFromArgs(args);
13709
13931
  const seen = /* @__PURE__ */ new Set();
13710
13932
  const pushOnce = async () => {
@@ -13739,7 +13961,7 @@ async function eventsWebhook(args) {
13739
13961
  `);
13740
13962
  }
13741
13963
  if (!follow) return 0;
13742
- await new Promise((resolve) => {
13964
+ await new Promise((resolve2) => {
13743
13965
  const timer = setInterval(() => {
13744
13966
  void pushOnce().catch((error) => {
13745
13967
  process2.stderr.write(`events webhook error: ${safeErrorMessage(error)}
@@ -13750,7 +13972,7 @@ async function eventsWebhook(args) {
13750
13972
  clearInterval(timer);
13751
13973
  process2.off("SIGINT", stop);
13752
13974
  process2.off("SIGTERM", stop);
13753
- resolve();
13975
+ resolve2();
13754
13976
  };
13755
13977
  process2.once("SIGINT", stop);
13756
13978
  process2.once("SIGTERM", stop);
@@ -14124,9 +14346,19 @@ function optionalPositiveIntegerArg(args, flag) {
14124
14346
  if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`);
14125
14347
  return parsed;
14126
14348
  }
14127
- function envValue(name) {
14128
- if (!name) return void 0;
14129
- return process2.env[name];
14349
+ function envValue2(first, second) {
14350
+ if (typeof first === "string" || first === void 0) {
14351
+ if (!first) return void 0;
14352
+ return trimmedEnvValue(process2.env, first);
14353
+ }
14354
+ if (!second) return void 0;
14355
+ return trimmedEnvValue(first, second);
14356
+ }
14357
+ function trimmedEnvValue(env, name) {
14358
+ const value = env[name];
14359
+ if (value === void 0) return void 0;
14360
+ const trimmed = value.trim();
14361
+ return trimmed.length > 0 ? trimmed : void 0;
14130
14362
  }
14131
14363
  function linkedProposalFilter(args, store, options = {}) {
14132
14364
  const noLinkedProposal = "__synapsor_no_linked_proposal__";
@@ -14346,11 +14578,11 @@ async function waitForReferenceDemoDatabase() {
14346
14578
  stdio: "pipe"
14347
14579
  });
14348
14580
  if (result.status === 0) {
14349
- await new Promise((resolve) => setTimeout(resolve, 500));
14581
+ await new Promise((resolve2) => setTimeout(resolve2, 500));
14350
14582
  return;
14351
14583
  }
14352
14584
  last = result.stderr || result.stdout || `exit ${result.status}`;
14353
- await new Promise((resolve) => setTimeout(resolve, 1e3));
14585
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
14354
14586
  }
14355
14587
  throw new Error(`demo database did not become ready: ${last}`);
14356
14588
  }
@@ -300,6 +300,34 @@ Use an app-owned executor when an approved proposal needs richer business work:
300
300
  creating a credit row, inserting an outbox event, updating multiple app tables,
301
301
  or calling your own service.
302
302
 
303
+ In DSL, keep the reviewed business contract portable and name the executor:
304
+
305
+ ```sql
306
+ CREATE CAPABILITY billing.propose_plan_credit
307
+ CONTEXT trusted_operator
308
+ SOURCE local_postgres
309
+ ON public.invoices
310
+ PRIMARY KEY id
311
+ TENANT KEY tenant_id
312
+ ARG invoice_id STRING REQUIRED
313
+ ARG amount_cents NUMBER REQUIRED
314
+ ARG reason STRING REQUIRED
315
+ LOOKUP invoice_id
316
+ VISIBLE id, tenant_id, customer_id, status, credit_requested_cents, credit_reason, updated_at
317
+ PROPOSE ACTION billing.grant_plan_credit
318
+ ALLOW WRITE credit_requested_cents, credit_reason
319
+ PATCH credit_requested_cents = ARG amount_cents
320
+ PATCH credit_reason = ARG reason
321
+ CONFLICT GUARD updated_at
322
+ APPROVAL ROLE billing_lead
323
+ WRITEBACK APP HANDLER EXECUTOR billing_handler
324
+ END
325
+ ```
326
+
327
+ `billing_handler` is contract content. Its URL, bearer-token env var, signing
328
+ secret env var, and timeout stay in `synapsor.runner.json` so credentials never
329
+ enter the portable contract:
330
+
303
331
  ```json
304
332
  "executors": {
305
333
  "billing_handler": {
@@ -316,7 +344,7 @@ or calling your own service.
316
344
  }
317
345
  ```
318
346
 
319
- Then reference it from a proposal capability:
347
+ For legacy embedded JSON capabilities, reference the same executor directly:
320
348
 
321
349
  ```json
322
350
  {
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapsor/runner",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -52,11 +52,6 @@
52
52
  "engines": {
53
53
  "node": ">=22.5.0"
54
54
  },
55
- "scripts": {
56
- "build": "tsc -b && node ../../scripts/build-runner-package.mjs",
57
- "prepack": "cd ../.. && corepack pnpm build:runner-package",
58
- "test": "vitest run"
59
- },
60
55
  "dependencies": {
61
56
  "@modelcontextprotocol/sdk": "1.29.0",
62
57
  "mysql2": "^3.11.0",
@@ -77,5 +72,9 @@
77
72
  },
78
73
  "publishConfig": {
79
74
  "access": "public"
75
+ },
76
+ "scripts": {
77
+ "build": "tsc -b && node ../../scripts/build-runner-package.mjs",
78
+ "test": "vitest run"
80
79
  }
81
- }
80
+ }