melchizedek-agents 0.16.2 → 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 (100) hide show
  1. package/.env.example +40 -3
  2. package/AGENT_SETUP.md +4 -2
  3. package/CHANGELOG.md +246 -0
  4. package/DOCUMENTATION.md +311 -15
  5. package/QUICKSTART.md +2 -2
  6. package/README.md +125 -149
  7. package/config/agents/examples/harness.yaml +135 -0
  8. package/config/agents/examples/model_zoo.yaml +23 -9
  9. package/config/agents/examples/pipeline.yaml +120 -0
  10. package/config/agents/examples/specs/open-meteo-forecast.json +77 -0
  11. package/config/agents/examples/specs/open-meteo-geocoding.json +53 -0
  12. package/config/agents/examples/weather.yaml +59 -0
  13. package/config/agents/syndicateSchema.yaml +125 -1
  14. package/db/hardening.sql +4 -3
  15. package/db/migrations/0011_erase_expired.sql +198 -0
  16. package/db/migrations/0012_audit_log.sql +98 -0
  17. package/dist/lib/a2a/app.d.ts +27 -0
  18. package/dist/lib/a2a/app.js +101 -5
  19. package/dist/lib/a2a/executor.d.ts +34 -4
  20. package/dist/lib/a2a/executor.js +109 -9
  21. package/dist/lib/compile.d.ts +41 -3
  22. package/dist/lib/compile.js +162 -17
  23. package/dist/lib/config.d.ts +24 -0
  24. package/dist/lib/config.js +24 -0
  25. package/dist/lib/doctor.js +17 -4
  26. package/dist/lib/index.d.ts +2 -0
  27. package/dist/lib/index.js +1 -0
  28. package/dist/lib/loadEnv.d.ts +5 -0
  29. package/dist/lib/loadEnv.js +7 -0
  30. package/dist/lib/loadSyndicate.d.ts +50 -0
  31. package/dist/lib/loadSyndicate.js +19 -3
  32. package/dist/lib/memory/erase.d.ts +5 -0
  33. package/dist/lib/memory/erase.js +5 -0
  34. package/dist/lib/memory/providers.d.ts +27 -0
  35. package/dist/lib/memory/providers.js +28 -0
  36. package/dist/lib/models/capabilities.js +10 -0
  37. package/dist/lib/models/endpoints.d.ts +1 -1
  38. package/dist/lib/models/endpoints.js +4 -1
  39. package/dist/lib/models/fallback.d.ts +41 -0
  40. package/dist/lib/models/fallback.js +105 -0
  41. package/dist/lib/models/gateway.d.ts +2 -2
  42. package/dist/lib/models/gateway.js +4 -4
  43. package/dist/lib/models/kimiLlm.d.ts +123 -0
  44. package/dist/lib/models/kimiLlm.js +171 -0
  45. package/dist/lib/models/providerMap.d.ts +2 -2
  46. package/dist/lib/models/providerMap.js +4 -1
  47. package/dist/lib/models/registry.d.ts +1 -1
  48. package/dist/lib/models/registry.js +11 -3
  49. package/dist/lib/net/redirects.d.ts +14 -0
  50. package/dist/lib/net/redirects.js +94 -0
  51. package/dist/lib/observability/audit.d.ts +21 -0
  52. package/dist/lib/observability/audit.js +39 -0
  53. package/dist/lib/observability/metrics.d.ts +6 -0
  54. package/dist/lib/observability/tracer.d.ts +14 -1
  55. package/dist/lib/observability/tracer.js +16 -2
  56. package/dist/lib/persistence/supabaseProvider.d.ts +2 -6
  57. package/dist/lib/persistence/supabaseProvider.js +2 -31
  58. package/dist/lib/runtime/questions.d.ts +55 -0
  59. package/dist/lib/runtime/questions.js +107 -0
  60. package/dist/lib/runtime/syndicateTurn.d.ts +45 -5
  61. package/dist/lib/runtime/syndicateTurn.js +165 -17
  62. package/dist/lib/storage/postgres/index.d.ts +9 -0
  63. package/dist/lib/storage/postgres/index.js +13 -0
  64. package/dist/lib/storage/rlsStatus.d.ts +45 -0
  65. package/dist/lib/storage/rlsStatus.js +69 -0
  66. package/dist/lib/syndicateSchema.d.ts +177 -0
  67. package/dist/lib/syndicateSchema.js +343 -3
  68. package/dist/lib/toolRegistry.js +9 -0
  69. package/dist/lib/tools/mcpToolFactory.d.ts +26 -0
  70. package/dist/lib/tools/mcpToolFactory.js +61 -4
  71. package/dist/lib/tools/openapiTools.d.ts +52 -0
  72. package/dist/lib/tools/openapiTools.js +225 -0
  73. package/dist/lib/tools/skillToolset.d.ts +135 -0
  74. package/dist/lib/tools/skillToolset.js +247 -0
  75. package/dist/lib/tools/urlContextTool.d.ts +29 -0
  76. package/dist/lib/tools/urlContextTool.js +39 -0
  77. package/dist/lib/workflow.d.ts +72 -0
  78. package/dist/lib/workflow.js +180 -0
  79. package/dist/lib/workflowConfig.d.ts +95 -0
  80. package/dist/lib/workflowConfig.js +93 -0
  81. package/dist/scripts/a2a_server.js +51 -8
  82. package/dist/scripts/db.d.ts +9 -0
  83. package/dist/scripts/db.js +30 -15
  84. package/dist/scripts/syndicate_chat.js +39 -5
  85. package/package.json +6 -3
  86. package/skills/briefs/_shared.md +1 -1
  87. package/skills/briefs/melchizedek-author.md +8 -4
  88. package/skills/briefs/melchizedek-memory.md +1 -1
  89. package/skills/briefs/melchizedek-models.md +1 -1
  90. package/skills/briefs/melchizedek-scribe.md +4 -4
  91. package/skills/briefs/melchizedek-serve.md +1 -1
  92. package/skills/briefs/melchizedek.md +4 -1
  93. package/skills/melchizedek/SKILL.md +4 -1
  94. package/skills/melchizedek-author/SKILL.md +7 -3
  95. package/skills/melchizedek-memory/SKILL.md +1 -1
  96. package/skills/melchizedek-models/SKILL.md +1 -1
  97. package/skills/melchizedek-scribe/SKILL.md +3 -3
  98. package/skills/melchizedek-serve/SKILL.md +1 -1
  99. /package/skills/melchizedek-author/{templates → assets}/minimal.yaml +0 -0
  100. /package/skills/melchizedek-scribe/{templates → assets}/brief.md +0 -0
