melchizedek-agents 0.17.0 → 0.18.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 (41) hide show
  1. package/.env.example +23 -3
  2. package/AGENT_SETUP.md +2 -1
  3. package/CHANGELOG.md +104 -0
  4. package/DOCUMENTATION.md +75 -9
  5. package/config/agents/syndicateSchema.yaml +13 -0
  6. package/db/hardening.sql +4 -3
  7. package/db/migrations/0012_audit_log.sql +98 -0
  8. package/dist/lib/a2a/app.d.ts +20 -0
  9. package/dist/lib/a2a/app.js +94 -4
  10. package/dist/lib/a2a/executor.d.ts +34 -4
  11. package/dist/lib/a2a/executor.js +68 -9
  12. package/dist/lib/compile.d.ts +1 -2
  13. package/dist/lib/compile.js +33 -6
  14. package/dist/lib/config.d.ts +7 -0
  15. package/dist/lib/config.js +7 -0
  16. package/dist/lib/loadEnv.d.ts +5 -0
  17. package/dist/lib/loadEnv.js +7 -0
  18. package/dist/lib/loadSyndicate.d.ts +4 -0
  19. package/dist/lib/memory/providers.d.ts +27 -0
  20. package/dist/lib/memory/providers.js +28 -0
  21. package/dist/lib/models/fallback.d.ts +41 -0
  22. package/dist/lib/models/fallback.js +105 -0
  23. package/dist/lib/observability/audit.d.ts +21 -0
  24. package/dist/lib/observability/audit.js +39 -0
  25. package/dist/lib/observability/metrics.d.ts +6 -0
  26. package/dist/lib/observability/tracer.d.ts +13 -0
  27. package/dist/lib/observability/tracer.js +16 -2
  28. package/dist/lib/runtime/syndicateTurn.d.ts +3 -1
  29. package/dist/lib/runtime/syndicateTurn.js +3 -1
  30. package/dist/lib/storage/postgres/index.d.ts +3 -0
  31. package/dist/lib/storage/postgres/index.js +2 -0
  32. package/dist/lib/syndicateSchema.d.ts +8 -0
  33. package/dist/lib/syndicateSchema.js +19 -4
  34. package/dist/lib/tools/mcpToolFactory.d.ts +26 -0
  35. package/dist/lib/tools/mcpToolFactory.js +61 -4
  36. package/dist/lib/tools/openapiTools.d.ts +6 -0
  37. package/dist/lib/tools/openapiTools.js +31 -1
  38. package/dist/scripts/a2a_server.js +51 -8
  39. package/dist/scripts/db.d.ts +9 -0
  40. package/dist/scripts/db.js +30 -15
  41. package/package.json +3 -3
package/.env.example CHANGED
@@ -129,6 +129,8 @@ A2A_SERVER_SECRET=
129
129
  # Used when compiling the AgentCard that advertises this agent to A2A clients.
130
130
  # Defaults to http://localhost:4000 if not set.
131
131
  # Only needed for cloud deployments where the server has a real public URL.
132
+ # With it set, the server also refuses to start until A2A_AUTH,
133
+ # A2A_SERVED_AGENTS and A2A_TRUST_PROXY are set explicitly (ADR 0039).
132
134
  PUBLIC_URL=
133
135
 
134
136
  # ── A2A server: identity and billing (all optional; ADR 0017, 0025) ─────────
@@ -141,6 +143,7 @@ PUBLIC_URL=
141
143
  # jwt a JWT from your identity provider; the scope comes from its claims.
142
144
  # header an authenticating gateway in front sets the user header; requires
143
145
  # A2A_SERVER_SECRET, which only the gateway holds.
146
+ # Required with PUBLIC_URL; prefer callers or jwt there.
144
147
  # A2A_AUTH=secret
145
148
  # callers: `name:sha256[:scope]` entries separated by ';'. The config holds only
146
149
  # each token's SHA-256. Mint one: npx melchizedek-serve --new-caller <name>
@@ -171,8 +174,8 @@ PUBLIC_URL=
171
174
  # The server refuses a database behind the migrations it ships (run
172
175
  # `npx melchizedek-db apply`); this starts it anyway.
173
176
  # ALLOW_SCHEMA_MISMATCH=false
174
- # Only these agent ids are served at /<agentId>/... (comma list). Unset = any
175
- # syndicate the loader can find.
177
+ # Only these agent ids are served at /<agentId>/... (comma list). Unset, or *,
178
+ # = any syndicate the loader can find. Required with PUBLIC_URL (* allowed).
176
179
  # A2A_SERVED_AGENTS=support_triage,research_brief
177
180
  # Per-task wall-clock budget (ms; 0 = none). Default 15 minutes.
178
181
  # A2A_TASK_TIMEOUT_MS=900000
@@ -191,11 +194,23 @@ PUBLIC_URL=
191
194
  # A2A_RATE_LIMIT_MAX=60
192
195
  # A2A_RATE_LIMIT_WINDOW_MS=900000
193
196
  # A2A_AUTH_FAILURE_MAX=30
