@synapsor/runner 1.1.1 → 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +123 -1
  2. package/README.md +52 -31
  3. package/SECURITY.md +11 -0
  4. package/THREAT_MODEL.md +7 -4
  5. package/dist/cli.d.ts +5 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/local-ui.d.ts.map +1 -1
  8. package/dist/runner.mjs +8103 -2601
  9. package/docs/README.md +23 -3
  10. package/docs/benchmarks/bounded-set-local.md +27 -0
  11. package/docs/bounded-set-writeback.md +298 -0
  12. package/docs/capability-authoring.md +26 -8
  13. package/docs/conformance.md +12 -0
  14. package/docs/current-scope.md +12 -4
  15. package/docs/doctor.md +21 -7
  16. package/docs/dsl-reference.md +91 -5
  17. package/docs/getting-started-own-database.md +26 -3
  18. package/docs/guarded-crud-writeback.md +218 -0
  19. package/docs/limitations.md +30 -9
  20. package/docs/oss-vs-cloud.md +1 -1
  21. package/docs/production.md +52 -20
  22. package/docs/release-notes.md +105 -2
  23. package/docs/release-policy.md +1 -1
  24. package/docs/result-envelope-v2.md +20 -6
  25. package/docs/result-envelope-v3.md +43 -0
  26. package/docs/result-envelope-v4.md +47 -0
  27. package/docs/reversible-change-sets.md +151 -0
  28. package/docs/rfcs/004-guarded-crud-receipt-authority.md +165 -0
  29. package/docs/rfcs/005-bounded-set-writeback.md +164 -0
  30. package/docs/rfcs/006-reviewed-reversible-change-sets.md +152 -0
  31. package/docs/runner-config-reference.md +83 -9
  32. package/docs/running-a-runner-fleet.md +2 -1
  33. package/docs/security-boundary.md +36 -9
  34. package/docs/why-synapsor-vs-app-guardrails.md +169 -0
  35. package/docs/writeback-executors.md +12 -11
  36. package/examples/runner-fleet/seed/mysql.sql +47 -0
  37. package/examples/runner-fleet/seed/postgres.sql +48 -0
  38. package/examples/support-plan-credit/README.md +31 -0
  39. package/examples/support-plan-credit/contract.synapsor.sql +26 -0
  40. package/examples/support-plan-credit/expected-output/tools-preview.txt +1 -0
  41. package/examples/support-plan-credit/seed/001_seed.sql +14 -1
  42. package/examples/support-plan-credit/synapsor.contract.json +101 -1
  43. package/examples/support-plan-credit/synapsor.runner.json +7 -1
  44. package/fixtures/protocol/MANIFEST.json +131 -1
  45. package/fixtures/protocol/change-set.bounded-update.v3.json +57 -0
  46. package/fixtures/protocol/change-set.delete.v2.json +31 -0
  47. package/fixtures/protocol/change-set.insert.v2.json +36 -0
  48. package/fixtures/protocol/change-set.update.v2.json +32 -0
  49. package/fixtures/protocol/compensation-change-set.update.v1.json +40 -0
  50. package/fixtures/protocol/execution-receipt.bounded-update-applied.v3.json +39 -0
  51. package/fixtures/protocol/execution-receipt.compensation-update-applied.v4.json +37 -0
  52. package/fixtures/protocol/execution-receipt.delete-applied.v2.json +25 -0
  53. package/fixtures/protocol/execution-receipt.insert-applied.v2.json +24 -0
  54. package/fixtures/protocol/execution-receipt.reconciliation-required.v2.json +25 -0
  55. package/fixtures/protocol/execution-receipt.update-applied.v2.json +25 -0
  56. package/fixtures/protocol/writeback-job.bounded-update.v3.json +43 -0
  57. package/fixtures/protocol/writeback-job.compensation-update.v4.json +34 -0
  58. package/fixtures/protocol/writeback-job.delete.v2.json +22 -0
  59. package/fixtures/protocol/writeback-job.insert.v2.json +28 -0
  60. package/fixtures/protocol/writeback-job.update.v2.json +24 -0
  61. package/package.json +2 -1
  62. package/schemas/change-set.v2.schema.json +245 -0
  63. package/schemas/change-set.v3.schema.json +112 -0
  64. package/schemas/compensation-change-set.v1.schema.json +32 -0
  65. package/schemas/execution-receipt.v2.schema.json +106 -0
  66. package/schemas/execution-receipt.v3.schema.json +40 -0
  67. package/schemas/execution-receipt.v4.schema.json +22 -0
  68. package/schemas/inverse-descriptor.v1.schema.json +46 -0
  69. package/schemas/synapsor.runner.schema.json +132 -2
  70. package/schemas/writeback-job.v2.schema.json +238 -0
  71. package/schemas/writeback-job.v3.schema.json +53 -0
  72. package/schemas/writeback-job.v4.schema.json +17 -0
