@synapsor/runner 0.1.15 → 0.1.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/dist/runner.mjs CHANGED
@@ -1381,7 +1381,7 @@ function isRecord(value) {
1381
1381
  }
1382
1382
 
1383
1383
  // packages/mcp-server/src/index.ts
1384
- import crypto from "node:crypto";
1384
+ import crypto2 from "node:crypto";
1385
1385
  import fs from "node:fs";
1386
1386
  import { createServer } from "node:http";
1387
1387
  import path from "node:path";
@@ -1390,9 +1390,291 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
1390
1390
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
1391
1391
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
1392
1392
 
1393
+ // packages/postgres/src/index.ts
1394
+ import crypto from "node:crypto";
1395
+ import { Pool, types as pgTypes } from "pg";
1396
+ var postgresReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
1397
+ idempotency_key text PRIMARY KEY,
1398
+ job_id text UNIQUE NOT NULL,
1399
+ proposal_id text NOT NULL,
1400
+ status text NOT NULL,
1401
+ result_hash text,
1402
+ created_at timestamptz NOT NULL DEFAULT now(),
1403
+ completed_at timestamptz
1404
+ )`;
1405
+ var POSTGRES_TIMESTAMP_OIDS = /* @__PURE__ */ new Set([1114, 1184]);
1406
+ function postgresPoolConfig(connectionString) {
1407
+ return {
1408
+ connectionString,
1409
+ types: {
1410
+ getTypeParser(oid, format) {
1411
+ if ((format ?? "text") === "text" && POSTGRES_TIMESTAMP_OIDS.has(oid)) {
1412
+ return (value) => value;
1413
+ }
1414
+ return pgTypes.getTypeParser(oid, format);
1415
+ }
1416
+ }
1417
+ };
1418
+ }
1419
+ function createPostgresPool(connectionString) {
1420
+ return new Pool(postgresPoolConfig(connectionString));
1421
+ }
1422
+ function quotePostgresIdentifier(identifier) {
1423
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
1424
+ throw new Error(`unsafe postgres identifier: ${identifier}`);
1425
+ }
1426
+ return `"${identifier}"`;
1427
+ }
1428
+ function buildPostgresUpdate(job) {
1429
+ validatePostgresPatch(job);
1430
+ const values = [];
1431
+ const setFragments = Object.entries(job.patch).map(([column, value]) => {
1432
+ values.push(value);
1433
+ return `${quotePostgresIdentifier(column)} = $${values.length}`;
1434
+ });
1435
+ values.push(job.target.primary_key.value);
1436
+ const pkParam = `$${values.length}`;
1437
+ values.push(job.target.tenant_guard.value);
1438
+ const tenantParam = `$${values.length}`;
1439
+ const where = [
1440
+ `${quotePostgresIdentifier(job.target.primary_key.column)} = ${pkParam}`,
1441
+ `${quotePostgresIdentifier(job.target.tenant_guard.column)} = ${tenantParam}`
1442
+ ];
1443
+ if (job.conflict_guard.kind === "version_column") {
1444
+ values.push(job.conflict_guard.expected_value);
1445
+ where.push(`${quotePostgresIdentifier(job.conflict_guard.column)} = $${values.length}`);
1446
+ }
1447
+ const sql = `UPDATE ${quotePostgresIdentifier(job.target.schema)}.${quotePostgresIdentifier(job.target.table)}
1448
+ SET ${setFragments.join(", ")}
1449
+ WHERE ${where.join(" AND ")}`;
1450
+ return { sql, values };
1451
+ }
1452
+ function validatePostgresPatch(job) {
1453
+ const patchColumns = Object.keys(job.patch || {});
1454
+ if (!patchColumns.length) {
1455
+ throw new Error("postgres writeback patch must not be empty");
1456
+ }
1457
+ const allowedColumns = new Set(Array.isArray(job.allowed_columns) ? job.allowed_columns : []);
1458
+ if (allowedColumns.has(job.target.primary_key.column)) {
1459
+ throw new Error("postgres primary key column must not be patch-allowlisted");
1460
+ }
1461
+ if (allowedColumns.has(job.target.tenant_guard.column)) {
1462
+ throw new Error("postgres tenant guard column must not be patch-allowlisted");
1463
+ }
1464
+ for (const column of patchColumns) {
1465
+ if (!allowedColumns.has(column)) {
1466
+ throw new Error(`postgres patch column not allowlisted: ${column}`);
1467
+ }
1468
+ }
1469
+ }
1470
+ function resultHash(job, status, version) {
1471
+ return `sha256:${crypto.createHash("sha256").update(JSON.stringify({ job_id: job.job_id, status, version })).digest("hex")}`;
1472
+ }
1473
+ function normalizeVersionValue(value) {
1474
+ if (value instanceof Date) return normalizeVersionValue(value.toISOString());
1475
+ const text = String(value ?? "").trim();
1476
+ const timestamp = text.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}(?::?\d{2})?)?$/i);
1477
+ if (timestamp) {
1478
+ const fraction = (timestamp[3] ?? "").padEnd(6, "0").slice(0, 6);
1479
+ const offset = timestamp[4];
1480
+ if (offset && offset.toUpperCase() !== "Z") {
1481
+ const dateParts = timestamp[1].split("-");
1482
+ const timeParts = timestamp[2].split(":");
1483
+ const year = Number(dateParts[0]);
1484
+ const month = Number(dateParts[1]);
1485
+ const day = Number(dateParts[2]);
1486
+ const hour = Number(timeParts[0]);
1487
+ const minute = Number(timeParts[1]);
1488
+ const second = Number(timeParts[2]);
1489
+ const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second) - offsetMinutes(offset) * 6e4);
1490
+ return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(utc.getUTCDate())} ${pad2(utc.getUTCHours())}:${pad2(utc.getUTCMinutes())}:${pad2(utc.getUTCSeconds())}.${fraction}`;
1491
+ }
1492
+ return `${timestamp[1]} ${timestamp[2]}.${fraction}`;
1493
+ }
1494
+ return text;
1495
+ }
1496
+ function offsetMinutes(offset) {
1497
+ const sign = offset.startsWith("-") ? -1 : 1;
1498
+ const compact = offset.slice(1).replace(":", "");
1499
+ const hours = Number(compact.slice(0, 2));
1500
+ const minutes = Number(compact.slice(2, 4) || "0");
1501
+ return sign * (hours * 60 + minutes);
1502
+ }
1503
+ function pad2(value) {
1504
+ return String(value).padStart(2, "0");
1505
+ }
1506
+ function versionValuesMatch(actual, expected) {
1507
+ return normalizeVersionValue(actual) === normalizeVersionValue(expected);
1508
+ }
1509
+ async function withTransaction(client, fn) {
1510
+ await client.query("BEGIN");
1511
+ try {
1512
+ const result = await fn();
1513
+ await client.query("COMMIT");
1514
+ return result;
1515
+ } catch (error) {
1516
+ await client.query("ROLLBACK").catch(() => void 0);
1517
+ throw error;
1518
+ }
1519
+ }
1520
+ async function applyPostgresJob(job, config) {
1521
+ if (config.dryRun) {
1522
+ buildPostgresUpdate(job);
1523
+ return {
1524
+ protocol_version: "1.0",
1525
+ job_id: job.job_id,
1526
+ runner_id: config.runnerId,
1527
+ status: "applied",
1528
+ affected_rows: 0,
1529
+ result_hash: resultHash(job, "dry-run"),
1530
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
1531
+ };
1532
+ }
1533
+ if (!config.databaseUrl) {
1534
+ return failed(job, config, "DATABASE_UNAVAILABLE");
1535
+ }
1536
+ const pool = createPostgresPool(config.databaseUrl);
1537
+ const client = await pool.connect();
1538
+ try {
1539
+ return await applyPostgresJobWithClient(job, config, client);
1540
+ } catch (error) {
1541
+ const message = error instanceof Error ? error.message : "TRANSACTION_FAILED";
1542
+ return failed(job, config, message.includes("MULTI_ROW") ? "MULTI_ROW_WRITE_BLOCKED" : message.includes("VERSION") ? "VERSION_CONFLICT" : "TRANSACTION_FAILED");
1543
+ } finally {
1544
+ client.release();
1545
+ await pool.end();
1546
+ }
1547
+ }
1548
+ async function applyPostgresJobWithClient(job, config, client) {
1549
+ return await withTransaction(client, async () => {
1550
+ const existing = await claimReceipt(client, job, config);
1551
+ if (existing) return existing;
1552
+ const conflictProjection = job.conflict_guard.kind === "version_column" ? `, ${quotePostgresIdentifier(job.conflict_guard.column)}::text AS "__synapsor_conflict_value"` : "";
1553
+ const row = await client.query(
1554
+ `SELECT *${conflictProjection} FROM ${quotePostgresIdentifier(job.target.schema)}.${quotePostgresIdentifier(job.target.table)}
1555
+ WHERE ${quotePostgresIdentifier(job.target.primary_key.column)} = $1
1556
+ AND ${quotePostgresIdentifier(job.target.tenant_guard.column)} = $2
1557
+ FOR UPDATE`,
1558
+ [job.target.primary_key.value, job.target.tenant_guard.value]
1559
+ );
1560
+ if (!row.rowCount) {
1561
+ const hash2 = resultHash(job, "ROW_NOT_FOUND");
1562
+ await recordReceipt(client, job, "conflict", hash2);
1563
+ return conflict(job, config, "ROW_NOT_FOUND", hash2);
1564
+ }
1565
+ const currentVersion = row.rows[0]?.__synapsor_conflict_value ?? row.rows[0]?.[job.conflict_guard.kind === "version_column" ? job.conflict_guard.column : ""];
1566
+ if (job.conflict_guard.kind === "version_column" && !versionValuesMatch(currentVersion, job.conflict_guard.expected_value)) {
1567
+ const hash2 = resultHash(job, "VERSION_CONFLICT", currentVersion);
1568
+ await recordReceipt(client, job, "conflict", hash2);
1569
+ return conflict(job, config, "VERSION_CONFLICT", hash2);
1570
+ }
1571
+ const update = buildPostgresUpdate(job);
1572
+ const applied = await client.query(update.sql, update.values);
1573
+ if (applied.rowCount !== 1) {
1574
+ throw new Error(applied.rowCount === 0 ? "VERSION_CONFLICT" : "MULTI_ROW_WRITE_BLOCKED");
1575
+ }
1576
+ const hash = resultHash(job, "applied", job.conflict_guard.kind === "version_column" ? job.conflict_guard.expected_value : void 0);
1577
+ await recordReceipt(client, job, "applied", hash);
1578
+ return {
1579
+ protocol_version: "1.0",
1580
+ job_id: job.job_id,
1581
+ runner_id: config.runnerId,
1582
+ status: "applied",
1583
+ affected_rows: 1,
1584
+ result_hash: hash,
1585
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
1586
+ };
1587
+ });
1588
+ }
1589
+ async function claimReceipt(client, job, config) {
1590
+ const claimed = await client.query(
1591
+ `INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash)
1592
+ VALUES ($1, $2, $3, 'in_progress', NULL)
1593
+ ON CONFLICT (idempotency_key) DO NOTHING
1594
+ RETURNING status, result_hash`,
1595
+ [job.idempotency_key, job.job_id, job.proposal_id]
1596
+ );
1597
+ if (claimed.rowCount === 1) return void 0;
1598
+ const receipt = await client.query(
1599
+ "SELECT status, result_hash FROM synapsor_writeback_receipts WHERE idempotency_key = $1 FOR UPDATE",
1600
+ [job.idempotency_key]
1601
+ );
1602
+ const row = receipt.rows[0];
1603
+ if (!row) return failed(job, config, "IDEMPOTENCY_RECEIPT_UNAVAILABLE");
1604
+ return resultFromExistingReceipt(job, config, row.status, row.result_hash);
1605
+ }
1606
+ function resultFromExistingReceipt(job, config, status, hash) {
1607
+ const result_hash = typeof hash === "string" && hash ? hash : resultHash(job, "idempotent");
1608
+ if (status === "applied" || status === "already_applied") {
1609
+ return {
1610
+ protocol_version: "1.0",
1611
+ job_id: job.job_id,
1612
+ runner_id: config.runnerId,
1613
+ status: "already_applied",
1614
+ affected_rows: 0,
1615
+ result_hash,
1616
+ completed_at: (/* @__PURE__ */ new Date()).toISOString()
1617
+ };
1618
+ }
1619
+ if (status === "conflict") return conflict(job, config, "IDEMPOTENT_CONFLICT", result_hash);
1620
+ if (status === "failed") return failed(job, config, "IDEMPOTENT_FAILED");
1621
+ return failed(job, config, "IDEMPOTENCY_RECEIPT_IN_PROGRESS");
1622
+ }
1623
+ async function recordReceipt(client, job, status, hash) {
1624
+ const result = await client.query(
1625
+ `UPDATE synapsor_writeback_receipts
1626
+ SET status = $2, result_hash = $3, completed_at = now()
1627
+ WHERE idempotency_key = $1`,
1628
+ [job.idempotency_key, status, hash]
1629
+ );
1630
+ if (result.rowCount !== 1) throw new Error("IDEMPOTENCY_RECEIPT_UNAVAILABLE");
1631
+ }
1632
+ function conflict(job, config, code, result_hash) {
1633
+ return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "conflict", affected_rows: 0, error_code: code, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
1634
+ }
1635
+ function failed(job, config, code) {
1636
+ return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "failed", error_code: code, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
1637
+ }
1638
+ var postgresAdapter = {
1639
+ async doctor(config) {
1640
+ if (!config.databaseUrl) return { ok: false, details: { error: "database URL required; local config apply reads source.write_url_env, legacy workers use SYNAPSOR_DATABASE_URL" } };
1641
+ const pool = createPostgresPool(config.databaseUrl);
1642
+ const client = await pool.connect();
1643
+ try {
1644
+ const result = await client.query("SELECT version()");
1645
+ await client.query("BEGIN");
1646
+ try {
1647
+ const doctorId = `doctor-${Date.now()}`;
1648
+ await client.query(
1649
+ `INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash, completed_at)
1650
+ VALUES ($1, $2, $3, $4, $5, now())`,
1651
+ [doctorId, doctorId, "doctor", "doctor", "sha256:doctor"]
1652
+ );
1653
+ await client.query("ROLLBACK");
1654
+ } catch (error) {
1655
+ await client.query("ROLLBACK").catch(() => void 0);
1656
+ throw error;
1657
+ }
1658
+ return {
1659
+ ok: true,
1660
+ details: {
1661
+ version: result.rows[0]?.version,
1662
+ receipt_table: "ready",
1663
+ write_permission_rollback: true,
1664
+ dry_run: config.dryRun
1665
+ }
1666
+ };
1667
+ } finally {
1668
+ client.release();
1669
+ await pool.end();
1670
+ }
1671
+ },
1672
+ apply: applyPostgresJob
1673
+ };
1674
+
1393
1675
  // packages/proposal-store/src/index.ts