194
- # Express trust-proxy: proxy hop count, true/false, or a subnet list. Default 1.
197
+ # Express trust-proxy: proxy hop count, true/false, or a subnet list. Default
198
+ # false (no proxy is trusted). Required with PUBLIC_URL: 1 behind one load
199
+ # balancer, false when clients connect directly.
195
200
  # A2A_TRUST_PROXY=1
201
+ # Concurrent tasks for one end user (scope), and for one caller. 0 = unlimited.
202
+ # A2A_MAX_CONCURRENT_PER_SCOPE=4
203
+ # A2A_MAX_CONCURRENT_PER_CALLER=0
204
+
205
+ # OpenAPI tools: the exact environment variables an `auth:` may name. Unset, any
206
+ # variable but the framework's own settings (this file's) is allowed.
207
+ # OPENAPI_CREDENTIAL_ENVS=WEATHER_API_KEY,CRM_TOKEN
196
208
  # A2A_BODY_LIMIT=1mb
197
209
  # How long SIGTERM waits for running tasks before canceling them.
198
210
  # A2A_SHUTDOWN_GRACE_MS=25000
211
+ # Of that, how long /readyz fails while requests are still served, so a load
212
+ # balancer deregisters this instance before the listener closes.
213
+ # A2A_SHUTDOWN_DELAY_MS=5000
199
214
  # Daily budgets per UTC day (ADR 0026), per caller and per scope (one end
200
215
  # user), on tasks, model calls and tokens. A task over budget ends `rejected`
201
216
  # with the reason. Counted in Postgres/Supabase (db/migrations/0004) when
@@ -279,6 +294,11 @@ WIKI_AGENT_MODEL=
279
294
  # retried with jittered backoff, honouring Retry-After up to 60 s, before any
280
295
  # output is produced. Total attempts per model call; 1 turns retries off.
281
296
  # MODEL_RETRY_MAX_ATTEMPTS=3
297
+ # For agents with a fallback_model: after this many consecutive provider-side
298
+ # failures a provider's circuit opens and those agents answer from their
299
+ # fallback for the cooldown. 0 disables the breaker.
300
+ # MODEL_BREAKER_THRESHOLD=5
301
+ # MODEL_BREAKER_COOLDOWN_MS=30000
282
302
  # Per-attempt timeout for xAI (Grok) calls, ms; floor 120000.
283
303
  # XAI_TIMEOUT_MS=600000
284
304
 
package/AGENT_SETUP.md CHANGED
@@ -193,7 +193,8 @@ user id.
193
193
 
194
194
  Deploying the server itself (Heroku, Fly, a VPS) needs `PUBLIC_URL` set
195
195
  — at which point the server refuses to start without the bearer secret,
196
- by design. Run `db/hardening.sql` before real user data arrives, and
196
+ and until `A2A_AUTH`, `A2A_SERVED_AGENTS` and `A2A_TRUST_PROXY` are set
197
+ explicitly, by design. Run `db/hardening.sql` before real user data arrives, and
197
198
  read the deployment notes in `lib/memory/README.md` end to end. The
198
199
  patient in module 2.02's specimen would expect no less of you.
199
200
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,110 @@ Consumers of the package read this file; it records changes to the
4
4
  **published API surface** (the exports map in `package.json`, the bins,
5
5
  the starter pack and the templates), not the repo's full history.
6
6
 