@@ -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.
@@ -42,19 +42,19 @@ For `synapsor-runner apply --job ... --config ...`, set the env var named by
42
42
  `write_url_env`. `SYNAPSOR_DATABASE_URL` is only a legacy fallback for direct
43
43
  worker flows that do not pass a local config.
44
44
 
45
- Direct SQL writeback also stores idempotency receipts in the source database.
46
- By default it runs:
45
+ Direct SQL writeback uses the receipt authority selected under the source
46
+ config. `source_db` can use an administrator-precreated table or explicitly run
47
+ an idempotent migration:
47
48
 
48
49
  ```sql
49
50
  CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (...);
50
51
  ```
51
52
 
52
- That means the writer needs permission to create and write that receipt table in
53
- the target schema/database, or the table must be pre-created by an administrator
54
- and granted to the writer. If you do not want Runner to create a table in the
55
- application schema, create a dedicated schema/database for receipts where your
56
- database policy allows it, or use `http_handler`/`command_handler` so your
57
- application owns receipt storage and business writes.
53
+ `precreated` needs only receipt-table DML; `auto_migrate` also needs bounded
54
+ `CREATE`. `runner_ledger` creates no source receipt table and uses a durable
55
+ Runner intent plus operator reconciliation for ambiguous post-commit crashes.
56
+ It does not claim distributed exactly-once. See
57
+ [Guarded Single-Row CRUD Writeback](guarded-crud-writeback.md).
58
58
 
59
59
  Use the helper commands before enabling direct SQL writeback:
60
60
 
