@synapsor/runner 0.1.0-alpha.11 → 0.1.0-alpha.14

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +169 -23
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/runner.mjs +855 -66
  5. package/docs/README.md +21 -0
  6. package/docs/app-owned-executors.md +5 -0
  7. package/docs/capability-authoring.md +265 -0
  8. package/docs/doctor.md +98 -0
  9. package/docs/handler-helper.md +217 -0
  10. package/docs/local-mode.md +13 -2
  11. package/docs/release-notes.md +57 -2
  12. package/docs/release-policy.md +86 -0
  13. package/docs/result-envelope-v2.md +148 -0
  14. package/docs/rfcs/001-result-envelope-v2.md +143 -0
  15. package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
  16. package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
  17. package/docs/store-lifecycle.md +83 -0
  18. package/docs/writeback-executors.md +18 -0
  19. package/examples/app-owned-writeback/README.md +1 -0
  20. package/examples/mcp-postgres-billing-app-handler/README.md +7 -2
  21. package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +77 -149
  22. package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +1 -0
  23. package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
  24. package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +1 -0
  25. package/package.json +3 -1
  26. package/schemas/change-set.v1.schema.json +140 -0
  27. package/schemas/execution-receipt.v1.schema.json +34 -0
  28. package/schemas/onboarding-selection.v1.schema.json +125 -0
  29. package/schemas/runner-registration.v1.schema.json +48 -0
  30. package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
  31. package/schemas/synapsor.app-handler-request.v1.json +119 -0
  32. package/schemas/synapsor.runner.schema.json +412 -0
  33. package/schemas/writeback-job.v1.schema.json +121 -0
package/dist/runner.mjs CHANGED
@@ -444,7 +444,7 @@ function sleep(ms) {
444
444
  }
445
445
 
446
446
  // packages/config/src/index.ts
447
- var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "cloud", "strict"]);
447
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "mode", "storage", "sources", "trusted_context", "contexts", "executors", "capabilities", "cloud", "strict", "result_format"]);
448
448
  var STORAGE_KEYS = /* @__PURE__ */ new Set(["sqlite_path"]);
449
449
  var CLOUD_KEYS = /* @__PURE__ */ new Set(["base_url_env", "runner_token_env", "runner_id", "runner_version", "project_id", "adapter_id", "source_id", "engines", "capabilities", "session"]);