7
+ ## 0.18.0 — 2026-10-06
8
+
9
+ ### Breaking — read before upgrading
10
+
11
+ - **A public server states its posture (ADR 0039).** With `PUBLIC_URL` set,
12
+ the server refuses to start until all three are set explicitly; the boot
13
+ message names each one missing:
14
+ - `A2A_AUTH`: `callers` or `jwt` (recommended), `header`, or `secret`
15
+ (one shared secret: any holder can act as any user via `X-User-Id`).
16
+ - `A2A_SERVED_AGENTS`: the agent ids to serve, or `*` for every agent.
17
+ - `A2A_TRUST_PROXY`: proxies in front (`1` behind one load balancer), or
18
+ `false` when clients connect directly.
19
+ - **`A2A_TRUST_PROXY` defaults to `false`** (was 1): a server reached
20
+ directly no longer takes the client's `X-Forwarded-For` as its address.
21
+ - **A turn stops at 50 model calls** when the YAML sets no `max_steps`
22
+ (`DEFAULT_MAX_STEPS`). A syndicate that needs more sets `max_steps`.
23
+ - **One end user runs at most 4 tasks at once** (`A2A_MAX_CONCURRENT_PER_SCOPE`,
24
+ `maxConcurrentPerScope`; 0 = unlimited). `A2A_MAX_CONCURRENT_PER_CALLER`
25
+ adds a per-caller cap, off by default. The refusal names the cap.
26
+
27
+ - **The `@google/adk` peer range is `~2.2.0`** (was `^2.2.0`): the tested
28
+ minor. A new ADK minor can bring a second `@google/genai` copy, whose
29
+ response shapes the framework pins; the range widens when a release is
30
+ tested against it. ADK 2.2.0 is the latest published, so no install
31
+ changes today.
32
+
33
+ - **An OpenAPI `auth` may not name one of the framework's own settings**
34
+ (the database URL, a provider key, an `A2A_` secret: anything under the
35
+ framework's prefixes). The YAML chooses the host too, so this was a way
36
+ to send a secret anywhere. `OPENAPI_CREDENTIAL_ENVS` makes the rule an
37
+ exact allowlist. ADR 0041.
38
+ - **MCP tool descriptions are cut at 1,000 characters and results at
39
+ 20,000**, each marked where it was cut.
40
+
41
+ ### Changes
42
+
43
+ - **An append-only audit trail** (`melchizedek_audit`, migration
44
+ `0012_audit_log`; ADR 0042). Every failed authentication, task outcome
45
+ and erasure is recorded with the caller, the source address, the agent
46
+ and task ids and a scope hash, and no content. A trigger refuses UPDATE
47
+ and DELETE; `melchizedek_prune_audit(days)` is the retention path.
48
+ `postgresStorage` supplies the sink (`audit`), and `createA2AApp` takes
49
+ an `audit` option for any other. Run `npx melchizedek-db apply`.
50
+ - **The server says where memory sends transcripts.** Long-term memory's
51
+ extraction and embedding providers default to Gemini whatever the agents
52
+ run on; a syndicate whose agents use other providers now gets one boot
53
+ warning naming the providers memory reaches and the variables that move
54
+ it (`memoryDestinations`, `memoryCrossesProviders`). DOCUMENTATION gains
55
+ "Where your data goes": every destination, its retention, and the setting
56
+ that changes it.
57
+ - **A request can be followed across systems.** Every task record
58
+ (`onTaskEnd`, and the JSON log line) carries `taskId` and `traceId`; the
59
+ trace id is the one the spans and the ledger row carry. A request with a
60
+ W3C `traceparent` header is linked from the turn's root span
61
+ (`caller.trace_id`, and `callerTraceId` on the record); the turn keeps
62
+ its own trace id. `validTraceparent` and `callerSpanContext` are exported
63
+ from `melchizedek-agents/observability/tracer`. ADR 0043.
64
+ - **`fallback_model:`, an agent key, and a per-provider circuit breaker.**
65
+ When an agent's model fails provider-side (5xx, 429, a reset, after its
66
+ retries) before producing output, its `fallback_model` answers; after
67
+ `MODEL_BREAKER_THRESHOLD` consecutive failures (default 5) the provider
68
+ is skipped for `MODEL_BREAKER_COOLDOWN_MS` (30 s) by agents that have a
69
+ fallback. A 4xx, a canceled turn and a half-finished stream are never
70
+ redirected. ADR 0044.
71
+ - **`mcp_tools:`, an agent key**: the MCP server's tools the agent may use;
72
+ any other tool the server lists is not exposed. On a dispatch route,
73
+ `require_approval` may name a listed MCP tool, so a write waits for a
74
+ person. `closeMcpConnections()` closes every open MCP connection.
75
+ - **`melchizedek-db apply` is one transaction under an advisory lock.** The
76
+ whole install (migrations, hardening, telemetry) runs as one psql
77
+ `--single-transaction` script that takes `pg_advisory_xact_lock` first: a
78
+ failure anywhere rolls everything back, and two applies started together
79
+ run one after the other. The output still names each file.
80
+ - `TaskLimiter` takes per-scope and per-caller limits and says which one
81
+ refused (`refusal()`); `createA2AApp` takes `maxConcurrentPerScope` and
82
+ `maxConcurrentPerCaller`; `DEFAULT_MAX_CONCURRENT_PER_SCOPE` is exported
83
+ from `melchizedek-agents/a2a` and `DEFAULT_MAX_STEPS` from
84
+ `melchizedek-agents/config`.
85
+
86
+ ## 0.17.1 — 2026-10-06
87
+
88
+ - **Fix: a rolling deploy no longer refuses requests.** On SIGTERM the
89
+ server closed its listener at once, while the load balancer was still
90
+ sending it traffic. It now fails `/readyz` first and keeps serving for
91
+ `A2A_SHUTDOWN_DELAY_MS` (default 5 s), then closes and drains. The delay
92
+ counts inside `A2A_SHUTDOWN_GRACE_MS`, so a stop takes no longer than
93
+ before. `A2AApp.markUnready()` does the same for an embedding app.
94
+ ADR 0038.
95
+ - **`/readyz` fails while durable storage does not answer** (503,
96
+ `reason: 'storage'`), so an instance that lost its database leaves the
97
+ rotation instead of failing turns. The cause is logged once per outage;
98
+ the answer is cached for 2 s and concurrent probes share one read, so the
99
+ unauthenticated route cannot be used to flood the database.
100
+ - **Fix: the MCP client no longer follows redirects past the SSRF guard.**
101
+ The SSE transport used the default fetch, which follows redirects; it now
102
+ uses `mcpFetch`, under the rule 0.17.0 gave OpenAPI tools: same-origin hops
103
+ get the server's check, cross-origin hops the full guard and no
104
+ credential header.
105
+ - **Fix: a failed MCP connect closes its transport.** The SSE stream's
106
+ reconnect timer kept running after `createMcpTools` gave up, for the life
107
+ of the process.
108
+ - **`MELCHIZEDEK_DOTENV=off`** makes every bin ignore `.env` files and run
109
+ on its environment alone.
110
+
7
111
  ## 0.17.0 — 2026-10-06
