@synapsor/runner 0.1.0-alpha.11 → 0.1.0-alpha.13
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/README.md +166 -23
- package/dist/cli.d.ts.map +1 -1
- package/dist/runner.mjs +855 -66
- package/docs/README.md +21 -0
- package/docs/app-owned-executors.md +5 -0
- package/docs/capability-authoring.md +265 -0
- package/docs/doctor.md +98 -0
- package/docs/handler-helper.md +200 -0
- package/docs/local-mode.md +13 -2
- package/docs/release-notes.md +47 -2
- package/docs/release-policy.md +86 -0
- package/docs/result-envelope-v2.md +148 -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/writeback-executors.md +18 -0
- package/examples/app-owned-writeback/README.md +1 -0
- package/examples/mcp-postgres-billing-app-handler/README.md +6 -2
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +77 -149
- package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +1 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +1 -0
- package/package.json +2 -1
- 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 +125 -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 +412 -0
- package/schemas/writeback-job.v1.schema.json +121 -0
package/docs/release-notes.md
CHANGED
|
@@ -11,6 +11,45 @@ npx -y -p @synapsor/runner@alpha synapsor-runner demo --quick
|
|
|
11
11
|
The OSS runner command is `synapsor-runner`. The `synapsor` command is reserved
|
|
12
12
|
for the Synapsor Cloud CLI.
|
|
13
13
|
|
|
14
|
+
## 0.1.0-alpha.13
|
|
15
|
+
|
|
16
|
+
### README First-Five-Minutes Polish
|
|
17
|
+
|
|
18
|
+
- The README now opens with the plain mental model: the agent talks to Runner,
|
|
19
|
+
can inspect scoped data, can create proposals, cannot commit, and writeback
|
|
20
|
+
plus replay happen outside the model-facing tool.
|
|
21
|
+
- Capability, proposal, writeback, and executor are defined before the first
|
|
22
|
+
command so a new reader can understand the rest of the docs.
|
|
23
|
+
- The README now states the direct-writeback rule early: guarded one-row updates
|
|
24
|
+
can use Runner direct writeback; inserts, multi-table work, events, and other
|
|
25
|
+
rich writes belong in an app-owned executor.
|
|
26
|
+
- The own-database section now includes a tiny readable config with one read
|
|
27
|
+
capability and one proposal capability so users can picture what the wizard
|
|
28
|
+
generates before they run it.
|
|
29
|
+
|
|
30
|
+
## 0.1.0-alpha.12
|
|
31
|
+
|
|
32
|
+
### Doctor And Writeback Checks
|
|
33
|
+
|
|
34
|
+
- `synapsor-runner doctor --config synapsor.runner.json --check-writeback`
|
|
35
|
+
verifies direct SQL writer connectivity, receipt-table readiness, and
|
|
36
|
+
rollback-only target-table access for reviewed proposal capabilities.
|
|
37
|
+
- Plain `doctor` warns when direct SQL writeback exists but has not been probed.
|
|
38
|
+
- The writeback probe uses fixed identifiers from reviewed config only. It does
|
|
39
|
+
not accept model SQL, user SQL, arbitrary table names, or arbitrary columns.
|
|
40
|
+
- Probe failures are redacted to safe categories such as `connection failed`,
|
|
41
|
+
`permission denied`, and `configured object not found`.
|
|
42
|
+
- `docs/doctor.md` explains handler checks, direct SQL writeback checks, and
|
|
43
|
+
receipt-table DDL/grant guidance.
|
|
44
|
+
|
|
45
|
+
### Store Lifecycle
|
|
46
|
+
|
|
47
|
+
- `synapsor-runner store reset --store ./.synapsor/local.db --yes` removes only
|
|
48
|
+
local SQLite ledger files and reports `source_database_changed: false`.
|
|
49
|
+
- Destructive store reset refuses active server leases by default and requires
|
|
50
|
+
`--force` for advanced/stale-lease recovery.
|
|
51
|
+
- Packed and public verifier scripts now cover `store reset`.
|
|
52
|
+
|
|
14
53
|
## 0.1.0-alpha.11
|
|
15
54
|
|
|
16
55
|
### OpenAI MCP Aliases
|
|
@@ -67,7 +106,8 @@ for the Synapsor Cloud CLI.
|
|
|
67
106
|
initialize/session behavior on the `/mcp` endpoint.
|
|
68
107
|
- `synapsor-runner mcp serve-http` is an authenticated JSON-RPC bridge for
|
|
69
108
|
simple `tools/list`, `tools/call`, and `resources/read` wrappers. It is not
|
|
70
|
-
the standard Streamable HTTP MCP transport
|
|
109
|
+
the standard Streamable HTTP MCP transport and prints a runtime warning when
|
|
110
|
+
started.
|
|
71
111
|
- The OpenAI Agents SDK HTTP example uses the Streamable HTTP MCP path. Use the
|
|
72
112
|
JSON-RPC bridge only when you intentionally want a thin app-owned wrapper.
|
|
73
113
|
|
|
@@ -100,6 +140,11 @@ for the Synapsor Cloud CLI.
|
|
|
100
140
|
binding, evidence handles, query audit, and local inspection records.
|
|
101
141
|
- Proposal workflows add full local replay across evidence, approval,
|
|
102
142
|
writeback jobs, execution receipts, and events.
|
|
143
|
+
- `synapsor-runner events tail` prints local lifecycle events from the SQLite
|
|
144
|
+
ledger and can follow new proposal/writeback events while a local flow runs.
|
|
145
|
+
- MCP server modes write an active-store lease next to the local SQLite file.
|
|
146
|
+
Destructive `store prune --yes` refuses while that lease points at a live
|
|
147
|
+
process unless `--force` is provided.
|
|
103
148
|
- External Postgres/MySQL databases are not physically branched by Runner.
|
|
104
149
|
Replay covers records captured by Runner; it is not external database
|
|
105
150
|
time travel.
|
|
@@ -154,5 +199,5 @@ After publishing an alpha, verify the public package from a clean temporary
|
|
|
154
199
|
directory:
|
|
155
200
|
|
|
156
201
|
```bash
|
|
157
|
-
./scripts/verify-published-alpha.sh 0.1.0-alpha.
|
|
202
|
+
./scripts/verify-published-alpha.sh 0.1.0-alpha.13
|
|
158
203
|
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Release Policy
|
|
2
|
+
|
|
3
|
+
Synapsor Runner is currently an alpha local runner. Use the explicit alpha tag
|
|
4
|
+
or an exact version:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx -y -p @synapsor/runner@alpha synapsor-runner demo --quick
|
|
8
|
+
npm install -g @synapsor/runner@0.1.0-alpha.13
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Do not rely on the untagged `latest` dist-tag until a stable release is
|
|
12
|
+
published.
|
|
13
|
+
|
|
14
|
+
## Alpha Expectations
|
|
15
|
+
|
|
16
|
+
Alpha versions may change:
|
|
17
|
+
|
|
18
|
+
- command names and help text;
|
|
19
|
+
- MCP transport defaults;
|
|
20
|
+
- config fields and JSON Schema;
|
|
21
|
+
- local store layout;
|
|
22
|
+
- result envelope format;
|
|
23
|
+
- writeback/handler contracts;
|
|
24
|
+
- example layout and docs.
|
|
25
|
+
|
|
26
|
+
Alpha releases must keep the safety boundary intact:
|
|
27
|
+
|
|
28
|
+
- no model-facing `execute_sql`;
|
|
29
|
+
- no model-facing write credentials;
|
|
30
|
+
- no model-facing approval/commit/apply tools;
|
|
31
|
+
- no generic model-generated INSERT/DELETE/UPSERT/DDL/multi-row SQL;
|
|
32
|
+
- proposal-first write path stays explicit.
|
|
33
|
+
|
|
34
|
+
## Stable Expectations
|
|
35
|
+
|
|
36
|
+
A stable `0.1.0` release should only be tagged after:
|
|
37
|
+
|
|
38
|
+
- npm README commands match the published package;
|
|
39
|
+
- `synapsor-runner demo --quick` works from a clean directory;
|
|
40
|
+
- own-database onboarding works from a clean directory;
|
|
41
|
+
- stdio MCP and Streamable HTTP MCP are both verified;
|
|
42
|
+
- OpenAI alias mode is verified;
|
|
43
|
+
- direct SQL writeback requirements are documented and tested;
|
|
44
|
+
- app-owned executor requirements are documented and tested;
|
|
45
|
+
- local evidence/proposal/receipt/replay inspection works;
|
|
46
|
+
- current limitations are accurate.
|
|
47
|
+
|
|
48
|
+
## Result Envelope Migration
|
|
49
|
+
|
|
50
|
+
`result_format: 2` is opt-in during alpha migration:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"result_format": 2
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
or:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
synapsor-runner mcp serve --result-format v2
|
|
62
|
+
synapsor-runner mcp serve-streamable-http --result-format v2
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
v1 remains the default until the migration is explicitly called out in a future
|
|
66
|
+
release note. v2 makes `ok` the only required branch point for MCP client code.
|
|
67
|
+
|
|
68
|
+
## Publish Checklist
|
|
69
|
+
|
|
70
|
+
Before publishing a new alpha:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
corepack pnpm typecheck
|
|
74
|
+
corepack pnpm exec vitest run packages/mcp-server/src/index.test.ts apps/runner/src/cli.test.ts
|
|
75
|
+
./scripts/verify-packed-runner.sh
|
|
76
|
+
npm pack --dry-run
|
|
77
|
+
git diff --check
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
After publishing:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm view @synapsor/runner@alpha version bin license
|
|
84
|
+
npx -y -p @synapsor/runner@alpha synapsor-runner demo --quick --no-interactive
|
|
85
|
+
npx -y -p @synapsor/runner@alpha synapsor-runner audit --example dangerous-db-mcp --format markdown
|
|
86
|
+
```
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Result Envelope v2
|
|
2
|
+
|
|
3
|
+
Result envelope v2 gives every model-facing MCP tool call one stable shape.
|
|
4
|
+
Client code and agents branch only on `ok`.
|
|
5
|
+
|
|
6
|
+
Enable it in config:
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"result_format": 2
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or at serve time:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
synapsor-runner mcp serve \
|
|
18
|
+
--config ./synapsor.runner.json \
|
|
19
|
+
--store ./.synapsor/local.db \
|
|
20
|
+
--result-format v2
|
|
21
|
+
|
|
22
|
+
synapsor-runner mcp serve-streamable-http \
|
|
23
|
+
--config ./synapsor.runner.json \
|
|
24
|
+
--store ./.synapsor/local.db \
|
|
25
|
+
--auth-token-env SYNAPSOR_RUNNER_HTTP_TOKEN \
|
|
26
|
+
--result-format v2
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Shape
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"ok": true,
|
|
34
|
+
"summary": "Read invoice INV-3001 through billing.inspect_invoice. Source database changed: no.",
|
|
35
|
+
"action": "billing.inspect_invoice",
|
|
36
|
+
"kind": "read",
|
|
37
|
+
"data": {},
|
|
38
|
+
"proposal": null,
|
|
39
|
+
"error": null,
|
|
40
|
+
"evidence": {
|
|
41
|
+
"bundle_id": "ev_...",
|
|
42
|
+
"note": "audit/replay handle; you do not need to act on it during this turn"
|
|
43
|
+
},
|
|
44
|
+
"source_database_changed": false,
|
|
45
|
+
"_meta": {
|
|
46
|
+
"tenant_id": "tenant_acme",
|
|
47
|
+
"principal": "demo-operator",
|
|
48
|
+
"provenance": "environment",
|
|
49
|
+
"canonical_capability": "billing.inspect_invoice"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Rules:
|
|
55
|
+
|
|
56
|
+
- `ok` is the only field callers need to branch on.
|
|
57
|
+
- `summary` is always present and safe for an agent to read or echo.
|
|
58
|
+
- On success, exactly one of `data` or `proposal` is non-null.
|
|
59
|
+
- On failure, both `data` and `proposal` are null.
|
|
60
|
+
- `source_database_changed` is always present.
|
|
61
|
+
- `_meta.canonical_capability` is always the dotted Synapsor capability name,
|
|
62
|
+
even when the model sees an OpenAI-safe alias.
|
|
63
|
+
|
|
64
|
+
## Proposal Result
|
|
65
|
+
|
|
66
|
+
Proposal tools create reviewable changes. They do not commit to the source
|
|
67
|
+
database.
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"ok": true,
|
|
72
|
+
"summary": "Created proposal wrp_123 for invoices INV-3001. Source database changed: no.",
|
|
73
|
+
"action": "billing.propose_late_fee_waiver",
|
|
74
|
+
"kind": "proposal",
|
|
75
|
+
"data": null,
|
|
76
|
+
"proposal": {
|
|
77
|
+
"id": "wrp_123",
|
|
78
|
+
"state": "review_required",
|
|
79
|
+
"target": "invoices:INV-3001",
|
|
80
|
+
"diff": {
|
|
81
|
+
"late_fee_cents": {
|
|
82
|
+
"before": 2500,
|
|
83
|
+
"proposed": 0
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"approval_required": true,
|
|
87
|
+
"writeback": {
|
|
88
|
+
"mode": "direct_update",
|
|
89
|
+
"applied": false
|
|
90
|
+
},
|
|
91
|
+
"next": "A human must approve outside this model-facing tool surface; nothing is committed yet."
|
|
92
|
+
},
|
|
93
|
+
"error": null,
|
|
94
|
+
"evidence": {
|
|
95
|
+
"bundle_id": "ev_..."
|
|
96
|
+
},
|
|
97
|
+
"source_database_changed": false,
|
|
98
|
+
"_meta": {
|
|
99
|
+
"canonical_capability": "billing.propose_late_fee_waiver"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For app-owned executors, `proposal.writeback.mode` is `app_handler`.
|
|
105
|
+
|
|
106
|
+
## Error Result
|
|
107
|
+
|
|
108
|
+
Model-facing errors are safe and stable. Raw driver details stay in local logs
|
|
109
|
+
or ledger inspection, not in the MCP result.
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"ok": false,
|
|
114
|
+
"summary": "The database is temporarily unavailable. Retry later.",
|
|
115
|
+
"action": "billing.inspect_invoice",
|
|
116
|
+
"kind": "read",
|
|
117
|
+
"data": null,
|
|
118
|
+
"proposal": null,
|
|
119
|
+
"error": {
|
|
120
|
+
"code": "TEMPORARILY_UNAVAILABLE",
|
|
121
|
+
"message": "The database is temporarily unavailable. Retry later.",
|
|
122
|
+
"retryable": true
|
|
123
|
+
},
|
|
124
|
+
"evidence": null,
|
|
125
|
+
"source_database_changed": false,
|
|
126
|
+
"_meta": {
|
|
127
|
+
"canonical_capability": "billing.inspect_invoice"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Safe error codes:
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
NOT_FOUND_IN_TENANT
|
|
136
|
+
INVALID_ARGUMENT
|
|
137
|
+
POLICY_VIOLATION
|
|
138
|
+
CAPABILITY_NOT_FOUND
|
|
139
|
+
VERSION_CONFLICT
|
|
140
|
+
MULTI_ROW_BLOCKED
|
|
141
|
+
APPROVAL_REQUIRED
|
|
142
|
+
TEMPORARILY_UNAVAILABLE
|
|
143
|
+
INTERNAL
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Current alpha implementation redacts raw connection and driver messages from v2
|
|
147
|
+
MCP results. Legacy result format v1 remains the default for compatibility in
|
|
148
|
+
this alpha.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Proposal: One Result Envelope for all Synapsor Runner tool results
|
|
2
|
+
|
|
3
|
+
Status: draft for synapsor-runner (OSS)
|
|
4
|
+
Author: external integrator feedback (built a full OpenAI Agents SDK + Postgres lab on alpha.6→alpha.11)
|
|
5
|
+
Goal: make every tool result (read, proposal, error) share one shape that is both
|
|
6
|
+
**machine-branchable** and **LLM-legible**, so agents behave reliably and client
|
|
7
|
+
code stops special-casing.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
Observed today (alpha.11), the shapes diverge:
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
// read success
|
|
15
|
+
{ "status": "ok", "action": "billing.inspect_invoice", "data": { ... },
|
|
16
|
+
"evidence_bundle_id": "ev_…", "trusted_context": { ... }, "source_database_changed": false }
|
|
17
|
+
|
|
18
|
+
// read not-found / tenant mismatch
|
|
19
|
+
{ "ok": false, "code": "ROW_NOT_FOUND", "error": "The scoped capability read did not find exactly one authorized row." }
|
|
20
|
+
|
|
21
|
+
// proposal success
|
|
22
|
+
{ "status": "review_required", "proposal_id": "wrp_…", "diff": { "late_fee_cents": { "before": 2500, "proposed": 0 } },
|
|
23
|
+
"source_database_changed": false }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Three different top-level keys (`status` vs `ok`), two different success vocabularies,
|
|
27
|
+
and raw infra strings leaking into `error`. An LLM driving these has to learn three
|
|
28
|
+
branches; in my live tests the model **stalled / misreported** ("database access issue")
|
|
29
|
+
when it hit the off-shape error path.
|
|
30
|
+
|
|
31
|
+
## The envelope
|
|
32
|
+
|
|
33
|
+
Every tool returns exactly this top-level shape:
|
|
34
|
+
|
|
35
|
+
```jsonc
|
|
36
|
+
{
|
|
37
|
+
"ok": true, // boolean — the ONLY field client code must branch on
|
|
38
|
+
"summary": "Invoice INV-3001: $100 + $25 late fee, status overdue.", // one-line NL for the model to read/echo
|
|
39
|
+
"action": "billing.inspect_invoice",
|
|
40
|
+
"kind": "read", // read | proposal
|
|
41
|
+
"data": { ... } | null, // read payload (the row), or null
|
|
42
|
+
"proposal": { ... } | null, // proposal payload (see below), or null
|
|
43
|
+
"error": { ... } | null, // populated iff ok=false (see below)
|
|
44
|
+
"evidence": { "bundle_id": "ev_…", "note": "audit handle; you do not need to act on it" } | null,
|
|
45
|
+
"source_database_changed": false, // ALWAYS present; true only after applied writeback
|
|
46
|
+
"_meta": { "tenant_id": "tenant_acme", "principal": "demo-operator", "provenance": "environment",
|
|
47
|
+
"canonical_capability": "billing.inspect_invoice" }
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Rules:
|
|
52
|
+
- `ok` is the single branch point. No more `status` vs `ok`.
|
|
53
|
+
- `summary` is mandatory and is the field the model is expected to read first and can
|
|
54
|
+
echo back to the user. Keep it short, factual, no internal ids unless useful.
|
|
55
|
+
- Exactly one of `data` / `proposal` is non-null on success; both null on error.
|
|
56
|
+
- `_meta` carries the trusted context and the canonical capability name (so OpenAI-safe
|
|
57
|
+
aliases like `billing__inspect_invoice` still expose their real name for reasoning/audit).
|
|
58
|
+
|
|
59
|
+
### Proposal payload
|
|
60
|
+
|
|
61
|
+
```jsonc
|
|
62
|
+
"proposal": {
|
|
63
|
+
"id": "wrp_…",
|
|
64
|
+
"state": "review_required", // review_required | approved | applied | conflict | rejected
|
|
65
|
+
"target": "invoices:INV-3001",
|
|
66
|
+
"diff": { "late_fee_cents": { "before": 2500, "proposed": 0 } },
|
|
67
|
+
"approval_required": true,
|
|
68
|
+
"writeback": { "mode": "direct_update" | "app_handler", "applied": false },
|
|
69
|
+
"next": "A human must approve outside this loop; nothing is committed yet."
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This part of today's output is already the best-designed — keep `diff.before/proposed`
|
|
74
|
+
and `approval_required` verbatim, just move it under `proposal`.
|
|
75
|
+
|
|
76
|
+
### Error payload (safe + stable)
|
|
77
|
+
|
|
78
|
+
```jsonc
|
|
79
|
+
"error": {
|
|
80
|
+
"code": "NOT_FOUND_IN_TENANT", // STABLE enum (below) — never a raw infra string
|
|
81
|
+
"message": "No invoice INV-9999 is visible in your tenant.", // safe, terse, actionable
|
|
82
|
+
"retryable": false
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Never surface raw driver text (`connect ECONNREFUSED 127.0.0.1:5433`) to the tool
|
|
87
|
+
caller — log that to the local ledger only. Leaking it is a small info disclosure
|
|
88
|
+
**and** degrades LLM behavior (the model parrots infra errors to the user).
|
|
89
|
+
|
|
90
|
+
## Stable error code enum
|
|
91
|
+
|
|
92
|
+
| code | meaning | retryable |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `NOT_FOUND_IN_TENANT` | lookup found 0 authorized rows (missing OR wrong tenant — do not distinguish, it's a scoping signal) | no |
|
|
95
|
+
| `INVALID_ARGUMENT` | arg failed schema/`numeric_bounds` | no |
|
|
96
|
+
| `POLICY_VIOLATION` | request outside an allowed bound/transition | no |
|
|
97
|
+
| `CAPABILITY_NOT_FOUND` | unknown tool name | no |
|
|
98
|
+
| `VERSION_CONFLICT` | row changed since the agent saw it (stale-row guard) | no (re-inspect first) |
|
|
99
|
+
| `MULTI_ROW_BLOCKED` | a write would touch ≠1 row | no |
|
|
100
|
+
| `APPROVAL_REQUIRED` | attempted to apply without approval | no |
|
|
101
|
+
| `TEMPORARILY_UNAVAILABLE` | DB/handler unreachable or timed out | yes |
|
|
102
|
+
| `INTERNAL` | anything else (details only in ledger) | maybe |
|
|
103
|
+
|
|
104
|
+
Keep this list small and documented; the model can be told "on `VERSION_CONFLICT`,
|
|
105
|
+
re-inspect then re-propose" and act correctly.
|
|
106
|
+
|
|
107
|
+
## Tool descriptions (ships with the envelope, same impact)
|
|
108
|
+
|
|
109
|
+
The envelope fixes *results*; descriptions fix *whether the model calls the right
|
|
110
|
+
tool at all*. Today they're generic ("Read public.outage_events through a reviewed
|
|
111
|
+
Synapsor capability…"). Let capability config carry model-facing text:
|
|
112
|
+
|
|
113
|
+
```jsonc
|
|
114
|
+
{
|
|
115
|
+
"name": "support.inspect_outage",
|
|
116
|
+
"description": "Look up an outage event: its time window, affected plan, and the credit policy that governs waivers/credits. Use this before deciding whether a waiver or credit is justified.",
|
|
117
|
+
"args": {
|
|
118
|
+
"outage_id": { "type": "string", "description": "Outage/incident id, e.g. OUT-9001 (often referenced in the support ticket)." }
|
|
119
|
+
},
|
|
120
|
+
"returns_hint": "Returns the outage window, affected_plan, and credit_policy."
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
In my live runs, the difference between a reliable agent and one that stalled before
|
|
125
|
+
proposing was exactly this: whether the outage tool's description told the model it
|
|
126
|
+
returns the *policy*. Surface `description` + per-arg `description` + `returns_hint`
|
|
127
|
+
in `tools/list`. Fall back to today's auto-text only when the author omits them.
|
|
128
|
+
|
|
129
|
+
## Migration
|
|
130
|
+
|
|
131
|
+
- Add `"result_format": 2` to `synapsor.runner.json` (or a server flag
|
|
132
|
+
`--result-format v2`); default stays v1 for one minor cycle, then flips.
|
|
133
|
+
- During transition the server can **dual-emit**: v2 envelope with a `legacy` mirror
|
|
134
|
+
of the old keys, so existing parsers don't break.
|
|
135
|
+
- Document a one-line mapping: `status:"ok"` → `ok:true`; `status:"review_required"`
|
|
136
|
+
→ `ok:true, proposal.state:"review_required"`; top-level `code/error` → `error.{code,message}`.
|
|
137
|
+
|
|
138
|
+
## Acceptance
|
|
139
|
+
|
|
140
|
+
- All of `tools/call` (read + proposal) and tool-level failures return the envelope.
|
|
141
|
+
- `ok` alone is sufficient to branch in client code.
|
|
142
|
+
- No raw driver/infra strings appear in any `error.message`.
|
|
143
|
+
- `tools/list` exposes author-supplied `description` / arg `description` / `returns_hint`.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Proposal: An app-owned writeback handler helper (safe-by-default executors)
|
|
2
|
+
|
|
3
|
+
Status: draft for synapsor-runner (OSS)
|
|
4
|
+
Goal: make `http_handler` / `command_handler` executors **safe by default**. Today a
|
|
5
|
+
handler author must re-implement tenant scoping, the stale-row (`expected_version`)
|
|
6
|
+
guard, and idempotency by hand — "match the example." That's a security-critical loop
|
|
7
|
+
to leave to copy-paste. Ship a tiny helper that enforces the guards and hands the
|
|
8
|
+
developer only the business write.
|
|
9
|
+
|
|
10
|
+
## The problem, concretely
|
|
11
|
+
|
|
12
|
+
I wrote a working credit handler for the lab. To be correct it had to, in order:
|
|
13
|
+
re-auth the bearer token, reject the wrong `action`, extract tenant/object/version
|
|
14
|
+
from a loosely-typed `change_set` (with 3 fallback paths each, copied from the
|
|
15
|
+
example), check idempotency, `SELECT … FOR UPDATE`, compare a normalized
|
|
16
|
+
`expected_version`, INSERT + UPDATE, and format a receipt with the right status
|
|
17
|
+
vocabulary. **Every one of those is a place to introduce a vulnerability** (skip the
|
|
18
|
+
version check → lost-update; skip tenant → cross-tenant write; trust body tenant
|
|
19
|
+
without signature → spoofing). Most integrators will get at least one wrong.
|
|
20
|
+
|
|
21
|
+
## The contract (formalize what's currently implicit)
|
|
22
|
+
|
|
23
|
+
Request the runner POSTs to an `http_handler` (make this a published, versioned schema):
|
|
24
|
+
|
|
25
|
+
```jsonc
|
|
26
|
+
{
|
|
27
|
+
"protocol_version": "1.0",
|
|
28
|
+
"proposal_id": "wrp_…",
|
|
29
|
+
"idempotency_key": "wrp_…:INV-3001",
|
|
30
|
+
"issued_at": "2026-06-28T…Z",
|
|
31
|
+
"signature": "sha256=…", // NEW: HMAC over the raw body (see Security)
|
|
32
|
+
"change_set": {
|
|
33
|
+
"action": "support.propose_plan_credit",
|
|
34
|
+
"scope": { "tenant_id": "tenant_acme", "object_id": "INV-3001" },
|
|
35
|
+
"principal": { "id": "human-reviewer" },
|
|
36
|
+
"target": { "schema": "public", "table": "invoices", "primary_key": { "column": "id", "value": "INV-3001" } },
|
|
37
|
+
"patch": { "credit_requested_cents": 1500, "credit_reason": "outage credit" },
|
|
38
|
+
"guards": { "tenant": { "column": "tenant_id", "value": "tenant_acme" },
|
|
39
|
+
"expected_version": { "column": "updated_at", "value": "2026-05-16T00:00:00Z" } }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Receipt the handler must return (today's status vocabulary, kept):
|
|
45
|
+
|
|
46
|
+
```jsonc
|
|
47
|
+
{ "status": "applied" | "already_applied" | "conflict" | "failed",
|
|
48
|
+
"rows_affected": 2,
|
|
49
|
+
"source_database_mutated": true,
|
|
50
|
+
"previous_version": "2026-05-16T00:00:00Z",
|
|
51
|
+
"new_version": "2026-06-28T…Z",
|
|
52
|
+
"safe_error_code": "ROW_CHANGED_AFTER_PROPOSAL", // on conflict/failed
|
|
53
|
+
"details": { "effects": [ { "type": "db.insert", "table": "credits", "id": "CR-…" } ] } }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Helper API (TypeScript — first-party, since the runner is TS/Node)
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { createWritebackHandler } from "@synapsor/handler";
|
|
60
|
+
|
|
61
|
+
export const handler = createWritebackHandler({
|
|
62
|
+
// 1. Authenticity: helper verifies bearer AND the HMAC signature for you.
|
|
63
|
+
tokenEnv: "SYNAPSOR_APP_HANDLER_TOKEN",
|
|
64
|
+
signingSecretEnv: "SYNAPSOR_APP_HANDLER_SIGNING_SECRET", // optional but recommended
|
|
65
|
+
|
|
66
|
+
// 2. Bind one apply() per capability. The helper has ALREADY:
|
|
67
|
+
// - verified auth + signature + protocol_version
|
|
68
|
+
// - matched the action
|
|
69
|
+
// - parsed scope/target/patch/guards into a typed `job`
|
|
70
|
+
// - opened a transaction, taken `SELECT … FOR UPDATE` on the target row,
|
|
71
|
+
// enforced tenant match + expected_version (stale-row), and short-circuited
|
|
72
|
+
// idempotency via the receipts table.
|
|
73
|
+
// You only write business effects with the provided tx; throw to roll back.
|
|
74
|
+
capabilities: {
|
|
75
|
+
"support.propose_plan_credit": async (job, tx) => {
|
|
76
|
+
const creditId = `CR-${job.proposalId.slice(-12)}`;
|
|
77
|
+
await tx.insert("credits", {
|
|
78
|
+
id: creditId, tenant_id: job.tenantId, invoice_id: job.objectId,
|
|
79
|
+
customer_id: job.row.customer_id, amount_cents: job.patch.credit_requested_cents,
|
|
80
|
+
reason: job.patch.credit_reason, created_by: job.principal,
|
|
81
|
+
});
|
|
82
|
+
await tx.update("invoices", job.objectId, {
|
|
83
|
+
credited_cents: job.row.credited_cents + job.patch.credit_requested_cents,
|
|
84
|
+
});
|
|
85
|
+
return { effects: [{ type: "db.insert", table: "credits", id: creditId }] };
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// 3. DB binding (helper owns the tx + FOR UPDATE + version compare + receipt write).
|
|
90
|
+
source: { engine: "postgres", writeUrlEnv: "SYNAPSOR_APP_WRITE_URL" },
|
|
91
|
+
});
|
|
92
|
+
// handler is a (req,res) you mount at POST /synapsor/writeback, or an express/fastify route.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The helper turns conflict/idempotency/auth into framework concerns. The author writes
|
|
96
|
+
**only** the INSERT/UPDATE and returns `effects`; status/`rows_affected`/version
|
|
97
|
+
bookkeeping/receipt shape are produced by the helper.
|
|
98
|
+
|
|
99
|
+
## Helper API (Python reference — handlers are often the app, not Node)
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from synapsor_handler import writeback_handler, Job, Tx # pip install synapsor-handler
|
|
103
|
+
|
|
104
|
+
@writeback_handler(
|
|
105
|
+
token_env="SYNAPSOR_APP_HANDLER_TOKEN",
|
|
106
|
+
signing_secret_env="SYNAPSOR_APP_HANDLER_SIGNING_SECRET",
|
|
107
|
+
write_url_env="SYNAPSOR_APP_WRITE_URL",
|
|
108
|
+
)
|
|
109
|
+
def support_propose_plan_credit(job: Job, tx: Tx):
|
|
110
|
+
credit_id = f"CR-{job.proposal_id[-12:]}"
|
|
111
|
+
tx.insert("credits", id=credit_id, tenant_id=job.tenant_id, invoice_id=job.object_id,
|
|
112
|
+
customer_id=job.row["customer_id"], amount_cents=job.patch["credit_requested_cents"],
|
|
113
|
+
reason=job.patch["credit_reason"], created_by=job.principal)
|
|
114
|
+
tx.update("invoices", job.object_id,
|
|
115
|
+
credited_cents=job.row["credited_cents"] + job.patch["credit_requested_cents"])
|
|
116
|
+
return {"effects": [{"type": "db.insert", "table": "credits", "id": credit_id}]}
|
|
117
|
+
|
|
118
|
+
# Mount as a FastAPI/Flask route: app.post("/synapsor/writeback")(support_propose_plan_credit.asgi)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`Job` is fully typed/validated: `proposal_id`, `idempotency_key`, `tenant_id`,
|
|
122
|
+
`object_id`, `principal`, `patch`, and `row` (the locked current row). `Tx` only
|
|
123
|
+
exposes scoped `insert`/`update`/`query` against the configured write URL.
|
|
124
|
+
|
|
125
|
+
## What the helper guarantees (so the author can't forget)
|
|
126
|
+
|
|
127
|
+
1. **Authenticity** — bearer + HMAC signature over the raw body. Without signing,
|
|
128
|
+
a handler trusts body-supplied `tenant_id`; with it, spoofing a writeback requires
|
|
129
|
+
the secret, not just network reach.
|
|
130
|
+
2. **Tenant scope** — the locked-row `SELECT` always includes `tenant_id = scope.tenant_id`.
|
|
131
|
+
3. **Stale-row guard** — `expected_version` compared at second precision (matching the
|
|
132
|
+
runner's own `versionValuesMatch`); mismatch → `conflict`, auto-rollback.
|
|
133
|
+
4. **Idempotency** — a receipts/dedup row keyed by `idempotency_key`; replay → `already_applied`, no double write.
|
|
134
|
+
5. **Atomicity** — author effects + receipt commit in one tx; any throw rolls back and returns a safe `failed`.
|
|
135
|
+
6. **Safe receipts** — never leaks raw driver errors; maps exceptions to `safe_error_code`.
|
|
136
|
+
|
|
137
|
+
## Security notes
|
|
138
|
+
|
|
139
|
+
- **Sign requests.** Add `signature = HMAC_SHA256(signing_secret, raw_body)` and a
|
|
140
|
+
short `issued_at` skew window. Document it as recommended for any handler not on loopback.
|
|
141
|
+
- The handler's DB credential should still be least-privilege (in the lab: `synapsor_app`
|
|
142
|
+
= SELECT/UPDATE invoices + SELECT/INSERT credits, nothing else). The helper doesn't
|
|
143
|
+
replace DB perms; it complements them.
|
|
144
|
+
- Receipts table/dedup store: the helper should create-or-require it and, on permission
|
|
145
|
+
error, print the exact `GRANT`/DDL (same gap as the direct-writeback receipts table).
|
|
146
|
+
|
|
147
|
+
## Why this matters for adoption
|
|
148
|
+
|
|
149
|
+
App-owned executors are the answer to "rich writes" (INSERT/multi-row/events) — the
|
|
150
|
+
thing the runner deliberately won't do itself. But that answer is only safe if the
|
|
151
|
+
handler is safe, and right now safety is the integrator's homework. A first-party
|
|
152
|
+
helper makes the secure path the easy path, which is exactly the framing the whole
|
|
153
|
+
product is built on: don't hand people a footgun, hand them a reviewed capability.
|
|
154
|
+
|
|
155
|
+
## Acceptance
|
|
156
|
+
|
|
157
|
+
- `createWritebackHandler` (TS) + `synapsor_handler` (Python) enforce auth, signature,
|
|
158
|
+
tenant, version, idempotency, atomicity with no author code.
|
|
159
|
+
- A handler written with the helper passes the same conflict/idempotency/tenant tests
|
|
160
|
+
the runner ships for direct writeback.
|
|
161
|
+
- Request/receipt schemas are published and versioned (`protocol_version`).
|