@synapsor/runner 0.1.0-alpha.10 → 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.
Files changed (54) hide show
  1. package/README.md +203 -21
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/runner.mjs +1103 -115
  4. package/docs/README.md +38 -0
  5. package/docs/app-owned-executors.md +26 -0
  6. package/docs/capability-authoring.md +265 -0
  7. package/docs/cloud-mode.md +24 -0
  8. package/docs/current-scope.md +24 -0
  9. package/docs/dependency-license-inventory.md +35 -0
  10. package/docs/doctor.md +98 -0
  11. package/docs/handler-helper.md +200 -0
  12. package/docs/http-mcp.md +35 -1
  13. package/docs/licensing.md +36 -0
  14. package/docs/local-mode.md +13 -2
  15. package/docs/mcp-client-setup.md +39 -0
  16. package/docs/openai-agents-sdk.md +57 -0
  17. package/docs/release-notes.md +76 -2
  18. package/docs/release-policy.md +86 -0
  19. package/docs/result-envelope-v2.md +148 -0
  20. package/docs/rfcs/001-result-envelope-v2.md +143 -0
  21. package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
  22. package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
  23. package/docs/store-lifecycle.md +83 -0
  24. package/docs/use-your-own-database.md +18 -0
  25. package/docs/writeback-executors.md +29 -0
  26. package/examples/app-owned-writeback/README.md +1 -0
  27. package/examples/mcp-postgres-billing-app-handler/README.md +86 -0
  28. package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +125 -0
  29. package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
  30. package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
  31. package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
  32. package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
  33. package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
  34. package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
  35. package/examples/openai-agents-http/README.md +10 -2
  36. package/examples/openai-agents-stdio/README.md +8 -4
  37. package/examples/openai-agents-stdio/agent.py +2 -0
  38. package/fixtures/benchmark/mcp-efficiency.json +53 -0
  39. package/fixtures/benchmark/mcp-efficiency.txt +25 -0
  40. package/fixtures/protocol/MANIFEST.json +54 -0
  41. package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
  42. package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
  43. package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
  44. package/fixtures/protocol/runner-registration.v1.json +22 -0
  45. package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
  46. package/package.json +4 -1
  47. package/schemas/change-set.v1.schema.json +140 -0
  48. package/schemas/execution-receipt.v1.schema.json +34 -0
  49. package/schemas/onboarding-selection.v1.schema.json +125 -0
  50. package/schemas/runner-registration.v1.schema.json +48 -0
  51. package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
  52. package/schemas/synapsor.app-handler-request.v1.json +119 -0
  53. package/schemas/synapsor.runner.schema.json +412 -0
  54. package/schemas/writeback-job.v1.schema.json +121 -0
@@ -88,6 +88,7 @@ literal values.
88
88
  "type": "bearer_env",
89
89
  "token_env": "SYNAPSOR_BILLING_HANDLER_TOKEN"
90
90
  },
91
+ "signing_secret_env": "SYNAPSOR_BILLING_HANDLER_SIGNING_SECRET",
91
92
  "timeout_ms": 5000
92
93
  }
93
94
  },
@@ -114,6 +115,17 @@ The handler receives proposal fields, the exact patch, evidence metadata,
114
115
  guards, and an idempotency key. It does not receive arbitrary model SQL or DB
115
116
  credentials from Synapsor Runner.
116
117
 
118
+ When `signing_secret_env` is set, Runner signs the exact JSON body with HMAC
119
+ SHA-256 and sends:
120
+
121
+ - `X-Synapsor-Signature: sha256=...`
122
+ - `X-Synapsor-Issued-At: ...`
123
+ - `X-Synapsor-Proposal-Id: ...`
124
+ - `Idempotency-Key: ...`
125
+
126
+ Use signing for any handler that is not strictly loopback-only and protected by
127
+ another trusted boundary.
128
+
117
129
  Handler responses:
118
130
 