1394
1676
  import { DatabaseSync } from "node:sqlite";
1395
- import { mkdirSync } from "node:fs";
1677
+ import { chmodSync, mkdirSync } from "node:fs";
1396
1678
  import { dirname, resolve } from "node:path";
1397
1679
  var ProposalStoreError = class extends Error {
1398
1680
  constructor(code, message) {
@@ -1408,9 +1690,17 @@ var ProposalStore = class {
1408
1690
  constructor(path4 = ":memory:") {
1409
1691
  this.path = path4;
1410
1692
  if (path4 !== ":memory:") {
1411
- mkdirSync(dirname(resolve(path4)), { recursive: true });
1693
+ mkdirSync(dirname(resolve(path4)), { recursive: true, mode: 448 });
1412
1694
  }
1413
1695
  this.db = new DatabaseSync(path4);
1696
+ if (path4 !== ":memory:" && process.platform !== "win32") {
1697
+ try {
1698
+ chmodSync(path4, 384);
1699
+ } catch (error) {
1700
+ process.stderr.write(`warning: unable to restrict Synapsor store permissions to 0600: ${error instanceof Error ? error.message : String(error)}
1701
+ `);
1702
+ }
1703
+ }
1414
1704
  this.migrate();
1415
1705
  }
1416
1706
  close() {
@@ -1802,6 +2092,18 @@ var ProposalStore = class {
1802
2092
  }
1803
2093
  return existing;
1804
2094
  }
2095
+ const active = this.findActiveProposal({
2096
+ tenant_id: changeSet.scope.tenant_id,
2097
+ action: changeSet.action,
2098
+ business_object: changeSet.scope.business_object,
2099
+ object_id: changeSet.scope.object_id
2100
+ });
2101
+ if (active) {
2102
+ throw new ProposalStoreError(
2103
+ "PROPOSAL_ALREADY_EXISTS",
2104
+ `active proposal ${active.proposal_id} is ${active.state} for ${active.business_object}:${active.object_id}`
2105
+ );
2106
+ }
1805
2107
  const state = stateFromChangeSet(changeSet);
1806
2108
  const now = changeSet.created_at || (/* @__PURE__ */ new Date()).toISOString();
1807
2109
  const insert = this.db.prepare(`
@@ -1867,6 +2169,19 @@ var ProposalStore = class {
1867
2169
  const row = this.db.prepare("SELECT * FROM proposals WHERE proposal_id = ?").get(proposalId);
1868
2170
  return rowToProposal(row);
1869
2171
  }
2172
+ findActiveProposal(input) {
2173
+ const row = this.db.prepare(`
2174
+ SELECT * FROM proposals
2175
+ WHERE tenant_id = ?
2176
+ AND action = ?
2177
+ AND business_object = ?
2178
+ AND object_id = ?
2179
+ AND state IN ('pending_review', 'approved', 'pending_worker')
2180
+ ORDER BY created_at DESC
2181
+ LIMIT 1
2182
+ `).get(input.tenant_id, input.action, input.business_object, input.object_id);
2183
+ return rowToProposal(row);
2184
+ }
1870
2185
  listProposals(filters) {
1871
2186
  if (typeof filters === "string") filters = { state: filters };
1872
2187
  const query = buildProposalQuery(filters ?? {});
@@ -3635,7 +3950,6 @@ function sortJson(value) {
3635
3950
 
3636
3951
  // packages/mcp-server/src/index.ts
3637
3952
  import mysql from "mysql2/promise";
3638
- import { Pool } from "pg";
3639
3953
  import { z as z2 } from "zod";
3640
3954
  var RESERVED_MODEL_ARGS = /* @__PURE__ */ new Set([
3641
3955
  "tenant_id",
@@ -4128,7 +4442,7 @@ async function handleStreamableHttpMcpRequest(input) {
4128
4442
  }
4129
4443
  let session;
4130
4444
  const transport = new StreamableHTTPServerTransport({
4131
- sessionIdGenerator: () => crypto.randomUUID(),
4445
+ sessionIdGenerator: () => crypto2.randomUUID(),
4132
4446
  onsessioninitialized: (newSessionId) => {
4133
4447
  if (session) {
4134
4448
  session.sessionId = newSessionId;
@@ -4253,7 +4567,7 @@ function validBearerToken(header, expected) {
4253
4567
  const actual = header.slice("Bearer ".length);
4254
4568
  const actualBuffer = Buffer.from(actual);
4255
4569
  const expectedBuffer = Buffer.from(expected);
4256
- return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
4570
+ return actualBuffer.length === expectedBuffer.length && crypto2.timingSafeEqual(actualBuffer, expectedBuffer);
4257
4571
  }
4258
4572
  function headerValue(value) {
4259
4573
  return Array.isArray(value) ? value[0] : value;
@@ -4311,7 +4625,7 @@ function toolNameExposureMap(canonicalNames, style) {
4311
4625
  return exposedByCanonical;
4312
4626
  }
4313
4627
  function shortToolHash(value) {
4314
- return crypto.createHash("sha256").update(value).digest("hex").slice(0, 8);
4628
+ return crypto2.createHash("sha256").update(value).digest("hex").slice(0, 8);
4315
4629
  }
4316
4630
  function setCorsHeaders(response, corsOrigin) {
4317
4631
  if (corsOrigin) {
@@ -4611,7 +4925,28 @@ async function callConfiguredTool(input) {
4611
4925
  queryFingerprint,
4612
4926
  objectId
4613
4927
  });
4614
- const proposal = input.store.createProposal(changeSet);
4928
+ const activeProposal = input.store.findActiveProposal({
4929
+ tenant_id: context.tenant_id,
4930
+ action: capability.name,
4931
+ business_object: capability.target.table,
4932
+ object_id: objectId
4933
+ });
4934
+ if (activeProposal) throw proposalAlreadyExists(activeProposal);
4935
+ let proposal;
4936
+ try {
4937
+ proposal = input.store.createProposal(changeSet);
4938
+ } catch (error) {
4939
+ if (error instanceof ProposalStoreError && error.code === "PROPOSAL_ALREADY_EXISTS") {
4940
+ const existing = input.store.findActiveProposal({
4941
+ tenant_id: context.tenant_id,
4942
+ action: capability.name,
4943
+ business_object: capability.target.table,
4944
+ object_id: objectId
4945
+ });
4946
+ if (existing) throw proposalAlreadyExists(existing);
4947
+ }
4948
+ throw error;
4949
+ }
4615
4950
  const approvalResult = maybeAutoApproveProposal({
4616
4951
  config: input.config,
4617
4952
  capability,
@@ -4893,6 +5228,9 @@ function safeToolError(error) {
4893
5228
  if (runtimeCode === "PROPOSALS_DISABLED") {
4894
5229
  return { code: "APPROVAL_REQUIRED", message: "Proposal tools are disabled for this runner mode.", retryable: false };
4895
5230
  }
5231
+ if (runtimeCode === "PROPOSAL_ALREADY_EXISTS") {
5232
+ return { code: "PROPOSAL_ALREADY_EXISTS", message: error instanceof Error ? error.message : "An active proposal already exists.", retryable: false };
5233
+ }
4896
5234
  if (runtimeCode && (runtimeCode.startsWith("ARGUMENT_") || runtimeCode === "LOOKUP_ARG_MISSING" || runtimeCode === "MODEL_CANNOT_OVERRIDE_BINDING" || runtimeCode === "TRUSTED_BINDING_MISSING" || runtimeCode === "TRUSTED_CONTEXT_MISSING")) {
4897
5235
  return { code: "INVALID_ARGUMENT", message: "The tool input or trusted context binding is invalid.", retryable: false };
4898
5236
  }
@@ -4920,6 +5258,7 @@ function buildChangeSet(input) {
4920
5258
  const writebackMode = capabilityWritebackMode2(input.capability);
4921
5259
  const changeSetWritebackMode = writebackMode === "none" ? "read_only" : "trusted_worker_required";
4922
5260
  const writebackExecutor = writebackMode === "none" ? "none" : writebackMode === "cloud_worker" ? "cloud_worker" : writebackMode === "direct_sql" ? "sql_update" : capabilityWritebackExecutor(input.capability);
5261
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
4923
5262
  const proposalCore = {
4924
5263
  schema_version: protocolVersions.changeSet,
4925
5264
  proposal_id: stableId("wrp", {
@@ -4927,7 +5266,8 @@ function buildChangeSet(input) {
4927
5266
  tenant: input.context.tenant_id,
4928
5267
  object: input.objectId,
4929
5268
  before,
4930
- patch
5269
+ patch,
5270
+ created_at: createdAt
4931
5271
  }),
4932
5272
  proposal_version: 1,
4933
5273
  action: input.capability.name,
@@ -4973,7 +5313,7 @@ function buildChangeSet(input) {
4973
5313
  executor: writebackExecutor
4974
5314
  },
4975
5315
  source_database_mutated: false,
4976
- created_at: (/* @__PURE__ */ new Date()).toISOString()
5316
+ created_at: createdAt
4977
5317
  };
4978
5318
  return {
4979
5319
  ...proposalCore,
@@ -4995,7 +5335,7 @@ async function readCurrentRow(input) {
4995
5335
  async function readPostgresRow(input) {
4996
5336
  const connectionString = envValue(input.env, input.source.read_url_env);
4997
5337
  if (!connectionString) throw new McpRuntimeError("SOURCE_CREDENTIAL_MISSING", `${input.source.read_url_env} is not set.`);
4998
- const pool = new Pool({ connectionString });
5338
+ const pool = createPostgresPool(connectionString);
4999
5339
  const client = await pool.connect();
5000
5340
  try {
5001
5341
  const query = buildSelect(input.capability, "$");
@@ -5279,309 +5619,94 @@ function scalar2(value) {
5279
5619
  if (typeof value === "bigint") return Number(value);
5280
5620
  return String(value);
5281
5621
  }
5282
- function stableId(prefix, input) {
5283
- return `${prefix}_${crypto.createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 20)}`;
5284
- }
5285
- function hashJson(input) {
5286
- return `sha256:${crypto.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
5287
- }
5288
- function isRecord4(value) {
5289
- return typeof value === "object" && value !== null && !Array.isArray(value);
5290
- }
5291
- function toolErrorPayload(error) {
5292
- if (error instanceof McpRuntimeError) {
5293
- if (error.code === "LOCAL_STORE_UNAVAILABLE") {
5294
- return { ok: false, code: "TEMPORARILY_UNAVAILABLE", error: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying." };
5295
- }
5296
- return { ok: false, code: error.code, error: error.message };
5297
- }
5298
- return { ok: false, code: "MCP_TOOL_FAILED", error: error instanceof Error ? error.message : String(error) };
5299
- }
5300
-
5301
- // packages/mysql/src/index.ts
5302
- import crypto2 from "node:crypto";
5303
- import mysql2 from "mysql2/promise";
5304
- var mysqlReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
5305
- idempotency_key varchar(255) PRIMARY KEY,
5306
- job_id varchar(255) UNIQUE NOT NULL,
5307
- proposal_id varchar(512) NOT NULL,
5308
- status varchar(64) NOT NULL,
5309
- result_hash varchar(128),
5310
- created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
5311
- completed_at timestamp NULL
5312
- )`;
5313
- function quoteMysqlIdentifier(identifier) {
5314
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
5315
- throw new Error(`unsafe mysql identifier: ${identifier}`);
5316
- }
5317
- return `\`${identifier}\``;
5318
- }
5319
- function buildMysqlUpdate(job) {
5320
- validateMysqlPatch(job);
5321
- const values = [];
5322
- const setFragments = Object.entries(job.patch).map(([column, value]) => {
5323
- values.push(value);
5324
- return `${quoteMysqlIdentifier(column)} = ?`;
5325
- });
5326
- values.push(job.target.primary_key.value, job.target.tenant_guard.value);
5327
- const where = [
5328
- `${quoteMysqlIdentifier(job.target.primary_key.column)} = ?`,
5329
- `${quoteMysqlIdentifier(job.target.tenant_guard.column)} = ?`
5330
- ];
5331
- if (job.conflict_guard.kind === "version_column") {
5332
- values.push(job.conflict_guard.expected_value);
5333
- where.push(`${quoteMysqlIdentifier(job.conflict_guard.column)} = ?`);
5334
- }
5335
- const sql = `UPDATE ${quoteMysqlIdentifier(job.target.schema)}.${quoteMysqlIdentifier(job.target.table)}
5336
- SET ${setFragments.join(", ")}
5337
- WHERE ${where.join(" AND ")}`;
5338
- return { sql, values };
5339
- }
5340
- function validateMysqlPatch(job) {
5341
- const patchColumns = Object.keys(job.patch || {});
5342
- if (!patchColumns.length) {
5343
- throw new Error("mysql writeback patch must not be empty");
5344
- }
5345
- const allowedColumns = new Set(Array.isArray(job.allowed_columns) ? job.allowed_columns : []);
5346
- if (allowedColumns.has(job.target.primary_key.column)) {
5347
- throw new Error("mysql primary key column must not be patch-allowlisted");
5348
- }
5349
- if (allowedColumns.has(job.target.tenant_guard.column)) {
5350
- throw new Error("mysql tenant guard column must not be patch-allowlisted");
5351
- }
5352
- for (const column of patchColumns) {
5353
- if (!allowedColumns.has(column)) {
5354
- throw new Error(`mysql patch column not allowlisted: ${column}`);
5355
- }
5356
- }
5357
- }
5358
- function resultHash(job, status, version) {
5359
- return `sha256:${crypto2.createHash("sha256").update(JSON.stringify({ job_id: job.job_id, status, version })).digest("hex")}`;
5360
- }
5361
- function normalizeVersionValue(value) {
5362
- if (value instanceof Date) return normalizeVersionValue(value.toISOString());
5363
- const text = String(value ?? "").trim();
5364
- const timestamp = text.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}(?::?\d{2})?)?$/i);
5365
- if (timestamp) {
5366
- const fraction = (timestamp[3] ?? "").padEnd(6, "0").slice(0, 6);
5367
- const offset = timestamp[4];
5368
- if (offset && offset.toUpperCase() !== "Z") {
5369
- const dateParts = timestamp[1].split("-");
5370
- const timeParts = timestamp[2].split(":");
5371
- const year = Number(dateParts[0]);
5372
- const month = Number(dateParts[1]);
5373
- const day = Number(dateParts[2]);
5374
- const hour = Number(timeParts[0]);
5375
- const minute = Number(timeParts[1]);
5376
- const second = Number(timeParts[2]);
5377
- const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second) - offsetMinutes(offset) * 6e4);
5378
- return `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(utc.getUTCDate())} ${pad2(utc.getUTCHours())}:${pad2(utc.getUTCMinutes())}:${pad2(utc.getUTCSeconds())}.${fraction}`;
5379
- }
5380
- return `${timestamp[1]} ${timestamp[2]}.${fraction}`;
5381
- }
5382
- return text;
5383
- }
5384
- function offsetMinutes(offset) {
5385
- const sign = offset.startsWith("-") ? -1 : 1;
5386
- const compact = offset.slice(1).replace(":", "");
5387
- const hours = Number(compact.slice(0, 2));
5388
- const minutes = Number(compact.slice(2, 4) || "0");
5389
- return sign * (hours * 60 + minutes);
5390
- }
5391
- function pad2(value) {
5392
- return String(value).padStart(2, "0");
5393
- }
5394
- function versionValuesMatch(actual, expected) {
5395
- return normalizeVersionValue(actual) === normalizeVersionValue(expected);
5396
- }
5397
- async function applyMysqlJob(job, config) {
5398
- if (config.dryRun) {
5399
- buildMysqlUpdate(job);
5400
- return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "applied", affected_rows: 0, result_hash: resultHash(job, "dry-run"), completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5401
- }
5402
- if (!config.databaseUrl) return failed(job, config, "DATABASE_UNAVAILABLE");
5403
- const connection = await mysql2.createConnection({ uri: config.databaseUrl, dateStrings: true });
5404
- try {
5405
- return await applyMysqlJobWithConnection(job, config, connection);
5406
- } catch (error) {
5407
- const message = error instanceof Error ? error.message : "TRANSACTION_FAILED";
5408
- return failed(job, config, message.includes("MULTI_ROW") ? "MULTI_ROW_WRITE_BLOCKED" : message.includes("VERSION") ? "VERSION_CONFLICT" : "TRANSACTION_FAILED");
5409
- } finally {
5410
- await connection.end();
5411
- }
5412
- }
5413
- async function applyMysqlJobWithConnection(job, config, connection) {
5414
- await connection.beginTransaction();
5415
- try {
5416
- await connection.query(mysqlReceiptMigration);
5417
- const existing = await claimReceipt(connection, job, config);
5418
- if (existing) {
5419
- await connection.commit();
5420
- return existing;
5421
- }
5422
- const [rows] = await connection.query(
5423
- `SELECT * FROM ${quoteMysqlIdentifier(job.target.schema)}.${quoteMysqlIdentifier(job.target.table)}
5424
- WHERE ${quoteMysqlIdentifier(job.target.primary_key.column)} = ?
5425
- AND ${quoteMysqlIdentifier(job.target.tenant_guard.column)} = ?
5426
- FOR UPDATE`,
5427
- [job.target.primary_key.value, job.target.tenant_guard.value]
5428
- );
5429
- if (!rows[0]) {
5430
- const hash2 = resultHash(job, "ROW_NOT_FOUND");
5431
- await recordReceipt(connection, job, "conflict", hash2);
5432
- await connection.commit();
5433
- return conflict(job, config, "ROW_NOT_FOUND", hash2);
5434
- }
5435
- if (job.conflict_guard.kind === "version_column" && !versionValuesMatch(rows[0][job.conflict_guard.column], job.conflict_guard.expected_value)) {
5436
- const hash2 = resultHash(job, "VERSION_CONFLICT", rows[0][job.conflict_guard.column]);
5437
- await recordReceipt(connection, job, "conflict", hash2);
5438
- await connection.commit();
5439
- return conflict(job, config, "VERSION_CONFLICT", hash2);
5440
- }
5441
- const update = buildMysqlUpdate(job);
5442
- const [result] = await connection.query(update.sql, update.values);
5443
- if (result.affectedRows !== 1) throw new Error(result.affectedRows === 0 ? "VERSION_CONFLICT" : "MULTI_ROW_WRITE_BLOCKED");
5444
- const hash = resultHash(job, "applied", job.conflict_guard.kind === "version_column" ? job.conflict_guard.expected_value : void 0);
5445
- await recordReceipt(connection, job, "applied", hash);
5446
- await connection.commit();
5447
- return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "applied", affected_rows: 1, result_hash: hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5448
- } catch (error) {
5449
- await connection.rollback().catch(() => void 0);
5450
- throw error;
5451
- }
5452
- }
5453
- async function claimReceipt(connection, job, config) {
5454
- const [claim] = await connection.query(
5455
- `INSERT IGNORE INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash)
5456
- VALUES (?, ?, ?, 'in_progress', NULL)`,
5457
- [job.idempotency_key, job.job_id, job.proposal_id]
5458
- );
5459
- if (claim.affectedRows === 1) return void 0;
5460
- const [receiptRows] = await connection.query(
5461
- "SELECT status, result_hash FROM synapsor_writeback_receipts WHERE idempotency_key = ? FOR UPDATE",
5462
- [job.idempotency_key]
5622
+ function proposalAlreadyExists(existing) {
5623
+ process.stderr.write(`${JSON.stringify({
5624
+ level: "warn",
5625
+ event: "proposal_already_exists",
5626
+ proposal_id: existing.proposal_id,
5627
+ state: existing.state,
5628
+ capability: existing.action,
5629
+ object_id: existing.object_id
5630
+ })}
5631
+ `);
5632
+ return new McpRuntimeError(
5633
+ "PROPOSAL_ALREADY_EXISTS",
5634
+ `Active proposal ${existing.proposal_id} is already ${existing.state} for this object. Inspect or resolve it before proposing again.`
5463
5635
  );
5464
- const row = receiptRows[0];
5465
- if (!row) return failed(job, config, "IDEMPOTENCY_RECEIPT_UNAVAILABLE");
5466
- return resultFromExistingReceipt(job, config, row.status, row.result_hash);
5467
5636
  }
5468
- function resultFromExistingReceipt(job, config, status, hash) {
5469
- const result_hash = typeof hash === "string" && hash ? hash : resultHash(job, "idempotent");
5470
- if (status === "applied" || status === "already_applied") {
5471
- return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "already_applied", affected_rows: 0, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5472
- }
5473
- if (status === "conflict") return conflict(job, config, "IDEMPOTENT_CONFLICT", result_hash);
5474
- if (status === "failed") return failed(job, config, "IDEMPOTENT_FAILED");
5475
- return failed(job, config, "IDEMPOTENCY_RECEIPT_IN_PROGRESS");
5476
- }
5477
- async function recordReceipt(connection, job, status, hash) {
5478
- const [result] = await connection.query(
5479
- `UPDATE synapsor_writeback_receipts
5480
- SET status = ?, result_hash = ?, completed_at = CURRENT_TIMESTAMP
5481
- WHERE idempotency_key = ?`,
5482
- [status, hash, job.idempotency_key]
5483
- );
5484
- if (result.affectedRows !== 1) throw new Error("IDEMPOTENCY_RECEIPT_UNAVAILABLE");
5637
+ function stableId(prefix, input) {
5638
+ return `${prefix}_${crypto2.createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 20)}`;
5485
5639
  }
5486
- function conflict(job, config, code, result_hash) {
5487
- return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "conflict", affected_rows: 0, error_code: code, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5640
+ function hashJson(input) {
5641
+ return `sha256:${crypto2.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
5488
5642
  }
5489
- function failed(job, config, code) {
5490
- return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "failed", error_code: code, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5643
+ function isRecord4(value) {
5644
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5491
5645
  }
5492
- var mysqlAdapter = {
5493
- async doctor(config) {
5494
- if (!config.databaseUrl) return { ok: false, details: { error: "database URL required; local config apply reads source.write_url_env, legacy workers use SYNAPSOR_DATABASE_URL" } };
5495
- const connection = await mysql2.createConnection({ uri: config.databaseUrl, dateStrings: true });
5496
- try {
5497
- const [rows] = await connection.query("SELECT VERSION() AS version");
5498
- await connection.query(mysqlReceiptMigration);
5499
- await connection.beginTransaction();
5500
- try {
5501
- const doctorId = `doctor-${Date.now()}`;
5502
- await connection.query(
5503
- `INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash, completed_at)
5504
- VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
5505
- [doctorId, doctorId, "doctor", "doctor", "sha256:doctor"]
5506
- );
5507
- await connection.rollback();
5508
- } catch (error) {
5509
- await connection.rollback().catch(() => void 0);
5510
- throw error;
5511
- }
5512
- return {
5513
- ok: true,
5514
- details: {
5515
- version: rows[0]?.version,
5516
- receipt_table: "ready",
5517
- write_permission_rollback: true,
5518
- dry_run: config.dryRun
5519
- }
5520
- };
5521
- } finally {
5522
- await connection.end();
5646
+ function toolErrorPayload(error) {
5647
+ if (error instanceof McpRuntimeError) {
5648
+ if (error.code === "LOCAL_STORE_UNAVAILABLE") {
5649
+ return { ok: false, code: "TEMPORARILY_UNAVAILABLE", error: "The local runner store is temporarily unavailable. Restart the runner or recreate the store before retrying." };
5523
5650
  }
5524
- },
5525
- apply: applyMysqlJob
5526
- };
5651
+ return { ok: false, code: error.code, error: error.message };
5652
+ }
5653
+ return { ok: false, code: "MCP_TOOL_FAILED", error: error instanceof Error ? error.message : String(error) };
5654
+ }
5527
5655
 