@@ -65,9 +65,10 @@ npx -y -p @synapsor/runner synapsor-runner writeback grants --engine postgres --
65
65
  ```
66
66
 
67
67
  `writeback doctor --check-db` connects with the configured writer credential and
68
- checks the pre-created receipt table path in a rolled-back transaction. Apply
69
- the printed migration as an administrator first; the steady-state writer needs
70
- table `SELECT`/`INSERT`/`UPDATE`, not schema `CREATE`.
68
+ checks the selected receipt authority plus operation-specific target guards in
69
+ a rolled-back transaction. In `precreated` mode, apply the printed migration as
70
+ an administrator first; the steady-state writer needs table
71
+ `SELECT`/`INSERT`/`UPDATE`, not schema `CREATE`.
71
72
 
72
73
  ## `http_handler`
73
74
 
@@ -7,6 +7,39 @@ CREATE TABLE invoices (
7
7
  updated_at datetime(6) NOT NULL
8
8
  );
9
9
 
10
+ CREATE TABLE guard_crud_items (
11
+ id bigint NOT NULL AUTO_INCREMENT PRIMARY KEY,
12
+ tenant_id varchar(128) NOT NULL,
13
+ request_id varchar(128) NOT NULL,
14
+ value_cents integer NOT NULL,
15
+ version bigint NOT NULL DEFAULT 1,
16
+ UNIQUE KEY guard_crud_items_tenant_request (tenant_id, request_id)
17
+ );
18
+
19
+ CREATE TABLE synapsor_receipts_precreated (
20
+ idempotency_key varchar(191) PRIMARY KEY,
21
+ job_id varchar(191) UNIQUE NOT NULL,
22
+ proposal_id varchar(191) NOT NULL,
23
+ status varchar(64) NOT NULL,
24
+ result_hash varchar(255),
25
+ created_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
26
+ completed_at datetime(6)
27
+ );
28
+
29
+ INSERT INTO guard_crud_items (id, tenant_id, request_id, value_cents, version) VALUES
30
+ (1001, 'acme', 'update-precreated', 100, 1),
31
+ (1002, 'acme', 'delete-precreated', 200, 1),
32
+ (1011, 'acme', 'update-auto', 110, 1),
33
+ (1012, 'acme', 'delete-auto', 210, 1),
34
+ (1021, 'acme', 'update-ledger', 120, 1),
35
+ (1022, 'acme', 'delete-ledger', 220, 1),
36
+ (1031, 'globex', 'wrong-tenant', 230, 1),
37
+ (1041, 'acme', 'stale-version', 240, 2),
38
+ (1051, 'acme', 'crash-after-commit', 250, 1),
39
+ (1052, 'acme', 'crash-before-commit', 260, 1),
40
+ (1061, 'acme', 'concurrent-apply', 270, 1);
41
+ ALTER TABLE guard_crud_items AUTO_INCREMENT = 2001;
42
+
10
43
  INSERT INTO invoices (id, tenant_id, status, late_fee_cents, waiver_reason, updated_at) VALUES
11
44
  ('MYSQL-ACME', 'acme', 'overdue', 700, NULL, '2026-07-12 12:00:00.000000');
12
45
 
@@ -15,6 +48,20 @@ SELECT invoices.*, SLEEP(0.35) AS synthetic_delay
15
48
  FROM invoices;
16
49
 
17
50
  CREATE USER IF NOT EXISTS 'synapsor_reader'@'%' IDENTIFIED BY 'synapsor_reader_password';
51
+ CREATE USER IF NOT EXISTS 'synapsor_crud_precreated'@'%' IDENTIFIED BY 'synapsor_crud_precreated_password';
52
+ CREATE USER IF NOT EXISTS 'synapsor_crud_auto'@'%' IDENTIFIED BY 'synapsor_crud_auto_password';
53
+ CREATE USER IF NOT EXISTS 'synapsor_crud_ledger'@'%' IDENTIFIED BY 'synapsor_crud_ledger_password';
18
54
  GRANT SELECT ON synapsor_fleet.invoices TO 'synapsor_reader'@'%';
19
55
  GRANT SELECT ON synapsor_fleet.slow_invoices TO 'synapsor_reader'@'%';
56
+ GRANT SELECT, INSERT, UPDATE, DELETE ON synapsor_fleet.guard_crud_items TO 'synapsor_crud_precreated'@'%';
57
+ GRANT SELECT, INSERT, UPDATE, DELETE ON synapsor_fleet.guard_crud_items TO 'synapsor_crud_auto'@'%';
58
+ GRANT SELECT, INSERT, UPDATE, DELETE ON synapsor_fleet.guard_crud_items TO 'synapsor_crud_ledger'@'%';
59
+ GRANT TRIGGER ON synapsor_fleet.guard_crud_items TO 'synapsor_crud_precreated'@'%';
60
+ GRANT TRIGGER ON synapsor_fleet.guard_crud_items TO 'synapsor_crud_auto'@'%';
61
+ GRANT TRIGGER ON synapsor_fleet.guard_crud_items TO 'synapsor_crud_ledger'@'%';
62
+ GRANT PROCESS ON *.* TO 'synapsor_crud_precreated'@'%';
63
+ GRANT PROCESS ON *.* TO 'synapsor_crud_auto'@'%';
64
+ GRANT PROCESS ON *.* TO 'synapsor_crud_ledger'@'%';
65
+ GRANT SELECT, INSERT, UPDATE ON synapsor_fleet.synapsor_receipts_precreated TO 'synapsor_crud_precreated'@'%';
66
+ GRANT SELECT, INSERT, UPDATE, CREATE ON synapsor_fleet.* TO 'synapsor_crud_auto'@'%';
20
67
  FLUSH PRIVILEGES;
@@ -25,10 +25,47 @@ CREATE TABLE public.synthetic_handler_receipts (
25
25
  created_at timestamptz NOT NULL DEFAULT now()
26
26
  );
27
27
 
28
+ CREATE TABLE public.guard_crud_items (
29
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
30
+ tenant_id text NOT NULL,
31
+ request_id text NOT NULL,
32
+ value_cents integer NOT NULL,
33
+ version bigint NOT NULL DEFAULT 1,
34
+ UNIQUE (tenant_id, request_id)
35
+ );
36
+
37
+ CREATE SCHEMA synapsor_precreated;
38
+ CREATE TABLE synapsor_precreated.receipts (
39
+ idempotency_key text PRIMARY KEY,
40
+ job_id text UNIQUE NOT NULL,
41
+ proposal_id text NOT NULL,
42
+ status text NOT NULL,
43
+ result_hash text,
44
+ created_at timestamptz NOT NULL DEFAULT now(),
45
+ completed_at timestamptz
46
+ );
47
+ CREATE SCHEMA synapsor_auto;
48
+
49
+ INSERT INTO public.guard_crud_items (id, tenant_id, request_id, value_cents, version) VALUES
50
+ (1001, 'acme', 'update-precreated', 100, 1),
51
+ (1002, 'acme', 'delete-precreated', 200, 1),
52
+ (1011, 'acme', 'update-auto', 110, 1),
53
+ (1012, 'acme', 'delete-auto', 210, 1),
54
+ (1021, 'acme', 'update-ledger', 120, 1),
55
+ (1022, 'acme', 'delete-ledger', 220, 1),
56
+ (1031, 'globex', 'wrong-tenant', 230, 1),
57
+ (1041, 'acme', 'stale-version', 240, 2),
58
+ (1051, 'acme', 'crash-after-commit', 250, 1),
59
+ (1052, 'acme', 'crash-before-commit', 260, 1),
60
+ (1061, 'acme', 'concurrent-apply', 270, 1);
61
+ SELECT setval(pg_get_serial_sequence('public.guard_crud_items', 'id'), 2000, true);
62
+
28
63
  INSERT INTO public.invoices (id, tenant_id, status, late_fee_cents, waiver_reason, updated_at) VALUES
29
64
  ('INV-ACME', 'acme', 'overdue', 2500, NULL, '2026-07-12T12:00:00Z'),
30
65
  ('INV-GLOBEX', 'globex', 'overdue', 3100, NULL, '2026-07-12T12:00:00Z'),
31
66
  ('INV-RATECO', 'rateco', 'overdue', 900, NULL, '2026-07-12T12:00:00Z'),
67
+ ('INV-BATCH-A', 'acme', 'overdue', 1100, NULL, '2026-07-12T12:00:00Z'),
68
+ ('INV-BATCH-B', 'acme', 'overdue', 1300, NULL, '2026-07-12T12:00:00Z'),
32
69
  ('INV-QUORUM-RACE', 'acme', 'overdue', 1400, NULL, '2026-07-12T12:00:00Z'),
33
70
  ('INV-WORKER-RACE', 'acme', 'overdue', 1600, NULL, '2026-07-12T12:00:00Z'),
34
71
  ('INV-DEAD-REQUEUE', 'acme', 'overdue', 1700, NULL, '2026-07-12T12:00:00Z'),
@@ -53,6 +90,9 @@ DO $$
53
90
  BEGIN
54
91
  CREATE ROLE synapsor_reader LOGIN PASSWORD 'synapsor_reader_password';
55
92
  CREATE ROLE synapsor_writer LOGIN PASSWORD 'synapsor_writer_password';
93
+ CREATE ROLE synapsor_crud_precreated LOGIN PASSWORD 'synapsor_crud_precreated_password';
94
+ CREATE ROLE synapsor_crud_auto LOGIN PASSWORD 'synapsor_crud_auto_password';
95
+ CREATE ROLE synapsor_crud_ledger LOGIN PASSWORD 'synapsor_crud_ledger_password';
56
96
  EXCEPTION WHEN duplicate_object THEN NULL;
57
97
  END
58
98
  $$;
@@ -64,6 +104,14 @@ GRANT SELECT ON public.slow_invoices TO synapsor_reader;
64
104
  GRANT UPDATE (late_fee_cents, waiver_reason, updated_at) ON public.invoices TO synapsor_writer;
65
105
  GRANT SELECT, INSERT, UPDATE ON public.synapsor_writeback_receipts TO synapsor_writer;
66
106
 
107
+ GRANT CONNECT ON DATABASE synapsor_fleet TO synapsor_crud_precreated, synapsor_crud_auto, synapsor_crud_ledger;
108
+ GRANT USAGE ON SCHEMA public TO synapsor_crud_precreated, synapsor_crud_auto, synapsor_crud_ledger;
109
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.guard_crud_items TO synapsor_crud_precreated, synapsor_crud_auto, synapsor_crud_ledger;
110
+ GRANT USAGE, SELECT ON SEQUENCE public.guard_crud_items_id_seq TO synapsor_crud_precreated, synapsor_crud_auto, synapsor_crud_ledger;
111
+ GRANT USAGE ON SCHEMA synapsor_precreated TO synapsor_crud_precreated;
112
+ GRANT SELECT, INSERT, UPDATE ON synapsor_precreated.receipts TO synapsor_crud_precreated;
113
+ GRANT USAGE, CREATE ON SCHEMA synapsor_auto TO synapsor_crud_auto;
114
+
67
115
  -- Docker's Postgres entrypoint briefly starts a temporary postmaster while it
68
116
  -- runs init scripts. The Compose health check compares this timestamp with the
69
117
  -- current postmaster start time so dependents wait for the final server.
@@ -6,6 +6,12 @@ This example shows three enforcement tiers on one support action:
6
6
  - `$100` (`10000` cents): saved as `pending_review` until a local operator approves.
7
7
  - `$1000` (`100000` cents): rejected before proposal creation by the contract bound.
8
8
 
9
+ It also shows native guarded single-row INSERT through
10
+ `support.propose_plan_credit_record`. Runner supplies the trusted tenant and a
11
+ proposal-derived request ID; the database enforces `(tenant_id, request_id)` as
12
+ a unique deduplication key. The existing UPDATE scenario above remains the
13
+ policy-tier demonstration.
14
+
9
15
  ## Prerequisites
10
16
 
11
17
  - Docker
@@ -132,6 +138,30 @@ synapsor-runner apply latest --config examples/support-plan-credit/synapsor.runn
132
138
  The live smoke test mutates `updated_at` before apply and proves stale proposals
133
139
  return `VERSION_CONFLICT` without changing the source row.
134
140
 
141
+ Create one reviewed credit record with native INSERT:
142
+
143
+ ```bash
144
+ synapsor-runner propose support.propose_plan_credit_record \
145
+ --config examples/support-plan-credit/synapsor.runner.json \
146
+ --store ./tmp/support-plan-credit/local.db \
147
+ --json '{"customer_id":"CUS-3001","credit_cents":2500,"reason":"SLA outage ticket SUP-481"}'
148
+
149
+ synapsor-runner proposals approve latest --yes \
150
+ --config examples/support-plan-credit/synapsor.runner.json \
151
+ --store ./tmp/support-plan-credit/local.db
152
+ synapsor-runner apply latest \
153
+ --config examples/support-plan-credit/synapsor.runner.json \
154
+ --store ./tmp/support-plan-credit/local.db
155
+ synapsor-runner replay show latest --store ./tmp/support-plan-credit/local.db
156
+ ```
157
+
158
+ The insert is exactly one row. The model cannot set `tenant_id`, `request_id`,
159
+ table names, or columns. Retry is classified from the atomic source receipt and
160
+ the source-unique deduplication key. Use an
161
+ [app-owned executor](../../docs/writeback-executors.md) for multi-table writes,
162
+ external effects, UPSERT, or business actions that cannot fit this one-row
163
+ boundary.
164
+
135
165
  Run the complete Docker-backed scenario, including all three policy tiers and
136
166
  the stale-row conflict:
137
167
 
@@ -153,6 +183,7 @@ The contract keeps these fields out:
153
183
  ```text