8
112
 
9
113
  ### Read before upgrading
package/DOCUMENTATION.md CHANGED
@@ -110,6 +110,8 @@ Field reference:
110
110
  | `a2a_agent_url` | subagent | A REMOTE agent over A2A (§6): the orchestrator delegates to it with one `request` argument; in plan-dispatch it can be a route. No `model`/`instruction` — the remote agent has its own. Credentials come from `A2A_AGENT_TOKENS`, never YAML. |
111
111
  | `max_steps` | root | Cap on model calls per turn, counted across every agent the turn reaches (orchestrator, subagents, nested syndicates). Exceeding it fails the turn with `STEP_LIMIT`. |
112
112
  | `includeContents` / `outputKey` / `globalInstruction` / `disallowTransferToParent` / `disallowTransferToPeers` | agent | Passed through to ADK's LlmAgent. `includeContents: none` makes an agent see only the current message. |
113
+ | `fallback_model` | any agent | A model, ideally on another provider, that answers when this agent's model fails provider-side (5xx, 429, a connection reset, after its own retries) before producing any output, or while that provider's circuit is open: after `MODEL_BREAKER_THRESHOLD` consecutive provider failures (default 5; 0 disables) the provider is skipped for `MODEL_BREAKER_COOLDOWN_MS` (default 30 s). A 4xx and a canceled turn are never redirected, and a stream that already produced text is never replayed elsewhere ([ADR 0044](./wiki/decisions/0044-fallback-model-and-circuit-breaker.md)). |
114
+ | `mcp_tools` | subagent | The MCP server's tools this agent may use; any other tool the server lists is not exposed. On a dispatch route `require_approval` may name them ([ADR 0041](./wiki/decisions/0041-tool-vendors-get-least-privilege.md)). |
113
115
  | `mcp_server_url` | subagent | Discover this subagent's tools from a remote MCP server at load time (§3). SSRF-guarded; `ALLOW_PRIVATE_MCP=true` permits localhost for development. |
114
116
  | `openapi` | any agent | HTTP APIs as tools, each from an OpenAPI 3 spec file (§3, OpenAPI tools): `spec`, and optionally `operations` (default: the GET operations only), `auth` (from environment variables), `base_url`, `prefix`. |
115
117
  | `code_execution` | any Gemini agent | `"gemini"`: the model writes Python and Gemini runs it in Google's server-side sandbox, returning the output to the model; nothing runs on this host. For arithmetic, data and checks a model gets wrong in its head. Gemini models only ([ADR 0033](./wiki/decisions/0033-context-task-code.md)). |
@@ -180,7 +182,11 @@ writes is exposed only by naming it, and a named operation can be listed
180
182
  under `require_approval` (as written under `operations`) so a person
181
183
  approves each call. `auth` names an environment variable, never a value
182
184
  (`bearer_env`, or `api_key` with `in: header | query` and `name`); an unset
183
- variable fails the compile, and a static token is applied to the request,
185
+ variable fails the compile, and so does one of the framework's own settings
186
+ (the database URL, a provider key, an `A2A_` secret: anything `.env.example`
187
+ documents), since the YAML chooses the host it goes to.
188
+ `OPENAPI_CREDENTIAL_ENVS`, when set, is the exact list of variables an `auth`
189
+ may name. A refused or unset variable fails the compile, and a static token is applied to the request,
184
190
  never stored in session state. Every server must be http(s) and pass the
185
191
  SSRF guard: its literal rules when the agent compiles, the full check with
186
192
  DNS before each call; `ALLOW_PRIVATE_OPENAPI=true` permits private hosts for
@@ -223,7 +229,11 @@ npx melchizedek-db status # schema version, hardening, session counts
223
229
 
224
230
  That runs the migrations in [`db/migrations/`](./db/migrations/) and then
225
231
  [`db/hardening.sql`](./db/hardening.sql). Both are idempotent, so re-running
226
- them is also the upgrade path from any earlier layout.
232
+ them is also the upgrade path from any earlier layout. `apply` runs the whole
233
+ install as one transaction under an advisory lock: if any statement fails,
234
+ nothing is changed, and two applies started together run one after the other.
235
+ Rolling back a migration that succeeded is a restore from backup (§6), since
236
+ the migrations only move forward.
227
237
 
228
238
  The tables go in the `public` schema by default. To keep them out of reach of
229
239
  a REST layer (Supabase exposes `public`), install into a private schema and
@@ -545,9 +555,28 @@ or pass your own `resolveRequest`.
545
555
  The counts live in the `melchizedek_usage` table (`db/migrations/0004_usage.sql`)
546
556
  when Postgres or Supabase is configured, else in process memory. A store
547
557
  that cannot be read refuses the task.