5528
- // packages/postgres/src/index.ts
5656
+ // packages/mysql/src/index.ts
5529
5657
  import crypto3 from "node:crypto";
5530
- import { Pool as Pool2 } from "pg";
5531
- var postgresReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
5532
- idempotency_key text PRIMARY KEY,
5533
- job_id text UNIQUE NOT NULL,
5534
- proposal_id text NOT NULL,
5535
- status text NOT NULL,
5536
- result_hash text,
5537
- created_at timestamptz NOT NULL DEFAULT now(),
5538
- completed_at timestamptz
5658
+ import mysql2 from "mysql2/promise";
5659
+ var mysqlReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
5660
+ idempotency_key varchar(255) PRIMARY KEY,
5661
+ job_id varchar(255) UNIQUE NOT NULL,
5662
+ proposal_id varchar(512) NOT NULL,
5663
+ status varchar(64) NOT NULL,
5664
+ result_hash varchar(128),
5665
+ created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
5666
+ completed_at timestamp NULL
5539
5667
  )`;
5540
- function quotePostgresIdentifier(identifier) {
5668
+ function quoteMysqlIdentifier(identifier) {
5541
5669
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
5542
- throw new Error(`unsafe postgres identifier: ${identifier}`);
5670
+ throw new Error(`unsafe mysql identifier: ${identifier}`);
5543
5671
  }
5544
- return `"${identifier}"`;
5672
+ return `\`${identifier}\``;
5545
5673
  }
