@synapsor/runner 1.1.1 → 1.4.0

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 (67) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +26 -7
  3. package/THREAT_MODEL.md +7 -4
  4. package/dist/cli.d.ts +5 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/local-ui.d.ts.map +1 -1
  7. package/dist/runner.mjs +8060 -2615
  8. package/docs/README.md +19 -3
  9. package/docs/capability-authoring.md +20 -8
  10. package/docs/conformance.md +12 -0
  11. package/docs/current-scope.md +12 -4
  12. package/docs/doctor.md +21 -7
  13. package/docs/dsl-reference.md +91 -5
  14. package/docs/getting-started-own-database.md +26 -3
  15. package/docs/guarded-crud-writeback.md +218 -0
  16. package/docs/limitations.md +30 -9
  17. package/docs/oss-vs-cloud.md +1 -1
  18. package/docs/production.md +52 -20
  19. package/docs/release-notes.md +82 -2
  20. package/docs/release-policy.md +1 -1
  21. package/docs/result-envelope-v2.md +20 -6
  22. package/docs/result-envelope-v4.md +47 -0
  23. package/docs/reversible-change-sets.md +151 -0
  24. package/docs/rfcs/004-guarded-crud-receipt-authority.md +165 -0
  25. package/docs/rfcs/005-bounded-set-writeback.md +164 -0
  26. package/docs/rfcs/006-reviewed-reversible-change-sets.md +152 -0
  27. package/docs/runner-config-reference.md +83 -9
  28. package/docs/running-a-runner-fleet.md +2 -1
  29. package/docs/security-boundary.md +29 -9
  30. package/docs/writeback-executors.md +12 -11
  31. package/examples/runner-fleet/seed/mysql.sql +47 -0
  32. package/examples/runner-fleet/seed/postgres.sql +48 -0
  33. package/examples/support-plan-credit/README.md +31 -0
  34. package/examples/support-plan-credit/contract.synapsor.sql +26 -0
  35. package/examples/support-plan-credit/expected-output/tools-preview.txt +1 -0
  36. package/examples/support-plan-credit/seed/001_seed.sql +14 -1
  37. package/examples/support-plan-credit/synapsor.contract.json +101 -1
  38. package/examples/support-plan-credit/synapsor.runner.json +7 -1
  39. package/fixtures/protocol/MANIFEST.json +131 -1
  40. package/fixtures/protocol/change-set.bounded-update.v3.json +57 -0
  41. package/fixtures/protocol/change-set.delete.v2.json +31 -0
  42. package/fixtures/protocol/change-set.insert.v2.json +36 -0
  43. package/fixtures/protocol/change-set.update.v2.json +32 -0
  44. package/fixtures/protocol/compensation-change-set.update.v1.json +40 -0
  45. package/fixtures/protocol/execution-receipt.bounded-update-applied.v3.json +39 -0
  46. package/fixtures/protocol/execution-receipt.compensation-update-applied.v4.json +37 -0
  47. package/fixtures/protocol/execution-receipt.delete-applied.v2.json +25 -0
  48. package/fixtures/protocol/execution-receipt.insert-applied.v2.json +24 -0
  49. package/fixtures/protocol/execution-receipt.reconciliation-required.v2.json +25 -0
  50. package/fixtures/protocol/execution-receipt.update-applied.v2.json +25 -0
  51. package/fixtures/protocol/writeback-job.bounded-update.v3.json +43 -0
  52. package/fixtures/protocol/writeback-job.compensation-update.v4.json +34 -0
  53. package/fixtures/protocol/writeback-job.delete.v2.json +22 -0
  54. package/fixtures/protocol/writeback-job.insert.v2.json +28 -0
  55. package/fixtures/protocol/writeback-job.update.v2.json +24 -0
  56. package/package.json +1 -1
  57. package/schemas/change-set.v2.schema.json +245 -0
  58. package/schemas/change-set.v3.schema.json +112 -0
  59. package/schemas/compensation-change-set.v1.schema.json +32 -0
  60. package/schemas/execution-receipt.v2.schema.json +106 -0
  61. package/schemas/execution-receipt.v3.schema.json +40 -0
  62. package/schemas/execution-receipt.v4.schema.json +22 -0
  63. package/schemas/inverse-descriptor.v1.schema.json +46 -0
  64. package/schemas/synapsor.runner.schema.json +132 -2
  65. package/schemas/writeback-job.v2.schema.json +238 -0
  66. package/schemas/writeback-job.v3.schema.json +53 -0
  67. package/schemas/writeback-job.v4.schema.json +17 -0
