@synapsor/runner 1.6.2 → 1.6.3

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 (47) hide show
  1. package/CHANGELOG.md +77 -2
  2. package/README.md +33 -19
  3. package/SECURITY.md +10 -2
  4. package/THREAT_MODEL.md +27 -4
  5. package/dist/authoring.mjs +53 -3
  6. package/dist/cli.d.ts +19 -2
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/local-ui.d.ts +85 -1
  9. package/dist/local-ui.d.ts.map +1 -1
  10. package/dist/runner.mjs +36107 -22949
  11. package/dist/runtime.mjs +3581 -237
  12. package/dist/shadow.mjs +2423 -65
  13. package/docs/README.md +22 -2
  14. package/docs/agent-guided-setup.md +239 -0
  15. package/docs/approval-roles-and-operator-identity.md +353 -0
  16. package/docs/auto-boundary-and-scoped-explore.md +32 -6
  17. package/docs/capability-authoring.md +19 -0
  18. package/docs/current-scope.md +20 -1
  19. package/docs/dsl-json-parity.md +75 -0
  20. package/docs/dsl-reference.md +10 -0
  21. package/docs/fresh-developer-usability.md +155 -80
  22. package/docs/getting-started-own-database.md +5 -4
  23. package/docs/guarded-crud-writeback.md +19 -0
  24. package/docs/guided-onboarding.md +331 -0
  25. package/docs/human-attention-notifications.md +367 -0
  26. package/docs/limitations.md +19 -3
  27. package/docs/local-mode.md +16 -2
  28. package/docs/migrating-to-synapsor-spec.md +38 -0
  29. package/docs/production.md +39 -0
  30. package/docs/release-notes.md +66 -2
  31. package/docs/runner-config-reference.md +132 -0
  32. package/docs/running-a-runner-fleet.md +16 -1
  33. package/docs/security-boundary.md +24 -0
  34. package/docs/store-lifecycle.md +20 -1
  35. package/docs/supervised-automatic-apply.md +267 -0
  36. package/docs/troubleshooting-first-run.md +35 -0
  37. package/examples/fitflow-guided-onboarding/README.md +43 -0
  38. package/examples/fitflow-guided-onboarding/app/page.tsx +8 -0
  39. package/examples/fitflow-guided-onboarding/docker-compose.yml +16 -0
  40. package/examples/fitflow-guided-onboarding/package.json +18 -0
  41. package/examples/fitflow-guided-onboarding/prisma/schema.prisma +98 -0
  42. package/examples/fitflow-guided-onboarding/seed/postgres.sql +401 -0
  43. package/examples/operator-oidc/issuer.mjs +188 -0
  44. package/examples/support-plan-credit/README.md +6 -2
  45. package/package.json +5 -3
  46. package/schemas/schema-candidate-review.schema.json +42 -0
  47. package/schemas/synapsor.runner.schema.json +227 -4