548
- - **One record per task** (option `onTaskEnd`): agent, caller, a hash of the
549
- scope, status, reason, duration, model calls and tokens.
558
+ - **One record per task** (option `onTaskEnd`): task id, trace id, agent,
559
+ caller, a hash of the scope, status, reason, duration, model calls and
560
+ tokens. The trace id is the one the turn's spans and its ledger row carry,
561
+ and the audit trail's `task.end` row names both ids, so one id joins the
562
+ log, the ledger, the audit trail and your tracing backend. A request
563
+ carrying a W3C `traceparent` header is linked, not joined: the turn's root
564
+ span links to the caller's span and records `caller.trace_id`, and the
565
+ record carries `callerTraceId`. The turn keeps a trace id of its own, since
566
+ ledger attribution and erasure key on it and a caller must not choose it.
550
567
  `A2A_LOG_FORMAT=json` prints every server line as JSON, this record included.
568
+ - **The audit trail** (`melchizedek_audit`, `db/migrations/0012_audit_log.sql`;
569
+ option `audit`, supplied by `postgresStorage`): one row per failed
570
+ authentication (`auth.failure`), task outcome (`task.end`) and erasure
571
+ (`memory.erase`), with the caller's name, the source address, the agent and
572
+ task ids, and a hash of the scope. Never a scope key and never conversation
573
+ content. A trigger refuses UPDATE and DELETE; rows leave only through
574
+ `SELECT melchizedek_prune_audit(<days>)`, which you schedule (pg_cron, or a
575
+ job) for the retention your evidence needs, since the source address is
576
+ personal data. A failed write is logged once and each event is printed to
577
+ stderr as JSON until writes recover. A database owner can still alter the
578
+ table; ship the rows to a store you do not administer when the evidence must
579
+ survive that.
551
580
  - **Rate limit**: with an authenticator it counts per caller (an operator's
552
581
  backend) or per scope (an end user); under the shared secret, per IP.
553
582
  - **The telemetry ledger is redacted before it is written**:
@@ -556,6 +585,30 @@ or pass your own `resolveRequest`.
556
585
  `setTelemetryRedactor(fn)`. Sessions and memory are not redacted; use
557
586
  `DELETE /memory` there.
558
587
 
588
+ #### Where your data goes
589
+
590
+ What a deployment sends where, how long it is kept, and the setting that
591
+ changes it. "Your Postgres" is the database `DATABASE_URL` (or Supabase)
592
+ names; nothing is kept by the framework anywhere else.
593
+
594
+ | Data | Goes to | Kept | Change it with |
595
+ |---|---|---|---|
596
+ | The prompt: the user's message, the conversation, tool results | The model provider of each agent that runs (the model-id prefix) | The provider's policy | The `model:` lines in the YAML; `ollama/` ids stay on your machine |
597
+ | Tool calls (`web_search`, `web_extract`, OpenAPI, MCP) | The tool's own host | That host's policy | The agent's `tools:`, `openapi:`, `mcp_server_url` / `mcp_tools` |
598
+ | Sessions (conversation history) | Your Postgres, `adk_sessions` | 7 days after the last message | `ttlDays` (`postgresStorage`) |
599
+ | Long-term memory facts | Your Postgres, `adk_memory_facts`; **the transcript is also sent to the extraction and embedding providers, Gemini by default whatever the agents run on** (the server warns at boot when they differ) | Until erased, or `memory_retention_days` | `MEMORY_EXTRACTION_MODEL`, `MEMORY_EMBEDDING_PROVIDER`, `memory_retention_days` |
600
+ | The ledger: each turn's input, output and tool results (key-shaped secrets redacted) | Your Postgres, `adk_turns`, `adk_telemetry` | **Until you prune it**: schedule `melchizedek_prune_telemetry(<days>)` | `TELEMETRY_REDACT`, the prune's `turn_days` |
601
+ | Sampled full model requests | Your Postgres, `adk_payloads` | 30 days | `TELEMETRY_PAYLOADS`, `TELEMETRY_PAYLOAD_TTL_DAYS` |
602
+ | Traces, when `OTEL_EXPORTER_OTLP_ENDPOINT` is set | Your OTLP collector, conversation content included with key-shaped secrets redacted (`off` drops it) | Your collector's policy | `OTEL_EXPORT_CONTENT` (`redacted`, `off`, `raw`) |
603
+ | The audit trail (no content) | Your Postgres, `melchizedek_audit` | Until `melchizedek_prune_audit(<days>)` | The prune schedule |
604
+ | The task record (no content) | stdout | Your log platform's policy | `A2A_LOG_FORMAT` |
605
+
606
+ `DELETE /memory` erases a user from every Postgres store above except the
607
+ audit trail, which holds no content, only a hash of the scope. It does not
608
+ reach a provider's own retention: read each provider's data terms, and the
609
+ business associate or data processing agreement you need with it, before
610
+ sending it regulated data.
611
+
559
612
  #### Who pays
560
613
 
561
614
  `A2A_KEY_MODE` (option `keyMode`), whatever the authenticator:
@@ -687,9 +740,13 @@ subagent or a workflow node listing it is a load error. In code,
687
740
  | `A2A_AUTH_FAILURE_MAX` failed logins per IP per 15 min, then blocked | 30 |
688
741
  | `A2A_TASK_TIMEOUT_MS` per task | 15 min |
689
742
  | `A2A_MAX_CONCURRENT_TASKS` | unlimited |
