@synapsor/runner 0.1.0-alpha.15 → 0.1.0-alpha.16

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
@@ -2,13 +2,24 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ No unreleased changes yet.
6
+
7
+ ## 0.1.0-alpha.16
8
+
5
9
  ### Added
6
10
 
7
- - `result_format: 2` for a stable MCP result envelope with
8
- `ok`, `summary`, `data`, `proposal`, `error`, `evidence`,
9
- `source_database_changed`, and `_meta.canonical_capability`.
11
+ - `synapsor-runner up` for first-session review-mode bring-up. It validates
12
+ the local config/store, checks active store leases, summarizes model-facing
13
+ tools, explains direct SQL versus app-owned executor writeback, and prints
14
+ the next smoke, approval, apply, replay, UI, and doctor commands.
15
+ - Guided app-owned executor setup can now write a starter handler template
16
+ during `init --wizard` / `start --from-env ... --mode review`.
17
+ - `result_format: 2` for a stable MCP result envelope with `ok`, `summary`,
18
+ `data`, `proposal`, `error`, `evidence`, `source_database_changed`, and
19
+ `_meta.canonical_capability`.
10
20
  - `--result-format v1|v2` for `mcp serve`, `mcp serve --transport
11
- streamable-http`, `mcp serve-streamable-http`, and the legacy JSON-RPC bridge.
21
+ streamable-http`, `mcp serve-streamable-http`, and the legacy JSON-RPC
22
+ bridge.
12
23
  - Capability config fields `description`, per-argument `description`, and
13
24
  `returns_hint`; these are surfaced in MCP tool metadata.
14
25
  - `tools list` as a first-class alias for `tools preview`, including
@@ -16,16 +27,23 @@
16
27
  - `mcp client-config --include-instructions` for Claude/Cursor/OpenAI-style
17
28
  client snippets with propose-first agent guidance.
18
29
  - `schemas/synapsor.runner.schema.json` for editor validation.
19
- - `docs/capability-authoring.md` and `docs/result-envelope-v2.md`.
20
- - RFC source docs under `docs/rfcs/`.
30
+ - `docs/capability-authoring.md`, `docs/result-envelope-v2.md`, and RFC source
31
+ docs under `docs/rfcs/`.
21
32
 
22
33
  ### Changed
23
34
 
35
+ - Handler templates, template CLI output, app-owned writeback docs, and
36
+ examples now carry the explicit handler security warning: app handlers own the
37
+ final business write and must re-check tenant/scope, conflict guards,
38
+ idempotency, business action, transactions, and safe receipts.
24
39
  - OpenAI-safe aliases include the canonical Synapsor capability name in
25
40
  descriptions/metadata so model-visible aliases can still be audited against
26
41
  dotted capability names.
27
42
  - v2 MCP errors redact raw driver/infra strings and map failures to a small
28
43
  safe error-code enum.
44
+ - Release policy now keeps the stable channel gated on `up`, review-mode wizard
45
+ verification, handler warning coverage, clean npm install checks, and at
46
+ least one external developer following the README without source reading.
29
47
 
30
48
  ### Compatibility
31
49
 
package/README.md CHANGED
@@ -223,6 +223,21 @@ The end-to-end shape is:
223
223
  The generated config is just the safety contract. A small reviewed version
224
224
  looks like this:
225
225
 
226
+ Bring the generated review-mode workspace up with one command:
227
+
228
+ ```bash
229
+ npx -y -p @synapsor/runner@alpha synapsor-runner up \
230
+ --config ./synapsor.runner.json \
231
+ --store ./.synapsor/local.db \
232
+ --dry-run
233
+ ```
234
+
235
+ `up` validates the config/store, summarizes model-facing tools, shows whether
236
+ proposal tools use direct SQL writeback or app-owned executors, checks active
237
+ store leases, and prints the next smoke, approve, apply, replay, UI, and doctor
238
+ commands. Use `--transport streamable-http` when you want `up` to start the
239
+ standard HTTP MCP server.
240
+
226
241
  ```json
