@synapsor/runner 1.5.3 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +44 -2
  2. package/README.md +20 -12
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/runner.mjs +2332 -501
  5. package/dist/runtime.mjs +788 -148
  6. package/dist/shadow.mjs +101 -1
  7. package/docs/capability-authoring.md +6 -1
  8. package/docs/cloud-mode.md +18 -0
  9. package/docs/database-enforced-scope.md +8 -0
  10. package/docs/dsl-reference.md +45 -4
  11. package/docs/getting-started-own-database.md +6 -3
  12. package/docs/guarded-crud-writeback.md +10 -1
  13. package/docs/http-mcp.md +222 -207
  14. package/docs/limitations.md +9 -2
  15. package/docs/local-mode.md +5 -2
  16. package/docs/mcp-client-setup.md +24 -11
  17. package/docs/mcp-clients.md +10 -4
  18. package/docs/openai-agents-sdk.md +16 -2
  19. package/docs/production.md +72 -7
  20. package/docs/release-notes.md +44 -3
  21. package/docs/runner-bundles.md +7 -2
  22. package/docs/runner-config-reference.md +96 -6
  23. package/docs/running-a-runner-fleet.md +42 -13
  24. package/docs/security-boundary.md +38 -3
  25. package/docs/store-lifecycle.md +93 -5
  26. package/examples/openai-agents-http/README.md +10 -4
  27. package/examples/openai-agents-http/agent.py +2 -2
  28. package/examples/runner-fleet/Dockerfile +7 -2
  29. package/examples/runner-fleet/README.md +11 -7
  30. package/examples/runner-fleet/docker-compose.yml +4 -4
  31. package/examples/runner-fleet/mint-dev-token.mjs +1 -1
  32. package/examples/runner-fleet/start-tls-runner.sh +21 -0
  33. package/examples/runner-fleet/synapsor.runner.json +27 -1
  34. package/examples/support-plan-credit/README.md +6 -1
  35. package/examples/support-plan-credit/mcp-client-examples/generic-streamable-http.json +4 -1
  36. package/examples/support-plan-credit/mcp-client-examples/openai-agents-streamable-http.ts +4 -0
  37. package/examples/support-plan-credit/synapsor.contract.json +0 -3
  38. package/package.json +1 -1
  39. package/schemas/synapsor.runner.schema.json +80 -0
package/dist/shadow.mjs CHANGED
@@ -1908,6 +1908,11 @@ var ProposalStore = class {
1908
1908
  const rows = this.db.prepare(query.sql).all(...query.params);
1909
1909
  return rows.map((row) => rowToProposal(row)).filter((proposal) => proposal !== void 0);
1910
1910
  }
1911
+ countProposals(filters = {}) {
1912
+ const query = buildProposalCountQuery(filters);
1913
+ const row = this.db.prepare(query.sql).get(...query.params);
1914
+ return isRecord(row) ? Number(row.count ?? 0) : 0;
1915
+ }
1911
1916
  listEvidenceBundles(filters = {}) {
1912
1917
  const query = buildEvidenceQuery(filters);
1913
1918
  const rows = this.db.prepare(query.sql).all(...query.params);
@@ -1934,6 +1939,19 @@ var ProposalStore = class {
1934
1939
  const proposalId = replayId.startsWith(prefix) ? replayId.slice(prefix.length) : replayId;
1935
1940
  return this.replay(proposalId);
1936
1941
  }
1942
+ getStoredReplay(replayId) {
1943
+ const row = this.db.prepare("SELECT * FROM replay_records WHERE replay_id = ?").get(replayId);
1944
+ return rowToStoredReplay(row);
1945
+ }
1946
+ getStoredReplayForProposal(proposalId) {
1947
+ const row = this.db.prepare(`
1948
+ SELECT * FROM replay_records
1949
+ WHERE proposal_id = ?
1950
+ ORDER BY created_at DESC, replay_id DESC
1951
+ LIMIT 1
1952
+ `).get(proposalId);
1953
+ return rowToStoredReplay(row);
1954
+ }
1937
1955
  proposalIdForEvidence(evidenceBundleId) {
1938
1956
  const evidence = this.getEvidenceBundle(evidenceBundleId);
1939
1957
  if (evidence?.proposal_id) return evidence.proposal_id;
@@ -2236,6 +2254,19 @@ var ProposalStore = class {
2236
2254
  });
2237
2255
  return job;
2238
2256
  }