690
- | `max_steps` (YAML): model calls per turn, subagents included | none |
743
+ | `A2A_MAX_CONCURRENT_PER_SCOPE`: tasks at once for one end user | 4 |
744
+ | `A2A_MAX_CONCURRENT_PER_CALLER`: tasks at once for one caller | unlimited |
745
+ | `max_steps` (YAML): model calls per turn, subagents included | 50 |
746
+ | `A2A_TRUST_PROXY`: proxies in front (required with `PUBLIC_URL`) | none trusted |
691
747
  | `A2A_BODY_LIMIT` | 1 MB |
692
748
  | `A2A_SHUTDOWN_GRACE_MS`: SIGTERM waits for running tasks | 25 s |
749
+ | `A2A_SHUTDOWN_DELAY_MS`: of that, `/readyz` fails while requests are still served | 5 s |
693
750
 
694
751
  The rate limit and the failed-login limit count in the process by default,
695
752
  so each replica keeps its own window. With `A2A_REDIS_URL` set (and the
@@ -719,7 +776,11 @@ Without `A2A_SERVER_SECRET` the server binds `127.0.0.1` only; binding
719
776
  another `HOST` requires the secret or `ALLOW_UNAUTHENTICATED=true`. With
720
777
  `PUBLIC_URL` set it refuses to start without the secret, with the
721
778
  `.env.example` placeholder as the secret, or against an unhardened
722
- database (unless `ALLOW_UNHARDENED_DB=true`). With durable storage it reads
779
+ database (unless `ALLOW_UNHARDENED_DB=true`), and until the deployment
780
+ states its posture: `A2A_AUTH` (prefer `callers` or `jwt`; `secret` lets any
781
+ holder of the secret act as any user), `A2A_SERVED_AGENTS` (a list, or `*`
782
+ for every agent) and `A2A_TRUST_PROXY` (the number of proxies in front, or
783
+ `false`). The refusal names each one missing. With durable storage it reads
723
784
  `melchizedek_schema_version` and refuses to start on a database behind the
724
785
  migrations it ships, naming `melchizedek-db apply` (or
725
786
  `ALLOW_SCHEMA_MISMATCH=true` to start anyway); a database ahead of it only
@@ -745,9 +806,14 @@ local conversation keeps talking to the same remote conversation.
745
806
  `Dockerfile` builds the compiled server and runs it as a non-root user with
746
807
  a health check; `compose.yaml` adds optional Ollama and Phoenix (traces).
747
808
  `npx melchizedek-db print|apply|status` installs and checks the database.
748
- Set `PUBLIC_URL`, `A2A_SERVER_SECRET` and the provider keys from your
749
- secret manager; give the orchestrator's stop timeout at least
750
- `A2A_SHUTDOWN_GRACE_MS`.
809
+ Set `PUBLIC_URL`, `A2A_SERVER_SECRET`, `A2A_AUTH`, `A2A_SERVED_AGENTS`,
810
+ `A2A_TRUST_PROXY` and the provider keys from your secret manager; give the orchestrator's stop timeout at least
811
+ `A2A_SHUTDOWN_GRACE_MS`. On SIGTERM the server first fails `/readyz` and
812
+ keeps serving for `A2A_SHUTDOWN_DELAY_MS` so the load balancer deregisters
813
+ it, then closes the listener and drains running tasks for the rest of the
814
+ grace period; point the readiness probe at `/readyz`, which also fails while
815
+ durable storage does not answer. `MELCHIZEDEK_DOTENV=off` makes every bin
816
+ ignore `.env` files and run on its environment alone.
751
817
 
752
818
  #### Backups
753
819
 
@@ -185,6 +185,12 @@ orchestrator:
185
185
  # Subagents inherit this if not set on their own config.
186
186
  model: "gemini-3.5-flash-lite"
187
187
 
188
+ # [optional] A model that answers when `model` fails provider-side (5xx,
189
+ # 429, a connection reset) before producing anything, or while that
190
+ # provider's circuit is open (MODEL_BREAKER_THRESHOLD, ADR 0044). Put it
191
+ # on another provider. A 4xx is never redirected.
192
+ # fallback_model: "claude-haiku-4-5"
193
+
188
194
  # [required] The agent's system prompt / persona.
189
195
  # Maps to: instruction
190
196
  instruction: |
@@ -414,6 +420,13 @@ subagents:
414
420
  # See config/agents/librarian.yaml for the worked example.
415
421
  # mcp_server_url: "http://localhost:8931/sse"
416
422
 
423
+ # [optional] The MCP server's tools this agent may use, by name. Any
424
+ # other tool the server lists is not exposed (a server's list is its
425
+ # own to change). Without it, every listed tool is. On a dispatch
426
+ # route, require_approval may name these (ADR 0041). Descriptions are
427
+ # cut at 1,000 characters and results at 20,000.
428
+ # mcp_tools: [wiki_search, wiki_read]
429
+
417
430
  # [optional] Agent Skills for this subagent; same block as on the
418
431
  # orchestrator. `scripts: "local"` is allowed here only under
419
432
  # `dispatch:` (a delegated subagent cannot pause the turn, ADR 0028).
package/db/hardening.sql CHANGED
@@ -51,7 +51,7 @@ DO $$
51
51
  DECLARE t text;
52
52
  BEGIN
53
53
  FOREACH t IN ARRAY ARRAY['adk_telemetry', 'adk_turns', 'adk_payloads', 'adk_verdicts', 'adk_labels',
54
- 'adk_session_events', 'adk_a2a_tasks', 'melchizedek_usage'] LOOP
54
+ 'adk_session_events', 'adk_a2a_tasks', 'melchizedek_usage', 'melchizedek_audit'] LOOP
55
55
  IF to_regclass('public.' || t) IS NOT NULL THEN
