@synapsor/runner 0.1.0-alpha.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +388 -41
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/runner.mjs +2982 -238
  6. package/docs/README.md +40 -0
  7. package/docs/app-owned-executors.md +38 -0
  8. package/docs/capability-authoring.md +265 -0
  9. package/docs/cloud-mode.md +24 -0
  10. package/docs/current-scope.md +29 -0
  11. package/docs/dependency-license-inventory.md +35 -0
  12. package/docs/doctor.md +98 -0
  13. package/docs/getting-started-own-database.md +131 -46
  14. package/docs/handler-helper.md +228 -0
  15. package/docs/http-mcp.md +85 -17
  16. package/docs/licensing.md +36 -0
  17. package/docs/local-mode.md +44 -25
  18. package/docs/mcp-audit.md +8 -8
  19. package/docs/mcp-client-setup.md +59 -21
  20. package/docs/openai-agents-sdk.md +57 -0
  21. package/docs/recipes.md +6 -6
  22. package/docs/release-notes.md +327 -0
  23. package/docs/release-policy.md +125 -0
  24. package/docs/result-envelope-v2.md +151 -0
  25. package/docs/rfcs/001-result-envelope-v2.md +143 -0
  26. package/docs/rfcs/002-app-owned-handler-helper.md +161 -0
  27. package/docs/rfcs/003-integrator-feedback-teardown.md +97 -0
  28. package/docs/store-lifecycle.md +83 -0
  29. package/docs/troubleshooting-first-run.md +6 -6
  30. package/docs/use-your-own-database.md +18 -0
  31. package/docs/writeback-executors.md +92 -1
  32. package/examples/app-owned-writeback/README.md +128 -0
  33. package/examples/app-owned-writeback/business-actions.md +221 -0
  34. package/examples/app-owned-writeback/command-handler.mjs +55 -0
  35. package/examples/app-owned-writeback/node-fastify-handler.mjs +64 -0
  36. package/examples/app-owned-writeback/python-fastapi-handler.py +66 -0
  37. package/examples/mcp-postgres-billing-app-handler/README.md +94 -0
  38. package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +123 -0
  39. package/examples/mcp-postgres-billing-app-handler/docker-compose.yml +13 -0
  40. package/examples/mcp-postgres-billing-app-handler/schema.sql +59 -0
  41. package/examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh +100 -0
  42. package/examples/mcp-postgres-billing-app-handler/seed.sql +39 -0
  43. package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +437 -0
  44. package/examples/mcp-postgres-billing-app-handler/synapsor.runner.json +158 -0
  45. package/examples/openai-agents-http/README.md +19 -12
  46. package/examples/openai-agents-http/agent.py +29 -65
  47. package/examples/openai-agents-stdio/README.md +10 -6
  48. package/examples/openai-agents-stdio/agent.py +4 -2
  49. package/examples/reference-support-billing-app/README.md +16 -16
  50. package/examples/reference-support-billing-app/mcp-client.generic.json +1 -1
  51. package/fixtures/benchmark/mcp-efficiency.json +53 -0
  52. package/fixtures/benchmark/mcp-efficiency.txt +25 -0
  53. package/fixtures/protocol/MANIFEST.json +54 -0
  54. package/fixtures/protocol/change-set.late-fee-waiver.v1.json +72 -0
  55. package/fixtures/protocol/execution-receipt.applied.v1.json +14 -0
  56. package/fixtures/protocol/execution-receipt.conflict.v1.json +15 -0
  57. package/fixtures/protocol/runner-registration.v1.json +22 -0
  58. package/fixtures/protocol/writeback-job.late-fee-waiver.v1.json +44 -0
  59. package/package.json +6 -1
  60. package/schemas/change-set.v1.schema.json +140 -0
  61. package/schemas/execution-receipt.v1.schema.json +34 -0
  62. package/schemas/onboarding-selection.v1.schema.json +132 -0
  63. package/schemas/runner-registration.v1.schema.json +48 -0
  64. package/schemas/synapsor.app-handler-receipt.v1.json +39 -0
  65. package/schemas/synapsor.app-handler-request.v1.json +119 -0
  66. package/schemas/synapsor.runner.schema.json +415 -0
  67. package/schemas/writeback-job.v1.schema.json +121 -0