2257
+ getWritebackJob(writebackJobId) {
2258
+ return rowToWritebackJob(this.db.prepare("SELECT * FROM writeback_jobs WHERE writeback_job_id = ?").get(writebackJobId));
2259
+ }
2260
+ listWritebackJobs(options = {}) {
2261
+ const clauses = [];
2262
+ const values = [];
2263
+ if (options.proposal_id) {
2264
+ clauses.push("proposal_id = ?");
2265
+ values.push(options.proposal_id);
2266
+ }
2267
+ const limit = Math.min(Math.max(options.limit ?? 100, 1), 1e3);
2268
+ return this.db.prepare(`SELECT * FROM writeback_jobs${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY created_at ASC, writeback_job_id ASC LIMIT ?`).all(...values, limit).map(rowToWritebackJob).filter((job) => Boolean(job));
2269
+ }
2239
2270
  claimWritebackIntent(jobInput, runnerId) {
2240
2271
  const job = parseWritebackJob(jobInput);
2241
2272
  const proposal = this.requireProposal(job.proposal_id);
@@ -4059,6 +4090,19 @@ function inWhere(column, values) {
4059
4090
  };
4060
4091
  }
4061
4092
  function buildProposalQuery(filters) {
4093
+ const { clauses, params } = proposalQueryParts(filters);
4094
+ const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
4095
+ return {
4096
+ sql: `SELECT * FROM proposals${where} ORDER BY created_at DESC, proposal_id DESC${filters.limit ? " LIMIT ?" : ""}`,
4097
+ params: filters.limit ? [...params, filters.limit] : params
4098
+ };
4099
+ }
4100
+ function buildProposalCountQuery(filters) {
4101
+ const { clauses, params } = proposalQueryParts(filters);
4102
+ const where = clauses.length ? ` WHERE ${clauses.join(" AND ")}` : "";
4103
+ return { sql: `SELECT COUNT(*) AS count FROM proposals${where}`, params };
4104
+ }
4105
+ function proposalQueryParts(filters) {
4062
4106
  const clauses = [];
4063
4107
  const params = [];
4064
4108
  addEqual(clauses, params, "proposal_id", filters.proposal);
@@ -4070,7 +4114,7 @@ function buildProposalQuery(filters) {
4070
4114
  addEqual(clauses, params, "action", filters.capability ?? filters.action);
4071
4115
  addObjectFilter(clauses, params, "business_object", "source_table", "object_id", filters.objectType, filters.objectId);
4072
4116
  addTimeRange(clauses, params, "created_at", filters.from, filters.to);
4073
- return finishQuery("SELECT * FROM proposals", clauses, params, filters.limit);
4117
+ return { clauses, params };
4074
4118
  }
4075
4119
  function buildEvidenceQuery(filters) {
4076
4120
  const clauses = [];
@@ -4652,6 +4696,62 @@ function rowToReceipt(row) {
4652
4696
  source_table: row.source_table == null ? void 0 : String(row.source_table)
4653
4697
  };
4654
4698
  }
4699
+ function rowToWritebackJob(row) {
4700
+ if (!isRecord(row)) return void 0;
4701
+ let payload;
4702
+ try {
4703
+ payload = JSON.parse(String(row.job_json));
4704
+ } catch {
4705
+ throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not valid JSON`);
4706
+ }
4707
+ if (!isRecord(payload)) {
4708
+ throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not an object`);
4709
+ }
4710
+ const handler = payload.schema_version === "synapsor.handler-writeback.v1";
4711
+ let normalizedJob;
4712
+ if (!handler) {
4713
+ try {
4714
+ normalizedJob = parseWritebackJob(payload);
4715
+ } catch {
4716
+ throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} payload is not a supported writeback protocol`);
4717
+ }
4718
+ }
4719
+ const payloadJobId = handler ? payload.writeback_job_id : normalizedJob?.job_id;
4720
+ const payloadProposalId = handler ? payload.proposal_id : normalizedJob?.proposal_id;
4721
+ const payloadProposalHash = handler ? payload.proposal_hash : normalizedJob?.approval_id;
4722
+ if (payloadJobId !== String(row.writeback_job_id) || payloadProposalId !== String(row.proposal_id) || payloadProposalHash !== String(row.proposal_hash)) {
4723
+ throw new ProposalStoreError("WRITEBACK_JOB_CORRUPT", `writeback job ${String(row.writeback_job_id)} index fields do not match its payload`);
4724
+ }
4725
+ return {
4726
+ writeback_job_id: String(row.writeback_job_id),
4727
+ proposal_id: String(row.proposal_id),
4728
+ proposal_hash: String(row.proposal_hash),
4729
+ status: String(row.status),
4730
+ kind: handler ? "app_handler" : "direct_sql",
4731
+ payload,
4732
+ ...normalizedJob ? { normalized_job: normalizedJob } : {},
4733
+ created_at: String(row.created_at),
4734
+ updated_at: String(row.updated_at)
4735
+ };
4736
+ }
4737
+ function rowToStoredReplay(row) {
4738
+ if (!isRecord(row)) return void 0;
4739
+ let payload;
4740
+ try {
4741
+ payload = JSON.parse(String(row.payload_json));
4742
+ } catch {
4743
+ throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${String(row.replay_id)} payload is not valid JSON`);
4744
+ }
4745
+ if (!isRecord(payload) || !isRecord(payload.proposal)) {
4746
+ throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${String(row.replay_id)} payload is not a supported replay record`);
4747
+ }
4748
+ const replayId = String(row.replay_id);
4749
+ const proposalId = String(row.proposal_id);
4750
+ if (payload.replay_id !== replayId || payload.proposal.proposal_id !== proposalId || !Array.isArray(payload.approvals) || !Array.isArray(payload.events) || !Array.isArray(payload.receipts) || !Array.isArray(payload.query_audit) || !Array.isArray(payload.evidence) || typeof payload.generated_at !== "string") {
4751
+ throw new ProposalStoreError("REPLAY_RECORD_CORRUPT", `replay ${replayId} index fields do not match its payload`);
4752
+ }
4753
+ return payload;
4754
+ }
4655
4755
  function rowToWritebackIntent(row) {
4656
4756
  if (!isRecord(row)) return void 0;
4657
4757
  const status = String(row.status);
@@ -196,7 +196,8 @@ reviewed runner JSON capabilities. Current parity:
196
196
  | arg `enum` | `ARG risk_level STRING ENUM('low', 'medium', 'high') REQUIRED` | 1.4.121 | Supports 1..64 same-type string, number, or boolean values; duplicates and mixed types fail compilation. |
197
197
  | proposal `numeric_bounds` | `BOUND column 1..2500`, `BOUND column ..2500`, or `BOUND column 1..` | 0.1.8 | Applies to patched numeric columns. Strict mode warns when a NUMBER arg is patched without arg min/max or a matching `BOUND`. |
198
198
  | proposal `transition_guards` | `TRANSITION status ALLOW pending -> approved\|rejected` or `TRANSITION status FROM current_status ALLOW open -> closed` | 0.1.8 | Values are state strings; use `|` for multiple target states. |
199
- | proposal `conflict_guard` | `CONFLICT GUARD updated_at` | 0.1 | If omitted, DSL emits an explicit weak-guard acknowledgement. Prefer a real row-version column. |
199
+ | proposal `conflict_guard` | `CONFLICT GUARD updated_at` | 0.1 | Required for ordinary UPDATE authoring. It compiles to an exact version-column check. Omission fails instead of silently weakening concurrency protection. |
200
+ | proposal weak `conflict_guard` | `CONFLICT GUARD WEAK ROW HASH ACKNOWLEDGED` | 1.4.4 | Explicit legacy escape hatch for an ordinary single-row source-DB UPDATE only. It hashes the captured projection and may miss concurrent changes outside that projection. It is rejected for DELETE, reversible writes, bounded sets, and Runner-ledger authority. |
200
201
  | proposal `approval` | `APPROVAL ROLE billing_lead` | 0.1 | Local mode records the required role; enforcement is still outside the model-facing MCP tool. |
201
202
  | proposal `approval.required_approvals` | `REQUIRE 2 APPROVALS` | 1.1 | Optional 1..10 distinct-reviewer quorum; defaults to 1. |
202
203
  | proposal `writeback` | `WRITEBACK DIRECT SQL`, `WRITEBACK APP HANDLER EXECUTOR name`, `WRITEBACK CLOUD WORKER`, `WRITEBACK NONE` | 0.1.7 | Handler URLs/tokens stay in `synapsor.runner.json`; contracts carry only the handler name. |
@@ -523,6 +524,8 @@ Canonical Synapsor names use dots, such as `billing.inspect_invoice`. Some
523
524
  clients require function-safe names. Use:
524
525
 
525
526
  ```bash
527
+ export SYNAPSOR_RUNNER_HTTP_TOKEN="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64url"))')"
528
+
526
529
  synapsor-runner mcp serve-streamable-http \
