@synapsor/runner 1.4.123 → 1.5.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.
- package/CHANGELOG.md +55 -19
- package/README.md +56 -46
- package/dist/cli.d.ts.map +1 -1
- package/dist/local-ui.d.ts.map +1 -1
- package/dist/runner.mjs +13848 -7752
- package/dist/runtime.d.ts +50 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.mjs +15066 -0
- package/docs/README.md +19 -3
- package/docs/capability-authoring.md +1 -1
- package/docs/database-enforced-scope.md +273 -0
- package/docs/dependency-license-inventory.md +6 -2
- package/docs/doctor.md +17 -0
- package/docs/effect-regression.md +142 -0
- package/docs/getting-started-own-database.md +7 -0
- package/docs/local-mode.md +7 -3
- package/docs/mcp-apps.md +188 -0
- package/docs/mcp-audit.md +54 -0
- package/docs/mcp-client-setup.md +16 -4
- package/docs/mcp-clients.md +8 -0
- package/docs/production.md +8 -0
- package/docs/release-notes.md +51 -13
- package/docs/release-policy.md +9 -5
- package/docs/runner-config-reference.md +25 -0
- package/docs/schema-api-candidates.md +68 -0
- package/docs/security-boundary.md +10 -0
- package/docs/shadow-studies.md +198 -0
- package/examples/mcp-postgres-billing-app-handler/app-handler.mjs +9 -1
- package/examples/mcp-postgres-billing-app-handler/schema.sql +11 -0
- package/examples/mcp-postgres-billing-app-handler/synapsor-handler.mjs +3 -0
- package/examples/raw-sql-vs-synapsor/Makefile +2 -2
- package/examples/raw-sql-vs-synapsor/README.md +6 -4
- package/examples/support-billing-agent/Makefile +5 -1
- package/examples/support-billing-agent/README.md +39 -5
- package/examples/support-billing-agent/db/schema.sql +88 -1
- package/examples/support-billing-agent/db/seed.sql +15 -15
- package/examples/support-billing-agent/scripts/run-demo.sh +4 -0
- package/examples/support-billing-agent/scripts/run-evaluation.sh +62 -0
- package/examples/support-billing-agent/shadow-study/cases.jsonl +6 -0
- package/examples/support-billing-agent/shadow-study/outcomes.jsonl +2 -0
- package/examples/support-billing-agent/synapsor.runner.json +17 -5
- package/fixtures/effects/changed/eff_support_late_fee_v1.result.json +58 -0
- package/fixtures/effects/dataset.json +8 -0
- package/fixtures/effects/results/eff_support_late_fee_v1.result.json +59 -0
- package/fixtures/effects/support-late-fee.effect.json +71 -0
- package/fixtures/generators/drizzle/malicious.ts +11 -0
- package/fixtures/generators/drizzle/schema.ts +12 -0
- package/fixtures/generators/openapi/external-ref.yaml +15 -0
- package/fixtures/generators/openapi/openapi.yaml +81 -0
- package/fixtures/generators/prisma/schema.prisma +34 -0
- package/package.json +13 -2
- package/schemas/effect-dataset.schema.json +19 -0
- package/schemas/effect-fixture.schema.json +191 -0
- package/schemas/effect-result.schema.json +52 -0
- package/schemas/mcp-audit-candidates.schema.json +41 -0
- package/schemas/mcp-audit-report.schema.json +104 -0
- package/schemas/schema-candidate-review.schema.json +167 -0
- package/schemas/schema-candidates.schema.json +49 -0
- package/schemas/synapsor.runner.schema.json +51 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Shadow Studies
|
|
2
|
+
|
|
3
|
+
Use a shadow study before granting an agent write authority. Runner serves the
|
|
4
|
+
reviewed proposal capability and records what the agent would have proposed,
|
|
5
|
+
but the proposal cannot be approved, queued, or applied.
|
|
6
|
+
|
|
7
|
+
A study compares two explicit records:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
agent result or proposed effect
|
|
11
|
+
versus
|
|
12
|
+
authoritative outcome recorded by your application or operator
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Runner does not infer a human rejection from an unchanged database row. A case
|
|
16
|
+
without an authoritative outcome remains visibly unmatched.
|
|
17
|
+
|
|
18
|
+
## Safety Boundary
|
|
19
|
+
|
|
20
|
+
Set the Runner configuration to:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"version": 1,
|
|
25
|
+
"mode": "shadow"
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The proposal store enforces the shadow boundary below the CLI and local UI:
|
|
30
|
+
|
|
31
|
+
- a shadow proposal cannot be approved;
|
|
32
|
+
- it cannot become a writeback job or worker item;
|
|
33
|
+
- it cannot invoke direct database writeback or an app-owned handler;
|
|
34
|
+
- no flag bypasses these checks;
|
|
35
|
+
- a report suggestion is inactive and never edits or activates a contract.
|
|
36
|
+
|
|
37
|
+
Shadow data remains in the configured local Runner store. Case and outcome
|
|
38
|
+
imports reject obvious credentials, bearer tokens, database URLs, and
|
|
39
|
+
secret-like fields.
|
|
40
|
+
|
|
41
|
+
## Start A Study
|
|
42
|
+
|
|
43
|
+
Create a study before serving the shadow capability. An empty capability list
|
|
44
|
+
means all shadow capabilities; repeat `--capability` or use comma-separated
|
|
45
|
+
names to narrow it.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
synapsor-runner shadow study create \
|
|
49
|
+
--id sst_support_pilot \
|
|
50
|
+
--name "Support waiver pilot" \
|
|
51
|
+
--capability billing.propose_late_fee_waiver \
|
|
52
|
+
--store ./.synapsor/local.db
|
|
53
|
+
|
|
54
|
+
synapsor-runner mcp serve \
|
|
55
|
+
--config ./synapsor.runner.json \
|
|
56
|
+
--store ./.synapsor/local.db
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
New matching shadow proposals are attached automatically. To attach proposals
|
|
60
|
+
that already exist, or to repair correlation after an interrupted process:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
synapsor-runner shadow study sync sst_support_pilot \
|
|
64
|
+
--store ./.synapsor/local.db
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Record Non-Proposal Results
|
|
68
|
+
|
|
69
|
+
A study also needs tasks where the agent was unable or not allowed to propose.
|
|
70
|
+
Import one JSON object with `case record`, or a JSON array/JSONL file with
|
|
71
|
+
`case import`.
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"request_id": "req-policy-42",
|
|
76
|
+
"tenant_id": "acme",
|
|
77
|
+
"principal": "support-agent-shadow",
|
|
78
|
+
"capability": "billing.propose_late_fee_waiver",
|
|
79
|
+
"business_object": "invoice",
|
|
80
|
+
"object_id": "INV-3042",
|
|
81
|
+
"agent_result": "policy_denied",
|
|
82
|
+
"decision_reason": "amount above reviewed bound",
|
|
83
|
+
"risk_score": 35
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Allowed `agent_result` values are:
|
|
88
|
+
|
|
89
|
+
- `proposed`
|
|
90
|
+
- `policy_denied`
|
|
91
|
+
- `unable_to_propose`
|
|
92
|
+
- `stale_conflict`
|
|
93
|
+
- `invalid_unsafe_scope_attempt`
|
|
94
|
+
|
|
95
|
+
For `proposed`, include normalized `before`, `after`, and `patch` objects under
|
|
96
|
+
`proposed_effect`.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
synapsor-runner shadow case import \
|
|
100
|
+
--study sst_support_pilot \
|
|
101
|
+
--input ./cases.jsonl \
|
|
102
|
+
--store ./.synapsor/local.db
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Imports are limited to 2 MiB and 10,000 records per command.
|
|
106
|
+
|
|
107
|
+
## Record Authoritative Outcomes
|
|
108
|
+
|
|
109
|
+
Export actual outcomes from your application audit log or have a trusted
|
|
110
|
+
operator create them. Each outcome must match an existing case by study,
|
|
111
|
+
request, trusted tenant, business object, and object ID. A supplied proposal ID
|
|
112
|
+
must match too.
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"request_id": "request_123",
|
|
117
|
+
"proposal_id": "wrp_123",
|
|
118
|
+
"tenant_id": "acme",
|
|
119
|
+
"business_object": "invoice",
|
|
120
|
+
"object_id": "INV-3001",
|
|
121
|
+
"actor": "support_lead_1",
|
|
122
|
+
"disposition": "applied",
|
|
123
|
+
"actual_effect": {
|
|
124
|
+
"before": { "late_fee_cents": 5500 },
|
|
125
|
+
"after": { "late_fee_cents": 0 },
|
|
126
|
+
"patch": { "late_fee_cents": 0 }
|
|
127
|
+
},
|
|
128
|
+
"occurred_at": "2026-07-19T12:00:00.000Z",
|
|
129
|
+
"source": "support-system-audit",
|
|
130
|
+
"reference": "ticket:SUP-184",
|
|
131
|
+
"reason": "customer qualified"
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Allowed dispositions are `applied`, `rejected_no_action`, and
|
|
136
|
+
`stale_conflict`. An applied outcome requires an exact normalized effect.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
synapsor-runner shadow outcome import \
|
|
140
|
+
--study sst_support_pilot \
|
|
141
|
+
--input ./outcomes.jsonl \
|
|
142
|
+
--store ./.synapsor/local.db
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Review The Report
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
synapsor-runner shadow report \
|
|
149
|
+
--study sst_support_pilot \
|
|
150
|
+
--output ./shadow-report.json \
|
|
151
|
+
--store ./.synapsor/local.db
|
|
152
|
+
|
|
153
|
+
synapsor-runner ui \
|
|
154
|
+
--config ./synapsor.runner.json \
|
|
155
|
+
--store ./.synapsor/local.db
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The stable JSON report preserves total and comparable denominators and
|
|
159
|
+
classifies exact, partial, disagreement, human rejection/no action, policy
|
|
160
|
+
denial, unable-to-propose, stale/conflict, unmatched, and unsafe-scope cases.
|
|
161
|
+
It also includes amount distributions, capability/reason breakdowns, and
|
|
162
|
+
risk-ranked disagreements.
|
|
163
|
+
|
|
164
|
+
Close a completed study:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
synapsor-runner shadow study close sst_support_pilot \
|
|
168
|
+
--store ./.synapsor/local.db
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Deterministic Reference Data
|
|
172
|
+
|
|
173
|
+
The packaged `examples/support-billing-agent/shadow-study/` directory contains
|
|
174
|
+
six synthetic cases and two authoritative outcomes. It covers an exact $55
|
|
175
|
+
waiver, human rejection, policy denial, stale state, tenant forgery, and an
|
|
176
|
+
unmatched task.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
synapsor-runner shadow study create \
|
|
180
|
+
--id sst_support_reference \
|
|
181
|
+
--name "Support reference study" \
|
|
182
|
+
--capability billing.propose_late_fee_waiver \
|
|
183
|
+
--store ./.synapsor/shadow-reference.db
|
|
184
|
+
|
|
185
|
+
synapsor-runner shadow case import \
|
|
186
|
+
--study sst_support_reference \
|
|
187
|
+
--input ./examples/support-billing-agent/shadow-study/cases.jsonl \
|
|
188
|
+
--store ./.synapsor/shadow-reference.db
|
|
189
|
+
|
|
190
|
+
synapsor-runner shadow outcome import \
|
|
191
|
+
--study sst_support_reference \
|
|
192
|
+
--input ./examples/support-billing-agent/shadow-study/outcomes.jsonl \
|
|
193
|
+
--store ./.synapsor/shadow-reference.db
|
|
194
|
+
|
|
195
|
+
synapsor-runner shadow report \
|
|
196
|
+
--study sst_support_reference \
|
|
197
|
+
--store ./.synapsor/shadow-reference.db
|
|
198
|
+
```
|
|
@@ -119,5 +119,13 @@ function writeJson(response, statusCode, body) {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
async function loadHandlerHelper() {
|
|
122
|
-
|
|
122
|
+
const bundledHelper = new URL("./synapsor-handler.mjs", import.meta.url);
|
|
123
|
+
try {
|
|
124
|
+
return await import(bundledHelper);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code !== "ERR_MODULE_NOT_FOUND" || error?.url !== bundledHelper.href) {
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
return await import(new URL("../../packages/handler/dist/index.js", import.meta.url));
|
|
130
|
+
}
|
|
123
131
|
}
|
|
@@ -50,6 +50,16 @@ CREATE TABLE IF NOT EXISTS public.synapsor_writeback_receipts (
|
|
|
50
50
|
completed_at timestamptz
|
|
51
51
|
);
|
|
52
52
|
|
|
53
|
+
CREATE TABLE IF NOT EXISTS public.synapsor_handler_receipts (
|
|
54
|
+
idempotency_key text PRIMARY KEY,
|
|
55
|
+
proposal_id text NOT NULL,
|
|
56
|
+
action text NOT NULL,
|
|
57
|
+
status text NOT NULL,
|
|
58
|
+
receipt_json jsonb NOT NULL,
|
|
59
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
60
|
+
completed_at timestamptz
|
|
61
|
+
);
|
|
62
|
+
|
|
53
63
|
DO $$
|
|
54
64
|
BEGIN
|
|
55
65
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'synapsor_reader') THEN
|
|
@@ -65,5 +75,6 @@ GRANT CONNECT ON DATABASE synapsor_billing_app_handler TO synapsor_reader, synap
|
|
|
65
75
|
GRANT USAGE ON SCHEMA public TO synapsor_reader, synapsor_writer;
|
|
66
76
|
GRANT SELECT ON public.tenants, public.customers, public.invoices, public.account_credits TO synapsor_reader, synapsor_writer;
|
|
67
77
|
GRANT SELECT, INSERT, UPDATE ON public.synapsor_writeback_receipts TO synapsor_writer;
|
|
78
|
+
GRANT SELECT, INSERT, UPDATE ON public.synapsor_handler_receipts TO synapsor_writer;
|
|
68
79
|
GRANT UPDATE (late_fee_cents, waiver_reason, credit_requested_cents, credit_reason, credited_cents, updated_at) ON public.invoices TO synapsor_writer;
|
|
69
80
|
GRANT INSERT ON public.account_credits TO synapsor_writer;
|
|
@@ -168,6 +168,9 @@ class PostgresWritebackHandlerTransaction {
|
|
|
168
168
|
this.receiptTable = receiptTable;
|
|
169
169
|
}
|
|
170
170
|
async ensureReceiptTable() {
|
|
171
|
+
const existing = await this.client.query("SELECT to_regclass($1) AS receipt_table", [this.receiptTableName()]);
|
|
172
|
+
if (existing.rows[0]?.receipt_table)
|
|
173
|
+
return;
|
|
171
174
|
await this.client.query(`
|
|
172
175
|
CREATE TABLE IF NOT EXISTS ${this.receiptTableName()} (
|
|
173
176
|
idempotency_key text PRIMARY KEY,
|
|
@@ -4,8 +4,8 @@ demo:
|
|
|
4
4
|
@printf '%s\n' "Unsafe shortcut:"
|
|
5
5
|
@printf '%s\n' ' agent -> execute_sql("UPDATE invoices SET late_fee_cents = 0 ...")'
|
|
6
6
|
@printf '%s\n' ""
|
|
7
|
-
@printf '%s\n' "Synapsor
|
|
8
|
-
npx -y @synapsor/runner
|
|
7
|
+
@printf '%s\n' "Synapsor guarded-action proof:"
|
|
8
|
+
npx -y @synapsor/runner try --prove --yes --no-open
|
|
9
9
|
@printf '%s\n' ""
|
|
10
10
|
@printf '%s\n' "Risk audit example:"
|
|
11
11
|
npx -y @synapsor/runner audit --example dangerous-db-mcp --format markdown
|
|
@@ -13,10 +13,12 @@ Expected output includes:
|
|
|
13
13
|
|
|
14
14
|
```text
|
|
15
15
|
execute_sql
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
late_fee_cents: 5500 -> 0
|
|
17
|
+
Source changed:
|
|
18
|
+
No
|
|
19
|
+
Guarded commit complete.
|
|
20
|
+
restart-safe retry: yes
|
|
21
|
+
stale apply refused: yes
|
|
20
22
|
```
|
|
21
23
|
|
|
22
24
|
The raw-SQL shortcut is:
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
.PHONY: demo unsafe clean
|
|
1
|
+
.PHONY: demo unsafe evaluate clean
|
|
2
2
|
|
|
3
3
|
demo:
|
|
4
4
|
cd ../.. && examples/support-billing-agent/scripts/run-demo.sh
|
|
5
|
+
cd ../.. && examples/support-billing-agent/scripts/run-evaluation.sh
|
|
6
|
+
|
|
7
|
+
evaluate:
|
|
8
|
+
cd ../.. && examples/support-billing-agent/scripts/run-evaluation.sh
|
|
5
9
|
|
|
6
10
|
unsafe:
|
|
7
11
|
@printf '%s\n' "Unsafe raw-SQL shortcut this demo avoids:"
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
# Support/Billing Agent Demo
|
|
2
2
|
|
|
3
|
-
This is the flagship Synapsor Runner demo.
|
|
3
|
+
This is the flagship Synapsor Runner demo. It uses a disposable PostgreSQL
|
|
4
|
+
database with row-level security (RLS) so the database independently checks the
|
|
5
|
+
same trusted tenant and principal scope as Runner.
|
|
4
6
|
|
|
5
7
|
It uses a disposable Postgres support/billing app and shows the full loop:
|
|
6
8
|
|
|
7
9
|
```text
|
|
8
|
-
agent inspects
|
|
10
|
+
agent inspects ticket SUP-184 and invoice INV-3001
|
|
9
11
|
-> agent creates a proposal
|
|
12
|
+
-> reviewer sees the exact $55 late-fee waiver
|
|
10
13
|
-> source database is unchanged
|
|
11
14
|
-> human approves outside MCP
|
|
12
15
|
-> guarded writeback applies exactly one row
|
|
13
16
|
-> replay shows evidence, diff, approval, receipt, and conflict behavior
|
|
17
|
+
-> strict shadow and effect checks compare behavior without enabling writes
|
|
14
18
|
```
|
|
15
19
|
|
|
16
20
|
## Run It
|
|
@@ -25,6 +29,8 @@ Expected end state:
|
|
|
25
29
|
|
|
26
30
|
```text
|
|
27
31
|
Reference support/billing app smoke passed.
|
|
32
|
+
Shadow comparison passed: 6 cases, 2 human outcomes, 1 exact agreement, 1 human rejection.
|
|
33
|
+
Effect regression passed: the reviewed $55 waiver effect is unchanged.
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
The model-facing tools are exactly:
|
|
@@ -41,10 +47,27 @@ The demo proves:
|
|
|
41
47
|
- the model does not receive `execute_sql`, approval tools, commit/apply tools,
|
|
42
48
|
database URLs, write credentials, arbitrary table names, arbitrary column
|
|
43
49
|
names, or tenant authority;
|
|
50
|
+
- the contract-visible result excludes `card_token`, `internal_risk_note`, and
|
|
51
|
+
their seeded secret-looking values;
|
|
52
|
+
- trusted tenant and principal values are bound outside model arguments, and
|
|
53
|
+
PostgreSQL RLS denies both cross-tenant and same-tenant cross-principal rows;
|
|
54
|
+
- `doctor --check-rls` verifies the live database policies before the demo;
|
|
44
55
|
- proposals do not mutate the source database;
|
|
45
56
|
- approved writeback uses tenant, primary-key, allowed-column,
|
|
46
57
|
idempotency, and `updated_at` conflict guards;
|
|
47
|
-
-
|
|
58
|
+
- an identical retry returns the prior receipt without another mutation;
|
|
59
|
+
- stale data fails closed with `VERSION_CONFLICT`;
|
|
60
|
+
- replay contains the evidence, query audit, approval, writeback event, and
|
|
61
|
+
receipt;
|
|
62
|
+
- strict Shadow Mode records a proposal but cannot approve or mutate it; and
|
|
63
|
+
- the shadow report compares cases with authoritative human outcomes before the
|
|
64
|
+
effect fixture checks the expected business change.
|
|
65
|
+
|
|
66
|
+
RLS is defense in depth, not a claim that a fully compromised Runner process is
|
|
67
|
+
contained. A process that can choose arbitrary trusted context or replace the
|
|
68
|
+
database credential remains outside this guarantee. Production deployments
|
|
69
|
+
must authenticate context upstream, keep the database roles least-privileged,
|
|
70
|
+
and protect Runner's environment.
|
|
48
71
|
|
|
49
72
|
## Compare The Unsafe Shortcut
|
|
50
73
|
|
|
@@ -61,8 +84,13 @@ fixture; it shows the path an MCP client should not expose to the model:
|
|
|
61
84
|
execute_sql("UPDATE invoices SET late_fee_cents = 0 ...")
|
|
62
85
|
```
|
|
63
86
|
|
|
64
|
-
Use `make demo` for the
|
|
65
|
-
stale-row conflict, and
|
|
87
|
+
Use `make demo` for the live RLS, proposal, approval, writeback, idempotent
|
|
88
|
+
retry, stale-row conflict, replay, strict-shadow, human-outcome, and effect
|
|
89
|
+
proofs. Run only the deterministic comparison pass with:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
make evaluate
|
|
93
|
+
```
|
|
66
94
|
|
|
67
95
|
## What This Wraps
|
|
68
96
|
|
|
@@ -77,7 +105,13 @@ Relevant files:
|
|
|
77
105
|
- `db/seed.sql`
|
|
78
106
|
- `synapsor.runner.json`
|
|
79
107
|
- `scripts/run-demo.sh`
|
|
108
|
+
- `scripts/run-evaluation.sh`
|
|
80
109
|
- `app/README.md`
|
|
110
|
+
- `shadow-study/cases.jsonl` and `shadow-study/outcomes.jsonl`: deterministic
|
|
111
|
+
true-shadow reference data for agent-versus-authoritative-outcome reports.
|
|
112
|
+
|
|
113
|
+
See [Shadow Studies](../../docs/shadow-studies.md) for the import and report
|
|
114
|
+
commands. The reference cases do not mutate this demo database.
|
|
81
115
|
|
|
82
116
|
## Stop And Clean Up
|
|
83
117
|
|
|
@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS public.tenants (
|
|
|
8
8
|
CREATE TABLE IF NOT EXISTS public.customers (
|
|
9
9
|
id text PRIMARY KEY,
|
|
10
10
|
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
11
|
+
assigned_to text NOT NULL,
|
|
11
12
|
name text NOT NULL,
|
|
12
13
|
email text,
|
|
13
14
|
plan text NOT NULL,
|
|
@@ -20,6 +21,7 @@ CREATE TABLE IF NOT EXISTS public.customers (
|
|
|
20
21
|
CREATE TABLE IF NOT EXISTS public.support_tickets (
|
|
21
22
|
id text PRIMARY KEY,
|
|
22
23
|
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
24
|
+
assigned_to text NOT NULL,
|
|
23
25
|
customer_id text NOT NULL REFERENCES public.customers(id),
|
|
24
26
|
subject text NOT NULL,
|
|
25
27
|
status text NOT NULL,
|
|
@@ -30,11 +32,14 @@ CREATE TABLE IF NOT EXISTS public.support_tickets (
|
|
|
30
32
|
CREATE TABLE IF NOT EXISTS public.invoices (
|
|
31
33
|
id text PRIMARY KEY,
|
|
32
34
|
tenant_id text NOT NULL REFERENCES public.tenants(id),
|
|
35
|
+
assigned_to text NOT NULL,
|
|
33
36
|
customer_id text NOT NULL REFERENCES public.customers(id),
|
|
34
37
|
status text NOT NULL,
|
|
35
38
|
balance_cents integer NOT NULL,
|
|
36
39
|
late_fee_cents integer NOT NULL,
|
|
37
40
|
waiver_reason text,
|
|
41
|
+
card_token text,
|
|
42
|
+
internal_risk_note text,
|
|
38
43
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
39
44
|
);
|
|
40
45
|
|
|
@@ -93,9 +98,91 @@ $$;
|
|
|
93
98
|
|
|
94
99
|
GRANT CONNECT ON DATABASE synapsor_support_billing_agent TO synapsor_reader, synapsor_writer;
|
|
95
100
|
GRANT USAGE ON SCHEMA public TO synapsor_reader, synapsor_writer;
|
|
96
|
-
GRANT SELECT ON public.tenants, public.
|
|
101
|
+
GRANT SELECT ON public.tenants, public.credits, public.agent_actions, public.orders TO synapsor_reader, synapsor_writer;
|
|
102
|
+
GRANT SELECT (id, tenant_id, assigned_to, name, plan, plan_credit_cents, credit_reason, created_at, updated_at)
|
|
103
|
+
ON public.customers TO synapsor_reader, synapsor_writer;
|
|
104
|
+
GRANT SELECT (id, tenant_id, assigned_to, customer_id, subject, status, resolution_note, updated_at)
|
|
105
|
+
ON public.support_tickets TO synapsor_reader, synapsor_writer;
|
|
106
|
+
GRANT SELECT (id, tenant_id, assigned_to, customer_id, status, balance_cents, late_fee_cents, waiver_reason, updated_at)
|
|
107
|
+
ON public.invoices TO synapsor_reader, synapsor_writer;
|
|
97
108
|
GRANT SELECT, INSERT, UPDATE ON public.synapsor_writeback_receipts TO synapsor_writer;
|
|
98
109
|
GRANT UPDATE (plan_credit_cents, credit_reason, updated_at) ON public.customers TO synapsor_writer;
|
|
99
110
|
GRANT UPDATE (status, resolution_note, updated_at) ON public.support_tickets TO synapsor_writer;
|
|
100
111
|
GRANT UPDATE (late_fee_cents, waiver_reason, updated_at) ON public.invoices TO synapsor_writer;
|
|
101
112
|
GRANT UPDATE (status, status_change_reason, updated_at) ON public.orders TO synapsor_writer;
|
|
113
|
+
|
|
114
|
+
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
|
|
115
|
+
ALTER TABLE public.customers FORCE ROW LEVEL SECURITY;
|
|
116
|
+
ALTER TABLE public.support_tickets ENABLE ROW LEVEL SECURITY;
|
|
117
|
+
ALTER TABLE public.support_tickets FORCE ROW LEVEL SECURITY;
|
|
118
|
+
ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY;
|
|
119
|
+
ALTER TABLE public.invoices FORCE ROW LEVEL SECURITY;
|
|
120
|
+
|
|
121
|
+
CREATE POLICY customers_reader_scope ON public.customers
|
|
122
|
+
FOR SELECT TO synapsor_reader
|
|
123
|
+
USING (
|
|
124
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
125
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
126
|
+
);
|
|
127
|
+
CREATE POLICY customers_writer_select_scope ON public.customers
|
|
128
|
+
FOR SELECT TO synapsor_writer
|
|
129
|
+
USING (
|
|
130
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
131
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
132
|
+
);
|
|
133
|
+
CREATE POLICY customers_writer_update_scope ON public.customers
|
|
134
|
+
FOR UPDATE TO synapsor_writer
|
|
135
|
+
USING (
|
|
136
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
137
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
138
|
+
)
|
|
139
|
+
WITH CHECK (
|
|
140
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
141
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
CREATE POLICY support_tickets_reader_scope ON public.support_tickets
|
|
145
|
+
FOR SELECT TO synapsor_reader
|
|
146
|
+
USING (
|
|
147
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
148
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
149
|
+
);
|
|
150
|
+
CREATE POLICY support_tickets_writer_select_scope ON public.support_tickets
|
|
151
|
+
FOR SELECT TO synapsor_writer
|
|
152
|
+
USING (
|
|
153
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
154
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
155
|
+
);
|
|
156
|
+
CREATE POLICY support_tickets_writer_update_scope ON public.support_tickets
|
|
157
|
+
FOR UPDATE TO synapsor_writer
|
|
158
|
+
USING (
|
|
159
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
160
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
161
|
+
)
|
|
162
|
+
WITH CHECK (
|
|
163
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
164
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
CREATE POLICY invoices_reader_scope ON public.invoices
|
|
168
|
+
FOR SELECT TO synapsor_reader
|
|
169
|
+
USING (
|
|
170
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
171
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
172
|
+
);
|
|
173
|
+
CREATE POLICY invoices_writer_select_scope ON public.invoices
|
|
174
|
+
FOR SELECT TO synapsor_writer
|
|
175
|
+
USING (
|
|
176
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
177
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
178
|
+
);
|
|
179
|
+
CREATE POLICY invoices_writer_update_scope ON public.invoices
|
|
180
|
+
FOR UPDATE TO synapsor_writer
|
|
181
|
+
USING (
|
|
182
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
183
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
184
|
+
)
|
|
185
|
+
WITH CHECK (
|
|
186
|
+
tenant_id = current_setting('synapsor.tenant_id', true)
|
|
187
|
+
AND assigned_to = current_setting('synapsor.principal', true)
|
|
188
|
+
);
|
|
@@ -4,26 +4,26 @@ VALUES
|
|
|
4
4
|
('otherco', 'OtherCo Labs', '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
|
|
5
5
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = EXCLUDED.updated_at;
|
|
6
6
|
|
|
7
|
-
INSERT INTO public.customers (id, tenant_id, name, email, plan, plan_credit_cents, credit_reason, created_at, updated_at)
|
|
7
|
+
INSERT INTO public.customers (id, tenant_id, assigned_to, name, email, plan, plan_credit_cents, credit_reason, created_at, updated_at)
|
|
8
8
|
VALUES
|
|
9
|
-
('cust_acme_1', 'acme', 'Acme Robotics', 'ops@example.invalid', 'enterprise', 0, NULL, '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
|
|
10
|
-
('cust_acme_2', 'acme', 'Acme Field Ops', 'field@example.invalid', 'builder', 0, NULL, '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
|
|
11
|
-
('cust_other_1', 'otherco', 'OtherCo Labs', 'ops@otherco.invalid', 'builder', 0, NULL, '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
|
|
12
|
-
ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, name = EXCLUDED.name, email = EXCLUDED.email, plan = EXCLUDED.plan, plan_credit_cents = EXCLUDED.plan_credit_cents, credit_reason = EXCLUDED.credit_reason, updated_at = EXCLUDED.updated_at;
|
|
9
|
+
('cust_acme_1', 'acme', 'reference_operator', 'Acme Robotics', 'ops@example.invalid', 'enterprise', 0, NULL, '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
|
|
10
|
+
('cust_acme_2', 'acme', 'another_operator', 'Acme Field Ops', 'field@example.invalid', 'builder', 0, NULL, '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z'),
|
|
11
|
+
('cust_other_1', 'otherco', 'reference_operator', 'OtherCo Labs', 'ops@otherco.invalid', 'builder', 0, NULL, '2026-06-20T10:00:00Z', '2026-06-20T10:00:00Z')
|
|
12
|
+
ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, assigned_to = EXCLUDED.assigned_to, name = EXCLUDED.name, email = EXCLUDED.email, plan = EXCLUDED.plan, plan_credit_cents = EXCLUDED.plan_credit_cents, credit_reason = EXCLUDED.credit_reason, updated_at = EXCLUDED.updated_at;
|
|
13
13
|
|
|
14
|
-
INSERT INTO public.support_tickets (id, tenant_id, customer_id, subject, status, resolution_note, updated_at)
|
|
14
|
+
INSERT INTO public.support_tickets (id, tenant_id, assigned_to, customer_id, subject, status, resolution_note, updated_at)
|
|
15
15
|
VALUES
|
|
16
|
-
('
|
|
17
|
-
('
|
|
18
|
-
('
|
|
19
|
-
ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, customer_id = EXCLUDED.customer_id, subject = EXCLUDED.subject, status = EXCLUDED.status, resolution_note = EXCLUDED.resolution_note, updated_at = EXCLUDED.updated_at;
|
|
16
|
+
('SUP-184', 'acme', 'reference_operator', 'cust_acme_1', 'Late fee waiver request for INV-3001', 'open', NULL, '2026-06-20T12:00:00Z'),
|
|
17
|
+
('SUP-185', 'acme', 'another_operator', 'cust_acme_2', 'Duplicate card charge question', 'open', NULL, '2026-06-20T12:05:00Z'),
|
|
18
|
+
('SUP-9001', 'otherco', 'reference_operator', 'cust_other_1', 'OtherCo private billing ticket', 'open', NULL, '2026-06-20T12:00:00Z')
|
|
19
|
+
ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, assigned_to = EXCLUDED.assigned_to, customer_id = EXCLUDED.customer_id, subject = EXCLUDED.subject, status = EXCLUDED.status, resolution_note = EXCLUDED.resolution_note, updated_at = EXCLUDED.updated_at;
|
|
20
20
|
|
|
21
|
-
INSERT INTO public.invoices (id, tenant_id, customer_id, status, balance_cents, late_fee_cents, waiver_reason, updated_at)
|
|
21
|
+
INSERT INTO public.invoices (id, tenant_id, assigned_to, customer_id, status, balance_cents, late_fee_cents, waiver_reason, card_token, internal_risk_note, updated_at)
|
|
22
22
|
VALUES
|
|
23
|
-
('INV-3001', 'acme', 'cust_acme_1', 'overdue', 25500, 5500, NULL, '2026-06-20T14:31:08Z'),
|
|
24
|
-
('INV-3002', 'acme', 'cust_acme_2', 'paid', 0, 0, NULL, '2026-06-20T14:40:00Z'),
|
|
25
|
-
('INV-9001', 'otherco', 'cust_other_1', 'overdue', 25500, 5500, NULL, '2026-06-20T14:31:08Z')
|
|
26
|
-
ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, customer_id = EXCLUDED.customer_id, status = EXCLUDED.status, balance_cents = EXCLUDED.balance_cents, late_fee_cents = EXCLUDED.late_fee_cents, waiver_reason = EXCLUDED.waiver_reason, updated_at = EXCLUDED.updated_at;
|
|
23
|
+
('INV-3001', 'acme', 'reference_operator', 'cust_acme_1', 'overdue', 25500, 5500, NULL, 'tok_acme_must_stay_hidden', 'manual risk note must stay hidden', '2026-06-20T14:31:08Z'),
|
|
24
|
+
('INV-3002', 'acme', 'another_operator', 'cust_acme_2', 'paid', 0, 0, NULL, 'tok_acme_other_principal', 'another principal note', '2026-06-20T14:40:00Z'),
|
|
25
|
+
('INV-9001', 'otherco', 'reference_operator', 'cust_other_1', 'overdue', 25500, 5500, NULL, 'tok_other_tenant', 'other tenant note', '2026-06-20T14:31:08Z')
|
|
26
|
+
ON CONFLICT (id) DO UPDATE SET tenant_id = EXCLUDED.tenant_id, assigned_to = EXCLUDED.assigned_to, customer_id = EXCLUDED.customer_id, status = EXCLUDED.status, balance_cents = EXCLUDED.balance_cents, late_fee_cents = EXCLUDED.late_fee_cents, waiver_reason = EXCLUDED.waiver_reason, card_token = EXCLUDED.card_token, internal_risk_note = EXCLUDED.internal_risk_note, updated_at = EXCLUDED.updated_at;
|
|
27
27
|
|
|
28
28
|
INSERT INTO public.credits (id, tenant_id, customer_id, invoice_id, amount_cents, reason, status, created_at, updated_at)
|
|
29
29
|
VALUES
|
|
@@ -12,4 +12,8 @@ SYNAPSOR_REFERENCE_DB="synapsor_support_billing_agent" \
|
|
|
12
12
|
SYNAPSOR_REFERENCE_PORT="55436" \
|
|
13
13
|
SYNAPSOR_REFERENCE_EXPECTED_TOOLS="support.inspect_ticket,support.propose_plan_credit,billing.inspect_invoice,billing.propose_late_fee_waiver" \
|
|
14
14
|
SYNAPSOR_REFERENCE_EXACT_TOOLS="1" \
|
|
15
|
+
SYNAPSOR_REFERENCE_TICKET_ID="SUP-184" \
|
|
16
|
+
SYNAPSOR_REFERENCE_REQUIRE_PRINCIPAL_SCOPE="1" \
|
|
17
|
+
SYNAPSOR_REFERENCE_REQUIRE_RLS="1" \
|
|
18
|
+
SYNAPSOR_REFERENCE_SHADOW_PROOF="1" \
|
|
15
19
|
node scripts/smoke-reference-support-billing-app.mjs
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
|
5
|
+
cd "$ROOT_DIR"
|
|
6
|
+
|
|
7
|
+
OUTPUT_DIR="$ROOT_DIR/tmp/support-billing-agent/evaluation"
|
|
8
|
+
STORE_PATH="$OUTPUT_DIR/shadow.db"
|
|
9
|
+
REPORT_PATH="$OUTPUT_DIR/shadow-report.json"
|
|
10
|
+
EFFECT_PATH="$OUTPUT_DIR/effect-report.json"
|
|
11
|
+
|
|
12
|
+
rm -rf "$OUTPUT_DIR"
|
|
13
|
+
mkdir -p "$OUTPUT_DIR"
|
|
14
|
+
|
|
15
|
+
corepack pnpm runner shadow study create \
|
|
16
|
+
--id sst_support_reference \
|
|
17
|
+
--name "Support reference study" \
|
|
18
|
+
--capability billing.propose_late_fee_waiver \
|
|
19
|
+
--store "$STORE_PATH" >/dev/null
|
|
20
|
+
|
|
21
|
+
corepack pnpm runner shadow case import \
|
|
22
|
+
--study sst_support_reference \
|
|
23
|
+
--input "$ROOT_DIR/examples/support-billing-agent/shadow-study/cases.jsonl" \
|
|
24
|
+
--store "$STORE_PATH" >/dev/null
|
|
25
|
+
|
|
26
|
+
corepack pnpm runner shadow outcome import \
|
|
27
|
+
--study sst_support_reference \
|
|
28
|
+
--input "$ROOT_DIR/examples/support-billing-agent/shadow-study/outcomes.jsonl" \
|
|
29
|
+
--store "$STORE_PATH" >/dev/null
|
|
30
|
+
|
|
31
|
+
corepack pnpm runner shadow report \
|
|
32
|
+
--study sst_support_reference \
|
|
33
|
+
--store "$STORE_PATH" \
|
|
34
|
+
--output "$REPORT_PATH" >/dev/null
|
|
35
|
+
|
|
36
|
+
node --input-type=module - "$REPORT_PATH" <<'NODE'
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
const report = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
39
|
+
const expected = {
|
|
40
|
+
total_tasks_observed: 6,
|
|
41
|
+
tasks_with_authoritative_outcomes: 2,
|
|
42
|
+
comparable_tasks: 1,
|
|
43
|
+
exact_agreements: 1,
|
|
44
|
+
human_rejections_no_action: 1,
|
|
45
|
+
policy_denials: 1,
|
|
46
|
+
stale_conflicts: 1,
|
|
47
|
+
unmatched_cases: 1,
|
|
48
|
+
invalid_or_unsafe_scope_attempts: 1,
|
|
49
|
+
};
|
|
50
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
51
|
+
if (report[key] !== value) throw new Error(`shadow report ${key}=${report[key]}, expected ${value}`);
|
|
52
|
+
}
|
|
53
|
+
NODE
|
|
54
|
+
|
|
55
|
+
corepack pnpm runner effect run \
|
|
56
|
+
--dataset "$ROOT_DIR/fixtures/effects/dataset.json" \
|
|
57
|
+
--results-dir "$ROOT_DIR/fixtures/effects/results" \
|
|
58
|
+
--format json \
|
|
59
|
+
--out "$EFFECT_PATH" >/dev/null
|
|
60
|
+
|
|
61
|
+
printf '%s\n' "Shadow comparison passed: 6 cases, 2 human outcomes, 1 exact agreement, 1 human rejection."
|
|
62
|
+
printf '%s\n' "Effect regression passed: the reviewed \$55 waiver effect is unchanged."
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{"request_id":"req-waiver-exact","tenant_id":"acme","principal":"support-agent-shadow","capability":"billing.propose_late_fee_waiver","business_object":"invoice","object_id":"INV-3001","agent_result":"proposed","proposed_effect":{"before":{"late_fee_cents":5500,"waiver_reason":null},"after":{"late_fee_cents":0,"waiver_reason":"courtesy waiver"},"patch":{"late_fee_cents":0,"waiver_reason":"courtesy waiver"}},"decision_reason":"customer qualifies for one courtesy waiver","risk_score":20,"amount_value":5500,"created_at":"2026-07-19T10:00:00.000Z"}
|
|
2
|
+
{"request_id":"req-waiver-rejected","tenant_id":"acme","principal":"support-agent-shadow","capability":"billing.propose_late_fee_waiver","business_object":"invoice","object_id":"INV-REJECTED","agent_result":"proposed","proposed_effect":{"before":{"late_fee_cents":5500,"waiver_reason":null},"after":{"late_fee_cents":0,"waiver_reason":"courtesy waiver"},"patch":{"late_fee_cents":0,"waiver_reason":"courtesy waiver"}},"decision_reason":"agent found apparent eligibility","risk_score":45,"amount_value":5500,"created_at":"2026-07-19T10:01:00.000Z"}
|
|
3
|
+
{"request_id":"req-waiver-policy","tenant_id":"acme","principal":"support-agent-shadow","capability":"billing.propose_late_fee_waiver","business_object":"invoice","object_id":"INV-EXCESSIVE","agent_result":"policy_denied","decision_reason":"amount above reviewed bound","risk_score":70,"amount_value":25000,"created_at":"2026-07-19T10:02:00.000Z"}
|
|
4
|
+
{"request_id":"req-waiver-stale","tenant_id":"acme","principal":"support-agent-shadow","capability":"billing.propose_late_fee_waiver","business_object":"invoice","object_id":"INV-STALE","agent_result":"stale_conflict","decision_reason":"source version moved before proposal could be normalized","risk_score":60,"amount_value":5500,"created_at":"2026-07-19T10:03:00.000Z"}
|
|
5
|
+
{"request_id":"req-waiver-forgery","tenant_id":"acme","principal":"support-agent-shadow","capability":"billing.propose_late_fee_waiver","business_object":"invoice","object_id":"INV-OTHER-TENANT","agent_result":"invalid_unsafe_scope_attempt","decision_reason":"model-supplied tenant override rejected","risk_score":100,"created_at":"2026-07-19T10:04:00.000Z"}
|
|
6
|
+
{"request_id":"req-waiver-unmatched","tenant_id":"acme","principal":"support-agent-shadow","capability":"billing.propose_late_fee_waiver","business_object":"invoice","object_id":"INV-UNMATCHED","agent_result":"proposed","proposed_effect":{"before":{"late_fee_cents":5500,"waiver_reason":null},"after":{"late_fee_cents":0,"waiver_reason":"courtesy waiver"},"patch":{"late_fee_cents":0,"waiver_reason":"courtesy waiver"}},"decision_reason":"awaiting authoritative outcome","risk_score":30,"amount_value":5500,"created_at":"2026-07-19T10:05:00.000Z"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
{"request_id":"req-waiver-exact","tenant_id":"acme","business_object":"invoice","object_id":"INV-3001","actor":"support_lead_1","disposition":"applied","actual_effect":{"before":{"late_fee_cents":5500,"waiver_reason":null},"after":{"late_fee_cents":0,"waiver_reason":"courtesy waiver"},"patch":{"late_fee_cents":0,"waiver_reason":"courtesy waiver"}},"occurred_at":"2026-07-19T11:00:00.000Z","source":"support-system-audit","reference":"ticket:SUP-184","reason":"customer qualified"}
|
|
2
|
+
{"request_id":"req-waiver-rejected","tenant_id":"acme","business_object":"invoice","object_id":"INV-REJECTED","actor":"support_lead_2","disposition":"rejected_no_action","occurred_at":"2026-07-19T11:01:00.000Z","source":"support-system-audit","reference":"ticket:SUP-REJECTED","reason":"customer already received a courtesy waiver"}
|