Storage Backends
The SDK storage layer is pluggable. TaskLifecycleService uses TaskStore, and
PushNotificationService uses PushNotificationStore.
In-memory stores
Use InMemoryStoreFactory for examples, tests, and single-process development:
a2a::server::stores::InMemoryStoreFactory factory;
auto stores = factory.CreateStoreBundle();
In-memory stores do not persist data or coordinate across processes.
PostgreSQL stores
PostgreSQL support is optional. Enable and link the PostgreSQL store target:
cmake -S . -B build-postgres -DA2A_ENABLE_POSTGRES_STORE=ON
cmake --build build-postgres
a2a::server::stores::PostgresStoreFactory factory({
.connection_string = "postgresql://a2a:a2a@127.0.0.1:5432/a2a",
.schema = "public",
.auto_create_schema = true,
.connection_pool_size = 8,
});
auto stores = factory.CreateStoreBundle();
Task and push-notification stores returned by CreateStoreBundle() share one
connection pool. Separately constructed stores own separate pools. Storage
matching uses libpq’s active connection target (selected host, resolved server
address, active port, and database) plus the configured target_session_attrs
value and schema. Exact coordinate equality confirms local authority; a
different database or schema confirms external authority; other endpoint
differences are uncertain. Separately constructed stores can set
PostgresStoreOptions::storage_authority_id: matching non-empty IDs prove local
authority, different non-empty IDs prove external authority, and a one-sided ID
remains uncertain. Database/schema differences still remain external. The ID is
a stable, non-secret deployment identifier and must reflect the real storage
authority because it affects provenance and cleanup. Passwords and raw
connection strings are not part of the identity. The effective role is tracked
as pool-consistency metadata, but matching endpoint/schema/role metadata does
not prove that independent sessions have the same row-security context. The
one-command list shortcut therefore requires the task and push stores to share
the exact connection pool and local storage authority. Separately constructed
stores validate the task through the authoritative task store first, even when
their DSNs and roles look equivalent. Do not leave pooled connections with
out-of-band session state such as SET ROLE or custom policy GUCs.
Task-aware PostgreSQL behavior
For same-storage task and push stores, create/update uses one push-store
acquisition and one PostgreSQL command. That statement calls the
SECURITY DEFINER task-lock helper, holds a shared transaction advisory lock
derived from the schema-qualified task table and complete task ID, validates task
existence, and performs the upsert atomically. Task deletion obtains the matching
exclusive advisory lock in a BEFORE DELETE trigger. It has no separate task
precheck, revalidation, compensating cleanup, or explicit multi-command
transaction. Missing tasks return TaskNotFound, and concurrent deletion cannot
leave a locally owned orphan. The local task-aware path rejects repeatable-read
and serializable transaction isolation before locking because their
transaction-scoped snapshots can remain stale after a concurrent delete commits.
Under PostgreSQL read-committed semantics, the VOLATILE helper rechecks task
existence with a fresh snapshot after a lock wait. Equal task IDs in different
schemas use different advisory keys. Hash collisions can still serialize
unrelated task IDs; data access still uses the original task ID.
PushConfig_CreateMany at fan-out eight therefore performs eight create
commands rather than sixteen.
GetConfig remains push-store-only and uses one PostgreSQL statement. List uses
one combined task/count/page statement when task and push stores share a
connection pool and local storage authority; separate pools, different storage
authority, or an external task store use the authoritative task lookup first
and then one combined push-list statement. External-authority creates are marked
with local_postgres_task=FALSE; direct non-task-aware CreateOrUpdate calls
use the same external provenance. Only locally owned rows are removed by the
task-delete cleanup trigger.
When auto_create_schema=true, the SDK installs the provenance column, shared
push-lock helper, exclusive task-delete lock helper with a BEFORE DELETE
trigger, cleanup helper with AFTER DELETE row and AFTER TRUNCATE statement triggers, sequences, and indexes,
removes the legacy push-to-task foreign key, and writes the
task-aware-push-config-v4 migration markers last.
Externally managed PostgreSQL schemas
When auto_create_schema=false, the push-notification store always validates
the provenance column and absence of the legacy push-to-task foreign key. If the
push-lock helper, task-delete lock helper and trigger, cleanup helper, and
cleanup AFTER DELETE and AFTER TRUNCATE triggers are all absent, construction is allowed for push-only use with
an external authoritative TaskStore; local PostgreSQL task-aware creation then
fails before writing until the migration is installed. Capability detection
happens during store construction rather than on the request path.
If any task-aware helper or trigger is present, the whole
task-aware-push-config-v4 migration is required. Validation checks all helper
implementations and markers, owner privileges, absence of PUBLIC EXECUTE, the
enabled advisory-lock BEFORE DELETE, cleanup AFTER DELETE row, and cleanup AFTER TRUNCATE statement triggers
without WHEN clauses, and the cleanup owner’s ability to bypass any row-level
security enabled on the push-config table. Partial or stale installations fail
construction.
Apply the following migration before using local PostgreSQL task-aware
create/update. The example uses the public schema and an SDK role named
a2a_sdk; replace both names for your deployment.
Grant the push-lock helper only to roles authorized to create push configurations.
The invoking push role needs task-table SELECT plus helper EXECUTE; neither
the caller nor the helper owner needs task-table UPDATE. Push-only roles paired
with an external authoritative TaskStore do not need task-table access or helper
execution. The push store checks the invoking role’s helper EXECUTE privilege
only when CreateOrUpdateForTask selects the local PostgreSQL atomic path.
Task-table row-level security is allowed for those external-authority
paths but is rejected when the local task-aware create helper is invoked. If
push-table row-level security is enabled, the cleanup helper owner must bypass it
via table ownership without FORCE ROW LEVEL SECURITY, or via BYPASSRLS or
superuser status, so task deletion cannot silently retain local push configurations.
BEGIN;
ALTER TABLE public.a2a_push_notification_configs
ADD COLUMN IF NOT EXISTS local_postgres_task BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE public.a2a_push_notification_configs
DROP CONSTRAINT IF EXISTS a2a_push_configs_task_fk;
DROP INDEX IF EXISTS public.idx_a2a_push_configs_task;
DROP INDEX IF EXISTS public.idx_a2a_push_configs_created_sequence;
CREATE OR REPLACE FUNCTION public.a2a_lock_task_for_push_config(requested_task_id TEXT)
RETURNS BOOLEAN
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $a2a$
DECLARE
caller_role NAME;
lock_key BIGINT;
BEGIN
IF pg_catalog.current_setting('transaction_isolation') IN ('repeatable read', 'serializable') THEN
RAISE EXCEPTION USING ERRCODE = '0A000', MESSAGE =
'PostgreSQL task-aware push configuration requires read-committed transaction isolation';
END IF;
lock_key := pg_catalog.hashtextextended(
requested_task_id,
pg_catalog.hashtextextended('public.a2a_tasks', 0));
IF NOT pg_catalog.pg_try_advisory_xact_lock_shared(lock_key) THEN
PERFORM pg_catalog.pg_advisory_xact_lock_shared(lock_key);
END IF;
caller_role := NULLIF(pg_catalog.current_setting('role', true), 'none');
IF caller_role IS NULL THEN
caller_role := session_user;
END IF;
IF EXISTS (
SELECT 1 FROM pg_catalog.pg_class
WHERE oid = pg_catalog.to_regclass('public.a2a_tasks') AND relrowsecurity
) THEN
RAISE EXCEPTION USING ERRCODE = '0A000', MESSAGE =
'PostgreSQL task-aware push configuration does not support row-level security on a2a_tasks';
END IF;
IF NOT pg_catalog.has_table_privilege(caller_role, 'public.a2a_tasks', 'SELECT') THEN
RAISE EXCEPTION USING ERRCODE = '42501', MESSAGE =
'PostgreSQL push store role requires SELECT on a2a_tasks for task-aware creation';
END IF;
PERFORM 1
FROM public.a2a_tasks
WHERE id = requested_task_id;
RETURN FOUND;
END
$a2a$;
REVOKE ALL ON FUNCTION public.a2a_lock_task_for_push_config(TEXT) FROM PUBLIC;
GRANT SELECT ON public.a2a_tasks TO a2a_sdk;
GRANT EXECUTE ON FUNCTION public.a2a_lock_task_for_push_config(TEXT) TO a2a_sdk;
CREATE OR REPLACE FUNCTION public.a2a_lock_task_for_delete()
RETURNS TRIGGER
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $a2a$
BEGIN
PERFORM pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(
OLD.id,
pg_catalog.hashtextextended('public.a2a_tasks', 0)));
RETURN OLD;
END
$a2a$;
REVOKE ALL ON FUNCTION public.a2a_lock_task_for_delete() FROM PUBLIC;
DROP TRIGGER IF EXISTS a2a_lock_task_for_delete_trigger ON public.a2a_tasks;
CREATE TRIGGER a2a_lock_task_for_delete_trigger
BEFORE DELETE ON public.a2a_tasks
FOR EACH ROW
EXECUTE FUNCTION public.a2a_lock_task_for_delete();
CREATE OR REPLACE FUNCTION public.a2a_delete_task_push_configs()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $a2a$
BEGIN
IF TG_OP = 'TRUNCATE' THEN
IF pg_catalog.current_setting('transaction_isolation') IN
('repeatable read', 'serializable') THEN
RAISE EXCEPTION USING
ERRCODE = '0A000',
MESSAGE = 'PostgreSQL task truncation cleanup requires read-committed transaction isolation';
END IF;
DELETE FROM public.a2a_push_notification_configs
WHERE local_postgres_task;
RETURN NULL;
END IF;
DELETE FROM public.a2a_push_notification_configs
WHERE task_id = OLD.id AND local_postgres_task;
RETURN OLD;
END
$a2a$;
REVOKE ALL ON FUNCTION public.a2a_delete_task_push_configs() FROM PUBLIC;
DROP TRIGGER IF EXISTS a2a_delete_task_push_configs_trigger ON public.a2a_tasks;
CREATE TRIGGER a2a_delete_task_push_configs_trigger
AFTER DELETE ON public.a2a_tasks
FOR EACH ROW
EXECUTE FUNCTION public.a2a_delete_task_push_configs();
DROP TRIGGER IF EXISTS a2a_truncate_task_push_configs_trigger ON public.a2a_tasks;
CREATE TRIGGER a2a_truncate_task_push_configs_trigger
AFTER TRUNCATE ON public.a2a_tasks
FOR EACH STATEMENT
EXECUTE FUNCTION public.a2a_delete_task_push_configs();
COMMENT ON FUNCTION public.a2a_lock_task_for_delete()
IS 'task-aware-push-config-v4';
COMMENT ON FUNCTION public.a2a_delete_task_push_configs()
IS 'task-aware-push-config-v4';
COMMENT ON FUNCTION public.a2a_lock_task_for_push_config(TEXT)
IS 'task-aware-push-config-v4';
COMMIT;
The statement-level truncate trigger deletes every locally owned push configuration in the same transaction as a direct TRUNCATE public.a2a_tasks. It rejects repeatable-read and serializable transactions because cleanup under a stale transaction snapshot could miss a concurrently committed local push configuration. Externally owned rows (local_postgres_task=FALSE) remain available because their authoritative task storage is outside this table. TRUNCATE ... CASCADE must include the push-config table only when the operator intentionally wants to remove those externally owned rows too.
The migration markers are written last inside the transaction, so partial
migrations are rejected. Existing rows receive the conservative external or
unknown provenance value from the new column’s FALSE default. The lock-helper
owner needs schema USAGE plus task-table SELECT; the task-delete lock helper
owner needs schema USAGE; and the cleanup helper owner needs schema USAGE plus
push-table SELECT and DELETE. These are SECURITY DEFINER owner requirements,
not privileges for every push-store role.
Sensitive push-notification data
Push configurations can contain callback URLs, tokens, and authentication metadata. The SDK does not implement application-level encryption at rest; use database encryption, least-privilege access, protected backups, and appropriate audit logging.