@synapsor/runner 0.1.5 → 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/README.md CHANGED
@@ -277,9 +277,16 @@ synapsor-runner dsl compile ./contract.synapsor --out ./synapsor.contract.json
277
277
  synapsor-runner contract validate ./synapsor.contract.json
278
278
  synapsor-runner contract bundle ./synapsor.contract.json --out ./synapsor-runner-bundle
279
279
  synapsor-runner cloud push ./synapsor.contract.json --dry-run
280
+ cd ./synapsor-runner-bundle
281
+ cp .env.example .env # fill in and export your read-only database values
280
282
  synapsor-runner mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
281
283
  ```
282
284
 
285
+ The `contract bundle` step generates `synapsor.runner.json` (with env-var
286
+ placeholders) inside the bundle directory, which is why `mcp serve` runs from
287
+ there. The server keeps stdout clean for MCP protocol frames and prints its
288
+ ready line on stderr.
289
+
283
290
  Your `synapsor.runner.json` supplies local wiring: database env var names,
284
291
  SQLite store path, MCP transport settings, and local debug options. The
285
292
  contract supplies the portable meaning: contexts, capabilities, evidence,
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
  }
@@ -3521,11 +3603,12 @@ async function serveStdio(options = {}) {
3521
3603
  const server = createSynapsorMcpServer(runtime, { toolNameStyle: options.toolNameStyle });
3522
3604
  const transport = new StdioServerTransport();
3523
3605
  await server.connect(transport);
3524
- await new Promise((resolve) => {
3606
+ process.stderr.write("synapsor-runner MCP stdio server ready. Waiting for an MCP client on stdio; logs stay on stderr.\n");
3607
+ await new Promise((resolve2) => {
3525
3608
  const previousOnClose = transport.onclose;
3526
3609
  const close = () => {
3527
3610
  runtime.close();
3528
- resolve();
3611
+ resolve2();
3529
3612
  };
3530
3613
  transport.onclose = () => {
3531
3614
  previousOnClose?.();
@@ -3544,7 +3627,7 @@ async function startHttpMcpServer(options = {}) {
3544
3627
  if (devNoAuth && !isLoopbackHost(host)) {
3545
3628
  throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
3546
3629
  }
3547
- const authToken = devNoAuth ? void 0 : env[authTokenEnv];
3630
+ const authToken = devNoAuth ? void 0 : envValue(env, authTokenEnv);
3548
3631
  if (!devNoAuth && !authToken) {
3549
3632
  throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. HTTP MCP requires bearer auth by default.`);
3550
3633
  }
@@ -3568,11 +3651,11 @@ async function startHttpMcpServer(options = {}) {
3568
3651
  });
3569
3652
  });