@@ -0,0 +1,331 @@
1
+ # Guided Onboarding: Database To First Safe Tool
2
+
3
+ This is the shortest path from an existing staging Postgres/MySQL database to
4
+ one useful agent tool. It requires no Synapsor account, Cloud login, database
5
+ replication, global install, MCP host, model API key, or hand-authored config,
6
+ DSL, JSON, SQL, DDL, or grants.
7
+
8
+ ## The One-Minute Mental Model
9
+
10
+ Synapsor is creating the small set of database powers your agent may use. It
11
+ does not give the agent SQL access.
12
+
13
+ - Reads return only fields a human reviewed, under tenant and principal scope
14
+ supplied outside model arguments.
15
+ - Scoped Explore is a temporary local development/staging authoring tool.
16
+ - Protect turns one useful analysis into a narrow named capability.
17
+ - A model-facing write call creates an exact proposal. It does not commit.
18
+ - The model cannot activate authority, approve a proposal, or apply a write.
19
+ - A trusted operator or worker rechecks the effect and records a receipt.
20
+
21
+ ## Before You Start
22
+
23
+ Use a disposable or staging database and a dedicated SELECT-only, non-owner
24
+ credential. Keep row-level security, restricted views, or tenant-bound
25
+ credentials underneath Runner where available.
26
+
27
+ Export credentials in the launching shell. Do not put their values in project
28
+ files or chat:
29
+
30
+ ```bash
31
+ export DATABASE_URL='<staging read-only URL>'
32
+ export SYNAPSOR_TENANT_ID='<staging tenant>'
33
+ export SYNAPSOR_PRINCIPAL='<staging principal>'
34
+ ```
35
+
36
+ Runner requires Node 22.13 or newer.
37
+
38
+ ## One Command To Workbench
39
+
40
+ Run this from an empty project directory or your application root:
41
+
42
+ ```bash
43
+ npx -y @synapsor/runner@latest start --from-env DATABASE_URL
44
+ ```
45
+
46
+ The command automatically:
47
+
48
+ 1. checks the database and credential posture;
49
+ 2. inspects schema metadata without sampling source rows;
50
+ 3. statically reads supported Prisma, Drizzle, OpenAPI, and existing Synapsor
51
+ definitions when present;
52
+ 4. drafts disabled public DSL, canonical JSON, tests, and a generation lock;
53
+ 5. creates a valid zero-authority Runner config and local SQLite ledger;
54
+ 6. validates every generated artifact;
55
+ 7. opens one secured loopback Workbench URL.
56
+
57
+ Before human activation, the agent has no generated authority and no source row
58
+ has been read.
59
+
60
+ Running the same command again resumes. Resume and Try do not rescan, rewrite
61
+ files, or change a digest. Rescan and destructive Start over remain explicit
62
+ human choices.
63
+
64
+ ## Five Minutes: First Safe Read
65
+
66
+ Workbench starts with an overview, not a permission matrix. It shows:
67
+
68
+ - database and framework evidence found;
69
+ - resources inspected;
70
+ - fields already kept out;
71
+ - unresolved sensitive fields;
72
+ - row identity;
73
+ - tenant and principal scope;
74
+ - database-role and RLS posture;
75
+ - ready and blocked capabilities.
76
+
77
+ Review only the exceptions. Routine conservative defaults are already applied.
78
+ There is no global “approve everything” control.
79
+
80
+ For each selected resource, confirm the exact primary/unique row identity,
81
+ trusted scope, visible fields, kept-out fields, and any aggregate-only fields.
82
+ Then sign the displayed digest. Changing a reviewed decision creates a new
83
+ disabled digest.
84
+
85
+ Choose **Try first safe read**. Runner calls the real local runtime and shows:
86
+
87
+ ```text
88
+ Your first safe tool is working.
89
+
90
+ Tool: <domain>.inspect_<resource>
91
+ Agent can see: <reviewed fields>
92
+ Agent cannot see: <kept-out fields>
93
+ Tenant scope: trusted outside model arguments
94
+ Principal scope: trusted outside model arguments
95
+ Source database changed: no
96
+ ```
97
+
98
+ CLI fallback:
99
+
100
+ ```bash
101
+ npx -y @synapsor/runner@latest try call --list --format json
102
+ npx -y @synapsor/runner@latest try call <returned-tool-name> --sample --json
103
+ ```
104
+
105
+ The second command is for an activated named tool with valid generated sample
106
+ input. It does not invent tenant or principal values.
107
+
108
+ ## Ten Minutes: Explore And Protect
109
+
110
+ Scoped Explore is available only after its exact local authoring boundary is
111
+ activated. It is absent from production, unknown-profile, remote, shared HTTP,
112
+ and non-loopback `tools/list`.
113
+
114
+ In Workbench, choose **Ask an aggregate question**. Select only reviewed
115
+ resources, dimensions, measures, filters, and time buckets. No SQL or plan JSON
116
+ is required.
117
+
118
+ The first release supports bounded:
119
+
120
+ - `count` and reviewed `count_distinct`;
121
+ - `sum` and `avg` over reviewed numeric measures;
122
+ - reviewed categorical dimensions;
123
+ - day, week, and month buckets;
124
+ - typed filters and bounded top-N;
125
+ - at most one reviewed many-to-one relationship.
126
+
127
+ Minimum cohorts, group limits, response limits, query limits, extraction
128
+ budgets, and anti-differencing budgets are fixed by the activated boundary.
129
+ Small groups are suppressed. Results describe changes or contributors; they do
130
+ not prove causation.
131
+
132
+ CLI fallback for the Workbench-suggested aggregate:
133
+
134
+ ```bash
135
+ npx -y @synapsor/runner@latest try explore --suggested --json
136
+ ```
137
+
138
+ Choose **Protect this analysis** directly on the successful result. Runner
139
+ freezes its reviewed resources, measures, grouping, time bucket, ordering,
140
+ scope, suppression, and limits into:
141
+
142
+ ```text
143
+ synapsor/protected/drafts/<namespace>__<name>/capability.synapsor.sql
144
+ synapsor/protected/drafts/<namespace>__<name>/synapsor.contract.json
145
+ synapsor/protected/drafts/<namespace>__<name>/contract-tests.json
146
+ ```
147
+
148
+ The capability remains disabled until a human activates its exact digest.
149
+ Disabling Scoped Explore does not remove the protected named capability.
150
+
151
+ CLI fallback, without copying an opaque query handle:
152
+
153
+ ```bash
154
+ npx -y @synapsor/runner@latest try protect \
155
+ --name analytics.reviewed_weekly_summary \
156
+ --json
157
+ ```
158
+
159
+ ## Fifteen Minutes: First Proposal
160
+
161
+ After the first read, choose **Add a safe action**. Schema metadata supplies
162
+ structure, never business permission. The human specifies:
163
+
164
+ 1. the business action;
165
+ 2. target resource and exact row identity;
166
+ 3. fields that may change;
167
+ 4. allowed values, transitions, and numeric bounds;
168
+ 5. trusted tenant and principal scope;
169
+ 6. conflict/version guard;
170
+ 7. approval role;
171
+ 8. optional small bounded auto-approval;
172
+ 9. per-operation and daily limits;
173
+ 10. optional reviewed compensation.
174
+
175
+ Runner generates public DSL, canonical JSON, tests, an exact-effect preview,
176
+ and a disabled action. Unsupported or unsafe combinations fail closed. In
177
+ particular, DELETE, bounded-set, quorum, auto-approval, and reversibility cannot
178
+ be combined beyond what the canonical DSL supports.
179
+
180
+ After exact-digest human activation, call the named proposal tool in Workbench
181
+ or through the host-neutral Try path. The result must say:
182
+
183
+ ```text
184
+ Proposal created.
185
+ Source database changed: no.
186
+ The model cannot approve or apply this proposal.
187
+ ```
188
+
189
+ Inspect the latest lifecycle without copying a proposal ID:
190
+
191
+ ```bash
192
+ npx -y @synapsor/runner@latest lifecycle --details
193
+ ```
194
+
195
+ Approval and apply stay outside MCP:
196
+
197
+ ```bash
198
+ npx -y @synapsor/runner@latest proposals approve latest --yes
199
+ npx -y @synapsor/runner@latest apply latest
200
+ npx -y @synapsor/runner@latest lifecycle show latest --details
201
+ ```
202
+
203
+ Production should use a verified signed-key or OIDC operator identity rather
204
+ than unverified development identity. See [Approval Roles And Verified
205
+ Operator Identity](approval-roles-and-operator-identity.md).
206
+
207
+ These commands show the default human-approval/manual-apply path. A production
208
+ operator can separately opt an eligible action into exact-digest supervised
209
+ execution. That does not give the model an apply tool: the model creates a
210
+ bounded proposal, reviewed policy may approve it, and a separately trusted
211
+ worker repeats all guards before any later source mutation. Existing
212
+ `AUTO APPROVE` contracts remain manual-apply unless both opt-ins are present.
213
+ See [Operator-Supervised Automatic
214
+ Apply](supervised-automatic-apply.md).
215
+
216
+ ## Guarded Development Writeback Setup
217
+
218
+ Preview is always the default:
219
+
220
+ ```bash
221
+ npx -y @synapsor/runner@latest writeback setup \
222
+ --profile staging \
223
+ --json
224
+ ```
225
+
226
+ The response contains one immutable plan digest and exact setup SQL, objects,
227
+ grants, receipt mode, and next action.
228
+
229
+ - `runner_ledger`: no Synapsor receipt table or receipt grant is added to the
230
+ source database.
231
+ - `source_db` + `auto_migrate`: verifies the configured idempotent first-use
232
+ path using the trusted writer.
233
+ - `source_db` + `precreated`: requires a separate setup/admin URL environment
234
+ name and exact steady-state writer role.
235
+ - app-owned handlers receive no unnecessary source receipt setup.
236
+
237
+ DDL apply is refused for missing, unknown, or production profiles. In
238
+ development/staging, it requires the exact displayed confirmation:
239
+
240
+ ```bash
241
+ npx -y @synapsor/runner@latest writeback setup \
242
+ --profile staging \
243
+ --writer-role '<steady-state-writer-role>' \
244
+ --setup-url-env SYNAPSOR_SETUP_DATABASE_URL \
245
+ --apply \
246
+ --confirm 'APPLY WRITEBACK SETUP sha256:<reviewed-plan-digest>'
247
+ ```
248
+
249
+ Never reuse the model/read credential as the elevated setup connection.
250
+
251
+ ## Connect An MCP Client
252
+
253
+ Workbench generates ready-to-copy Cursor, Claude, Codex, and generic stdio
254
+ snippets for the same reviewed authority. Stdio opens no network listener and
255
+ needs no HTTP bearer credential.
256
+
257
+ Render the generic snippet:
258
+
259
+ ```bash
260
+ npx -y @synapsor/runner@latest mcp config --absolute-paths
261
+ ```
262
+
263
+ Preview a project-scoped Cursor change:
264
+
265
+ ```bash
266
+ npx -y @synapsor/runner@latest mcp install cursor --project --dry-run
267
+ ```
268
+
269
+ No client config contains database URLs, trusted scope values, approval,
270
+ activation, apply, or revert authority.
271
+
272
+ ## Machine-Readable Output
273
+
274
+ Commands that support machine output write exactly one JSON value to stdout.
275
+ Operational logs and diagnostics use stderr:
276
+
277
+ ```bash
278
+ npx -y @synapsor/runner@latest try explore --suggested --json | jq -e .
279
+ ```
280
+
281
+ On a process-level failure, JSON output uses:
282
+
283
+ ```json
284
+ {
285
+ "ok": false,
286
+ "error": {
287
+ "code": "COMMAND_REJECTED",
288
+ "message": "Safe redacted explanation"
289
+ },
290
+ "recovery": {
291
+ "state_preserved": "What remains intact",
292
+ "source_database_changed": null,
293
+ "next_action": "One exact next action"
294
+ }
295
+ }
296
+ ```
297
+
298
+ `null` means Runner cannot honestly infer the source mutation outcome from a
299
+ generic command exception. Operation-specific receipts and reconciliation
300
+ states remain authoritative.
301
+
302
+ ## Before And After
303
+
304
+ The earlier manual path could require roughly thirteen disconnected steps:
305
+ inspect, compile, create config, validate, doctor, initialize a store, start
306
+ Workbench, generate MCP config, create receipt DDL/grants, run a smoke call,
307
+ approve, and apply.
308
+
309
+ The guided path through the first proposal uses:
310
+
311
+ - one public shell command;
312
+ - deliberate Workbench review and activation;
313
+ - no manual file edits;
314
+ - no external documentation search;
315
+ - no Cursor requirement.
316
+
317
+ The packed FitFlow gate records the exact command count, human decisions, and
318
+ timings in `development/runner-1.6.3-fitflow-results.json`.
319
+
320
+ ## Visual Reference
321
+
322
+ Repository screenshots cover desktop, mobile, light, dark, loading, blocked,
323
+ stale, failure, empty Protect, and unavailable-action states:
324
+
325
+ - [Desktop overview](https://github.com/Synapsor/Synapsor-Runner/blob/main/development/runner-1.6.3-visual/workbench-overview-desktop-light.png)
326
+ - [Mobile overview](https://github.com/Synapsor/Synapsor-Runner/blob/main/development/runner-1.6.3-visual/workbench-overview-mobile-light.png)
327
+ - [Blocked identity review](https://github.com/Synapsor/Synapsor-Runner/blob/main/development/runner-1.6.3-visual/workbench-blocked-identity.png)
328
+ - [Stale/failure recovery](https://github.com/Synapsor/Synapsor-Runner/blob/main/development/runner-1.6.3-visual/workbench-keyboard-stale-failure.png)
329
+
330
+ For common blocked states and one-action recovery commands, use
331
+ [Troubleshooting First Run](troubleshooting-first-run.md).
@@ -0,0 +1,367 @@
1
+ # Human Attention And Notifications
2
+
3
+ Runner keeps the full lifecycle in its ledger and secured Workbench. External
4
+ notifications are a quiet interruption channel for meaningful human attention,
5
+ not a copy of every state transition.
6
+
7
+ Notifications are additive, local-first, and disabled by default. Runner needs
8
+ no webhook, network connection, Cloud account, or continuously running
9
+ dispatcher for its existing proposal, approval, apply, receipt, and replay
10
+ flows.
11
+
12
+ ## Architecture
13
+
14
+ ```text
15
+ authoritative ledger transition
16
+ -> immutable attention event in the same store transaction
17
+ -> coalesced Human Attention item
18
+ -> separately operated dispatcher
19
+ -> configured JSONL or signed webhook sink
20
+ -> authenticated human opens Workbench
21
+ -> existing approval/recovery control handles the item
22
+ ```
23
+
24
+ An event informs. It never authorizes. A webhook response, delivery receipt,
25
+ inbox acknowledgement, or notification replay cannot activate a capability,
26
+ approve a proposal, apply a write, cancel work, or resolve an unknown database
27
+ outcome.
28
+
29
+ SQLite and shared PostgreSQL runtime stores retain the same event identities,
30
+ attention projection, acknowledgement, and delivery state.
31
+
32
+ ## Quiet Default
33
+
34
+ Under the default route, successful activity remains visible in the ledger,
35
+ Workbench timeline, CLI, metrics, and optional digest, but does not interrupt a
36
+ human.
37
+
38
+ No immediate external notification is sent by default for:
39
+
40
+ - proposal creation, policy auto-approval, approval, or queueing;
41
+ - a worker lease or individual retry;
42
+ - successful manual or automatic apply;
43
+ - an isolated conflict that Runner handled safely;
44
+ - planned worker start, pause, resume, drain, or stop;
45
+ - capability activation by the same operator.
46
+
47
+ Immediate delivery defaults to states where timely attention matters:
48
+
49
+ - human approval required;
50
+ - UNKNOWN transaction outcome or reconciliation required;
51
+ - dead-letter work;
52
+ - sustained worker-health failure;
53
+ - queue depth/age crossing an operator threshold;
54
+ - schema drift or stale digest blocking authority;
55
+ - invalidated writer credential posture;
56
+ - critical policy/limit failure;
57
+ - an approved proposal approaching expiry while action is still possible.
58
+
59
+ Informational success events can be sent only through an explicit all-events
60
+ route or periodic digest.
61
+
62
+ ## Events And Attention Items
63
+
64
+ Events are immutable audit facts. Attention items are durable incident
65
+ projections over related events.
66
+
67
+ For example, ten proposals requiring the same role can produce ten immutable
68
+ events but one item saying that ten proposals need review. Repeated worker
69
+ failures, queue backlog, or the same schema-drift condition update one item and
70
+ its occurrence count. Retry attempts stay internal until the configured
71
+ attempt/duration threshold is crossed.
72
+
73
+ Attention states are:
74
+
75
+ - `open`;
76
+ - `acknowledged`;
77
+ - `resolved`;
78
+ - `expired`.
79
+
80
+ Acknowledgement means only that a verified operator saw the item. It is not
81
+ approval or resolution.
82
+
83
+ ## Configure Sinks
84
+
85
+ This operator-owned config enables one signed webhook and one optional
86
+ development JSONL stream:
87
+
88
+ ```json
89
+ {
90
+ "notifications": {
91
+ "enabled": true,
92
+ "workbench_url_env": "SYNAPSOR_WORKBENCH_URL",
93
+ "sinks": [
94
+ {
95
+ "id": "operations",
96
+ "type": "webhook",
97
+ "enabled": true,
98
+ "url_env": "SYNAPSOR_NOTIFY_WEBHOOK_URL",
99
+ "signing_secret_env": "SYNAPSOR_NOTIFY_SIGNING_SECRET",
100
+ "minimum_severity": "warning",
101
+ "events": [
102
+ "proposal.review_required",
103
+ "worker.dead_lettered",
104
+ "worker.unknown_outcome",
105
+ "worker.reconciliation_required",
106
+ "schema.drift_detected"
107
+ ],
108
+ "capabilities": ["billing.propose_small_credit"],
109
+ "environments": ["staging", "production"],
110
+ "delivery": "immediate",
111
+ "max_attempts": 5,
112
+ "timeout_ms": 3000,
113
+ "max_response_bytes": 1024,
114
+ "replay_window_seconds": 300,
115
+ "allow_private_destinations": false,
116
+ "recovery_notifications": false,
117
+ "budgets": {
118
+ "per_minute": 10,
119
+ "per_hour": 100,
120
+ "immediate_informational_per_hour": 0,
121
+ "aggregation_window_seconds": 300,
122
+ "cooldown_seconds": 600,
123
+ "max_unresolved_reminders": 3,
124
+ "digest_cadence_minutes": 1440,
125
+ "escalation_delay_seconds": 60,
126
+ "retry_attempt_threshold": 3,
127
+ "degraded_duration_seconds": 120,
128
+ "queue_depth_threshold": 100,
129
+ "queue_age_seconds": 300
130
+ },
131
+ "quiet_hours": {
132
+ "start_utc_hour": 22,
133
+ "end_utc_hour": 7
134
+ }
135
+ },
136
+ {
137
+ "id": "development",
138
+ "type": "jsonl",
139
+ "enabled": false,
140
+ "destination": "stdout",
141
+ "minimum_severity": "informational",
142
+ "delivery": "all"
143
+ }
144
+ ]
145
+ }
146
+ }
147
+ ```
148
+
149
+ Set values only in the operator environment:
150
+
151
+ ```bash
152
+ export SYNAPSOR_WORKBENCH_URL=https://runner-ops.example.internal
153
+ export SYNAPSOR_NOTIFY_WEBHOOK_URL=https://events.example.com/synapsor
154
+ export SYNAPSOR_NOTIFY_SIGNING_SECRET='at-least-32-random-bytes'
155
+ ```
156
+
157
+ The config stores environment-variable names, not URLs or secrets. Events never
158
+ contain credentials, database URLs, SQL, source rows, aggregate group results,
159
+ kept-out fields, trusted tenant/principal values, sensitive arguments, or
160
+ authority tokens.
161
+
162
+ ## Route By Destination
163
+
164
+ Every sink has its own event, severity, capability, environment, and delivery
165
+ route. A practical split is:
166
+
167
+ | Sink | Route |
168
+ | --- | --- |
169
+ | Workbench | Complete event and attention history |
170
+ | Team chat webhook | Review-required and operational warnings |
171
+ | Incident webhook | UNKNOWN, reconciliation, dead letter, sustained worker failure |
172
+ | Email-provider webhook | Daily digest |
173
+ | Audit webhook | Explicit all-events redacted stream |
174
+
175
+ The ledger and Workbench remain the source of truth.
176
+
177
+ ## Operate Without Copying IDs
178
+
179
+ Inspect the highest-priority item:
180
+
181
+ ```bash
182
+ synapsor-runner attention list --config ./synapsor.runner.json
183
+ synapsor-runner attention show --config ./synapsor.runner.json
184
+ ```
185
+
186
+ Use `attention show <attention_id>` only when selecting a specific item. JSON
187
+ automation is available with `--json`.
188
+
189
+ Acknowledgement is explicit:
190
+
191
+ ```bash
192
+ synapsor-runner attention acknowledge --actor alice \
193
+ --config ./synapsor.runner.json
194
+ ```
195
+
196
+ Production acknowledgement follows configured verified operator identity and is
197
+ cryptographically bound to the exact item digest and occurrence.
198
+
199
+ Inspect and test delivery:
200
+
201
+ ```bash
202
+ synapsor-runner notifications status --config ./synapsor.runner.json
203
+ synapsor-runner notifications test --sink operations \
204
+ --config ./synapsor.runner.json
205
+ synapsor-runner notifications dispatch --sink operations --limit 20 \
206
+ --config ./synapsor.runner.json
207
+ ```
208
+
209
+ `notifications test` sends an unmistakable synthetic event containing no
210
+ database or proposal data. Opening Workbench, installing Runner, and starting
211
+ MCP never start outbound delivery.
212
+
213
+ Requeue one dead-letter delivery:
214
+
215
+ ```bash
216
+ synapsor-runner notifications replay latest --yes \
217
+ --reason "Webhook repaired and synthetic test passed" \
218
+ --config ./synapsor.runner.json
219
+ ```
220
+
221
+ This replays only the immutable redacted notification. It does not replay an
222
+ approval or database mutation. Replay is an operator recovery action and
223
+ requires the configured `signed_key` or `jwt_oidc` identity. Runner binds the
224
+ verified decision to the exact delivery revision and records a redacted
225
+ immutable `notification.replayed` audit event.
226
+
227
+ ## Webhook Contract
228
+
229
+ Runner sends a CloudEvents 1.0 structured JSON body with content type
230
+ `application/cloudevents+json`. Important headers are:
231
+
232
+ ```text
233
+ X-Synapsor-Event-Id
234
+ X-Synapsor-Timestamp
235
+ X-Synapsor-Signature-Version: v1
236
+ X-Synapsor-Signature: sha256=<hex HMAC>
237
+ ```
238
+
239
+ The HMAC-SHA-256 input is the UTF-8 string
240
+ `v1.<timestamp>.<event-id>.<exact-body>`. Receivers must:
241
+
242
+ 1. Require signature version `v1`.
243
+ 2. Reject timestamps outside the configured replay window.
244
+ 3. Verify the HMAC with a timing-safe comparison.
245
+ 4. Deduplicate the stable event ID.
246
+ 5. Return only a bounded transport acknowledgement.
247
+
248
+ Runner ignores response content. A receiver cannot return a command.
249
+
250
+ A minimal Node receiver can verify the exact bytes before parsing JSON:
251
+
252
+ ```js
253
+ import { createHmac, timingSafeEqual } from "node:crypto";
254
+
255
+ export function verifySynapsorWebhook(rawBody, headers, secret, now = Date.now()) {
256
+ const eventId = headers["x-synapsor-event-id"];
257
+ const timestamp = headers["x-synapsor-timestamp"];
258
+ const version = headers["x-synapsor-signature-version"];
259
+ const suppliedHeader = headers["x-synapsor-signature"];
260
+ if (!eventId || !timestamp || version !== "v1" || !suppliedHeader?.startsWith("sha256=")) {
261
+ return false;
262
+ }
263
+
264
+ const timestampSeconds = Number(timestamp);
265
+ const ageMs = Math.abs(now - timestampSeconds * 1000);
266
+ if (!Number.isSafeInteger(timestampSeconds) || ageMs > 300_000) return false;
267
+
268
+ const expected = Buffer.from(
269
+ createHmac("sha256", secret)
270
+ .update(`v1.${timestamp}.${eventId}.${rawBody}`, "utf8")
271
+ .digest("hex"),
272
+ "hex",
273
+ );
274
+ const supplied = Buffer.from(suppliedHeader.slice("sha256=".length), "hex");
275
+ return expected.length === supplied.length && timingSafeEqual(expected, supplied);
276
+ }
277
+ ```
278
+
279
+ Read the request as raw UTF-8 bytes for verification; reserializing parsed JSON
280
+ changes the signed body. Store the event ID for at least the replay window and
281
+ reject a duplicate before processing it.
282
+
283
+ The structured body resembles:
284
+
285
+ ```json
286
+ {
287
+ "specversion": "1.0",
288
+ "id": "aev_...",
289
+ "type": "ai.synapsor.worker.reconciliation_required",
290
+ "source": "urn:synapsor:runner:production",
291
+ "time": "2026-07-25T00:00:00.000Z",
292
+ "datacontenttype": "application/json",
293
+ "subject": "billing.propose_small_credit",
294
+ "data": {
295
+ "schema_version": "synapsor.notification.v1",
296
+ "severity": "critical",
297
+ "environment": "production",
298
+ "capability": "billing.propose_small_credit",
299
+ "contract_digest": "sha256:...",
300
+ "summary": "A guarded apply needs operator reconciliation.",
301
+ "workbench_url": "https://runner-ops.example.internal/attention/att_..."
302
+ }
303
+ }
304
+ ```
305
+
306
+ The Workbench URL identifies an item but carries no approval or recovery
307
+ authority. The operator must authenticate normally.
308
+
309
+ HTTPS is required by default. Redirects and unsafe loopback, link-local,
310
+ metadata, multicast, unspecified, private, and rebinding destinations are
311
+ refused. Intentional private destinations require both
312
+ `allow_private_destinations: true` and an exact `private_host_allowlist`.
313
+
314
+ ## Delivery And Noise Guarantees
315
+
316
+ Delivery is at least once. Sink-specific records use atomic leases, fencing,
317
+ bounded retries, jittered backoff, timeout, maximum attempts, and dead-letter
318
+ state. Repeated delivery cannot repeat proposal authority or source mutation.
319
+
320
+ When a non-critical budget is exhausted, Runner preserves every event, updates
321
+ the Workbench item, and suppresses or batches external messages. Critical events
322
+ bypass ordinary quiet hours and budgets but still coalesce under a separate
323
+ bounded emergency ceiling.
324
+
325
+ Optional digests report redacted counts for proposals, auto-approval, apply,
326
+ review-required work, conflicts, retries, dead letters, limits, queue age, and
327
+ unresolved critical items. They link to authenticated Workbench detail.
328
+
329
+ Recovery messages are off by default. When an incident resolves, Workbench is
330
+ updated; an external recovery message is sent only when that sink explicitly
331
+ enables `recovery_notifications` or the critical-route policy requires it.
332
+
333
+ ## Retry And Dead-Letter Runbook
334
+
335
+ 1. Run `notifications status --json` and inspect sink health, pending delivery,
336
+ and dead-letter counts.
337
+ 2. Open the corresponding Workbench attention item. Confirm the authoritative
338
+ proposal/worker state before changing delivery.
339
+ 3. Repair the operator-owned destination, DNS policy, TLS, or secret rotation.
340
+ 4. Send `notifications test --sink <id>`; it contains no database data.
341
+ 5. Run `notifications dispatch --sink <id> --limit 20` to drain eligible work.
342
+ 6. Replay a dead-letter delivery only with
343
+ `notifications replay <delivery_id> --yes --reason "<why replay is safe>"`.
344
+
345
+ Replay preserves the original immutable event ID. It cannot re-run the database
346
+ operation. The exact recovery decision must be verified through the configured
347
+ operator identity. A still-failing sink remains observable and does not rewrite
348
+ the underlying proposal outcome.
349
+
350
+ ## Supervised Worker Health Gate
351
+
352
+ An exact supervised-worker capability may list:
353
+
354
+ ```json
355
+ {
356
+ "required_attention_sinks": ["operations"]
357
+ }
358
+ ```
359
+
360
+ If every required sink is unhealthy or untested, an otherwise eligible
361
+ policy-approved production write remains queued. Workbench explains that
362
+ operator visibility is unavailable. A healthy sink does not approve anything;
363
+ after recovery, the worker still repeats every ordinary digest, policy, scope,
364
+ freshness, limit, credential, lease, and guarded-write check.
365
+
366
+ See [Operator-Supervised Automatic
367
+ Apply](supervised-automatic-apply.md) for the execution boundary.