154
184
  support.inspect_customer
155
185
  support.propose_plan_credit
186
+ support.propose_plan_credit_record
156
187
  auto-approval: enabled
157
188
  ```
158
189
 
@@ -51,10 +51,36 @@ CREATE CAPABILITY support.propose_plan_credit
51
51
  WRITEBACK DIRECT SQL
52
52
  END
53
53
 
54
+ CREATE CAPABILITY support.propose_plan_credit_record
55
+ DESCRIPTION 'Propose creating one bounded plan-credit record with source-enforced deduplication.'
56
+ RETURNS HINT 'Returns a proposal id for one new account_credits row; source database unchanged until human approval and guarded apply.'
57
+ USING CONTEXT support_agent_context
58
+ SOURCE local_postgres
59
+ ON public.account_credits
60
+ PRIMARY KEY id
61
+ TENANT KEY tenant_id
62
+ ARG customer_id STRING REQUIRED MAX LENGTH 128 DESCRIPTION 'Customer id such as CUS-3001.'
63
+ ARG credit_cents NUMBER REQUIRED MIN 1 MAX 50000 DESCRIPTION 'Requested plan credit in cents.'
64
+ ARG reason TEXT REQUIRED MAX LENGTH 500 DESCRIPTION 'Business reason tied to support evidence.'
65
+ ALLOW READ id, tenant_id, request_id, customer_id, amount_cents, reason, created_at
66
+ REQUIRE EVIDENCE
67
+ MAX ROWS 1
68
+ PROPOSE ACTION create_plan_credit INSERT
69
+ DEDUP KEY tenant_id = TRUSTED TENANT, request_id = PROPOSAL ID
70
+ ALLOW WRITE customer_id, amount_cents, reason
71
+ PATCH customer_id = ARG customer_id
72
+ PATCH amount_cents = ARG credit_cents
73
+ PATCH reason = ARG reason
74
+ BOUND amount_cents 1..50000
75
+ APPROVAL ROLE support_reviewer
76
+ WRITEBACK DIRECT SQL
77
+ END
78
+
54
79
  CREATE AGENT WORKFLOW support.plan_credit_request
55
80
  USING CONTEXT support_agent_context
56
81
  ALLOW CAPABILITY support.inspect_customer
57
82
  ALLOW CAPABILITY support.propose_plan_credit
83
+ ALLOW CAPABILITY support.propose_plan_credit_record
58
84
  REQUIRE EVIDENCE
59
85
  APPROVAL REQUIRED ROLE support_reviewer
60
86
  CHECKPOINT PROPOSAL ONLY
@@ -3,6 +3,7 @@ Synapsor tools preview: ok
3
3
  Exposed to MCP:
4
4
  - support.inspect_customer
5
5
  - support.propose_plan_credit
6
+ - support.propose_plan_credit_record
6
7
 
7
8
  Not exposed to MCP:
8
9
  - execute_sql / raw query tools
@@ -24,6 +24,17 @@ CREATE TABLE IF NOT EXISTS public.synapsor_writeback_receipts (
24
24
  completed_at timestamptz
25
25
  );
26
26
 
27
+ CREATE TABLE IF NOT EXISTS public.account_credits (
28
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
29
+ tenant_id text NOT NULL,
30
+ request_id text NOT NULL,
31
+ customer_id text NOT NULL,
32
+ amount_cents integer NOT NULL CHECK (amount_cents BETWEEN 1 AND 50000),
33
+ reason text NOT NULL,
34
+ created_at timestamptz NOT NULL DEFAULT now(),
35
+ CONSTRAINT account_credits_tenant_request_unique UNIQUE (tenant_id, request_id)
36
+ );
37
+
27
38
  INSERT INTO public.customers (
28
39
  id,
29
40
  tenant_id,
@@ -97,6 +108,8 @@ $$;
97
108
 
98
109
  GRANT CONNECT ON DATABASE synapsor_runner_plan_credit TO synapsor_reader, synapsor_writer;
99
110
  GRANT USAGE ON SCHEMA public TO synapsor_reader, synapsor_writer;
100
- GRANT SELECT ON public.customers TO synapsor_reader, synapsor_writer;
111
+ GRANT SELECT ON public.customers, public.account_credits TO synapsor_reader, synapsor_writer;
101
112
  GRANT UPDATE (plan_credit_cents, credit_reason, updated_at) ON public.customers TO synapsor_writer;
113
+ GRANT INSERT (tenant_id, request_id, customer_id, amount_cents, reason) ON public.account_credits TO synapsor_writer;
114
+ GRANT USAGE, SELECT ON SEQUENCE public.account_credits_id_seq TO synapsor_writer;
102
115
  GRANT SELECT, INSERT, UPDATE ON public.synapsor_writeback_receipts TO synapsor_writer;
@@ -139,6 +139,105 @@
139
139
  "credit_reason",
140
140
  "updated_at"
141
141
  ]
142
+ },
143
+ {
144
+ "args": {
145
+ "credit_cents": {
146
+ "description": "Requested plan credit in cents.",
147
+ "maximum": 50000,
148
+ "minimum": 1,
149
+ "required": true,
150
+ "type": "number"
151
+ },
152
+ "customer_id": {
153
+ "description": "Customer id such as CUS-3001.",
154
+ "max_length": 128,
155
+ "required": true,
156
+ "type": "string"
157
+ },
158
+ "reason": {
159
+ "description": "Business reason tied to support evidence.",
160
+ "max_length": 500,
161
+ "required": true,
162
+ "type": "string"
163
+ }
164
+ },
165
+ "context": "support_agent_context",
166
+ "description": "Propose creating one bounded plan-credit record with source-enforced deduplication.",
167
+ "evidence": {
168
+ "query_audit": true,
169
+ "required": true
170
+ },
171
+ "kind": "proposal",
172
+ "max_rows": 1,
173
+ "name": "support.propose_plan_credit_record",
174
+ "proposal": {
175
+ "action": "create_plan_credit",
176
+ "allowed_fields": [
177
+ "customer_id",
178
+ "amount_cents",
179
+ "reason"
180
+ ],
181
+ "approval": {
182
+ "mode": "human",
183
+ "required_role": "support_reviewer"
184
+ },
185
+ "conflict_guard": {
186
+ "weak_guard_ack": true
187
+ },
188
+ "numeric_bounds": {
189
+ "amount_cents": {
190
+ "maximum": 50000,
191
+ "minimum": 1
192
+ }
193
+ },
194
+ "operation": {
195
+ "deduplication": {
196
+ "components": [
197
+ {
198
+ "column": "tenant_id",
199
+ "source": "trusted_tenant"
200
+ },
201
+ {
202
+ "column": "request_id",
203
+ "source": "proposal_id"
204
+ }
205
+ ]
206
+ },
207
+ "kind": "insert"
208
+ },
209
+ "patch": {
210
+ "amount_cents": {
211
+ "from_arg": "credit_cents"
212
+ },
213
+ "customer_id": {
214
+ "from_arg": "customer_id"
215
+ },
216
+ "reason": {
217
+ "from_arg": "reason"
218
+ }
219
+ },
220
+ "writeback": {
221
+ "mode": "direct_sql"
222
+ }
223
+ },
224
+ "returns_hint": "Returns a proposal id for one new account_credits row; source database unchanged until human approval and guarded apply.",
225
+ "source": "local_postgres",
226
+ "subject": {
227
+ "primary_key": "id",
228
+ "schema": "public",
229
+ "table": "account_credits",
230
+ "tenant_key": "tenant_id"
231
+ },
232
+ "visible_fields": [
233
+ "id",
234
+ "tenant_id",
235
+ "request_id",
236
+ "customer_id",
237
+ "amount_cents",
238
+ "reason",
239
+ "created_at"
240
+ ]
142
241
  }
143
242
  ],
144
243
  "contexts": [
@@ -196,7 +295,8 @@
196
295
  {
197
296
  "allowed_capabilities": [
198
297
  "support.inspect_customer",
199
- "support.propose_plan_credit"
298
+ "support.propose_plan_credit",
299
+ "support.propose_plan_credit_record"
200
300
  ],
201
301
  "approval": {
202
302
  "required": true,
@@ -12,7 +12,13 @@
12
12
  "engine": "postgres",
13
13
  "read_url_env": "PLAN_CREDIT_POSTGRES_READ_URL",
14
14
  "write_url_env": "PLAN_CREDIT_POSTGRES_WRITE_URL",
15
- "statement_timeout_ms": 3000
15
+ "statement_timeout_ms": 3000,
16
+ "receipts": {
17
+ "authority": "source_db",
18
+ "provisioning": "precreated",
19
+ "schema": "public",
20
+ "table": "synapsor_writeback_receipts"
21
+ }
16
22
  }
17
23
  }
18
24
  }