119
131
  ```json
@@ -139,6 +151,12 @@ receipt is stored in replay.
139
151
  Use your application/API for business logic. Use Synapsor Runner for proposal,
140
152
  approval, evidence, policy boundary, and replay.
141
153
 
154
+ For TypeScript services, use the source-level helper in `packages/handler`.
155
+ It verifies bearer/HMAC auth, parses the request, locks the target row with the
156
+ tenant guard, checks the expected version, handles idempotency, wraps the
157
+ business effect in a transaction, and returns safe receipts without raw driver
158
+ errors. See [Handler Helper](handler-helper.md).
159
+
142
160
  This is the recommended path for writes that are richer than the current
143
161
  `sql_update` scope, such as:
144
162
 
@@ -159,6 +177,17 @@ Concrete business-action examples are in:
159
177
  examples/app-owned-writeback/business-actions.md
160
178
  ```
161
179
 
180
+ The full disposable Postgres account-credit demo is in:
181
+
182
+ ```text
183
+ examples/mcp-postgres-billing-app-handler/
184
+ ```
185
+
186
+ It proves the rich-write path end to end: the model creates a proposal, the
187
+ source DB is unchanged before approval, the app-owned handler inserts an
188
+ `account_credits` row after approval, retry is idempotent, and replay stores the
189
+ handler receipt.
190
+
162
191
  Or generate one into your app:
163
192
 