227
242
  {
228
243
  "version": 1,
@@ -588,6 +603,14 @@ Use direct guarded DB writeback for simple local/staging single-row updates. If
588
603
  your application service already owns business writes, configure an
589
604
  `http_handler` or `command_handler` executor. Approval still happens outside
590
605
  MCP, and the handler returns an applied/conflict/failed receipt for replay.
606
+
607
+ > **Important:** your app handler owns the final business write. Runner creates
608
+ > the proposal and calls your handler only after approval, but your handler must
609
+ > still enforce tenant/scope checks, expected-version or conflict guards,
610
+ > idempotency keys, allowed business actions, transaction/rollback, and safe
611
+ > error receipts. If you skip those checks, you can reintroduce cross-tenant
612
+ > writes, lost updates, or duplicate writes. Keep handler credentials out of MCP.
613
+
591
614
  Starter handlers are included under `examples/app-owned-writeback`.
592
615
  The packaged app-owned billing example also includes a bundled
593
616
  `synapsor-handler.mjs` helper shim; `@synapsor/handler` is not published as a
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;AAmB3P,OAAO,EAA6G,KAAK,YAAY,EAAwB,MAAM,2BAA2B,CAAC;AAC/L,OAAO,EAOL,KAAK,gBAAgB,EAEtB,MAAM,mCAAmC,CAAC;AAoS3C,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAkD1D;AA0DD,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,CA6OjB;AA4uDD,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,EAAqG,KAAK,WAAW,EAA2F,MAAM,6BAA6B,CAAC;AAmB3P,OAAO,EAA6G,KAAK,YAAY,EAAwB,MAAM,2BAA2B,CAAC;AAC/L,OAAO,EAOL,KAAK,gBAAgB,EAEtB,MAAM,mCAAmC,CAAC;AA4U3C,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAmD1D;AA0DD,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,CA+RjB;AAivDD,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAM/H"}
package/dist/runner.mjs CHANGED
@@ -4525,15 +4525,18 @@ function generateRunnerConfigFromSpec(spec) {
4525
4525
  const lookupArg = spec.lookup_arg ?? `${objectName}_id`;
4526
4526
  const inspectToolName = spec.inspect_tool_name ?? `${spec.namespace}.inspect_${objectName}`;
4527
4527
  const proposalToolName = spec.proposal_tool_name ?? `${spec.namespace}.propose_${objectName}_update`;
4528
+ const objectLabel = objectName.replace(/_/g, " ");
4528
4529
  const visibleColumns = unique([spec.primary_key, spec.tenant_key, spec.conflict_column, ...spec.visible_columns].filter((value) => Boolean(value)));
4529
4530
  const readCapability = {
4530
4531
  name: inspectToolName,
4531
4532
  kind: "read",
4533
+ description: spec.inspect_description ?? `Inspect one ${objectLabel} in trusted tenant scope before answering or proposing a change.`,
4534
+ returns_hint: spec.inspect_returns_hint ?? `Returns reviewed ${objectLabel} fields, evidence handle, query audit, and source_database_changed:false.`,
4532
4535
  source: sourceName,
4533
4536
  context: "local_operator",
4534
4537
  target: target(spec),
4535
4538
  args: {
4536
- [lookupArg]: { type: "string", required: true, max_length: 128 }
4539
+ [lookupArg]: { type: "string", required: true, max_length: 128, description: `${capitalize(objectLabel)} id from the user request or trusted app context.` }
4537
4540
  },
4538
4541
  lookup: { id_from_arg: lookupArg },
4539
4542
  visible_columns: visibleColumns,
@@ -4546,6 +4549,8 @@ function generateRunnerConfigFromSpec(spec) {
4546
4549
  capabilities.push({
4547
4550
  name: proposalToolName,
4548
4551
  kind: "proposal",
4552
+ description: spec.proposal_description ?? `Create a review-required proposal to update one ${objectLabel}. The source database remains unchanged until approval and writeback.`,
4553
+ returns_hint: spec.proposal_returns_hint ?? "Returns a proposal id, exact before/after diff, evidence handle, approval status, and source_database_changed:false.",
4549
4554
  source: sourceName,
4550
4555
  context: "local_operator",
4551
4556
  ...writeback2.executor !== "sql_update" ? { executor: writeback2.executorName } : {},
@@ -4569,6 +4574,7 @@ function generateRunnerConfigFromSpec(spec) {
4569
4574
  const config = {
4570
4575
  version: 1,
4571
4576
  mode,
4577
+ ...spec.result_format ? { result_format: spec.result_format } : {},
4572
4578
  storage: { sqlite_path: "./.synapsor/local.db" },
4573
4579
  sources: {
4574
4580
  [sourceName]: {
@@ -5017,6 +5023,7 @@ function validateSelectionSpec(spec) {
5017
5023
  if (spec.version !== void 0 && spec.version !== 1) throw new Error("onboarding selection version must be 1.");
5018
5024
  if (spec.engine !== "postgres" && spec.engine !== "mysql") throw new Error("selection engine must be postgres or mysql.");
5019
5025
  if (!["read_only", "shadow", "review", void 0].includes(spec.mode)) throw new Error("selection mode must be read_only, shadow, or review.");
5026
+ if (spec.result_format !== void 0 && spec.result_format !== 1 && spec.result_format !== 2) throw new Error("selection result_format must be 1 or 2.");
5020
5027
  if (spec.writeback?.executor && !["sql_update", "http_handler", "command_handler"].includes(spec.writeback.executor)) {
5021
5028
  throw new Error("selection writeback.executor must be sql_update, http_handler, or command_handler.");
5022
5029
  }
@@ -5121,6 +5128,10 @@ function singularize(value) {
5121
5128
  function safeName(value) {
5122
5129
  return value.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "record";
5123
5130
  }
5131
+ function capitalize(value) {
5132
+ if (!value) return value;
5133
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
5134
+ }
5124
5135
  function assertSafeIdentifier(identifier) {
5125
5136
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
5126
5137
  throw new Error(`unsafe identifier in selection: ${identifier}`);
@@ -6573,6 +6584,19 @@ var defaultConfigPath = "synapsor.runner.json";
6573
6584
  var defaultStorePath = "./.synapsor/local.db";
6574
6585
  var quickDemoStorePath = "./.synapsor/quick-demo.db";
6575
6586
  var generatedSmokeInputPath = "./.synapsor/smoke-input.json";
6587
+ var handlerSecurityWarning = [
6588
+ "IMPORTANT: your app handler owns the final business write.",
6589
+ "Runner creates the proposal and calls your handler only after approval, but your handler must still enforce:",
6590
+ "- tenant/scope check;",
6591
+ "- expected-version or conflict guard;",
6592
+ "- idempotency key;",
6593
+ "- allowed business action;",
6594
+ "- transaction/rollback;",
6595
+ "- safe error receipt.",
6596
+ "",
6597
+ "If you skip those checks, you can reintroduce cross-tenant writes, lost updates, or duplicate writes.",
6598
+ "Use the generated template/helper pattern and keep handler credentials out of MCP."
6599
+ ].join("\n");
6576
6600
  var handlerTemplateDefinitions = {
6577
6601
  "node-fastify": {
6578
6602
  aliases: ["node", "fastify"],
@@ -6608,6 +6632,15 @@ app.post("/synapsor/writeback", async (request, reply) => {
6608
6632
  }
6609
6633
 
6610
6634
  /*
6635
+ * IMPORTANT: your app handler owns the final business write.
6636
+ * Runner creates the proposal and calls your handler only after approval,
6637
+ * but your handler must still enforce tenant/scope, expected-version or
6638
+ * conflict guard, idempotency key, allowed business action,
6639
+ * transaction/rollback, and safe error receipt.
6640
+ *
6641
+ * If you skip those checks, you can reintroduce cross-tenant writes,
6642
+ * lost updates, or duplicate writes. Keep handler credentials out of MCP.
6643
+ *
6611
6644
  * Put your app-owned transaction here.
6612
6645
  *
6613
6646
  * Examples:
@@ -6665,6 +6698,15 @@ async def synapsor_writeback(body: dict, authorization: str | None = Header(defa
6665
6698
 
6666
6699
  # Put your app-owned transaction here.
6667
6700
  #
6701
+ # IMPORTANT: your app handler owns the final business write.
6702
+ # Runner creates the proposal and calls your handler only after approval,
6703
+ # but your handler must still enforce tenant/scope, expected-version or
6704
+ # conflict guard, idempotency key, allowed business action,
6705
+ # transaction/rollback, and safe error receipt.
6706
+ #
6707
+ # If you skip those checks, you can reintroduce cross-tenant writes,
6708
+ # lost updates, or duplicate writes. Keep handler credentials out of MCP.
6709
+ #
6668
6710
  # Examples:
6669
6711
  # - insert a refund_review row;
6670
6712
  # - insert an account_credit row;
@@ -6716,6 +6758,15 @@ if (request.dry_run) {
6716
6758
  }
6717
6759
 
6718
6760
  /*
6761
+ * IMPORTANT: your app handler owns the final business write.
6762
+ * Runner creates the proposal and calls your handler only after approval,
6763
+ * but your handler must still enforce tenant/scope, expected-version or
6764
+ * conflict guard, idempotency key, allowed business action,
6765
+ * transaction/rollback, and safe error receipt.
6766
+ *
6767
+ * If you skip those checks, you can reintroduce cross-tenant writes,
6768
+ * lost updates, or duplicate writes. Keep handler credentials out of MCP.
6769
+ *
6719
6770
  * Put your app-owned command transaction here.
6720
6771
  *
6721
6772
  * Examples:
@@ -6784,6 +6835,7 @@ ${cliCommandName()} --help
6784
6835
  if (command === "propose") return propose(rest);
6785
6836
  if (command === "audit") return audit(rest);
6786
6837
  if (command === "start") return start(rest);
6838
+ if (command === "up") return up(rest);
6787
6839
  if (command === "runner") return runnerCommand(rest);
6788
6840
  if (command === "cloud") return cloud(rest);
6789
6841
  if (command === "mcp") return mcp(rest);
@@ -6987,8 +7039,32 @@ async function runInitWizard(args, options = {}) {
6987
7039
  const objectName = await askDefault(ask, "Business object name", optionalArg(args, "--object-name") ?? recipeSpec?.object_name ?? safeObjectName(table.name));
6988
7040
  const lookupArg = await askDefault(ask, "Model-visible object id argument", optionalArg(args, "--lookup-arg") ?? recipeSpec?.lookup_arg ?? `${objectName}_id`);
6989
7041
  const smokeObjectId = await askDefault(ask, "Optional real object id for a first smoke call", optionalArg(args, "--smoke-id") ?? "");
7042
+ const objectLabel = objectName.replace(/_/g, " ");
7043
+ const inspectDescription = await askDefault(
7044
+ ask,
7045
+ "Read capability description",
7046
+ optionalArg(args, "--inspect-description") ?? `Inspect one ${objectLabel} in trusted tenant scope before answering or proposing a change.`
7047
+ );
7048
+ const inspectReturnsHint = await askDefault(
7049
+ ask,
7050
+ "Read capability returns hint",
7051
+ optionalArg(args, "--inspect-returns-hint") ?? `Returns reviewed ${objectLabel} fields, evidence handle, query audit, and source_database_changed:false.`
7052
+ );
7053
+ const proposalDescription = mode === "read_only" ? void 0 : await askDefault(
7054
+ ask,
7055
+ "Proposal capability description",
7056
+ optionalArg(args, "--proposal-description") ?? `Create a review-required proposal to update one ${objectLabel}. The source database remains unchanged until approval and writeback.`
7057
+ );
7058
+ const proposalReturnsHint = mode === "read_only" ? void 0 : await askDefault(
7059
+ ask,
7060
+ "Proposal capability returns hint",
7061
+ optionalArg(args, "--proposal-returns-hint") ?? "Returns a proposal id, exact before/after diff, evidence handle, approval status, and source_database_changed:false."
7062
+ );
7063
+ const resultFormatAnswer = await askChoice(ask, "MCP result envelope", optionalArg(args, "--result-format") ? normalizeResultFormatAnswer(optionalArg(args, "--result-format")) : "default", ["default", "v1", "v2"]);
7064
+ const resultFormat = resultFormatAnswer === "v1" ? 1 : resultFormatAnswer === "v2" ? 2 : void 0;
6990
7065
  let writeUrlEnv = optionalArg(args, "--write-url-env");
6991
7066
  let writeback2;
7067
+ let generatedHandlerTemplate;
6992
7068
  if (mode === "review") {
6993
7069
  const writebackPath = await askChoice(
6994
7070
  ask,
@@ -7011,6 +7087,12 @@ async function runInitWizard(args, options = {}) {
7011
7087
  ...signingSecretEnv ? { handler_signing_secret_env: signingSecretEnv } : {},
7012
7088
  timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
7013
7089
  };
7090
+ const writeTemplate = await askChoice(ask, "Write starter app-owned handler template", args.includes("--skip-handler-template") ? "no" : "yes", ["yes", "no"]);
7091
+ if (writeTemplate === "yes") {
7092
+ const template = await askChoice(ask, "Handler template", optionalArg(args, "--handler-template") ?? "node-fastify", ["node-fastify", "python-fastapi"]);
7093
+ const output = await askDefault(ask, "Handler template output", optionalArg(args, "--handler-template-output") ?? handlerTemplateDefinitions[template].fileName);
7094
+ generatedHandlerTemplate = { name: template, output };
7095
+ }
7014
7096
  } else {
7015
7097
  const commandEnv = await askEnvName(ask, "App-owned command handler env var", optionalArg(args, "--handler-command-env") ?? "SYNAPSOR_APP_WRITEBACK_COMMAND");
7016
7098
  writeback2 = {
@@ -7019,6 +7101,11 @@ async function runInitWizard(args, options = {}) {
7019
7101
  handler_command_env: commandEnv,
7020
7102
  timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
7021
7103
  };
7104
+ const writeTemplate = await askChoice(ask, "Write starter app-owned handler template", args.includes("--skip-handler-template") ? "no" : "yes", ["yes", "no"]);
7105
+ if (writeTemplate === "yes") {
7106
+ const output = await askDefault(ask, "Handler template output", optionalArg(args, "--handler-template-output") ?? handlerTemplateDefinitions.command.fileName);
7107
+ generatedHandlerTemplate = { name: "command", output };
7108
+ }
7022
7109
  }
7023
7110
  }
7024
7111
  const approvalRole = mode === "read_only" ? "local_reviewer" : await askDefault(ask, "Required approval role", optionalArg(args, "--approval-role") ?? recipeSpec?.approval?.required_role ?? "local_reviewer");
@@ -7039,7 +7126,12 @@ async function runInitWizard(args, options = {}) {
7039
7126
  object_name: objectName,
7040
7127
  inspect_tool_name: recipeSpec?.inspect_tool_name,
7041
7128
  proposal_tool_name: recipeSpec?.proposal_tool_name,
7129
+ inspect_description: inspectDescription,
7130
+ inspect_returns_hint: inspectReturnsHint,
7131
+ proposal_description: proposalDescription,
7132
+ proposal_returns_hint: proposalReturnsHint,
7042
7133
  lookup_arg: lookupArg,
7134
+ result_format: resultFormat,
7043
7135
  visible_columns: visibleColumns,
7044
7136
  allowed_columns: allowedColumns,
7045
7137
  patch,
@@ -7065,16 +7157,29 @@ async function runInitWizard(args, options = {}) {
7065
7157
  stdout.write(` source: ${inspection.engine} ${table.schema}.${table.name}
7066
7158
  `);
7067
7159
  stdout.write(` mode: ${mode}
7160
+ `);
7161
+ stdout.write(` result envelope: ${resultFormat ? `v${resultFormat}` : "default"}
7068
7162
  `);
7069
7163
  stdout.write(` writeback path: ${writeback2?.executor ?? (mode === "review" ? "sql_update" : "none")}
7070
7164
  `);
7071
7165
  stdout.write(` exposed tools: ${tools2.join(", ")}
7072
7166
  `);
7073
7167
  stdout.write(" not exposed: execute_sql, approval tools, commit tools, database URLs, write credentials, model-controlled tenant authority\n");
7168
+ if (generatedHandlerTemplate) {
7169
+ stdout.write(` handler template: ${generatedHandlerTemplate.output}
7170
+ `);
7171
+ stdout.write(`${handlerSecurityWarning}
7172
+ `);
7173
+ }
7074
7174
  const confirmed = await askDefault(ask, "Write generated config and MCP snippets? Type yes to continue", "no");
7075
7175
  if (confirmed.toLowerCase() !== "yes") throw new Error("guided init canceled before writing files");
7076
7176
  const outputPath = outputArg(args) ?? "synapsor.runner.json";
7077
7177
  await writeGeneratedOnboardingFiles(outputPath, generated, args.includes("--force"), { printNext: false });
7178
+ if (generatedHandlerTemplate) {
7179
+ await writeHandlerTemplateFile(generatedHandlerTemplate.name, generatedHandlerTemplate.output, args.includes("--force"));
7180
+ stdout.write(`created ${generatedHandlerTemplate.output}
7181
+ `);
7182
+ }
7078
7183
  if (smokeObjectId) {
7079
7184
  await writeGeneratedSmokeInputFile(lookupArg, smokeObjectId, args.includes("--force"));
7080
7185
  stdout.write(`created ${generatedSmokeInputPath}
@@ -7103,6 +7208,8 @@ async function runInitWizard(args, options = {}) {
7103
7208
  `);
7104
7209
  }
7105
7210
  stdout.write(` 3. Serve MCP tools: ${cliCommandName()} mcp serve --config ${outputPath} --store ${defaultStorePath}
7211
+ `);
7212
+ stdout.write(` OpenAI Agents SDK: use ${cliCommandName()} mcp serve-streamable-http --config ${outputPath} --store ${defaultStorePath} --alias-mode openai
7106
7213
  `);
7107
7214
  return 0;
7108
7215
  }
@@ -7179,6 +7286,11 @@ async function initFromInspection(args, inspection, databaseUrlEnv) {
7179
7286
  namespace: optionalArg(args, "--namespace") ?? "source",
7180
7287
  object_name: optionalArg(args, "--object-name"),
7181
7288
  lookup_arg: optionalArg(args, "--lookup-arg"),
7289
+ inspect_description: optionalArg(args, "--inspect-description"),
7290
+ inspect_returns_hint: optionalArg(args, "--inspect-returns-hint"),
7291
+ proposal_description: optionalArg(args, "--proposal-description"),
7292
+ proposal_returns_hint: optionalArg(args, "--proposal-returns-hint"),
7293
+ result_format: resultFormatOption(args),
7182
7294
  visible_columns: visibleColumns,
7183
7295
  allowed_columns: allowedColumns,
7184
7296
  patch,
@@ -9131,9 +9243,202 @@ async function start(args = []) {
9131
9243
  await startPolling(config, adapters, controller.signal);
9132
9244
  return 0;
9133
9245
  }
9246
+ async function up(args = []) {
9247
+ const allowed = /* @__PURE__ */ new Set([
9248
+ "--config",
9249
+ "--store",
9250
+ "--transport",
9251
+ "--host",
9252
+ "--port",
9253
+ "--auth-token-env",
9254
+ "--alias-mode",
9255
+ "--tool-name-style",
9256
+ "--openai-tool-aliases",
9257
+ "--result-format",
9258
+ "--handler-check",
9259
+ "--open-ui",
9260
+ "--print-next",
9261
+ "--dry-run",
9262
+ "--dev-no-auth",
9263
+ "--cors-origin",
9264
+ "--allow-concurrent-store"
9265
+ ]);
9266
+ assertKnownOptions(args, allowed, "up");
9267
+ const configPath = optionalArg(args, "--config") ?? defaultConfigPath;
9268
+ const config = await readRuntimeConfig(configPath);
9269
+ const storePath = optionalArg(args, "--store") ?? config.storage?.sqlite_path ?? defaultStorePath;
9270
+ const transport = optionalArg(args, "--transport") ?? "stdio";
9271
+ if (transport !== "stdio" && transport !== "streamable-http") {
9272
+ throw new Error("--transport must be stdio or streamable-http");
9273
+ }
9274
+ const port = Number(optionalArg(args, "--port") ?? "8766");
9275
+ if (transport === "streamable-http" && (!Number.isInteger(port) || port <= 0 || port > 65535)) {
9276
+ throw new Error("--port must be an integer from 1 to 65535");
9277
+ }
9278
+ const aliasMode = toolNameStyleOption(args);
9279
+ const resultFormat = resultFormatOption(args);
9280
+ const validation = validateRunnerCapabilityConfig(config);
9281
+ if (!validation.ok) {
9282
+ throw new Error(`cannot bring Runner up with invalid config: ${validation.errors.map((error) => `${error.path} ${error.code}`).join("; ")}`);
9283
+ }
9284
+ if (storePath !== ":memory:") {
9285
+ await fs3.mkdir(path3.dirname(path3.resolve(storePath)), { recursive: true });
9286
+ }
9287
+ await assertNoActiveStoreLease(storePath, args.includes("--allow-concurrent-store"), "review-mode up");
9288
+ const boundary = await inspectMcpToolBoundary([
9289
+ "--config",
9290
+ configPath,
9291
+ "--store",
9292
+ storePath,
9293
+ "--alias-mode",
9294
+ aliasMode
9295
+ ]);
9296
+ process2.stdout.write(formatReviewModeUp({
9297
+ aliasMode,
9298
+ authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
9299
+ boundary,
9300
+ config,
9301
+ configPath,
9302
+ dryRun: args.includes("--dry-run"),
9303
+ host: optionalArg(args, "--host") ?? "127.0.0.1",
9304
+ openUi: args.includes("--open-ui"),
9305
+ port,
9306
+ resultFormat,
9307
+ storePath,
9308
+ transport
9309
+ }));
9310
+ if (args.includes("--handler-check")) {
9311
+ process2.stdout.write("\nHandler check:\n");
9312
+ const doctorCode = await doctor(["--config", configPath, "--store", storePath, "--check-handlers"]);
9313
+ if (doctorCode !== 0) return doctorCode;
9314
+ }
9315
+ if (args.includes("--dry-run") || transport === "stdio") return boundary.ok ? 0 : 1;
9316
+ const serveArgs = [
9317
+ "--config",
9318
+ configPath,
9319
+ "--store",
9320
+ storePath,
9321
+ "--host",
9322
+ optionalArg(args, "--host") ?? "127.0.0.1",
9323
+ "--port",
9324
+ String(port),
9325
+ "--auth-token-env",
9326
+ optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
9327
+ "--alias-mode",
9328
+ aliasMode,
9329
+ ...resultFormat ? ["--result-format", String(resultFormat)] : [],
9330
+ ...args.includes("--dev-no-auth") ? ["--dev-no-auth"] : [],
9331
+ ...optionalArg(args, "--cors-origin") ? ["--cors-origin", optionalArg(args, "--cors-origin")] : [],
9332
+ ...args.includes("--allow-concurrent-store") ? ["--allow-concurrent-store"] : []
9333
+ ];
9334
+ return mcpServeStreamableHttp(serveArgs);
9335
+ }
9336
+ function formatReviewModeUp(input) {
9337
+ const lines = [
9338
+ "Synapsor Runner review-mode up",
9339
+ "",
9340
+ `Config: ${input.configPath}`,
9341
+ `Store: ${input.storePath}`,
9342
+ `Mode: ${input.config.mode}`,
9343
+ `Transport: ${input.transport}`,
9344
+ `Alias mode: ${input.aliasMode}`,
9345
+ `Result format: ${input.resultFormat ? `v${input.resultFormat}` : configResultFormat(input.config)}`,
9346
+ `Dry run: ${input.dryRun ? "yes" : "no"}`,
9347
+ "",
9348
+ "Model-facing tools:",
9349
+ ...formatUpToolLines(input.boundary),
9350
+ "",
9351
+ "Writeback paths:",
9352
+ ...formatUpWritebackLines(input.config)
9353
+ ];
9354
+ const handlerLines = formatUpHandlerLines(input.config);
9355
+ if (handlerLines.length > 0) {
9356
+ lines.push("", "App-owned handler requirements:", ...handlerLines, "", handlerSecurityWarning);
9357
+ }
9358
+ lines.push("", "Server guidance:");
9359
+ if (input.transport === "stdio") {
9360
+ lines.push(
9361
+ " stdio mode is launched by an MCP client. This command does not hold a protocol session open.",
9362
+ ` Print client config: ${cliCommandName()} mcp client-config --client claude-desktop --config ${input.configPath} --store ${input.storePath}`,
9363
+ ` Serve command used by clients: ${cliCommandName()} mcp serve --config ${input.configPath} --store ${input.storePath} --alias-mode ${input.aliasMode}`
9364
+ );
9365
+ } else {
9366
+ lines.push(
9367
+ ` Streamable HTTP endpoint: http://${input.host}:${input.port}/mcp`,
9368
+ ` Auth token env: ${input.authTokenEnv} (${process2.env[input.authTokenEnv] ? "set" : "missing"})`,
9369
+ ` Start command: ${cliCommandName()} mcp serve-streamable-http --config ${input.configPath} --store ${input.storePath} --port ${input.port} --auth-token-env ${input.authTokenEnv} --alias-mode ${input.aliasMode}`
9370
+ );
9371
+ }
9372
+ if (input.openUi) {
9373
+ lines.push("", "Local review UI:", ` ${cliCommandName()} ui --open --tour --config ${input.configPath} --store ${input.storePath}`);
9374
+ }
9375
+ lines.push("", "Next commands:", ...formatUpNextCommands(input.config, input.configPath, input.storePath), "");
9376
+ return `${lines.join("\n")}
9377
+ `;
9378
+ }
9379
+ function formatUpToolLines(boundary) {
9380
+ if (boundary.exposures.length === 0) return [" - (none)"];
9381
+ return boundary.exposures.map((item) => item.isAlias ? ` - ${item.exposedName} -> ${item.canonicalName}` : ` - ${item.exposedName}`);
9382
+ }
9383
+ function formatUpWritebackLines(config) {
9384
+ const proposals2 = (config.capabilities ?? []).filter((capability) => capability.kind === "proposal");
9385
+ if (proposals2.length === 0) return [" - no proposal capabilities; this config is read-only from Runner's perspective"];
9386
+ return proposals2.map((capability) => {
9387
+ const executorName = capability.executor ?? "sql_update";
9388
+ if (executorName === "sql_update") {
9389
+ const source = config.sources?.[capability.source];
9390
+ const envName = source?.write_url_env ?? "SYNAPSOR_DATABASE_URL";
9391
+ return ` - ${capability.name}: direct guarded one-row UPDATE via ${envName} (${process2.env[envName] ? "set" : "missing"})`;
9392
+ }
9393
+ const executor = config.executors?.[executorName];
9394
+ return ` - ${capability.name}: app-owned ${String(executor?.type ?? "executor")} ${executorName}`;
9395
+ });
9396
+ }
9397
+ function formatUpHandlerLines(config) {
9398
+ const lines = [];
9399
+ for (const [name, executor] of Object.entries(config.executors ?? {})) {
9400
+ if (!isRecord6(executor)) continue;
9401
+ if (executor.type === "http_handler") {
9402
+ const urlEnv = typeof executor.url_env === "string" ? executor.url_env : "";
9403
+ const auth = isRecord6(executor.auth) ? executor.auth : void 0;
9404
+ const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
9405
+ const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
9406
+ lines.push(` - ${name}: http_handler`);
9407
+ if (urlEnv) lines.push(` url env: ${urlEnv} (${process2.env[urlEnv] ? "set" : "missing"})`);
9408
+ if (tokenEnv) lines.push(` bearer token env: ${tokenEnv} (${process2.env[tokenEnv] ? "set" : "missing"})`);
9409
+ if (signingSecretEnv) lines.push(` signing secret env: ${signingSecretEnv} (${process2.env[signingSecretEnv] ? "set" : "missing"})`);
9410
+ if (!signingSecretEnv) lines.push(" signing secret env: not configured (recommended unless loopback-only)");
9411
+ } else if (executor.type === "command_handler") {
9412
+ const commandEnv = typeof executor.command_env === "string" ? executor.command_env : "";
9413
+ lines.push(` - ${name}: command_handler`);
9414
+ if (commandEnv) lines.push(` command env: ${commandEnv} (${process2.env[commandEnv] ? "set" : "missing"})`);
9415
+ }
9416
+ }
9417
+ return lines;
9418
+ }
9419
+ function configResultFormat(config) {
9420
+ return config.result_format === 2 ? "v2" : config.result_format === 1 ? "v1" : "default";
9421
+ }
9422
+ function formatUpNextCommands(config, configPath, storePath) {
9423
+ const firstTool = (config.capabilities ?? [])[0]?.name ?? "<capability>";
9424
+ const hasHandlers = Object.keys(config.executors ?? {}).length > 0;
9425
+ return [
9426
+ ` - Preview tools: ${cliCommandName()} tools preview --config ${configPath} --store ${storePath}`,
9427
+ ` - Smoke call: ${cliCommandName()} smoke call ${firstTool} --sample --config ${configPath} --store ${storePath}`,
9428
+ ` - List proposals: ${cliCommandName()} proposals list --store ${storePath}`,
9429
+ ` - Show proposal: ${cliCommandName()} proposals show latest --store ${storePath}`,
9430
+ ` - Approve proposal: ${cliCommandName()} proposals approve latest --yes --store ${storePath}`,
9431
+ ` - Apply approved proposal: ${cliCommandName()} apply latest --config ${configPath} --store ${storePath}`,
9432
+ ` - Replay: ${cliCommandName()} replay show latest --store ${storePath}`,
9433
+ ` - Tail events: ${cliCommandName()} events tail --store ${storePath}`,
9434
+ ` - Direct writeback doctor: ${cliCommandName()} doctor --config ${configPath} --check-writeback`,
9435
+ ...hasHandlers ? [` - Handler doctor: ${cliCommandName()} doctor --config ${configPath} --check-handlers`] : []
9436
+ ];
9437
+ }
9134
9438
  async function runnerCommand(args) {
9135
9439
  const [subcommand, ...rest] = args;
9136
9440
  if (subcommand === "start") return start(rest);
9441
+ if (subcommand === "up") return up(rest);
9137
9442
  if (subcommand === "doctor") return doctor(rest);
9138
9443
  usage();
9139
9444
  return 2;
@@ -9172,7 +9477,7 @@ async function cloudConnect(args) {
9172
9477
  return 1;
9173
9478
  }
9174
9479
  const runnerId = String(parsed.cloud.runner_id || process2.env.SYNAPSOR_RUNNER_ID || "synapsor_runner_local").trim();
9175
- const runnerVersion = String(parsed.cloud.runner_version || process2.env.npm_package_version || "0.1.0-alpha.15").trim();
9480
+ const runnerVersion = String(parsed.cloud.runner_version || process2.env.npm_package_version || "0.1.0-alpha.16").trim();
9176
9481
  const engines = normalizeEngines(parsed.cloud.engines);
9177
9482
  const capabilities = normalizeCapabilities(parsed.cloud.capabilities);
9178
9483
  const client = new ControlPlaneClient({
@@ -9277,21 +9582,28 @@ async function handlerTemplate(args) {
9277
9582
  return 0;
9278
9583
  }
9279
9584
  const output = outputArg(args) ?? definition.fileName;
9280
- await writeFileGuarded(output, content, args.includes("--force"));
9281
- if (name === "command" || output.endsWith(".mjs") || output.endsWith(".js")) {
9282
- await fs3.chmod(path3.resolve(output), 493).catch(() => void 0);
9283
- }
9585
+ await writeHandlerTemplateFile(name, output, args.includes("--force"));
9284
9586
  process2.stdout.write(`created ${output}
9285
9587
  `);
9286
- process2.stdout.write("Review the template and add your app-owned transaction, authorization, idempotency, and conflict checks before using it with approved proposals.\n");
9588
+ process2.stdout.write(`${handlerSecurityWarning}
9589
+ `);
9287
9590
  return 0;
9288
9591
  }
9592
+ async function writeHandlerTemplateFile(name, output, force) {
9593
+ const definition = handlerTemplateDefinitions[name];
9594
+ await writeFileGuarded(output, definition.content, force);
9595
+ if (name === "command" || output.endsWith(".mjs") || output.endsWith(".js")) {
9596
+ await fs3.chmod(path3.resolve(output), 493).catch(() => void 0);
9597
+ }
9598
+ }
9289
9599
  function formatHandlerTemplateList() {
9290
9600
  return [
9291
9601
  "Synapsor app-owned writeback handler templates",
9292
9602
  "",
9293
9603
  ...Object.entries(handlerTemplateDefinitions).map(([name, definition]) => `- ${name}: ${definition.description}`),
9294
9604
  "",
9605
+ handlerSecurityWarning,
9606
+ "",
9295
9607
  "Examples:",
9296
9608
  ` ${cliCommandName()} handler template node-fastify --output ./synapsor-writeback-handler.mjs`,
9297
9609
  ` ${cliCommandName()} handler template python-fastapi --output ./synapsor_writeback_handler.py`,
@@ -10040,7 +10352,7 @@ async function assertNoActiveStoreLease(storePath, force, operation) {
10040
10352
  await fs3.rm(storeLeasePath(resolved), { force: true });
10041
10353
  return;
10042
10354
  }
10043
- const message = `Local store appears active for ${lease.mode}/${lease.transport} (pid ${lease.pid}, started ${lease.started_at}). Refusing ${operation}. Stop the server or rerun with --force if you have verified it is safe.`;
10355
+ const message = `Local store appears active for ${lease.mode}/${lease.transport} (pid ${lease.pid}, started ${lease.started_at}). Refusing ${operation}. Stop the server or rerun with --allow-concurrent-store/--force if you have verified it is safe.`;
10044
10356
  if (!force) throw new Error(message);
10045
10357
  process2.stderr.write(`Warning: ${message}
10046
10358
  `);
@@ -10104,6 +10416,12 @@ function resultFormatOption(args) {
10104
10416
  if (requested === "2" || requested === "v2") return 2;
10105
10417
  throw new Error("--result-format must be v1, 1, v2, or 2");
10106
10418
  }
10419
+ function normalizeResultFormatAnswer(value) {
10420
+ if (value === "1" || value === "v1") return "v1";
10421
+ if (value === "2" || value === "v2") return "v2";
10422
+ if (value === "default") return "default";
10423
+ throw new Error("--result-format must be default, v1, 1, v2, or 2");
10424
+ }
10107
10425
  async function mcpAudit(args) {
10108
10426
  const format = optionalArg(args, "--format") ?? (args.includes("--json") ? "json" : "text");
10109
10427
  if (!["text", "json", "markdown"].includes(format)) {
@@ -12231,8 +12549,8 @@ async function prepareReferenceDemo(args) {
12231
12549
  ].join("\n"));
12232
12550
  const down = spawnSync("docker", ["compose", "-f", composePath, "down", "-v", "--remove-orphans"], { stdio: "inherit", env: process2.env });
12233
12551
  if (down.status !== 0) return down.status ?? 1;
12234
- const up = spawnSync("docker", ["compose", "-f", composePath, "up", "-d"], { stdio: "inherit", env: process2.env });
12235
- if (up.status !== 0) return up.status ?? 1;
12552
+ const up2 = spawnSync("docker", ["compose", "-f", composePath, "up", "-d"], { stdio: "inherit", env: process2.env });
12553
+ if (up2.status !== 0) return up2.status ?? 1;
12236
12554
  await waitForReferenceDemoDatabase();
12237
12555
  await fs3.copyFile(path3.join(demoDir, "synapsor.runner.json"), configPath);
12238
12556
  process2.stdout.write([
@@ -13311,7 +13629,7 @@ function starterCloudConfig() {
13311
13629
  base_url_env: "SYNAPSOR_CLOUD_BASE_URL",
13312
13630
  runner_token_env: "SYNAPSOR_RUNNER_TOKEN",
13313
13631
  runner_id: "synapsor_runner_local",
13314
- runner_version: "0.1.0-alpha.15",
13632
+ runner_version: "0.1.0-alpha.16",
13315
13633
  project_id: "token_scope",
13316
13634
  adapter_id: "mcp.your_adapter",
13317
13635
  source_id: "src_replace_me",
@@ -13346,6 +13664,7 @@ function isKnownTopLevelCommand(command) {
13346
13664
  "propose",
13347
13665
  "audit",
13348
13666
  "start",
13667
+ "up",
13349
13668
  "runner",
13350
13669
  "cloud",
13351
13670
  "mcp",
@@ -13388,6 +13707,7 @@ Usage:
13388
13707
  Commands:
13389
13708
  inspect Inspect a Postgres/MySQL schema
13390
13709
  start Start guided own-database setup, or no-arg legacy worker polling
13710
+ up Bring up local review mode guidance/server
13391
13711
  init Generate a Synapsor capability contract
13392
13712
  mcp Serve safe semantic tools over MCP
13393
13713
  onboard One-command own-database setup
@@ -13411,6 +13731,7 @@ Commands:
13411
13731
 
13412
13732
  Examples:
13413
13733
  ${cmd} start --from-env DATABASE_URL
13734
+ ${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db --dry-run
13414
13735
  ${cmd} onboard db --from-env DATABASE_URL
13415
13736
  ${cmd} inspect --from-env DATABASE_URL
13416
13737
  ${cmd} init --wizard --from-env DATABASE_URL
@@ -13420,6 +13741,26 @@ Examples:
13420
13741
  ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
13421
13742
  ${cmd} propose billing.propose_late_fee_waiver --sample
13422
13743
  ${cmd} audit ./synapsor.runner.json
13744
+ `,
13745
+ up: `Usage:
13746
+ ${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db [--transport stdio|streamable-http]
13747
+ ${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db --transport streamable-http --port 8766 --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
13748
+ ${cmd} up --config ./synapsor.runner.json --store ./.synapsor/local.db --handler-check --dry-run
13749
+
13750
+ Validate the local Runner config and store, summarize model-facing tools,
13751
+ explain direct SQL versus app-owned executor writeback, and print the next
13752
+ smoke/approve/apply/replay commands.
13753
+
13754
+ With --transport stdio, \`${cmd} up\` prints MCP client wiring because stdio is
13755
+ launched by the client. With --transport streamable-http and without --dry-run,
13756
+ it starts the standard Streamable HTTP MCP server.
13757
+
13758
+ Options:
13759
+ --alias-mode canonical|openai|both
13760
+ --result-format v1|v2
13761
+ --handler-check
13762
+ --open-ui
13763
+ --dry-run
13423
13764
  `,
13424
13765
  start: `Usage:
13425
13766
  ${cmd} start --from-env DATABASE_URL [--schema public] [--mode read_only|shadow|review]
@@ -17,6 +17,13 @@ The model-facing MCP tool only creates a proposal. Approval happens outside MCP.
17
17
  After approval, Runner calls your `http_handler` or `command_handler`, records
18
18
  the receipt, and includes the result in replay.
19
19
 
20
+ > **Important:** your app handler owns the final business write. Runner creates
21
+ > the proposal and calls your handler only after approval, but your handler must
22
+ > still enforce tenant/scope checks, expected-version or conflict guards,
23
+ > idempotency keys, allowed business actions, transaction/rollback, and safe
24
+ > error receipts. If you skip those checks, you can reintroduce cross-tenant
25
+ > writes, lost updates, or duplicate writes. Keep handler credentials out of MCP.
26
+
20
27
  A handler is your application endpoint or script. It is not a second Synapsor
21
28
  package that users need to install. Install `@synapsor/runner`, then generate
22
29
  or copy a handler template only when your approved write needs app-owned
@@ -17,6 +17,13 @@ The model-facing MCP tool still creates a proposal only. A human/operator
17
17
  approves outside MCP. After approval, Runner sends the structured writeback
18
18
  request to your handler.
19
19
 
20
+ > **Important:** your app handler owns the final business write. Runner creates
21
+ > the proposal and calls your handler only after approval, but your handler must
22
+ > still enforce tenant/scope checks, expected-version or conflict guards,
23
+ > idempotency keys, allowed business actions, transaction/rollback, and safe
24
+ > error receipts. If you skip those checks, you can reintroduce cross-tenant
25
+ > writes, lost updates, or duplicate writes. Keep handler credentials out of MCP.
26
+
20
27
  ## Scope
21
28
 
22
29
  Current alpha scope:
@@ -11,6 +11,40 @@ npx -y -p @synapsor/runner@alpha synapsor-runner demo --quick
11
11
  The OSS runner command is `synapsor-runner`. The `synapsor` command is reserved
12
12
  for the Synapsor Cloud CLI.
13
13
 
14
+ ## 0.1.0-alpha.16
15
+
16
+ ### Review-Mode Bring-Up
17
+
18
+ - Added `synapsor-runner up` as the local review-mode orientation command. It
19
+ validates the config/store, checks active store leases, summarizes
20
+ model-facing tools, identifies direct SQL versus app-owned executor writeback
21
+ paths, and prints the next smoke, approval, apply, replay, UI, and doctor
22
+ commands.
23
+ - `up --dry-run` gives the full checklist without starting a server.
24
+ - `up --transport streamable-http` starts the standard MCP Streamable HTTP
25
+ server after the same validation and guidance.
26
+ - `up --handler-check` runs the redacted handler env/reachability doctor path
27
+ before serving.
28
+ - The guided wizard now writes model-facing capability descriptions,
29
+ per-argument descriptions, returns hints, and can opt into
30
+ `result_format: 2`.
31
+ - `result_format: 2` gives MCP clients a stable envelope with `ok`, `summary`,
32
+ `data`, `proposal`, `error`, `evidence`, `source_database_changed`, and
33
+ `_meta.canonical_capability`.
34
+ - `tools list`, `tools list --aliases`, and
35
+ `mcp client-config --include-instructions` help users inspect exposed tools
36
+ and generate client snippets without source reading.
37
+
38
+ ### Handler Security
39
+
40
+ - Generated handler templates, template-list output, app-owned writeback docs,
41
+ and examples now explicitly warn that the app handler owns the final business
42
+ write. Handlers must re-check tenant/scope, expected-version or conflict
43
+ guard, idempotency, allowed business action, transaction/rollback, and safe
44
+ error receipts before mutating application state.
45
+ - The guided review-mode wizard can now write a starter handler template when
46
+ the app-owned HTTP or command handler path is selected.
47
+
14
48
  ## 0.1.0-alpha.15
15
49
 
16
50
  ### Handler Wording Clarification
@@ -217,5 +251,5 @@ After publishing an alpha, verify the public package from a clean temporary
217
251
  directory:
218
252
 
219
253
  ```bash
220
- ./scripts/verify-published-alpha.sh 0.1.0-alpha.15
254
+ ./scripts/verify-published-alpha.sh 0.1.0-alpha.16
221
255
  ```
@@ -5,7 +5,7 @@ or an exact version:
5
5
 
6
6
  ```bash
7
7
  npx -y -p @synapsor/runner@alpha synapsor-runner demo --quick
8
- npm install -g @synapsor/runner@0.1.0-alpha.15
8
+ npm install -g @synapsor/runner@0.1.0-alpha.16
9
9
  ```
10
10
 
11
11
  Do not rely on the untagged `latest` dist-tag until a stable release is
@@ -38,12 +38,26 @@ A stable `0.1.0` release should only be tagged after:
38
38
  - npm README commands match the published package;
39
39
  - `synapsor-runner demo --quick` works from a clean directory;
40
40
  - own-database onboarding works from a clean directory;
41
+ - one-command review-mode `synapsor-runner up` is verified from a clean
42
+ directory and clearly prints model-facing tools, writeback path, handler
43
+ requirements, and next commands;
44
+ - review-mode wizard output is verified for one read capability plus one
45
+ proposal capability;
46
+ - handler template security warnings are verified in docs, CLI output, and
47
+ generated templates;
41
48
  - stdio MCP and Streamable HTTP MCP are both verified;
42
49
  - OpenAI alias mode is verified;
43
50
  - direct SQL writeback requirements are documented and tested;
44
51
  - app-owned executor requirements are documented and tested;
45
52
  - local evidence/proposal/receipt/replay inspection works;
46
53
  - current limitations are accurate.
54
+ - at least one external developer can follow the README without reading source;
55
+ - there are no known docs/code mismatches around transport, credentials,
56
+ receipt tables, or handler expectations.
57
+
58
+ Serious alpha users should pin an exact alpha version in package.json, CI, and
59
+ MCP client snippets. Use `@alpha` only when intentionally testing the moving
60
+ preview channel.
47
61
 
48
62
  ## Result Envelope Migration
49
63
 
@@ -115,6 +115,13 @@ The handler receives proposal fields, the exact patch, evidence metadata,
115
115
  guards, and an idempotency key. It does not receive arbitrary model SQL or DB
116
116
  credentials from Synapsor Runner.
117
117
 
118
+ > **Important:** your app handler owns the final business write. Runner creates
119
+ > the proposal and calls your handler only after approval, but your handler must
120
+ > still enforce tenant/scope checks, expected-version or conflict guards,
121
+ > idempotency keys, allowed business actions, transaction/rollback, and safe
122
+ > error receipts. If you skip those checks, you can reintroduce cross-tenant
123
+ > writes, lost updates, or duplicate writes. Keep handler credentials out of MCP.
124
+
118
125
  When `signing_secret_env` is set, Runner signs the exact JSON body with HMAC
119
126
  SHA-256 and sends:
120
127
 
@@ -220,6 +227,12 @@ npx -y -p @synapsor/runner@alpha synapsor-runner handler template command \
220
227
  The command receives the same structured JSON request on stdin and should print
221
228
  a JSON receipt body on stdout.
222
229
 
230
+ > **Important:** command handlers have the same responsibility as HTTP
231
+ > handlers. Re-check tenant/scope, expected-version or conflict guard,
232
+ > idempotency, allowed business action, transaction/rollback, and safe error
233
+ > receipt before mutating state. Otherwise the script can reintroduce
234
+ > cross-tenant writes, lost updates, or duplicate writes.
235
+
223
236
  Use `examples/app-owned-writeback/command-handler.mjs` as a starting point when
224
237
  your safest apply path is an app script or job runner.
225
238
 
@@ -14,6 +14,13 @@ The model-facing MCP tool still only creates a proposal. Approval happens
14
14
  outside MCP. After approval, `synapsor-runner apply` sends a structured request
15
15
  to your handler, and the handler returns an execution receipt for replay.
16
16
 
17
+ > **Important:** your app handler owns the final business write. Runner creates
18
+ > the proposal and calls your handler only after approval, but your handler must
19
+ > still enforce tenant/scope checks, expected-version or conflict guards,
20
+ > idempotency keys, allowed business actions, transaction/rollback, and safe
21
+ > error receipts. If you skip those checks, you can reintroduce cross-tenant
22
+ > writes, lost updates, or duplicate writes. Keep handler credentials out of MCP.
23
+
17
24
  ## Config Snippet
18
25
 
19
26
  Add an executor and point one proposal capability at it:
@@ -26,6 +26,15 @@ if (request.dry_run) {
26
26
  }
27
27
 
28
28
  /*
29
+ * IMPORTANT: your app handler owns the final business write.
30
+ * Runner creates the proposal and calls your handler only after approval,
31
+ * but your handler must still enforce tenant/scope, expected-version or
32
+ * conflict guard, idempotency key, allowed business action,
33
+ * transaction/rollback, and safe error receipt.
34
+ *
35
+ * If you skip those checks, you can reintroduce cross-tenant writes,
36
+ * lost updates, or duplicate writes. Keep handler credentials out of MCP.
37
+ *
29
38
  * Put your app-owned command transaction here.
30
39
  *
31
40
  * Examples:
@@ -28,6 +28,15 @@ app.post("/synapsor/writeback", async (request, reply) => {
28
28
  }
29
29
 
30
30
  /*
31
+ * IMPORTANT: your app handler owns the final business write.
32
+ * Runner creates the proposal and calls your handler only after approval,
33
+ * but your handler must still enforce tenant/scope, expected-version or
34
+ * conflict guard, idempotency key, allowed business action,
35
+ * transaction/rollback, and safe error receipt.
36
+ *
37
+ * If you skip those checks, you can reintroduce cross-tenant writes,
38
+ * lost updates, or duplicate writes. Keep handler credentials out of MCP.
39
+ *
31
40
  * Put your app-owned transaction here.
32
41
  *
33
42
  * Examples:
@@ -36,6 +36,15 @@ def writeback(request: HandlerRequest, authorization: str | None = Header(defaul
36
36
  "details": {"dry_run": True},
37
37
  }
38
38
 
39
+ # IMPORTANT: your app handler owns the final business write.
40
+ # Runner creates the proposal and calls your handler only after approval,
41
+ # but your handler must still enforce tenant/scope, expected-version or
42
+ # conflict guard, idempotency key, allowed business action,
43
+ # transaction/rollback, and safe error receipt.
44
+ #
45
+ # If you skip those checks, you can reintroduce cross-tenant writes,
46
+ # lost updates, or duplicate writes. Keep handler credentials out of MCP.
47
+ #
39
48
  # Put your app-owned transaction here.
40
49
  #
41
50
  # Examples:
@@ -26,6 +26,13 @@ App-owned rich writeback:
26
26
  The model never receives `execute_sql`, approval tools, commit/apply tools,
27
27
  database URLs, or write credentials.
28
28
 
29
+ > **Important:** the app handler owns the final business write. Runner creates
30
+ > the proposal and calls the handler only after approval, but the handler must
31
+ > still enforce tenant/scope checks, expected-version or conflict guards,
32
+ > idempotency keys, allowed business actions, transaction/rollback, and safe
33
+ > error receipts. If those checks are skipped, the app can reintroduce
34
+ > cross-tenant writes, lost updates, or duplicate writes.
35
+
29
36
  ## Run
30
37
 
31
38
  From the repository root:
@@ -54,6 +54,12 @@ async function shutdown() {
54
54
  }
55
55
 
56
56
  async function applyAccountCredit(job, tx) {
57
+ /*
58
+ * IMPORTANT: this app handler owns the final business write.
59
+ * The helper has already verified auth, tenant scope, expected version,
60
+ * idempotency, and transaction wrapping before this function runs. Keep that
61
+ * pattern if you replace the helper or move this logic into your app.
62
+ */
57
63
  const amountCents = Number(job.patch.credit_requested_cents);
58
64
  const reason = String(job.patch.credit_reason || "approved account credit");
59
65
  if (!Number.isInteger(amountCents) || amountCents <= 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapsor/runner",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.16",
4
4
  "description": "Commit-safe MCP runner for Postgres and MySQL agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,7 +24,12 @@
24
24
  "object_name": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" },
25
25
  "inspect_tool_name": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)+$" },
26
26
  "proposal_tool_name": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)+$" },
27
+ "inspect_description": { "type": "string", "minLength": 1 },
28
+ "proposal_description": { "type": "string", "minLength": 1 },
29
+ "inspect_returns_hint": { "type": "string", "minLength": 1 },
30
+ "proposal_returns_hint": { "type": "string", "minLength": 1 },
27
31
  "lookup_arg": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" },
32
+ "result_format": { "enum": [1, 2] },
28
33
  "visible_columns": {
29
34
  "type": "array",
30
35
  "items": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" },