56
56
  EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
57
57
  EXECUTE format('REVOKE ALL ON %I FROM anon, authenticated', t);
@@ -133,7 +133,7 @@ BEGIN
133
133
  'melchizedek_registry_versions_immutable', 'melchizedek_registry_publish',
134
134
  'melchizedek_registry_activate', 'melchizedek_memory_commit',
135
135
  'melchizedek_memory_dimensions', 'melchizedek_prune_memory_ingest',
136
- 'melchizedek_prune_memory')
136
+ 'melchizedek_prune_memory', 'melchizedek_prune_audit')
137
137
  LOOP
138
138
  EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated', fn);
139
139
  IF has_service_role THEN
@@ -160,7 +160,8 @@ AS $$
160
160
  'adk_turns', 'adk_payloads', 'adk_verdicts', 'adk_labels',
161
161
  'adk_agent_registry', 'adk_agent_registry_versions',
162
162
  'adk_session_events', 'adk_a2a_tasks', 'melchizedek_usage',
163
- 'melchizedek_memory_ingest', 'melchizedek_tasks', 'melchizedek_task_owners');
163
+ 'melchizedek_memory_ingest', 'melchizedek_tasks', 'melchizedek_task_owners',
164
+ 'melchizedek_audit');
164
165
  $$;
165
166
 
166
167
  REVOKE ALL ON FUNCTION melchizedek_rls_status() FROM PUBLIC, anon, authenticated;
@@ -0,0 +1,98 @@
1
+ -- ============================================================================
2
+ -- 0012_audit_log.sql — an append-only record of who did what, when (ADR 0042)
3
+ -- ============================================================================
4
+ -- One row per security-relevant event: a failed authentication, a task's
5
+ -- outcome, an erasure. The ledger (adk_turns) records what a turn said;
6
+ -- this records who asked, how they authenticated, from where, and what
7
+ -- happened, which is what an auditor asks for.
8
+ --
9
+ -- What a row holds: the event, its outcome, the caller's name and auth
10
+ -- method, a SHA-256 prefix of the scope (never the scope key itself), the
11
+ -- source address, the agent and task ids, and a small detail object. No
12
+ -- conversation content, ever.
13
+ --
14
+ -- Append-only: a trigger refuses UPDATE and DELETE. Rows leave only
15
+ -- through melchizedek_prune_audit(days), which removes rows older than the
16
+ -- retention period (the source address is personal data; keep it no longer
17
+ -- than the evidence needs). TRUNCATE is revoked from every API role. A
18
+ -- database superuser can still alter the table; ship the rows to a log
19
+ -- store you do not administer when the evidence must survive that.
20
+ -- Idempotent.
21
+ -- ============================================================================
22
+
23
+ CREATE TABLE IF NOT EXISTS melchizedek_audit (
24
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
25
+ at TIMESTAMPTZ NOT NULL DEFAULT now(),
26
+ event TEXT NOT NULL, -- auth.failure | task.end | memory.erase
27
+ outcome TEXT, -- denied | completed | failed | rejected | canceled | input-required | ok
28
+ caller TEXT, -- the authenticated caller's name ('shared-secret', a caller token's name, 'jwt')
29
+ scope_hash TEXT, -- SHA-256 prefix of the scope key; joins rows without naming a user
30
+ source_ip TEXT,
31
+ agent_id TEXT,
32
+ task_id TEXT,
33
+ detail JSONB NOT NULL DEFAULT '{}'::jsonb
34
+ );
35
+ CREATE INDEX IF NOT EXISTS melchizedek_audit_at_idx ON melchizedek_audit (at);
36
+ CREATE INDEX IF NOT EXISTS melchizedek_audit_event_idx ON melchizedek_audit (event, at);
37
+
38
+ CREATE OR REPLACE FUNCTION melchizedek_audit_append_only()
39
+ RETURNS trigger
40
+ LANGUAGE plpgsql
41
+ SET search_path = public
42
+ AS $$
43
+ BEGIN
44
+ -- The retention function sets this for its own transaction only.
45
+ IF TG_OP = 'DELETE' AND coalesce(current_setting('melchizedek.audit_prune', true), '') = 'on' THEN
46
+ RETURN OLD;
47
+ END IF;
48
+ RAISE EXCEPTION 'melchizedek_audit is append-only (% refused); rows leave only through melchizedek_prune_audit', TG_OP;
49
+ END;
50
+ $$;
51
+
52
+ DROP TRIGGER IF EXISTS melchizedek_audit_append_only ON melchizedek_audit;
53
+ CREATE TRIGGER melchizedek_audit_append_only
54
+ BEFORE UPDATE OR DELETE ON melchizedek_audit
55
+ FOR EACH ROW EXECUTE FUNCTION melchizedek_audit_append_only();
56
+
57
+ -- Retention: delete rows older than p_days (at least 1). Returns the count.
58
+ -- SECURITY DEFINER: the API role holds SELECT and INSERT only, so pruning
59
+ -- runs with the owner's rights, through this function and nothing else.
60
+ CREATE OR REPLACE FUNCTION melchizedek_prune_audit(p_days integer)
61
+ RETURNS bigint
62
+ LANGUAGE plpgsql
63
+ SECURITY DEFINER
64
+ SET search_path = public
65
+ AS $$
66
+ DECLARE n bigint;
67
+ BEGIN
68
+ IF p_days IS NULL OR p_days < 1 THEN
69
+ RAISE EXCEPTION 'melchizedek_prune_audit: p_days must be at least 1';
70
+ END IF;
71
+ PERFORM set_config('melchizedek.audit_prune', 'on', true);
72
+ DELETE FROM melchizedek_audit WHERE at < now() - make_interval(days => p_days);
73
+ GET DIAGNOSTICS n = ROW_COUNT;
74
+ PERFORM set_config('melchizedek.audit_prune', '', true);
75
+ RETURN n;
76
+ END;
77
+ $$;
78
+
79
+ DO $$
80
+ BEGIN
81
+ REVOKE ALL ON melchizedek_audit FROM PUBLIC;
82
+ REVOKE ALL ON FUNCTION melchizedek_prune_audit(integer) FROM PUBLIC;
83
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
84
+ REVOKE ALL ON melchizedek_audit FROM anon;
85
+ END IF;
86
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
87
+ REVOKE ALL ON melchizedek_audit FROM authenticated;
88
+ END IF;
89
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
90
+ -- The server appends and reads; nothing else, so no UPDATE, DELETE or TRUNCATE.
91
+ GRANT SELECT, INSERT ON melchizedek_audit TO service_role;
92
+ GRANT EXECUTE ON FUNCTION melchizedek_prune_audit(integer) TO service_role;
93
+ END IF;
94
+ END $$;
95
+
96
+ INSERT INTO melchizedek_schema_version (version, name)
97
+ VALUES (12, '0012_audit_log')
98
+ ON CONFLICT (version) DO NOTHING;
@@ -32,6 +32,9 @@ import type { SyndicateYamlConfig } from '../loadSyndicate.ts';
32
32
  import type { ProviderEndpoint, ProviderId } from '../models/registry.ts';