164
193
  ```bash
@@ -29,6 +29,7 @@ Add an executor and point one proposal capability at it:
29
29
  "type": "bearer_env",
30
30
  "token_env": "SYNAPSOR_APP_WRITEBACK_TOKEN"
31
31
  },
32
+ "signing_secret_env": "SYNAPSOR_APP_WRITEBACK_SIGNING_SECRET",
32
33
  "timeout_ms": 5000
33
34
  }
34
35
  },
@@ -0,0 +1,86 @@
1
+ # Postgres Billing App Handler
2
+
3
+ This example shows the two Synapsor Runner commit paths against one disposable
4
+ Postgres billing database.
5
+
6
+ Direct guarded SQL writeback:
7
+
8
+ - `billing.propose_late_fee_waiver`
9
+ - one-row `UPDATE`;
10
+ - tenant guard, allowed-column guard, conflict guard, idempotency receipt.
11
+
12
+ App-owned rich writeback:
13
+
14
+ - `billing.propose_account_credit`
15
+ - model-facing MCP only creates a proposal;
16
+ - approval happens outside MCP;
17
+ - Runner calls `billing_app_handler`;
18
+ - the app uses the first-party `@synapsor/handler` helper to verify bearer
19
+ auth, HMAC signature, tenant scope, expected row version, idempotency, and
20
+ transaction/receipt shape;
21
+ - the handler business code inserts an `account_credits` row and updates the
22
+ invoice inside the helper-owned transaction;
23
+ - Runner records the handler receipt and replay.
24
+
25
+ The model never receives `execute_sql`, approval tools, commit/apply tools,
26
+ database URLs, or write credentials.
27
+
28
+ ## Run
29
+
30
+ From the repository root:
31
+
32
+ ```bash
33
+ examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh
34
+ ```
35
+
36
+ Expected ending:
37
+
38
+ ```text
39
+ App-owned billing handler demo passed.
40
+ Verified: proposal first, source unchanged before approval, account credit inserted by app handler, idempotent retry, replay.
41
+ ```
42
+
43
+ ## Manual Start
44
+
45
+ ```bash
46
+ docker compose -f examples/mcp-postgres-billing-app-handler/docker-compose.yml up -d
47
+
48
+ export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_password@localhost:55437/synapsor_billing_app_handler"
49
+ export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
50
+ export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
51
+ export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
52
+ export BILLING_APP_HANDLER_SIGNING_SECRET="dev-handler-signing-secret"
53
+ export SYNAPSOR_TENANT_ID="acme"
54
+ export SYNAPSOR_PRINCIPAL="local_billing_operator"
55
+
56
+ node examples/mcp-postgres-billing-app-handler/app-handler.mjs
57
+ ```
58
+
59
+ Then, in another terminal:
60
+
61
+ ```bash
62
+ synapsor-runner tools preview \
63
+ --config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
64
+ --store ./tmp/billing-app-handler/local.db
65
+
66
+ synapsor-runner propose billing.propose_account_credit \
67
+ --json '{"invoice_id":"INV-3001","amount_cents":2500,"reason":"support-approved credit"}' \
68
+ --config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
69
+ --store ./tmp/billing-app-handler/local.db
70
+
71
+ synapsor-runner proposals approve latest --yes --store ./tmp/billing-app-handler/local.db
72
+ synapsor-runner apply latest \
73
+ --config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
74
+ --store ./tmp/billing-app-handler/local.db
75
+ synapsor-runner replay show latest --store ./tmp/billing-app-handler/local.db
76
+ ```
77
+
78
+ ## Why This Exists
79
+
80
+ Direct Runner SQL writeback should stay intentionally narrow. It is good for
81
+ simple, bounded, single-row updates.
82
+
83
+ For richer business transactions such as creating credits, refund reviews,
84
+ ledger rows, tickets, events, or multi-row updates, keep execution in your
85
+ application service. Synapsor Runner still owns proposal creation, evidence,
86
+ approval boundary, idempotency, receipt storage, and replay.
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ import http from "node:http";
3
+
4
+ const { createWritebackHandler } = await loadHandlerHelper();
5
+
6
+ const port = Number(process.env.BILLING_APP_HANDLER_PORT || "8787");
7
+
8
+ if (!process.env.BILLING_APP_WRITE_URL) {
9
+ console.error("BILLING_APP_WRITE_URL is required.");
10
+ process.exit(1);
11
+ }
12
+
13
+ const writebackHandler = createWritebackHandler({
14
+ tokenEnv: "BILLING_APP_HANDLER_TOKEN",
15
+ signingSecretEnv: "BILLING_APP_HANDLER_SIGNING_SECRET",
16
+ source: {
17
+ engine: "postgres",
18
+ writeUrlEnv: "BILLING_APP_WRITE_URL",
19
+ receiptTable: {
20
+ schema: "public",
21
+ table: "synapsor_handler_receipts",
22
+ },
23
+ },
24
+ capabilities: {
25
+ "billing.propose_account_credit": applyAccountCredit,
26
+ },
27
+ });
28
+
29
+ const server = http.createServer(async (request, response) => {
30
+ if (request.method === "GET" && request.url === "/healthz") {
31
+ return writeJson(response, 200, { ok: true });
32
+ }
33
+ if (request.method !== "POST" || request.url !== "/synapsor/writeback") {
34
+ return writeJson(response, 404, {
35
+ status: "failed",
36
+ rows_affected: 0,
37
+ safe_error_code: "NOT_FOUND",
38
+ source_database_mutated: false,
39
+ });
40
+ }
41
+ await writebackHandler(request, response);
42
+ });
43
+
44
+ server.listen(port, "127.0.0.1", () => {
45
+ console.error(`Billing app handler listening on http://127.0.0.1:${port}/synapsor/writeback`);
46
+ });
47
+
48
+ process.once("SIGINT", shutdown);
49
+ process.once("SIGTERM", shutdown);
50
+
51
+ async function shutdown() {
52
+ server.close();
53
+ process.exit(0);
54
+ }
55
+
56
+ async function applyAccountCredit(job, tx) {
57
+ const amountCents = Number(job.patch.credit_requested_cents);
58
+ const reason = String(job.patch.credit_reason || "approved account credit");
59
+ if (!Number.isInteger(amountCents) || amountCents <= 0) {
60
+ throw new Error("credit amount must be a positive integer");
61
+ }
62
+
63
+ const creditId = `CR-${job.proposalId.replace(/[^A-Za-z0-9]/g, "").slice(-12) || Date.now()}`;
64
+ await tx.insert("account_credits", {
65
+ id: creditId,
66
+ tenant_id: job.tenantId,
67
+ invoice_id: job.objectId,
68
+ customer_id: String(job.row.customer_id),
69
+ amount_cents: amountCents,
70
+ reason,
71
+ idempotency_key: job.idempotencyKey,
72
+ created_by: job.principal,
73
+ }, { schema: "public" });
74
+
75
+ const newVersion = new Date().toISOString();
76
+ const update = await tx.update("invoices", {
77
+ id: job.objectId,
78
+ tenant_id: job.tenantId,
79
+ }, {
80
+ credit_requested_cents: amountCents,
81
+ credit_reason: reason,
82
+ credited_cents: Number(job.row.credited_cents ?? 0) + amountCents,
83
+ updated_at: newVersion,
84
+ }, {
85
+ schema: "public",
86
+ returning: ["updated_at"],
87
+ });
88
+
89
+ if (update.rowCount !== 1) {
90
+ throw new Error("invoice update affected an unexpected number of rows");
91
+ }
92
+
93
+ return {
94
+ rowsAffected: 2,
95
+ newVersion: timestampString(update.rows[0]?.updated_at ?? newVersion),
96
+ effects: [
97
+ { type: "db.insert", table: "account_credits", id: creditId },
98
+ { type: "db.update", table: "invoices", id: job.objectId },
99
+ { type: "event", name: "billing.account_credit_created" },
100
+ ],
101
+ };
102
+ }
103
+
104
+ function timestampString(value) {
105
+ if (value instanceof Date) return value.toISOString();
106
+ return new Date(String(value)).toISOString();
107
+ }
108
+
109
+ function writeJson(response, statusCode, body) {
110
+ response.statusCode = statusCode;
111
+ response.setHeader("content-type", "application/json; charset=utf-8");
112
+ response.end(`${JSON.stringify(body, null, 2)}\n`);
113
+ }
114
+
115
+ async function loadHandlerHelper() {
116
+ try {
117
+ return await import("@synapsor/handler");
118
+ } catch (workspaceError) {
119
+ try {
120
+ return await import(new URL("./synapsor-handler.mjs", import.meta.url));
121
+ } catch {
122
+ throw workspaceError;
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,13 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:16
4
+ container_name: synapsor_runner_billing_app_handler
5
+ environment:
6
+ POSTGRES_DB: synapsor_billing_app_handler
7
+ POSTGRES_USER: synapsor_admin
8
+ POSTGRES_PASSWORD: synapsor_admin_password
9
+ ports:
10
+ - "55437:5432"
11
+ volumes:
12
+ - ./schema.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro
13
+ - ./seed.sql:/docker-entrypoint-initdb.d/002_seed.sql:ro
@@ -0,0 +1,59 @@
1
+ CREATE TABLE IF NOT EXISTS public.tenants (
2
+ id text PRIMARY KEY,
3
+ name text NOT NULL,
4
+ created_at timestamptz NOT NULL DEFAULT now(),
5
+ updated_at timestamptz NOT NULL DEFAULT now()
6
+ );
7
+
8
+ CREATE TABLE IF NOT EXISTS public.customers (
9
+ id text PRIMARY KEY,
10
+ tenant_id text NOT NULL REFERENCES public.tenants(id),
11
+ name text NOT NULL,
12
+ plan text NOT NULL,
13
+ created_at timestamptz NOT NULL DEFAULT now(),
14
+ updated_at timestamptz NOT NULL DEFAULT now()
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS public.invoices (
18
+ id text PRIMARY KEY,
19
+ tenant_id text NOT NULL REFERENCES public.tenants(id),
20
+ customer_id text NOT NULL REFERENCES public.customers(id),
21
+ status text NOT NULL,
22
+ balance_cents integer NOT NULL,
23
+ late_fee_cents integer NOT NULL,
24
+ waiver_reason text,
25
+ credit_requested_cents integer NOT NULL DEFAULT 0,
26
+ credit_reason text,
27
+ credited_cents integer NOT NULL DEFAULT 0,
28
+ updated_at timestamptz NOT NULL DEFAULT now()
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS public.account_credits (
32
+ id text PRIMARY KEY,
33
+ tenant_id text NOT NULL REFERENCES public.tenants(id),
34
+ invoice_id text NOT NULL REFERENCES public.invoices(id),
35
+ customer_id text NOT NULL REFERENCES public.customers(id),
36
+ amount_cents integer NOT NULL CHECK (amount_cents > 0),
37
+ reason text NOT NULL,
38
+ idempotency_key text NOT NULL UNIQUE,
39
+ created_by text NOT NULL,
40
+ created_at timestamptz NOT NULL DEFAULT now()
41
+ );
42
+
43
+ DO $$
44
+ BEGIN
45
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'synapsor_reader') THEN
46
+ CREATE ROLE synapsor_reader LOGIN PASSWORD 'synapsor_reader_password';
47
+ END IF;
48
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'synapsor_writer') THEN
49
+ CREATE ROLE synapsor_writer LOGIN PASSWORD 'synapsor_writer_password';
50
+ END IF;
51
+ END
52
+ $$;
53
+
54
+ GRANT CONNECT ON DATABASE synapsor_billing_app_handler TO synapsor_reader, synapsor_writer;
55
+ GRANT USAGE ON SCHEMA public TO synapsor_reader, synapsor_writer;
56
+ GRANT CREATE ON SCHEMA public TO synapsor_writer;
57
+ GRANT SELECT ON public.tenants, public.customers, public.invoices, public.account_credits TO synapsor_reader, synapsor_writer;
58
+ GRANT UPDATE (late_fee_cents, waiver_reason, credit_requested_cents, credit_reason, credited_cents, updated_at) ON public.invoices TO synapsor_writer;
59
+ GRANT INSERT ON public.account_credits TO synapsor_writer;
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
5
+ EXAMPLE_DIR="$ROOT/examples/mcp-postgres-billing-app-handler"
6
+ COMPOSE_FILE="$EXAMPLE_DIR/docker-compose.yml"
7
+ CONFIG="$EXAMPLE_DIR/synapsor.runner.json"
8
+ STORE="$ROOT/tmp/billing-app-handler/local.db"
9
+ BIN="${SYNAPSOR_RUNNER_BIN:-$ROOT/bin/synapsor-runner}"
10
+ HANDLER_LOG="$ROOT/tmp/billing-app-handler/handler.log"
11
+
12
+ cleanup() {
13
+ if [[ -n "${HANDLER_PID:-}" ]]; then
14
+ kill "$HANDLER_PID" >/dev/null 2>&1 || true
15
+ wait "$HANDLER_PID" >/dev/null 2>&1 || true
16
+ fi
17
+ docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
18
+ }
19
+ trap cleanup EXIT
20
+
21
+ mkdir -p "$ROOT/tmp/billing-app-handler"
22
+ rm -f "$STORE" "$HANDLER_LOG"
23
+
24
+ docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1 || true
25
+ docker compose -f "$COMPOSE_FILE" up -d >/dev/null
26
+
27
+ for _ in $(seq 1 90); do
28
+ invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices" 2>/dev/null || true)"
29
+ if [[ "$invoice_count" == "2" ]]; then
30
+ sleep 1
31
+ invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices" 2>/dev/null || true)"
32
+ if [[ "$invoice_count" == "2" ]]; then
33
+ break
34
+ fi
35
+ fi
36
+ sleep 1
37
+ done
38
+ invoice_count="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.invoices")"
39
+ if [[ "$invoice_count" != "2" ]]; then
40
+ echo "Postgres fixture did not finish seeding invoices." >&2
41
+ exit 1
42
+ fi
43
+
44
+ export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_password@localhost:55437/synapsor_billing_app_handler"
45
+ export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
46
+ export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
47
+ export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
48
+ export BILLING_APP_HANDLER_SIGNING_SECRET="dev-handler-signing-secret"
49
+ export SYNAPSOR_TENANT_ID="acme"
50
+ export SYNAPSOR_PRINCIPAL="local_billing_operator"
51
+
52
+ node "$EXAMPLE_DIR/app-handler.mjs" >"$HANDLER_LOG" 2>&1 &
53
+ HANDLER_PID="$!"
54
+
55
+ for _ in $(seq 1 90); do
56
+ if node -e "fetch('http://127.0.0.1:8787/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then
57
+ break
58
+ fi
59
+ sleep 1
60
+ done
61
+ node -e "fetch('http://127.0.0.1:8787/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
62
+
63
+ "$BIN" config validate --config "$CONFIG" >/dev/null
64
+ "$BIN" tools preview --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/tools.txt"
65
+ grep -F "billing.propose_account_credit" "$ROOT/tmp/billing-app-handler/tools.txt" >/dev/null
66
+ grep -F "execute_sql absent" "$ROOT/tmp/billing-app-handler/tools.txt" >/dev/null
67
+
68
+ printf '{"invoice_id":"INV-3001","amount_cents":2500,"reason":"support-approved credit"}\n' > "$ROOT/tmp/billing-app-handler/credit-input.json"
69
+ "$BIN" propose billing.propose_account_credit \
70
+ --input "$ROOT/tmp/billing-app-handler/credit-input.json" \
71
+ --config "$CONFIG" \
72
+ --store "$STORE" > "$ROOT/tmp/billing-app-handler/propose-credit.txt"
73
+ grep -F "Source DB changed:" "$ROOT/tmp/billing-app-handler/propose-credit.txt" >/dev/null
74
+ grep -F "no" "$ROOT/tmp/billing-app-handler/propose-credit.txt" >/dev/null
75
+
76
+ credit_count_before="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.account_credits")"
77
+ if [[ "$credit_count_before" != "0" ]]; then
78
+ echo "expected no account credits before approval, got $credit_count_before" >&2
79
+ exit 1
80
+ fi
81
+
82
+ "$BIN" proposals approve latest --yes --store "$STORE" >/dev/null
83
+ "$BIN" apply latest --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/apply-credit.txt"
84
+ grep -F "App-owned writeback applied." "$ROOT/tmp/billing-app-handler/apply-credit.txt" >/dev/null
85
+ grep -F "source database changed by handler: yes" "$ROOT/tmp/billing-app-handler/apply-credit.txt" >/dev/null
86
+
87
+ credit_count_after="$(docker exec synapsor_runner_billing_app_handler psql -U synapsor_admin -d synapsor_billing_app_handler -Atc "SELECT count(*) FROM public.account_credits WHERE invoice_id = 'INV-3001' AND amount_cents = 2500")"
88
+ if [[ "$credit_count_after" != "1" ]]; then
89
+ echo "expected one inserted account credit, got $credit_count_after" >&2
90
+ exit 1
91
+ fi
92
+
93
+ "$BIN" apply latest --config "$CONFIG" --store "$STORE" > "$ROOT/tmp/billing-app-handler/apply-credit-retry.txt"
94
+ grep -F "App-owned writeback already applied." "$ROOT/tmp/billing-app-handler/apply-credit-retry.txt" >/dev/null
95
+
96
+ "$BIN" replay show latest --store "$STORE" > "$ROOT/tmp/billing-app-handler/replay.txt"
97
+ grep -F "billing.propose_account_credit" "$ROOT/tmp/billing-app-handler/replay.txt" >/dev/null
98
+
99
+ echo "App-owned billing handler demo passed."
100
+ echo "Verified: proposal first, source unchanged before approval, account credit inserted by app handler, idempotent retry, replay."
@@ -0,0 +1,39 @@
1
+ INSERT INTO public.tenants (id, name, created_at, updated_at)
2
+ VALUES
3
+ ('acme', 'Acme Robotics', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
4
+ ('globex', 'Globex Labs', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
5
+ ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at;
6
+
7
+ INSERT INTO public.customers (id, tenant_id, name, plan, created_at, updated_at)
8
+ VALUES
9
+ ('cust_acme_1', 'acme', 'Acme Robotics', 'enterprise', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
10
+ ('cust_globex_1', 'globex', 'Globex Labs', 'builder', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
11
+ ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, name = EXCLUDED.name, plan = EXCLUDED.plan, updated_at = EXCLUDED.updated_at;
12
+
13
+ INSERT INTO public.invoices (
14
+ id,
15
+ tenant_id,
16
+ customer_id,
17
+ status,
18
+ balance_cents,
19
+ late_fee_cents,
20
+ waiver_reason,
21
+ credit_requested_cents,
22
+ credit_reason,
23
+ credited_cents,
24
+ updated_at
25
+ )
26
+ VALUES
27
+ ('INV-3001', 'acme', 'cust_acme_1', 'overdue', 25500, 5500, NULL, 0, NULL, 0, '2026-06-20T14:31:08Z'),
28
+ ('INV-9001', 'globex', 'cust_globex_1', 'overdue', 25500, 5500, NULL, 0, NULL, 0, '2026-06-20T14:31:08Z')
29
+ ON CONFLICT (id) DO UPDATE SET
30
+ tenant_id = EXCLUDED.tenant_id,
31
+ customer_id = EXCLUDED.customer_id,
32
+ status = EXCLUDED.status,
33
+ balance_cents = EXCLUDED.balance_cents,
34
+ late_fee_cents = EXCLUDED.late_fee_cents,
35
+ waiver_reason = EXCLUDED.waiver_reason,
36
+ credit_requested_cents = EXCLUDED.credit_requested_cents,
37
+ credit_reason = EXCLUDED.credit_reason,
38
+ credited_cents = EXCLUDED.credited_cents,
39
+ updated_at = EXCLUDED.updated_at;