@@ -0,0 +1,198 @@
1
+ -- ============================================================================
2
+ -- 0011_erase_expired.sql — a namespace erase reaches expired conversations
3
+ -- ============================================================================
4
+ -- melchizedek_erase_scope (0010) found a namespace's conversations through
5
+ -- its live adk_sessions rows and erased ledger turns and A2A tasks only for
6
+ -- those. Sessions expire after seven idle days (0001) while the ledger keeps
7
+ -- turns indefinitely, so a namespace erase (the default for DELETE /memory)
8
+ -- left every expired conversation's input, output and tool results behind
9
+ -- and reported success.
10
+ --
11
+ -- Turns and tasks do not record the memory namespace, so a namespace erase
12
+ -- now keeps only what provably belongs to ANOTHER namespace: rows whose
13
+ -- conversation is still live there. Everything else of the scope goes,
14
+ -- including conversations whose sessions have expired, whichever namespace
15
+ -- they were in. Erasure errs toward deleting, never toward keeping (0002).
16
+ -- An erase without a namespace is unchanged. Idempotent.
17
+ -- ============================================================================
18
+
19
+ CREATE OR REPLACE FUNCTION melchizedek_erase_scope(
20
+ p_scope_key text,
21
+ p_namespace text DEFAULT NULL,
22
+ p_include_nested boolean DEFAULT false
23
+ ) RETURNS TABLE(store text, deleted bigint)
24
+ LANGUAGE plpgsql
25
+ SECURITY INVOKER
26
+ SET search_path = public
27
+ AS $$
28
+ DECLARE
29
+ -- LIKE pattern for scopes nested beneath this one, with the scope's own
30
+ -- wildcard characters escaped.
31
+ nested_like text := replace(replace(replace(p_scope_key, '\', '\\'), '%', '\%'), '_', '\_') || '/%';
32
+ contexts text[];
33
+ kept text[];
34
+ traces text[];
35
+ n bigint;
36
+ BEGIN
37
+ IF p_scope_key IS NULL OR btrim(p_scope_key) = '' THEN
38
+ RAISE EXCEPTION 'melchizedek_erase_scope: scope_key is required';
39
+ END IF;
40
+
41
+ -- The conversations (context ids) being erased. With a namespace, they are
42
+ -- the namespace's own session rows; without one, every row of the scope.
43
+ -- adk_sessions.id is '<app_name>:<user_id>:<context id>'.
44
+ SELECT coalesce(array_agg(DISTINCT substr(s.id, length(s.app_name) + length(s.user_id) + 3)), '{}')
45
+ INTO contexts
46
+ FROM adk_sessions s
47
+ WHERE (s.user_id = p_scope_key OR (p_include_nested AND s.user_id LIKE nested_like))
48
+ AND (p_namespace IS NULL OR s.app_name = p_namespace);
49
+
50
+ -- With a namespace: the conversations still live in another namespace,
51
+ -- which are the only ones a namespace erase keeps. Sub-agent rows share
52
+ -- their conversation's id, so excluding `contexts` keeps them with it.
53
+ SELECT coalesce(array_agg(DISTINCT substr(s.id, length(s.app_name) + length(s.user_id) + 3)), '{}')
54
+ INTO kept
55
+ FROM adk_sessions s
56
+ WHERE p_namespace IS NOT NULL
57
+ AND (s.user_id = p_scope_key OR (p_include_nested AND s.user_id LIKE nested_like))
58
+ AND s.app_name <> p_namespace
59
+ AND NOT (substr(s.id, length(s.app_name) + length(s.user_id) + 3) = ANY(contexts));
60
+
61
+ -- Ledger traces of the turns being erased, collected before anything is deleted.
62
+ IF to_regclass('public.adk_turns') IS NOT NULL THEN
63
+ EXECUTE $q$
64
+ SELECT coalesce(array_agg(DISTINCT trace_id), '{}') FROM adk_turns
65
+ WHERE (user_id = $1 OR ($2 AND user_id LIKE $3))
66
+ AND ($4 IS NULL OR session_id IS NULL OR NOT (session_id = ANY($5)))
67
+ $q$ INTO traces USING p_scope_key, p_include_nested, nested_like, p_namespace, kept;
68
+ ELSE
69
+ traces := '{}';
70
+ END IF;
71
+
72
+ -- Memory facts. user_key is '<namespace>/<scope>' and a namespace never
73
+ -- contains '/', so the scope is exactly what follows the first slash.
74
+ DELETE FROM adk_memory_facts f
75
+ WHERE (p_namespace IS NULL OR split_part(f.user_key, '/', 1) = p_namespace)
76
+ AND (substr(f.user_key, strpos(f.user_key, '/') + 1) = p_scope_key
77
+ OR (p_include_nested AND substr(f.user_key, strpos(f.user_key, '/') + 1) LIKE nested_like));
78
+ GET DIAGNOSTICS n = ROW_COUNT;
79
+ store := 'memory_facts'; deleted := n; RETURN NEXT;
80
+
81
+ -- Sessions: every row of the scope in those conversations, whatever app
82
+ -- name ADK gave it (subagents run under their own agent name).
83
+ DELETE FROM adk_sessions s
84
+ WHERE (s.user_id = p_scope_key OR (p_include_nested AND s.user_id LIKE nested_like))
85
+ AND substr(s.id, length(s.app_name) + length(s.user_id) + 3) = ANY(contexts);
86
+ GET DIAGNOSTICS n = ROW_COUNT;
87
+ store := 'sessions'; deleted := n; RETURN NEXT;
88
+
89
+ -- The ledger, when installed.
90
+ IF to_regclass('public.adk_turns') IS NOT NULL THEN
91
+ EXECUTE $q$
92
+ DELETE FROM adk_turns
93
+ WHERE (user_id = $1 OR ($2 AND user_id LIKE $3))
94
+ AND ($4 IS NULL OR session_id IS NULL OR NOT (session_id = ANY($5)))
95
+ $q$ USING p_scope_key, p_include_nested, nested_like, p_namespace, kept;
96
+ GET DIAGNOSTICS n = ROW_COUNT;
97
+ ELSE
98
+ n := 0;
99
+ END IF;
100
+ store := 'turns'; deleted := n; RETURN NEXT;
101
+
102
+ IF to_regclass('public.adk_telemetry') IS NOT NULL THEN
103
+ EXECUTE 'DELETE FROM adk_telemetry WHERE trace_id = ANY($1)' USING traces;
104
+ GET DIAGNOSTICS n = ROW_COUNT;
105
+ ELSE
106
+ n := 0;
107
+ END IF;
108
+ store := 'spans'; deleted := n; RETURN NEXT;
109
+
110
+ IF to_regclass('public.adk_payloads') IS NOT NULL THEN
111
+ EXECUTE 'DELETE FROM adk_payloads WHERE trace_id = ANY($1)' USING traces;
112
+ GET DIAGNOSTICS n = ROW_COUNT;
113
+ ELSE
114
+ n := 0;
115
+ END IF;
116
+ store := 'payloads'; deleted := n; RETURN NEXT;
117
+
118
+ IF to_regclass('public.adk_verdicts') IS NOT NULL THEN
119
+ EXECUTE 'DELETE FROM adk_verdicts WHERE trace_id = ANY($1)' USING traces;
120
+ GET DIAGNOSTICS n = ROW_COUNT;
121
+ ELSE
122
+ n := 0;
123
+ END IF;
124
+ store := 'verdicts'; deleted := n; RETURN NEXT;
125
+
126
+ IF EXISTS (
127
+ SELECT 1 FROM information_schema.columns
128
+ WHERE table_schema = 'public' AND table_name = 'adk_labels' AND column_name = 'trace_id'
129
+ ) THEN
130
+ EXECUTE 'DELETE FROM adk_labels WHERE trace_id = ANY($1)' USING traces;
131
+ GET DIAGNOSTICS n = ROW_COUNT;
132
+ ELSE
133
+ n := 0;
134
+ END IF;
135
+ store := 'labels'; deleted := n; RETURN NEXT;
136
+
137
+ IF to_regclass('public.adk_a2a_tasks') IS NOT NULL THEN
138
+ EXECUTE $q$
139
+ DELETE FROM adk_a2a_tasks
140
+ WHERE (owner = $1 OR ($2 AND owner LIKE $3))
141
+ AND ($4 IS NULL OR context_id IS NULL OR NOT (context_id = ANY($5)))
142
+ $q$ USING p_scope_key, p_include_nested, nested_like, p_namespace, kept;
143
+ GET DIAGNOSTICS n = ROW_COUNT;
144
+ ELSE
145
+ n := 0;
146
+ END IF;
147
+ store := 'tasks'; deleted := n; RETURN NEXT;
148
+
149
+ -- Memory ingestion markers (migration 0007): how far each conversation was
150
+ -- distilled. They carry the scope's key and its conversation ids.
151
+ IF to_regclass('public.melchizedek_memory_ingest') IS NOT NULL THEN
152
+ EXECUTE $q$
153
+ DELETE FROM melchizedek_memory_ingest m
154
+ WHERE ($1 IS NULL OR split_part(m.user_key, '/', 1) = $1)
155
+ AND (substr(m.user_key, strpos(m.user_key, '/') + 1) = $2
156
+ OR ($3 AND substr(m.user_key, strpos(m.user_key, '/') + 1) LIKE $4))
157
+ $q$ USING p_namespace, p_scope_key, p_include_nested, nested_like;
158
+ GET DIAGNOSTICS n = ROW_COUNT;
159
+ ELSE
160
+ n := 0;
161
+ END IF;
162
+ store := 'memory_markers'; deleted := n; RETURN NEXT;
163
+
164
+ -- The task tools' list and job queue (migration 0009), per caller across
165
+ -- every syndicate: erased with the whole scope, kept by a namespace erase.
166
+ n := 0;
167
+ IF p_namespace IS NULL AND to_regclass('public.melchizedek_tasks') IS NOT NULL THEN
168
+ EXECUTE $q$
169
+ DELETE FROM melchizedek_tasks WHERE owner = $1 OR ($2 AND owner LIKE $3)
170
+ $q$ USING p_scope_key, p_include_nested, nested_like;
171
+ GET DIAGNOSTICS n = ROW_COUNT;
172
+ EXECUTE $q$
173
+ DELETE FROM melchizedek_task_owners WHERE owner = $1 OR ($2 AND owner LIKE $3)
174
+ $q$ USING p_scope_key, p_include_nested, nested_like;
175
+ END IF;
176
+ store := 'task_tools'; deleted := n; RETURN NEXT;
177
+ END;
178
+ $$;
179
+
180
+ -- No API role may execute it (db/hardening.sql repeats this for every
181
+ -- melchizedek function); the server's role gets it back.
182
+ DO $$
183
+ BEGIN
184
+ REVOKE ALL ON FUNCTION melchizedek_erase_scope(text, text, boolean) FROM PUBLIC;
185
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
186
+ REVOKE ALL ON FUNCTION melchizedek_erase_scope(text, text, boolean) FROM anon;
187
+ END IF;
188
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
189
+ REVOKE ALL ON FUNCTION melchizedek_erase_scope(text, text, boolean) FROM authenticated;
190
+ END IF;
191
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
192
+ GRANT EXECUTE ON FUNCTION melchizedek_erase_scope(text, text, boolean) TO service_role;
193
+ END IF;
194
+ END $$;
195
+
196
+ INSERT INTO melchizedek_schema_version (version, name)
197
+ VALUES (11, '0011_erase_expired')
198
+ ON CONFLICT (version) DO NOTHING;
@@ -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;
@@ -31,6 +31,10 @@ import type { BaseLlm, BaseMemoryService, BaseSessionService } from '@google/adk
31
31
  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
+ 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;
34
38
  import type { EraseCounts } from '../memory/erase.ts';
35
39
  import type { Embedder, MemoryExtractor } from '../memory/providers.ts';
36
40
  import type { A2AContext } from './executor.ts';
@@ -68,6 +72,14 @@ export interface A2AAppOptions {
68
72
  * migrations this package ships (ADR 0021).
69
73
  */
70
74
  schemaVersion?: () => Promise<number | null>;
75
+ /**
76
+ * Whether db/hardening.sql is in force (postgresStorage supplies it).
77
+ * When given, the server checks it at boot like the supabase-js path:
78
+ * fatal with `requireHardenedDb`, a warning otherwise.
79
+ */
80
+ rlsHardening?: () => Promise<RlsHardeningStatus>;
81
+ /** Appends audit events (postgresStorage supplies one; ADR 0042). */
82
+ audit?: AuditSink;
71
83
  /**
72
84
  * One turn at a time per conversation across instances (postgresStorage
73
85
  * supplies an advisory lock). Default: a lock in this process.
@@ -155,6 +167,15 @@ export interface A2AAppOptions {
155
167
  streamText?: boolean;
156
168
  /** Concurrent tasks across all agents. 0 = unlimited. */
157
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;
158
179
  /** Task submissions (POST) per window per client IP. */
159
180
  rateLimit?: {
160
181
  windowMs: number;
@@ -225,6 +246,12 @@ export interface A2AApp {
225
246
  /** Stop admitting tasks, wait up to `graceMs` for running ones, cancel the rest.
226
247
  * Resolves with the number of tasks that had to be canceled. */
227
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;
228
255
  }
229
256
  /**
230
257
  * Build the agent card for one syndicate at one route prefix, in the A2A 1.0
@@ -36,8 +36,13 @@ import { providerForModel, resolveModel } from '../models/registry.js';
36
36
  import { createSupabaseServices, hasSupabaseCredentials } from '../persistence/supabaseProvider.js';
37
37
  import { inProcessTurnLock } from './turnLock.js';
38
38
  import { compareSchema, schemaBehindMessage, shippedSchemaVersion } from '../storage/schemaVersion.js';
39
+ import { scopeHashOf } from '../observability/audit.js';
40
+ import { validTraceparent } from '../observability/tracer.js';
41
+ /** One end user's concurrent tasks, unless maxConcurrentPerScope says otherwise (ADR 0039). */
42
+ export const DEFAULT_MAX_CONCURRENT_PER_SCOPE = 4;
39
43
  import { eraseScope } from '../memory/erase.js';
40
44
  import { namespacedMemoryService } from '../memory/namespace.js';
45
+ import { memoryCrossesProviders, memoryDestinations } from '../memory/providers.js';
41
46
  import { A2A_APP_NAME, SyndicateExecutor, TaskLimiter, deriveUserId, requestContextStorage, } from './executor.js';
42
47
  import { HEADER_VALUE_PATTERN, SCOPE_KEY_PATTERN } from './identity.js';
43
48
  import { createMetrics } from '../observability/metrics.js';
@@ -187,11 +192,13 @@ export async function createA2AApp(options) {
187
192
  let memoryService;
188
193
  let erase;
189
194
  let readSchemaVersion;
195
+ let checkHardening;
190
196
  if (options.storage) {
191
197
  durableSessions = options.storage.sessionService;
192
198
  memoryService = options.storage.memoryService;
193
199
  erase = options.storage.erase;
194
200
  readSchemaVersion = options.storage.schemaVersion;
201
+ checkHardening = options.storage.rlsHardening;
195
202
  }
196
203
  else if (hasSupabaseCredentials()) {
197
204
  // ADR 0021 item 6: the supabase-js path stays for a transition period.
@@ -209,7 +216,11 @@ export async function createA2AApp(options) {
209
216
  memoryService = services.memoryService;
210
217
  erase = (scopeKey, eraseOpts) => eraseScope(services.rpcClient, scopeKey, eraseOpts);
211
218
  readSchemaVersion = services.schemaVersion;
212
- const rls = await services.checkRlsHardening();
219
+ checkHardening = services.checkRlsHardening;
220
+ }
221
+ // Both storage paths: refuse a public deployment whose tables an API key can read.
222
+ if (checkHardening) {
223
+ const rls = await checkHardening();
213
224
  if (rls.applied) {
214
225
  log(`✓ DB hardening verified — ${rls.detail}.`);
215
226
  }
@@ -280,6 +291,22 @@ export async function createA2AApp(options) {
280
291
  * and only `long-term` syndicates get the memory service.
281
292
  */
282
293
  const internalSessions = new InMemorySessionService();
294
+ // Long-term memory sends transcripts to its extraction and embedding
295
+ // providers; say so once per syndicate when they are not the agents' own.
296
+ const memoryFlowWarned = new Set();
297
+ const warnMemoryFlow = (cfg) => {
298
+ if (memoryFlowWarned.has(cfg.syndicate_name))
299
+ return;
300
+ memoryFlowWarned.add(cfg.syndicate_name);
301
+ const dest = memoryDestinations(process.env, { extractor: options.memory?.extractor, embedder: options.memory?.embedder });
302
+ const models = [cfg.orchestrator?.model, ...(cfg.subagents ?? []).map((s) => s.model)];
303
+ const crossed = memoryCrossesProviders(models, dest);
304
+ if (crossed.length) {
305
+ warn(`'${cfg.syndicate_name}' keeps long-term memory, so its transcripts also go to ${crossed.join(' and ')} `
306
+ + `(extraction: ${dest.extraction}, embeddings: ${dest.embeddings}), which its agents do not use. `
307
+ + 'Set MEMORY_EXTRACTION_MODEL and MEMORY_EMBEDDING_PROVIDER to keep memory on an approved provider.');
308
+ }
309
+ };
283
310
  const servicesFor = (cfg) => {
284
311
  const mode = cfg.memory_system;
285
312
  const sessionService = mode === 'internal-only' || !durableSessions ? internalSessions : durableSessions;
@@ -287,6 +314,8 @@ export async function createA2AApp(options) {
287
314
  // turn reaches — including AgentTool children, which run under their own
288
315
  // ADK app name — recalls and stores in the root syndicate's memory.
289
316
  const memory = mode === 'long-term' && memoryService ? namespacedMemoryService(memoryService, memoryAppName(cfg)) : undefined;
317
+ if (memory)
318
+ warnMemoryFlow(cfg);
290
319
  if (mode === 'long-term' && !memoryService) {
291
320
  warn(`'${cfg.syndicate_name}' requests long-term memory but Supabase is not configured — memory disabled.`);
292
321
  }
@@ -295,7 +324,11 @@ export async function createA2AApp(options) {
295
324
  }
296
325
  return { sessionService, memoryService: memory, durable: sessionService !== internalSessions };
297
326
  };
298
- const limiter = new TaskLimiter(options.maxConcurrentTasks ?? 0);
327
+ const audit = options.audit ?? options.storage?.audit;
328
+ const limiter = new TaskLimiter(options.maxConcurrentTasks ?? 0, {
329
+ perScope: options.maxConcurrentPerScope ?? DEFAULT_MAX_CONCURRENT_PER_SCOPE,
330
+ perCaller: options.maxConcurrentPerCaller ?? 0,
331
+ });
299
332
  const metrics = options.metricsToken ? createMetrics() : undefined;
300
333
  const onTaskEnd = (record) => {
301
334
  metrics?.observeTask(record);
@@ -357,6 +390,7 @@ export async function createA2AApp(options) {
357
390
  agentId,
358
391
  policy: options.policy,
359
392
  onTaskEnd,
393
+ onAudit: audit,
360
394
  log,
361
395
  warn,
362
396
  });
@@ -409,11 +443,60 @@ export async function createA2AApp(options) {
409
443
  res.type('text/plain; version=0.0.4').send(metrics.render(limiter.inFlight));
410
444
  });
411
445
  }
412
- app.get('/readyz', (_req, res) => {
413
- if (limiter.isDraining) {
446
+ // Readiness: not while stopping, and not while durable storage is
447
+ // unreachable (a turn there would fail). The reason is logged once per
448
+ // change of state, never per probe, and the answer names no host.
449
+ let unready = false;
450
+ let storageDown = false;
451
+ // /readyz is unauthenticated, so the database is asked at most once per
452
+ // READY_CACHE_MS however often it is probed; concurrent probes share the
453
+ // one check in flight.
454
+ const READY_CACHE_MS = 2_000;
455
+ let lastCheck;
456
+ let checking;
457
+ const checkStorage = async () => {
458
+ let timer;
459
+ try {
460
+ await Promise.race([
461
+ readSchemaVersion(),
462
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('no answer within 2 s')), 2_000); }),
463
+ ]);
464
+ if (storageDown)
465
+ log('readyz: the database answers again.');
466
+ storageDown = false;
467
+ return true;
468
+ }
469
+ catch (err) {
470
+ if (!storageDown)
471
+ warn(`readyz: the database is unreachable (${err instanceof Error ? err.message : String(err)}); reporting not ready.`);
472
+ storageDown = true;
473
+ return false;
474
+ }
475
+ finally {
476
+ clearTimeout(timer);
477
+ }
478
+ };
479
+ const storageReachable = async () => {
480
+ if (!readSchemaVersion)
481
+ return true;
482
+ if (lastCheck && Date.now() - lastCheck.at < READY_CACHE_MS)
483
+ return lastCheck.ok;
484
+ checking ??= checkStorage().then((ok) => {
485
+ lastCheck = { at: Date.now(), ok };
486
+ checking = undefined;
487
+ return ok;
488
+ });
489
+ return checking;
490
+ };
491
+ app.get('/readyz', async (_req, res) => {
492
+ if (unready || limiter.isDraining) {
414
493
  res.status(503).json({ status: 'draining' });
415
494
  return;
416
495
  }
496
+ if (!(await storageReachable())) {
497
+ res.status(503).json({ status: 'unavailable', reason: 'storage' });
498
+ return;
499
+ }
417
500
  res.json({ status: 'ready', sessions: sessionBackend, inFlight: limiter.inFlight });
418
501
  });
419
502
  // ── 2–3. Authentication ───────────────────────────────────────────────────
@@ -431,6 +514,12 @@ export async function createA2AApp(options) {
431
514
  }));
432
515
  const rejectAuth = (req, res, error) => {
433
516
  warn(`401 ${req.method} ${req.path} from ${req.ip}: ${error}`);
517
+ try {
518
+ audit?.({ event: 'auth.failure', outcome: 'denied', sourceIp: req.ip, detail: { method: req.method, path: req.path, reason: error } });
519
+ }
520
+ catch {
521
+ /* the refusal stands whatever the sink does */
522
+ }
434
523
  res.status(401).json({ error });
435
524
  };
436
525
  if (options.serverSecret) {
@@ -538,6 +627,7 @@ export async function createA2AApp(options) {
538
627
  caller: identity.caller,
539
628
  ownsNested: identity.ownsNested ?? false,
540
629
  operator: identity.operator ?? false,
630
+ sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent),
541
631
  }, () => next());
542
632
  return;
543
633
  }
@@ -562,7 +652,7 @@ export async function createA2AApp(options) {
562
652
  // The caller's key funds its provider, and its hash prefixes the scope
563
653
  // so no key holder can reach another's data.
564
654
  const scopeKey = deriveUserId({ apiKey, siteUserId });
565
- requestContextStorage.run({ apiKey, provider, siteUserId, scopeKey, surface, caller: 'shared-secret', ownsNested: !siteUserId, operator: !!options.serverSecret }, () => next());
655
+ requestContextStorage.run({ apiKey, provider, siteUserId, scopeKey, surface, caller: 'shared-secret', ownsNested: !siteUserId, operator: !!options.serverSecret, sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent) }, () => next());
566
656
  return;
567
657
  }
568
658
  // Server mode: the server's credentials pay; X-API-Key is not used.
@@ -579,6 +669,7 @@ export async function createA2AApp(options) {
579
669
  // Everyone holding the secret is one caller; without a secret (loopback) the caller is local.
580
670
  caller: options.serverSecret ? 'shared-secret' : 'local',
581
671
  operator: !!options.serverSecret,
672
+ sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent),
582
673
  }, () => next());