33
33
  import type { TurnLock } from './turnLock.ts';
34
34
  import type { RlsHardeningStatus } from '../storage/rlsStatus.ts';
35
+ import type { AuditSink } from '../observability/audit.ts';
36
+ /** One end user's concurrent tasks, unless maxConcurrentPerScope says otherwise (ADR 0039). */
37
+ export declare const DEFAULT_MAX_CONCURRENT_PER_SCOPE = 4;
35
38
  import type { EraseCounts } from '../memory/erase.ts';
36
39
  import type { Embedder, MemoryExtractor } from '../memory/providers.ts';
37
40
  import type { A2AContext } from './executor.ts';
@@ -75,6 +78,8 @@ export interface A2AAppOptions {
75
78
  * fatal with `requireHardenedDb`, a warning otherwise.
76
79
  */
77
80
  rlsHardening?: () => Promise<RlsHardeningStatus>;
81
+ /** Appends audit events (postgresStorage supplies one; ADR 0042). */
82
+ audit?: AuditSink;
78
83
  /**
79
84
  * One turn at a time per conversation across instances (postgresStorage
80
85
  * supplies an advisory lock). Default: a lock in this process.
@@ -162,6 +167,15 @@ export interface A2AAppOptions {
162
167
  streamText?: boolean;
163
168
  /** Concurrent tasks across all agents. 0 = unlimited. */
164
169
  maxConcurrentTasks?: number;
170
+ /**
171
+ * Where audit events go: failed authentications, task outcomes, erasures
172
+ * (ADR 0042). Default: the storage's sink when it has one, else none.
173
+ */
174
+ audit?: AuditSink;
175
+ /** Concurrent tasks for one scope (one end user). Default 4; 0 = unlimited. */
176
+ maxConcurrentPerScope?: number;
177
+ /** Concurrent tasks for one authenticated caller. Default 0 (unlimited). */
178
+ maxConcurrentPerCaller?: number;
165
179
  /** Task submissions (POST) per window per client IP. */
166
180
  rateLimit?: {
167
181
  windowMs: number;
@@ -232,6 +246,12 @@ export interface A2AApp {
232
246
  /** Stop admitting tasks, wait up to `graceMs` for running ones, cancel the rest.
233
247
  * Resolves with the number of tasks that had to be canceled. */
234
248
  shutdown(graceMs: number): Promise<number>;
249
+ /**
250
+ * Fail `/readyz` from now on while still serving every request, so a load
251
+ * balancer stops routing here before the listener closes. The first step of
252
+ * a graceful stop; `shutdown()` is the last.
253
+ */
254
+ markUnready(): void;
235
255
  }
236
256
  /**
237
257
  * Build the agent card for one syndicate at one route prefix, in the A2A 1.0