@synapsor/runner 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.1 (prepared, not published)
4
+
5
+ ### Canonical Bounded-Set Digest Verification
6
+
7
+ - Fixes contract-authored bounded-set proposals that could fail unchanged
8
+ apply with `SET_DIGEST_MISMATCH` after protocol parsing reordered aggregate
9
+ object fields.
10
+ - Uses recursive canonical JSON key ordering for new member and set digests,
11
+ while narrowly accepting the deterministic raw representations emitted by
12
+ `1.4.0` so valid stored proposals remain applyable.
13
+ - Keeps every frozen member, expected version, reviewed value, aggregate,
14
+ tenant guard, and atomic source check intact; malformed digests and genuine
15
+ source drift still fail closed.
16
+ - Adds PostgreSQL/MySQL regression coverage for the exact DSL-to-contract path
17
+ under source-database and Runner-ledger receipt authority, plus independent
18
+ version, predicate, aggregate, writable-value, missing-member, and tenant
19
+ drift checks.
20
+ - Ships the bounded-set guide and other linked public docs in the Runner
21
+ tarball, and fails package assembly when a shipped local Markdown link cannot
22
+ resolve.
23
+ - Removes obsolete `0.1 preview` wording from the DSL package without changing
24
+ canonical Spec `spec_version: "0.1"`.
25
+ - Adds a build-vs-adopt guide for teams already using prompts and custom
26
+ parameterized tools. It distinguishes behavioral instructions from
27
+ structural authorization, explains where SQL authority lives, and documents
28
+ the approval, receipt, replay, and compensation layer without claiming
29
+ prompt-injection prevention or compliance certification.
30
+ - Stages `@synapsor/runner@1.4.1` and `@synapsor/dsl@1.4.1`;
31
+ `@synapsor/spec` remains `1.4.0` because the public contract schema did not
32
+ change. Nothing is published by this change.
33
+
3
34
  ## 1.4.0 (prepared, not published)
4
35
 
5
36
  ### Reviewed Reversible Change Sets
package/README.md CHANGED
@@ -88,10 +88,26 @@ approve/reject prompt:
88
88
  - evidence, query audit, idempotency receipts, and replay preserve what was
89
89
  inspected, requested, approved, and applied.
90
90
 
91
- Runner supports bounded production deployments when its documented database,
92
- identity, ledger, backup, and operational controls are satisfied. It does not
93
- make arbitrary agent code, raw SQL tools, host infrastructure, or
94
- prompt-injection-prone clients safe by itself.
91
+ Runner supports bounded deployments when its documented database, identity,
92
+ ledger, backup, and operational controls are satisfied. It does not make raw
93
+ SQL tools, host infrastructure, or prompt-injection-prone clients safe.
94
+
95
+ ## Why Not Just Use A Prompt And App Code?
96
+
97
+ Prompt instructions can guide behavior, but prompt-only enforcement is not an
98
+ authorization boundary. First ask who produces the SQL:
99
+
100
+ - **The model produces SQL:** your validator must safely understand arbitrary
101
+ queries, scope, and side effects. That is `execute_sql` with an extra parser.
102
+ - **Trusted app code produces fixed, parameterized SQL:** good. You have built
103
+ a semantic tool, and that may be enough for a small read-only application.
104
+
105
+ Runner becomes useful when those tools also need one reviewed contract for
106
+ trusted tenant scope, field controls, evidence, approval outside MCP, guarded
107
+ and idempotent writeback, receipts, replay, and reviewed compensation. Use your
108
+ own code when you do not need that lifecycle. Read the [full build-vs-adopt
109
+ guide](docs/why-synapsor-vs-app-guardrails.md) for the decision table, public
110
+ incident evidence, and regulated-data boundaries.
95
111
 
96
112
  ## Connect A Staging Database
97
113
 
@@ -109,11 +125,9 @@ creates trusted context, generates reviewed capabilities, previews the MCP tool
109
125
  surface, and prints the next smoke and serve commands. It stores environment
110
126
  variable names, not connection strings.
111
127
 
112
- The interesting part of the generated configuration is the capability entry: a
113
- reviewed, tenant-scoped read with an explicit column allowlist and required
114
- evidence. The storage, source, and trusted-context wiring around it (including
115
- statement timeouts) is generated for you; the full file is in the
116
- [own-database guide](docs/getting-started-own-database.md).
128
+ The generated capability is a tenant-scoped read with an explicit column
129
+ allowlist and required evidence. See the [own-database
130
+ guide](docs/getting-started-own-database.md) for the full configuration.
117
131
 