@@ -0,0 +1,94 @@
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 handler helper from the source workspace, or the
19
+ bundled `synapsor-handler.mjs` shim included in the runner npm package, to
20
+ verify bearer auth, HMAC signature, tenant scope, expected row version,
21
+ idempotency, and transaction/receipt shape;
22
+ - the handler business code inserts an `account_credits` row and updates the
23
+ invoice inside the helper-owned transaction;
24
+ - Runner records the handler receipt and replay.
25
+
26
+ The model never receives `execute_sql`, approval tools, commit/apply tools,
27
+ database URLs, or write credentials.
28
+
29
+ > **Important:** the app handler owns the final business write. Runner creates
30
+ > the proposal and calls the handler only after approval, but the handler must
31
+ > still enforce tenant/scope checks, expected-version or conflict guards,
32
+ > idempotency keys, allowed business actions, transaction/rollback, and safe
33
+ > error receipts. If those checks are skipped, the app can reintroduce
34
+ > cross-tenant writes, lost updates, or duplicate writes.
35
+
36
+ ## Run
37
+
38
+ From the repository root:
39
+
40
+ ```bash
41
+ examples/mcp-postgres-billing-app-handler/scripts/run-demo.sh
42
+ ```
43
+
44
+ Expected ending:
45
+
46
+ ```text
47
+ App-owned billing handler demo passed.
48
+ Verified: proposal first, source unchanged before approval, account credit inserted by app handler, idempotent retry, replay.
49
+ ```
50
+
51
+ ## Manual Start
52
+
53
+ ```bash
54
+ docker compose -f examples/mcp-postgres-billing-app-handler/docker-compose.yml up -d
55
+
56
+ export BILLING_APP_READ_URL="postgresql://synapsor_reader:synapsor_reader_password@localhost:55437/synapsor_billing_app_handler"
57
+ export BILLING_APP_WRITE_URL="postgresql://synapsor_writer:synapsor_writer_password@localhost:55437/synapsor_billing_app_handler"
58
+ export BILLING_APP_HANDLER_URL="http://127.0.0.1:8787/synapsor/writeback"
59
+ export BILLING_APP_HANDLER_TOKEN="dev-handler-token"
60
+ export BILLING_APP_HANDLER_SIGNING_SECRET="dev-handler-signing-secret"
61
+ export SYNAPSOR_TENANT_ID="acme"
62
+ export SYNAPSOR_PRINCIPAL="local_billing_operator"
63
+
64
+ node examples/mcp-postgres-billing-app-handler/app-handler.mjs
65
+ ```
66
+
67
+ Then, in another terminal:
68
+
69
+ ```bash
70
+ synapsor-runner tools preview \
71
+ --config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
72
+ --store ./tmp/billing-app-handler/local.db
73
+
74
+ synapsor-runner propose billing.propose_account_credit \
75
+ --json '{"invoice_id":"INV-3001","amount_cents":2500,"reason":"support-approved credit"}' \
76
+ --config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
77
+ --store ./tmp/billing-app-handler/local.db
78
+
79
+ synapsor-runner proposals approve latest --yes --store ./tmp/billing-app-handler/local.db
80
+ synapsor-runner apply latest \
81
+ --config examples/mcp-postgres-billing-app-handler/synapsor.runner.json \
82
+ --store ./tmp/billing-app-handler/local.db
83
+ synapsor-runner replay show latest --store ./tmp/billing-app-handler/local.db
84
+ ```
85
+
86
+ ## Why This Exists
87
+
88
+ Direct Runner SQL writeback should stay intentionally narrow. It is good for
89
+ simple, bounded, single-row updates.
90
+
91
+ For richer business transactions such as creating credits, refund reviews,
92
+ ledger rows, tickets, events, or multi-row updates, keep execution in your
93
+ application service. Synapsor Runner still owns proposal creation, evidence,
94
+ approval boundary, idempotency, receipt storage, and replay.
@@ -0,0 +1,123 @@
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
+ /*
58
+ * IMPORTANT: this app handler owns the final business write.
59
+ * The helper has already verified auth, tenant scope, expected version,
60
+ * idempotency, and transaction wrapping before this function runs. Keep that
61
+ * pattern if you replace the helper or move this logic into your app.
62
+ */
63
+ const amountCents = Number(job.patch.credit_requested_cents);
64
+ const reason = String(job.patch.credit_reason || "approved account credit");
65
+ if (!Number.isInteger(amountCents) || amountCents <= 0) {
66
+ throw new Error("credit amount must be a positive integer");
67
+ }
68
+
69
+ const creditId = `CR-${job.proposalId.replace(/[^A-Za-z0-9]/g, "").slice(-12) || Date.now()}`;
70
+ await tx.insert("account_credits", {
71
+ id: creditId,
72
+ tenant_id: job.tenantId,
73
+ invoice_id: job.objectId,
74
+ customer_id: String(job.row.customer_id),
75
+ amount_cents: amountCents,
76
+ reason,
77
+ idempotency_key: job.idempotencyKey,
78
+ created_by: job.principal,
79
+ }, { schema: "public" });
80
+
81
+ const newVersion = new Date().toISOString();
82
+ const update = await tx.update("invoices", {
83
+ id: job.objectId,
84
+ tenant_id: job.tenantId,
85
+ }, {
86
+ credit_requested_cents: amountCents,
87
+ credit_reason: reason,
88
+ credited_cents: Number(job.row.credited_cents ?? 0) + amountCents,
89
+ updated_at: newVersion,
90
+ }, {
91
+ schema: "public",
92
+ returning: ["updated_at"],
93
+ });
94
+
95
+ if (update.rowCount !== 1) {
96
+ throw new Error("invoice update affected an unexpected number of rows");
97
+ }
98
+
99
+ return {
100
+ rowsAffected: 2,
101
+ newVersion: timestampString(update.rows[0]?.updated_at ?? newVersion),
102
+ effects: [
103
+ { type: "db.insert", table: "account_credits", id: creditId },
104
+ { type: "db.update", table: "invoices", id: job.objectId },
105
+ { type: "event", name: "billing.account_credit_created" },
106
+ ],
107
+ };
108
+ }
109
+
110
+ function timestampString(value) {
111
+ if (value instanceof Date) return value.toISOString();
112
+ return new Date(String(value)).toISOString();
113
+ }
114
+
115
+ function writeJson(response, statusCode, body) {
116
+ response.statusCode = statusCode;
117
+ response.setHeader("content-type", "application/json; charset=utf-8");
118
+ response.end(`${JSON.stringify(body, null, 2)}\n`);
119
+ }
120
+
121
+ async function loadHandlerHelper() {
122
+ return await import(new URL("./synapsor-handler.mjs", import.meta.url));
123
+ }
@@ -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;