527
530
  --config ./synapsor.runner.json \
528
531
  --store ./.synapsor/local.db \
@@ -533,6 +536,8 @@ synapsor-runner mcp serve-streamable-http \
533
536
  The model sees aliases such as `billing__inspect_invoice`. Runner includes the
534
537
  canonical name in tool metadata and descriptions so audit/replay still use the
535
538
  real capability name.
539
+ This loopback token is opaque endpoint access, not tenant identity. See
540
+ [HTTP MCP](http-mcp.md) before using a network listener.
536
541
 
537
542
  ## Why Not `execute_sql`
538
543
 
@@ -19,6 +19,24 @@ The local Runner still keeps database credentials in your environment. MCP
19
19
  client config snippets contain command paths and Runner arguments, not database
20
20
  URLs or write credentials.
21
21
 
22
+ Keep Cloud and MCP credentials distinct:
23
+
24
+ | Credential | Purpose | Issuer/provisioner | Never substitutes for |
25
+ | --- | --- | --- | --- |
26
+ | Cloud user session | Human control-panel/CLI identity and subscription/RBAC checks | Synapsor Cloud identity system | MCP tenant identity or database access |
27
+ | Cloud service/API key | Contract push and scoped Cloud administration | Synapsor Cloud | MCP endpoint access |
28
+ | Source-scoped Runner token | Runner registration, heartbeat, lease, activity, and result calls for one Cloud source | Synapsor Cloud Connect Runner flow | Model-facing MCP access or operator approval |
29
+ | Opaque MCP endpoint token | Loopback or explicitly single-tenant HTTP endpoint access | Customer operator/secret manager | User/tenant identity |
30
+ | Signed MCP access JWT | Shared HTTP client identity and verified tenant/principal claims | Customer identity provider/authorization server | Cloud subscription credential or DB role |
31
+ | Operator credential | Activation, approval, apply, reconcile, dead-letter, and revert authority | Customer operator identity system | Model-facing MCP capability |
32
+ | Database/handler credential | Local read or trusted post-approval effect | Customer secret manager/database/application | Cloud or MCP authentication |
33
+
34
+ Cloud-linked mode does not require networked MCP: a desktop client can still
35
+ launch the local Runner over stdio. If the local Runner exposes Streamable HTTP,
36
+ the same [HTTP MCP](http-mcp.md) channel and identity profiles apply. Cloud API
37
+ keys and source-scoped Runner tokens are never accepted as shortcuts for MCP
38
+ session authentication.
39
+
22
40
  ## Trust Boundary
