@consiliency/agent-board-schema 1.2.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 (92) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +55 -0
  4. package/bin/agent-board-schema.js +97 -0
  5. package/fixtures/already-applied.json +181 -0
  6. package/fixtures/divergent-history.json +173 -0
  7. package/fixtures/production-history.json +173 -0
  8. package/migrations/20260501000001_agent_board_schema_version.sql +12 -0
  9. package/migrations/20260502000000_agent_board_schema.sql +180 -0
  10. package/migrations/20260503000000_agent_board_rpcs.sql +422 -0
  11. package/migrations/20260503000001_agent_board_coordination_rpcs.sql +229 -0
  12. package/migrations/20260504000000_agent_board_read_models.sql +206 -0
  13. package/migrations/20260505000000_agent_board_inbox_schema.sql +341 -0
  14. package/migrations/20260506000000_agent_board_inbox_delivery_rpcs.sql +1200 -0
  15. package/migrations/20260506000001_agent_board_inbox_callback_rpcs.sql +330 -0
  16. package/migrations/20260507000000_agent_board_inbox_reads.sql +334 -0
  17. package/migrations/20260508000000_agent_board_callback_runtime.sql +453 -0
  18. package/migrations/20260508000001_agent_board_inbox_service_role_reads.sql +10 -0
  19. package/migrations/20260510000000_agent_board_dblint_repairs.sql +413 -0
  20. package/migrations/20260511000000_agent_board_auth_schema.sql +146 -0
  21. package/migrations/20260511000001_agent_board_auth_credentials.sql +39 -0
  22. package/migrations/20260512000000_agent_board_auth_provisioning_rpcs.sql +622 -0
  23. package/migrations/20260512000001_agent_board_auth_v1_rpcs.sql +798 -0
  24. package/migrations/20260512000002_agent_board_auth_v2_rpcs.sql +1243 -0
  25. package/migrations/20260512000003_agent_board_auth_rls.sql +484 -0
  26. package/migrations/20260513000000_agent_board_backup_ops.sql +34 -0
  27. package/migrations/20260513000001_agent_board_retention.sql +160 -0
  28. package/migrations/20260513000002_agent_board_monitoring_views.sql +227 -0
  29. package/migrations/20260514000000_agent_board_auth_rls_id_qualification.sql +150 -0
  30. package/migrations/20260515000000_agent_board_boardopt.sql +493 -0
  31. package/migrations/20260516000000_agent_board_callbackcap.sql +113 -0
  32. package/migrations/20260517000000_agent_board_addressing.sql +542 -0
  33. package/migrations/20260628000000_agent_board_signing_store.sql +174 -0
  34. package/migrations/20260628000001_agent_board_signing_proof_rpcs.sql +215 -0
  35. package/migrations/20260628000002_agent_board_signature_rls_reads.sql +300 -0
  36. package/migrations/20260628000003_agent_board_signed_write_gateway.sql +288 -0
  37. package/migrations/20260628000004_agent_board_fleet_registry.sql +411 -0
  38. package/migrations/20260628000005_agent_board_fleet_policy.sql +509 -0
  39. package/migrations/20260628000006_agent_board_fleet_read_models.sql +170 -0
  40. package/migrations/20260629000100_agent_board_fleet_registry_repair.sql +411 -0
  41. package/migrations/20260629000101_agent_board_fleet_policy_refresh.sql +509 -0
  42. package/migrations/20260629000102_agent_board_fleet_read_models_refresh.sql +170 -0
  43. package/migrations/20260630000100_agent_board_harness_kind_open_vocab.sql +15 -0
  44. package/migrations/20260710000000_agent_board_authbound_boundary.sql +155 -0
  45. package/migrations/20260710000100_agent_board_schema_version_rpc.sql +14 -0
  46. package/migrations/20260710000400_agent_board_nonfleet_identity.sql +87 -0
  47. package/migrations/20260710000401_agent_board_workload_providers.sql +39 -0
  48. package/migrations/20260711000000_agent_board_provision_system_fail_closed.sql +242 -0
  49. package/migrations/20260711000100_canary_signed_fleet_enforcement.sql +234 -0
  50. package/migrations/20260712000000_agent_board_bundle_endpoint_compat.sql +97 -0
  51. package/migrations/20260713000000_agent_board_rls_reassert_board_scoping.sql +90 -0
  52. package/package.json +42 -0
  53. package/provenance.json +580 -0
  54. package/schema-manifest.json +458 -0
  55. package/src/check.js +23 -0
  56. package/src/index.js +50 -0
  57. package/src/materialize.js +73 -0
  58. package/src/plan.js +44 -0
  59. package/src/tooling.test.mjs +97 -0
  60. package/tests/agent_board_addressing.test.sql +166 -0
  61. package/tests/agent_board_auth_rls.test.sql +243 -0
  62. package/tests/agent_board_auth_rls_id_qualification.test.sql +225 -0
  63. package/tests/agent_board_auth_rpcs.test.sql +311 -0
  64. package/tests/agent_board_auth_schema.test.sql +411 -0
  65. package/tests/agent_board_authbound_boundary.test.sql +148 -0
  66. package/tests/agent_board_boardopt.test.sql +170 -0
  67. package/tests/agent_board_bundle_endpoint_compat.test.sql +49 -0
  68. package/tests/agent_board_callback_runtime.test.sql +425 -0
  69. package/tests/agent_board_callbackcap.test.sql +222 -0
  70. package/tests/agent_board_canary_signed_fleet_enforcement.test.sql +60 -0
  71. package/tests/agent_board_dblint_rpcs.test.sql +247 -0
  72. package/tests/agent_board_fleet_policy.test.sql +370 -0
  73. package/tests/agent_board_fleet_read_models.test.sql +158 -0
  74. package/tests/agent_board_fleet_registry.test.sql +149 -0
  75. package/tests/agent_board_inbox_reads.test.sql +471 -0
  76. package/tests/agent_board_inbox_rpcs.test.sql +452 -0
  77. package/tests/agent_board_inbox_schema.test.sql +355 -0
  78. package/tests/agent_board_nonfleet_identity.test.sql +26 -0
  79. package/tests/agent_board_rls_reassert_board_scoping.test.sql +39 -0
  80. package/tests/agent_board_schema_version.test.sql +24 -0
  81. package/tests/agent_board_schema_version_rpc.test.sql +40 -0
  82. package/tests/agent_board_signature_rls_reads.test.sql +133 -0
  83. package/tests/agent_board_signed_write_gateway.test.sql +219 -0
  84. package/tests/agent_board_signing_proof_rpcs.test.sql +143 -0
  85. package/tests/agent_board_signing_store.test.sql +183 -0
  86. package/tests/agent_board_smoke.test.sql +12 -0
  87. package/tests/agent_board_v1_invariants.test.sql +298 -0
  88. package/tests/agent_board_v2_invariants.test.sql +753 -0
  89. package/tests/agent_board_workload_providers.test.sql +15 -0
  90. package/tests/fixtures/adopter-base.sql +19 -0
  91. package/tests/fixtures/operator-hardening.sql +27 -0
  92. package/tests/fixtures/test-prereqs.sql +84 -0
