@synapsor/runner 0.1.0-alpha.9 → 0.1.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 +189 -0
- package/README.md +949 -164
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +2982 -238
- package/docs/README.md +90 -15
- package/docs/app-owned-executors.md +38 -0
- package/docs/capability-authoring.md +265 -0
- package/docs/cloud-mode.md +24 -0
- package/docs/current-scope.md +29 -0
- package/docs/dependency-license-inventory.md +35 -0
- package/docs/doctor.md +98 -0
- package/docs/getting-started-own-database.md +131 -46
- package/docs/handler-helper.md +228 -0
- package/docs/http-mcp.md +85 -17
- package/docs/licensing.md +36 -0
- package/docs/local-mode.md +44 -25
- package/docs/mcp-audit.md +8 -8
- package/docs/mcp-client-setup.md +59 -21
- package/docs/openai-agents-sdk.md +57 -0
- package/docs/recipes.md +6 -6
- package/docs/release-notes.md +348 -0
- package/docs/release-policy.md +125 -0
- package/docs/result-envelope-v2.md +151 -0
- package/docs/rfcs/001-result-envelope-v2.md +143 -0
- package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
- package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
- package/docs/store-lifecycle.md +83 -0
- package/docs/troubleshooting-first-run.md +6 -6
- package/docs/use-your-own-database.md +18 -0
- package/docs/writeback-executors.md +92 -1
- package/examples/app-owned-writeback/README.md +128 -0
- package/examples/app-owned-writeback/business-actions.md +221 -0
- package/examples/app-owned-writeback/command-handler.mjs +55 -0
- package/examples/app-owned-writeback/node-fastify-handler.mjs +64 -0
- package/examples/app-owned-writeback/python-fastapi-handler.py +66 -0
- package/examples/claude-desktop-postgres/Makefile +6 -0
- package/examples/claude-desktop-postgres/README.md +40 -0
- package/examples/cursor-postgres/Makefile +6 -0
- package/examples/cursor-postgres/README.md +30 -0
- package/examples/mcp-postgres-billing-app-handler/README.md +94 -0
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +123 -0
- package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
- package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
- package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
- package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
- package/examples/mysql-refund-agent/Makefile +4 -0
- package/examples/mysql-refund-agent/README.md +36 -0
- package/examples/openai-agents-http/Makefile +6 -0
- package/examples/openai-agents-http/README.md +33 -12
- package/examples/openai-agents-http/agent.py +29 -65
- package/examples/openai-agents-stdio/Makefile +6 -0
- package/examples/openai-agents-stdio/README.md +24 -6
- package/examples/openai-agents-stdio/agent.py +4 -2
- package/examples/raw-sql-vs-synapsor/Makefile +11 -0
- package/examples/raw-sql-vs-synapsor/README.md +41 -0
- package/examples/reference-support-billing-app/README.md +16 -16
- package/examples/reference-support-billing-app/mcp-client.generic.json +1 -1
- package/examples/support-billing-agent/Makefile +19 -0
- package/examples/support-billing-agent/README.md +89 -0
- package/examples/support-billing-agent/app/README.md +13 -0
- package/examples/support-billing-agent/db/schema.sql +91 -0
- package/examples/support-billing-agent/db/seed.sql +43 -0
- package/examples/support-billing-agent/docker-compose.yml +13 -0
- package/examples/support-billing-agent/scripts/run-demo.sh +15 -0
- package/examples/support-billing-agent/synapsor.runner.json +233 -0
- package/fixtures/benchmark/mcp-efficiency.json +53 -0
- package/fixtures/benchmark/mcp-efficiency.txt +25 -0
- package/fixtures/protocol/MANIFEST.json +54 -0
- package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
- package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
- package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
- package/fixtures/protocol/runner-registration.v1.json +22 -0
- package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
- package/package.json +27 -4
- package/schemas/change-set.v1.schema.json +140 -0
- package/schemas/execution-receipt.v1.schema.json +34 -0
- package/schemas/onboarding-selection.v1.schema.json +132 -0
- package/schemas/runner-registration.v1.schema.json +48 -0
- package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
- package/schemas/synapsor.app-handler-request.v1.json +119 -0
- package/schemas/synapsor.runner.schema.json +415 -0
- package/schemas/writeback-job.v1.schema.json +121 -0
|
@@ -56,6 +56,19 @@ application schema, create a dedicated schema/database for receipts where your
|
|
|
56
56
|
database policy allows it, or use `http_handler`/`command_handler` so your
|
|
57
57
|
application owns receipt storage and business writes.
|
|
58
58
|
|
|
59
|
+
Use the helper commands before enabling direct SQL writeback:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx -y -p @synapsor/runner synapsor-runner writeback doctor --config ./synapsor.runner.json
|
|
63
|
+
npx -y -p @synapsor/runner synapsor-runner writeback migration --engine postgres
|
|
64
|
+
npx -y -p @synapsor/runner synapsor-runner writeback grants --engine postgres --writer-role app_writer
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`writeback doctor --check-db` connects with the configured writer credential and
|
|
68
|
+
checks the receipt table path. That check can create the receipt table if the
|
|
69
|
+
writer has `CREATE`, so run it only against staging/disposable databases or
|
|
70
|
+
after reviewing the printed migration/grants.
|
|
71
|
+
|
|
59
72
|
## `http_handler`
|
|
60
73
|
|
|
61
74
|
Use `http_handler` when your application/API should own business execution.
|
|
@@ -75,6 +88,7 @@ literal values.
|
|
|
75
88
|
"type": "bearer_env",
|
|
76
89
|
"token_env": "SYNAPSOR_BILLING_HANDLER_TOKEN"
|
|
77
90
|
},
|
|
91
|
+
"signing_secret_env": "SYNAPSOR_BILLING_HANDLER_SIGNING_SECRET",
|
|
78
92
|
"timeout_ms": 5000
|
|
79
93
|
}
|
|
80
94
|
},
|
|
@@ -91,7 +105,7 @@ literal values.
|
|
|
91
105
|
Run after approval:
|
|
92
106
|
|
|
93
107
|
```bash
|
|
94
|
-
npx -y -p @synapsor/runner
|
|
108
|
+
npx -y -p @synapsor/runner synapsor-runner apply \
|
|
95
109
|
--proposal wrp_123 \
|
|
96
110
|
--config ./synapsor.runner.json \
|
|
97
111
|
--store ./.synapsor/local.db
|
|
@@ -101,6 +115,24 @@ The handler receives proposal fields, the exact patch, evidence metadata,
|
|
|
101
115
|
guards, and an idempotency key. It does not receive arbitrary model SQL or DB
|
|
102
116
|
credentials from Synapsor Runner.
|
|
103
117
|
|
|
118
|
+
> **Important:** your app handler owns the final business write. Runner creates
|
|
119
|
+
> the proposal and calls your handler only after approval, but your handler must
|
|
120
|
+
> still enforce tenant/scope checks, expected-version or conflict guards,
|
|
121
|
+
> idempotency keys, allowed business actions, transaction/rollback, and safe
|
|
122
|
+
> error receipts. If you skip those checks, you can reintroduce cross-tenant
|
|
123
|
+
> writes, lost updates, or duplicate writes. Keep handler credentials out of MCP.
|
|
124
|
+
|
|
125
|
+
When `signing_secret_env` is set, Runner signs the exact JSON body with HMAC
|
|
126
|
+
SHA-256 and sends:
|
|
127
|
+
|
|
128
|
+
- `X-Synapsor-Signature: sha256=...`
|
|
129
|
+
- `X-Synapsor-Issued-At: ...`
|
|
130
|
+
- `X-Synapsor-Proposal-Id: ...`
|
|
131
|
+
- `Idempotency-Key: ...`
|
|
132
|
+
|
|
133
|
+
Use signing for any handler that is not strictly loopback-only and protected by
|
|
134
|
+
another trusted boundary.
|
|
135
|
+
|
|
104
136
|
Handler responses:
|
|
105
137
|
|
|
106
138
|
```json
|
|
@@ -126,6 +158,56 @@ receipt is stored in replay.
|
|
|
126
158
|
Use your application/API for business logic. Use Synapsor Runner for proposal,
|
|
127
159
|
approval, evidence, policy boundary, and replay.
|
|
128
160
|
|
|
161
|
+
For TypeScript services, use the source-level helper in `packages/handler`.
|
|
162
|
+
It verifies bearer/HMAC auth, parses the request, locks the target row with the
|
|
163
|
+
tenant guard, checks the expected version, handles idempotency, wraps the
|
|
164
|
+
business effect in a transaction, and returns safe receipts without raw driver
|
|
165
|
+
errors. See [Handler Helper](handler-helper.md).
|
|
166
|
+
|
|
167
|
+
This is the recommended path for writes that are richer than the current
|
|
168
|
+
`sql_update` scope, such as:
|
|
169
|
+
|
|
170
|
+
- creating a refund review;
|
|
171
|
+
- inserting an account credit row;
|
|
172
|
+
- opening a support ticket;
|
|
173
|
+
- updating multiple related rows in one app transaction.
|
|
174
|
+
|
|
175
|
+
Starter templates are in:
|
|
176
|
+
|
|
177
|
+
```text
|
|
178
|
+
examples/app-owned-writeback/
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Concrete business-action examples are in:
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
examples/app-owned-writeback/business-actions.md
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The full disposable Postgres account-credit demo is in:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
examples/mcp-postgres-billing-app-handler/
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
It proves the rich-write path end to end: the model creates a proposal, the
|
|
194
|
+
source DB is unchanged before approval, the app-owned handler inserts an
|
|
195
|
+
`account_credits` row after approval, retry is idempotent, and replay stores the
|
|
196
|
+
handler receipt.
|
|
197
|
+
|
|
198
|
+
Or generate one into your app:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
npx -y -p @synapsor/runner synapsor-runner handler template node-fastify \
|
|
202
|
+
--output ./synapsor-writeback-handler.mjs
|
|
203
|
+
|
|
204
|
+
npx -y -p @synapsor/runner synapsor-runner handler template python-fastapi \
|
|
205
|
+
--output ./synapsor_writeback_handler.py
|
|
206
|
+
|
|
207
|
+
npx -y -p @synapsor/runner synapsor-runner handler template command \
|
|
208
|
+
--output ./synapsor-command-handler.mjs
|
|
209
|
+
```
|
|
210
|
+
|
|
129
211
|
## `command_handler`
|
|
130
212
|
|
|
131
213
|
`command_handler` is a local integration path for scripts:
|
|
@@ -145,6 +227,15 @@ approval, evidence, policy boundary, and replay.
|
|
|
145
227
|
The command receives the same structured JSON request on stdin and should print
|
|
146
228
|
a JSON receipt body on stdout.
|
|
147
229
|
|
|
230
|
+
> **Important:** command handlers have the same responsibility as HTTP
|
|
231
|
+
> handlers. Re-check tenant/scope, expected-version or conflict guard,
|
|
232
|
+
> idempotency, allowed business action, transaction/rollback, and safe error
|
|
233
|
+
> receipt before mutating state. Otherwise the script can reintroduce
|
|
234
|
+
> cross-tenant writes, lost updates, or duplicate writes.
|
|
235
|
+
|
|
236
|
+
Use `examples/app-owned-writeback/command-handler.mjs` as a starting point when
|
|
237
|
+
your safest apply path is an app script or job runner.
|
|
238
|
+
|
|
148
239
|
## Safety Boundary
|
|
149
240
|
|
|
150
241
|
Executor secrets are never exposed over MCP. The model never receives:
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# App-Owned Writeback Templates
|
|
2
|
+
|
|
3
|
+
Use app-owned writeback when an approved Synapsor proposal should be executed
|
|
4
|
+
by your application service instead of Runner writing SQL directly.
|
|
5
|
+
|
|
6
|
+
This is the right path for rich business actions:
|
|
7
|
+
|
|
8
|
+
- create a refund review;
|
|
9
|
+
- insert an account credit row;
|
|
10
|
+
- open a support ticket;
|
|
11
|
+
- update multiple related rows through your app service.
|
|
12
|
+
|
|
13
|
+
The model-facing MCP tool still only creates a proposal. Approval happens
|
|
14
|
+
outside MCP. After approval, `synapsor-runner apply` sends a structured request
|
|
15
|
+
to your handler, and the handler returns an execution receipt for replay.
|
|
16
|
+
|
|
17
|
+
> **Important:** your app handler owns the final business write. Runner creates
|
|
18
|
+
> the proposal and calls your handler only after approval, but your handler must
|
|
19
|
+
> still enforce tenant/scope checks, expected-version or conflict guards,
|
|
20
|
+
> idempotency keys, allowed business actions, transaction/rollback, and safe
|
|
21
|
+
> error receipts. If you skip those checks, you can reintroduce cross-tenant
|
|
22
|
+
> writes, lost updates, or duplicate writes. Keep handler credentials out of MCP.
|
|
23
|
+
|
|
24
|
+
## Config Snippet
|
|
25
|
+
|
|
26
|
+
Add an executor and point one proposal capability at it:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"executors": {
|
|
31
|
+
"app_writeback_api": {
|
|
32
|
+
"type": "http_handler",
|
|
33
|
+
"url_env": "SYNAPSOR_APP_WRITEBACK_URL",
|
|
34
|
+
"method": "POST",
|
|
35
|
+
"auth": {
|
|
36
|
+
"type": "bearer_env",
|
|
37
|
+
"token_env": "SYNAPSOR_APP_WRITEBACK_TOKEN"
|
|
38
|
+
},
|
|
39
|
+
"signing_secret_env": "SYNAPSOR_APP_WRITEBACK_SIGNING_SECRET",
|
|
40
|
+
"timeout_ms": 5000
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"capabilities": [
|
|
44
|
+
{
|
|
45
|
+
"name": "refunds.propose_refund_review",
|
|
46
|
+
"kind": "proposal",
|
|
47
|
+
"executor": "app_writeback_api"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Run after approval:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
export SYNAPSOR_APP_WRITEBACK_URL="http://127.0.0.1:8787/synapsor/writeback"
|
|
57
|
+
export SYNAPSOR_APP_WRITEBACK_TOKEN="dev-handler-token"
|
|
58
|
+
|
|
59
|
+
synapsor-runner apply \
|
|
60
|
+
--proposal wrp_... \
|
|
61
|
+
--config ./synapsor.runner.json \
|
|
62
|
+
--store ./.synapsor/local.db
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Handler Request Shape
|
|
66
|
+
|
|
67
|
+
Your handler receives JSON like:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"schema_version": "synapsor.handler-writeback.v1",
|
|
72
|
+
"writeback_job_id": "hwb_wrp_...",
|
|
73
|
+
"proposal_id": "wrp_...",
|
|
74
|
+
"idempotency_key": "wrp_...",
|
|
75
|
+
"change_set": {
|
|
76
|
+
"action": "refunds.propose_refund_review",
|
|
77
|
+
"scope": {
|
|
78
|
+
"tenant_id": "acme",
|
|
79
|
+
"object_id": "INV-3001"
|
|
80
|
+
},
|
|
81
|
+
"before": {},
|
|
82
|
+
"patch": {},
|
|
83
|
+
"after": {},
|
|
84
|
+
"guards": {},
|
|
85
|
+
"evidence": {
|
|
86
|
+
"bundle_id": "ev_..."
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"executor": "app_writeback_api",
|
|
90
|
+
"dry_run": false
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Do not trust request fields blindly. Your service should re-check tenant,
|
|
95
|
+
principal, authorization, idempotency, row versions, and business rules before
|
|
96
|
+
mutating state.
|
|
97
|
+
|
|
98
|
+
## Handler Response Shape
|
|
99
|
+
|
|
100
|
+
Return one terminal receipt:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"status": "applied",
|
|
105
|
+
"rows_affected": 1,
|
|
106
|
+
"previous_version": "2026-06-20T14:31:08Z",
|
|
107
|
+
"new_version": "2026-06-20T14:34:19Z",
|
|
108
|
+
"source_database_mutated": true
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Allowed statuses:
|
|
113
|
+
|
|
114
|
+
- `applied`
|
|
115
|
+
- `already_applied`
|
|
116
|
+
- `conflict`
|
|
117
|
+
- `failed`
|
|
118
|
+
|
|
119
|
+
## Templates
|
|
120
|
+
|
|
121
|
+
- `node-fastify-handler.mjs`: HTTP handler template for a Node/Fastify service.
|
|
122
|
+
- `python-fastapi-handler.py`: HTTP handler template for a Python/FastAPI service.
|
|
123
|
+
- `command-handler.mjs`: local command handler template for scripts.
|
|
124
|
+
- `business-actions.md`: concrete examples for refund reviews, account
|
|
125
|
+
credits, support tickets, and multi-row app transactions.
|
|
126
|
+
|
|
127
|
+
Each template returns safe demo receipts and marks where your application
|
|
128
|
+
should run its own transaction.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# App-Owned Business Action Examples
|
|
2
|
+
|
|
3
|
+
These examples show the kinds of approved proposals an app-owned handler can
|
|
4
|
+
apply. They are intentionally not direct SQL examples. Your application service
|
|
5
|
+
owns the write transaction, re-checks authorization and row/version guards, and
|
|
6
|
+
returns a terminal receipt to Synapsor Runner.
|
|
7
|
+
|
|
8
|
+
Each request uses the same boundary:
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
model-facing MCP tool -> proposal
|
|
12
|
+
human/operator approval -> app-owned handler
|
|
13
|
+
handler transaction -> applied/conflict/failed receipt
|
|
14
|
+
local replay
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The handler must not trust request fields blindly. Re-check tenant,
|
|
18
|
+
principal/role, idempotency, row versions, and business policy before mutating
|
|
19
|
+
state.
|
|
20
|
+
|
|
21
|
+
## Create A Refund Review
|
|
22
|
+
|
|
23
|
+
Use this when an agent may request a refund review, but your application must
|
|
24
|
+
create the review record through normal business logic.
|
|
25
|
+
|
|
26
|
+
Capability:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
refunds.propose_refund_review(order_id, reason, requested_amount_cents)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Handler request:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"schema_version": "synapsor.handler-writeback.v1",
|
|
37
|
+
"writeback_job_id": "hwb_wrp_refund_001",
|
|
38
|
+
"proposal_id": "wrp_refund_001",
|
|
39
|
+
"idempotency_key": "wrp_refund_001",
|
|
40
|
+
"change_set": {
|
|
41
|
+
"action": "refunds.propose_refund_review",
|
|
42
|
+
"scope": {
|
|
43
|
+
"tenant_id": "acme",
|
|
44
|
+
"principal": "support_lead@example.com",
|
|
45
|
+
"object_type": "order",
|
|
46
|
+
"object_id": "ORD-3001"
|
|
47
|
+
},
|
|
48
|
+
"before": {
|
|
49
|
+
"order_status": "delivered",
|
|
50
|
+
"refund_review_id": null
|
|
51
|
+
},
|
|
52
|
+
"patch": {
|
|
53
|
+
"requested_amount_cents": 2500,
|
|
54
|
+
"reason": "duplicate charge"
|
|
55
|
+
},
|
|
56
|
+
"after": {
|
|
57
|
+
"refund_review_status": "pending_review"
|
|
58
|
+
},
|
|
59
|
+
"guards": {
|
|
60
|
+
"expected_version": {
|
|
61
|
+
"column": "updated_at",
|
|
62
|
+
"value": "2026-06-20T14:31:08Z"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"evidence": {
|
|
66
|
+
"bundle_id": "ev_refund_001"
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"executor": "app_writeback_api",
|
|
70
|
+
"dry_run": false
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Handler transaction sketch:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
BEGIN
|
|
78
|
+
verify principal can request refunds for tenant acme
|
|
79
|
+
verify order ORD-3001 still belongs to tenant acme
|
|
80
|
+
verify order.updated_at still matches expected_version
|
|
81
|
+
verify no receipt exists for idempotency_key
|
|
82
|
+
INSERT INTO refund_reviews (...)
|
|
83
|
+
INSERT INTO synapsor_app_receipts (...)
|
|
84
|
+
COMMIT
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Receipt:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"status": "applied",
|
|
92
|
+
"rows_affected": 1,
|
|
93
|
+
"new_object_id": "RR-9001",
|
|
94
|
+
"source_database_mutated": true
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Insert An Account Credit Row
|
|
99
|
+
|
|
100
|
+
Use this when the safe write is an append-only ledger/accounting operation.
|
|
101
|
+
|
|
102
|
+
Capability:
|
|
103
|
+
|
|
104
|
+
```text
|
|
105
|
+
credits.propose_account_credit(customer_id, amount_cents, reason)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Handler transaction sketch:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
BEGIN
|
|
112
|
+
verify customer belongs to trusted tenant
|
|
113
|
+
verify amount is within app policy
|
|
114
|
+
verify idempotency_key has not already created a credit
|
|
115
|
+
INSERT INTO account_credits (...)
|
|
116
|
+
UPDATE customers SET credit_balance_cents = credit_balance_cents + amount
|
|
117
|
+
INSERT INTO synapsor_app_receipts (...)
|
|
118
|
+
COMMIT
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Conflict receipt if the app policy no longer allows the credit:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"status": "conflict",
|
|
126
|
+
"safe_error_code": "CREDIT_POLICY_CHANGED",
|
|
127
|
+
"source_database_mutated": false,
|
|
128
|
+
"details": {
|
|
129
|
+
"reason": "customer credit limit changed after proposal"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Open A Support Ticket
|
|
135
|
+
|
|
136
|
+
Use this when the agent may propose opening a ticket, but ticket creation must
|
|
137
|
+
go through your helpdesk/application service.
|
|
138
|
+
|
|
139
|
+
Capability:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
support.propose_open_ticket(customer_id, subject, body)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Handler transaction sketch:
|
|
146
|
+
|
|
147
|
+
```text
|
|
148
|
+
BEGIN
|
|
149
|
+
verify principal can open support tickets for tenant
|
|
150
|
+
verify customer belongs to tenant
|
|
151
|
+
validate subject/body against app policy
|
|
152
|
+
INSERT INTO support_tickets (...)
|
|
153
|
+
INSERT INTO ticket_events (...)
|
|
154
|
+
INSERT INTO synapsor_app_receipts (...)
|
|
155
|
+
COMMIT
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Receipt:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"status": "applied",
|
|
163
|
+
"rows_affected": 2,
|
|
164
|
+
"new_object_id": "T-9100",
|
|
165
|
+
"source_database_mutated": true
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Update Multiple Related Rows
|
|
170
|
+
|
|
171
|
+
Use this when an approved business action spans several tables and should stay
|
|
172
|
+
inside your normal application transaction.
|
|
173
|
+
|
|
174
|
+
Capability:
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
subscriptions.propose_trial_extension(customer_id, extension_days, reason)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Handler transaction sketch:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
BEGIN
|
|
184
|
+
verify tenant/principal authorization
|
|
185
|
+
SELECT subscription FOR UPDATE
|
|
186
|
+
verify expected subscription version
|
|
187
|
+
UPDATE subscriptions SET trial_ends_at = ...
|
|
188
|
+
INSERT INTO customer_events (...)
|
|
189
|
+
INSERT INTO billing_notes (...)
|
|
190
|
+
INSERT INTO synapsor_app_receipts (...)
|
|
191
|
+
COMMIT
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Receipt:
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"status": "applied",
|
|
199
|
+
"rows_affected": 3,
|
|
200
|
+
"previous_version": "2026-06-20T14:31:08Z",
|
|
201
|
+
"new_version": "2026-06-20T14:34:19Z",
|
|
202
|
+
"source_database_mutated": true
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Idempotent Retry
|
|
207
|
+
|
|
208
|
+
If the same `idempotency_key` reaches your handler again after a successful
|
|
209
|
+
apply, return `already_applied` and the original receipt details rather than
|
|
210
|
+
running the transaction again.
|
|
211
|
+
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"status": "already_applied",
|
|
215
|
+
"rows_affected": 0,
|
|
216
|
+
"source_database_mutated": false,
|
|
217
|
+
"details": {
|
|
218
|
+
"original_receipt_id": "rct_abc123"
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const chunks = [];
|
|
4
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
5
|
+
|
|
6
|
+
const request = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
7
|
+
const changeSet = request.change_set || {};
|
|
8
|
+
|
|
9
|
+
if (!request.proposal_id || !request.idempotency_key || !changeSet.scope?.tenant_id) {
|
|
10
|
+
process.stdout.write(JSON.stringify({
|
|
11
|
+
status: "failed",
|
|
12
|
+
safe_error_code: "BAD_WRITEBACK_REQUEST",
|
|
13
|
+
source_database_mutated: false,
|
|
14
|
+
}));
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (request.dry_run) {
|
|
19
|
+
process.stdout.write(JSON.stringify({
|
|
20
|
+
status: "applied",
|
|
21
|
+
rows_affected: 0,
|
|
22
|
+
source_database_mutated: false,
|
|
23
|
+
details: { dry_run: true },
|
|
24
|
+
}));
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* IMPORTANT: your app handler owns the final business write.
|
|
30
|
+
* Runner creates the proposal and calls your handler only after approval,
|
|
31
|
+
* but your handler must still enforce tenant/scope, expected-version or
|
|
32
|
+
* conflict guard, idempotency key, allowed business action,
|
|
33
|
+
* transaction/rollback, and safe error receipt.
|
|
34
|
+
*
|
|
35
|
+
* If you skip those checks, you can reintroduce cross-tenant writes,
|
|
36
|
+
* lost updates, or duplicate writes. Keep handler credentials out of MCP.
|
|
37
|
+
*
|
|
38
|
+
* Put your app-owned command transaction here.
|
|
39
|
+
*
|
|
40
|
+
* Examples:
|
|
41
|
+
* - call an internal service;
|
|
42
|
+
* - enqueue a review job;
|
|
43
|
+
* - run an app migration-safe script that uses your normal ORM.
|
|
44
|
+
*
|
|
45
|
+
* Re-check tenant/principal authorization, idempotency, row/version guards,
|
|
46
|
+
* and business policy before mutating application state.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
process.stdout.write(JSON.stringify({
|
|
50
|
+
status: "applied",
|
|
51
|
+
rows_affected: 1,
|
|
52
|
+
previous_version: String(changeSet.guards?.expected_version?.value || ""),
|
|
53
|
+
new_version: new Date().toISOString(),
|
|
54
|
+
source_database_mutated: true,
|
|
55
|
+
}));
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
|
|
3
|
+
const port = Number(process.env.PORT || 8787);
|
|
4
|
+
const expectedToken = process.env.SYNAPSOR_APP_WRITEBACK_TOKEN || "dev-handler-token";
|
|
5
|
+
|
|
6
|
+
const app = Fastify({ logger: true });
|
|
7
|
+
|
|
8
|
+
app.post("/synapsor/writeback", async (request, reply) => {
|
|
9
|
+
const auth = request.headers.authorization || "";
|
|
10
|
+
if (auth !== `Bearer ${expectedToken}`) {
|
|
11
|
+
return reply.code(401).send({ status: "failed", error_code: "UNAUTHORIZED" });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const body = request.body || {};
|
|
15
|
+
const changeSet = body.change_set || {};
|
|
16
|
+
|
|
17
|
+
if (!body.proposal_id || !body.idempotency_key || !changeSet.scope?.tenant_id) {
|
|
18
|
+
return reply.code(400).send({ status: "failed", error_code: "BAD_WRITEBACK_REQUEST" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (body.dry_run) {
|
|
22
|
+
return {
|
|
23
|
+
status: "applied",
|
|
24
|
+
rows_affected: 0,
|
|
25
|
+
source_database_mutated: false,
|
|
26
|
+
details: { dry_run: true },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* IMPORTANT: your app handler owns the final business write.
|
|
32
|
+
* Runner creates the proposal and calls your handler only after approval,
|
|
33
|
+
* but your handler must still enforce tenant/scope, expected-version or
|
|
34
|
+
* conflict guard, idempotency key, allowed business action,
|
|
35
|
+
* transaction/rollback, and safe error receipt.
|
|
36
|
+
*
|
|
37
|
+
* If you skip those checks, you can reintroduce cross-tenant writes,
|
|
38
|
+
* lost updates, or duplicate writes. Keep handler credentials out of MCP.
|
|
39
|
+
*
|
|
40
|
+
* Put your app-owned transaction here.
|
|
41
|
+
*
|
|
42
|
+
* Examples:
|
|
43
|
+
* - insert a refund_review row;
|
|
44
|
+
* - insert an account_credit row;
|
|
45
|
+
* - open a support_ticket row;
|
|
46
|
+
* - update invoice + ledger rows together.
|
|
47
|
+
*
|
|
48
|
+
* Re-check:
|
|
49
|
+
* - tenant and principal authorization;
|
|
50
|
+
* - idempotency_key has not already been applied;
|
|
51
|
+
* - row/version guards still match;
|
|
52
|
+
* - requested business action is allowed by your app policy.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
status: "applied",
|
|
57
|
+
rows_affected: 1,
|
|
58
|
+
previous_version: String(changeSet.guards?.expected_version?.value || ""),
|
|
59
|
+
new_version: new Date().toISOString(),
|
|
60
|
+
source_database_mutated: true,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
app.listen({ host: "127.0.0.1", port });
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, Header, HTTPException
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HandlerRequest(BaseModel):
|
|
9
|
+
schema_version: str
|
|
10
|
+
writeback_job_id: str
|
|
11
|
+
proposal_id: str
|
|
12
|
+
idempotency_key: str
|
|
13
|
+
change_set: dict
|
|
14
|
+
executor: str | None = None
|
|
15
|
+
dry_run: bool = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
app = FastAPI()
|
|
19
|
+
EXPECTED_TOKEN = os.environ.get("SYNAPSOR_APP_WRITEBACK_TOKEN", "dev-handler-token")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.post("/synapsor/writeback")
|
|
23
|
+
def writeback(request: HandlerRequest, authorization: str | None = Header(default=None)):
|
|
24
|
+
if authorization != f"Bearer {EXPECTED_TOKEN}":
|
|
25
|
+
raise HTTPException(status_code=401, detail="UNAUTHORIZED")
|
|
26
|
+
|
|
27
|
+
scope = request.change_set.get("scope", {})
|
|
28
|
+
if not scope.get("tenant_id"):
|
|
29
|
+
raise HTTPException(status_code=400, detail="BAD_WRITEBACK_REQUEST")
|
|
30
|
+
|
|
31
|
+
if request.dry_run:
|
|
32
|
+
return {
|
|
33
|
+
"status": "applied",
|
|
34
|
+
"rows_affected": 0,
|
|
35
|
+
"source_database_mutated": False,
|
|
36
|
+
"details": {"dry_run": True},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# IMPORTANT: your app handler owns the final business write.
|
|
40
|
+
# Runner creates the proposal and calls your handler only after approval,
|
|
41
|
+
# but your handler must still enforce tenant/scope, expected-version or
|
|
42
|
+
# conflict guard, idempotency key, allowed business action,
|
|
43
|
+
# transaction/rollback, and safe error receipt.
|
|
44
|
+
#
|
|
45
|
+
# If you skip those checks, you can reintroduce cross-tenant writes,
|
|
46
|
+
# lost updates, or duplicate writes. Keep handler credentials out of MCP.
|
|
47
|
+
#
|
|
48
|
+
# Put your app-owned transaction here.
|
|
49
|
+
#
|
|
50
|
+
# Examples:
|
|
51
|
+
# - insert a refund_review row;
|
|
52
|
+
# - insert an account_credit row;
|
|
53
|
+
# - open a support_ticket row;
|
|
54
|
+
# - update invoice + ledger rows together.
|
|
55
|
+
#
|
|
56
|
+
# Re-check tenant/principal authorization, idempotency, row/version guards,
|
|
57
|
+
# and business policy before mutating application state.
|
|
58
|
+
|
|
59
|
+
expected = request.change_set.get("guards", {}).get("expected_version", {})
|
|
60
|
+
return {
|
|
61
|
+
"status": "applied",
|
|
62
|
+
"rows_affected": 1,
|
|
63
|
+
"previous_version": str(expected.get("value", "")),
|
|
64
|
+
"new_version": datetime.now(timezone.utc).isoformat(),
|
|
65
|
+
"source_database_mutated": True,
|
|
66
|
+
}
|