3570
3653
  try {
3571
- await new Promise((resolve, reject) => {
3654
+ await new Promise((resolve2, reject) => {
3572
3655
  server.once("error", reject);
3573
3656
  server.listen(port, host, () => {
3574
3657
  server.off("error", reject);
3575
- resolve();
3658
+ resolve2();
3576
3659
  });
3577
3660
  });
3578
3661
  } catch (error) {
@@ -3610,7 +3693,7 @@ async function startStreamableHttpMcpServer(options = {}) {
3610
3693
  if (devNoAuth && !isLoopbackHost(host)) {
3611
3694
  throw new McpRuntimeError("HTTP_DEV_NO_AUTH_UNSAFE_HOST", "--dev-no-auth is only allowed with localhost or 127.0.0.1.");
3612
3695
  }
3613
- const authToken = devNoAuth ? void 0 : env[authTokenEnv];
3696
+ const authToken = devNoAuth ? void 0 : envValue(env, authTokenEnv);
3614
3697
  if (!devNoAuth && !authToken) {
3615
3698
  throw new McpRuntimeError("HTTP_AUTH_TOKEN_MISSING", `${authTokenEnv} is not set. Streamable HTTP MCP requires bearer auth by default.`);
3616
3699
  }
@@ -3637,11 +3720,11 @@ async function startStreamableHttpMcpServer(options = {}) {
3637
3720
  });
3638
3721
  });
3639
3722
  try {
3640
- await new Promise((resolve, reject) => {
3723
+ await new Promise((resolve2, reject) => {
3641
3724
  server.once("error", reject);
3642
3725
  server.listen(port, host, () => {
3643
3726
  server.off("error", reject);
3644
- resolve();
3727
+ resolve2();
3645
3728
  });
3646
3729
  });
3647
3730
  } catch (error) {
@@ -3965,20 +4048,20 @@ function isLoopbackHost(host) {
3965
4048
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
3966
4049
  }
3967
4050
  async function closeHttpServer(server, runtime) {
3968
- await new Promise((resolve, reject) => {
4051
+ await new Promise((resolve2, reject) => {
3969
4052
  server.close((error) => {
3970
4053
  if (error) reject(error);
3971
- else resolve();
4054
+ else resolve2();
3972
4055
  });
3973
4056
  }).finally(() => {
3974
4057
  runtime.close();
3975
4058
  });
3976
4059
  }
3977
4060
  async function closeStreamableHttpServer(server, sessions) {
3978
- await new Promise((resolve, reject) => {
4061
+ await new Promise((resolve2, reject) => {
3979
4062
  server.close((error) => {
3980
4063
  if (error) reject(error);
3981
- else resolve();
4064
+ else resolve2();
3982
4065
  });
3983
4066
  }).finally(() => closeStreamableSessions(sessions));
3984
4067
  }
@@ -4022,8 +4105,8 @@ function listedLocalCapabilities(config) {
4022
4105
  }
4023
4106
  function createCloudClient(config, env) {
4024
4107
  const cloud2 = requireCloudConfig(config);
4025
- const baseUrl = env[cloud2.base_url_env];
4026
- 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);
4027
4110
  if (!baseUrl) throw new McpRuntimeError("CLOUD_BASE_URL_MISSING", `${cloud2.base_url_env} is not set.`);
4028
4111
  if (!runnerToken) throw new McpRuntimeError("CLOUD_RUNNER_TOKEN_MISSING", `${cloud2.runner_token_env} is not set.`);
4029
4112
  return new ControlPlaneClient({
@@ -4105,6 +4188,9 @@ async function callConfiguredTool(input) {
4105
4188
  if (capability.kind === "proposal" && input.config.mode === "read_only") {
4106
4189
  throw new McpRuntimeError("PROPOSALS_DISABLED", "This runner is in read_only mode; proposal tools are disabled.");
4107
4190
  }
4191
+ if (capability.kind === "proposal" && input.config.mode === "review") {
4192
+ assertProposalWritebackResolvable(input.config, capability);
4193
+ }
4108
4194
  const source = input.config.sources?.[capability.source];
4109
4195
  if (!source) throw new McpRuntimeError("SOURCE_NOT_FOUND", `Unknown source: ${capability.source}`);
4110
4196
  const context = resolveTrustedContext(input.config, input.env, capability);
@@ -4274,7 +4360,7 @@ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
4274
4360
  const targetType = typeof target2?.type === "string" ? target2.type : capability?.target.table ?? "object";
4275
4361
  const targetId = target2?.id !== void 0 ? String(target2.id) : "unknown";
4276
4362
  const executor = writebackExecutorName(legacy.writeback);
4277
- 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";
4278
4364
  return {
4279
4365
  ok: true,
4280
4366
  summary: `Created proposal ${proposalId} for ${targetType} ${targetId}. Source database changed: no.`,
@@ -4329,6 +4415,44 @@ function writebackExecutorName(value) {
4329
4415
  if (!isRecord4(value)) return void 0;
4330
4416
  return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
4331
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
+ }
4332
4456
  function evidenceHandle(bundleId) {
4333
4457
  return {
4334
4458
  bundle_id: bundleId,
@@ -4388,6 +4512,9 @@ function buildChangeSet(input) {
4388
4512
  enforcePatchGuards(input.capability, before, patch);
4389
4513
  const after = { ...before, ...patch };
4390
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);
4391
4518
  const proposalCore = {
4392
4519
  schema_version: protocolVersions.changeSet,
4393
4520
  proposal_id: stableId("wrp", {
@@ -4435,8 +4562,8 @@ function buildChangeSet(input) {
4435
4562
  },
4436
4563
  writeback: {
4437
4564
  status: "not_applied",
4438
- mode: "trusted_worker_required",
4439
- executor: input.capability.executor ?? "sql_update"
4565
+ mode: changeSetWritebackMode,
4566
+ executor: writebackExecutor
4440
4567
  },
4441
4568
  source_database_mutated: false,
4442
4569
  created_at: (/* @__PURE__ */ new Date()).toISOString()
@@ -4459,7 +4586,7 @@ async function readCurrentRow(input) {
4459
4586
  return readMysqlRow(input);
4460
4587
  }
4461
4588
  async function readPostgresRow(input) {
4462
- const connectionString = input.env[input.source.read_url_env];
4589
+ const connectionString = envValue(input.env, input.source.read_url_env);
4463
4590
  if (!connectionString) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
4464
4591
  const pool = new Pool({ connectionString });
4465
4592
  const client = await pool.connect();
@@ -4481,7 +4608,7 @@ async function readPostgresRow(input) {
4481
4608
  }
4482
4609
  }
4483
4610
  async function readMysqlRow(input) {
4484
- const uri = input.env[input.source.read_url_env];
4611
+ const uri = envValue(input.env, input.source.read_url_env);
4485
4612
  if (!uri) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
4486
4613
  const connection = await mysql.createConnection({ uri, dateStrings: true });
4487
4614
  try {
@@ -4536,8 +4663,8 @@ function resolveTrustedContext(config, env, capability) {
4536
4663
  if (provider === "environment") {
4537
4664
  const tenantEnv = String(values.tenant_id_env ?? "SYNAPSOR_TENANT_ID");
4538
4665
  const principalEnv = String(values.principal_env ?? "SYNAPSOR_PRINCIPAL");
4539
- const tenant = env[tenantEnv];
4540
- const principal = env[principalEnv];
4666
+ const tenant = envValue(env, tenantEnv);
4667
+ const principal = envValue(env, principalEnv);
4541
4668
  if (!tenant || !principal) throw new McpRuntimeError("TRUSTED_BINDING_MISSING", `${tenantEnv} and ${principalEnv} must be set.`);
4542
4669
  return { tenant_id: tenant, principal, provenance: "environment" };
4543
4670
  }
@@ -4550,8 +4677,19 @@ function resolveTrustedContext(config, env, capability) {
4550
4677
  throw new McpRuntimeError("TRUSTED_CONTEXT_UNSUPPORTED", `${provider} trusted context is not available in local stdio mode.`);
4551
4678
  }
4552
4679
  function valueFromEnvOrLiteral(envName, literal, env) {
4553
- if (typeof envName === "string" && env[envName]) return env[envName];
4554
- 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;
4555
4693
  }
4556
4694
  function validateToolArgs(capability, args) {
4557
4695
  for (const [name, spec] of Object.entries(capability.args)) {
@@ -6420,7 +6558,7 @@ async function startPolling(config, adapters2, signal) {
6420
6558
  } catch (error) {
6421
6559
  logger.error("runner loop failed", { error: error instanceof Error ? error.message : String(error) });
6422
6560
  }
6423
- await new Promise((resolve) => setTimeout(resolve, config.pollIntervalMs));
6561
+ await new Promise((resolve2) => setTimeout(resolve2, config.pollIntervalMs));
6424
6562
  }
6425
6563
  }
6426
6564
 
@@ -6856,11 +6994,11 @@ async function startLocalUiServer(options = {}) {
6856
6994
  });
6857
6995
  }
6858
6996
  });
6859
- await new Promise((resolve, reject) => {
6997
+ await new Promise((resolve2, reject) => {
6860
6998
  server.once("error", reject);
6861
6999
  server.listen(options.port ?? 0, host, () => {
6862
7000
  server.off("error", reject);
6863
- resolve();
7001
+ resolve2();
6864
7002
  });
6865
7003
  });
6866
7004
  const address = server.address();
@@ -6873,10 +7011,10 @@ async function startLocalUiServer(options = {}) {
6873
7011
  port,
6874
7012
  token,
6875
7013
  csrfToken,
6876
- close: () => new Promise((resolve, reject) => {
7014
+ close: () => new Promise((resolve2, reject) => {
6877
7015
  server.close((error) => {
6878
7016
  if (error) reject(error);
6879
- else resolve();
7017
+ else resolve2();
6880
7018
  });
6881
7019
  })
6882
7020
  };
@@ -8719,7 +8857,7 @@ async function writeGeneratedSmokeInputFile(lookupArg, objectId, force) {
8719
8857
  `, force);
8720
8858
  }
8721
8859
  async function maybeRunGeneratedSmokeCall(input) {
8722
- 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));
8723
8861
  if (required.length > 0) {
8724
8862
  return [
8725
8863
  "Smoke call not run yet.",
@@ -9352,8 +9490,7 @@ function bundleReadme(contract) {
9352
9490
  }
9353
9491
  async function configValidate(args) {
9354
9492
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
9355
- const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
9356
- const result = validateRunnerCapabilityConfig(parsed);
9493
+ const result = await validateConfigFile(configPath);
9357
9494
  if (args.includes("--json")) {
9358
9495
  process2.stdout.write(`${JSON.stringify(result, null, 2)}
9359
9496
  `);
@@ -9374,6 +9511,30 @@ async function configValidate(args) {
9374
9511
  }
9375
9512
  return result.ok ? 0 : 1;
9376
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
+ }
9377
9538
  async function configShow(args) {
9378
9539
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
9379
9540
  const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
@@ -9437,13 +9598,49 @@ function trustedContextsForDoctor(config) {
9437
9598
  return contexts;
9438
9599
  }
9439
9600
  function envPresenceCheck(envName, message) {
9601
+ const value = envValue2(process2.env, envName);
9440
9602
  return {
9441
9603
  name: `env:${envName}`,
9442
- ok: Boolean(process2.env[envName]),
9443
- level: process2.env[envName] ? "pass" : "fail",
9444
- message: process2.env[envName] ? `${envName} is set.` : message
9604
+ ok: Boolean(value),
9605
+ level: value ? "pass" : "fail",
9606
+ message: value ? `${envName} is set.` : message
9445
9607
  };
9446
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
+ }
9447
9644
  async function httpHandlerReachabilityCheck(executorName, rawUrl, timeoutMs) {
9448
9645
  try {
9449
9646
  const url = new URL(rawUrl);
@@ -9493,7 +9690,7 @@ function safeReachabilityError(error) {
9493
9690
  return "connection failed";
9494
9691
  }
9495
9692
  async function inspectConfiguredSource(input) {
9496
- if (!process2.env[input.source.read_url_env]) return;
9693
+ if (!envValue2(process2.env, input.source.read_url_env)) return;
9497
9694
  const capabilities = (input.config.capabilities ?? []).filter((capability) => capability.source === input.sourceName);
9498
9695
  const schemas = Array.from(new Set(capabilities.map((capability) => capability.target.schema)));
9499
9696
  for (const schema of schemas.length ? schemas : [void 0]) {
@@ -9836,15 +10033,15 @@ function firstRunConfigEnvChecks(config) {
9836
10033
  String(values.tenant_id_env ?? "SYNAPSOR_TENANT_ID"),
9837
10034
  String(values.principal_env ?? "SYNAPSOR_PRINCIPAL")
9838
10035
  ]) {
9839
- 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.`));
9840
10037
  }
9841
10038
  }
9842
10039
  for (const [sourceName, source] of Object.entries(config.sources ?? {})) {
9843
- 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.`));
9844
10041
  if (source.write_url_env) {
9845
- 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.`));
9846
- const readValue = process2.env[source.read_url_env];
9847
- 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);
9848
10045
  if (readValue && writeValue && readValue === writeValue) {
9849
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."));
9850
10047
  }
@@ -9899,11 +10096,11 @@ function fail(name, checked, why, fix) {
9899
10096
  return { name, status: "fail", checked, why, fix };
9900
10097
  }
9901
10098
  async function isPortAvailable(port) {
9902
- return new Promise((resolve) => {
10099
+ return new Promise((resolve2) => {
9903
10100
  const server = net.createServer();
9904
- server.once("error", () => resolve(false));
10101
+ server.once("error", () => resolve2(false));
9905
10102
  server.listen(port, "127.0.0.1", () => {
9906
- server.close(() => resolve(true));
10103
+ server.close(() => resolve2(true));
9907
10104
  });
9908
10105
  });
9909
10106
  }
@@ -9923,9 +10120,10 @@ async function localDoctor(args) {
9923
10120
  const allowSharedCredential = args.includes("--allow-shared-credential");
9924
10121
  const checkHandlers = args.includes("--check-handlers");
9925
10122
  const checkWriteback = args.includes("--check-writeback") || args.includes("--check-db");
9926
- const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
10123
+ const rawConfig = JSON.parse(await fs3.readFile(configPath, "utf8"));
10124
+ let parsed = rawConfig;
9927
10125
  const checks = [];
9928
- const validation = validateRunnerCapabilityConfig(parsed);
10126
+ const validation = await validateConfigFile(configPath);
9929
10127
  checks.push({
9930
10128
  name: "config-valid",
9931
10129
  ok: validation.ok,
@@ -9935,6 +10133,9 @@ async function localDoctor(args) {
9935
10133
  for (const warning of validation.warnings) {
9936
10134
  checks.push({ name: `config-warning:${warning.code}`, ok: true, level: "warn", message: warning.message });
9937
10135
  }
10136
+ if (validation.ok) {
10137
+ parsed = await readRuntimeConfig(configPath);
10138
+ }
9938
10139
  const contextsToCheck = trustedContextsForDoctor(parsed);
9939
10140
  for (const [contextName, contextValues] of contextsToCheck) {
9940
10141
  const tenantEnv = String(contextValues.tenant_id_env ?? "SYNAPSOR_TENANT_ID");
@@ -9944,14 +10145,19 @@ async function localDoctor(args) {
9944
10145
  }
9945
10146
  }
9946
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
+ }
9947
10153
  for (const [sourceName, source] of Object.entries(sources)) {
9948
10154
  checks.push(envPresenceCheck(source.read_url_env, `${source.read_url_env} is required for ${sourceName} reads.`));
9949
10155
  if (parsed.mode === "review") {
9950
10156
  if (sourceNeedsSqlWriteback(parsed, sourceName)) {
9951
10157
  if (source.write_url_env) {
9952
10158
  checks.push(envPresenceCheck(source.write_url_env, `${source.write_url_env} is required for trusted writeback in review mode.`));
9953
- const readValue = process2.env[source.read_url_env];
9954
- 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);
9955
10161
  if (readValue && writeValue && readValue === writeValue) {
9956
10162
  checks.push({
9957
10163
  name: `source:${sourceName}:credential-separation`,
@@ -9965,7 +10171,7 @@ async function localDoctor(args) {
9965
10171
  } else {
9966
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." });
9967
10173
  }
9968
- 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;
9969
10175
  if (checkWriteback && writeUrl) {
9970
10176
  checks.push(...await directSqlWritebackDoctorChecks(parsed, sourceName, source, writeUrl));
9971
10177
  } else if (checkWriteback) {
@@ -9993,7 +10199,7 @@ async function localDoctor(args) {
9993
10199
  const urlEnv = String(executor.url_env ?? "");
9994
10200
  if (urlEnv) {
9995
10201
  checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
9996
- const handlerUrl = process2.env[urlEnv];
10202
+ const handlerUrl = envValue2(process2.env, urlEnv);
9997
10203
  if (checkHandlers && handlerUrl) {
9998
10204
  checks.push(await httpHandlerReachabilityCheck(executorName, handlerUrl, Number(executor.timeout_ms ?? 3e3)));
9999
10205
  } else if (!checkHandlers) {
@@ -10107,7 +10313,7 @@ async function directSqlWritebackDoctorChecks(config, sourceName, source, writeU
10107
10313
  function directSqlProposalCapabilities(config, sourceName) {
10108
10314
  return (config.capabilities ?? []).filter((capability) => {
10109
10315
  if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
10110
- return (capability.executor ?? "sql_update") === "sql_update";
10316
+ return capabilityWritebackMode2(capability) === "direct_sql";
10111
10317
  });
10112
10318
  }
10113
10319
  async function rollbackOnlyTargetProbe(engine, databaseUrl, capability) {
@@ -10298,7 +10504,7 @@ async function applyProposal(args, proposalId) {
10298
10504
  const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
10299
10505
  const dryRun = args.includes("--dry-run") || process2.env.SYNAPSOR_DRY_RUN === "true";
10300
10506
  const runnerId = optionalArg(args, "--runner") ?? process2.env.SYNAPSOR_RUNNER_ID ?? "local_runner";
10301
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
10507
+ const config = await readRuntimeConfig(configPath);
10302
10508
  const resolvedProposalId = await resolveProposalId(proposalId, storePath);
10303
10509
  const validation = validateRunnerCapabilityConfig(config);
10304
10510
  if (!validation.ok) {
@@ -10315,6 +10521,9 @@ async function applyProposal(args, proposalId) {
10315
10521
  const proposal = requireLocalProposal(store, resolvedProposalId);
10316
10522
  const capability = findProposalCapability(config, proposal);
10317
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
+ }
10318
10527
  if (executorName === "sql_update") {
10319
10528
  const job = store.createWritebackJobFromProposal(resolvedProposalId, {
10320
10529
  project_id: optionalArg(args, "--project") ?? "local",
@@ -10372,16 +10581,19 @@ async function applySqlJob(job, configPath, storePath, dryRun, env = process2.en
10372
10581
  return result;
10373
10582
  }
10374
10583
  async function resolveSqlWriteDatabaseUrl(job, configPath, env) {
10375
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
10584
+ const config = await readRuntimeConfig(configPath);
10376
10585
  const source = config.sources?.[job.source_id];
10377
10586
  const writeUrlEnv = source?.write_url_env;
10378
- if (writeUrlEnv && env[writeUrlEnv]) return env[writeUrlEnv] ?? "";
10379
- 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") || "";
10380
10592
  }
10381
10593
  function sourceNeedsSqlWriteback(config, sourceName) {
10382
10594
  return (config.capabilities ?? []).some((capability) => {
10383
10595
  if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
10384
- return (capability.executor ?? "sql_update") === "sql_update";
10596
+ return capabilityWritebackMode2(capability) === "direct_sql";
10385
10597
  });
10386
10598
  }
10387
10599
  function findProposalCapability(config, proposal) {
@@ -10400,8 +10612,12 @@ function findProposalCapability(config, proposal) {
10400
10612
  return capability;
10401
10613
  }
10402
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";
10403
10619
  const writeback2 = proposal.change_set.writeback;
10404
- 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";
10405
10621
  }
10406
10622
  function executorConfig(config, executorName) {
10407
10623
  const raw = config.executors?.[executorName];
@@ -10426,7 +10642,7 @@ async function applyHttpHandlerProposal(input) {
10426
10642
  executor: input.executorName,
10427
10643
  request: prepared.request
10428
10644
  });
10429
- const url = input.env[input.executor.url_env];
10645
+ const url = envValue2(input.env, input.executor.url_env);
10430
10646
  if (!url) throw new Error(`${input.executor.url_env} is not set`);
10431
10647
  const headers = {
10432
10648
  accept: "application/json",
@@ -10434,7 +10650,7 @@ async function applyHttpHandlerProposal(input) {
10434
10650
  "idempotency-key": prepared.request.idempotency_key
10435
10651
  };
10436
10652
  if (input.executor.auth) {
10437
- const token = input.env[input.executor.auth.token_env];
10653
+ const token = envValue2(input.env, input.executor.auth.token_env);
10438
10654
  if (!token) throw new Error(`${input.executor.auth.token_env} is not set`);
10439
10655
  headers.authorization = `Bearer ${token}`;
10440
10656
  }
@@ -10449,7 +10665,7 @@ async function applyHttpHandlerProposal(input) {
10449
10665
  headers["x-synapsor-issued-at"] = issuedAt;
10450
10666
  headers["x-synapsor-proposal-id"] = prepared.proposal.proposal_id;
10451
10667
  if (input.executor.signing_secret_env) {
10452
- const signingSecret = input.env[input.executor.signing_secret_env];
10668
+ const signingSecret = envValue2(input.env, input.executor.signing_secret_env);
10453
10669
  if (!signingSecret) throw new Error(`${input.executor.signing_secret_env} is not set`);
10454
10670
  headers["x-synapsor-signature"] = signHandlerRequestBody(requestBody, signingSecret);
10455
10671
  }
@@ -10487,7 +10703,7 @@ async function applyCommandHandlerProposal(input) {
10487
10703
  executor: input.executorName,
10488
10704
  request: prepared.request
10489
10705
  });
10490
- const commandText = input.env[input.executor.command_env];
10706
+ const commandText = envValue2(input.env, input.executor.command_env);
10491
10707
  if (!commandText) throw new Error(`${input.executor.command_env} is not set`);
10492
10708
  const [command, ...commandArgs] = splitCommand(commandText);
10493
10709
  if (!command) throw new Error(`${input.executor.command_env} did not contain a command`);
@@ -10737,7 +10953,7 @@ function safeHandlerDetails(value) {
10737
10953
  return redactConfig(value);
10738
10954
  }
10739
10955
  async function runCommandHandler(command, args, request, timeoutMs) {
10740
- return new Promise((resolve) => {
10956
+ return new Promise((resolve2) => {
10741
10957
  const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: process2.env });
10742
10958
  let stdout = "";
10743
10959
  let stderr = "";
@@ -10746,7 +10962,7 @@ async function runCommandHandler(command, args, request, timeoutMs) {
10746
10962
  if (settled) return;
10747
10963
  settled = true;
10748
10964
  clearTimeout(timeout);
10749
- resolve(body);
10965
+ resolve2(body);
10750
10966
  };
10751
10967
  const timeout = setTimeout(() => {
10752
10968
  child.kill("SIGTERM");
@@ -10771,7 +10987,7 @@ async function runCommandHandler(command, args, request, timeoutMs) {
10771
10987
  });
10772
10988
  }
10773
10989
  async function verifyLocalWritebackAuthority(job, configPath, storePath) {
10774
- const config = JSON.parse(await fs3.readFile(configPath, "utf8"));
10990
+ const config = await readRuntimeConfig(configPath);
10775
10991
  const validation = validateRunnerCapabilityConfig(config);
10776
10992
  if (!validation.ok) {
10777
10993
  throw new Error(`cannot apply writeback with invalid local config: ${validation.errors.map((error) => error.code).join(", ")}`);
@@ -10789,7 +11005,7 @@ async function verifyLocalWritebackAuthority(job, configPath, storePath) {
10789
11005
  if (Date.parse(String(job.lease_expires_at)) < Date.now()) {
10790
11006
  throw new Error("writeback job lease has expired");
10791
11007
  }
10792
- 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");
10793
11009
  const matching = proposalCapabilities.find((capability) => capabilityMatchesJob(capability, job));
10794
11010
  if (!matching) {
10795
11011
  throw new Error("writeback job does not match any reviewed proposal capability in local config");
@@ -10978,7 +11194,7 @@ function formatReviewModeUp(input) {
10978
11194
  } else {
10979
11195
  lines.push(
10980
11196
  ` Streamable HTTP endpoint: http://${input.host}:${input.port}/mcp`,
10981
- ` Auth token env: ${input.authTokenEnv} (${process2.env[input.authTokenEnv] ? "set" : "missing"})`,
11197
+ ` Auth token env: ${input.authTokenEnv} (${envValue2(process2.env, input.authTokenEnv) ? "set" : "missing"})`,
10982
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}`
10983
11199
  );
10984
11200
  }
@@ -10997,12 +11213,19 @@ function formatUpWritebackLines(config) {
10997
11213
  const proposals2 = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal");
10998
11214
  if (proposals2.length === 0) return [" - no proposal capabilities; this config is read-only from Runner's perspective"];
10999
11215
  return proposals2.map((capability) => {
11000
- const executorName = capability.executor ?? "sql_update";
11001
- 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") {
11002
11224
  const source = config.sources?.[capability.source];
11003
11225
  const envName = source?.write_url_env ?? "SYNAPSOR_DATABASE_URL";
11004
- 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"})`;
11005
11227
  }
11228
+ const executorName = capabilityWritebackExecutor(capability) ?? "missing_executor";
11006
11229
  const executor = config.executors?.[executorName];
11007
11230
  return ` - ${capability.name}: app-owned ${String(executor?.type ?? "executor")} ${executorName}`;
11008
11231
  });
@@ -11017,14 +11240,14 @@ function formatUpHandlerLines(config) {
11017
11240
  const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
11018
11241
  const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
11019
11242
  lines.push(` - ${name}: http_handler`);
11020
- if (urlEnv) lines.push(` url env: ${urlEnv} (${process2.env[urlEnv] ? "set" : "missing"})`);
11021
- if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${process2.env[tokenEnv] ? "set" : "missing"})`);
11022
- 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"})`);
11023
11246
  if (!signingSecretEnv) lines.push(" signing secret env: not configured (recommended unless loopback-only)");
11024
11247
  } else if (executor.type === "command_handler") {
11025
11248
  const commandEnv = typeof executor.command_env === "string" ? executor.command_env : "";
11026
11249
  lines.push(` - ${name}: command_handler`);
11027
- 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"})`);
11028
11251
  }
11029
11252
  }
11030
11253
  return lines;
@@ -11125,8 +11348,8 @@ async function cloudConnect(args) {
11125
11348
  }
11126
11349
  const baseUrlEnv = parsed.cloud.base_url_env || "SYNAPSOR_CLOUD_BASE_URL";
11127
11350
  const tokenEnv = parsed.cloud.runner_token_env || "SYNAPSOR_RUNNER_TOKEN";
11128
- const baseUrl = process2.env[baseUrlEnv];
11129
- const runnerToken = process2.env[tokenEnv];
11351
+ const baseUrl = envValue2(process2.env, baseUrlEnv);
11352
+ const runnerToken = envValue2(process2.env, tokenEnv);
11130
11353
  const missing = [baseUrl ? "" : baseUrlEnv, runnerToken ? "" : tokenEnv].filter(Boolean);
11131
11354
  if (missing.length > 0) {
11132
11355
  process2.stdout.write(`missing environment variables: ${missing.join(", ")}
@@ -11302,7 +11525,7 @@ async function writebackDoctor(args) {
11302
11525
  let ok = true;
11303
11526
  for (const [sourceName, source] of sqlSources) {
11304
11527
  const writeEnv = source.write_url_env;
11305
- const writeUrl = writeEnv ? process2.env[writeEnv] : void 0;
11528
+ const writeUrl = writeEnv ? envValue2(process2.env, writeEnv) : void 0;
11306
11529
  lines.push(`Source: ${sourceName}`);
11307
11530
  lines.push(` engine: ${source.engine}`);
11308
11531
  lines.push(` writer env: ${writeEnv ?? "(missing write_url_env)"}`);
@@ -11928,13 +12151,13 @@ async function mcpServeHttp(args) {
11928
12151
  throw error;
11929
12152
  }
11930
12153
  process2.stderr.write("Press Ctrl+C to stop.\n");
11931
- await new Promise((resolve) => {
12154
+ await new Promise((resolve2) => {
11932
12155
  const stop = async () => {
11933
12156
  process2.off("SIGINT", stop);
11934
12157
  process2.off("SIGTERM", stop);
11935
12158
  await server.close();
11936
12159
  await releaseLease();
11937
- resolve();
12160
+ resolve2();
11938
12161
  };
11939
12162
  process2.once("SIGINT", stop);
11940
12163
  process2.once("SIGTERM", stop);
@@ -11976,13 +12199,13 @@ async function mcpServeStreamableHttp(args) {
11976
12199
  throw error;
11977
12200
  }
11978
12201
  process2.stderr.write("Press Ctrl+C to stop.\n");
11979
- await new Promise((resolve) => {
12202
+ await new Promise((resolve2) => {
11980
12203
  const stop = async () => {
11981
12204
  process2.off("SIGINT", stop);
11982
12205
  process2.off("SIGTERM", stop);
11983
12206
  await server.close();
11984
12207
  await releaseLease();
11985
- resolve();
12208
+ resolve2();
11986
12209
  };
11987
12210
  process2.once("SIGINT", stop);
11988
12211
  process2.once("SIGTERM", stop);
@@ -12988,7 +13211,7 @@ function isRunnerConfigLike(value) {
12988
13211
  }
12989
13212
  async function fetchRemoteMcpTools(target2, args, timeoutMs) {
12990
13213
  const bearerEnv = optionalArg(args, "--bearer-env") ?? "SYNAPSOR_MCP_AUDIT_BEARER";
12991
- const bearer = process2.env[bearerEnv];
13214
+ const bearer = envValue2(process2.env, bearerEnv);
12992
13215
  const controller = new AbortController();
12993
13216
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
12994
13217
  try {
@@ -13014,7 +13237,7 @@ async function fetchRemoteMcpTools(target2, args, timeoutMs) {
13014
13237
  async function fetchStdioMcpTools(commandText, timeoutMs) {
13015
13238
  const [command, ...commandArgs] = splitCommand(commandText);
13016
13239
  if (!command) throw new Error("mcp audit stdio target requires a command");
13017
- return new Promise((resolve, reject) => {
13240
+ return new Promise((resolve2, reject) => {
13018
13241
  const child = spawn(command, commandArgs, {
13019
13242
  env: process2.env,
13020
13243
  stdio: ["pipe", "pipe", "pipe"]
@@ -13052,7 +13275,7 @@ async function fetchStdioMcpTools(commandText, timeoutMs) {
13052
13275
  reject(new Error(`mcp audit stdio tools/list response not found${stderr ? `: ${stderr.slice(0, 240)}` : ""}`));
13053
13276
  return;
13054
13277
  }
13055
- resolve(response);
13278
+ resolve2(response);
13056
13279
  });
13057
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" } } })}
13058
13281
  `);
@@ -13151,12 +13374,12 @@ async function ui(args) {
13151
13374
  process2.stdout.write("Opening the local review UI in your browser when a desktop opener is available.\n");
13152
13375
  }
13153
13376
  process2.stdout.write("Approval and rejection actions require the per-run local session plus CSRF token. Press Ctrl+C to stop.\n");
13154
- await new Promise((resolve) => {
13377
+ await new Promise((resolve2) => {
13155
13378
  const stop = async () => {
13156
13379
  process2.off("SIGINT", stop);
13157
13380
  process2.off("SIGTERM", stop);
13158
13381
  await server.close();
13159
- resolve();
13382
+ resolve2();
13160
13383
  };
13161
13384
  process2.once("SIGINT", stop);
13162
13385
  process2.once("SIGTERM", stop);
@@ -13669,7 +13892,7 @@ async function eventsTail(args) {
13669
13892
  }
13670
13893
  const seen = /* @__PURE__ */ new Set();
13671
13894
  await printOnce(seen);
13672
- await new Promise((resolve) => {
13895
+ await new Promise((resolve2) => {
13673
13896
  const timer = setInterval(() => {
13674
13897
  void printOnce(seen).catch((error) => {
13675
13898
  process2.stderr.write(`events tail error: ${safeErrorMessage(error)}
@@ -13680,7 +13903,7 @@ async function eventsTail(args) {
13680
13903
  clearInterval(timer);
13681
13904
  process2.off("SIGINT", stop);
13682
13905
  process2.off("SIGTERM", stop);
13683
- resolve();
13906
+ resolve2();
13684
13907
  };
13685
13908
  process2.once("SIGINT", stop);
13686
13909
  process2.once("SIGTERM", stop);
@@ -13689,7 +13912,7 @@ async function eventsTail(args) {
13689
13912
  }
13690
13913
  async function eventsWebhook(args) {
13691
13914
  assertKnownOptions(args, eventWebhookAllowedOptions, "events webhook");
13692
- const url = optionalArg(args, "--url") ?? envValue(optionalArg(args, "--url-env"));
13915
+ const url = optionalArg(args, "--url") ?? envValue2(optionalArg(args, "--url-env"));
13693
13916
  if (!url) throw new Error("events webhook requires --url <https://...> or --url-env <ENV>");
13694
13917
  const endpoint = new URL(url);
13695
13918
  if (!["http:", "https:"].includes(endpoint.protocol)) throw new Error("events webhook URL must use http or https");
@@ -13703,7 +13926,7 @@ async function eventsWebhook(args) {
13703
13926
  const sinceEventId = optionalPositiveIntegerArg(args, "--since-event-id");
13704
13927
  if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
13705
13928
  if (!Number.isFinite(timeoutMs) || timeoutMs < 250) throw new Error("--timeout-ms must be at least 250");
13706
- const token = envValue(optionalArg(args, "--auth-token-env"));
13929
+ const token = envValue2(optionalArg(args, "--auth-token-env"));
13707
13930
  const filters = eventFiltersFromArgs(args);
13708
13931
  const seen = /* @__PURE__ */ new Set();
13709
13932
  const pushOnce = async () => {
@@ -13738,7 +13961,7 @@ async function eventsWebhook(args) {
13738
13961
  `);
13739
13962
  }
13740
13963
  if (!follow) return 0;
13741
- await new Promise((resolve) => {
13964
+ await new Promise((resolve2) => {
13742
13965
  const timer = setInterval(() => {
13743
13966
  void pushOnce().catch((error) => {
13744
13967
  process2.stderr.write(`events webhook error: ${safeErrorMessage(error)}
@@ -13749,7 +13972,7 @@ async function eventsWebhook(args) {
13749
13972
  clearInterval(timer);
13750
13973
  process2.off("SIGINT", stop);
13751
13974
  process2.off("SIGTERM", stop);
13752
- resolve();
13975
+ resolve2();
13753
13976
  };
13754
13977
  process2.once("SIGINT", stop);
13755
13978
  process2.once("SIGTERM", stop);
@@ -14123,9 +14346,19 @@ function optionalPositiveIntegerArg(args, flag) {
14123
14346
  if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`);
14124
14347
  return parsed;
14125
14348
  }
14126
- function envValue(name) {
14127
- if (!name) return void 0;
14128
- 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;
14129
14362
  }
14130
14363
  function linkedProposalFilter(args, store, options = {}) {
14131
14364
  const noLinkedProposal = "__synapsor_no_linked_proposal__";
@@ -14345,11 +14578,11 @@ async function waitForReferenceDemoDatabase() {
14345
14578
  stdio: "pipe"
14346
14579
  });
14347
14580
  if (result.status === 0) {
14348
- await new Promise((resolve) => setTimeout(resolve, 500));
14581
+ await new Promise((resolve2) => setTimeout(resolve2, 500));
14349
14582
  return;
14350
14583
  }
14351
14584
  last = result.stderr || result.stdout || `exit ${result.status}`;
14352
- await new Promise((resolve) => setTimeout(resolve, 1e3));
14585
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
14353
14586
  }
14354
14587
  throw new Error(`demo database did not become ready: ${last}`);
14355
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.5",
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
+ }