5546
- function buildPostgresUpdate(job) {
5547
- validatePostgresPatch(job);
5674
+ function buildMysqlUpdate(job) {
5675
+ validateMysqlPatch(job);
5548
5676
  const values = [];
5549
5677
  const setFragments = Object.entries(job.patch).map(([column, value]) => {
5550
5678
  values.push(value);
5551
- return `${quotePostgresIdentifier(column)} = $${values.length}`;
5679
+ return `${quoteMysqlIdentifier(column)} = ?`;
5552
5680
  });
5553
- values.push(job.target.primary_key.value);
5554
- const pkParam = `$${values.length}`;
5555
- values.push(job.target.tenant_guard.value);
5556
- const tenantParam = `$${values.length}`;
5681
+ values.push(job.target.primary_key.value, job.target.tenant_guard.value);
5557
5682
  const where = [
5558
- `${quotePostgresIdentifier(job.target.primary_key.column)} = ${pkParam}`,
5559
- `${quotePostgresIdentifier(job.target.tenant_guard.column)} = ${tenantParam}`
5683
+ `${quoteMysqlIdentifier(job.target.primary_key.column)} = ?`,
5684
+ `${quoteMysqlIdentifier(job.target.tenant_guard.column)} = ?`
5560
5685
  ];
5561
5686
  if (job.conflict_guard.kind === "version_column") {
5562
5687
  values.push(job.conflict_guard.expected_value);
5563
- where.push(`${quotePostgresIdentifier(job.conflict_guard.column)} = $${values.length}`);
5688
+ where.push(`${quoteMysqlIdentifier(job.conflict_guard.column)} = ?`);
5564
5689
  }
5565
- const sql = `UPDATE ${quotePostgresIdentifier(job.target.schema)}.${quotePostgresIdentifier(job.target.table)}
5690
+ const sql = `UPDATE ${quoteMysqlIdentifier(job.target.schema)}.${quoteMysqlIdentifier(job.target.table)}
5566
5691
  SET ${setFragments.join(", ")}
5567
5692
  WHERE ${where.join(" AND ")}`;
5568
5693
  return { sql, values };
5569
5694
  }
5570
- function validatePostgresPatch(job) {
5695
+ function validateMysqlPatch(job) {
5571
5696
  const patchColumns = Object.keys(job.patch || {});
5572
5697
  if (!patchColumns.length) {
5573
- throw new Error("postgres writeback patch must not be empty");
5698
+ throw new Error("mysql writeback patch must not be empty");
5574
5699
  }
5575
5700
  const allowedColumns = new Set(Array.isArray(job.allowed_columns) ? job.allowed_columns : []);
5576
5701
  if (allowedColumns.has(job.target.primary_key.column)) {
5577
- throw new Error("postgres primary key column must not be patch-allowlisted");
5702
+ throw new Error("mysql primary key column must not be patch-allowlisted");
5578
5703
  }
5579
5704
  if (allowedColumns.has(job.target.tenant_guard.column)) {
5580
- throw new Error("postgres tenant guard column must not be patch-allowlisted");
5705
+ throw new Error("mysql tenant guard column must not be patch-allowlisted");
5581
5706
  }
5582
5707
  for (const column of patchColumns) {
5583
5708
  if (!allowedColumns.has(column)) {
5584
- throw new Error(`postgres patch column not allowlisted: ${column}`);
5709
+ throw new Error(`mysql patch column not allowlisted: ${column}`);
5585
5710
  }
5586
5711
  }
5587
5712
  }
@@ -5624,129 +5749,93 @@ function pad22(value) {
5624
5749
  function versionValuesMatch2(actual, expected) {
5625
5750
  return normalizeVersionValue2(actual) === normalizeVersionValue2(expected);
5626
5751
  }
5627
- async function withTransaction(client, fn) {
5628
- await client.query("BEGIN");
5629
- try {
5630
- const result = await fn();
5631
- await client.query("COMMIT");
5632
- return result;
5633
- } catch (error) {
5634
- await client.query("ROLLBACK").catch(() => void 0);
5635
- throw error;
5636
- }
5637
- }
5638
- async function applyPostgresJob(job, config) {
5752
+ async function applyMysqlJob(job, config) {
5639
5753
  if (config.dryRun) {
5640
- buildPostgresUpdate(job);
5641
- return {
5642
- protocol_version: "1.0",
5643
- job_id: job.job_id,
5644
- runner_id: config.runnerId,
5645
- status: "applied",
5646
- affected_rows: 0,
5647
- result_hash: resultHash2(job, "dry-run"),
5648
- completed_at: (/* @__PURE__ */ new Date()).toISOString()
5649
- };
5650
- }
5651
- if (!config.databaseUrl) {
5652
- return failed2(job, config, "DATABASE_UNAVAILABLE");
5754
+ buildMysqlUpdate(job);
5755
+ return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "applied", affected_rows: 0, result_hash: resultHash2(job, "dry-run"), completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5653
5756
  }
5654
- const pool = new Pool2({ connectionString: config.databaseUrl });
5655
- const client = await pool.connect();
5757
+ if (!config.databaseUrl) return failed2(job, config, "DATABASE_UNAVAILABLE");
5758
+ const connection = await mysql2.createConnection({ uri: config.databaseUrl, dateStrings: true });
5656
5759
  try {
5657
- return await applyPostgresJobWithClient(job, config, client);
5760
+ return await applyMysqlJobWithConnection(job, config, connection);
5658
5761
  } catch (error) {
5659
5762
  const message = error instanceof Error ? error.message : "TRANSACTION_FAILED";
5660
5763
  return failed2(job, config, message.includes("MULTI_ROW") ? "MULTI_ROW_WRITE_BLOCKED" : message.includes("VERSION") ? "VERSION_CONFLICT" : "TRANSACTION_FAILED");
5661
5764
  } finally {
5662
- client.release();
5663
- await pool.end();
5765
+ await connection.end();
5664
5766
  }
5665
5767
  }
5666
- async function applyPostgresJobWithClient(job, config, client) {
5667
- return await withTransaction(client, async () => {
5668
- await client.query(postgresReceiptMigration);
5669
- const existing = await claimReceipt2(client, job, config);
5670
- if (existing) return existing;
5671
- const conflictProjection = job.conflict_guard.kind === "version_column" ? `, ${quotePostgresIdentifier(job.conflict_guard.column)}::text AS "__synapsor_conflict_value"` : "";
5672
- const row = await client.query(
5673
- `SELECT *${conflictProjection} FROM ${quotePostgresIdentifier(job.target.schema)}.${quotePostgresIdentifier(job.target.table)}
5674
- WHERE ${quotePostgresIdentifier(job.target.primary_key.column)} = $1
5675
- AND ${quotePostgresIdentifier(job.target.tenant_guard.column)} = $2
5768
+ async function applyMysqlJobWithConnection(job, config, connection) {
5769
+ await connection.beginTransaction();
5770
+ try {
5771
+ const existing = await claimReceipt2(connection, job, config);
5772
+ if (existing) {
5773
+ await connection.commit();
5774
+ return existing;
5775
+ }
5776
+ const [rows] = await connection.query(
5777
+ `SELECT * FROM ${quoteMysqlIdentifier(job.target.schema)}.${quoteMysqlIdentifier(job.target.table)}
5778
+ WHERE ${quoteMysqlIdentifier(job.target.primary_key.column)} = ?
5779
+ AND ${quoteMysqlIdentifier(job.target.tenant_guard.column)} = ?
5676
5780
  FOR UPDATE`,
5677
5781
  [job.target.primary_key.value, job.target.tenant_guard.value]
5678
5782
  );
5679
- if (!row.rowCount) {
5783
+ if (!rows[0]) {
5680
5784
  const hash2 = resultHash2(job, "ROW_NOT_FOUND");
5681
- await recordReceipt2(client, job, "conflict", hash2);
5785
+ await recordReceipt2(connection, job, "conflict", hash2);
5786
+ await connection.commit();
5682
5787
  return conflict2(job, config, "ROW_NOT_FOUND", hash2);
5683
5788
  }
5684
- const currentVersion = row.rows[0]?.__synapsor_conflict_value ?? row.rows[0]?.[job.conflict_guard.kind === "version_column" ? job.conflict_guard.column : ""];
5685
- if (job.conflict_guard.kind === "version_column" && !versionValuesMatch2(currentVersion, job.conflict_guard.expected_value)) {
5686
- const hash2 = resultHash2(job, "VERSION_CONFLICT", currentVersion);
5687
- await recordReceipt2(client, job, "conflict", hash2);
5789
+ if (job.conflict_guard.kind === "version_column" && !versionValuesMatch2(rows[0][job.conflict_guard.column], job.conflict_guard.expected_value)) {
5790
+ const hash2 = resultHash2(job, "VERSION_CONFLICT", rows[0][job.conflict_guard.column]);
5791
+ await recordReceipt2(connection, job, "conflict", hash2);
5792
+ await connection.commit();
5688
5793
  return conflict2(job, config, "VERSION_CONFLICT", hash2);
5689
5794
  }
5690
- const update = buildPostgresUpdate(job);
5691
- const applied = await client.query(update.sql, update.values);
5692
- if (applied.rowCount !== 1) {
5693
- throw new Error(applied.rowCount === 0 ? "VERSION_CONFLICT" : "MULTI_ROW_WRITE_BLOCKED");
5694
- }
5795
+ const update = buildMysqlUpdate(job);
5796
+ const [result] = await connection.query(update.sql, update.values);
5797
+ if (result.affectedRows !== 1) throw new Error(result.affectedRows === 0 ? "VERSION_CONFLICT" : "MULTI_ROW_WRITE_BLOCKED");
5695
5798
  const hash = resultHash2(job, "applied", job.conflict_guard.kind === "version_column" ? job.conflict_guard.expected_value : void 0);
5696
- await recordReceipt2(client, job, "applied", hash);
5697
- return {
5698
- protocol_version: "1.0",
5699
- job_id: job.job_id,
5700
- runner_id: config.runnerId,
5701
- status: "applied",
5702
- affected_rows: 1,
5703
- result_hash: hash,
5704
- completed_at: (/* @__PURE__ */ new Date()).toISOString()
5705
- };
5706
- });
5799
+ await recordReceipt2(connection, job, "applied", hash);
5800
+ await connection.commit();
5801
+ return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "applied", affected_rows: 1, result_hash: hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5802
+ } catch (error) {
5803
+ await connection.rollback().catch(() => void 0);
5804
+ throw error;
5805
+ }
5707
5806
  }
5708
- async function claimReceipt2(client, job, config) {
5709
- const claimed = await client.query(
5710
- `INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash)
5711
- VALUES ($1, $2, $3, 'in_progress', NULL)
5712
- ON CONFLICT (idempotency_key) DO NOTHING
5713
- RETURNING status, result_hash`,
5807
+ async function claimReceipt2(connection, job, config) {
5808
+ const [claim] = await connection.query(
5809
+ `INSERT IGNORE INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash)
5810
+ VALUES (?, ?, ?, 'in_progress', NULL)`,
5714
5811
  [job.idempotency_key, job.job_id, job.proposal_id]
5715
5812
  );
5716
- if (claimed.rowCount === 1) return void 0;
5717
- const receipt = await client.query(
5718
- "SELECT status, result_hash FROM synapsor_writeback_receipts WHERE idempotency_key = $1 FOR UPDATE",
5813
+ if (claim.affectedRows === 1) return void 0;
5814
+ const [receiptRows] = await connection.query(
5815
+ "SELECT status, result_hash FROM synapsor_writeback_receipts WHERE idempotency_key = ? FOR UPDATE",
5719
5816
  [job.idempotency_key]
5720
5817
  );
5721
- const row = receipt.rows[0];
5818
+ const row = receiptRows[0];
5722
5819
  if (!row) return failed2(job, config, "IDEMPOTENCY_RECEIPT_UNAVAILABLE");
5723
5820
  return resultFromExistingReceipt2(job, config, row.status, row.result_hash);
5724
5821
  }
5725
5822
  function resultFromExistingReceipt2(job, config, status, hash) {
5726
5823
  const result_hash = typeof hash === "string" && hash ? hash : resultHash2(job, "idempotent");
5727
5824
  if (status === "applied" || status === "already_applied") {
5728
- return {
5729
- protocol_version: "1.0",
5730
- job_id: job.job_id,
5731
- runner_id: config.runnerId,
5732
- status: "already_applied",
5733
- affected_rows: 0,
5734
- result_hash,
5735
- completed_at: (/* @__PURE__ */ new Date()).toISOString()
5736
- };
5825
+ return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "already_applied", affected_rows: 0, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5737
5826
  }
5738
5827
  if (status === "conflict") return conflict2(job, config, "IDEMPOTENT_CONFLICT", result_hash);
5739
5828
  if (status === "failed") return failed2(job, config, "IDEMPOTENT_FAILED");
5740
5829
  return failed2(job, config, "IDEMPOTENCY_RECEIPT_IN_PROGRESS");
5741
5830
  }
5742
- async function recordReceipt2(client, job, status, hash) {
5743
- const result = await client.query(
5831
+ async function recordReceipt2(connection, job, status, hash) {
5832
+ const [result] = await connection.query(
5744
5833
  `UPDATE synapsor_writeback_receipts
5745
- SET status = $2, result_hash = $3, completed_at = now()
5746
- WHERE idempotency_key = $1`,
5747
- [job.idempotency_key, status, hash]
5834
+ SET status = ?, result_hash = ?, completed_at = CURRENT_TIMESTAMP
5835
+ WHERE idempotency_key = ?`,
5836
+ [status, hash, job.idempotency_key]
5748
5837
  );
5749
- if (result.rowCount !== 1) throw new Error("IDEMPOTENCY_RECEIPT_UNAVAILABLE");
5838
+ if (result.affectedRows !== 1) throw new Error("IDEMPOTENCY_RECEIPT_UNAVAILABLE");
5750
5839
  }
5751
5840
  function conflict2(job, config, code, result_hash) {
5752
5841
  return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "conflict", affected_rows: 0, error_code: code, result_hash, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
@@ -5754,47 +5843,44 @@ function conflict2(job, config, code, result_hash) {
5754
5843
  function failed2(job, config, code) {
5755
5844
  return { protocol_version: "1.0", job_id: job.job_id, runner_id: config.runnerId, status: "failed", error_code: code, completed_at: (/* @__PURE__ */ new Date()).toISOString() };
5756
5845
  }
5757
- var postgresAdapter = {
5846
+ var mysqlAdapter = {
5758
5847
  async doctor(config) {
5759
5848
  if (!config.databaseUrl) return { ok: false, details: { error: "database URL required; local config apply reads source.write_url_env, legacy workers use SYNAPSOR_DATABASE_URL" } };
5760
- const pool = new Pool2({ connectionString: config.databaseUrl });
5761
- const client = await pool.connect();
5849
+ const connection = await mysql2.createConnection({ uri: config.databaseUrl, dateStrings: true });
5762
5850
  try {
5763
- const result = await client.query("SELECT version()");
5764
- await client.query(postgresReceiptMigration);
5765
- await client.query("BEGIN");
5851
+ const [rows] = await connection.query("SELECT VERSION() AS version");
5852
+ await connection.beginTransaction();
5766
5853
  try {
5767
5854
  const doctorId = `doctor-${Date.now()}`;
5768
- await client.query(
5855
+ await connection.query(
5769
5856
  `INSERT INTO synapsor_writeback_receipts (idempotency_key, job_id, proposal_id, status, result_hash, completed_at)
5770
- VALUES ($1, $2, $3, $4, $5, now())`,
5857
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
5771
5858
  [doctorId, doctorId, "doctor", "doctor", "sha256:doctor"]
5772
5859
  );
5773
- await client.query("ROLLBACK");
5860
+ await connection.rollback();
5774
5861
  } catch (error) {
5775
- await client.query("ROLLBACK").catch(() => void 0);
5862
+ await connection.rollback().catch(() => void 0);
5776
5863
  throw error;
5777
5864
  }
5778
5865
  return {
5779
5866
  ok: true,
5780
5867
  details: {
5781
- version: result.rows[0]?.version,
5868
+ version: rows[0]?.version,
5782
5869
  receipt_table: "ready",
5783
5870
  write_permission_rollback: true,
5784
5871
  dry_run: config.dryRun
5785
5872
  }
5786
5873
  };
5787
5874
  } finally {
5788
- client.release();
5789
- await pool.end();
5875
+ await connection.end();
5790
5876
  }
5791
5877
  },
5792
- apply: applyPostgresJob
5878
+ apply: applyMysqlJob
5793
5879
  };
5794
5880
 
5795
5881
  // packages/schema-inspector/src/index.ts
5796
5882
  import mysql3 from "mysql2/promise";
5797
- import { Pool as Pool3 } from "pg";
5883
+ import { Pool as Pool2 } from "pg";
5798
5884
  var TENANT_COLUMNS = /* @__PURE__ */ new Set(["tenant_id", "account_id", "organization_id", "org_id", "workspace_id", "customer_id"]);
5799
5885
  var CONFLICT_COLUMNS = /* @__PURE__ */ new Set(["updated_at", "modified_at", "row_version", "version", "lock_version", "etag"]);
5800
5886
  var IMMUTABLE_COLUMNS = /* @__PURE__ */ new Set(["id", "uuid", "created_at", "created_by"]);
@@ -5977,7 +6063,7 @@ function summarizeInspection(inspection) {
5977
6063
  `;
5978
6064
  }
5979
6065
  async function inspectPostgres(options) {
5980
- const pool = new Pool3({ connectionString: options.url, connectionTimeoutMillis: options.statementTimeoutMs ?? 3e3 });
6066
+ const pool = new Pool2({ connectionString: options.url, connectionTimeoutMillis: options.statementTimeoutMs ?? 3e3 });
5981
6067
  const client = await pool.connect();
5982
6068
  try {
5983
6069
  await client.query("BEGIN READ ONLY");
@@ -7220,8 +7306,7 @@ function parseCapabilityBlock(block) {
7220
7306
  }
7221
7307
  const lookup = item.text.match(/^LOOKUP\s+([A-Za-z_][A-Za-z0-9_]*)\s+BY\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
7222
7308
  if (lookup?.[1] && lookup[2]) {
7223
- capability.lookup = { arg: lookup[1], column: lookup[2] };
7224
- capability.primaryKey = lookup[2];
7309
+ capability.lookup = { arg: lookup[1], column: lookup[2], line: item.line };
7225
7310
  if (!capability.args[lookup[1]])
7226
7311
  capability.args[lookup[1]] = { type: "string", required: true, max_length: 128 };
7227
7312
  continue;
@@ -7317,6 +7402,9 @@ function parseCapabilityBlock(block) {
7317
7402
  throw dslError(block.line, 1, "CAPABILITY_SUBJECT_REQUIRED", `${block.name} requires ON schema.table`);
7318
7403
  if (!capability.tenantKey)
7319
7404
  throw dslError(block.line, 1, "CAPABILITY_TENANT_REQUIRED", `${block.name} requires TENANT KEY for 0.1 DSL`);
7405
+ if (capability.lookup && capability.lookup.column !== capability.primaryKey) {
7406
+ throw dslError(capability.lookup.line ?? block.line, 1, "LOOKUP_COLUMN_UNSUPPORTED", `${block.name} LOOKUP BY ${capability.lookup.column} cannot be represented by spec 0.1; use declared PRIMARY KEY ${capability.primaryKey}`);
7407
+ }
7320
7408
  if (capability.visibleFields.length === 0)
7321
7409
  throw dslError(block.line, 1, "CAPABILITY_VISIBLE_FIELDS_REQUIRED", `${block.name} requires ALLOW READ`);
7322
7410
  if (Object.keys(capability.args).length === 0 && capability.lookup)
@@ -8439,7 +8527,7 @@ function stringOrDefault(value, fallback) {
8439
8527
  // apps/runner/package.json
8440
8528
  var package_default = {
8441
8529
  name: "@synapsor/runner",
8442
- version: "0.1.15",
8530
+ version: "0.1.16",
8443
8531
  description: "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
8444
8532
  license: "Apache-2.0",
8445
8533
  type: "module",
@@ -8526,7 +8614,7 @@ var package_default = {
8526
8614
  // packages/dsl/package.json
8527
8615
  var package_default2 = {
8528
8616
  name: "@synapsor/dsl",
8529
- version: "0.1.5",
8617
+ version: "0.1.6",
8530
8618
  description: "Preview SQL-like Synapsor authoring DSL compiler for canonical @synapsor/spec contracts.",
8531
8619
  license: "Apache-2.0",
8532
8620
  type: "module",
@@ -8538,6 +8626,7 @@ var package_default2 = {
8538
8626
  files: [
8539
8627
  "dist/**",
8540
8628
  "examples/**",
8629
+ "fixtures/**",
8541
8630
  "README.md",
8542
8631
  "package.json"
8543
8632
  ],
@@ -9865,7 +9954,7 @@ function formatPostgresReceiptMigration(schema) {
9865
9954
  if (!schema) {
9866
9955
  return [
9867
9956
  "-- Synapsor Runner direct SQL writeback receipt table.",
9868
- "-- Run this as a database owner, or grant CREATE on the target schema to the trusted writer.",
9957
+ "-- Run this once as a database owner before doctor/apply. The steady-state writer does not need schema CREATE.",
9869
9958
  `${postgresReceiptMigration};`,
9870
9959
  ""
9871
9960
  ].join("\n");
@@ -9874,7 +9963,7 @@ function formatPostgresReceiptMigration(schema) {
9874
9963
  const qualified = `${quotedSchema}.synapsor_writeback_receipts`;
9875
9964
  return [
9876
9965
  "-- Synapsor Runner direct SQL writeback receipt table.",
9877
- "-- If you use a dedicated schema, ensure the writer connection search_path includes it.",
9966
+ "-- Run this once as a database owner. If you use a dedicated schema, ensure the writer connection search_path includes it.",
9878
9967
  `CREATE SCHEMA IF NOT EXISTS ${quotedSchema};`,
9879
9968
  `${postgresReceiptMigration.replace("synapsor_writeback_receipts", qualified)};`,
9880
9969
  "",
@@ -9901,9 +9990,6 @@ function formatPostgresReceiptGrants(schema, writerRole) {
9901
9990
  `GRANT USAGE ON SCHEMA ${quotedSchema} TO ${quotedRole};`,
9902
9991
  `GRANT SELECT, INSERT, UPDATE ON TABLE ${table} TO ${quotedRole};`,
9903
9992
  "",
9904
- "-- If you want Runner to create the receipt table itself, also grant CREATE on the schema:",
9905
- `-- GRANT CREATE ON SCHEMA ${quotedSchema} TO ${quotedRole};`,
9906
- "",
9907
9993
  "-- If the schema is not public, make sure the writer connection search_path includes it.",
9908
9994
  `-- ALTER ROLE ${quotedRole} SET search_path = ${schema}, public;`,
9909
9995
  ""
@@ -9915,9 +10001,6 @@ function formatMysqlReceiptGrants(database, writerRole) {
9915
10001
  return [
9916
10002
  "-- Least-privilege grants for a pre-created Synapsor Runner receipt table.",
9917
10003
  `GRANT SELECT, INSERT, UPDATE ON ${quotedDatabase}.synapsor_writeback_receipts TO ${account};`,
9918
- "",
9919
- "-- If you want Runner to create the receipt table itself, also grant CREATE on the database:",
9920
- `-- GRANT CREATE ON ${quotedDatabase}.* TO ${account};`,
9921
10004
  ""
9922
10005
  ].join("\n");
9923
10006
  }
@@ -10833,6 +10916,9 @@ async function doctor(args = []) {
10833
10916
  if (configPath || await fileExists("synapsor.runner.json")) {
10834
10917
  return localDoctor(args);
10835
10918
  }
10919
+ if (!process2.env.SYNAPSOR_CONTROL_PLANE_URL) {
10920
+ throw new Error(`Local doctor requires --config ./synapsor.runner.json. Cloud worker doctor requires SYNAPSOR_CONTROL_PLANE_URL and the scoped worker environment.`);
10921
+ }
10836
10922
  const config = loadConfig();
10837
10923
  const logger = createLogger(config);
10838
10924
  const report = await doctorChecks(config, adapters[config.engine]);
@@ -11777,6 +11863,9 @@ function formatApplyResult(job, result, dryRun, storePath) {
11777
11863
  "",
11778
11864
  "Why:",
11779
11865
  errorCode === "VERSION_CONFLICT" ? "conflict/version guard did not match" : errorCode || "guarded writeback returned conflict",
11866
+ "",
11867
+ "Next:",
11868
+ "Re-inspect the current source row and create a fresh proposal. The conflicted proposal and receipt remain in replay history.",
11780
11869
  ""
11781
11870
  );
11782
11871
  }
@@ -14242,7 +14331,7 @@ async function readMcpAuditTarget(target2, args, timeoutMs) {
14242
14331
  }
14243
14332
  const parsed = JSON.parse(await fs3.readFile(target2, "utf8"));
14244
14333
  if (isRunnerConfigLike(parsed)) {
14245
- const runtime = createMcpRuntime(parsed, { storePath: ":memory:" });
14334
+ const runtime = createMcpRuntime(loadRuntimeConfigFromFile(target2), { storePath: ":memory:" });
14246
14335
  try {
14247
14336
  return { tools: runtime.listTools() };
14248
14337
  } finally {
@@ -16593,6 +16682,12 @@ function proposalNextCommands(proposal, proposalRef, storeSuffix) {
16593
16682
  `${cliCommandName()} replay show --proposal ${proposal.proposal_id}${storeSuffix}`
16594
16683
  ];
16595
16684
  }
16685
+ if (proposal.state === "conflict") {
16686
+ return [
16687
+ `${cliCommandName()} propose ${proposal.action} --json '<fresh reviewed input>'${storeSuffix}`,
16688
+ `${cliCommandName()} replay show --proposal ${proposal.proposal_id}${storeSuffix}`
16689
+ ];
16690
+ }
16596
16691
  return [
16597
16692
  `${cliCommandName()} replay show --proposal ${proposal.proposal_id}${storeSuffix}`
16598
16693
  ];
@@ -16818,6 +16913,7 @@ Commands:
16818
16913
  start Start guided own-database setup, or no-arg legacy worker polling
16819
16914
  up Bring up local review mode guidance/server
16820
16915
  init Generate a Synapsor capability contract
16916
+ config Validate local synapsor.runner.json wiring
16821
16917
  mcp Serve safe semantic tools over MCP
16822
16918
  contract Validate and normalize canonical Synapsor contract files
16823
16919
  dsl Compile SQL-like Synapsor authoring DSL to contract JSON
@@ -16847,6 +16943,7 @@ Examples:
16847
16943
  ${cmd} onboard db --from-env DATABASE_URL
16848
16944
  ${cmd} inspect --from-env DATABASE_URL
16849
16945
  ${cmd} init --wizard --from-env DATABASE_URL
16946
+ ${cmd} config validate --config ./synapsor.runner.json
16850
16947
  ${cmd} contract validate ./synapsor.contract.json
16851
16948
  ${cmd} contract normalize ./synapsor.contract.json --out ./synapsor.contract.normalized.json
16852
16949
  ${cmd} dsl compile ./contract.synapsor.sql --out ./synapsor.contract.json
@@ -16857,6 +16954,14 @@ Examples:
16857
16954
  ${cmd} mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
16858
16955
  ${cmd} propose billing.propose_late_fee_waiver --sample
16859
16956
  ${cmd} audit ./synapsor.runner.json
16957
+ `,
16958
+ config: `Usage:
16959
+ ${cmd} config validate --config ./synapsor.runner.json
16960
+ ${cmd} config migrate --config ./synapsor.runner.json --out ./synapsor.runner.migrated.json
16961
+
16962
+ Validate local Runner wiring before tools preview, doctor, smoke, or MCP serve.
16963
+ Contract paths are resolved relative to the config file. SQLite store paths are
16964
+ resolved by the Runner process working directory.
16860
16965
  `,
16861
16966
  contract: `Usage:
16862
16967
  ${cmd} contract validate ./synapsor.contract.json [--json]
@@ -17107,7 +17212,8 @@ Static MCP/database risk review only. This is not a security guarantee.
17107
17212
  ${cmd} doctor --first-run
17108
17213
 
17109
17214
  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.
17110
- 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.
17215
+ Use --check-writeback only after an administrator applies the receipt-table migration and grants. It verifies the pre-created table with the trusted writer inside a rolled-back transaction and does not require schema CREATE.
17216
+ Without --config, doctor is the legacy Cloud worker check and requires SYNAPSOR_CONTROL_PLANE_URL plus the scoped worker environment.
17111
17217
  `,
17112
17218
  proposals: `Usage:
17113
17219
  ${cmd} proposals list [--tenant acme] [--capability billing.propose_late_fee_waiver] [--object invoice:INV-3001] [--status applied]
@@ -17143,6 +17249,7 @@ Inspect local query fingerprints, table names, row counts, and redacted-paramete
17143
17249
  Inspect guarded writeback receipts recorded by the trusted runner path. Use --details for idempotency keys, receipt hashes, and runner metadata.
17144
17250
  `,
17145
17251
  apply: `Usage:
17252
+ ${cmd} apply <proposal-id> [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
17146
17253
  ${cmd} apply latest [--config ./synapsor.runner.json] [--store ./.synapsor/local.db]
17147
17254
  ${cmd} apply --job job.json --config ./synapsor.runner.json --store ./.synapsor/local.db
17148
17255
 
@@ -17152,9 +17259,9 @@ With --config, the writer connection comes from source.write_url_env, such as
17152
17259
  SYNAPSOR_DATABASE_WRITE_URL. SYNAPSOR_DATABASE_URL is only the legacy fallback
17153
17260
  for direct worker/apply flows without a local config.
17154
17261
 
17155
- Direct SQL writeback creates or writes synapsor_writeback_receipts for
17156
- idempotency and replay, so the trusted writer needs permission for that table
17157
- or an administrator must pre-create and grant it.
17262
+ Direct SQL writeback writes the administrator-created
17263
+ synapsor_writeback_receipts table for idempotency and replay. The trusted writer
17264
+ needs SELECT/INSERT/UPDATE on that table, but does not need schema CREATE.
17158
17265
  `,
17159
17266
  replay: `Usage:
17160
17267
  ${cmd} replay list [--tenant acme] [--object invoice:INV-3001]