118
132
  ```json
119
133
  {
@@ -174,19 +188,11 @@ trust boundaries, covered threats, non-goals, and required operator controls.
174
188
  - MCP proposal, evidence, and replay handles are references rather than bearer
175
189
  authority: resource reads re-check the owning tenant and principal against
176
190
  the current trusted session.
177
- - `corepack pnpm test:live-apply` runs disposable Postgres and MySQL scenarios.
178
- It proves source rows stay unchanged before approval, guarded writeback
179
- applies once, idempotent retry does not duplicate the effect, and a stale row
180
- returns a conflict.
181
- - `corepack pnpm test:guarded-crud` proves native single-row INSERT, UPDATE,
182
- and DELETE across both databases and all receipt modes, including the
183
- zero-source-schema Runner-ledger path and fail-closed reconciliation.
184
- - `corepack pnpm test:bounded-set` proves proposal-time cap and aggregate
185
- rejection, frozen-set drift checks, all-or-nothing set UPDATE/DELETE and
186
- batch INSERT, delete-side-effect refusal, exact receipts, and 1/10/100-row
187
- bounds on disposable PostgreSQL and MySQL.
188
- - `corepack pnpm test:reversible` proves approval, conflict guards, redaction,
189
- receipts, and replay on PostgreSQL and MySQL.
191
+ - `test:live-apply`, `test:guarded-crud`, `test:bounded-set`, and
192
+ `test:reversible` run disposable PostgreSQL/MySQL scenarios. They prove no
193
+ pre-approval mutation, guarded single-row CRUD, idempotent retry,
194
+ fail-closed conflicts, bounded atomic sets, exact receipts, and reviewed
195
+ compensation.
190
196
  - The C++/Cloud round-trip verifier exports normalized contracts, validates
191
197
  them with `@synapsor/spec`, and loads them in Runner. The shared contract and
192
198
  verification commands are documented in [Conformance](docs/conformance.md).
@@ -198,12 +204,9 @@ See [Security Boundary](docs/security-boundary.md) and
198
204
 
199
205
  ## Operate The Approval Loop
200
206
 
201
- Reviewed policies can combine a per-proposal threshold with daily count and
202
- total ceilings. Exceeding a ceiling routes that proposal to human review; it
203
- never auto-applies. Operators can apply the bounded approved queue independently
204
- with `apply --all-approved --yes`, inspect Prometheus counters with `metrics
205
- show`, and consume safe newline-delimited JSON outcome logs from stderr. Signed
206
- operator keys can enforce contract reviewer roles and separate apply roles.
207
+ Reviewed policies combine per-proposal and daily ceilings; exceeding one routes
208
+ the proposal to human review. Operators use `apply --all-approved --yes`,
209
+ Prometheus metrics, structured logs, and optional signed reviewer/apply roles.
207
210
  Shared Postgres ledger mirror mode is available for bounded operator handoffs,
208
211
  and `storage.shared_postgres.mode = "runtime_store"` lets MCP serving use
209
212
  Postgres as the primary proposal/evidence/replay store with bounded CLI
@@ -230,11 +233,10 @@ dead-letter, and rolling-upgrade rules are in
230
233
  | `@synapsor/spec` | Canonical portable contracts for contexts, capabilities, workflows, evidence, proposals, receipts, and replay. |
231
234
  | `@synapsor/dsl` | SQL-like authoring that compiles contexts, capabilities, and workflow declarations into canonical contract JSON. |
232
235
 
233
- Runner executes locally. The spec is the portable contract shared by Runner
234
- and Cloud/C++. The DSL gives that contract a reviewable source format. Start
235
- with [Capability Authoring](docs/capability-authoring.md). Use `.synapsor.sql`
236
- for DSL source files when you want editors to provide generic SQL highlighting;
237
- legacy `.synapsor` files remain supported and produce the same contract JSON.
236
+ Runner executes locally. The spec is shared by Runner and Cloud/C++; the DSL
237
+ is its reviewable source format. Start with [Capability
238
+ Authoring](docs/capability-authoring.md). Use `.synapsor.sql` for generic SQL
239
+ highlighting; legacy `.synapsor` files produce the same contract JSON.
238
240
 
239
241
  Use the [DSL Reference](docs/dsl-reference.md) for exact authoring grammar and
240
242
  the [Runner Config Reference](docs/runner-config-reference.md) for every wiring
package/SECURITY.md ADDED
@@ -0,0 +1,11 @@
1
+ # Security Policy
2
+
3
+ Report security issues privately to security@synapsor.ai.
4
+
5
+ Do not include production credentials, customer data, or full source rows in bug reports. Include runner version, job id, proposal id, source id, engine, safe error code, and redacted logs where possible.
6
+
7
+ ## Supported security scope
8
+
9
+ The v0.1 runner protects the local writeback boundary for approved structured jobs. It does not make the hosted Synapsor control plane self-hosted, and it does not provide HA, compliance certification, or a general database proxy.
10
+
11
+ See `docs/threat-model.md` for detailed trust boundaries, covered threats, non-goals, and release blockers.
package/dist/runner.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // apps/runner/src/cli.ts
4
4
  import fs5 from "node:fs/promises";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
- import crypto8 from "node:crypto";
6
+ import crypto9 from "node:crypto";
7
7
  import net from "node:net";
8
8
  import os from "node:os";
9
9
  import path5 from "node:path";
@@ -12,7 +12,42 @@ import readline from "node:readline/promises";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
 
14
14
  // packages/protocol/src/index.ts
15
+ import crypto2 from "node:crypto";
15
16
  import { z } from "zod";
17
+ function canonicalJsonStringify(input) {
18
+ return JSON.stringify(canonicalJsonValue(input, /* @__PURE__ */ new Set()));
19
+ }
20
+ function canonicalJsonDigest(input) {
21
+ return `sha256:${crypto2.createHash("sha256").update(canonicalJsonStringify(input)).digest("hex")}`;
22
+ }
23
+ function canonicalJsonValue(input, ancestors) {
24
+ if (input === null || typeof input === "string" || typeof input === "boolean") return input;
25
+ if (typeof input === "number") {
26
+ if (!Number.isFinite(input)) throw new TypeError("canonical JSON accepts only finite numbers");
27
+ return Object.is(input, -0) ? 0 : input;
28
+ }
29
+ if (Array.isArray(input)) {
30
+ if (ancestors.has(input)) throw new TypeError("canonical JSON does not accept circular values");
31
+ ancestors.add(input);
32
+ const output2 = input.map((item) => canonicalJsonValue(item, ancestors));
33
+ ancestors.delete(input);
34
+ return output2;
35
+ }
36
+ if (typeof input !== "object" || input === void 0) throw new TypeError("canonical JSON accepts only JSON values");
37
+ const record = input;
38
+ const prototype = Object.getPrototypeOf(record);
39
+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError("canonical JSON accepts only plain objects");
40
+ if (ancestors.has(record)) throw new TypeError("canonical JSON does not accept circular values");
41
+ ancestors.add(record);
42
+ const output = {};
43
+ for (const key of Object.keys(record).sort()) {
44
+ const value = record[key];
45
+ if (value === void 0) throw new TypeError("canonical JSON does not accept undefined values");
46
+ output[key] = canonicalJsonValue(value, ancestors);
47
+ }
48
+ ancestors.delete(record);
49
+ return output;
50
+ }
16
51
  var scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
17
52
  var scalarMap = z.record(scalar).refine((value) => Object.keys(value).length > 0, "object must not be empty");
18
53
  var boundedScalarRecord = z.record(scalar).refine((value) => Object.keys(value).length <= 256, "object exceeds 256 fields");
@@ -2755,7 +2790,7 @@ function isRecord(value) {
2755
2790
  }
2756
2791
 
2757
2792
  // packages/mcp-server/src/index.ts
2758
- import crypto4 from "node:crypto";
2793
+ import crypto5 from "node:crypto";
2759
2794
  import fs2 from "node:fs";
2760
2795
  import { createServer } from "node:http";
2761
2796
  import { createServer as createHttpsServer } from "node:https";
@@ -2766,11 +2801,11 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
2766
2801
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
2767
2802
 
2768
2803
  // packages/postgres/src/index.ts
2769
- import crypto3 from "node:crypto";
2804
+ import crypto4 from "node:crypto";
2770
2805
  import { Pool, types as pgTypes } from "pg";
2771
2806
 
2772
2807
  // packages/worker-core/src/index.ts
2773
- import crypto2 from "node:crypto";
2808
+ import crypto3 from "node:crypto";
2774
2809
 
2775
2810
  // packages/worker-core/src/mcp-audit.ts
2776
2811
  var MCP_AUDIT_DISCLAIMER = "This is a static risk review, not proof that an MCP server is secure.";
@@ -3308,21 +3343,21 @@ function assertFrozenSetJobIntegrity(job) {
3308
3343
  if (member.before[job.target.tenant_guard.column] !== job.target.tenant_guard.value) throw new Error("SET_TENANT_GUARD_MISMATCH");
3309
3344
  const expectedAfter = { ...member.before, ...job.patch, [job.version_advance.column]: member.expected_version.value + 1 };
3310
3345
  if (!recordsEqual(member.after, expectedAfter)) throw new Error("SET_AFTER_STATE_MISMATCH");
3311
- if (member.before_digest !== reconciliationDigest({ primary_key: member.primary_key.value, before: member.before })) throw new Error("SET_BEFORE_DIGEST_MISMATCH");
3312
- if (member.after_digest !== reconciliationDigest({ primary_key: member.primary_key.value, after: member.after })) throw new Error("SET_AFTER_DIGEST_MISMATCH");
3346
+ if (!reviewedDigestMatches(member.before_digest, { primary_key: member.primary_key.value, before: member.before })) throw new Error("SET_BEFORE_DIGEST_MISMATCH");
3347
+ if (!reviewedDigestMatches(member.after_digest, { primary_key: member.primary_key.value, after: member.after })) throw new Error("SET_AFTER_DIGEST_MISMATCH");
3313
3348
  } else if (job.operation === "set_delete") {
3314
3349
  if (!member.expected_version || member.before[member.expected_version.column] !== member.expected_version.value) throw new Error("SET_VERSION_GUARD_MISMATCH");
3315
3350
  if (member.before[job.target.tenant_guard.column] !== job.target.tenant_guard.value) throw new Error("SET_TENANT_GUARD_MISMATCH");
3316
3351
  if (Object.keys(member.after).length !== 0) throw new Error("SET_DELETE_PATCH_FORBIDDEN");
3317
- if (member.before_digest !== reconciliationDigest({ primary_key: member.primary_key.value, before: member.before })) throw new Error("SET_BEFORE_DIGEST_MISMATCH");
3318
- if (member.tombstone_digest !== reconciliationDigest({ primary_key: member.primary_key.value, expected_version: member.expected_version })) throw new Error("SET_TOMBSTONE_DIGEST_MISMATCH");
3352
+ if (!reviewedDigestMatches(member.before_digest, { primary_key: member.primary_key.value, before: member.before })) throw new Error("SET_BEFORE_DIGEST_MISMATCH");
3353
+ if (!reviewedDigestMatches(member.tombstone_digest, { primary_key: member.primary_key.value, expected_version: member.expected_version })) throw new Error("SET_TOMBSTONE_DIGEST_MISMATCH");
3319
3354
  } else {
3320
3355
  const components = member.deduplication?.components ?? [];
3321
3356
  const primary = components.find((component) => component.column === job.target.primary_key.column);
3322
3357
  const tenant = components.find((component) => component.column === job.target.tenant_guard.column);
3323
3358
  if (!primary || primary.value !== member.primary_key.value || !tenant || tenant.source !== "trusted_tenant" || tenant.value !== job.target.tenant_guard.value) throw new Error("BATCH_DEDUP_REQUIRED");
3324
3359
  if (member.after[job.target.primary_key.column] !== member.primary_key.value || member.after[job.target.tenant_guard.column] !== job.target.tenant_guard.value) throw new Error("BATCH_IDENTITY_MISMATCH");
3325
- if (member.after_digest !== reconciliationDigest({ primary_key: member.primary_key.value, after: member.after })) throw new Error("SET_AFTER_DIGEST_MISMATCH");
3360
+ if (!reviewedDigestMatches(member.after_digest, { primary_key: member.primary_key.value, after: member.after })) throw new Error("SET_AFTER_DIGEST_MISMATCH");
3326
3361
  }
3327
3362
  }
3328
3363
  for (const bound of set.aggregate_bounds) {
@@ -3333,8 +3368,24 @@ function assertFrozenSetJobIntegrity(job) {
3333
3368
  }, 0);
3334
3369
  if (actual !== bound.actual || actual > bound.maximum) throw new Error("SET_AGGREGATE_BOUND_MISMATCH");
3335
3370
  }
3336
- const expectedSetDigest = reconciliationDigest({ operation: job.operation, members: set.members, aggregate_bounds: set.aggregate_bounds });
3337
- if (set.set_digest !== expectedSetDigest) throw new Error("SET_DIGEST_MISMATCH");
3371
+ const setMaterial = { operation: job.operation, members: set.members, aggregate_bounds: set.aggregate_bounds };
3372
+ const legacyContractMaterial = {
3373
+ operation: job.operation,
3374
+ members: set.members,
3375
+ aggregate_bounds: set.aggregate_bounds.map((bound) => ({
3376
+ column: bound.column,
3377
+ maximum: bound.maximum,
3378
+ measure: bound.measure,
3379
+ actual: bound.actual
3380
+ }))
3381
+ };
3382
+ if (!reviewedDigestMatches(set.set_digest, setMaterial, legacyContractMaterial)) throw new Error("SET_DIGEST_MISMATCH");
3383
+ }
3384
+ function reviewedDigestMatches(actual, current, legacyContract) {
3385
+ if (!actual) return false;
3386
+ const expected = /* @__PURE__ */ new Set([canonicalJsonDigest(current), reconciliationDigest(current)]);
3387
+ if (legacyContract) expected.add(reconciliationDigest(legacyContract));
3388
+ return expected.has(actual);
3338
3389
  }
3339
3390
  function recordValuesMatch(actual, expected, valuesEqual) {
3340
3391
  return Object.entries(expected).every(([column, value]) => valuesEqual(actual[column], value));
@@ -3352,7 +3403,7 @@ function asScalar(value) {
3352
3403
  return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : String(value);
3353
3404
  }
3354
3405
  function reconciliationDigest(value) {
3355
- return `sha256:${crypto2.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
3406
+ return `sha256:${crypto3.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
3356
3407
  }
3357
3408
  function loadConfig(env = process.env) {
3358
3409
  const engine = (env.SYNAPSOR_ENGINE || "postgres").toLowerCase();
@@ -3606,7 +3657,7 @@ function scalar2(value) {
3606
3657
  return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : String(value);
3607
3658
  }
3608
3659
  function digest(value) {
3609
- return `sha256:${crypto3.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
3660
+ return `sha256:${crypto4.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
3610
3661
  }
3611
3662
  function reconciliationProjection(job) {
3612
3663
  const columns = /* @__PURE__ */ new Set([job.target.primary_key.column, job.target.tenant_guard.column, ...job.allowed_columns]);
@@ -10897,7 +10948,7 @@ async function handleStreamableHttpMcpRequest(input) {
10897
10948
  }
10898
10949
  let session;
10899
10950
  const transport = new StreamableHTTPServerTransport({
10900
- sessionIdGenerator: () => crypto4.randomUUID(),
10951
+ sessionIdGenerator: () => crypto5.randomUUID(),
10901
10952
  onsessioninitialized: (newSessionId) => {
10902
10953
  if (session) {
10903
10954
  session.sessionId = newSessionId;
@@ -11038,7 +11089,7 @@ function validBearerToken(header, expected) {
11038
11089
  const actual = header.slice("Bearer ".length);
11039
11090
  const actualBuffer = Buffer.from(actual);
11040
11091
  const expectedBuffer = Buffer.from(expected);
11041
- return actualBuffer.length === expectedBuffer.length && crypto4.timingSafeEqual(actualBuffer, expectedBuffer);
11092
+ return actualBuffer.length === expectedBuffer.length && crypto5.timingSafeEqual(actualBuffer, expectedBuffer);
11042
11093
  }
11043
11094
  function resolveMetricsEndpointAccess(config, env, host) {
11044
11095
  if (config.metrics?.enabled !== true) return { enabled: false };
@@ -11178,7 +11229,7 @@ async function checkRunnerReadiness(config, env = process.env, timeoutMs = 3e3)
11178
11229
  const table = `${quotePostgresIdentifier(shared.schema ?? "synapsor_runner")}.ledger_entries`;
11179
11230
  await client.query(
11180
11231
  `INSERT INTO ${table} (entry_key, kind, payload_json) VALUES ($1, 'readiness_probe', '{}'::jsonb)`,
11181
- [`readiness:${crypto4.randomUUID()}`]
11232
+ [`readiness:${crypto5.randomUUID()}`]
11182
11233
  );
11183
11234
  await client.query("ROLLBACK");
11184
11235
  } catch (error) {
@@ -11274,7 +11325,7 @@ function bearerToken(header) {
11274
11325
  return token || void 0;
11275
11326
  }
11276
11327
  function tokenFingerprint(token) {
11277
- return `sha256:${crypto4.createHash("sha256").update(token).digest("hex")}`;
11328
+ return `sha256:${crypto5.createHash("sha256").update(token).digest("hex")}`;
11278
11329
  }
11279
11330
  function safeSessionClaim(value) {
11280
11331
  if (typeof value !== "string") return void 0;
@@ -11338,7 +11389,7 @@ function toolNameExposureMap(canonicalNames, style) {
11338
11389
  return exposedByCanonical;
11339
11390
  }
11340
11391
  function shortToolHash(value) {
11341
- return crypto4.createHash("sha256").update(value).digest("hex").slice(0, 8);
11392
+ return crypto5.createHash("sha256").update(value).digest("hex").slice(0, 8);
11342
11393
  }
11343
11394
  function setCorsHeaders(response, corsOrigin) {
11344
11395
  if (corsOrigin) {
@@ -12339,7 +12390,7 @@ function buildBoundedSetChangeSet(input) {
12339
12390
  primary_key: { column: input.capability.target.primary_key, value: primary.value },
12340
12391
  before: {},
12341
12392
  after,
12342
- after_digest: hashJson({ primary_key: primary.value, after }),
12393
+ after_digest: canonicalJsonDigest({ primary_key: primary.value, after }),
12343
12394
  deduplication
12344
12395
  };
12345
12396
  }) : input.currentRows.map((rawRow) => {
@@ -12354,8 +12405,8 @@ function buildBoundedSetChangeSet(input) {
12354
12405
  expected_version: expectedVersion,
12355
12406
  before,
12356
12407
  after: {},
12357
- before_digest: hashJson({ primary_key: primaryValue, before }),
12358
- tombstone_digest: hashJson({ primary_key: primaryValue, expected_version: expectedVersion })
12408
+ before_digest: canonicalJsonDigest({ primary_key: primaryValue, before }),
12409
+ tombstone_digest: canonicalJsonDigest({ primary_key: primaryValue, expected_version: expectedVersion })
12359
12410
  };
12360
12411
  }
12361
12412
  const after = { ...before, ...input.patch };
@@ -12369,15 +12420,20 @@ function buildBoundedSetChangeSet(input) {
12369
12420
  expected_version: expectedVersion,
12370
12421
  before,
12371
12422
  after,
12372
- before_digest: hashJson({ primary_key: primaryValue, before }),
12373
- after_digest: hashJson({ primary_key: primaryValue, after })
12423
+ before_digest: canonicalJsonDigest({ primary_key: primaryValue, before }),
12424
+ after_digest: canonicalJsonDigest({ primary_key: primaryValue, after })
12374
12425
  };
12375
12426
  });
12376
12427
  const members = rawMembers.sort((left, right) => JSON.stringify(left.primary_key.value).localeCompare(JSON.stringify(right.primary_key.value)));
12377
12428
  if (new Set(members.map((member) => JSON.stringify(member.primary_key.value))).size !== members.length) {
12378
12429
  throw new McpRuntimeError("SET_IDENTITY_NOT_UNIQUE", "Every frozen set member must have a unique primary-key identity.");
12379
12430
  }
12380
- const aggregateBounds = operation.aggregate_bounds.map((bound) => ({ ...bound, actual: aggregateValue(members, bound) }));
12431
+ const aggregateBounds = operation.aggregate_bounds.map((bound) => ({
12432
+ column: bound.column,
12433
+ measure: bound.measure,
12434
+ maximum: bound.maximum,
12435
+ actual: aggregateValue(members, bound)
12436
+ }));
12381
12437
  for (const bound of aggregateBounds) {
12382
12438
  if (bound.actual > bound.maximum) throw new McpRuntimeError("SET_AGGREGATE_BOUND_EXCEEDED", `${bound.measure} aggregate for ${bound.column} exceeds the reviewed maximum ${bound.maximum}.`);
12383
12439
  }
@@ -12386,7 +12442,7 @@ function buildBoundedSetChangeSet(input) {
12386
12442
  row_count: members.length,
12387
12443
  aggregate_bounds: aggregateBounds,
12388
12444
  members,
12389
- set_digest: hashJson({ operation: kind, members, aggregate_bounds: aggregateBounds })
12445
+ set_digest: canonicalJsonDigest({ operation: kind, members, aggregate_bounds: aggregateBounds })
12390
12446
  };
12391
12447
  const approvalMode = input.capability.approval?.mode === "operator" ? "operator" : "human";
12392
12448
  const proposalCore = {
@@ -12512,7 +12568,7 @@ var RuntimeRateLimiter = class {
12512
12568
  if (this.sharedPool && this.sharedSchema) {
12513
12569
  await this.migration;
12514
12570
  const table = `${quotePostgresIdentifier(this.sharedSchema)}.rate_limit_buckets`;
12515
- const bucketKey = crypto4.createHash("sha256").update(`${context.tenant_id}\0${capability}`).digest("hex");
12571
+ const bucketKey = crypto5.createHash("sha256").update(`${context.tenant_id}\0${capability}`).digest("hex");
12516
12572
  const result = await this.sharedPool.query(
12517
12573
  `INSERT INTO ${table} AS bucket (bucket_key, window_start, request_count, rejected_count)
12518
12574
  VALUES ($1, $2, 1, 0)
@@ -13102,10 +13158,10 @@ function proposalAlreadyExists(existing) {
13102
13158
  );
13103
13159
  }
13104
13160
  function stableId(prefix, input) {
13105
- return `${prefix}_${crypto4.createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 20)}`;
13161
+ return `${prefix}_${crypto5.createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 20)}`;
13106
13162
  }
13107
13163
  function hashJson(input) {
13108
- return `sha256:${crypto4.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
13164
+ return `sha256:${crypto5.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
13109
13165
  }
13110
13166
  function isRecord5(value) {
13111
13167
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -13127,7 +13183,7 @@ function toolErrorPayload(error) {
13127
13183
  }
13128
13184
 
13129
13185
  // packages/mysql/src/index.ts
13130
- import crypto5 from "node:crypto";
13186
+ import crypto6 from "node:crypto";
13131
13187
  import mysql2 from "mysql2/promise";
13132
13188
  var mysqlReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
13133
13189
  idempotency_key varchar(255) PRIMARY KEY,
@@ -13241,7 +13297,7 @@ function scalar4(value) {
13241
13297
  return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : String(value);
13242
13298
  }
13243
13299
  function digest2(value) {
13244
- return `sha256:${crypto5.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
13300
+ return `sha256:${crypto6.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
13245
13301
  }
13246
13302
  function resultHash2(job, status, version) {
13247
13303
  return digest2({ job_id: job.job_id, status, version });
@@ -15621,7 +15677,7 @@ function parseList(value) {
15621
15677
  }
15622
15678
  function unsupported(item, blockKind) {
15623
15679
  if (/ROOT\s+EXTERNAL|JOIN\s+EXTERNAL|RETURN\s+ANSWER|AUTO\s+BRANCH|AUTO\s+MERGE/i.test(item.text)) {
15624
- throw dslError(item.line, 1, "UNSUPPORTED_PREVIEW_SYNTAX", `${blockKind} clause is not supported by @synapsor/dsl 0.1 preview: ${item.text}`);
15680
+ throw dslError(item.line, 1, "UNSUPPORTED_PREVIEW_SYNTAX", `${blockKind} clause is not supported by the current @synapsor/dsl grammar: ${item.text}`);
15625
15681
  }
15626
15682
  throw dslError(item.line, 1, "UNSUPPORTED_DSL_CLAUSE", `unsupported ${blockKind} clause: ${item.text}`);
15627
15683
  }
@@ -15630,7 +15686,7 @@ function dslError(line, column, code, message2) {
15630
15686
  }
15631
15687
 
15632
15688
  // apps/runner/src/local-ui.ts
15633
- import crypto6 from "node:crypto";
15689
+ import crypto7 from "node:crypto";
15634
15690
  import fs3 from "node:fs/promises";
15635
15691
  import { createServer as createServer2 } from "node:http";
15636
15692
  import path3 from "node:path";
@@ -15641,8 +15697,8 @@ async function startLocalUiServer(options = {}) {
15641
15697
  }
15642
15698
  const configPath = path3.resolve(options.configPath ?? "synapsor.runner.json");
15643
15699
  const storePath = path3.resolve(options.storePath ?? "./.synapsor/local.db");
15644
- const token = options.token ?? crypto6.randomBytes(24).toString("base64url");
15645
- const csrfToken = options.csrfToken ?? crypto6.randomBytes(24).toString("base64url");
15700
+ const token = options.token ?? crypto7.randomBytes(24).toString("base64url");
15701
+ const csrfToken = options.csrfToken ?? crypto7.randomBytes(24).toString("base64url");
15646
15702
  const storeAccess = options.storeAccess ?? localStoreAccess(storePath);
15647
15703
  const server = createServer2(async (request, response) => {
15648
15704
  try {
@@ -16606,7 +16662,7 @@ function stringOrDefault(value, fallback) {
16606
16662
  }
16607
16663
 
16608
16664
  // apps/runner/src/operator-identity.ts
16609
- import crypto7 from "node:crypto";
16665
+ import crypto8 from "node:crypto";
16610
16666
  import fs4 from "node:fs/promises";
16611
16667
  import path4 from "node:path";
16612
16668
  async function resolveOperatorIdentity(input) {
@@ -16692,8 +16748,8 @@ async function resolveOperatorIdentity(input) {
16692
16748
  const decision = operatorDecision(input, subject);
16693
16749
  const canonical = stableJson(decision);
16694
16750
  const decisionHash = sha2562(canonical);
16695
- const signature = crypto7.sign("sha256", Buffer.from(canonical), privateKey).toString("base64url");
16696
- if (!crypto7.verify("sha256", Buffer.from(canonical), publicKey, Buffer.from(signature, "base64url"))) {
16751
+ const signature = crypto8.sign("sha256", Buffer.from(canonical), privateKey).toString("base64url");
16752
+ if (!crypto8.verify("sha256", Buffer.from(canonical), publicKey, Buffer.from(signature, "base64url"))) {
16697
16753
  throw new Error(`private key does not match the reviewed public key for operator ${subject}`);
16698
16754
  }
16699
16755
  const core = {
@@ -16717,7 +16773,7 @@ function verifyJwtOperatorProof(proof, attestationSecret) {
16717
16773
  const expected = hmacAttestation(unsigned, attestationSecret);
16718
16774
  const actualBuffer = Buffer.from(signature);
16719
16775
  const expectedBuffer = Buffer.from(expected);
16720
- if (actualBuffer.length !== expectedBuffer.length || !crypto7.timingSafeEqual(actualBuffer, expectedBuffer)) return false;
16776
+ if (actualBuffer.length !== expectedBuffer.length || !crypto8.timingSafeEqual(actualBuffer, expectedBuffer)) return false;
16721
16777
  const core = { ...unsigned, signature };
16722
16778
  return proof.integrity_hash === sha2562(stableJson(core));
16723
16779
  }
@@ -16727,7 +16783,7 @@ function verifySignedOperatorProof(proof, publicKey) {
16727
16783
  if (proof.decision_hash !== sha2562(canonical)) return false;
16728
16784
  const { integrity_hash: _integrity, ...core } = proof;
16729
16785
  if (proof.integrity_hash !== sha2562(stableJson(core))) return false;
16730
- return crypto7.verify("sha256", Buffer.from(canonical), publicKey, Buffer.from(proof.signature, "base64url"));
16786
+ return crypto8.verify("sha256", Buffer.from(canonical), publicKey, Buffer.from(proof.signature, "base64url"));
16731
16787
  }
16732
16788
  function operatorDecision(input, subject) {
16733
16789
  return {
@@ -16781,10 +16837,10 @@ function rolesFromClaim(value) {
16781
16837
  return [...new Set(roles)].sort();
16782
16838
  }
16783
16839
  function hmacAttestation(value, secret) {
16784
- return crypto7.createHmac("sha256", secret).update(stableJson(value)).digest("base64url");
16840
+ return crypto8.createHmac("sha256", secret).update(stableJson(value)).digest("base64url");
16785
16841
  }
16786
16842
  function sha2562(value) {
16787
- return `sha256:${crypto7.createHash("sha256").update(value).digest("hex")}`;
16843
+ return `sha256:${crypto8.createHash("sha256").update(value).digest("hex")}`;
16788
16844
  }
16789
16845
  function stableJson(value) {
16790
16846
  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
@@ -16883,7 +16939,7 @@ function isRecord7(value) {
16883
16939
  // apps/runner/package.json
16884
16940
  var package_default = {
16885
16941
  name: "@synapsor/runner",
16886
- version: "1.4.0",
16942
+ version: "1.4.1",
16887
16943
  description: "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
16888
16944
  license: "Apache-2.0",
16889
16945
  type: "module",
@@ -16918,6 +16974,7 @@ var package_default = {
16918
16974
  "AGENTS.md",
16919
16975
  "CHANGELOG.md",
16920
16976
  "CONTRIBUTING.md",
16977
+ "SECURITY.md",
16921
16978
  "LICENSE",
16922
16979
  "NOTICE",
16923
16980
  "THREAT_MODEL.md",
@@ -16972,8 +17029,8 @@ var package_default = {
16972
17029
  // packages/dsl/package.json
16973
17030
  var package_default2 = {
16974
17031
  name: "@synapsor/dsl",
16975
- version: "1.4.0",
16976
- description: "Preview SQL-like Synapsor authoring DSL compiler for canonical @synapsor/spec contracts.",
17032
+ version: "1.4.1",
17033
+ description: "SQL-like Synapsor authoring frontend for canonical @synapsor/spec contracts.",
16977
17034
  license: "Apache-2.0",
16978
17035
  type: "module",
16979
17036
  main: "dist/index.js",
@@ -20374,8 +20431,8 @@ function trustedCliContext(config, capability, env) {
20374
20431
  return { tenant_id: tenant, principal };
20375
20432
  }
20376
20433
  function createCompensationProposal(input) {
20377
- const proposalId = `wrp_revert_${crypto8.randomBytes(10).toString("hex")}`;
20378
- const evidenceId = `ev_revert_${crypto8.randomBytes(10).toString("hex")}`;
20434
+ const proposalId = `wrp_revert_${crypto9.randomBytes(10).toString("hex")}`;
20435
+ const evidenceId = `ev_revert_${crypto9.randomBytes(10).toString("hex")}`;
20379
20436
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
20380
20437
  const one = input.inverse.members.length === 1 ? input.inverse.members[0] : void 0;
20381
20438
  const before = one ? one.expected_state : { row_count: input.inverse.members.length };
@@ -20845,7 +20902,7 @@ function executorConfig(config, executorName) {
20845
20902
  throw new Error(`executor ${executorName} has unsupported type`);
20846
20903
  }
20847
20904
  function signHandlerRequestBody(body, secret) {
20848
- return `sha256=${crypto8.createHmac("sha256", secret).update(body).digest("hex")}`;
20905
+ return `sha256=${crypto9.createHmac("sha256", secret).update(body).digest("hex")}`;
20849
20906
  }
20850
20907
  async function applyHttpHandlerProposal(input) {
20851
20908
  const duplicate = duplicateHandlerReceipt(input.store, input.proposalId);
@@ -21050,7 +21107,7 @@ function buildHandlerReceipt(input) {
21050
21107
  };
21051
21108
  }
21052
21109
  function hashReceipt(input) {
21053
- return `sha256:${crypto8.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
21110
+ return `sha256:${crypto9.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
21054
21111
  }
21055
21112
  function formatApplyResult(job, result, dryRun, storePath) {
21056
21113
  const status = writebackResultStatus(result);
@@ -22113,7 +22170,7 @@ function reconciliationReceipt(intent, observation, outcome, runnerId, reason) {
22113
22170
  };
22114
22171
  return {
22115
22172
  ...base2,
22116
- receipt_hash: `sha256:${crypto8.createHash("sha256").update(JSON.stringify(base2)).digest("hex")}`
22173
+ receipt_hash: `sha256:${crypto9.createHash("sha256").update(JSON.stringify(base2)).digest("hex")}`
22117
22174
  };
22118
22175
  }
22119
22176
  if (job.protocol_version !== protocolVersions.normalizedWritebackJobV2) throw new Error("single-row reconciliation requires a writeback-job v2");
@@ -22140,7 +22197,7 @@ function reconciliationReceipt(intent, observation, outcome, runnerId, reason) {
22140
22197
  };
22141
22198
  return {
22142
22199
  ...base,
22143
- receipt_hash: `sha256:${crypto8.createHash("sha256").update(JSON.stringify(base)).digest("hex")}`
22200
+ receipt_hash: `sha256:${crypto9.createHash("sha256").update(JSON.stringify(base)).digest("hex")}`
22144
22201
  };
22145
22202
  }
22146
22203
  function publicWritebackIntent(intent) {
@@ -25616,7 +25673,7 @@ no rows deleted
25616
25673
  await pool.query(`DELETE FROM ${qualified} WHERE entry_key = ANY($1::text[])`, [archivedEntries.map((entry) => entry.entry_key)]);
25617
25674
  }
25618
25675
  const retentionEntry = {
25619
- entry_key: `retention:${crypto8.randomUUID()}`,
25676
+ entry_key: `retention:${crypto9.randomUUID()}`,
25620
25677
  kind: "retention_event",
25621
25678
  payload: {
25622
25679
  cutoff,
@@ -27647,7 +27704,7 @@ function toExecutionReceipt(job, result, dryRun) {
27647
27704
  const affectedRows2 = result.affected_rows ?? 0;
27648
27705
  const terminalStatus = result.status === "applied" && !dryRun && affectedRows2 === 0 ? "already_applied" : result.status;
27649
27706
  const previousVersion = job.conflict_guard.kind === "version_column" ? job.conflict_guard.expected_value : void 0;
27650
- const receiptHash = typeof result.result_hash === "string" && result.result_hash.startsWith("sha256:") ? result.result_hash : `sha256:${crypto8.createHash("sha256").update(JSON.stringify({
27707
+ const receiptHash = typeof result.result_hash === "string" && result.result_hash.startsWith("sha256:") ? result.result_hash : `sha256:${crypto9.createHash("sha256").update(JSON.stringify({
27651
27708
  job_id: job.job_id,
27652
27709
  status: terminalStatus,
27653
27710
  affected_rows: affectedRows2,
package/docs/README.md CHANGED
@@ -24,6 +24,10 @@ no-database demo, wire your database, then read deeper concepts.
24
24
  ## 02 Why Raw SQL Is Dangerous
25
25
 
26
26
  - [Security Boundary](security-boundary.md): what the model can and cannot see.
27
+ - [Why Synapsor Over Prompt And Application
28
+ Guardrails](why-synapsor-vs-app-guardrails.md): where SQL authority lives,
29
+ what a hand-built semantic tool already gets right, and when the shared
30
+ contract, approval, receipt, and replay layer is worth adopting.
27
31
  - [MCP Audit](mcp-audit.md): static review for risky database MCP tools such as
28
32
  `execute_sql`, broad query tools, model-controlled tenant filters, or
29
33
  model-facing approval/commit tools.
@@ -0,0 +1,27 @@
1
+ # Bounded Set Local Measurements
2
+
3
+ These measurements are test evidence, not throughput claims or capacity
4
+ guidance. They record one run of `corepack pnpm test:bounded-set` on 2026-07-13
5
+ using synthetic rows and disposable local Docker databases.
6
+
7
+ Environment:
8
+
9
+ - Linux laptop, Intel Core i7-13800H, 20 logical CPUs, 30 GiB RAM;
10
+ - Node.js 22.22.2 and pnpm 10.14.0;
11
+ - Docker 29.5.2;
12
+ - PostgreSQL 16 and MySQL 8 containers;
13
+ - source-database receipt authority with an administrator-precreated receipt
14
+ table;
15
+ - one sequential set UPDATE per measurement, including connection setup,
16
+ source receipt, lock/preflight, transaction, and exact receipt generation.
17
+
18
+ | Engine | 1 row | 10 rows | 100 rows |
19
+ | --- | ---: | ---: | ---: |
20
+ | PostgreSQL | 30.42 ms | 53.88 ms | 154.90 ms |
21
+ | MySQL | 25.30 ms | 53.91 ms | 163.20 ms |
22
+
23
+ These figures are not portable across hardware, network topology, source
24
+ indexes, lock contention, triggers, database configuration, or receipt mode.
25
+ The claim supported by this gate is only that execution stays bounded and the
26
+ hard 100-row ceiling is exercised on both adapters. Operators must benchmark
27
+ their own reviewed contracts and database topology.
@@ -0,0 +1,298 @@
1
+ # Bounded Set Writeback
2
+
3
+ Synapsor Runner can apply a deliberately narrow class of reviewed
4
+ multi-row database changes to PostgreSQL and MySQL:
5
+
6
+ - fixed-predicate set `UPDATE`;
7
+ - fixed-predicate set `DELETE`;
8
+ - exact-review batch `INSERT`.
9
+
10
+ This is not model-generated SQL and it is not a generic batch API. The model
11
+ can ask to run a reviewed business action, but it cannot choose a table,
12
+ column, operator, ordering, predicate, tenant, or unbounded set.
13
+
14
+ ## Safety invariant
15
+
16
+ The worst action available to a prompt-injected model is to request one
17
+ contract-authored rule, against the trusted tenant, up to the reviewer-authored
18
+ row and value caps. Before approval, Runner freezes the exact ordered members.
19
+ Apply later touches only those members in one transaction and records every
20
+ identity and bounded digest.
21
+
22
+ Every bounded set requires:
23
+
24
+ 1. a fixed typed selection for existing-row UPDATE/DELETE, or the complete
25
+ reviewed item array for batch INSERT;
26
+ 2. `MAX ROWS n`, where `1 <= n <= 100`;
27
+ 3. at least one aggregate `MAX TOTAL` bound;
28
+ 4. trusted tenant binding;
29
+ 5. exact version guards and integer advancement for set UPDATE;
30
+ 6. source-unique per-item identities for batch INSERT;
31
+ 7. human/operator approval outside MCP;
32
+ 8. one atomic source transaction and an exact affected-row count;
33
+ 9. source or Runner-ledger receipts with the documented crash semantics.
34
+
35
+ A row cap is a rejection threshold, never `LIMIT n` truncation. Proposal reads
36
+ fetch at most `n + 1`; finding the extra row rejects before proposal
37
+ persistence. Aggregate overflow also rejects before persistence.
38
+
39
+ ## DSL examples
40
+
41
+ Fixed set UPDATE:
42
+
43
+ ```sql
44
+ PROPOSE ACTION close_overdue UPDATE SET
45
+ SELECT WHERE status = 'overdue'
46
+ MAX ROWS 10
47
+ MAX TOTAL balance_cents BEFORE 50000
48
+ ALLOW WRITE status
49
+ PATCH status = 'closed'
50
+ ADVANCE VERSION version USING INTEGER INCREMENT
51
+ APPROVAL ROLE billing_reviewer
52
+ WRITEBACK DIRECT SQL
53
+ ```
54
+
55
+ The selection is a contract literal. It is not an argument and is never taken
56
+ from the model. The first release supports equality terms joined by `AND`.
57
+
58
+ Exact-review batch INSERT:
59
+
60
+ ```sql
61
+ ARG items ROWS MAX 10 REQUIRED
62
+ ITEM FIELD items.id STRING REQUIRED MAX LENGTH 128
63
+ ITEM FIELD items.external_id STRING REQUIRED MAX LENGTH 128
64
+ ITEM FIELD items.amount_cents NUMBER REQUIRED MIN 1 MAX 2500
65
+ ITEM FIELD items.reason STRING REQUIRED MAX LENGTH 500
66
+
67
+ PROPOSE ACTION create_credits INSERT SET
68
+ BATCH ITEMS FROM ARG items
69
+ MAX ROWS 10
70
+ MAX TOTAL amount_cents AFTER 25000
71
+ DEDUP KEY tenant_id = TRUSTED TENANT, id = ITEM id, external_id = ITEM external_id
72
+ ALLOW WRITE amount_cents, reason
73
+ PATCH amount_cents = ITEM amount_cents
74
+ PATCH reason = ITEM reason
75
+ APPROVAL ROLE billing_reviewer
76
+ WRITEBACK DIRECT SQL
77
+ ```
78
+
79
+ The reviewer sees every allowlisted candidate item before approval. The source
80
+ must enforce the declared identity with a primary or unique constraint.
81
+
82
+ Set DELETE uses `DELETE SET`, a fixed selection, `MAX ROWS`, `MAX TOTAL`, and
83
+ an exact conflict guard. It has no patch. Runner rejects hard DELETE if it
84
+ cannot prove trigger/FK visibility or detects write triggers or widening
85
+ cascades. Prefer a set UPDATE of `deleted_at` or status when possible.
86
+
87
+ ## Worked contract-to-apply example
88
+
89
+ This PostgreSQL example closes two overdue tickets. MySQL is supported with
90
+ the same contract semantics; change the source engine and schema/database name
91
+ to match the inspected MySQL object.
92
+
93
+ Create the fixture with an administrator account, then give the Runner reader
94
+ `SELECT` and its separate writer only `SELECT, UPDATE` on this table:
95
+
96
+ ```sql
97
+ CREATE TABLE public.service_tickets (
98
+ id bigint PRIMARY KEY,
99
+ tenant_id text NOT NULL,
100
+ status text NOT NULL,
101
+ cost_cents integer NOT NULL,
102
+ version integer NOT NULL
103
+ );
104
+
105
+ INSERT INTO public.service_tickets
106
+ (id, tenant_id, status, cost_cents, version)
107
+ VALUES
108
+ (3, 'acme', 'overdue', 8000, 1),
109
+ (4, 'acme', 'overdue', 15000, 1),
110
+ (99, 'globex', 'overdue', 49000, 1);
111
+ ```
112
+
113
+ Save this as `tickets.synapsor.sql`:
114
+
115
+ ```sql
116
+ CREATE AGENT CONTEXT local_operator
117
+ BIND tenant_id FROM ENVIRONMENT SYNAPSOR_TENANT_ID REQUIRED
118
+ BIND principal FROM ENVIRONMENT SYNAPSOR_PRINCIPAL REQUIRED
119
+ TENANT BINDING tenant_id
120
+ PRINCIPAL BINDING principal
121
+ END
122
+
123
+ CREATE CAPABILITY tickets.close_overdue
124
+ USING CONTEXT local_operator
125
+ SOURCE local_db
126
+ ON public.service_tickets
127
+ PRIMARY KEY id
128
+ TENANT KEY tenant_id
129
+ CONFLICT GUARD version
130
+ LOOKUP reason BY id
131
+ ARG reason STRING REQUIRED MAX LENGTH 100
132
+ ALLOW READ id, tenant_id, status, cost_cents, version
133
+ REQUIRE EVIDENCE
134
+ PROPOSE ACTION close_overdue UPDATE SET
135
+ SELECT WHERE status = 'overdue'
136
+ MAX ROWS 10
137
+ MAX TOTAL cost_cents BEFORE 50000
138
+ ALLOW WRITE status
139
+ PATCH status = 'closed'
140
+ ADVANCE VERSION version USING INTEGER INCREMENT
141
+ APPROVAL ROLE ops_manager
142
+ WRITEBACK DIRECT SQL
143
+ END
144
+ ```
145
+
146
+ Compile the contract and create `synapsor.runner.json`:
147
+
148
+ ```bash
149
+ synapsor-runner dsl compile ./tickets.synapsor.sql \
150
+ --out ./synapsor.contract.json --strict
151
+ synapsor-runner contract validate ./synapsor.contract.json
152
+ ```
153
+
154
+ ```json
155
+ {
156
+ "version": 1,
157
+ "mode": "review",
158
+ "storage": { "sqlite_path": "./.synapsor/local.db" },
159
+ "sources": {
160
+ "local_db": {
161
+ "engine": "postgres",
162
+ "read_url_env": "SYNAPSOR_DATABASE_READ_URL",
163
+ "write_url_env": "SYNAPSOR_DATABASE_WRITE_URL",
164
+ "statement_timeout_ms": 3000,
165
+ "receipts": { "authority": "runner_ledger" }
166
+ }
167
+ },
168
+ "contracts": ["./synapsor.contract.json"]
169
+ }
170
+ ```
171
+
172
+ Load database URLs from your shell or secret manager. The config stores only
173
+ environment-variable names. Trusted tenant/principal values are process-owned,
174
+ not tool arguments:
175
+
176
+ ```bash
177
+ export SYNAPSOR_TENANT_ID=acme
178
+ export SYNAPSOR_PRINCIPAL=local_operator
179
+
180
+ synapsor-runner config validate --config ./synapsor.runner.json
181
+ synapsor-runner doctor --config ./synapsor.runner.json --check-writeback
182
+ synapsor-runner propose tickets.close_overdue \
183
+ --json '{"reason":"reviewed overdue-ticket close"}' \
184
+ --config ./synapsor.runner.json --store ./.synapsor/local.db
185
+ synapsor-runner proposals show latest --store ./.synapsor/local.db --details
186
+ ```
187
+
188
+ At this point IDs `3` and `4` are frozen in the proposal, but both source rows
189
+ are still `overdue` at version `1`; the `globex` row is outside trusted tenant
190
+ scope. Approval and apply remain operator-side commands and are not MCP tools:
191
+
192
+ ```bash
193
+ synapsor-runner proposals approve latest --actor ops_manager --yes \
194
+ --store ./.synapsor/local.db
195
+ synapsor-runner apply latest --config ./synapsor.runner.json \
196
+ --store ./.synapsor/local.db
197
+ synapsor-runner receipts list --proposal <proposal_id> \
198
+ --store ./.synapsor/local.db
199
+ synapsor-runner replay show --proposal <proposal_id> --details \
200
+ --store ./.synapsor/local.db
201
+ ```
202
+
203
+ Exactly IDs `3` and `4` become `closed` at version `2` in one transaction.
204
+ The receipt/replay list both safe primary-key identities and their bounded
205
+ digests. ID `99` is unchanged.
206
+
207
+ To test drift on a freshly seeded copy, create the proposal, then use a
208
+ database-operator session to increment one frozen row's version before
209
+ approval. `apply` returns `SET_DRIFT_CONFLICT`; the other frozen row remains
210
+ unchanged because no partial set mutation is committed:
211
+
212
+ ```sql
213
+ UPDATE public.service_tickets SET version = version + 1 WHERE id = 4;
214
+ ```
215
+
216
+ The example uses `runner_ledger`, which creates no receipt table in the source
217
+ database. For one local process, the local store is the authority; a fleet must
218
+ use authoritative shared-Postgres `runtime_store`. `source_db` authority
219
+ instead writes the mutation and receipt atomically in one source transaction
220
+ and requires its fixed precreated or auto-migrated receipt table. See
221
+ [Guarded CRUD receipt authority](guarded-crud-writeback.md).
222
+
223
+ ## Proposal and apply behavior
224
+
225
+ Proposal creation stores:
226
+
227
+ - the exact ordered primary keys and trusted tenant;
228
+ - every exact expected version;
229
+ - allowlisted before/after values or bounded digests;
230
+ - reviewed row count and aggregate values;
231
+ - a digest over the complete frozen set;
232
+ - pending human/operator approval.
233
+
234
+ Member and set digests use SHA-256 over JSON whose object keys are sorted
235
+ recursively. Array order remains significant because it is the reviewed,
236
+ deterministic primary-key order. JSON strings, finite numbers, booleans, and
237
+ null retain their protocol representation; unsupported runtime objects and
238
+ non-finite values are rejected rather than stringified implicitly. Runner
239
+ 1.4.1 also verifies the narrowly known deterministic raw-object order emitted
240
+ by 1.4.0, reconstructed from the complete stored reviewed data, so unchanged
241
+ 1.4.0 proposals remain applyable without weakening the drift checks.
242
+
243
+ Apply locks the frozen rows in deterministic primary-key order, rechecks every
244
+ reviewed value/version, performs every member mutation in one transaction, and
245
+ requires the affected count to equal the frozen count. One missing/stale row or
246
+ one database error rolls back the whole set.
247
+
248
+ Set `sources.<name>.statement_timeout_ms` to bound source waits. PostgreSQL
249
+ uses transaction-local statement and lock timeouts. MySQL applies the value to
250
+ read/preflight execution and rounds it up to whole seconds for InnoDB lock
251
+ waits; MySQL does not offer the same general DML statement timeout as
252
+ PostgreSQL.
253
+
254
+ The protocol-v3 receipt and replay include every primary key plus the bounded
255
+ before/after or tombstone digests. Kept-out fields are never added merely to
256
+ support a set operation.
257
+
258
+ ## Receipt authority and ambiguity
259
+
260
+ `source_db` authority commits the mutation and receipt in the same source
261
+ transaction. It gives the strongest retry classification.
262
+
263
+ `runner_ledger` authority creates no source receipt table, but no atomic
264
+ transaction spans the source and Runner ledger. A process loss after source
265
+ commit can therefore produce `reconciliation_required`. Runner does not retry
266
+ or guess. The operator reconciliation path re-inspects only the frozen,
267
+ allowlisted identities and records an immutable decision.
268
+
269
+ ## Explicit boundary
270
+
271
+ Use an [app-owned executor](writeback-executors.md) for:
272
+
273
+ - model-supplied or free-form predicates;
274
+ - ranges, wildcards, ordering, subqueries, or dynamic identifiers;
275
+ - more than 100 rows or any unbounded batch;
276
+ - UPSERT/MERGE, DDL, stored procedures, or cross-table/database transactions;
277
+ - external API calls, events, files, emails, payments, or other side effects;
278
+ - a hard DELETE whose trigger/cascade effects cannot be proven bounded.
279
+
280
+ Policy auto-approval is not supported for bounded sets. Every set proposal
281
+ requires a verified human/operator decision.
282
+
283
+ ## Verification
284
+
285
+ Run the disposable-engine gate:
286
+
287
+ ```bash
288
+ corepack pnpm test:bounded-set
289
+ ```
290
+
291
+ It verifies both PostgreSQL and MySQL: the exact DSL/contract path under both
292
+ receipt authorities; cap+1 and aggregate rejection before persistence; exact
293
+ UPDATE/DELETE; batch INSERT; idempotent retry; independent version, predicate,
294
+ aggregate, writable-value, missing-member, and tenant drift; injected mid-set
295
+ rollback; insert dedup/atomicity; delete trigger/cascade refusal; exact receipt
296
+ members; Runner-ledger reconciliation; and 1/10/100-row bounds. See the
297
+ [local benchmark note](benchmarks/bounded-set-local.md) and the normative
298
+ [safety RFC](rfcs/005-bounded-set-writeback.md).
@@ -447,3 +447,9 @@ Commit authority stays outside the model:
447
447
  ```text
448
448
  human/operator approval -> guarded writeback or app-owned handler -> receipt/replay
449
449
  ```
450
+
451
+ If your application already exposes fixed parameterized functions, that is a
452
+ good foundation rather than an anti-pattern. Read [Why Synapsor Over Prompt And
453
+ Application Guardrails](why-synapsor-vs-app-guardrails.md) to decide whether
454
+ you also need Runner's shared contract, trusted context, evidence, approval,
455
+ receipt, replay, and compensation lifecycle.
@@ -10,6 +10,29 @@ npx -y -p @synapsor/runner synapsor-runner demo --quick
10
10
  The OSS runner command is `synapsor-runner`. The `synapsor` command is reserved
11
11
  for the Synapsor Cloud CLI.
12
12
 
13
+ ## 1.4.1 (prepared, not published)
14
+
15
+ ### Bounded-set digest compatibility patch
16
+
17
+ - Contract-authored bounded-set proposals now use deterministic recursive
18
+ object-key ordering for member and set digest material.
19
+ - Valid proposals created by `1.4.0` remain applyable; the compatibility path
20
+ accepts only the known deterministic `1.4.0` serializations reconstructed
21
+ from the complete stored reviewed data.
22
+ - Genuine member, version, value, aggregate, membership, or tenant drift still
23
+ fails closed before source mutation on PostgreSQL and MySQL.
24
+ - The Runner package now includes the linked bounded-set guide and validates
25
+ all shipped local Markdown links while packaging.
26
+ - The DSL package description and README no longer label the current `1.4.x`
27
+ package as a `0.1 preview`. Canonical contract `spec_version: "0.1"` is
28
+ unchanged.
29
+ - Adds an honest prompt-and-application-guardrails decision guide covering SQL
30
+ authority, hand-built semantic tools, structural enforcement, build-vs-adopt
31
+ fit, and regulated-data boundaries.
32
+
33
+ Prepared package versions: `@synapsor/dsl@1.4.1` and
34
+ `@synapsor/runner@1.4.1`. `@synapsor/spec` remains `1.4.0`.
35
+
13
36
  ## 1.4.0 (prepared, not published)
14
37
 
15
38
  ### Reviewed Reversible Change Sets
@@ -0,0 +1,43 @@
1
+ # Result Envelope V3
2
+
3
+ Protocol v3 is used only for bounded set direct writeback. Protocol v1 and v2
4
+ remain accepted for legacy UPDATE and guarded single-row CRUD.
5
+
6
+ The public artifacts are:
7
+
8
+ - `synapsor.change-set.v3`;
9
+ - `synapsor.writeback-job.v3`;
10
+ - `synapsor.execution-receipt.v3`.
11
+
12
+ Each v3 object names one operation: `set_update`, `set_delete`, or
13
+ `batch_insert`. It carries the same frozen-set authority:
14
+
15
+ ```json
16
+ {
17
+ "max_rows": 10,
18
+ "row_count": 2,
19
+ "aggregate_bounds": [
20
+ { "column": "balance_cents", "measure": "before", "maximum": 20000, "actual": 15000 }
21
+ ],
22
+ "members": [
23
+ {
24
+ "primary_key": { "column": "id", "value": "INV-1" },
25
+ "expected_version": { "column": "version", "value": 1 },
26
+ "before_digest": "sha256:...",
27
+ "after_digest": "sha256:..."
28
+ }
29
+ ],
30
+ "set_digest": "sha256:..."
31
+ }
32
+ ```
33
+
34
+ The complete protocol fixtures live in [`fixtures/protocol`](../fixtures/protocol)
35
+ and the JSON Schemas live in [`schemas`](../schemas). The execution receipt
36
+ reports every `target_identity` and matching `member_effect`, plus receipt
37
+ authority, set digest, exact affected count, safe status/error, and receipt
38
+ hash. An applied result is invalid unless its affected count and member-effect
39
+ count match the frozen identity count.
40
+
41
+ `runner_ledger` may return `reconciliation_required` with an intent ID when a
42
+ cross-database crash window prevents proof of commit. That is a terminal
43
+ operator state, not a retry instruction or an `already_applied` guess.
@@ -20,6 +20,13 @@ trusted context, evidence handles, query audit, local inspection, and
20
20
  proposal-first writes. Proposal workflows add full replay across evidence,
21
21
  approval, writeback receipts, and events.
22
22
 
23
+ Prompt instructions and application validation are separate layers. Prompts
24
+ can guide behavior but cannot supply authorization. A fixed, parameterized
25
+ application tool can be a sound boundary for a small system; Runner adds a
26
+ portable reviewed contract and a consistent proposal, approval, receipt,
27
+ replay, and compensation lifecycle. See [Why Synapsor Over Prompt And
28
+ Application Guardrails](why-synapsor-vs-app-guardrails.md).
29
+
23
30
  The model-facing MCP server exposes reviewed semantic tools such as
24
31
  `billing.inspect_invoice` and `billing.propose_late_fee_waiver`.
25
32
 
@@ -0,0 +1,169 @@
1
+ # Why Synapsor Over Prompt And Application Guardrails
2
+
3
+ You can build a safe database tool in application code. The important question
4
+ is not whether the code is custom or packaged. It is where authority lives and
5
+ which safety properties are enforced outside the model.
6
+
7
+ Synapsor Runner is useful when a narrow application function is no longer the
8
+ whole requirement. It adds a shared, reviewable contract and an operational
9
+ record around model-facing reads and writes: trusted context, evidence, query
10
+ audit, proposals, approval outside MCP, guarded writeback, idempotency
11
+ receipts, replay, and opt-in reviewed compensation.
12
+
13
+ It does not make prompts trustworthy, prevent prompt injection, replace
14
+ database permissions, or make an application compliant by itself.
15
+
16
+ ## Prompt Instructions Are Not A Security Boundary
17
+
18
+ A system prompt can improve normal model behavior. It cannot grant or remove
19
+ database authority. **Prompt-only enforcement is not a security boundary.** A
20
+ confused or prompt-injected model can ignore an instruction, so a rule such as
21
+ "only access this tenant" must be enforced by trusted code, database policy,
22
+ or both.
23
+
24
+ This is a general agent-security issue, not a hypothetical specific to one
25
+ vendor:
26
+
27
+ - [OWASP LLM01: Prompt Injection](https://genai.owasp.org/llmrisk/llm01-prompt-injection/)
28
+ describes direct and indirect prompt injection and recommends constrained
29
+ model behavior plus human approval for high-risk actions.
30
+ - [General Analysis published a controlled Supabase MCP
31
+ demonstration](https://generalanalysis.com/blog/supabase-mcp-blog) in which
32
+ untrusted support-ticket content influenced an agent with broad database
33
+ access and exposed integration-token data. The demonstration disabled
34
+ read-only mode and used a privileged `service_role`; it was a controlled
35
+ security test, not evidence that every Supabase MCP deployment is
36
+ vulnerable.
37
+ - [Supabase's defense-in-depth
38
+ response](https://supabase.com/blog/defense-in-depth-mcp) likewise recommends
39
+ a protective layer, least privilege, and avoiding direct production access.
40
+
41
+ The lesson is not that a prompt can never be useful. It is that prompt text
42
+ must not be the control that protects tenant scope, hidden fields, approval,
43
+ or commit authority.
44
+
45
+ ## First Ask: Who Produces The SQL?
46
+
47
+ ### The model produces SQL
48
+
49
+ If the model emits arbitrary SQL and application code decides whether to run
50
+ it, that validator must correctly understand the database grammar,
51
+ subqueries, functions, views, permissions, row scope, side effects, and future
52
+ schema changes. A blocklist or regular expression is not an authorization
53
+ system. This remains the `execute_sql` problem with an extra parser in front
54
+ of it.
55
+
56
+ Prefer fixed, parameterized statements selected by trusted application code,
57
+ restricted database roles, views, and row-level security. Do not make the
58
+ model the query planner for privileged production access.
59
+
60
+ ### Your application produces SQL
61
+
62
+ If application code selects a fixed parameterized statement and exposes a
63
+ narrow function such as `billing.inspect_invoice`, you have already made the
64
+ most important architectural move: a semantic business tool replaces raw SQL.
65
+ For a small application, that may be enough.
66
+
67
+ The next question is whether you also need a consistent review and operations
68
+ layer. Without one, each function must separately implement and preserve:
69
+
70
+ - trusted tenant and principal binding;
71
+ - read and write field allowlists;
72
+ - evidence and query-audit records;
73
+ - exact proposed diffs before a write;
74
+ - approval identity and quorum outside the model;
75
+ - conflict, transition, numeric, row-count, and aggregate guards;
76
+ - idempotent execution and receipts;
77
+ - activity search and replay;
78
+ - bounded, separately approved compensation where reversibility is possible.
79
+
80
+ Synapsor centralizes those mechanics in a contract and runtime instead of
81
+ requiring every tool handler to invent them independently.
82
+
83
+ ## What Runner Adds
84
+
85
+ | Concern | Prompt or one-off handler | Synapsor Runner |
86
+ | --- | --- | --- |
87
+ | Model-facing authority | Depends on instructions and handler design | Only reviewed semantic capabilities are served |
88
+ | Tenant/principal scope | Often passed as model arguments or scattered checks | Bound from trusted environment or authenticated session context |
89
+ | Data exposure | Handler-specific projection | Contract-reviewed visible fields and kept-out fields |
90
+ | Risky writes | Handler may write during the tool call | MCP creates a proposal; approval and apply stay outside MCP |
91
+ | Concurrency | Must be designed per handler | Version/conflict guards and frozen-set drift checks fail closed |
92
+ | Retry behavior | Must be designed per handler | Idempotency keys and source or Runner-ledger receipts |
93
+ | Investigation | Application logs | Evidence handles, query audit, proposal events, receipts, activity, and replay |
94
+ | Undo | Usually another bespoke endpoint | Opt-in reviewed compensation for supported direct writes |
95
+ | Review surface | Prompt, code, and policies may diverge | Portable canonical contract plus SQL-like DSL |
96
+
97
+ This is structural enforcement only for traffic that actually passes through
98
+ Runner and its reviewed capabilities. An unrestricted credential, a second raw
99
+ SQL MCP server, or a bypass path in the application remains outside the
100
+ boundary.
101
+
102
+ The local SQLite or shared Postgres ledger is durable through Runner's
103
+ supported interfaces, but a trusted host or database administrator can alter
104
+ local state. Use Synapsor Cloud or your own tamper-evident retention and access
105
+ controls when organizational audit requirements demand a stronger shared
106
+ record.
107
+
108
+ ## Build Or Adopt
109
+
110
+ Your own code is probably enough when all of these are true:
111
+
112
+ - the agent is read-only or has one or two fixed, low-risk functions;
113
+ - the application already derives tenant scope from authenticated server
114
+ state;
115
+ - database roles, views, and row-level security provide the required floor;
116
+ - you do not need approval history, evidence, receipts, replay, or reviewed
117
+ compensation;
118
+ - one team owns every tool and can test every change consistently.
119
+
120
+ Consider Runner when one or more of these are true:
121
+
122
+ - agents can propose writes to customer, billing, support, health, finance,
123
+ or other consequential data;
124
+ - tenant or principal isolation must not depend on model-supplied arguments;
125
+ - a human or operator must approve some changes outside the agent loop;
126
+ - retries, stale writes, bounded sets, or partial failures need explicit
127
+ fail-closed behavior;
128
+ - investigators need to answer what the agent read, requested, approved, and
129
+ changed;
130
+ - capabilities are growing across teams, agents, databases, or MCP clients;
131
+ - the safety boundary needs to be reviewed in source control as one portable
132
+ contract.
133
+
134
+ This is not a claim that the mechanics are impossible to build. It is a choice
135
+ between maintaining them independently in every integration and adopting a
136
+ tested contract/runtime that makes the same controls visible and repeatable.
137
+
138
+ ## Regulated And High-Consequence Data
139
+
140
+ Health, finance, and other regulated workloads make the distinction more
141
+ important, but Runner does not confer HIPAA, PCI DSS, SOC 2, or any other
142
+ compliance status. A real deployment still needs the appropriate legal and
143
+ organizational controls, least-privilege roles, encryption, retention,
144
+ monitoring, incident response, vendor agreements, and human access policy.
145
+
146
+ Runner can contribute technical evidence for that program: tenant-bound
147
+ capabilities, explicit field projections, proposal/approval separation,
148
+ signed operator identity where configured, guarded writeback receipts, and
149
+ replay. Validate those controls with your security and compliance owners
150
+ before using production regulated data.
151
+
152
+ ## Evaluate It Against Your Existing Layer
153
+
154
+ 1. Run the static MCP risk review against your current tools:
155
+
156
+ ```bash
157
+ npx -y @synapsor/runner audit ./tools-list.json
158
+ ```
159
+
160
+ 2. List every rule currently enforced only by a prompt.
161
+ 3. Identify whether the model or trusted code chooses SQL, tenant scope,
162
+ columns, predicates, approval, and commit timing.
163
+ 4. Pick one consequential staging workflow and compare its current evidence,
164
+ retry, conflict, approval, receipt, and investigation behavior with the
165
+ [own-database Runner flow](getting-started-own-database.md).
166
+
167
+ Keep your existing database permissions either way. Runner shapes the
168
+ agent-facing interface; it does not replace the database's own authorization
169
+ boundary.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapsor/runner",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,6 +35,7 @@
35
35
  "AGENTS.md",
36
36
  "CHANGELOG.md",
37
37
  "CONTRIBUTING.md",
38
+ "SECURITY.md",
38
39
  "LICENSE",
39
40
  "NOTICE",
40
41
  "THREAT_MODEL.md",