23
41
 
24
42
  ```text
@@ -11,6 +11,10 @@ from model arguments. Choose the database enforcement mode deliberately:
11
11
 
12
12
  These modes are defense in depth, not substitutes for least-privilege roles,
13
13
  restricted views, application authorization, or staging-first validation.
14
+ HTTP endpoint authentication is another independent layer: it establishes who
15
+ may call Runner and, for signed shared sessions, which trusted tenant/principal
16
+ claims are bound. It does not turn `application_scope` into database-enforced
17
+ isolation. See [HTTP MCP](http-mcp.md).
14
18
 
15
19
  ## Default: Application-Level Scope
16
20
 
@@ -49,6 +53,10 @@ with environment-bound capabilities.
49
53
  For shared production HTTP, prefer asymmetric JWT verification so Runner holds
50
54
  only public verification material. HS256 remains useful for local development
51
55
  and controlled deployments but gives Runner access to the signing secret.
56
+ Also declare `http_security.deployment: shared`, an exact HTTPS
57
+ audience/protected resource, a direct-TLS or trusted-proxy channel, and exact
58
+ Host/Origin policy. A static endpoint token cannot supply shared tenant or
59
+ principal identity.
52
60
 
53
61
  Run `doctor --json` or `tools preview --json` to inspect the effective
54
62
  per-source assurance mode and trusted-context binding. Server startup prints
@@ -29,7 +29,7 @@ END
29
29
 
30
30
  | Clause | Meaning |
31
31
  | --- | --- |
32
- | `BIND name FROM source key [REQUIRED]` | Defines trusted context. Sources: `SESSION`, `ENV`/`ENVIRONMENT`, `CLOUD_SESSION`, `STATIC_DEV`, `HTTP_CLAIM`. Model tool arguments cannot set these bindings. |
32
+ | `BIND name FROM source key [REQUIRED]` | Defines trusted context. Canonical sources: `SESSION`, `ENV`/`ENVIRONMENT`, `CLOUD_SESSION`, `STATIC_DEV`, `HTTP_CLAIM`. Model tool arguments cannot set these bindings. Runner rejects `SESSION`; use one of its implemented verified providers described below. |
33
33
  | `TENANT BINDING name` | Selects the binding used for tenant scope. Defaults to a binding named `tenant_id`. |
34
34
  | `PRINCIPAL BINDING name` | Selects the actor binding. Defaults to a binding named `principal`. |
35
35
 
@@ -65,16 +65,57 @@ END
65
65
  | `TENANT KEY column` | Required in DSL 0.1. Adds trusted tenant scope to every read/write. |
66
66
  | `PRINCIPAL SCOPE KEY column` | Optional tenant-additive row lock. Runner binds this fixed column to the context's required trusted `PRINCIPAL BINDING`; it is never a model argument. |
67
67
  | `CONFLICT GUARD column` | Captures the row-version value for exact guarded writeback. Prefer a monotonic version or native-precision timestamp. |
68
+ | `CONFLICT GUARD WEAK ROW HASH ACKNOWLEDGED` | Explicitly accepts a weaker hash over the captured projection for a legacy, ordinary single-row source-DB UPDATE. It may miss concurrent changes outside that projection and is never equivalent to a version column. |
69
+
70
+ An UPDATE proposal must choose one of those guard clauses. Omitting the clause
71
+ is a compile error; the compiler never silently selects the weak form. The weak
72
+ form is rejected for INSERT, DELETE, reversible writes, bounded sets, and
73
+ Runner-ledger authority. `contract lint`, `contract explain`, `doctor`, and
74
+ `tools preview` all identify it prominently.
75
+
76
+ To migrate a legacy DSL file that omitted the guard, inspect the target schema
77
+ and add an exact source column:
78
+
79
+ ```sql
80
+ CONFLICT GUARD version
81
+ ```
82
+
83
+ Use a monotonic integer/version column when available, or a native-precision
84
+ `updated_at` maintained by the database. If a legacy source has no usable
85
+ version column and the capability is an ordinary single-row UPDATE using
86
+ source-DB receipt authority, the explicit weak clause keeps the previous
87
+ projection-hash behavior while recording the reduced assurance in review
88
+ output. Do not use it as a production-equivalent substitute for a real version
89
+ column.
68
90
 
69
91
  `PRINCIPAL SCOPE KEY` means `tenant_key = trusted tenant AND