@@ -0,0 +1,155 @@
1
+ create or replace function agent_board.current_agent_id()
2
+ returns uuid
3
+ language sql
4
+ stable
5
+ security definer
6
+ set search_path = ''
7
+ as $$
8
+ with credential_claim as (
9
+ select nullif(current_setting('request.jwt.claim.agent_credential_id', true), '') as credential_id_text
10
+ )
11
+ select a.id
12
+ from agent_board.agents a
13
+ cross join credential_claim cc
14
+ where a.auth_user_id = auth.uid()
15
+ and a.status = 'active'
16
+ and (
17
+ cc.credential_id_text is null
18
+ or (
19
+ cc.credential_id_text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
20
+ and exists (
21
+ select 1
22
+ from agent_board.agent_credentials ac
23
+ where ac.id = cc.credential_id_text::uuid
24
+ and ac.agent_id = a.id
25
+ and ac.revoked_at is null
26
+ and (ac.expires_at is null or ac.expires_at > now())
27
+ )
28
+ )
29
+ )
30
+ limit 1;
31
+ $$;
32
+
33
+ comment on function agent_board.current_agent_id() is 'Resolves auth.uid() to the active agent_board.agents.id and, when present, requires the JWT agent_credential_id claim to reference a non-revoked, non-expired credential for that agent.';
34
+
35
+ grant usage on schema agent_board to service_role;
36
+ grant select on agent_board.agents to service_role;
37
+ grant select, update(last_used_at, updated_at) on agent_board.agent_credentials to service_role;
38
+
39
+ create or replace function agent_board._signed_write_actor_id(
40
+ p_payload jsonb,
41
+ p_gateway_decision jsonb,
42
+ p_actor_field text
43
+ )
44
+ returns uuid
45
+ language plpgsql
46
+ security definer
47
+ set search_path = agent_board, public
48
+ as $$
49
+ declare
50
+ v_actor uuid;
51
+ v_payload_actor uuid;
52
+ begin
53
+ v_actor := nullif(p_gateway_decision ->> 'agent_id', '')::uuid;
54
+ if v_actor is null then
55
+ raise exception 'actor_mismatch: signed write gateway actor is required' using errcode = '42501';
56
+ end if;
57
+
58
+ v_payload_actor := nullif(p_payload ->> p_actor_field, '')::uuid;
59
+ if v_payload_actor is not null and v_payload_actor <> v_actor then
60
+ raise exception 'actor_mismatch: signed write payload actor must match gateway actor' using errcode = '42501';
61
+ end if;
62
+
63
+ return v_actor;
64
+ end;
65
+ $$;
66
+
67
+ create or replace function agent_board._record_signed_write_evidence(
68
+ p_source_kind text,
69
+ p_source_id uuid,
70
+ p_operation text,
71
+ p_payload jsonb,
72
+ p_gateway_decision jsonb
73
+ )
74
+ returns uuid
75
+ language plpgsql
76
+ security definer
77
+ set search_path = agent_board, public
78
+ as $$
79
+ declare
80
+ v_evidence_id uuid;
81
+ v_agent_id uuid := nullif(p_gateway_decision ->> 'agent_id', '')::uuid;
82
+ v_status text := coalesce(nullif(p_gateway_decision ->> 'verification_status', ''), 'unsigned');
83
+ begin
84
+ if v_agent_id is null then
85
+ raise exception 'actor_mismatch: signed write evidence requires gateway actor' using errcode = '42501';
86
+ end if;
87
+
88
+ if v_status not in ('unsigned', 'observed', 'warn', 'verified', 'failed') then
89
+ v_status := 'failed';
90
+ end if;
91
+
92
+ insert into agent_board.signature_evidence(
93
+ agent_id,
94
+ signing_key_id,
95
+ key_id,
96
+ key_version,
97
+ algorithm,
98
+ source_kind,
99
+ source_message_id,
100
+ source_task_event_id,
101
+ operation,
102
+ payload_digest,
103
+ canonicalization_version,
104
+ signature_digest,
105
+ verification_status,
106
+ failure_class,
107
+ signed_at,
108
+ verified_at,
109
+ nonce,
110
+ idempotency_key,
111
+ external_evidence_ref,
112
+ metadata
113
+ )
114
+ values (
115
+ v_agent_id,
116
+ nullif(p_gateway_decision ->> 'signing_key_id', '')::uuid,
117
+ nullif(p_gateway_decision ->> 'key_id', ''),
118
+ nullif(p_gateway_decision ->> 'key_version', '')::integer,
119
+ coalesce(nullif(p_gateway_decision ->> 'algorithm', ''), 'Ed25519'),
120
+ p_source_kind,
121
+ case when p_source_kind = 'message' then p_source_id else null end,
122
+ case when p_source_kind = 'task_event' then p_source_id else null end,
123
+ p_operation,
124
+ coalesce(nullif(p_gateway_decision ->> 'payload_digest', ''), 'sha256:unsigned'),
125
+ coalesce(nullif(p_gateway_decision ->> 'canonicalization_version', ''), 'agent-provenance-jcs.v1'),
126
+ nullif(p_gateway_decision ->> 'signature_digest', ''),
127
+ v_status,
128
+ nullif(p_gateway_decision ->> 'failure_class', ''),
129
+ nullif(p_gateway_decision ->> 'signed_at', '')::timestamptz,
130
+ nullif(p_gateway_decision ->> 'verified_at', '')::timestamptz,
131
+ nullif(p_gateway_decision ->> 'nonce', ''),
132
+ nullif(p_gateway_decision ->> 'idempotency_key', ''),
133
+ nullif(p_gateway_decision ->> 'external_evidence_ref', ''),
134
+ jsonb_build_object(
135
+ 'gateway', 'signed-write',
136
+ 'authbound', true,
137
+ 'enforcementMode', coalesce(p_gateway_decision ->> 'enforcement_mode', 'observe'),
138
+ 'redacted', true,
139
+ 'metadata-safe', true
140
+ )
141
+ )
142
+ returning id into v_evidence_id;
143
+
144
+ update agent_board.signature_replay_state
145
+ set signature_evidence_id = v_evidence_id
146
+ where agent_id = v_agent_id
147
+ and operation = p_operation
148
+ and (
149
+ (nonce is not null and nonce = nullif(p_gateway_decision ->> 'nonce', ''))
150
+ or (idempotency_key is not null and idempotency_key = nullif(p_gateway_decision ->> 'idempotency_key', ''))
151
+ );
152
+
153
+ return v_evidence_id;
154
+ end;
155
+ $$;
@@ -0,0 +1,14 @@
1
+ create or replace function agent_board.get_schema_version()
2
+ returns table(version integer)
3
+ language sql
4
+ stable
5
+ security definer
6
+ set search_path = agent_board, pg_temp
7
+ as $$
8
+ select schema_version.version
9
+ from agent_board.schema_version
10
+ limit 1;
11
+ $$;
12
+
13
+ revoke all on function agent_board.get_schema_version() from public;
14
+ grant execute on function agent_board.get_schema_version() to authenticated, service_role;
@@ -0,0 +1,87 @@
1
+ -- Non-fleet identity tables for delegation, workload exchange, and invitation APIs.
2
+ -- Forward-only: do not apply linked or production migrations in NONFLEET phase.
3
+
4
+ create table if not exists agent_board.nonfleet_sessions (
5
+ id text primary key,
6
+ session_id text not null,
7
+ identity_class text not null check (identity_class in ('ephemeral_child', 'hosted_workload', 'remote_sandbox', 'invited_external')),
8
+ agent_id uuid references agent_board.agents(id),
9
+ parent_agent_id uuid references agent_board.agents(id),
10
+ parent_credential_id uuid references agent_board.agent_credentials(id),
11
+ issuer text,
12
+ audience text,
13
+ repository text,
14
+ workflow_or_service text,
15
+ trust_domain text,
16
+ invitation_id text,
17
+ board_scope text,
18
+ repo_scope text,
19
+ task_scope text,
20
+ supervisor_allowed boolean not null default false,
21
+ elevated_admin_allowed boolean not null default false,
22
+ max_ttl_seconds integer not null default 3600,
23
+ issued_at timestamptz not null default now(),
24
+ expires_at timestamptz not null,
25
+ revoked_at timestamptz,
26
+ created_at timestamptz not null default now()
27
+ );
28
+
29
+ alter table agent_board.nonfleet_sessions
30
+ add column if not exists session_id text,
31
+ add column if not exists parent_credential_id uuid references agent_board.agent_credentials(id),
32
+ add column if not exists invitation_id text,
33
+ add column if not exists supervisor_allowed boolean not null default false,
34
+ add column if not exists elevated_admin_allowed boolean not null default false;
35
+
36
+ alter table agent_board.nonfleet_sessions
37
+ alter column parent_credential_id type uuid using nullif(parent_credential_id::text, '')::uuid;
38
+
39
+ create unique index if not exists idx_nonfleet_sessions_session_id on agent_board.nonfleet_sessions(session_id);
40
+ create index if not exists idx_nonfleet_sessions_identity_class on agent_board.nonfleet_sessions(identity_class);
41
+ create index if not exists idx_nonfleet_sessions_expires_at on agent_board.nonfleet_sessions(expires_at) where revoked_at is null;
42
+
43
+ alter table agent_board.nonfleet_sessions enable row level security;
44
+ drop policy if exists nonfleet_sessions_deny_anon on agent_board.nonfleet_sessions;
45
+ drop policy if exists nonfleet_sessions_authenticated_read on agent_board.nonfleet_sessions;
46
+ create policy nonfleet_sessions_deny_anon on agent_board.nonfleet_sessions for all to anon using (false);
47
+ create policy nonfleet_sessions_authenticated_read on agent_board.nonfleet_sessions for select to authenticated
48
+ using (agent_id = agent_board.current_agent_id() or parent_agent_id = agent_board.current_agent_id());
49
+
50
+ create table if not exists agent_board.nonfleet_invitations (
51
+ id text primary key,
52
+ trust_domain text not null,
53
+ invited_by_agent_id uuid not null references agent_board.agents(id),
54
+ accepting_agent_id uuid references agent_board.agents(id),
55
+ revoked_by_agent_id uuid references agent_board.agents(id),
56
+ board_scope text,
57
+ receive_policy text,
58
+ signing_posture text not null default 'require_for_external',
59
+ lifecycle_state text not null default 'pending' check (lifecycle_state in ('pending', 'accepted', 'expired', 'revoked', 'rejected')),
60
+ accepted_at timestamptz,
61
+ expires_at timestamptz not null,
62
+ revoked_at timestamptz,
63
+ created_at timestamptz not null default now()
64
+ );
65
+
66
+ alter table agent_board.nonfleet_invitations
67
+ add column if not exists invited_by_agent_id uuid references agent_board.agents(id),
68
+ add column if not exists revoked_by_agent_id uuid references agent_board.agents(id),
69
+ add column if not exists accepted_at timestamptz;
70
+
71
+ alter table agent_board.nonfleet_invitations drop column if exists invited_by;
72
+
73
+ create index if not exists idx_nonfleet_invitations_lifecycle on agent_board.nonfleet_invitations(lifecycle_state) where lifecycle_state = 'pending';
74
+
75
+ alter table agent_board.nonfleet_invitations enable row level security;
76
+ drop policy if exists nonfleet_invitations_deny_anon on agent_board.nonfleet_invitations;
77
+ drop policy if exists nonfleet_invitations_authenticated_read on agent_board.nonfleet_invitations;
78
+ create policy nonfleet_invitations_deny_anon on agent_board.nonfleet_invitations for all to anon using (false);
79
+ create policy nonfleet_invitations_authenticated_read on agent_board.nonfleet_invitations for select to authenticated
80
+ using (invited_by_agent_id = agent_board.current_agent_id() or accepting_agent_id = agent_board.current_agent_id());
81
+
82
+ revoke all on agent_board.nonfleet_sessions, agent_board.nonfleet_invitations from public, anon, authenticated;
83
+ grant select on agent_board.nonfleet_sessions, agent_board.nonfleet_invitations to authenticated;
84
+ grant select, insert, update on agent_board.nonfleet_sessions, agent_board.nonfleet_invitations to service_role;
85
+
86
+ comment on table agent_board.nonfleet_sessions is 'Short-lived sessions for non-fleet identity classes: ephemeral children, hosted workloads, remote sandboxes, and invited external agents.';
87
+ comment on table agent_board.nonfleet_invitations is 'Trust-domain invitations for explicitly invited third-party agents.';
@@ -0,0 +1,39 @@
1
+ -- Workload provider registry for non-fleet identity exchange.
2
+ -- Forward-only: do not apply linked or production migrations in NONFLEET phase.
3
+
4
+ create table if not exists agent_board.workload_providers (
5
+ id text primary key default gen_random_uuid()::text,
6
+ issuer text not null,
7
+ audience text not null,
8
+ repository_claim text not null default 'repository',
9
+ max_session_ttl_seconds integer not null default 3600,
10
+ callback_support boolean not null default false,
11
+ pull_support boolean not null default true,
12
+ supported boolean not null default true,
13
+ policy_approved boolean not null default false,
14
+ allowed_repositories text[] not null default '{}',
15
+ allowed_workflows text[] not null default '{}',
16
+ allowed_board_scopes text[] not null default '{}',
17
+ created_at timestamptz not null default now(),
18
+ unique (issuer, audience)
19
+ );
20
+
21
+ alter table agent_board.workload_providers
22
+ add column if not exists policy_approved boolean not null default false,
23
+ add column if not exists allowed_repositories text[] not null default '{}',
24
+ add column if not exists allowed_workflows text[] not null default '{}',
25
+ add column if not exists allowed_board_scopes text[] not null default '{}';
26
+
27
+ insert into agent_board.workload_providers (issuer, audience, repository_claim, max_session_ttl_seconds, callback_support, pull_support, supported)
28
+ values
29
+ ('https://token.actions.githubusercontent.com', 'message-board', 'repository', 3600, true, true, true),
30
+ ('https://accounts.google.com', 'message-board', 'project_id', 3600, false, true, true),
31
+ ('https://sts.windows.net', 'message-board', 'unknown', 0, false, false, false),
32
+ ('https://login.microsoftonline.com', 'message-board', 'unknown', 0, false, false, false)
33
+ on conflict (issuer, audience) do nothing;
34
+
35
+ comment on table agent_board.workload_providers is 'Registry of supported and explicitly unsupported workload identity providers for non-fleet exchange.';
36
+
37
+ revoke all on agent_board.workload_providers from public, anon, authenticated;
38
+ grant select on agent_board.workload_providers to authenticated;
39
+ grant select, insert, update on agent_board.workload_providers to service_role;
@@ -0,0 +1,242 @@
1
+ -- portal#203 — provision_agent must fail closed for system principals.
2
+ --
3
+ -- `agent_board.provision_agent` previously minted an auth user and issued a
4
+ -- refresh credential for `p_kind = 'system'`, but `trg_sync_agent_principal_columns`
5
+ -- (20260511000000) clears `auth_user_id` for system principals and the
6
+ -- `agents_system_owner_shape` CHECK enforces it. The credential therefore can
7
+ -- never bind an auth session, so `/api/agent-board/auth/exchange` returns
8
+ -- `502 refresh_failed` for a token the RPC reported as valid.
9
+ --
10
+ -- Contract (issue option 1): reject credential issuance for system principals
11
+ -- BEFORE minting anything. The raise sits ahead of `_ensure_agent_auth_user`
12
+ -- and every insert, so no orphan `auth.users` row, agent row, or credential is
13
+ -- created. System principals continue to exist as records created outside this
14
+ -- credential-minting RPC (direct insert / migration); they simply cannot be
15
+ -- issued exchangeable credentials.
16
+ --
17
+ -- Signature is byte-identical to the original definition so this replaces the
18
+ -- function in place (grants survive CREATE OR REPLACE; no re-grant needed).
19
+ --
20
+ -- Defense in depth: the same un-exchangeable-credential class is also reachable
21
+ -- through `rotate_agent_token` (and any future issuer), which likewise calls
22
+ -- `_issue_agent_credential`. Rather than guard each entry point, reject system
23
+ -- principals centrally in `_issue_agent_credential` — the single choke point
24
+ -- every issuer funnels through — while keeping the earlier, clearer rejection in
25
+ -- `provision_agent` (which also avoids creating an auth.users row up front).
26
+
27
+ create or replace function agent_board._issue_agent_credential(
28
+ p_agent_id uuid,
29
+ p_credential_label text,
30
+ p_replaces_credential_id uuid default null,
31
+ p_metadata jsonb default '{}'::jsonb
32
+ )
33
+ returns table(
34
+ credential_id uuid,
35
+ refresh_token text,
36
+ expires_at timestamptz,
37
+ created_at timestamptz,
38
+ replaces_credential_id uuid
39
+ )
40
+ language plpgsql
41
+ security definer
42
+ set search_path = ''
43
+ as $$
44
+ declare
45
+ v_refresh_token text;
46
+ v_expires_at timestamptz := now() + interval '30 days';
47
+ v_created_at timestamptz := now();
48
+ v_credential_id uuid;
49
+ begin
50
+ -- Fail closed for system principals. They have no auth binding (the sync
51
+ -- trigger clears auth_user_id and agents_system_owner_shape enforces it), so
52
+ -- any credential minted here is deterministically un-exchangeable. Rejecting
53
+ -- centrally covers provision_agent, rotate_agent_token, and any future issuer.
54
+ if exists (
55
+ select 1
56
+ from agent_board.agents a
57
+ where a.id = p_agent_id
58
+ and a.kind = 'system'::agent_board.agent_kind
59
+ ) then
60
+ raise exception 'cannot issue an exchangeable credential for a system principal (no auth binding)'
61
+ using errcode = '22023';
62
+ end if;
63
+
64
+ v_refresh_token := agent_board._mint_agent_refresh_token();
65
+
66
+ insert into agent_board.agent_credentials(
67
+ agent_id,
68
+ refresh_token_hash,
69
+ hash_version,
70
+ credential_label,
71
+ expires_at,
72
+ replaces_credential_id,
73
+ metadata,
74
+ created_at,
75
+ updated_at
76
+ )
77
+ values (
78
+ p_agent_id,
79
+ extensions.digest(v_refresh_token, 'sha256'),
80
+ 1,
81
+ p_credential_label,
82
+ v_expires_at,
83
+ p_replaces_credential_id,
84
+ coalesce(p_metadata, '{}'::jsonb),
85
+ v_created_at,
86
+ v_created_at
87
+ )
88
+ returning id into v_credential_id;
89
+
90
+ if p_replaces_credential_id is not null then
91
+ update agent_board.agent_credentials
92
+ set revoked_at = coalesce(revoked_at, v_created_at),
93
+ rotated_at = coalesce(rotated_at, v_created_at),
94
+ replaced_by_credential_id = v_credential_id,
95
+ updated_at = v_created_at
96
+ where id = p_replaces_credential_id;
97
+ end if;
98
+
99
+ return query
100
+ select v_credential_id, v_refresh_token, v_expires_at, v_created_at, p_replaces_credential_id;
101
+ end;
102
+ $$;
103
+
104
+ create or replace function agent_board.provision_agent(
105
+ p_slug text,
106
+ p_kind text,
107
+ p_scopes jsonb default '[]'::jsonb,
108
+ p_owner_user_id uuid default null
109
+ )
110
+ returns table(
111
+ agent_id uuid,
112
+ auth_user_id uuid,
113
+ credential_id uuid,
114
+ refresh_token text,
115
+ expires_at timestamptz,
116
+ created_at timestamptz
117
+ )
118
+ language plpgsql
119
+ security definer
120
+ set search_path = agent_board, public
121
+ as $$
122
+ declare
123
+ v_slug text;
124
+ v_kind text;
125
+ v_owner_user_id uuid;
126
+ v_admin_user_id uuid;
127
+ v_auth_user_id uuid;
128
+ v_agent agent_board.agents%rowtype;
129
+ v_credential record;
130
+ begin
131
+ v_admin_user_id := agent_board._require_platform_admin_user_id();
132
+ v_slug := agent_board._normalize_provision_slug(p_slug);
133
+ v_kind := lower(coalesce(trim(p_kind), ''));
134
+ v_owner_user_id := p_owner_user_id;
135
+
136
+ if v_kind not in ('human', 'agent', 'system') then
137
+ raise exception 'kind must be one of human, agent, or system' using errcode = '22023';
138
+ end if;
139
+
140
+ -- Fail closed: system principals have no auth binding (the sync trigger and
141
+ -- agents_system_owner_shape CHECK force auth_user_id to NULL), so any
142
+ -- credential minted here is deterministically un-exchangeable. Reject before
143
+ -- minting rather than returning an unusable-but-valid-looking token.
144
+ if v_kind = 'system' then
145
+ raise exception 'system principals cannot be issued exchangeable credentials; provision_agent does not support kind=system'
146
+ using errcode = '22023';
147
+ elsif v_owner_user_id is null then
148
+ raise exception 'owner_user_id is required for human and agent principals' using errcode = '22023';
149
+ elsif not exists (select 1 from auth.users where id = v_owner_user_id) then
150
+ raise exception 'owner user not found' using errcode = 'P0002';
151
+ end if;
152
+
153
+ select *
154
+ into v_agent
155
+ from agent_board.agents
156
+ where handle = v_slug
157
+ limit 1
158
+ for update;
159
+
160
+ v_auth_user_id := coalesce(v_agent.auth_user_id, agent_board._ensure_agent_auth_user(v_slug));
161
+
162
+ if v_agent.id is null then
163
+ insert into agent_board.agents(
164
+ agent_type,
165
+ kind,
166
+ handle,
167
+ display_name,
168
+ status,
169
+ auth_user_id,
170
+ owner_user_id,
171
+ metadata
172
+ )
173
+ values (
174
+ v_kind,
175
+ v_kind::agent_board.agent_kind,
176
+ v_slug,
177
+ agent_board._default_provision_display_name(v_slug),
178
+ 'active',
179
+ v_auth_user_id,
180
+ v_owner_user_id,
181
+ jsonb_build_object(
182
+ 'scopes', coalesce(p_scopes, '[]'::jsonb),
183
+ 'provisionedByUserId', v_admin_user_id,
184
+ 'provisionedAt', now()
185
+ )
186
+ )
187
+ returning * into v_agent;
188
+ else
189
+ update agent_board.agents a
190
+ set agent_type = v_kind,
191
+ kind = v_kind::agent_board.agent_kind,
192
+ status = 'active',
193
+ auth_user_id = v_auth_user_id,
194
+ owner_user_id = v_owner_user_id,
195
+ metadata = coalesce(a.metadata, '{}'::jsonb)
196
+ || jsonb_build_object(
197
+ 'scopes', coalesce(p_scopes, '[]'::jsonb),
198
+ 'provisionedByUserId', v_admin_user_id,
199
+ 'provisionedAt', now()
200
+ ),
201
+ updated_at = now()
202
+ where a.id = v_agent.id
203
+ returning * into v_agent;
204
+ end if;
205
+
206
+ select *
207
+ into v_credential
208
+ from agent_board._issue_agent_credential(
209
+ v_agent.id,
210
+ 'provisioned',
211
+ (
212
+ select ac.id
213
+ from agent_board.agent_credentials ac
214
+ where ac.agent_id = v_agent.id
215
+ and ac.revoked_at is null
216
+ order by ac.created_at desc
217
+ limit 1
218
+ ),
219
+ jsonb_build_object('issuedByUserId', v_admin_user_id, 'issuedAt', now(), 'scopes', coalesce(p_scopes, '[]'::jsonb))
220
+ );
221
+
222
+ return query
223
+ select v_agent.id, v_auth_user_id, v_credential.credential_id, v_credential.refresh_token, v_credential.expires_at, v_credential.created_at;
224
+ end;
225
+ $$;
226
+
227
+ -- Data repair (idempotent, scoped): revoke any still-live credentials previously
228
+ -- minted for system principals. They can never bind a session, so keeping them
229
+ -- unrevoked only exposes un-exchangeable tokens. Touches only `revoked_at is null`
230
+ -- credentials of `kind='system'` agents; a zero-row result is expected and fine.
231
+ update agent_board.agent_credentials ac
232
+ set revoked_at = now(),
233
+ updated_at = now(),
234
+ metadata = coalesce(ac.metadata, '{}'::jsonb)
235
+ || jsonb_build_object(
236
+ 'revokedReason', 'system_principal_fail_closed',
237
+ 'revokedAt', now()
238
+ )
239
+ from agent_board.agents a
240
+ where a.id = ac.agent_id
241
+ and a.kind = 'system'::agent_board.agent_kind
242
+ and ac.revoked_at is null;