450
450
  var SOURCE_KEYS = /* @__PURE__ */ new Set([
@@ -456,11 +456,13 @@ var SOURCE_KEYS = /* @__PURE__ */ new Set([
456
456
  ]);
457
457
  var TRUSTED_CONTEXT_KEYS = /* @__PURE__ */ new Set(["provider", "values"]);
458
458
  var CONTEXT_KEYS = TRUSTED_CONTEXT_KEYS;
459
- var EXECUTOR_KEYS = /* @__PURE__ */ new Set(["type", "url_env", "method", "auth", "timeout_ms", "command_env"]);
459
+ var EXECUTOR_KEYS = /* @__PURE__ */ new Set(["type", "url_env", "method", "auth", "signing_secret_env", "timeout_ms", "command_env"]);
460
460
  var EXECUTOR_AUTH_KEYS = /* @__PURE__ */ new Set(["type", "token_env"]);
461
461
  var CAPABILITY_KEYS = /* @__PURE__ */ new Set([
462
462
  "name",
463
463
  "kind",
464
+ "description",
465
+ "returns_hint",
464
466
  "source",
465
467
  "context",
466
468
  "executor",
@@ -480,7 +482,7 @@ var CAPABILITY_KEYS = /* @__PURE__ */ new Set([
480
482
  ]);
481
483
  var TARGET_KEYS = /* @__PURE__ */ new Set(["schema", "table", "primary_key", "tenant_key", "single_tenant_dev"]);
482
484
  var LOOKUP_KEYS = /* @__PURE__ */ new Set(["id_from_arg"]);
483
- var ARG_KEYS = /* @__PURE__ */ new Set(["type", "required", "max_length", "minimum", "maximum", "enum"]);
485
+ var ARG_KEYS = /* @__PURE__ */ new Set(["type", "description", "required", "max_length", "minimum", "maximum", "enum"]);
484
486
  var PATCH_BINDING_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
485
487
  var NUMERIC_BOUND_KEYS = /* @__PURE__ */ new Set(["minimum", "maximum"]);
486
488
  var TRANSITION_GUARD_KEYS = /* @__PURE__ */ new Set(["from_column", "allowed"]);
@@ -549,6 +551,9 @@ function validateRunnerCapabilityConfig(input) {
549
551
  if (input.version !== 1) {
550
552
  errors.push({ path: "$.version", code: "UNSUPPORTED_CONFIG_VERSION", message: "Runner config version must be 1." });
551
553
  }
554
+ if (input.result_format !== void 0 && input.result_format !== 1 && input.result_format !== 2) {
555
+ errors.push({ path: "$.result_format", code: "INVALID_RESULT_FORMAT", message: "result_format must be 1 or 2." });
556
+ }
552
557
  if (!isRunnerMode(input.mode)) {
553
558
  errors.push({ path: "$.mode", code: "INVALID_MODE", message: "mode must be read_only, shadow, review, or cloud." });
554
559
  }
@@ -797,6 +802,9 @@ function validateExecutors(value, mode, strict, errors) {
797
802
  errors.push({ path: `${path4}.method`, code: "INVALID_HANDLER_METHOD", message: "http_handler.method must be POST, PUT, or PATCH." });
798
803
  }
799
804
  validateExecutorAuth(executor.auth, `${path4}.auth`, strict, errors);
805
+ if (executor.signing_secret_env !== void 0 && !isEnvName(executor.signing_secret_env)) {
806
+ errors.push({ path: `${path4}.signing_secret_env`, code: "HANDLER_SIGNING_SECRET_ENV_INVALID", message: "http_handler.signing_secret_env must name an environment variable containing the HMAC signing secret." });
807
+ }
800
808
  if (executor.timeout_ms !== void 0 && !isPositiveInteger(executor.timeout_ms)) {
801
809
  errors.push({ path: `${path4}.timeout_ms`, code: "INVALID_HANDLER_TIMEOUT", message: "http_handler.timeout_ms must be a positive integer." });
802
810
  }
@@ -852,6 +860,12 @@ function validateCapability(value, index, sourceNames, contextNames, executorNam
852
860
  if (!isCapabilityKind(value.kind)) {
853
861
  errors.push({ path: `${path4}.kind`, code: "INVALID_CAPABILITY_KIND", message: "kind must be read or proposal." });
854
862
  }
863
+ if (value.description !== void 0 && !isNonEmptyString(value.description)) {
864
+ errors.push({ path: `${path4}.description`, code: "INVALID_CAPABILITY_DESCRIPTION", message: "description must be a non-empty string when provided." });
865
+ }
866
+ if (value.returns_hint !== void 0 && !isNonEmptyString(value.returns_hint)) {
867
+ errors.push({ path: `${path4}.returns_hint`, code: "INVALID_RETURNS_HINT", message: "returns_hint must be a non-empty string when provided." });
868
+ }
855
869
  if (!isNonEmptyString(value.source) || !sourceNames.has(value.source)) {
856
870
  errors.push({ path: `${path4}.source`, code: "UNKNOWN_SOURCE", message: "Capability source must reference a configured source." });
857
871
  }
@@ -926,6 +940,9 @@ function validateArgs(value, path4, strict, errors) {
926
940
  if (!["string", "number", "boolean"].includes(String(arg.type))) {
927
941
  errors.push({ path: `${argPath}.type`, code: "INVALID_ARG_TYPE", message: "Argument type must be string, number, or boolean." });
928
942
  }
943
+ if (arg.description !== void 0 && !isNonEmptyString(arg.description)) {
944
+ errors.push({ path: `${argPath}.description`, code: "INVALID_ARG_DESCRIPTION", message: "Argument description must be a non-empty string when provided." });
945
+ }
929
946
  if (arg.max_length !== void 0 && !isPositiveInteger(arg.max_length)) {
930
947
  errors.push({ path: `${argPath}.max_length`, code: "INVALID_MAX_LENGTH", message: "max_length must be a positive integer." });
931
948
  }
@@ -2073,6 +2090,11 @@ var ProposalStore = class {
2073
2090
  const rows = this.db.prepare("SELECT * FROM proposal_events WHERE proposal_id = ? ORDER BY event_id ASC").all(proposalId);
2074
2091
  return rows.map(rowToEvent).filter((event) => event !== void 0);
2075
2092
  }
2093
+ listEvents(filters = {}) {
2094
+ const query = buildEventQuery(filters);
2095
+ const rows = this.db.prepare(query.sql).all(...query.params);
2096
+ return rows.map(rowToEvent).filter((event) => event !== void 0);
2097
+ }
2076
2098
  receipts(proposalId) {
2077
2099
  const rows = this.db.prepare("SELECT * FROM writeback_receipts WHERE proposal_id = ? ORDER BY receipt_id ASC").all(proposalId);
2078
2100
  return rows.map(rowToReceipt).filter((receipt) => receipt !== void 0);
@@ -2387,6 +2409,15 @@ function buildReceiptQuery(filters) {
2387
2409
  addTimeRange(clauses, params, "created_at", filters.from, filters.to);
2388
2410
  return finishQuery("SELECT * FROM writeback_receipts", clauses, params, filters.limit);
2389
2411
  }
2412
+ function buildEventQuery(filters) {
2413
+ const clauses = [];
2414
+ const params = [];
2415
+ addEqual(clauses, params, "proposal_id", filters.proposal);
2416
+ addEqual(clauses, params, "kind", filters.kind);
2417
+ addEqual(clauses, params, "actor", filters.actor);
2418
+ addTimeRange(clauses, params, "created_at", filters.from, filters.to);
2419
+ return finishQuery("SELECT * FROM proposal_events", clauses, params, filters.limit);
2420
+ }
2390
2421
  function addEqual(clauses, params, column, value) {
2391
2422
  if (!value) return;
2392
2423
  clauses.push(`${column} = ?`);
@@ -2670,24 +2701,53 @@ function loadRuntimeConfigFromFile(configPath = process.env.SYNAPSOR_MCP_CONFIG
2670
2701
  function createMcpRuntime(config, options = {}) {
2671
2702
  assertValidRunnerCapabilityConfig(config);
2672
2703
  const env = options.env ?? process.env;
2673
- const store = options.store ?? new ProposalStore(options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db");
2704
+ const storePath = options.storePath ?? config.storage?.sqlite_path ?? "./.synapsor/local.db";
2705
+ const ownsStore = !options.store;
2706
+ const store = options.store ?? new ProposalStore(storePath);
2674
2707
  const readRow = options.readRow ?? readCurrentRow;
2675
2708
  const cloudClient = options.controlPlaneClient ?? (config.mode === "cloud" ? createCloudClient(config, env) : void 0);
2676
2709
  const cloudTools = options.cloudTools ?? [];
2710
+ const resultFormat = options.resultFormat ?? config.result_format ?? 1;
2711
+ const assertStoreAvailable = () => {
2712
+ if (ownsStore) assertPersistentStoreAvailable(storePath);
2713
+ };
2677
2714
  return {
2678
2715
  config,
2679
2716
  store,
2680
2717
  listTools: () => config.mode === "cloud" ? cloudTools : listedLocalCapabilities(config).map((capability) => toolMetadata(capability)),
2681
- callTool: async (name, args) => callConfiguredTool({ config, env, store, readRow, cloudClient, name, args }),
2682
- readResource: (uri) => readLocalResource(store, uri),
2718
+ callTool: async (name, args) => {
2719
+ if (resultFormat === 2) {
2720
+ try {
2721
+ assertStoreAvailable();
2722
+ return await callConfiguredToolV2({ config, env, store, readRow, cloudClient, name, args });
2723
+ } catch (error) {
2724
+ const capability = config.mode === "cloud" ? void 0 : localCapabilities(config).find((item) => item.name === name);
2725
+ return errorEnvelopeFromError(error, capability, name);
2726
+ }
2727
+ }
2728
+ assertStoreAvailable();
2729
+ return callConfiguredTool({ config, env, store, readRow, cloudClient, name, args });
2730
+ },
2731
+ readResource: (uri) => {
2732
+ assertStoreAvailable();
2733
+ return readLocalResource(store, uri);
2734
+ },
2683
2735
  close: () => {
2684
2736
  if (!options.store) store.close();
2685
2737
  }
2686
2738
  };
2687
2739
  }
2740
+ function assertPersistentStoreAvailable(storePath) {
2741
+ if (storePath === ":memory:") return;
2742
+ if (fs.existsSync(storePath)) return;
2743
+ throw new McpRuntimeError(
2744
+ "LOCAL_STORE_UNAVAILABLE",
2745
+ "The local Synapsor store is temporarily unavailable. Restart the runner or recreate the store before retrying."
2746
+ );
2747
+ }
2688
2748
  function createSynapsorMcpServer(runtime, options = {}) {
2689
2749
  const server = new McpServer(
2690
- { name: "synapsor-runner", version: "0.1.0-alpha.11" },
2750
+ { name: "synapsor-runner", version: "0.1.0-alpha.14" },
2691
2751
  { capabilities: { tools: {}, resources: {} } }
2692
2752
  );
2693
2753
  const toolNameStyle = options.toolNameStyle ?? "canonical";
@@ -2700,7 +2760,7 @@ function createSynapsorMcpServer(runtime, options = {}) {
2700
2760
  exposedName,
2701
2761
  {
2702
2762
  title: tool.title,
2703
- description: tool.description,
2763
+ description: toolDescriptionWithCanonical(tool.description, tool.name, exposedName),
2704
2764
  inputSchema: zodInputShapeFromJsonSchema(tool.input_schema),
2705
2765
  annotations: {
2706
2766
  readOnlyHint: Boolean(tool.annotations.readOnlyHint),
@@ -2731,7 +2791,7 @@ function createSynapsorMcpServer(runtime, options = {}) {
2731
2791
  exposedName,
2732
2792
  {
2733
2793
  title: capability.name,
2734
- description: capabilityDescription(capability),
2794
+ description: capabilityDescription(capability, exposedName),
2735
2795
  inputSchema: zodInputShape(capability),
2736
2796
  annotations: {
2737
2797
  readOnlyHint: capability.kind === "read",
@@ -2778,7 +2838,7 @@ function createSynapsorMcpServer(runtime, options = {}) {
2778
2838
  async function serveStdio(options = {}) {
2779
2839
  const config = options.config ?? loadRuntimeConfigFromFile(options.configPath);
2780
2840
  const cloudTools = config.mode === "cloud" ? await fetchCloudToolMetadata(config, process.env) : void 0;
2781
- const runtime = createMcpRuntime(config, { storePath: options.storePath, cloudTools });
2841
+ const runtime = createMcpRuntime(config, { storePath: options.storePath, resultFormat: options.resultFormat, cloudTools });
2782
2842
  const server = createSynapsorMcpServer(runtime, { toolNameStyle: options.toolNameStyle });
2783
2843
  const transport = new StdioServerTransport();
2784
2844
  await server.connect(transport);
@@ -2814,6 +2874,7 @@ async function startHttpMcpServer(options = {}) {
2814
2874
  const runtime = createMcpRuntime(config, {
2815
2875
  env,
2816
2876
  storePath: options.storePath,
2877
+ resultFormat: options.resultFormat,
2817
2878
  readRow: options.readRow,
2818
2879
  cloudTools
2819
2880
  });
@@ -2888,6 +2949,7 @@ async function startStreamableHttpMcpServer(options = {}) {
2888
2949
  cloudTools,
2889
2950
  env,
2890
2951
  toolNameStyle: options.toolNameStyle,
2952
+ resultFormat: options.resultFormat,
2891
2953
  authToken,
2892
2954
  devNoAuth,
2893
2955
  corsOrigin: options.corsOrigin,
@@ -2930,7 +2992,7 @@ async function startStreamableHttpMcpServer(options = {}) {
2930
2992
  };
2931
2993
  }
2932
2994
  async function handleStreamableHttpMcpRequest(input) {
2933
- const { request, response, config, storePath, readRow, cloudTools, env, toolNameStyle, authToken, devNoAuth, corsOrigin, sessions, openSessions } = input;
2995
+ const { request, response, config, storePath, readRow, cloudTools, env, toolNameStyle, resultFormat, authToken, devNoAuth, corsOrigin, sessions, openSessions } = input;
2934
2996
  try {
2935
2997
  setCorsHeaders(response, corsOrigin);
2936
2998
  if (request.method === "OPTIONS" && corsOrigin) {
@@ -2992,7 +3054,7 @@ async function handleStreamableHttpMcpRequest(input) {
2992
3054
  }
2993
3055
  }
2994
3056
  });
2995
- const runtime = createMcpRuntime(config, { env, storePath, readRow, cloudTools });
3057
+ const runtime = createMcpRuntime(config, { env, storePath, resultFormat, readRow, cloudTools });
2996
3058
  session = { transport, runtime };
2997
3059
  openSessions.add(session);
2998
3060
  transport.onclose = () => {
@@ -3330,6 +3392,11 @@ function cloudToolMetadata(tool) {
3330
3392
  }
3331
3393
  };
3332
3394
  }
3395
+ function toolDescriptionWithCanonical(description, canonicalName, exposedName) {
3396
+ if (!exposedName || exposedName === canonicalName) return description;
3397
+ return `Canonical Synapsor capability: ${canonicalName}.
3398
+ ${description}`;
3399
+ }
3333
3400
  function zodInputShapeFromJsonSchema(schema) {
3334
3401
  const properties = isRecord3(schema.properties) ? schema.properties : {};
3335
3402
  const required = Array.isArray(schema.required) ? new Set(schema.required.map(String)) : /* @__PURE__ */ new Set();
@@ -3507,6 +3574,135 @@ async function callConfiguredTool(input) {
3507
3574
  source_database_mutated: false
3508
3575
  };
3509
3576
  }
3577
+ async function callConfiguredToolV2(input) {
3578
+ const capability = input.config.mode === "cloud" ? void 0 : localCapabilities(input.config).find((item) => item.name === input.name);
3579
+ try {
3580
+ const legacy = await callConfiguredTool(input);
3581
+ return resultEnvelopeFromLegacy(legacy, capability, input.name);
3582
+ } catch (error) {
3583
+ return errorEnvelopeFromError(error, capability, input.name);
3584
+ }
3585
+ }
3586
+ function resultEnvelopeFromLegacy(legacy, capability, canonicalName) {
3587
+ const action = typeof legacy.action === "string" ? legacy.action : canonicalName;
3588
+ const kind = capability?.kind ?? (typeof legacy.proposal_id === "string" ? "proposal" : "read");
3589
+ const evidenceBundleId = typeof legacy.evidence_bundle_id === "string" ? legacy.evidence_bundle_id : void 0;
3590
+ const sourceChanged = Boolean(legacy.source_database_changed ?? legacy.source_database_mutated ?? false);
3591
+ const context = isRecord3(legacy.trusted_context) ? legacy.trusted_context : void 0;
3592
+ const target2 = isRecord3(legacy.target) ? legacy.target : void 0;
3593
+ if (kind === "proposal") {
3594
+ const proposalId = typeof legacy.proposal_id === "string" ? legacy.proposal_id : "wrp_unknown";
3595
+ const targetType = typeof target2?.type === "string" ? target2.type : capability?.target.table ?? "object";
3596
+ const targetId = target2?.id !== void 0 ? String(target2.id) : "unknown";
3597
+ const executor = writebackExecutorName(legacy.writeback);
3598
+ const writebackMode = executor && executor !== "sql_update" && executor !== "trusted_worker_required" ? "app_handler" : "direct_update";
3599
+ return {
3600
+ ok: true,
3601
+ summary: `Created proposal ${proposalId} for ${targetType} ${targetId}. Source database changed: no.`,
3602
+ action,
3603
+ kind,
3604
+ data: null,
3605
+ proposal: {
3606
+ id: proposalId,
3607
+ state: typeof legacy.status === "string" ? legacy.status : "review_required",
3608
+ target: `${targetType}:${targetId}`,
3609
+ diff: isRecord3(legacy.diff) ? legacy.diff : {},
3610
+ approval_required: legacy.approval_required !== false,
3611
+ writeback: {
3612
+ mode: writebackMode,
3613
+ applied: false
3614
+ },
3615
+ next: "A human must approve outside this model-facing tool surface; nothing is committed yet."
3616
+ },
3617
+ error: null,
3618
+ evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
3619
+ source_database_changed: sourceChanged,
3620
+ _meta: {
3621
+ tenant_id: typeof target2?.tenant_id === "string" ? target2.tenant_id : void 0,
3622
+ principal: typeof context?.principal === "string" ? context.principal : void 0,
3623
+ provenance: typeof context?.provenance === "string" ? context.provenance : void 0,
3624
+ canonical_capability: action
3625
+ }
3626
+ };
3627
+ }
3628
+ const businessObject = isRecord3(legacy.business_object) ? legacy.business_object : void 0;
3629
+ const objectType = typeof businessObject?.type === "string" ? businessObject.type : capability?.target.table ?? "record";
3630
+ const objectId = businessObject?.id !== void 0 ? String(businessObject.id) : String(legacy.action ?? action);
3631
+ return {
3632
+ ok: true,
3633
+ summary: `Read ${objectType} ${objectId} through ${action}. Source database changed: no.`,
3634
+ action,
3635
+ kind: "read",
3636
+ data: isRecord3(legacy.data) ? legacy.data : {},
3637
+ proposal: null,
3638
+ error: null,
3639
+ evidence: evidenceBundleId ? evidenceHandle(evidenceBundleId) : null,
3640
+ source_database_changed: sourceChanged,
3641
+ _meta: {
3642
+ tenant_id: typeof context?.tenant_id === "string" ? context.tenant_id : void 0,
3643
+ principal: typeof context?.principal === "string" ? context.principal : void 0,
3644
+ provenance: typeof context?.provenance === "string" ? context.provenance : void 0,
3645
+ canonical_capability: action
3646
+ }
3647
+ };
3648
+ }
3649
+ function writebackExecutorName(value) {
3650
+ if (!isRecord3(value)) return void 0;
3651
+ return typeof value.executor === "string" ? value.executor : typeof value.mode === "string" ? value.mode : void 0;
3652
+ }
3653
+ function evidenceHandle(bundleId) {
3654
+ return {
3655
+ bundle_id: bundleId,
3656
+ note: "audit/replay handle; you do not need to act on it during this turn"
3657
+ };
3658
+ }
3659
+ function errorEnvelopeFromError(error, capability, canonicalName) {
3660
+ const safe = safeToolError(error);
3661
+ const action = capability?.name ?? canonicalName;
3662
+ return {
3663
+ ok: false,
3664
+ summary: safe.message,
3665
+ action,
3666
+ kind: capability?.kind ?? "read",
3667
+ data: null,
3668
+ proposal: null,
3669
+ error: safe,
3670
+ evidence: null,
3671
+ source_database_changed: false,
3672
+ _meta: {
3673
+ canonical_capability: action
3674
+ }
3675
+ };
3676
+ }
3677
+ function safeToolError(error) {
3678
+ const runtimeCode = error instanceof McpRuntimeError ? error.code : void 0;
3679
+ if (runtimeCode === "ROW_NOT_FOUND") {
3680
+ return { code: "NOT_FOUND_IN_TENANT", message: "No authorized row was found in the trusted tenant scope.", retryable: false };
3681
+ }
3682
+ if (runtimeCode === "MCP_TOOL_NOT_FOUND") {
3683
+ return { code: "CAPABILITY_NOT_FOUND", message: "The requested Synapsor capability is not available.", retryable: false };
3684
+ }
3685
+ if (runtimeCode === "PROPOSALS_DISABLED") {
3686
+ return { code: "APPROVAL_REQUIRED", message: "Proposal tools are disabled for this runner mode.", retryable: false };
3687
+ }
3688
+ if (runtimeCode && (runtimeCode.startsWith("ARGUMENT_") || runtimeCode === "LOOKUP_ARG_MISSING" || runtimeCode === "MODEL_CANNOT_OVERRIDE_BINDING" || runtimeCode === "TRUSTED_BINDING_MISSING" || runtimeCode === "TRUSTED_CONTEXT_MISSING")) {
3689
+ return { code: "INVALID_ARGUMENT", message: "The tool input or trusted context binding is invalid.", retryable: false };
3690
+ }
3691
+ if (runtimeCode && (runtimeCode.startsWith("PATCH_") || runtimeCode === "CONFLICT_GUARD_MISSING")) {
3692
+ return { code: "POLICY_VIOLATION", message: "The requested change is outside the reviewed capability policy.", retryable: false };
3693
+ }
3694
+ if (runtimeCode === "LOCAL_STORE_UNAVAILABLE") {
3695
+ return { code: "TEMPORARILY_UNAVAILABLE", message: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying.", retryable: true };
3696
+ }
3697
+ if (runtimeCode === "SOURCE_CREDENTIAL_MISSING" || looksLikeInfraError(error)) {
3698
+ return { code: "TEMPORARILY_UNAVAILABLE", message: "The database is temporarily unavailable. Retry later.", retryable: true };
3699
+ }
3700
+ return { code: "INTERNAL", message: "The capability failed safely. Check the local runner logs for details.", retryable: false };
3701
+ }
3702
+ function looksLikeInfraError(error) {
3703
+ const message = error instanceof Error ? error.message : String(error ?? "");
3704
+ return /\b(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|timeout|connect|connection|database|authentication|certificate)\b/i.test(message);
3705
+ }
3510
3706
  function buildChangeSet(input) {
3511
3707
  const patch = buildPatch(input.capability, input.args);
3512
3708
  const before = scalarRecord(input.currentRow);
@@ -3708,7 +3904,7 @@ function zodInputShape(capability) {
3708
3904
  if (spec.type === "number" && spec.maximum !== void 0) schema = schema.max(spec.maximum);
3709
3905
  if (spec.enum && spec.enum.length > 0) schema = schema.refine((value) => spec.enum?.includes(value), "value is not allowlisted");
3710
3906
  if (spec.required === false) schema = schema.optional();
3711
- shape[name] = schema.describe(`${name} business argument`);
3907
+ shape[name] = schema.describe(spec.description ?? `${name} business argument`);
3712
3908
  }
3713
3909
  return shape;
3714
3910
  }
@@ -3721,6 +3917,7 @@ function toolMetadata(capability) {
3721
3917
  input_schema: Object.fromEntries(Object.entries(capability.args).map(([name, spec]) => [name, {
3722
3918
  type: spec.type,
3723
3919
  required: spec.required !== false,
3920
+ ...spec.description !== void 0 ? { description: spec.description } : {},
3724
3921
  ...spec.max_length !== void 0 ? { max_length: spec.max_length } : {},
3725
3922
  ...spec.minimum !== void 0 ? { minimum: spec.minimum } : {},
3726
3923
  ...spec.maximum !== void 0 ? { maximum: spec.maximum } : {},
@@ -3736,11 +3933,23 @@ function toolMetadata(capability) {
3736
3933
  }
3737
3934
  };
3738
3935
  }
3739
- function capabilityDescription(capability) {
3740
- if (capability.kind === "read") {
3741
- return `Read ${capability.target.schema}.${capability.target.table} through a reviewed Synapsor capability with trusted tenant context and evidence.`;
3936
+ function capabilityDescription(capability, exposedName) {
3937
+ const lines = [];
3938
+ if (exposedName && exposedName !== capability.name) {
3939
+ lines.push(`Canonical Synapsor capability: ${capability.name}.`);
3940
+ }
3941
+ if (capability.description) {
3942
+ lines.push(capability.description);
3943
+ } else if (capability.kind === "read") {
3944
+ lines.push(`Read ${capability.target.schema}.${capability.target.table} through a reviewed Synapsor capability with trusted tenant context and evidence.`);
3945
+ } else {
3946
+ lines.push(`Create an evidence-backed Synapsor proposal for ${capability.target.schema}.${capability.target.table}; the source database is not mutated by this tool.`);
3947
+ }
3948
+ if (capability.returns_hint) {
3949
+ lines.push(capability.returns_hint);
3742
3950
  }
3743
- return `Create an evidence-backed Synapsor proposal for ${capability.target.schema}.${capability.target.table}; the source database is not mutated by this tool.`;
3951
+ lines.push("Evidence handles are audit/replay handles; the model does not need to call them during this turn.");
3952
+ return lines.join("\n");
3744
3953
  }
3745
3954
  function buildPatch(capability, args) {
3746
3955
  if (!capability.patch) throw new McpRuntimeError("PATCH_REQUIRED", "Proposal capability has no patch mapping.");
@@ -3857,6 +4066,9 @@ function isRecord3(value) {
3857
4066
  }
3858
4067
  function toolErrorPayload(error) {
3859
4068
  if (error instanceof McpRuntimeError) {
4069
+ if (error.code === "LOCAL_STORE_UNAVAILABLE") {
4070
+ return { ok: false, code: "TEMPORARILY_UNAVAILABLE", error: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying." };
4071
+ }
3860
4072
  return { ok: false, code: error.code, error: error.message };
3861
4073
  }
3862
4074
  return { ok: false, code: "MCP_TOOL_FAILED", error: error instanceof Error ? error.message : String(error) };
@@ -4719,6 +4931,7 @@ function normalizedWriteback(spec) {
4719
4931
  if (executor === "http_handler") {
4720
4932
  const urlEnv = spec.writeback?.handler_url_env ?? "SYNAPSOR_APP_WRITEBACK_URL";
4721
4933
  const tokenEnv = spec.writeback?.handler_token_env;
4934
+ const signingSecretEnv = spec.writeback?.handler_signing_secret_env;
4722
4935
  return {
4723
4936
  executor,
4724
4937
  executorName,
@@ -4728,12 +4941,14 @@ function normalizedWriteback(spec) {
4728
4941
  url_env: urlEnv,
4729
4942
  method: "POST",
4730
4943
  ...tokenEnv ? { auth: { type: "bearer_env", token_env: tokenEnv } } : {},
4944
+ ...signingSecretEnv ? { signing_secret_env: signingSecretEnv } : {},
4731
4945
  timeout_ms: spec.writeback?.timeout_ms ?? 5e3
4732
4946
  }
4733
4947
  },
4734
4948
  extraEnv: [
4735
4949
  { name: urlEnv, value: "http://127.0.0.1:8787/synapsor/writeback", comment: "App-owned writeback handler endpoint." },
4736
- ...tokenEnv ? [{ name: tokenEnv, value: "<handler-bearer-token>", comment: "Optional handler bearer token." }] : []
4950
+ ...tokenEnv ? [{ name: tokenEnv, value: "<handler-bearer-token>", comment: "Optional handler bearer token." }] : [],
4951
+ ...signingSecretEnv ? [{ name: signingSecretEnv, value: "<handler-hmac-signing-secret>", comment: "Optional HMAC signing secret for Runner-to-handler requests." }] : []
4737
4952
  ]
4738
4953
  };
4739
4954
  }
@@ -4871,6 +5086,7 @@ function validateSelectionSpec(spec) {
4871
5086
  spec.write_url_env,
4872
5087
  spec.writeback?.handler_url_env,
4873
5088
  spec.writeback?.handler_token_env,
5089
+ spec.writeback?.handler_signing_secret_env,
4874
5090
  spec.writeback?.handler_command_env,
4875
5091
  spec.trusted_context?.tenant_id_env,
4876
5092
  spec.trusted_context?.principal_env
@@ -6585,6 +6801,7 @@ ${cliCommandName()} --help
6585
6801
  if (command === "query-audit") return queryAudit(rest);
6586
6802
  if (command === "receipts") return receipts(rest);
6587
6803
  if (command === "activity") return activity(rest);
6804
+ if (command === "events") return events(rest);
6588
6805
  if (command === "store") return storeCommand(rest);
6589
6806
  if (command === "shadow") return shadow(rest);
6590
6807
  if (command === "ui") return ui(rest);
@@ -6785,11 +7002,13 @@ async function runInitWizard(args, options = {}) {
6785
7002
  } else if (writebackPath === "http_handler") {
6786
7003
  const urlEnv = await askEnvName(ask, "App-owned HTTP handler URL env var", optionalArg(args, "--handler-url-env") ?? "SYNAPSOR_APP_WRITEBACK_URL");
6787
7004
  const tokenEnv = await askOptionalEnvName(ask, "Optional HTTP handler bearer-token env var", optionalArg(args, "--handler-token-env") ?? "");
7005
+ const signingSecretEnv = await askOptionalEnvName(ask, "Optional HTTP handler HMAC signing-secret env var", optionalArg(args, "--handler-signing-secret-env") ?? "");
6788
7006
  writeback2 = {
6789
7007
  executor: "http_handler",
6790
7008
  executor_name: optionalArg(args, "--executor-name"),
6791
7009
  handler_url_env: urlEnv,
6792
7010
  ...tokenEnv ? { handler_token_env: tokenEnv } : {},
7011
+ ...signingSecretEnv ? { handler_signing_secret_env: signingSecretEnv } : {},
6793
7012
  timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
6794
7013
  };
6795
7014
  } else {
@@ -7092,6 +7311,7 @@ function writebackSpecFromArgs(args) {
7092
7311
  executor_name: optionalArg(args, "--executor-name"),
7093
7312
  handler_url_env: optionalArg(args, "--handler-url-env") ?? "SYNAPSOR_APP_WRITEBACK_URL",
7094
7313
  ...optionalArg(args, "--handler-token-env") ? { handler_token_env: optionalArg(args, "--handler-token-env") } : {},
7314
+ ...optionalArg(args, "--handler-signing-secret-env") ? { handler_signing_secret_env: optionalArg(args, "--handler-signing-secret-env") } : {},
7095
7315
  timeout_ms: positiveIntegerOption(args, "--handler-timeout-ms")
7096
7316
  };
7097
7317
  }
@@ -7530,6 +7750,54 @@ function envPresenceCheck(envName, message) {
7530
7750
  message: process2.env[envName] ? `${envName} is set.` : message
7531
7751
  };
7532
7752
  }
7753
+ async function httpHandlerReachabilityCheck(executorName, rawUrl, timeoutMs) {
7754
+ try {
7755
+ const url = new URL(rawUrl);
7756
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
7757
+ return {
7758
+ name: `executor:${executorName}:handler-reachability`,
7759
+ ok: false,
7760
+ level: "fail",
7761
+ message: "HTTP handler URL must use http or https."
7762
+ };
7763
+ }
7764
+ } catch {
7765
+ return {
7766
+ name: `executor:${executorName}:handler-reachability`,
7767
+ ok: false,
7768
+ level: "fail",
7769
+ message: "HTTP handler URL env value is not a valid URL."
7770
+ };
7771
+ }
7772
+ const controller = new AbortController();
7773
+ const timeout = setTimeout(() => controller.abort(), Math.max(1, Math.min(timeoutMs || 3e3, 1e4)));
7774
+ try {
7775
+ const response = await fetch(rawUrl, {
7776
+ method: "OPTIONS",
7777
+ headers: { accept: "application/json" },
7778
+ signal: controller.signal
7779
+ });
7780
+ return {
7781
+ name: `executor:${executorName}:handler-reachability`,
7782
+ ok: true,
7783
+ level: "pass",
7784
+ message: `HTTP handler endpoint responded with HTTP ${response.status}; network path is reachable. This is not an apply/writeback probe.`
7785
+ };
7786
+ } catch (error) {
7787
+ return {
7788
+ name: `executor:${executorName}:handler-reachability`,
7789
+ ok: false,
7790
+ level: "fail",
7791
+ message: `HTTP handler endpoint did not respond to the reachability probe (${safeReachabilityError(error)}).`
7792
+ };
7793
+ } finally {
7794
+ clearTimeout(timeout);
7795
+ }
7796
+ }
7797
+ function safeReachabilityError(error) {
7798
+ if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return "timeout";
7799
+ return "connection failed";
7800
+ }
7533
7801
  async function inspectConfiguredSource(input) {
7534
7802
  if (!process2.env[input.source.read_url_env]) return;
7535
7803
  const capabilities = (input.config.capabilities ?? []).filter((capability) => capability.source === input.sourceName);
@@ -7959,6 +8227,8 @@ function formatFirstRunDoctor(report) {
7959
8227
  async function localDoctor(args) {
7960
8228
  const configPath = optionalArg(args, "--config") ?? "synapsor.runner.json";
7961
8229
  const allowSharedCredential = args.includes("--allow-shared-credential");
8230
+ const checkHandlers = args.includes("--check-handlers");
8231
+ const checkWriteback = args.includes("--check-writeback") || args.includes("--check-db");
7962
8232
  const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
7963
8233
  const checks = [];
7964
8234
  const validation = validateRunnerCapabilityConfig(parsed);
@@ -8001,6 +8271,24 @@ async function localDoctor(args) {
8001
8271
  } else {
8002
8272
  checks.push({ name: `source:${sourceName}:write-url-env`, ok: false, level: "fail", message: "SQL writeback proposal capabilities require write_url_env for trusted writeback." });
8003
8273
  }
8274
+ const writeUrl = source.write_url_env ? process2.env[source.write_url_env] : void 0;
8275
+ if (checkWriteback && writeUrl) {
8276
+ checks.push(...await directSqlWritebackDoctorChecks(parsed, sourceName, source, writeUrl));
8277
+ } else if (checkWriteback) {
8278
+ checks.push({
8279
+ name: `source:${sourceName}:writeback-probe`,
8280
+ ok: false,
8281
+ level: "fail",
8282
+ message: "Direct SQL writeback probe skipped because the writer env var is missing."
8283
+ });
8284
+ } else {
8285
+ checks.push({
8286
+ name: `source:${sourceName}:writeback-probe`,
8287
+ ok: true,
8288
+ level: "warn",
8289
+ message: `Direct SQL writeback was not probed. Rerun doctor with --check-writeback to verify writer connectivity, receipt-table permissions, and rollback-only target-table access.`
8290
+ });
8291
+ }
8004
8292
  }
8005
8293
  }
8006
8294
  await inspectConfiguredSource({ config: parsed, sourceName, source, checks });
@@ -8009,10 +8297,34 @@ async function localDoctor(args) {
8009
8297
  if (!isRecord6(executor)) continue;
8010
8298
  if (executor.type === "http_handler") {
8011
8299
  const urlEnv = String(executor.url_env ?? "");
8012
- if (urlEnv) checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
8300
+ if (urlEnv) {
8301
+ checks.push(envPresenceCheck(urlEnv, `${urlEnv} is required for http_handler executor ${executorName}.`));
8302
+ const handlerUrl = process2.env[urlEnv];
8303
+ if (checkHandlers && handlerUrl) {
8304
+ checks.push(await httpHandlerReachabilityCheck(executorName, handlerUrl, Number(executor.timeout_ms ?? 3e3)));
8305
+ } else if (!checkHandlers) {
8306
+ checks.push({
8307
+ name: `executor:${executorName}:handler-reachability`,
8308
+ ok: true,
8309
+ level: "warn",
8310
+ message: `Handler reachability was not probed for ${executorName}. Rerun doctor with --check-handlers to verify the network path without applying a proposal.`
8311
+ });
8312
+ }
8313
+ }
8013
8314
  const auth = isRecord6(executor.auth) ? executor.auth : void 0;
8014
8315
  const tokenEnv = typeof auth?.token_env === "string" ? auth.token_env : void 0;
8015
8316
  if (tokenEnv) checks.push(envPresenceCheck(tokenEnv, `${tokenEnv} is required for http_handler executor ${executorName} bearer auth.`));
8317
+ const signingSecretEnv = typeof executor.signing_secret_env === "string" ? executor.signing_secret_env : void 0;
8318
+ if (signingSecretEnv) {
8319
+ checks.push(envPresenceCheck(signingSecretEnv, `${signingSecretEnv} is required to sign http_handler requests for executor ${executorName}.`));
8320
+ } else {
8321
+ checks.push({
8322
+ name: `executor:${executorName}:handler-signing`,
8323
+ ok: true,
8324
+ level: "warn",
8325
+ message: `No signing_secret_env is configured for http_handler executor ${executorName}. HMAC signing is recommended unless the handler is loopback-only and protected by another trusted boundary.`
8326
+ });
8327
+ }
8016
8328
  }
8017
8329
  if (executor.type === "command_handler") {
8018
8330
  const commandEnv = String(executor.command_env ?? "");
@@ -8049,6 +8361,155 @@ async function localDoctor(args) {
8049
8361
  }
8050
8362
  return report.ok ? 0 : 1;
8051
8363
  }
8364
+ async function directSqlWritebackDoctorChecks(config, sourceName, source, writeUrl) {
8365
+ const checks = [];
8366
+ try {
8367
+ const result = await adapters[source.engine].doctor({
8368
+ controlPlaneUrl: "local",
8369
+ runnerToken: "local",
8370
+ runnerId: "doctor",
8371
+ sourceId: sourceName,
8372
+ databaseUrl: writeUrl,
8373
+ engine: source.engine,
8374
+ pollIntervalMs: 0,
8375
+ logLevel: "error",
8376
+ dryRun: true,
8377
+ stateDir: "./state"
8378
+ });
8379
+ checks.push({
8380
+ name: `source:${sourceName}:receipt-table-probe`,
8381
+ ok: result.ok,
8382
+ level: result.ok ? "pass" : "fail",
8383
+ message: result.ok ? "Writer credential can reach the database and the receipt-table rollback probe succeeded." : `Writer receipt-table probe failed (${safeDatabaseProbeError(result.details)}). ${receiptTableGuidance(source.engine)}`
8384
+ });
8385
+ } catch (error) {
8386
+ checks.push({
8387
+ name: `source:${sourceName}:receipt-table-probe`,
8388
+ ok: false,
8389
+ level: "fail",
8390
+ message: `Writer receipt-table probe failed (${safeDatabaseProbeError(error)}). ${receiptTableGuidance(source.engine)}`
8391
+ });
8392
+ }
8393
+ for (const capability of directSqlProposalCapabilities(config, sourceName)) {
8394
+ try {
8395
+ await rollbackOnlyTargetProbe(source.engine, writeUrl, capability);
8396
+ checks.push({
8397
+ name: `capability:${capability.name}:writeback-target-probe`,
8398
+ ok: true,
8399
+ level: "pass",
8400
+ message: `Rollback-only writer probe reached ${capability.target.schema}.${capability.target.table} and verified configured write columns without mutating business rows.`
8401
+ });
8402
+ } catch (error) {
8403
+ checks.push({
8404
+ name: `capability:${capability.name}:writeback-target-probe`,
8405
+ ok: false,
8406
+ level: "fail",
8407
+ message: `Rollback-only writer probe failed for configured target ${capability.target.schema}.${capability.target.table} (${safeDatabaseProbeError(error)}). Verify writer SELECT/UPDATE on the target table and configured columns.`
8408
+ });
8409
+ }
8410
+ }
8411
+ return checks;
8412
+ }
8413
+ function directSqlProposalCapabilities(config, sourceName) {
8414
+ return (config.capabilities ?? []).filter((capability) => {
8415
+ if (capability.kind !== "proposal" || capability.source !== sourceName) return false;
8416
+ return (capability.executor ?? "sql_update") === "sql_update";
8417
+ });
8418
+ }
8419
+ async function rollbackOnlyTargetProbe(engine, databaseUrl, capability) {
8420
+ if (engine === "postgres") {
8421
+ await rollbackOnlyPostgresTargetProbe(databaseUrl, capability);
8422
+ return;
8423
+ }
8424
+ await rollbackOnlyMysqlTargetProbe(databaseUrl, capability);
8425
+ }
8426
+ async function rollbackOnlyPostgresTargetProbe(databaseUrl, capability) {
8427
+ const pg = await dynamicImportModule("pg");
8428
+ const pool = new pg.Pool({ connectionString: databaseUrl });
8429
+ const client = await pool.connect();
8430
+ try {
8431
+ await client.query("BEGIN");
8432
+ try {
8433
+ const table = `${quotePostgresIdentifier2(capability.target.schema)}.${quotePostgresIdentifier2(capability.target.table)}`;
8434
+ const columns = proposalProbeColumns(capability).map(quotePostgresIdentifier2).join(", ");
8435
+ await client.query(`SELECT ${columns} FROM ${table} WHERE false FOR UPDATE`);
8436
+ for (const column of proposalUpdateProbeColumns(capability)) {
8437
+ const quoted = quotePostgresIdentifier2(column);
8438
+ await client.query(`UPDATE ${table} SET ${quoted} = ${quoted} WHERE false`);
8439
+ }
8440
+ await client.query("ROLLBACK");
8441
+ } catch (error) {
8442
+ await client.query("ROLLBACK").catch(() => void 0);
8443
+ throw error;
8444
+ }
8445
+ } finally {
8446
+ client.release();
8447
+ await pool.end();
8448
+ }
8449
+ }
8450
+ async function rollbackOnlyMysqlTargetProbe(databaseUrl, capability) {
8451
+ const mysql4 = await dynamicImportModule("mysql2/promise");
8452
+ const connection = await mysql4.createConnection({ uri: databaseUrl, dateStrings: true });
8453
+ try {
8454
+ await connection.beginTransaction();
8455
+ try {
8456
+ const table = `${quoteMysqlIdentifier2(capability.target.schema)}.${quoteMysqlIdentifier2(capability.target.table)}`;
8457
+ const columns = proposalProbeColumns(capability).map(quoteMysqlIdentifier2).join(", ");
8458
+ await connection.query(`SELECT ${columns} FROM ${table} WHERE 1 = 0 FOR UPDATE`);
8459
+ for (const column of proposalUpdateProbeColumns(capability)) {
8460
+ const quoted = quoteMysqlIdentifier2(column);
8461
+ await connection.query(`UPDATE ${table} SET ${quoted} = ${quoted} WHERE 1 = 0`);
8462
+ }
8463
+ await connection.rollback();
8464
+ } catch (error) {
8465
+ await connection.rollback().catch(() => void 0);
8466
+ throw error;
8467
+ }
8468
+ } finally {
8469
+ await connection.end();
8470
+ }
8471
+ }
8472
+ async function dynamicImportModule(specifier) {
8473
+ const importer = new Function("specifier", "return import(specifier)");
8474
+ return importer(specifier);
8475
+ }
8476
+ function proposalProbeColumns(capability) {
8477
+ const columns = /* @__PURE__ */ new Set();
8478
+ columns.add(capability.target.primary_key);
8479
+ if (capability.target.tenant_key) columns.add(capability.target.tenant_key);
8480
+ if (capability.conflict_guard?.column) columns.add(capability.conflict_guard.column);
8481
+ for (const column of capability.visible_columns ?? []) columns.add(column);
8482
+ for (const column of proposalUpdateProbeColumns(capability)) columns.add(column);
8483
+ return [...columns];
8484
+ }
8485
+ function proposalUpdateProbeColumns(capability) {
8486
+ const columns = /* @__PURE__ */ new Set();
8487
+ for (const column of capability.allowed_columns ?? []) columns.add(column);
8488
+ for (const column of Object.keys(capability.patch ?? {})) columns.add(column);
8489
+ return [...columns];
8490
+ }
8491
+ function quotePostgresIdentifier2(value) {
8492
+ return `"${value.replace(/"/g, '""')}"`;
8493
+ }
8494
+ function quoteMysqlIdentifier2(value) {
8495
+ return `\`${value.replace(/`/g, "``")}\``;
8496
+ }
8497
+ function safeDatabaseProbeError(error) {
8498
+ const raw = typeof error === "string" ? error : error instanceof Error ? error.message : JSON.stringify(error ?? {});
8499
+ const message = raw.toLowerCase();
8500
+ if (/permission|denied|not authorized|insufficient|42501|er_tableaccess_denied|er_dbaccess_denied/.test(message)) return "permission denied";
8501
+ if (/authentication|password|28p01|access denied for user|invalid authorization/.test(message)) return "authentication failed";
8502
+ if (/timeout|timed out|etimedout/.test(message)) return "timeout";
8503
+ if (/econnrefused|enotfound|eai_again|network|connection terminated|connection failed/.test(message)) return "connection failed";
8504
+ if (/does not exist|unknown database|no such table|undefined_table|er_no_such_table|42p01/.test(message)) return "configured object not found";
8505
+ return "database probe failed";
8506
+ }
8507
+ function receiptTableGuidance(engine) {
8508
+ if (engine === "postgres") {
8509
+ return `Pre-create the receipt table with "${cliCommandName()} writeback migration --engine postgres --schema synapsor" and grant it with "${cliCommandName()} writeback grants --engine postgres --schema synapsor --writer-role <writer_role>", or use an app-owned handler executor.`;
8510
+ }
8511
+ return `Pre-create the receipt table with "${cliCommandName()} writeback migration --engine mysql --schema <database_name>" and grant it with "${cliCommandName()} writeback grants --engine mysql --schema <database_name> --writer-role \\"'<writer>'@'%'\\"", or use an app-owned handler executor.`;
8512
+ }
8052
8513
  async function localDoctorStoreStats(storePath) {
8053
8514
  if (!storePath || storePath === ":memory:") return { path: storePath ?? "not configured", exists: storePath === ":memory:" };
8054
8515
  if (!await fileExists(storePath)) return { path: storePath, exists: false };
@@ -8234,6 +8695,9 @@ function executorConfig(config, executorName) {
8234
8695
  if (raw.type === "sql_update") return { type: "sql_update" };
8235
8696
  throw new Error(`executor ${executorName} has unsupported type`);
8236
8697
  }
8698
+ function signHandlerRequestBody(body, secret) {
8699
+ return `sha256=${crypto5.createHmac("sha256", secret).update(body).digest("hex")}`;
8700
+ }
8237
8701
  async function applyHttpHandlerProposal(input) {
8238
8702
  const duplicate = duplicateHandlerReceipt(input.store, input.proposalId);
8239
8703
  if (duplicate) return alreadyAppliedReceipt(duplicate.receipt, input.runnerId);
@@ -8258,6 +8722,21 @@ async function applyHttpHandlerProposal(input) {
8258
8722
  if (!token) throw new Error(`${input.executor.auth.token_env} is not set`);
8259
8723
  headers.authorization = `Bearer ${token}`;
8260
8724
  }
8725
+ const issuedAt = (/* @__PURE__ */ new Date()).toISOString();
8726
+ const requestBody = JSON.stringify({
8727
+ protocol_version: "1.0",
8728
+ ...prepared.request,
8729
+ issued_at: issuedAt,
8730
+ executor: input.executorName,
8731
+ dry_run: input.dryRun
8732
+ });
8733
+ headers["x-synapsor-issued-at"] = issuedAt;
8734
+ headers["x-synapsor-proposal-id"] = prepared.proposal.proposal_id;
8735
+ if (input.executor.signing_secret_env) {
8736
+ const signingSecret = input.env[input.executor.signing_secret_env];
8737
+ if (!signingSecret) throw new Error(`${input.executor.signing_secret_env} is not set`);
8738
+ headers["x-synapsor-signature"] = signHandlerRequestBody(requestBody, signingSecret);
8739
+ }
8261
8740
  const controller = new AbortController();
8262
8741
  const timeout = setTimeout(() => controller.abort(), Math.max(1, input.executor.timeout_ms ?? 5e3));
8263
8742
  let receipt;
@@ -8265,7 +8744,7 @@ async function applyHttpHandlerProposal(input) {
8265
8744
  const response = await fetch(url, {
8266
8745
  method: input.executor.method ?? "POST",
8267
8746
  headers,
8268
- body: JSON.stringify({ ...prepared.request, executor: input.executorName, dry_run: input.dryRun }),
8747
+ body: requestBody,
8269
8748
  signal: controller.signal
8270
8749
  });
8271
8750
  const text = await response.text();
@@ -8693,7 +9172,7 @@ async function cloudConnect(args) {
8693
9172
  return 1;
8694
9173
  }
8695
9174
  const runnerId = String(parsed.cloud.runner_id || process2.env.SYNAPSOR_RUNNER_ID || "synapsor_runner_local").trim();
8696
- const runnerVersion = String(parsed.cloud.runner_version || process2.env.npm_package_version || "0.1.0-alpha.11").trim();
9175
+ const runnerVersion = String(parsed.cloud.runner_version || process2.env.npm_package_version || "0.1.0-alpha.14").trim();
8697
9176
  const engines = normalizeEngines(parsed.cloud.engines);
8698
9177
  const capabilities = normalizeCapabilities(parsed.cloud.capabilities);
8699
9178
  const client = new ControlPlaneClient({
@@ -8756,6 +9235,7 @@ async function mcp(args) {
8756
9235
  async function tools(args) {
8757
9236
  const [subcommand, ...rest] = args;
8758
9237
  if (subcommand === "preview") return toolsPreview(rest);
9238
+ if (subcommand === "list") return toolsPreview(rest);
8759
9239
  usage(["tools"]);
8760
9240
  return 2;
8761
9241
  }
@@ -9414,42 +9894,66 @@ async function mcpServe(args) {
9414
9894
  const readOnly = args.includes("--read-only");
9415
9895
  const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
9416
9896
  const toolNameStyle = toolNameStyleOption(args);
9417
- await serveStdio({
9418
- configPath,
9419
- storePath: optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE,
9420
- config,
9421
- toolNameStyle
9422
- });
9423
- return 0;
9897
+ const resultFormat = resultFormatOption(args);
9898
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
9899
+ const releaseLease = await writeStoreLease(storePath, "mcp", "stdio", args.includes("--allow-concurrent-store"));
9900
+ try {
9901
+ await serveStdio({
9902
+ configPath,
9903
+ storePath,
9904
+ config,
9905
+ toolNameStyle,
9906
+ resultFormat
9907
+ });
9908
+ return 0;
9909
+ } finally {
9910
+ await releaseLease();
9911
+ }
9424
9912
  }
9425
9913
  async function mcpServeHttp(args) {
9914
+ process2.stderr.write([
9915
+ "Warning: mcp serve-http is a legacy JSON-RPC bridge, not spec MCP Streamable HTTP.",
9916
+ `For OpenAI Agents SDK or standard HTTP MCP clients, use: ${cliCommandName()} mcp serve --transport streamable-http`,
9917
+ ""
9918
+ ].join("\n"));
9426
9919
  const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG;
9427
9920
  const readOnly = args.includes("--read-only");
9428
9921
  const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
9429
9922
  const host = optionalArg(args, "--host") ?? "127.0.0.1";
9430
9923
  const port = Number(optionalArg(args, "--port") ?? "8765");
9924
+ const resultFormat = resultFormatOption(args);
9431
9925
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
9432
9926
  throw new Error("--port must be an integer from 1 to 65535");
9433
9927
  }
9434
9928
  if (host === "0.0.0.0") {
9435
9929
  process2.stderr.write("Warning: binding Synapsor Runner HTTP MCP to 0.0.0.0 exposes model-facing tools on the network. Use TLS, private networking, authentication, and rate limits.\n");
9436
9930
  }
9437
- const server = await startHttpMcpServer({
9438
- configPath,
9439
- config,
9440
- storePath: optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE,
9441
- host,
9442
- port,
9443
- authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
9444
- devNoAuth: args.includes("--dev-no-auth"),
9445
- corsOrigin: optionalArg(args, "--cors-origin")
9446
- });
9931
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
9932
+ const releaseLease = await writeStoreLease(storePath, "mcp", "legacy-jsonrpc", args.includes("--allow-concurrent-store"));
9933
+ let server;
9934
+ try {
9935
+ server = await startHttpMcpServer({
9936
+ configPath,
9937
+ config,
9938
+ storePath,
9939
+ host,
9940
+ port,
9941
+ authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
9942
+ devNoAuth: args.includes("--dev-no-auth"),
9943
+ corsOrigin: optionalArg(args, "--cors-origin"),
9944
+ resultFormat
9945
+ });
9946
+ } catch (error) {
9947
+ await releaseLease();
9948
+ throw error;
9949
+ }
9447
9950
  process2.stderr.write("Press Ctrl+C to stop.\n");
9448
9951
  await new Promise((resolve) => {
9449
9952
  const stop = async () => {
9450
9953
  process2.off("SIGINT", stop);
9451
9954
  process2.off("SIGTERM", stop);
9452
9955
  await server.close();
9956
+ await releaseLease();
9453
9957
  resolve();
9454
9958
  };
9455
9959
  process2.once("SIGINT", stop);
@@ -9462,6 +9966,7 @@ async function mcpServeStreamableHttp(args) {
9462
9966
  const readOnly = args.includes("--read-only");
9463
9967
  const config = readOnly ? { ...await readRuntimeConfig(configPath ?? defaultConfigPath), mode: "read_only" } : void 0;
9464
9968
  const toolNameStyle = toolNameStyleOption(args);
9969
+ const resultFormat = resultFormatOption(args);
9465
9970
  const host = optionalArg(args, "--host") ?? "127.0.0.1";
9466
9971
  const port = Number(optionalArg(args, "--port") ?? "8766");
9467
9972
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
@@ -9470,23 +9975,33 @@ async function mcpServeStreamableHttp(args) {
9470
9975
  if (host === "0.0.0.0") {
9471
9976
  process2.stderr.write("Warning: binding Synapsor Runner Streamable HTTP MCP to 0.0.0.0 exposes model-facing tools on the network. Use TLS, private networking, authentication, and rate limits.\n");
9472
9977
  }
9473
- const server = await startStreamableHttpMcpServer({
9474
- configPath,
9475
- config,
9476
- storePath: optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE,
9477
- host,
9478
- port,
9479
- toolNameStyle,
9480
- authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
9481
- devNoAuth: args.includes("--dev-no-auth"),
9482
- corsOrigin: optionalArg(args, "--cors-origin")
9483
- });
9978
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE;
9979
+ const releaseLease = await writeStoreLease(storePath, "mcp", "streamable-http", args.includes("--allow-concurrent-store"));
9980
+ let server;
9981
+ try {
9982
+ server = await startStreamableHttpMcpServer({
9983
+ configPath,
9984
+ config,
9985
+ storePath,
9986
+ host,
9987
+ port,
9988
+ toolNameStyle,
9989
+ authTokenEnv: optionalArg(args, "--auth-token-env") ?? "SYNAPSOR_RUNNER_HTTP_TOKEN",
9990
+ devNoAuth: args.includes("--dev-no-auth"),
9991
+ corsOrigin: optionalArg(args, "--cors-origin"),
9992
+ resultFormat
9993
+ });
9994
+ } catch (error) {
9995
+ await releaseLease();
9996
+ throw error;
9997
+ }
9484
9998
  process2.stderr.write("Press Ctrl+C to stop.\n");
9485
9999
  await new Promise((resolve) => {
9486
10000
  const stop = async () => {
9487
10001
  process2.off("SIGINT", stop);
9488
10002
  process2.off("SIGTERM", stop);
9489
10003
  await server.close();
10004
+ await releaseLease();
9490
10005
  resolve();
9491
10006
  };
9492
10007
  process2.once("SIGINT", stop);
@@ -9494,6 +10009,79 @@ async function mcpServeStreamableHttp(args) {
9494
10009
  });
9495
10010
  return 0;
9496
10011
  }
10012
+ async function writeStoreLease(storePath, mode, transport, allowConcurrent) {
10013
+ const resolved = resolveStorePathForLease(storePath);
10014
+ if (!resolved) return async () => void 0;
10015
+ await assertNoActiveStoreLease(resolved, allowConcurrent, "serve");
10016
+ const leasePath = storeLeasePath(resolved);
10017
+ const lease = {
10018
+ pid: process2.pid,
10019
+ mode,
10020
+ transport,
10021
+ store_path: resolved,
10022
+ started_at: (/* @__PURE__ */ new Date()).toISOString()
10023
+ };
10024
+ await fs3.mkdir(path3.dirname(resolved), { recursive: true });
10025
+ await fs3.writeFile(leasePath, `${JSON.stringify(lease, null, 2)}
10026
+ `, "utf8");
10027
+ return async () => {
10028
+ const current = await readStoreLease(resolved);
10029
+ if (current?.pid === process2.pid && current.transport === transport) {
10030
+ await fs3.rm(leasePath, { force: true });
10031
+ }
10032
+ };
10033
+ }
10034
+ async function assertNoActiveStoreLease(storePath, force, operation) {
10035
+ const resolved = resolveStorePathForLease(storePath);
10036
+ if (!resolved) return;
10037
+ const lease = await readStoreLease(resolved);
10038
+ if (!lease) return;
10039
+ if (!pidIsActive(lease.pid)) {
10040
+ await fs3.rm(storeLeasePath(resolved), { force: true });
10041
+ return;
10042
+ }
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.`;
10044
+ if (!force) throw new Error(message);
10045
+ process2.stderr.write(`Warning: ${message}
10046
+ `);
10047
+ }
10048
+ function resolveStorePathForLease(storePath) {
10049
+ const value = storePath ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
10050
+ if (value === ":memory:") return void 0;
10051
+ return path3.resolve(value);
10052
+ }
10053
+ function storeLeasePath(resolvedStorePath) {
10054
+ return `${resolvedStorePath}.lease.json`;
10055
+ }
10056
+ async function readStoreLease(storePath) {
10057
+ const resolved = resolveStorePathForLease(storePath);
10058
+ if (!resolved) return void 0;
10059
+ try {
10060
+ const parsed = JSON.parse(await fs3.readFile(storeLeasePath(resolved), "utf8"));
10061
+ if (typeof parsed.pid !== "number" || typeof parsed.mode !== "string" || typeof parsed.transport !== "string" || typeof parsed.started_at !== "string") {
10062
+ return void 0;
10063
+ }
10064
+ return {
10065
+ pid: parsed.pid,
10066
+ mode: parsed.mode,
10067
+ transport: parsed.transport,
10068
+ store_path: typeof parsed.store_path === "string" ? parsed.store_path : resolved,
10069
+ started_at: parsed.started_at
10070
+ };
10071
+ } catch (error) {
10072
+ if (error.code === "ENOENT") return void 0;
10073
+ return void 0;
10074
+ }
10075
+ }
10076
+ function pidIsActive(pid) {
10077
+ if (!Number.isInteger(pid) || pid <= 0) return false;
10078
+ try {
10079
+ process2.kill(pid, 0);
10080
+ return true;
10081
+ } catch (error) {
10082
+ return error.code === "EPERM";
10083
+ }
10084
+ }
9497
10085
  function toolNameStyleOption(args) {
9498
10086
  const requestedStyle = optionalArg(args, "--tool-name-style");
9499
10087
  const requestedAliasMode = optionalArg(args, "--alias-mode");
@@ -9509,6 +10097,13 @@ function toolNameStyleOption(args) {
9509
10097
  if (requested === "canonical" || requested === "openai" || requested === "both") return requested;
9510
10098
  throw new Error("--alias-mode must be canonical, openai, or both");
9511
10099
  }
10100
+ function resultFormatOption(args) {
10101
+ const requested = optionalArg(args, "--result-format");
10102
+ if (!requested) return void 0;
10103
+ if (requested === "1" || requested === "v1") return 1;
10104
+ if (requested === "2" || requested === "v2") return 2;
10105
+ throw new Error("--result-format must be v1, 1, v2, or 2");
10106
+ }
9512
10107
  async function mcpAudit(args) {
9513
10108
  const format = optionalArg(args, "--format") ?? (args.includes("--json") ? "json" : "text");
9514
10109
  if (!["text", "json", "markdown"].includes(format)) {
@@ -9660,6 +10255,7 @@ async function mcpConfigure(args) {
9660
10255
  const storePath = useAbsolutePaths ? path3.resolve(rawStorePath) : rawStorePath;
9661
10256
  const transport = mcpClientConfigTransport(args, client);
9662
10257
  const aliasMode = mcpClientConfigAliasMode(args, client);
10258
+ const includeInstructions = args.includes("--include-instructions");
9663
10259
  const host = optionalArg(args, "--host") ?? "127.0.0.1";
9664
10260
  const port = Number(optionalArg(args, "--port") ?? "8766");
9665
10261
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
@@ -9674,6 +10270,9 @@ async function mcpConfigure(args) {
9674
10270
  process2.stderr.write("Warning: relative paths are resolved by the MCP client working directory. Use --absolute-paths if the client runs from another directory.\n");
9675
10271
  }
9676
10272
  const snippet = mcpClientSnippet(client, configPath, storePath, { transport, aliasMode, host, port, authTokenEnv });
10273
+ if (includeInstructions) {
10274
+ snippet.agent_instructions = mcpAgentInstructions(client, aliasMode);
10275
+ }
9677
10276
  if (args.includes("--write")) {
9678
10277
  const destination = optionalArg(args, "--destination");
9679
10278
  if (!destination) throw new Error("mcp configure --write requires --destination <path>");
@@ -9786,6 +10385,29 @@ function mcpClientSnippet(client, configPath, storePath, options) {
9786
10385
  }
9787
10386
  throw new Error(`unsupported MCP client: ${client}`);
9788
10387
  }
10388
+ function mcpAgentInstructions(client, aliasMode) {
10389
+ const toolNameNote = aliasMode === "openai" ? "OpenAI-facing tool names may use aliases such as billing__inspect_invoice. Treat the canonical Synapsor capability name in tool metadata/results as the audit name." : "Use the model-visible Synapsor tool names exactly as listed by the MCP client.";
10390
+ return {
10391
+ target_client: client,
10392
+ alias_mode: aliasMode,
10393
+ recommended_system_prompt: [
10394
+ "Use Synapsor Runner tools in a propose-first pattern.",
10395
+ "Inspect relevant records, policy rows, and other evidence before proposing a change.",
10396
+ "Do not claim a database change was committed unless a result says source_database_changed: true.",
10397
+ "Proposal tools create reviewable proposals only; they do not commit writes.",
10398
+ "You cannot approve, apply, commit, or write back through model-facing MCP tools.",
10399
+ "On VERSION_CONFLICT, re-inspect the record before proposing again.",
10400
+ "Evidence handles are audit/replay handles; you do not need to call them during the turn.",
10401
+ toolNameNote
10402
+ ].join(" "),
10403
+ checklist: [
10404
+ "Inspect evidence before proposing.",
10405
+ "Use trusted session scope; never ask the user/model for tenant or principal values.",
10406
+ "Report proposal ids and source_database_changed exactly from the tool result.",
10407
+ "If ok is false, follow error.code. On TEMPORARILY_UNAVAILABLE, retry later. On NOT_FOUND_IN_TENANT, do not infer cross-tenant existence."
10408
+ ]
10409
+ };
10410
+ }
9789
10411
  async function mcpSmoke(args) {
9790
10412
  const boundary = await inspectMcpToolBoundary(args);
9791
10413
  if (args.includes("--json")) {
@@ -10501,11 +11123,18 @@ async function activity(args) {
10501
11123
  usage(["activity"]);
10502
11124
  return 2;
10503
11125
  }
11126
+ async function events(args) {
11127
+ const [subcommand, ...rest] = args;
11128
+ if (subcommand === "tail") return eventsTail(rest);
11129
+ usage(["events"]);
11130
+ return 2;
11131
+ }
10504
11132
  async function storeCommand(args) {
10505
11133
  const [subcommand, ...rest] = args;
10506
11134
  if (subcommand === "stats") return storeStats(rest);
10507
11135
  if (subcommand === "vacuum") return storeVacuum(rest);
10508
11136
  if (subcommand === "prune") return storePrune(rest);
11137
+ if (subcommand === "reset") return storeReset(rest);
10509
11138
  usage(["store"]);
10510
11139
  return 2;
10511
11140
  }
@@ -11021,6 +11650,56 @@ async function activitySearch(args) {
11021
11650
  store.close();
11022
11651
  }
11023
11652
  }
11653
+ async function eventsTail(args) {
11654
+ assertKnownOptions(args, eventTailAllowedOptions, "events tail");
11655
+ const follow = args.includes("--follow");
11656
+ if (follow && args.includes("--json")) throw new Error("events tail --follow does not support --json yet");
11657
+ const storePath = optionalArg(args, "--store");
11658
+ const intervalMs = Number(optionalArg(args, "--interval-ms") ?? "1000");
11659
+ if (!Number.isFinite(intervalMs) || intervalMs < 250) throw new Error("--interval-ms must be at least 250");
11660
+ const filters = eventFiltersFromArgs(args);
11661
+ const printOnce = async (seen2) => {
11662
+ const store = await openLocalStore(["--store", storePath ?? "./.synapsor/local.db"]);
11663
+ try {
11664
+ const rows = store.listEvents(filters).sort((left, right) => left.event_id - right.event_id).filter((event) => !seen2?.has(event.event_id));
11665
+ if (seen2) rows.forEach((event) => seen2.add(event.event_id));
11666
+ if (args.includes("--json")) {
11667
+ process2.stdout.write(`${JSON.stringify({ events: rows }, null, 2)}
11668
+ `);
11669
+ } else if (rows.length === 0 && !follow) {
11670
+ process2.stdout.write("No local events found.\n");
11671
+ } else {
11672
+ for (const event of rows) process2.stdout.write(formatEventLine(event, showDetails(args)));
11673
+ }
11674
+ return rows.length;
11675
+ } finally {
11676
+ store.close();
11677
+ }
11678
+ };
11679
+ if (!follow) {
11680
+ await printOnce();
11681
+ return 0;
11682
+ }
11683
+ const seen = /* @__PURE__ */ new Set();
11684
+ await printOnce(seen);
11685
+ await new Promise((resolve) => {
11686
+ const timer = setInterval(() => {
11687
+ void printOnce(seen).catch((error) => {
11688
+ process2.stderr.write(`events tail error: ${safeErrorMessage(error)}
11689
+ `);
11690
+ });
11691
+ }, intervalMs);
11692
+ const stop = () => {
11693
+ clearInterval(timer);
11694
+ process2.off("SIGINT", stop);
11695
+ process2.off("SIGTERM", stop);
11696
+ resolve();
11697
+ };
11698
+ process2.once("SIGINT", stop);
11699
+ process2.once("SIGTERM", stop);
11700
+ });
11701
+ return 0;
11702
+ }
11024
11703
  async function storeStats(args) {
11025
11704
  assertKnownOptions(args, storeStatsAllowedOptions, "store stats");
11026
11705
  const store = await openLocalStore(args);
@@ -11058,6 +11737,7 @@ async function storePrune(args) {
11058
11737
  if (args.includes("--yes") && args.includes("--dry-run")) throw new Error("store prune accepts either --dry-run or --yes, not both");
11059
11738
  const cutoff = cutoffFromOlderThan(olderThan);
11060
11739
  const dryRun = !args.includes("--yes");
11740
+ if (!dryRun) await assertNoActiveStoreLease(optionalArg(args, "--store"), args.includes("--force"), "store prune");
11061
11741
  const store = await openLocalStore(args);
11062
11742
  try {
11063
11743
  const result = store.pruneBefore(cutoff, { dryRun });
@@ -11069,6 +11749,36 @@ async function storePrune(args) {
11069
11749
  store.close();
11070
11750
  }
11071
11751
  }
11752
+ async function storeReset(args) {
11753
+ assertKnownOptions(args, storeResetAllowedOptions, "store reset");
11754
+ const storePath = optionalArg(args, "--store") ?? process2.env.SYNAPSOR_LOCAL_STORE ?? "./.synapsor/local.db";
11755
+ if (storePath === ":memory:") throw new Error("store reset does not apply to :memory: stores");
11756
+ if (!args.includes("--yes")) {
11757
+ throw new Error("store reset is destructive for the local ledger. Rerun with --yes after backing up anything you need.");
11758
+ }
11759
+ await assertNoActiveStoreLease(storePath, args.includes("--force"), "store reset");
11760
+ const resolved = path3.resolve(storePath);
11761
+ const candidates = [resolved, `${resolved}-wal`, `${resolved}-shm`, storeLeasePath(resolved)];
11762
+ const removed = [];
11763
+ for (const candidate of candidates) {
11764
+ try {
11765
+ await fs3.rm(candidate, { force: true });
11766
+ removed.push(candidate);
11767
+ } catch (error) {
11768
+ if (error.code !== "ENOENT") throw error;
11769
+ }
11770
+ }
11771
+ const result = {
11772
+ ok: true,
11773
+ store: resolved,
11774
+ removed,
11775
+ source_database_changed: false
11776
+ };
11777
+ if (args.includes("--json")) process2.stdout.write(`${JSON.stringify(result, null, 2)}
11778
+ `);
11779
+ else process2.stdout.write(formatStoreReset(result));
11780
+ return 0;
11781
+ }
11072
11782
  var commonReadOptions = /* @__PURE__ */ new Set(["--store", "--json", "--details", "--debug"]);
11073
11783
  var showAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
11074
11784
  var exportAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--output", "--out", "--format", "--evidence", "--audit"]);
@@ -11128,6 +11838,17 @@ var receiptListAllowedOptions = /* @__PURE__ */ new Set([
11128
11838
  "--to",
11129
11839
  "--limit"
11130
11840
  ]);
11841
+ var eventTailAllowedOptions = /* @__PURE__ */ new Set([
11842
+ ...commonReadOptions,
11843
+ "--proposal",
11844
+ "--kind",
11845
+ "--actor",
11846
+ "--from",
11847
+ "--to",
11848
+ "--limit",
11849
+ "--follow",
11850
+ "--interval-ms"
11851
+ ]);
11131
11852
  var replayShowAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--proposal", "--replay", "--evidence"]);
11132
11853
  var replayExportAllowedOptions = /* @__PURE__ */ new Set([...replayShowAllowedOptions, "--output", "--out", "--format"]);
11133
11854
  var replayListAllowedOptions = /* @__PURE__ */ new Set([
@@ -11170,7 +11891,8 @@ var activitySearchAllowedOptions = /* @__PURE__ */ new Set([
11170
11891
  ]);
11171
11892
  var storeStatsAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
11172
11893
  var storeVacuumAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions]);
11173
- var storePruneAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--older-than", "--dry-run", "--yes"]);
11894
+ var storePruneAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--older-than", "--dry-run", "--yes", "--force"]);
11895
+ var storeResetAllowedOptions = /* @__PURE__ */ new Set([...commonReadOptions, "--yes", "--force"]);
11174
11896
  function assertKnownOptions(args, allowed, commandName) {
11175
11897
  for (const arg of args) {
11176
11898
  if (!arg.startsWith("--")) continue;
@@ -11319,6 +12041,16 @@ function receiptFiltersFromActivityArgs(args, store) {
11319
12041
  limit: limitFromArgs(args)
11320
12042
  };
11321
12043
  }
12044
+ function eventFiltersFromArgs(args) {
12045
+ return {
12046
+ proposal: optionalArg(args, "--proposal"),
12047
+ kind: optionalArg(args, "--kind"),
12048
+ actor: optionalArg(args, "--actor"),
12049
+ from: optionalArg(args, "--from"),
12050
+ to: optionalArg(args, "--to"),
12051
+ limit: limitFromArgs(args)
12052
+ };
12053
+ }
11322
12054
  function linkedProposalFilter(args, store, options = {}) {
11323
12055
  const noLinkedProposal = "__synapsor_no_linked_proposal__";
11324
12056
  const replay2 = optionalArg(args, "--replay");
@@ -11813,11 +12545,11 @@ function formatProposalDetail(proposal, storedEvidenceItemCount) {
11813
12545
  ...formatChangeLines(proposal)
11814
12546
  ].join("\n") + "\n";
11815
12547
  }
11816
- function formatProposalEventDetail(events) {
11817
- if (events.length === 0) return "Events:\n none\n";
12548
+ function formatProposalEventDetail(events2) {
12549
+ if (events2.length === 0) return "Events:\n none\n";
11818
12550
  return [
11819
12551
  "Events:",
11820
- ...events.map((event) => ` event ${event.event_id}: ${event.kind} by ${event.actor} at ${event.created_at}`)
12552
+ ...events2.map((event) => ` event ${event.event_id}: ${event.kind} by ${event.actor} at ${event.created_at}`)
11821
12553
  ].join("\n") + "\n";
11822
12554
  }
11823
12555
  function formatProposalDebug(proposal, storePath) {
@@ -12279,6 +13011,22 @@ function formatActivityNext(items, storeSuffix) {
12279
13011
  return `${lines.join("\n")}
12280
13012
  `;
12281
13013
  }
13014
+ function formatEventLine(event, details = false) {
13015
+ const lines = [
13016
+ `${event.created_at} ${event.kind}`,
13017
+ ` proposal: ${event.proposal_id}`,
13018
+ ` actor: ${event.actor}`
13019
+ ];
13020
+ if (details && Object.keys(event.payload).length > 0) {
13021
+ lines.push(` payload: ${JSON.stringify(event.payload)}`);
13022
+ }
13023
+ lines.push("");
13024
+ return `${lines.join("\n")}
13025
+ `;
13026
+ }
13027
+ function safeErrorMessage(error) {
13028
+ return error instanceof Error ? error.message : String(error);
13029
+ }
12282
13030
  function formatStoreStats(stats) {
12283
13031
  return [
12284
13032
  `Local store: ${stats.path}`,
@@ -12310,6 +13058,18 @@ function formatStorePrune(result) {
12310
13058
  return `${lines.join("\n")}
12311
13059
  `;
12312
13060
  }
13061
+ function formatStoreReset(result) {
13062
+ const lines = [
13063
+ "Local store reset complete",
13064
+ `Store: ${result.store}`,
13065
+ `Source database changed: ${result.source_database_changed ? "yes" : "no"}`,
13066
+ "",
13067
+ "Removed:",
13068
+ ...result.removed.length ? result.removed.map((entry) => ` - ${entry}`) : [" - no local store files were present"]
13069
+ ];
13070
+ return `${lines.join("\n")}
13071
+ `;
13072
+ }
12313
13073
  function cutoffFromOlderThan(value) {
12314
13074
  const match = value.match(/^(\d+)([smhd])$/i);
12315
13075
  if (!match) throw new Error("--older-than must use a duration such as 30d, 12h, 90m, or 0d");
@@ -12551,7 +13311,7 @@ function starterCloudConfig() {
12551
13311
  base_url_env: "SYNAPSOR_CLOUD_BASE_URL",
12552
13312
  runner_token_env: "SYNAPSOR_RUNNER_TOKEN",
12553
13313
  runner_id: "synapsor_runner_local",
12554
- runner_version: "0.1.0-alpha.11",
13314
+ runner_version: "0.1.0-alpha.14",
12555
13315
  project_id: "token_scope",
12556
13316
  adapter_id: "mcp.your_adapter",
12557
13317
  source_id: "src_replace_me",
@@ -12603,6 +13363,7 @@ function isKnownTopLevelCommand(command) {
12603
13363
  "query-audit",
12604
13364
  "receipts",
12605
13365
  "activity",
13366
+ "events",
12606
13367
  "store",
12607
13368
  "shadow",
12608
13369
  "ui"
@@ -12631,6 +13392,7 @@ Commands:
12631
13392
  mcp Serve safe semantic tools over MCP
12632
13393
  onboard One-command own-database setup
12633
13394
  smoke Test generated tool calls before wiring an MCP client
13395
+ tools List model-facing MCP tools and aliases
12634
13396
  writeback Print direct SQL writeback receipt DDL, grants, and checks
12635
13397
  handler Create app-owned writeback handler templates
12636
13398
  propose Create a local evidence-backed proposal
@@ -12640,6 +13402,7 @@ Commands:
12640
13402
  query-audit Inspect local query audit records
12641
13403
  receipts Inspect guarded writeback receipts
12642
13404
  activity Search local evidence/replay ledger
13405
+ events Tail local proposal/writeback lifecycle events
12643
13406
  store Inspect and maintain the local SQLite ledger
12644
13407
  apply Apply an approved proposal with guarded writeback
12645
13408
  replay Show what happened
@@ -12652,6 +13415,7 @@ Examples:
12652
13415
  ${cmd} inspect --from-env DATABASE_URL
12653
13416
  ${cmd} init --wizard --from-env DATABASE_URL
12654
13417
  ${cmd} smoke call --config ./synapsor.runner.json --store ./.synapsor/local.db
13418
+ ${cmd} tools list --aliases --config ./synapsor.runner.json --store ./.synapsor/local.db
12655
13419
  ${cmd} handler template node-fastify --output ./synapsor-writeback-handler.mjs
12656
13420
  ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
12657
13421
  ${cmd} propose billing.propose_late_fee_waiver --sample
@@ -12659,7 +13423,7 @@ Examples:
12659
13423
  `,
12660
13424
  start: `Usage:
12661
13425
  ${cmd} start --from-env DATABASE_URL [--schema public] [--mode read_only|shadow|review]
12662
- ${cmd} start --from-env DATABASE_URL --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL
13426
+ ${cmd} start --from-env DATABASE_URL --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL [--handler-signing-secret-env APP_WRITEBACK_SIGNING_SECRET]
12663
13427
  ${cmd} start
12664
13428
 
12665
13429
  With --from-env, run the guided own-database setup: inspect schema, choose one
@@ -12681,7 +13445,7 @@ Inspect schema metadata without mutating the database or printing credentials.
12681
13445
  ${cmd} init --wizard --from-env DATABASE_URL [--mode read_only|review|shadow] [--out synapsor.runner.json]
12682
13446
  ${cmd} init --engine postgres --url-env DATABASE_URL --mode review --table public.invoices
12683
13447
  ${cmd} init --inspection-json schema.json --table invoices --mode review --patch-from-arg waiver_reason=reason
12684
- ${cmd} init --inspection-json schema.json --table invoices --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL
13448
+ ${cmd} init --inspection-json schema.json --table invoices --mode review --writeback http_handler --handler-url-env APP_WRITEBACK_URL [--handler-signing-secret-env APP_WRITEBACK_SIGNING_SECRET]
12685
13449
 
12686
13450
  Generate a reviewed Synapsor Runner contract. Defaults to read-only in the wizard.
12687
13451
  Review mode writeback choices: sql_update, http_handler, command_handler.
@@ -12698,17 +13462,27 @@ Review mode writeback choices: sql_update, http_handler, command_handler.
12698
13462
 
12699
13463
  Use stdio for local MCP clients that launch the runner. Use Streamable HTTP for standard HTTP MCP clients. Use serve-http only when you explicitly want the lightweight JSON-RPC bridge.
12700
13464
  MCP clients see semantic tools. They do not receive raw SQL, write credentials, approval tools, or commit tools.
13465
+ `,
13466
+ tools: `Usage:
13467
+ ${cmd} tools list --config ./synapsor.runner.json --store ./.synapsor/local.db
13468
+ ${cmd} tools list --aliases --config ./synapsor.runner.json --store ./.synapsor/local.db
13469
+ ${cmd} tools preview --config ./synapsor.runner.json --store ./.synapsor/local.db
13470
+
13471
+ List the model-facing MCP tools generated from a reviewed Runner config.
13472
+ Use --aliases to show canonical Synapsor names and OpenAI-safe aliases.
13473
+ This command never prints database URLs or write credentials.
12701
13474
  `,
12702
13475
  "mcp serve": `Usage:
12703
- ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db [--transport stdio] [--read-only] [--local] [--alias-mode canonical|openai|both]
12704
- ${cmd} mcp serve --transport streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN
13476
+ ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db [--transport stdio] [--read-only] [--local] [--alias-mode canonical|openai|both] [--result-format v1|v2]
13477
+ ${cmd} mcp serve --transport streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db --auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN [--result-format v2]
12705
13478
 
12706
13479
  Start the stdio MCP server for local MCP clients such as Claude Desktop, Cursor, or local agent tools. Startup logs stay off stdout so the MCP protocol remains clean.
12707
13480
  Use --alias-mode openai, or --openai-tool-aliases, for clients that reject dotted tool names. Use --alias-mode both to expose canonical and alias names.
13481
+ Use --result-format v2 to return one stable ok/summary/data/proposal/error envelope from every tool call.
12708
13482
  `,
12709
13483
  "mcp serve-streamable-http": `Usage:
12710
13484
  export SYNAPSOR_RUNNER_HTTP_TOKEN=...
12711
- ${cmd} mcp serve-streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8766] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN] [--alias-mode canonical|openai|both]
13485
+ ${cmd} mcp serve-streamable-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8766] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN] [--alias-mode canonical|openai|both] [--result-format v1|v2]
12712
13486
 
12713
13487
  Start the spec-compatible MCP Streamable HTTP endpoint for clients and SDKs that support HTTP MCP.
12714
13488
  Bearer auth is required by default.
@@ -12717,6 +13491,7 @@ Alpha scope:
12717
13491
  - Supports MCP initialize/session behavior through the official MCP Streamable HTTP transport.
12718
13492
  - Use --alias-mode openai, or --openai-tool-aliases, for clients that reject dotted tool names.
12719
13493
  - Use --alias-mode both to expose canonical names and aliases.
13494
+ - Use --result-format v2 for the stable ok/summary/data/proposal/error envelope.
12720
13495
  - OpenAI aliases expose names such as billing__inspect_invoice while preserving the canonical Synapsor name in _meta.
12721
13496
  - Use /mcp for the MCP endpoint and /healthz for service health.
12722
13497
  - Sessions are in-memory. Restarting the runner clears active HTTP MCP sessions.
@@ -12730,7 +13505,7 @@ Security:
12730
13505
  `,
12731
13506
  "mcp serve-http": `Usage:
12732
13507
  export SYNAPSOR_RUNNER_HTTP_TOKEN=...
12733
- ${cmd} mcp serve-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8765] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN]
13508
+ ${cmd} mcp serve-http --config ./synapsor.runner.json --store ./.synapsor/local.db [--host 127.0.0.1] [--port 8765] [--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN] [--result-format v1|v2]
12734
13509
 
12735
13510
  Start the lightweight HTTP JSON-RPC bridge for app/server deployments that want simple POST calls.
12736
13511
  Bearer auth is required by default.
@@ -12747,18 +13522,19 @@ Security:
12747
13522
  `,
12748
13523
  "mcp config": `Usage:
12749
13524
  ${cmd} mcp config [claude-desktop|cursor|generic|vscode|openai-agents] [--absolute-paths] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
12750
- ${cmd} mcp client-config --client openai-agents [--transport streamable-http] [--port 8766] [--alias-mode openai] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
13525
+ ${cmd} mcp client-config --client openai-agents [--transport streamable-http] [--port 8766] [--alias-mode openai] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
12751
13526
 
12752
13527
  Print MCP client configuration that references the local runner command, not database URLs. Defaults to claude-desktop.
12753
13528
  OpenAI Agents SDK output uses Streamable HTTP and OpenAI-safe aliases by default.
12754
13529
  `,
12755
13530
  "mcp client-config": `Usage:
12756
- ${cmd} mcp client-config --client claude-desktop [--absolute-paths] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
12757
- ${cmd} mcp client-config --client cursor [--absolute-paths] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
12758
- ${cmd} mcp client-config --client openai-agents [--transport streamable-http] [--port 8766] [--alias-mode openai] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
13531
+ ${cmd} mcp client-config --client claude-desktop [--absolute-paths] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
13532
+ ${cmd} mcp client-config --client cursor [--absolute-paths] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
13533
+ ${cmd} mcp client-config --client openai-agents [--transport streamable-http] [--port 8766] [--alias-mode openai] [--include-instructions] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
12759
13534
 
12760
13535
  Print MCP client configuration that references the local runner command, not database URLs.
12761
13536
  OpenAI Agents SDK output uses Streamable HTTP and OpenAI-safe aliases by default.
13537
+ Use --include-instructions to include the recommended propose-first agent prompt.
12762
13538
  `,
12763
13539
  smoke: `Usage:
12764
13540
  ${cmd} smoke call [capability-name] [--sample] [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
@@ -12830,10 +13606,13 @@ Static MCP/database risk review only. This is not a security guarantee.
12830
13606
  doctor: `Usage:
12831
13607
  ${cmd} doctor --config synapsor.runner.json
12832
13608
  ${cmd} doctor --config synapsor.runner.json --json
13609
+ ${cmd} doctor --config synapsor.runner.json --check-handlers
13610
+ ${cmd} doctor --config synapsor.runner.json --check-writeback
12833
13611
  ${cmd} doctor --config synapsor.runner.json --report --redact --output synapsor-doctor.md
12834
13612
  ${cmd} doctor --first-run
12835
13613
 
12836
- Validate local config, environment bindings, semantic tool boundary, source metadata when reachable, and local store stats. Reports are redacted; do not paste secrets into issues.
13614
+ Validate local config, environment bindings, semantic tool boundary, source metadata when reachable, handler signing/reachability, direct SQL writeback readiness, and local store stats. Reports are redacted; do not paste secrets into issues.
13615
+ Use --check-writeback only after reviewing receipt-table DDL/grants; it connects with the trusted writer and can create the receipt table if the writer has permission.
12837
13616
  `,
12838
13617
  proposals: `Usage:
12839
13618
  ${cmd} proposals list [--tenant acme] [--capability billing.propose_late_fee_waiver] [--object invoice:INV-3001] [--status applied]
@@ -12900,14 +13679,24 @@ or an administrator must pre-create and grant it.
12900
13679
  ${cmd} activity search --capability billing.propose_late_fee_waiver --from 2026-06-01 --to 2026-06-23
12901
13680
 
12902
13681
  Search the local SQLite evidence/replay ledger across proposals, evidence, query audit, receipts, and replay records.
13682
+ `,
13683
+ events: `Usage:
13684
+ ${cmd} events tail --store ./.synapsor/local.db
13685
+ ${cmd} events tail --proposal wrp_...
13686
+ ${cmd} events tail --kind writeback_applied
13687
+ ${cmd} events tail --follow --interval-ms 1000
13688
+
13689
+ Show local proposal/writeback lifecycle events such as proposal_created, proposal_approved, writeback_applied, writeback_conflict, and writeback_failed.
12903
13690
  `,
12904
13691
  store: `Usage:
12905
13692
  ${cmd} store stats --store ./.synapsor/local.db
12906
13693
  ${cmd} store vacuum --store ./.synapsor/local.db
12907
13694
  ${cmd} store prune --store ./.synapsor/local.db --older-than 30d --dry-run
12908
13695
  ${cmd} store prune --store ./.synapsor/local.db --older-than 30d --yes
13696
+ ${cmd} store prune --store ./.synapsor/local.db --older-than 30d --yes --force
13697
+ ${cmd} store reset --store ./.synapsor/local.db --yes
12909
13698
 
12910
- Local store maintenance only. Prune defaults to dry-run and never touches your source Postgres/MySQL database.
13699
+ Local store maintenance only. Prune defaults to dry-run and reset requires --yes. These commands never touch your source Postgres/MySQL database. Destructive operations refuse while an active server lease exists unless --force is provided.
12911
13700
  `,
12912
13701
  demo: `Usage:
12913
13702
  ${cmd} demo [--force]