70
92
  principal_scope_key = trusted principal`. It cannot replace tenant scope,
71
93
  default to all rows, or be overridden by an argument. The principal binding
72
- must be required and come from a trusted provider such as `ENVIRONMENT`,
73
- `HTTP_CLAIM`, `SESSION`, or `CLOUD_SESSION`. For networked multi-user serving,
74
- use signed HTTP claims. See the complete
94
+ must be required and come from a trusted Runner provider: `ENVIRONMENT`,
95
+ verified `HTTP_CLAIM`, verified `CLOUD_SESSION`, or explicit
96
+ `STATIC_DEV` for development only. For networked multi-user serving, use
97
+ signed HTTP claims. Canonical `SESSION` is implemented by C++/Cloud but is
98
+ rejected by Runner; Runner never aliases it to environment variables. See the complete
75
99
  [`principal-row-scope.synapsor.sql`](../fixtures/dsl/principal-row-scope.synapsor.sql)
76
100
  example.
77
101
 
102
+ ### Migrating `FROM SESSION` for Runner
103
+
104
+ Choose the provider that owns the verified identity at the Runner boundary:
105
+
106
+ - local stdio or one trusted process: `FROM ENVIRONMENT`; export the named
107
+ values before starting Runner;
108
+ - Streamable HTTP: `FROM HTTP_CLAIM`; configure signed JWT verification and
109
+ bind only verified claims;
110
+ - Cloud embedding: `FROM CLOUD_SESSION`; use the verified Cloud session
111
+ supplied by the control plane; or
112
+ - disposable development only: `FROM STATIC_DEV`.
113
+
114
+ `synapsor-runner dsl validate` and `dsl compile` target Runner and fail with
115
+ `SESSION_BINDING_UNSUPPORTED` before serving. The standalone DSL accepts the
116
+ canonical syntax by default for other implementations; pass `--target runner`
117
+ to request the same fail-closed Runner validation.
118
+
78
119
  ## Arguments and lookup
79
120
 
80
121
  ```sql