@@ -67,17 +67,35 @@ outside the model-facing MCP server and verifies:
67
67
  - allowed mutable columns;
68
68
  - conflict/version guard;
69
69
  - idempotency key;
70
+ - operation-specific version or source-unique deduplication guard;
70
71
  - job expiry;
71
- - exactly one affected row.
72
+ - exactly one reviewed row or every member of one bounded frozen set.
72
73
 
73
74
  If any authority check cannot be verified, the write fails closed.
74
75
 
75
76
  For direct SQL writeback, the writer connection is the env var named by the
76
- source `write_url_env` in `synapsor.runner.json`. Direct SQL writeback writes an
77
- administrator-created `synapsor_writeback_receipts` table for idempotency and
78
- replay. The writer needs `SELECT`/`INSERT`/`UPDATE` on that table, but not schema
79
- `CREATE`. If your database policy forbids the receipt table, use an app-owned
80
- `http_handler` or `command_handler` executor instead.
77
+ source `write_url_env` in `synapsor.runner.json`. With `source_db` receipt
78
+ authority, the source mutation and receipt commit atomically; the receipt table
79
+ can be administrator-precreated or explicitly auto-migrated. With
80
+ `runner_ledger` authority, Runner creates no source receipt table, but a crash
81
+ after source commit and before ledger completion can require verified operator
82
+ reconciliation. It is not distributed exactly-once. See
83
+ [Guarded Single-Row CRUD Writeback](guarded-crud-writeback.md).
84
+
85
+ Bounded-set direct writeback additionally requires a contract-fixed predicate
86
+ or complete reviewed item list, `MAX ROWS` plus aggregate bounds,
87
+ human/operator approval, exact frozen identities and versions, deterministic
88
+ locks, one atomic source transaction, and per-member receipt digests. The
89
+ model cannot supply the predicate or cap. See [Bounded Set
90
+ Writeback](bounded-set-writeback.md).
91
+
92
+ When a direct SQL capability opts into reviewed reversibility, an applied
93
+ receipt may carry a bounded inverse containing only trusted identity, version,
94
+ and reviewed writable values. `revert` is operator-only and creates a new
95
+ proposal; it never mutates or approves. Compensation rechecks the exact forward
96
+ result under the trusted tenant and uses the normal approval, receipt,
97
+ reconciliation, and replay boundaries. See [Reviewed Reversible Change
98
+ Sets](reversible-change-sets.md).
81
99
 
82
100
  When a capability uses an `http_handler` or `command_handler` executor, the
83
101
  same approval boundary applies. The runner sends a structured proposal/job
@@ -96,6 +114,8 @@ approve/reject actions. It does not expose raw SQL, database URLs, write
96
114
  credentials, approval tools, commit tools, or controls that widen reviewed
97
115
  tables/columns.
98
116
 
99
- Synapsor Runner supports reviewed single-row business actions only in the
100
- current alpha. It does not support arbitrary SQL, DDL, `INSERT`, `DELETE`,
101
- `UPSERT`, or multi-row updates.
117
+ Synapsor Runner supports reviewed single-row CRUD and the narrowly bounded set
118
+ path documented above. It does not support arbitrary SQL, DDL, UPSERT,
119
+ model-generated predicates, unbounded sets, or cross-table direct writeback.
120
+ Hard DELETE fails closed when cascades, write triggers, or required metadata
121
+ visibility prevent a bounded-effect proof.
@@ -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
  }