@synapsor/runner 0.1.0-alpha.11 → 0.1.0-alpha.14

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 (33) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +169 -23
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/runner.mjs +855 -66
  5. package/docs/README.md +21 -0
  6. package/docs/app-owned-executors.md +5 -0
  7. package/docs/capability-authoring.md +265 -0
  8. package/docs/doctor.md +98 -0
  9. package/docs/handler-helper.md +217 -0
  10. package/docs/local-mode.md +13 -2
  11. package/docs/release-notes.md +57 -2
  12. package/docs/release-policy.md +86 -0
  13. package/docs/result-envelope-v2.md +148 -0
  14. package/docs/rfcs/001-result-envelope-v2.md +143 -0
  15. package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
  16. package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
  17. package/docs/store-lifecycle.md +83 -0
  18. package/docs/writeback-executors.md +18 -0
  19. package/examples/app-owned-writeback/README.md +1 -0
  20. package/examples/mcp-postgres-billing-app-handler/README.md +7 -2
  21. package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +77 -149
  22. package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +1 -0
  23. package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
  24. package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +1 -0
  25. package/package.json +3 -1
  26. package/schemas/change-set.v1.schema.json +140 -0
  27. package/schemas/execution-receipt.v1.schema.json +34 -0
  28. package/schemas/onboarding-selection.v1.schema.json +125 -0
  29. package/schemas/runner-registration.v1.schema.json +48 -0
  30. package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
  31. package/schemas/synapsor.app-handler-request.v1.json +119 -0
  32. package/schemas/synapsor.runner.schema.json +412 -0
  33. package/schemas/writeback-job.v1.schema.json +121 -0
@@ -11,6 +11,55 @@ 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.14
15
+
16
+ ### Handler Helper And Changelog Clarity
17
+
18
+ - Public docs now state that `@synapsor/handler` is not published as a
19
+ standalone npm package yet. The helper currently ships as source under
20
+ `packages/handler` and as the bundled `synapsor-handler.mjs` shim in the
21
+ app-owned executor example included with `@synapsor/runner`.
22
+ - `CHANGELOG.md` is included in the `@synapsor/runner` npm tarball.
23
+
24
+ ## 0.1.0-alpha.13
25
+
26
+ ### README First-Five-Minutes Polish
27
+
28
+ - The README now opens with the plain mental model: the agent talks to Runner,
29
+ can inspect scoped data, can create proposals, cannot commit, and writeback
30
+ plus replay happen outside the model-facing tool.
31
+ - Capability, proposal, writeback, and executor are defined before the first
32
+ command so a new reader can understand the rest of the docs.
33
+ - The README now states the direct-writeback rule early: guarded one-row updates
34
+ can use Runner direct writeback; inserts, multi-table work, events, and other
35
+ rich writes belong in an app-owned executor.
36
+ - The own-database section now includes a tiny readable config with one read
37
+ capability and one proposal capability so users can picture what the wizard
38
+ generates before they run it.
39
+
40
+ ## 0.1.0-alpha.12
41
+
42
+ ### Doctor And Writeback Checks
43
+
44
+ - `synapsor-runner doctor --config synapsor.runner.json --check-writeback`
45
+ verifies direct SQL writer connectivity, receipt-table readiness, and
46
+ rollback-only target-table access for reviewed proposal capabilities.
47
+ - Plain `doctor` warns when direct SQL writeback exists but has not been probed.
48
+ - The writeback probe uses fixed identifiers from reviewed config only. It does
49
+ not accept model SQL, user SQL, arbitrary table names, or arbitrary columns.
50
+ - Probe failures are redacted to safe categories such as `connection failed`,
51
+ `permission denied`, and `configured object not found`.
52
+ - `docs/doctor.md` explains handler checks, direct SQL writeback checks, and
53
+ receipt-table DDL/grant guidance.
54
+
55
+ ### Store Lifecycle
56
+
57
+ - `synapsor-runner store reset --store ./.synapsor/local.db --yes` removes only
58
+ local SQLite ledger files and reports `source_database_changed: false`.
59
+ - Destructive store reset refuses active server leases by default and requires
60
+ `--force` for advanced/stale-lease recovery.
61
+ - Packed and public verifier scripts now cover `store reset`.
62
+
14
63
  ## 0.1.0-alpha.11
15
64
 
16
65
  ### OpenAI MCP Aliases
@@ -67,7 +116,8 @@ for the Synapsor Cloud CLI.
67
116
  initialize/session behavior on the `/mcp` endpoint.
68
117
  - `synapsor-runner mcp serve-http` is an authenticated JSON-RPC bridge for
69
118
  simple `tools/list`, `tools/call`, and `resources/read` wrappers. It is not
70
- the standard Streamable HTTP MCP transport.
119
+ the standard Streamable HTTP MCP transport and prints a runtime warning when
120
+ started.
71
121
  - The OpenAI Agents SDK HTTP example uses the Streamable HTTP MCP path. Use the
72
122
  JSON-RPC bridge only when you intentionally want a thin app-owned wrapper.
73
123
 
@@ -100,6 +150,11 @@ for the Synapsor Cloud CLI.
100
150
  binding, evidence handles, query audit, and local inspection records.
101
151
  - Proposal workflows add full local replay across evidence, approval,
102
152
  writeback jobs, execution receipts, and events.
153
+ - `synapsor-runner events tail` prints local lifecycle events from the SQLite
154
+ ledger and can follow new proposal/writeback events while a local flow runs.
155
+ - MCP server modes write an active-store lease next to the local SQLite file.
156
+ Destructive `store prune --yes` refuses while that lease points at a live
157
+ process unless `--force` is provided.
103
158
  - External Postgres/MySQL databases are not physically branched by Runner.
104
159
  Replay covers records captured by Runner; it is not external database
105
160
  time travel.
@@ -154,5 +209,5 @@ After publishing an alpha, verify the public package from a clean temporary
154
209
  directory:
155
210
 
156
211
  ```bash
157
- ./scripts/verify-published-alpha.sh 0.1.0-alpha.11
212
+ ./scripts/verify-published-alpha.sh 0.1.0-alpha.14
158
213
  ```
@@ -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.14
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`).