583
674
  });
584
675
  // ── 6. Task rate limit ────────────────────────────────────────────────────
@@ -634,11 +725,13 @@ export async function createA2AApp(options) {
634
725
  includeNested: !!ctx.ownsNested,
635
726
  });
636
727
  log(`Erasure for scope ${ctx.scopeKey}${all ? ' (all namespaces)' : ''}: ${JSON.stringify(counts)}`);
728
+ audit?.({ event: 'memory.erase', outcome: 'ok', caller: ctx.caller, scopeHash: scopeHashOf(ctx.scopeKey), sourceIp: ctx.sourceIp, detail: { allNamespaces: all, deleted: counts } });
637
729
  res.json({ deleted: counts });
638
730
  }
639
731
  catch (err) {
640
732
  const msg = err instanceof Error ? err.message : String(err);
641
733
  warn(`Erasure failed: ${msg}`);
734
+ audit?.({ event: 'memory.erase', outcome: 'failed', caller: ctx.caller, scopeHash: scopeHashOf(ctx.scopeKey), sourceIp: ctx.sourceIp, detail: { allNamespaces: all } });
642
735
  res.status(503).json({ error: msg });
643
736
  }
644
737
  });
@@ -739,6 +832,9 @@ export async function createA2AApp(options) {
739
832
  app,
740
833
  config,
741
834
  sessionBackend,
835
+ markUnready: () => {
836
+ unready = true;
837
+ },
742
838
  shutdown: async (graceMs) => {
743
839
  const left = await limiter.drain(graceMs);
744
840
  for (const t of leaseTimers)
@@ -18,6 +18,7 @@ import type { TurnLock } from './turnLock.ts';
18
18
  import type { PendingApproval } from '../runtime/approvals.ts';
19
19
  import type { MessagePart, SyndicateTurnResult } from '../runtime/syndicateTurn.ts';
20
20
  import type { TaskRecord } from '../observability/metrics.ts';
21
+ import type { AuditSink } from '../observability/audit.ts';
21
22
  import type { Policy } from './policy.ts';
22
23
  /** Per-request caller context, set by the server's identity middleware. */
23
24
  export interface A2AContext {
@@ -44,6 +45,10 @@ export interface A2AContext {
44
45
  * server secret), as opposed to an end user (a JWT, a gateway header).
45
46
  * Operator-only adopter routes check it. */
46
47
  operator?: boolean;
48
+ /** The request's source address (behind A2A_TRUST_PROXY), for the audit trail only. */
49
+ sourceIp?: string;
50
+ /** The caller's W3C traceparent, when well formed: the turn's root span links to it. */
51
+ traceparent?: string;
47
52
  }
48
53
  export interface SurfaceContext {
49
54
  name: string;
@@ -103,6 +108,8 @@ export interface ExecutorOptions {
103
108
  policy?: Policy;
104
109
  /** One record per task, however it ended: the task log and metrics. */
105
110
  onTaskEnd?: (record: TaskRecord) => void;
111
+ /** Receives one `task.end` audit event per task (ADR 0042). */
112
+ onAudit?: AuditSink;
106
113
  /**
107
114
  * Stream the answer as the model writes it, as `answer` artifact chunks
108
115
  * (see answerStream). Off by default; never for a syndicate with guards.
@@ -115,16 +122,39 @@ export interface ExecutorOptions {
115
122
  log: (message: string) => void;
116
123
  warn: (message: string) => void;
117
124
  }
118
- /** Concurrency cap and in-flight registry shared by all executors. */
125
+ /** Per-key caps beside the global one: 0 or absent means no cap. */
126
+ export interface TaskLimits {
127
+ /** Concurrent tasks for one scope (one end user's data). */
128
+ perScope?: number;
129
+ /** Concurrent tasks for one authenticated caller (a backend, a token). */
130
+ perCaller?: number;
131
+ }
132
+ /** Concurrency caps and in-flight registry shared by all executors. */
119
133
  export declare class TaskLimiter {
120
134
  readonly max: number;
135
+ readonly perScope: number;
136
+ readonly perCaller: number;
121
137
  private readonly running;
138
+ /** taskId → the keys it counts against, so release frees exactly those. */
139
+ private readonly keysOf;
140
+ private readonly counts;
122
141
  private draining;
123
142
  private idleWaiters;
124
- constructor(max: number);
143
+ constructor(max: number, limits?: TaskLimits);
144
+ /**
145
+ * Why a task for this scope and caller may not start now, or undefined.
146
+ * Checked before acquire() so the refusal can name the cap that was hit.
147
+ */
148
+ refusal(who?: {
149
+ scopeKey?: string;
150
+ caller?: string;
151
+ }): string | undefined;
125
152
  get inFlight(): number;
126
- /** Reserve a slot. Returns undefined when saturated or shutting down. */
127
- acquire(taskId: string): AbortController | undefined;
153
+ /** Reserve a slot. Returns undefined when any cap is reached or the server is shutting down. */
154
+ acquire(taskId: string, who?: {
155
+ scopeKey?: string;
156
+ caller?: string;
157
+ }): AbortController | undefined;
128
158
  release(taskId: string): void;
129
159
  cancel(taskId: string): boolean;
130
160
  /** Stop admitting tasks; resolve when all running ones finish or `graceMs` passes,