@@ -520,7 +520,7 @@ MCP client:
520
520
  ```bash
521
521
  export SYNAPSOR_TENANT_ID="acme"
522
522
  export SYNAPSOR_PRINCIPAL="local_operator"
523
- export SYNAPSOR_RUNNER_HTTP_TOKEN="dev-local-token"
523
+ export SYNAPSOR_RUNNER_HTTP_TOKEN="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("base64url"))')"
524
524
 
525
525
  npx -y -p @synapsor/runner synapsor-runner up --serve \
526
526
  --config ./synapsor.runner.json \
@@ -543,9 +543,12 @@ npx -y -p @synapsor/runner synapsor-runner mcp serve-streamable-http \
543
543
  ```
544
544
 
545
545
  Streamable HTTP defaults to `127.0.0.1:8766` and requires bearer auth by
546
- default. Use private networking/TLS before exposing it beyond localhost. See
546
+ default. A non-loopback listener refuses to start until direct TLS, an explicitly
547
+ trusted TLS proxy, or authenticated emergency break glass is selected. Shared
548
+ services use signed per-session identity rather than one endpoint token. See
547
549
  [HTTP MCP](http-mcp.md). If you want the smaller JSON-RPC bridge instead, use
548
- `synapsor-runner mcp serve-http`.
550
+ `synapsor-runner mcp serve-http`; it is not suitable for claim-bound shared MCP
551
+ sessions.
549
552
 
550
553
  The model-facing MCP server exposes semantic tools such as:
551
554
 
@@ -27,13 +27,22 @@ does not fit either direct boundary.
27
27
 
28
28
  | Operation | Required source guard | Direct-write behavior |
29
29
  | --- | --- | --- |
30
- | `UPDATE` | Primary key, trusted tenant, exact version/conflict column | Patches only allowlisted columns and affects exactly one row. In Runner-ledger mode, the version must advance in the same source transaction. |
30
+ | `UPDATE` | Primary key, trusted tenant, and normally an exact version/conflict column | Patches only allowlisted columns and affects exactly one row. In Runner-ledger mode, the exact version must advance in the same source transaction. |
31
31
  | `INSERT` | Source `PRIMARY KEY` or `UNIQUE` constraint over the reviewed dedup identity | Runner injects tenant and proposal-derived identity values, inserts only allowlisted fields, and requires exactly one row. |
32
32
  | `DELETE` | Primary key, trusted tenant, exact version column | Deletes exactly one reviewed row. Hard delete requires human/operator approval and is refused when Runner detects write triggers or widening cascades. Prefer soft delete as guarded `UPDATE`. |
33
33
 
34
34
  Existing proposal capabilities with no `operation` field continue to mean
35
35
  single-row `UPDATE`.
36
36
 
37
+ DSL authoring requires `CONFLICT GUARD <column>` for UPDATE and never silently
38
+ chooses weak semantics. A narrow legacy single-row, non-reversible UPDATE using
39
+ source-DB receipt authority may explicitly declare
40
+ `CONFLICT GUARD WEAK ROW HASH ACKNOWLEDGED`. That mode hashes only the captured
41
+ projection and may miss concurrent changes outside it. It is prominently
42
+ reported by lint/explain/doctor/preview and is unavailable for Runner-ledger,
43
+ DELETE, reversibility, or bounded sets. Treat it as a compatibility escape
44
+ hatch, not equivalent protection.
45
+
37
46
  ## Receipt authority
38
47
 
39
48
  Receipt authority and source-table provisioning are separate decisions: