@opengeni/db 4.4.0-canary.0 → 4.4.0-canary.2

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 (105) hide show
  1. package/dist/canonical-human-identities.js +4 -3
  2. package/dist/{chunk-2ZMTQVTR.js → chunk-2KOLL33E.js} +2 -2
  3. package/dist/chunk-45CP2YKY.js +1062 -0
  4. package/dist/chunk-45CP2YKY.js.map +1 -0
  5. package/dist/{chunk-6TKL3GQO.js → chunk-5UUWZDAV.js} +6531 -6480
  6. package/dist/chunk-5UUWZDAV.js.map +1 -0
  7. package/dist/{chunk-QLA3UQNX.js → chunk-6IJD3XXF.js} +17 -3
  8. package/dist/chunk-6IJD3XXF.js.map +1 -0
  9. package/dist/{chunk-232VC2LW.js → chunk-6V3HDEMR.js} +2 -2
  10. package/dist/{chunk-M7PSMVFF.js → chunk-CHBLBEU6.js} +2 -2
  11. package/dist/chunk-M4PKBJRS.js +20318 -0
  12. package/dist/chunk-M4PKBJRS.js.map +1 -0
  13. package/dist/chunk-S2TZ3F4L.js +237 -0
  14. package/dist/chunk-S2TZ3F4L.js.map +1 -0
  15. package/dist/{chunk-UK57QA2H.js → chunk-UOXWVP3T.js} +2 -2
  16. package/dist/{chunk-GVZJ7XSR.js → chunk-WFU6PS54.js} +11 -4
  17. package/dist/chunk-WFU6PS54.js.map +1 -0
  18. package/dist/{chunk-THJGMEK5.js → chunk-XJP7KJ6U.js} +121 -66
  19. package/dist/chunk-XJP7KJ6U.js.map +1 -0
  20. package/dist/connection-token-resolver.d.ts +2 -0
  21. package/dist/database.d.ts +2 -0
  22. package/dist/editable-artifact-durable-export.js +3 -2
  23. package/dist/editable-artifact-durable-export.js.map +1 -1
  24. package/dist/editable-artifacts.js +4 -3
  25. package/dist/external-identities.d.ts +1 -0
  26. package/dist/external-membership-operations.d.ts +44 -0
  27. package/dist/governed-learning-evaluator.d.ts +1 -14
  28. package/dist/host-mcp-bindings.d.ts +4 -0
  29. package/dist/host-mcp-resolvers.d.ts +47 -0
  30. package/dist/host-mcp-task-authority.d.ts +2 -6
  31. package/dist/index.d.ts +29 -31
  32. package/dist/index.js +8616 -8321
  33. package/dist/index.js.map +1 -1
  34. package/dist/knowledge-document-preparation.d.ts +56 -0
  35. package/dist/knowledge-entries-schema.d.ts +2479 -0
  36. package/dist/knowledge-entries.d.ts +421 -0
  37. package/dist/knowledge-indexing.d.ts +99 -0
  38. package/dist/knowledge-migration.d.ts +6 -0
  39. package/dist/managed-auth-session-sets.js +4 -3
  40. package/dist/mcp-operations.js +3 -2
  41. package/dist/mcp-operations.js.map +1 -1
  42. package/dist/migrate.js +2 -1
  43. package/dist/provision-roles.js +1 -1
  44. package/dist/retained-provider-commands.js +3 -2
  45. package/dist/retained-provider-commands.js.map +1 -1
  46. package/dist/runtime-posture.d.ts +7 -7
  47. package/dist/schema.d.ts +115 -12
  48. package/dist/schema.js +26 -1
  49. package/dist/session-background-commands.js +5 -4
  50. package/dist/session-command-output.js +8 -6
  51. package/dist/session-command-output.js.map +1 -1
  52. package/dist/session-event-slices.js +6 -4
  53. package/dist/session-event-slices.js.map +1 -1
  54. package/dist/session-mcp-credential-rotation.js +5 -4
  55. package/dist/session-mcp-credential-rotation.js.map +1 -1
  56. package/dist/session-tenancy.js +4 -3
  57. package/dist/video-generation.js +4 -3
  58. package/dist/workspace-artifacts.d.ts +1 -0
  59. package/dist/workspace-instruction-policies-schema.d.ts +21 -2
  60. package/dist/workspace-instruction-policies.d.ts +1 -1
  61. package/dist/workspace-membership-access.d.ts +11 -0
  62. package/dist/workspace-tool-defaults.js +5 -4
  63. package/dist/workspace-tool-defaults.js.map +1 -1
  64. package/drizzle/0461_unified_knowledge.sql +2859 -0
  65. package/drizzle/0462_agent_instruction_non_destructive_edits.sql +212 -0
  66. package/drizzle/0463_host_mcp_resolver_registration.sql +111 -0
  67. package/drizzle/0464_external_workspace_membership_operations.sql +166 -0
  68. package/package.json +6 -6
  69. package/src/connection-token-resolver.ts +75 -6
  70. package/src/database.ts +12 -1
  71. package/src/external-identities.ts +1 -0
  72. package/src/external-membership-operations.ts +134 -0
  73. package/src/governed-learning-evaluator.ts +0 -69
  74. package/src/host-mcp-binding-match.ts +1 -0
  75. package/src/host-mcp-bindings.ts +24 -1
  76. package/src/host-mcp-resolvers.ts +266 -0
  77. package/src/host-mcp-task-authority.ts +11 -10
  78. package/src/index.ts +307 -128
  79. package/src/knowledge-document-preparation.ts +90 -0
  80. package/src/knowledge-entries-schema.ts +264 -0
  81. package/src/knowledge-entries.ts +860 -0
  82. package/src/knowledge-indexing.ts +126 -0
  83. package/src/knowledge-migration.ts +761 -0
  84. package/src/knowledge-source-sync.ts +46 -7
  85. package/src/migrate.ts +19 -0
  86. package/src/provision-roles.ts +23 -57
  87. package/src/runtime-posture.ts +103 -7
  88. package/src/schema.ts +15 -3
  89. package/src/skills.ts +4 -0
  90. package/src/video-generation.ts +14 -0
  91. package/src/workspace-artifacts.ts +2 -0
  92. package/src/workspace-instruction-policies-schema.ts +1 -0
  93. package/src/workspace-membership-access.ts +61 -0
  94. package/dist/chunk-6TKL3GQO.js.map +0 -1
  95. package/dist/chunk-EYF6MH6C.js +0 -4772
  96. package/dist/chunk-EYF6MH6C.js.map +0 -1
  97. package/dist/chunk-GSBONN55.js +0 -533
  98. package/dist/chunk-GSBONN55.js.map +0 -1
  99. package/dist/chunk-GVZJ7XSR.js.map +0 -1
  100. package/dist/chunk-QLA3UQNX.js.map +0 -1
  101. package/dist/chunk-THJGMEK5.js.map +0 -1
  102. /package/dist/{chunk-2ZMTQVTR.js.map → chunk-2KOLL33E.js.map} +0 -0
  103. /package/dist/{chunk-232VC2LW.js.map → chunk-6V3HDEMR.js.map} +0 -0
  104. /package/dist/{chunk-M7PSMVFF.js.map → chunk-CHBLBEU6.js.map} +0 -0
  105. /package/dist/{chunk-UK57QA2H.js.map → chunk-UOXWVP3T.js.map} +0 -0
@@ -0,0 +1,2859 @@
1
+ -- deployment-mode: maintenance
2
+ -- Unified retained Knowledge. Old binaries must stay stopped after activation.
3
+ SET LOCAL lock_timeout = '5s';
4
+ SET LOCAL statement_timeout = '10min';
5
+
6
+ DO $drain$
7
+ DECLARE roles jsonb := nullif(current_setting('opengeni.migration_application_roles', true), '')::jsonb;
8
+ BEGIN
9
+ IF to_regclass('pg_temp.knowledge_conversion_0461') IS NULL THEN
10
+ RAISE EXCEPTION '0461 requires the codec-aware TypeScript migration runner' USING ERRCODE='55000';
11
+ END IF;
12
+ IF roles IS NULL OR jsonb_typeof(roles) <> 'array' THEN
13
+ RAISE EXCEPTION '0461 requires explicit application database roles' USING ERRCODE='55000';
14
+ END IF;
15
+ IF jsonb_array_length(roles) NOT BETWEEN 1 AND 16 OR EXISTS (
16
+ SELECT 1 FROM jsonb_array_elements(roles) item WHERE jsonb_typeof(item) <> 'string'
17
+ OR octet_length(item #>> '{}') NOT BETWEEN 1 AND 63
18
+ OR item #>> '{}' <> btrim(item #>> '{}')
19
+ ) THEN RAISE EXCEPTION '0461 received invalid application roles' USING ERRCODE='55000'; END IF;
20
+ IF EXISTS (SELECT 1 FROM pg_stat_activity a
21
+ JOIN jsonb_array_elements_text(roles) r ON r.value=a.usename
22
+ WHERE a.datname=current_database() AND a.pid<>pg_backend_pid()) THEN
23
+ RAISE EXCEPTION '0461 requires drained application sessions' USING ERRCODE='55000';
24
+ END IF;
25
+ END $drain$;
26
+
27
+ -- The old toggle remains readable for historical settings bags, but is no
28
+ -- longer a writable policy. Full old-client bags may echo the unchanged value.
29
+ CREATE FUNCTION knowledge_legacy_memory_setting_retired() RETURNS trigger
30
+ LANGUAGE plpgsql SET search_path=pg_catalog AS $$
31
+ BEGIN
32
+ IF NEW.settings->'memoryEnabled' IS DISTINCT FROM OLD.settings->'memoryEnabled' THEN
33
+ RAISE EXCEPTION 'Memory settings moved to Agent learning' USING ERRCODE='0A000';
34
+ END IF;
35
+ RETURN NEW;
36
+ END $$;
37
+ CREATE TRIGGER knowledge_legacy_memory_setting_retired BEFORE UPDATE OF settings ON workspaces
38
+ FOR EACH ROW EXECUTE FUNCTION knowledge_legacy_memory_setting_retired();
39
+
40
+ -- Generated source sessions use the existing private-create capability and
41
+ -- remain bound atomically to the exact accepted scheduled execution. All other
42
+ -- generated-session identity checks are preserved byte for byte.
43
+ DO $source_session_fence$
44
+ DECLARE definition text; old_fragment text; new_fragment text;
45
+ BEGIN
46
+ definition:=pg_get_functiondef('fence_scheduled_task_run_connection_session_identity()'::regprocedure);
47
+ old_fragment:=$old$OR session_row.visibility <> 'workspace_shared'
48
+ OR session_row.authority_epoch <> 1
49
+ OR session_row.owner_organization_membership_id IS NOT NULL
50
+ OR session_row.owner_subject_id IS NOT NULL$old$;
51
+ new_fragment:=$new$OR session_row.visibility IS DISTINCT FROM (CASE
52
+ WHEN task_snapshot#>>'{agentConfig,knowledgeSource,destination,kind}'='personal' THEN 'user_private' ELSE 'workspace_shared' END)
53
+ OR session_row.authority_epoch <> 1
54
+ OR session_row.owner_organization_membership_id IS DISTINCT FROM (CASE
55
+ WHEN task_snapshot#>>'{agentConfig,knowledgeSource,destination,kind}'='personal'
56
+ THEN (accepted#>>'{causalHumanAuthority,organizationMembershipId}')::uuid END)
57
+ OR session_row.owner_subject_id IS DISTINCT FROM (CASE
58
+ WHEN task_snapshot#>>'{agentConfig,knowledgeSource,destination,kind}'='personal'
59
+ THEN accepted->>'causalHumanSubjectId' END)
60
+ OR (task_snapshot#>>'{agentConfig,knowledgeSource,destination,kind}'='personal' AND (
61
+ accepted->>'causalHumanSubjectId' IS NULL
62
+ OR task_snapshot#>>'{agentConfig,knowledgeSource,destination,subjectId}' IS DISTINCT FROM accepted->>'causalHumanSubjectId'
63
+ OR task_snapshot#>>'{agentConfig,knowledgeSource,initiatingSubjectId}' IS DISTINCT FROM accepted->>'causalHumanSubjectId'
64
+ OR task_snapshot#>>'{agentConfig,knowledgeSource,connection,ownerSubjectId}' IS DISTINCT FROM accepted->>'causalHumanSubjectId'
65
+ OR accepted#>>'{causalHumanAuthority,subjectId}' IS DISTINCT FROM accepted->>'causalHumanSubjectId'
66
+ OR task_snapshot->>'runMode'<>'new_session_per_run'))$new$;
67
+ IF strpos(definition,old_fragment)=0 THEN RAISE EXCEPTION 'Scheduled generated-session fence changed before 0461'; END IF;
68
+ EXECUTE replace(definition,old_fragment,new_fragment);
69
+ END $source_session_fence$;
70
+
71
+ DO $source_private_create$
72
+ DECLARE definition text; old_fragment text; new_fragment text;
73
+ BEGIN
74
+ definition:=pg_get_functiondef('guard_session_authority_write()'::regprocedure);
75
+ old_fragment:=$old$AND capability.actor_subject_id = NEW.created_by_subject_id
76
+ AND capability.owner_membership_id = NEW.owner_organization_membership_id
77
+ AND NEW.created_by_kind = 'subject'$old$;
78
+ new_fragment:=$new$AND capability.owner_membership_id = NEW.owner_organization_membership_id
79
+ AND ((NEW.created_by_kind='subject' AND capability.actor_subject_id=NEW.created_by_subject_id)
80
+ OR (NEW.created_by_kind='service' AND NEW.created_by_subject_id='scheduler' AND EXISTS (
81
+ SELECT 1 FROM scheduled_task_runs run WHERE run.account_id=NEW.account_id
82
+ AND run.workspace_id=NEW.workspace_id AND run.id::text=NEW.metadata->>'scheduledTaskRunId'
83
+ AND run.action_kind='agent_turn' AND run.session_id IS NULL AND run.status IN ('queued','dispatched')
84
+ AND run.accepted_execution_snapshot#>>'{generatedSessionBinding,createIdempotencyKey}'=NEW.create_idempotency_key
85
+ AND run.accepted_execution_snapshot->>'causalHumanSubjectId'=capability.actor_subject_id
86
+ AND run.accepted_execution_snapshot#>>'{causalHumanAuthority,subjectId}'=capability.actor_subject_id
87
+ AND run.accepted_execution_snapshot#>>'{causalHumanAuthority,organizationMembershipId}'=capability.owner_membership_id::text
88
+ AND run.accepted_execution_snapshot#>>'{task,runMode}'='new_session_per_run'
89
+ AND run.accepted_execution_snapshot#>>'{task,agentConfig,knowledgeSource,destination,kind}'='personal'
90
+ AND run.accepted_execution_snapshot#>>'{task,agentConfig,knowledgeSource,destination,subjectId}'=capability.actor_subject_id
91
+ AND run.accepted_execution_snapshot#>>'{task,agentConfig,knowledgeSource,connection,ownerSubjectId}'=capability.actor_subject_id
92
+ AND run.accepted_execution_snapshot#>>'{task,agentConfig,knowledgeSource,initiatingSubjectId}'=capability.actor_subject_id
93
+ )))$new$;
94
+ IF strpos(definition,old_fragment)=0 THEN RAISE EXCEPTION 'Private-create authority fence changed before 0461'; END IF;
95
+ EXECUTE replace(definition,old_fragment,new_fragment);
96
+ END $source_private_create$;
97
+
98
+
99
+ -- Original chat attachments have an explicit owner before any byte upload.
100
+ -- Shared files retain the existing workspace policy. Personal files additionally
101
+ -- require a host-verified owner scope; a workspace or service subject is not one.
102
+ ALTER TABLE files ADD COLUMN private_owner_subject_ids text[] DEFAULT
103
+ CASE WHEN nullif(current_setting('opengeni.private_file_owner',true),'') IS NULL THEN NULL
104
+ ELSE ARRAY[current_setting('opengeni.private_file_owner',true)] END
105
+ CHECK(private_owner_subject_ids IS NULL OR (cardinality(private_owner_subject_ids)>0 AND array_position(private_owner_subject_ids,NULL) IS NULL));
106
+ -- Derive old original ownership from typed resource authority, never prose,
107
+ -- filenames, or the uploading service's subject. A file that was explicitly
108
+ -- shared as well as used privately keeps that existing shared authority.
109
+ ALTER TABLE files NO FORCE ROW LEVEL SECURITY;
110
+ ALTER TABLE documents NO FORCE ROW LEVEL SECURITY;
111
+ ALTER TABLE sessions NO FORCE ROW LEVEL SECURITY;
112
+ ALTER TABLE session_turns NO FORCE ROW LEVEL SECURITY;
113
+ ALTER TABLE generated_image_artifacts NO FORCE ROW LEVEL SECURITY;
114
+ ALTER TABLE generated_video_artifacts NO FORCE ROW LEVEL SECURITY;
115
+ ALTER TABLE retained_screenshot_artifacts NO FORCE ROW LEVEL SECURITY;
116
+ ALTER TABLE organization_memberships NO FORCE ROW LEVEL SECURITY;
117
+ CREATE TEMP TABLE knowledge_session_file_owners_0461 ON COMMIT DROP AS
118
+
119
+ SELECT s.id,s.account_id,s.resources,CASE
120
+ WHEN s.visibility='user_private' THEN s.owner_subject_id
121
+ WHEN s.memory_scope='user' THEN s.scope_subject_id
122
+ ELSE personal.subject_id END AS owner
123
+ FROM sessions s LEFT JOIN organization_memberships personal
124
+ ON personal.account_id=s.account_id AND personal.personal_workspace_id=s.workspace_id
125
+ ;
126
+ CREATE TEMP TABLE knowledge_file_owners_0461 ON COMMIT DROP AS
127
+ WITH session_owners AS (SELECT * FROM knowledge_session_file_owners_0461), original_references AS (
128
+ SELECT d.file_id,d.account_id,
129
+ CASE WHEN d.authority_kind='personal' THEN d.authority_subject_id END AS owner
130
+ FROM documents d WHERE d.file_id IS NOT NULL
131
+ UNION ALL
132
+ SELECT (r->>'fileId')::uuid,s.account_id,s.owner FROM session_owners s
133
+ CROSS JOIN LATERAL jsonb_array_elements(s.resources) r
134
+ WHERE r->>'kind'='file' AND r->>'fileId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
135
+ UNION ALL
136
+ SELECT (r->>'fileId')::uuid,s.account_id,s.owner FROM session_owners s
137
+ JOIN session_turns t ON t.session_id=s.id AND t.account_id=s.account_id
138
+ CROSS JOIN LATERAL jsonb_array_elements(t.resources) r
139
+ WHERE r->>'kind'='file' AND r->>'fileId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
140
+ UNION ALL
141
+ SELECT a.artifact_id,a.account_id,s.owner FROM generated_image_artifacts a
142
+ JOIN session_owners s ON s.id=a.session_id AND s.account_id=a.account_id
143
+ UNION ALL
144
+ SELECT a.primary_file_id,a.account_id,s.owner FROM generated_video_artifacts a
145
+ JOIN session_owners s ON s.id=a.session_id AND s.account_id=a.account_id
146
+ UNION ALL
147
+ SELECT a.artifact_id,a.account_id,s.owner FROM retained_screenshot_artifacts a
148
+ JOIN session_owners s ON s.id=a.session_id AND s.account_id=a.account_id
149
+ )
150
+ SELECT file_id,account_id,array_agg(DISTINCT owner ORDER BY owner) AS owners FROM original_references
151
+ GROUP BY file_id,account_id
152
+ HAVING bool_and(owner IS NOT NULL);
153
+ UPDATE files f SET private_owner_subject_ids=ownership.owners
154
+ FROM knowledge_file_owners_0461 ownership WHERE f.id=ownership.file_id AND f.account_id=ownership.account_id;
155
+ -- Async upload/media settlement and cleanup retain the accepted file owner
156
+ -- even after the source session/turn is removed. This is operation provenance,
157
+ -- never a grant for a user or agent to read another person's originals.
158
+ ALTER TABLE file_uploads NO FORCE ROW LEVEL SECURITY;
159
+ ALTER TABLE file_uploads ADD COLUMN private_file_owner_subject_id text
160
+ DEFAULT nullif(current_setting('opengeni.private_file_owner',true),'');
161
+ UPDATE file_uploads u SET private_file_owner_subject_id=f.private_owner_subject_ids[1]
162
+ FROM files f WHERE u.file_id=f.id AND u.account_id=f.account_id;
163
+ ALTER TABLE file_uploads FORCE ROW LEVEL SECURITY;
164
+ ALTER TABLE video_generation_operations NO FORCE ROW LEVEL SECURITY;
165
+ ALTER TABLE video_generation_operations ADD COLUMN private_file_owner_subject_id text
166
+ DEFAULT nullif(current_setting('opengeni.private_file_owner',true),'');
167
+ ALTER TABLE retained_screenshot_artifacts ADD COLUMN private_file_owner_subject_id text
168
+ DEFAULT nullif(current_setting('opengeni.private_file_owner',true),'');
169
+ UPDATE video_generation_operations o SET private_file_owner_subject_id=s.owner
170
+ FROM knowledge_session_file_owners_0461 s WHERE o.session_id=s.id AND o.account_id=s.account_id;
171
+ UPDATE retained_screenshot_artifacts a SET private_file_owner_subject_id=s.owner
172
+ FROM knowledge_session_file_owners_0461 s WHERE a.session_id=s.id AND a.account_id=s.account_id;
173
+ ALTER TABLE video_generation_operations FORCE ROW LEVEL SECURITY;
174
+ CREATE FUNCTION knowledge_media_file_owner_immutable() RETURNS trigger
175
+ LANGUAGE plpgsql SET search_path=pg_catalog AS $$
176
+ BEGIN
177
+ IF NEW.private_file_owner_subject_id IS DISTINCT FROM OLD.private_file_owner_subject_id THEN
178
+ RAISE EXCEPTION 'An operation retains its accepted original-file owner' USING ERRCODE='55000';
179
+ END IF;
180
+ RETURN NEW;
181
+ END $$;
182
+ CREATE TRIGGER file_upload_owner_immutable BEFORE UPDATE ON file_uploads
183
+ FOR EACH ROW EXECUTE FUNCTION knowledge_media_file_owner_immutable();
184
+ CREATE TRIGGER video_generation_file_owner_immutable BEFORE UPDATE ON video_generation_operations
185
+ FOR EACH ROW EXECUTE FUNCTION knowledge_media_file_owner_immutable();
186
+ CREATE TRIGGER retained_screenshot_file_owner_immutable BEFORE UPDATE ON retained_screenshot_artifacts
187
+ FOR EACH ROW EXECUTE FUNCTION knowledge_media_file_owner_immutable();
188
+ ALTER TABLE organization_memberships FORCE ROW LEVEL SECURITY;
189
+ ALTER TABLE retained_screenshot_artifacts FORCE ROW LEVEL SECURITY;
190
+ ALTER TABLE generated_video_artifacts FORCE ROW LEVEL SECURITY;
191
+ ALTER TABLE generated_image_artifacts FORCE ROW LEVEL SECURITY;
192
+ ALTER TABLE session_turns FORCE ROW LEVEL SECURITY;
193
+ ALTER TABLE sessions FORCE ROW LEVEL SECURITY;
194
+ ALTER TABLE documents FORCE ROW LEVEL SECURITY;
195
+ ALTER TABLE files FORCE ROW LEVEL SECURITY;
196
+
197
+ CREATE POLICY files_personal_owner ON files AS RESTRICTIVE FOR ALL
198
+ USING(private_owner_subject_ids IS NULL OR nullif(current_setting('opengeni.private_file_owner',true),'')=ANY(private_owner_subject_ids))
199
+ WITH CHECK(private_owner_subject_ids IS NULL OR nullif(current_setting('opengeni.private_file_owner',true),'')=ANY(private_owner_subject_ids));
200
+ -- A personal original belongs to its organization/user. Its origin workspace
201
+ -- is provenance, while shared files still cascade with their owning workspace.
202
+ ALTER TABLE files DROP CONSTRAINT files_workspace_id_fkey;
203
+ ALTER TABLE files DROP CONSTRAINT files_workspace_account_fk;
204
+ ALTER TABLE files ADD COLUMN retention_workspace_id uuid GENERATED ALWAYS AS
205
+ (CASE WHEN private_owner_subject_ids IS NULL THEN workspace_id END) STORED;
206
+ ALTER TABLE files ADD CONSTRAINT files_retention_workspace_account_fk FOREIGN KEY(retention_workspace_id,account_id)
207
+ REFERENCES workspaces(id,account_id) ON DELETE CASCADE;
208
+ CREATE POLICY files_personal_read ON files FOR SELECT USING (
209
+ account_id=nullif(current_setting('opengeni.account_id',true),'')::uuid
210
+ AND nullif(current_setting('opengeni.private_file_owner',true),'')=ANY(private_owner_subject_ids));
211
+ CREATE FUNCTION knowledge_file_owner_immutable() RETURNS trigger
212
+ LANGUAGE plpgsql SET search_path=pg_catalog AS $$
213
+ BEGIN
214
+ IF NEW.private_owner_subject_ids IS DISTINCT FROM OLD.private_owner_subject_ids
215
+ OR NEW.workspace_id IS DISTINCT FROM OLD.workspace_id OR NEW.account_id IS DISTINCT FROM OLD.account_id THEN
216
+ RAISE EXCEPTION 'File ownership is immutable; share an explicitly authorized copy' USING ERRCODE='55000';
217
+ END IF;
218
+ RETURN NEW;
219
+ END $$;
220
+ CREATE TRIGGER files_personal_owner_immutable BEFORE UPDATE ON files
221
+ FOR EACH ROW EXECUTE FUNCTION knowledge_file_owner_immutable();
222
+
223
+ -- Defaults and context overrides are revisions of one policy authority. Empty
224
+ -- overrides mean inheritance. Personal defaults are organization-user owned;
225
+ -- origin_workspace_id records provenance, not a cross-workspace access grant.
226
+ -- Provenance UUIDs deliberately have no workspace foreign key: deleting an
227
+ -- originating workspace must not erase personal/organization retained content
228
+ -- or its receipts. Only actual ownership columns cascade workspace deletion.
229
+ CREATE TABLE agent_learning_revisions (
230
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
231
+ account_id uuid NOT NULL REFERENCES managed_accounts(id) ON DELETE CASCADE,
232
+ origin_workspace_id uuid NOT NULL,
233
+ owner_key text NOT NULL,
234
+ owner_workspace_id uuid GENERATED ALWAYS AS
235
+ (CASE WHEN subject_id IS NULL THEN origin_workspace_id END) STORED,
236
+ subject_id text,
237
+ context_key text NOT NULL DEFAULT 'defaults',
238
+ version integer NOT NULL CHECK (version>0),
239
+ settings jsonb NOT NULL CHECK (jsonb_typeof(settings)='object'),
240
+ operation_id uuid NOT NULL,
241
+ request_hash text NOT NULL,
242
+ actor_subject_id text NOT NULL,
243
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
244
+ UNIQUE(account_id,owner_key,context_key,version),
245
+ UNIQUE(account_id,operation_id),
246
+ FOREIGN KEY(owner_workspace_id,account_id) REFERENCES workspaces(id,account_id) ON DELETE CASCADE,
247
+ CHECK ((subject_id IS NULL AND owner_key='workspace:'||origin_workspace_id::text)
248
+ OR (length(subject_id) BETWEEN 1 AND 1024 AND owner_key='personal:'||subject_id))
249
+ );
250
+ CREATE INDEX agent_learning_revisions_current_idx ON agent_learning_revisions
251
+ (account_id,owner_key,context_key,version DESC);
252
+
253
+ CREATE TABLE agent_learning_snapshots (
254
+ account_id uuid NOT NULL,
255
+ workspace_id uuid NOT NULL,
256
+ session_id uuid NOT NULL,
257
+ turn_id uuid NOT NULL,
258
+ snapshot jsonb NOT NULL,
259
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
260
+ PRIMARY KEY(account_id,turn_id),
261
+ FOREIGN KEY(workspace_id,account_id) REFERENCES workspaces(id,account_id) ON DELETE CASCADE,
262
+ FOREIGN KEY(workspace_id,turn_id) REFERENCES session_turns(workspace_id,id) ON DELETE CASCADE
263
+ );
264
+
265
+ CREATE TABLE knowledge_entries (
266
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
267
+ account_id uuid NOT NULL REFERENCES managed_accounts(id) ON DELETE CASCADE,
268
+ origin_workspace_id uuid NOT NULL,
269
+ scope text NOT NULL CHECK(scope IN ('organization','workspace','personal')),
270
+ scope_workspace_id uuid,
271
+ scope_subject_id text,
272
+ version integer NOT NULL DEFAULT 0 CHECK(version>=0),
273
+ published_revision_id uuid,
274
+ latest_revision_id uuid,
275
+ archived boolean NOT NULL DEFAULT false,
276
+ -- A migrated scope is an additional restriction, never a broader owner.
277
+ legacy_memory_id uuid UNIQUE,
278
+ legacy_document_id uuid UNIQUE,
279
+ legacy_claim_id uuid UNIQUE,
280
+ prepared_file_id uuid,
281
+ document_preparation jsonb,
282
+ legacy_document_version_id uuid UNIQUE,
283
+ legacy_scope_workspace_id uuid,
284
+ access_document_id uuid,
285
+ access_file_id uuid,
286
+ legacy_scope_type text,
287
+ legacy_scope_role_key text,
288
+ legacy_scope_session_id uuid,
289
+ valid_from timestamptz NOT NULL DEFAULT now(),
290
+ valid_until timestamptz,
291
+ created_at timestamptz NOT NULL DEFAULT now(),
292
+ updated_at timestamptz NOT NULL DEFAULT now(),
293
+ UNIQUE(account_id,id),
294
+ FOREIGN KEY(scope_workspace_id,account_id) REFERENCES workspaces(id,account_id) ON DELETE CASCADE,
295
+ CHECK ((scope='organization' AND scope_workspace_id IS NULL AND scope_subject_id IS NULL)
296
+ OR (scope='workspace' AND scope_workspace_id IS NOT NULL AND scope_subject_id IS NULL)
297
+ OR (scope='personal' AND scope_workspace_id IS NULL AND length(scope_subject_id) BETWEEN 1 AND 1024))
298
+ );
299
+ CREATE INDEX knowledge_entries_scope_idx ON knowledge_entries(account_id,scope,scope_workspace_id,scope_subject_id,id);
300
+
301
+ CREATE TABLE knowledge_review_batches (
302
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
303
+ account_id uuid NOT NULL REFERENCES managed_accounts(id) ON DELETE CASCADE,
304
+ origin_workspace_id uuid NOT NULL,
305
+ owner_key text NOT NULL,
306
+ session_id uuid,
307
+ turn_id uuid,
308
+ scheduled_task_run_id uuid,
309
+ created_at timestamptz NOT NULL DEFAULT now(),
310
+ UNIQUE(account_id,id),
311
+ UNIQUE(account_id,owner_key,turn_id)
312
+ );
313
+ CREATE UNIQUE INDEX knowledge_review_batches_run_idx ON knowledge_review_batches
314
+ (account_id,owner_key,scheduled_task_run_id) WHERE scheduled_task_run_id IS NOT NULL;
315
+
316
+ CREATE TABLE knowledge_entry_revisions (
317
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
318
+ account_id uuid NOT NULL,
319
+ entry_id uuid NOT NULL,
320
+ number integer NOT NULL CHECK(number>0),
321
+ change_kind text NOT NULL DEFAULT 'upsert' CHECK(change_kind IN ('upsert','archive')),
322
+ body jsonb NOT NULL CHECK(jsonb_typeof(body)='object'),
323
+ body_codec_version integer,
324
+ legacy_snapshot jsonb,
325
+ preview text NOT NULL DEFAULT '',
326
+ preview_codec_version integer,
327
+ previous_revision_id uuid,
328
+ restored_from_revision_id uuid,
329
+ actor jsonb NOT NULL,
330
+ created_by_session_id uuid,
331
+ created_by_turn_id uuid,
332
+ review_batch_id uuid,
333
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
334
+ UNIQUE(account_id,entry_id,id),
335
+ UNIQUE(account_id,entry_id,number),
336
+ FOREIGN KEY(account_id,entry_id) REFERENCES knowledge_entries(account_id,id) ON DELETE CASCADE,
337
+ FOREIGN KEY(account_id,entry_id,previous_revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id),
338
+ FOREIGN KEY(account_id,entry_id,restored_from_revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id),
339
+ FOREIGN KEY(account_id,review_batch_id) REFERENCES knowledge_review_batches(account_id,id),
340
+ CHECK(body_codec_version IS NULL OR body_codec_version=1)
341
+ );
342
+ ALTER TABLE knowledge_entries ADD CONSTRAINT knowledge_entries_published_fk
343
+ FOREIGN KEY(account_id,id,published_revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id)
344
+ DEFERRABLE INITIALLY DEFERRED;
345
+ ALTER TABLE knowledge_entries ADD CONSTRAINT knowledge_entries_latest_fk
346
+ FOREIGN KEY(account_id,id,latest_revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id)
347
+ DEFERRABLE INITIALLY DEFERRED;
348
+
349
+ -- Relational indexes of each immutable revision. Labels, quotes and locations
350
+ -- live once in its exact body. Membership does not copy or authorize content.
351
+ CREATE TABLE knowledge_entry_links (
352
+ account_id uuid NOT NULL,
353
+ entry_id uuid NOT NULL,
354
+ revision_id uuid NOT NULL,
355
+ ordinal integer NOT NULL CHECK(ordinal>=0),
356
+ target_entry_id uuid NOT NULL,
357
+ target_revision_id uuid,
358
+ relation text NOT NULL CHECK(relation IN
359
+ ('evidence','group','related_to','depends_on','applies_to','conflicts_with','supersedes')),
360
+ PRIMARY KEY(revision_id,ordinal),
361
+ FOREIGN KEY(account_id,entry_id,revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id) ON DELETE CASCADE,
362
+ FOREIGN KEY(account_id,target_entry_id) REFERENCES knowledge_entries(account_id,id),
363
+ FOREIGN KEY(account_id,target_entry_id,target_revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id),
364
+ CHECK(entry_id<>target_entry_id),
365
+ CHECK((relation='evidence')=(target_revision_id IS NOT NULL))
366
+ );
367
+ CREATE INDEX knowledge_entry_links_target_idx ON knowledge_entry_links(account_id,target_entry_id,relation,revision_id);
368
+
369
+ CREATE TABLE knowledge_entry_decisions (
370
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
371
+ account_id uuid NOT NULL,
372
+ entry_id uuid NOT NULL,
373
+ revision_id uuid NOT NULL,
374
+ version integer NOT NULL CHECK(version>0),
375
+ outcome text NOT NULL CHECK(outcome IN ('published','pending','rejected','archived','restored')),
376
+ actor jsonb NOT NULL,
377
+ policy_snapshot jsonb,
378
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
379
+ UNIQUE(account_id,entry_id,version),
380
+ FOREIGN KEY(account_id,entry_id,revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id) ON DELETE CASCADE
381
+ );
382
+ CREATE TABLE knowledge_entry_operations (
383
+ account_id uuid NOT NULL,
384
+ origin_workspace_id uuid NOT NULL,
385
+ operation_id uuid NOT NULL,
386
+ entry_id uuid NOT NULL,
387
+ actor jsonb NOT NULL,
388
+ request_hash text NOT NULL,
389
+ receipt jsonb NOT NULL,
390
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
391
+ PRIMARY KEY(account_id,operation_id),
392
+ FOREIGN KEY(account_id,entry_id) REFERENCES knowledge_entries(account_id,id) ON DELETE CASCADE
393
+ );
394
+
395
+ -- Rebuildable search projection. It never owns retained content or publication.
396
+ -- Instruction content remains in its native revision/head lifecycle. This
397
+ -- append-only ledger owns operation replay and review decisions only.
398
+ CREATE TABLE agent_instruction_operations (
399
+ account_id uuid NOT NULL,
400
+ origin_workspace_id uuid NOT NULL,
401
+ operation_id uuid NOT NULL,
402
+ revision_id uuid NOT NULL REFERENCES workspace_instruction_policy_revisions(id) ON DELETE CASCADE,
403
+ request_hash text NOT NULL,
404
+ actor jsonb NOT NULL,
405
+ receipt jsonb NOT NULL,
406
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
407
+ PRIMARY KEY(account_id,operation_id),
408
+ FOREIGN KEY(origin_workspace_id,account_id) REFERENCES workspaces(id,account_id) ON DELETE CASCADE
409
+ );
410
+ ALTER TABLE workspace_instruction_policy_revisions ADD COLUMN agent_learning_context jsonb;
411
+ ALTER TABLE workspace_instruction_policy_revisions DROP CONSTRAINT workspace_instruction_policy_revisions_provenance_chk;
412
+ ALTER TABLE workspace_instruction_policy_revisions ADD CONSTRAINT workspace_instruction_policy_revisions_provenance_chk
413
+ CHECK(provenance_source IN ('human','onboarding','knowledge_proposal','legacy_import','agent_learning')
414
+ AND (provenance_source_id IS NULL OR length(provenance_source_id) BETWEEN 1 AND 512)
415
+ AND ((provenance_source='agent_learning')=(agent_learning_context IS NOT NULL)));
416
+
417
+ -- Search caches are projections, never a content or publication authority.
418
+ CREATE TABLE knowledge_index_jobs (
419
+ account_id uuid NOT NULL,
420
+ entry_id uuid NOT NULL,
421
+ revision_id uuid PRIMARY KEY,
422
+ state text NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','running','ready','obsolete')),
423
+ model text,
424
+ dimensions integer,
425
+ generation integer NOT NULL DEFAULT 0,
426
+ completed_generation integer,
427
+ next_index integer NOT NULL DEFAULT 0,
428
+ lease_id uuid,
429
+ lease_until timestamptz,
430
+ attempts integer NOT NULL DEFAULT 0,
431
+ next_attempt_at timestamptz NOT NULL DEFAULT clock_timestamp(),
432
+ last_failure text,
433
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
434
+ FOREIGN KEY(account_id,entry_id,revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id) ON DELETE CASCADE
435
+ );
436
+ CREATE INDEX knowledge_index_jobs_due ON knowledge_index_jobs(next_attempt_at,revision_id) WHERE state<>'obsolete';
437
+ CREATE TABLE knowledge_entry_vectors (
438
+ account_id uuid NOT NULL,
439
+ entry_id uuid NOT NULL,
440
+ revision_id uuid NOT NULL,
441
+ generation integer NOT NULL,
442
+ chunk_index integer NOT NULL,
443
+ model text NOT NULL,
444
+ dimensions integer NOT NULL CHECK(dimensions BETWEEN 1 AND 4096),
445
+ field text NOT NULL CHECK(field IN ('title','content')),
446
+ start_offset integer NOT NULL CHECK(start_offset>=0),
447
+ end_offset integer NOT NULL CHECK(end_offset>=start_offset),
448
+ text text NOT NULL,
449
+ text_codec_version integer NOT NULL CHECK(text_codec_version=1),
450
+ embedding vector NOT NULL,
451
+ PRIMARY KEY(account_id,entry_id,revision_id,generation,chunk_index),
452
+ FOREIGN KEY(account_id,entry_id,revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id) ON DELETE CASCADE,
453
+ CHECK(vector_dims(embedding)=dimensions AND vector_norm(embedding)>0)
454
+ );
455
+ CREATE FUNCTION knowledge_enqueue_index() RETURNS trigger
456
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
457
+ BEGIN
458
+ IF NEW.change_kind<>'archive' THEN
459
+ INSERT INTO knowledge_index_jobs(account_id,entry_id,revision_id) VALUES(NEW.account_id,NEW.entry_id,NEW.id);
460
+ END IF;
461
+ RETURN NEW;
462
+ END $$;
463
+ CREATE TRIGGER knowledge_revision_index_queue AFTER INSERT ON knowledge_entry_revisions
464
+ FOR EACH ROW EXECUTE FUNCTION knowledge_enqueue_index();
465
+ -- Only the owner-definer dispatcher can use this scope. Setting the GUC on an
466
+ -- application connection cannot reveal jobs or turn it into a content reader.
467
+ CREATE POLICY knowledge_index_dispatcher ON knowledge_index_jobs USING (
468
+ current_user=pg_get_userbyid((SELECT relowner FROM pg_class WHERE oid='knowledge_index_jobs'::regclass))
469
+ AND current_setting('opengeni.knowledge_index_dispatcher',true)='1');
470
+ CREATE FUNCTION knowledge_index_claim(p_model text,p_dimensions integer,p_limit integer)
471
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
472
+ DECLARE previous text:=current_setting('opengeni.knowledge_index_dispatcher',true); result jsonb;
473
+ BEGIN
474
+ IF p_model IS NULL OR p_dimensions IS NULL OR p_limit IS NULL OR length(p_model) NOT BETWEEN 1 AND 512 OR p_dimensions NOT BETWEEN 1 AND 4096 OR p_limit NOT BETWEEN 1 AND 20 THEN
475
+ RAISE EXCEPTION 'Invalid Knowledge indexing claim' USING ERRCODE='22023'; END IF;
476
+ PERFORM set_config('opengeni.knowledge_index_dispatcher','1',true);
477
+ WITH candidates AS (
478
+ SELECT j.revision_id FROM knowledge_index_jobs j WHERE j.state<>'obsolete' AND j.next_attempt_at<=clock_timestamp()
479
+ AND (j.lease_until IS NULL OR j.lease_until<=clock_timestamp())
480
+ AND (j.state<>'ready' OR j.model IS DISTINCT FROM p_model OR j.dimensions IS DISTINCT FROM p_dimensions)
481
+ ORDER BY j.next_attempt_at,j.revision_id LIMIT p_limit FOR UPDATE SKIP LOCKED
482
+ ), claimed AS (
483
+ UPDATE knowledge_index_jobs j SET state='running',lease_id=gen_random_uuid(),lease_until=clock_timestamp()+interval '5 minutes',
484
+ generation=CASE WHEN j.model IS DISTINCT FROM p_model OR j.dimensions IS DISTINCT FROM p_dimensions THEN j.generation+1 ELSE j.generation END,
485
+ next_index=CASE WHEN j.model IS DISTINCT FROM p_model OR j.dimensions IS DISTINCT FROM p_dimensions THEN 0 ELSE j.next_index END,
486
+ model=p_model,dimensions=p_dimensions,attempts=j.attempts+1
487
+ FROM candidates c WHERE j.revision_id=c.revision_id RETURNING j.*
488
+ ) SELECT coalesce(jsonb_agg(jsonb_build_object('accountId',account_id,'entryId',entry_id,'revisionId',revision_id,
489
+ 'leaseId',lease_id,'model',model,'dimensions',dimensions,'generation',generation,'nextIndex',next_index)),'[]'::jsonb) INTO result FROM claimed;
490
+ PERFORM set_config('opengeni.knowledge_index_dispatcher',coalesce(previous,''),true);
491
+ RETURN result;
492
+ EXCEPTION WHEN OTHERS THEN
493
+ PERFORM set_config('opengeni.knowledge_index_dispatcher',coalesce(previous,''),true);
494
+ RAISE;
495
+ END $$;
496
+ -- Projection processing is a lease capability, never an impersonated human or
497
+ -- an agent attempt. It may process retained bytes but cannot publish revisions.
498
+ -- Recheck source lifecycle for the entry and its pinned evidence before every
499
+ -- provider batch and cache commit; retrieval still applies the requesting
500
+ -- subject's current ACL independently.
501
+ CREATE FUNCTION knowledge_index_retention_active(p_account uuid,p_entry uuid,p_revision uuid,p_preparing_document uuid DEFAULT NULL)
502
+ RETURNS boolean LANGUAGE plpgsql VOLATILE SET search_path FROM CURRENT AS $$
503
+ DECLARE dep record; document_id uuid; file_id uuid; file_subject text; allowed boolean:=true;
504
+ previous_workspace text:=current_setting('opengeni.workspace_id',true);
505
+ previous_subject text:=current_setting('opengeni.subject_id',true);
506
+ previous_owner text:=current_setting('opengeni.private_file_owner',true);
507
+ BEGIN
508
+ FOR dep IN WITH RECURSIVE dependencies(entry_id,revision_id) AS (
509
+ SELECT p_entry,p_revision UNION
510
+ SELECT l.target_entry_id,l.target_revision_id FROM dependencies d JOIN knowledge_entry_links l
511
+ ON l.account_id=p_account AND l.entry_id=d.entry_id AND l.revision_id=d.revision_id AND l.relation='evidence'
512
+ ) SELECT e.*,r.body,r.id AS revision_id FROM dependencies d
513
+ LEFT JOIN knowledge_entries e ON e.account_id=p_account AND e.id=d.entry_id
514
+ LEFT JOIN knowledge_entry_revisions r ON r.account_id=p_account AND r.entry_id=d.entry_id AND r.id=d.revision_id
515
+ LOOP
516
+ IF dep.id IS NULL OR dep.revision_id IS NULL OR dep.archived
517
+ OR dep.valid_from>clock_timestamp() OR dep.valid_until<=clock_timestamp() THEN allowed:=false; EXIT; END IF;
518
+ PERFORM set_config('opengeni.workspace_id',coalesce(dep.scope_workspace_id,dep.origin_workspace_id)::text,true);
519
+ PERFORM set_config('opengeni.subject_id',coalesce(dep.scope_subject_id,''),true);
520
+ PERFORM set_config('opengeni.private_file_owner',coalesce(dep.scope_subject_id,''),true);
521
+ IF dep.legacy_document_version_id IS NOT NULL AND NOT EXISTS (
522
+ SELECT 1 FROM knowledge_document_versions v
523
+ JOIN knowledge_source_objects o ON o.account_id=v.account_id AND o.id=v.object_id AND o.lifecycle_state='active'
524
+ JOIN knowledge_sources s ON s.account_id=v.account_id AND s.id=v.source_id AND s.lifecycle_state='active'
525
+ JOIN knowledge_providers p ON p.account_id=s.account_id AND p.id=s.provider_id AND p.lifecycle_state='active'
526
+ JOIN knowledge_source_acl_versions a ON a.account_id=s.account_id AND a.source_id=s.id AND a.generation=s.current_acl_generation
527
+ WHERE v.account_id=p_account AND v.id=dep.legacy_document_version_id
528
+ ) THEN allowed:=false; EXIT; END IF;
529
+ document_id:=coalesce(dep.access_document_id,dep.legacy_document_id,(dep.body#>>'{source,documentId}')::uuid);
530
+ IF document_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM documents d
531
+ WHERE d.account_id=p_account AND d.id=document_id AND d.status='ready') THEN allowed:=false; EXIT; END IF;
532
+ file_id:=coalesce(dep.access_file_id,(dep.body#>>'{source,fileId}')::uuid);
533
+ -- The retained original's immutable initiator is processing provenance, not
534
+ -- a human grant. Ordinary Knowledge reads still check the current caller.
535
+ file_subject:=dep.scope_subject_id;
536
+ IF document_id IS NOT NULL THEN
537
+ SELECT coalesce(d.authority_subject_id,d.created_by) INTO file_subject FROM documents d
538
+ WHERE d.account_id=p_account AND d.id=document_id;
539
+ END IF;
540
+ PERFORM set_config('opengeni.subject_id',coalesce(file_subject,''),true);
541
+ IF file_id IS NOT NULL AND (NOT EXISTS(SELECT 1 FROM files f
542
+ WHERE f.account_id=p_account AND f.id=file_id AND f.status='ready')
543
+ OR ((p_preparing_document IS NULL OR document_id IS DISTINCT FROM p_preparing_document) AND NOT google_drive_file_authorized(p_account,coalesce(dep.scope_workspace_id,dep.origin_workspace_id),file_subject,file_id)))
544
+ THEN allowed:=false; EXIT; END IF;
545
+ END LOOP;
546
+ PERFORM set_config('opengeni.workspace_id',coalesce(previous_workspace,''),true);
547
+ PERFORM set_config('opengeni.subject_id',coalesce(previous_subject,''),true);
548
+ PERFORM set_config('opengeni.private_file_owner',coalesce(previous_owner,''),true);
549
+ RETURN allowed;
550
+ EXCEPTION WHEN OTHERS THEN
551
+ PERFORM set_config('opengeni.workspace_id',coalesce(previous_workspace,''),true);
552
+ PERFORM set_config('opengeni.subject_id',coalesce(previous_subject,''),true);
553
+ PERFORM set_config('opengeni.private_file_owner',coalesce(previous_owner,''),true);
554
+ RAISE;
555
+ END $$;
556
+
557
+ CREATE FUNCTION knowledge_index_work(p_account uuid,p_revision uuid,p_lease uuid,p_request jsonb)
558
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
559
+ DECLARE job knowledge_index_jobs%ROWTYPE; revision knowledge_entry_revisions%ROWTYPE; entry knowledge_entries%ROWTYPE;
560
+ operation text:=p_request->>'operation'; chunk jsonb; ordinal integer:=0; vector_value vector;
561
+ BEGIN
562
+ IF p_account IS DISTINCT FROM opengeni_private.current_account_id() THEN
563
+ RAISE EXCEPTION 'Knowledge index tenant mismatch' USING ERRCODE='42501'; END IF;
564
+ SELECT * INTO job FROM knowledge_index_jobs j WHERE j.account_id=p_account AND j.revision_id=p_revision
565
+ AND j.lease_id=p_lease AND j.state='running' AND j.lease_until>clock_timestamp() FOR UPDATE;
566
+ IF NOT FOUND THEN RAISE EXCEPTION 'Knowledge index lease unavailable' USING ERRCODE='40001'; END IF;
567
+ SELECT * INTO entry FROM knowledge_entries e WHERE e.account_id=p_account AND e.id=job.entry_id;
568
+ SELECT * INTO revision FROM knowledge_entry_revisions r WHERE r.account_id=p_account AND r.id=p_revision;
569
+ IF entry.id IS NULL OR revision.id IS NULL OR entry.archived OR revision.change_kind='archive'
570
+ OR (p_revision IS DISTINCT FROM entry.latest_revision_id AND p_revision IS DISTINCT FROM entry.published_revision_id) THEN
571
+ UPDATE knowledge_index_jobs SET state='obsolete',lease_id=NULL,lease_until=NULL WHERE revision_id=p_revision;
572
+ DELETE FROM knowledge_entry_vectors WHERE account_id=p_account AND revision_id=p_revision;
573
+ RETURN jsonb_build_object('status','obsolete');
574
+ END IF;
575
+ IF operation NOT IN ('fail','continue') AND NOT knowledge_index_retention_active(p_account,entry.id,p_revision) THEN
576
+ UPDATE knowledge_index_jobs SET state='pending',lease_id=NULL,lease_until=NULL,last_failure='source_unavailable',
577
+ next_attempt_at=clock_timestamp()+interval '1 hour' WHERE revision_id=p_revision;
578
+ RETURN jsonb_build_object('status','pending');
579
+ END IF;
580
+ IF operation='read' THEN
581
+ UPDATE knowledge_index_jobs SET lease_until=clock_timestamp()+interval '5 minutes' WHERE revision_id=p_revision;
582
+ RETURN jsonb_build_object('status','running','body',revision.body,'codecVersion',revision.body_codec_version,
583
+ 'originWorkspaceId',entry.origin_workspace_id,'scope',entry.scope,'subjectId',entry.scope_subject_id,
584
+ 'nextIndex',job.next_index,'generation',job.generation);
585
+ ELSIF operation='append' THEN
586
+ IF jsonb_typeof(p_request->'chunks') IS DISTINCT FROM 'array' OR jsonb_array_length(p_request->'chunks') NOT BETWEEN 1 AND 64
587
+ OR (p_request->>'expectedNextIndex')::integer IS DISTINCT FROM job.next_index THEN
588
+ RAISE EXCEPTION 'Knowledge index checkpoint changed' USING ERRCODE='40001'; END IF;
589
+ FOR chunk IN SELECT value FROM jsonb_array_elements(p_request->'chunks') LOOP
590
+ IF (chunk->>'index')::integer IS DISTINCT FROM job.next_index+ordinal
591
+ OR jsonb_typeof(chunk->'text') IS DISTINCT FROM 'string' OR length(chunk->>'text')>16384
592
+ OR jsonb_typeof(chunk->'embedding') IS DISTINCT FROM 'array' OR jsonb_array_length(chunk->'embedding')<>job.dimensions THEN
593
+ RAISE EXCEPTION 'Invalid Knowledge index chunk' USING ERRCODE='22023'; END IF;
594
+ vector_value:=(chunk->'embedding')::text::vector;
595
+ INSERT INTO knowledge_entry_vectors(account_id,entry_id,revision_id,generation,chunk_index,model,dimensions,
596
+ field,start_offset,end_offset,text,text_codec_version,embedding)
597
+ VALUES(p_account,job.entry_id,p_revision,job.generation,job.next_index+ordinal,job.model,job.dimensions,
598
+ chunk->>'field',(chunk->>'start')::integer,(chunk->>'end')::integer,chunk->>'text',1,vector_value);
599
+ ordinal:=ordinal+1;
600
+ END LOOP;
601
+ UPDATE knowledge_index_jobs SET next_index=next_index+ordinal,lease_until=clock_timestamp()+interval '5 minutes' WHERE revision_id=p_revision;
602
+ RETURN jsonb_build_object('status','running','nextIndex',job.next_index+ordinal);
603
+ ELSIF operation='complete' THEN
604
+ IF (p_request->>'expectedNextIndex')::integer IS DISTINCT FROM job.next_index OR job.next_index<1 THEN
605
+ RAISE EXCEPTION 'Knowledge index checkpoint changed' USING ERRCODE='40001'; END IF;
606
+ UPDATE knowledge_index_jobs SET state='ready',completed_generation=generation,lease_id=NULL,lease_until=NULL,last_failure=NULL WHERE revision_id=p_revision;
607
+ DELETE FROM knowledge_entry_vectors WHERE account_id=p_account AND revision_id=p_revision AND generation<>job.generation;
608
+ RETURN jsonb_build_object('status','ready');
609
+ ELSIF operation='continue' THEN
610
+ UPDATE knowledge_index_jobs SET state='pending',lease_id=NULL,lease_until=NULL,next_attempt_at=clock_timestamp(),attempts=0
611
+ WHERE revision_id=p_revision;
612
+ RETURN jsonb_build_object('status','pending');
613
+ ELSIF operation='fail' THEN
614
+ UPDATE knowledge_index_jobs SET state='pending',lease_id=NULL,lease_until=NULL,last_failure='embedding_unavailable',
615
+ next_attempt_at=clock_timestamp()+least(3600,power(2,least(attempts,10))*5)*interval '1 second' WHERE revision_id=p_revision;
616
+ RETURN jsonb_build_object('status','pending');
617
+ END IF;
618
+ RAISE EXCEPTION 'Unknown Knowledge index operation' USING ERRCODE='22023';
619
+ END $$;
620
+
621
+ -- A Drive ACL refresh can finish just after the projector deferred a source.
622
+ -- Wake its existing job immediately; the normal lease and access checks remain
623
+ -- authoritative at read and commit, including a concurrent revocation.
624
+ CREATE FUNCTION knowledge_source_index_wake() RETURNS trigger
625
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
626
+ BEGIN
627
+ IF NEW.status='indexed' AND NEW.acl_eligibility='eligible' THEN
628
+ UPDATE knowledge_index_jobs j SET next_attempt_at=clock_timestamp()
629
+ FROM knowledge_entries e WHERE e.account_id=NEW.account_id AND j.account_id=e.account_id
630
+ AND j.entry_id=e.id AND j.state='pending'
631
+ AND (e.legacy_document_id=NEW.document_id OR e.legacy_document_version_id=NEW.knowledge_document_version_id);
632
+ END IF;
633
+ RETURN NEW;
634
+ END $$;
635
+ CREATE TRIGGER knowledge_source_index_ready AFTER UPDATE OF status,acl_eligibility,google_drive_acl_evidence_id
636
+ ON knowledge_source_sync_index_obligations FOR EACH ROW EXECUTE FUNCTION knowledge_source_index_wake();
637
+
638
+ CREATE TABLE knowledge_entry_search (
639
+ account_id uuid NOT NULL,
640
+ entry_id uuid NOT NULL,
641
+ revision_id uuid NOT NULL,
642
+ chunk_index integer NOT NULL CHECK(chunk_index>=0),
643
+ search_vector tsvector NOT NULL,
644
+ PRIMARY KEY(account_id,entry_id,revision_id,chunk_index),
645
+ FOREIGN KEY(account_id,entry_id,revision_id) REFERENCES knowledge_entry_revisions(account_id,entry_id,id) ON DELETE CASCADE
646
+ );
647
+ CREATE INDEX knowledge_entry_search_vector_idx ON knowledge_entry_search USING gin(search_vector);
648
+
649
+ -- Bounded, overlapping projections avoid PostgreSQL's tsvector size limit for
650
+ -- long sources. Canonical text is always the exact revision body above.
651
+ CREATE FUNCTION knowledge_index_revision(p_account uuid,p_entry uuid,p_revision uuid,p_text text)
652
+ RETURNS void LANGUAGE sql SET search_path FROM CURRENT AS $$
653
+ INSERT INTO knowledge_entry_search(account_id,entry_id,revision_id,chunk_index,search_vector)
654
+ SELECT p_account,p_entry,p_revision,(n/14000)::integer,
655
+ setweight(to_tsvector('simple',r.body->>'title'),'A') ||
656
+ setweight(to_tsvector('simple',substring(p_text FROM n+1 FOR 16000)),'B')
657
+ FROM generate_series(0,greatest(length(p_text)-1,0),14000) n
658
+ JOIN knowledge_entry_revisions r ON r.account_id=p_account AND r.entry_id=p_entry AND r.id=p_revision
659
+ $$;
660
+
661
+ CREATE FUNCTION knowledge_reject_history_mutation() RETURNS trigger
662
+ LANGUAGE plpgsql SET search_path=pg_catalog AS $$
663
+ BEGIN
664
+ IF TG_OP='DELETE' AND pg_trigger_depth()>1 THEN RETURN OLD; END IF;
665
+ RAISE EXCEPTION 'Knowledge history is immutable' USING ERRCODE='55000';
666
+ END $$;
667
+
668
+ -- Tenant fences apply even inside owner lifecycle functions. No runtime table
669
+ -- grants: the read capability also checks publication, source ACLs and links.
670
+ DO $tables$
671
+ DECLARE t text; runtime_role text;
672
+ BEGIN
673
+ FOREACH t IN ARRAY ARRAY['agent_learning_revisions','agent_learning_snapshots','agent_instruction_operations','knowledge_entries','knowledge_review_batches',
674
+ 'knowledge_entry_revisions','knowledge_entry_links','knowledge_entry_decisions',
675
+ 'knowledge_entry_operations','knowledge_entry_search','knowledge_index_jobs','knowledge_entry_vectors'] LOOP
676
+ EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY',t);
677
+ EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY',t);
678
+ EXECUTE format('CREATE POLICY knowledge_tenant ON %I USING (account_id = nullif(current_setting(''opengeni.account_id'',true),'''')::uuid)',t);
679
+ EXECUTE format('REVOKE ALL ON %I FROM PUBLIC',t);
680
+ FOR runtime_role IN SELECT jsonb_array_elements_text(current_setting('opengeni.migration_application_roles')::jsonb) LOOP
681
+ IF runtime_role <> current_user AND EXISTS(SELECT 1 FROM pg_roles WHERE rolname=runtime_role) THEN
682
+ EXECUTE format('REVOKE ALL ON %I FROM %I',t,runtime_role);
683
+ END IF;
684
+ END LOOP;
685
+ END LOOP;
686
+ FOREACH t IN ARRAY ARRAY['agent_learning_revisions','agent_learning_snapshots','agent_instruction_operations','knowledge_entry_revisions','knowledge_entry_links',
687
+ 'knowledge_entry_decisions','knowledge_entry_operations'] LOOP
688
+ EXECUTE format('CREATE TRIGGER knowledge_immutable BEFORE UPDATE OR DELETE ON %I FOR EACH ROW EXECUTE FUNCTION knowledge_reject_history_mutation()',t);
689
+ END LOOP;
690
+ END $tables$;
691
+
692
+ -- Compatibility selectors are retained as restrictions. A migration never
693
+ -- interprets a legacy label or source-created identity as a personal owner.
694
+ CREATE FUNCTION knowledge_scope_visible(e knowledge_entries) RETURNS boolean
695
+ LANGUAGE sql STABLE SET search_path FROM CURRENT AS $$
696
+ SELECT coalesce(e.account_id=nullif(current_setting('opengeni.account_id',true),'')::uuid
697
+ AND (e.scope='organization'
698
+ OR (e.scope='workspace' AND e.scope_workspace_id=nullif(current_setting('opengeni.workspace_id',true),'')::uuid)
699
+ OR (e.scope='personal' AND e.scope_subject_id=nullif(current_setting('opengeni.subject_id',true),'')
700
+ AND (current_setting('opengeni.knowledge_actor_kind',true)='human'
701
+ OR current_setting('opengeni.knowledge_default_scope',true)='personal')))
702
+ AND (e.legacy_scope_workspace_id IS NULL OR e.legacy_scope_workspace_id=nullif(current_setting('opengeni.workspace_id',true),'')::uuid)
703
+ AND e.valid_from<=transaction_timestamp() AND (e.valid_until IS NULL OR e.valid_until>transaction_timestamp())
704
+ AND (e.legacy_memory_id IS NULL OR e.origin_workspace_id=nullif(current_setting('opengeni.workspace_id',true),'')::uuid)
705
+ AND (e.legacy_scope_type IS NULL OR opengeni_private.memory_scope_visible(
706
+ e.legacy_scope_type,e.scope_subject_id,e.legacy_scope_role_key,e.legacy_scope_session_id,e.valid_from,e.valid_until)),false)
707
+ $$;
708
+
709
+ CREATE FUNCTION knowledge_learning_settings_valid(value jsonb, complete boolean) RETURNS boolean
710
+ LANGUAGE sql IMMUTABLE SET search_path=pg_catalog AS $$
711
+ SELECT coalesce(jsonb_typeof(value)='object'
712
+ AND value-'knowledge'-'instructions'-'skills'='{}'::jsonb
713
+ AND (NOT complete OR value ?& ARRAY['knowledge','instructions','skills'])
714
+ AND NOT EXISTS(SELECT 1 FROM jsonb_each(value) p
715
+ WHERE jsonb_typeof(p.value)<>'string' OR p.value#>>'{}' NOT IN ('automatic','review_first','off')),false)
716
+ $$;
717
+ ALTER TABLE agent_learning_revisions ADD CONSTRAINT agent_learning_settings_valid
718
+ CHECK(knowledge_learning_settings_valid(settings,context_key='defaults'));
719
+
720
+ CREATE FUNCTION knowledge_learning_resolve(p_account uuid,p_workspace uuid,p_subject text,
721
+ p_context text,p_at timestamptz) RETURNS jsonb
722
+ LANGUAGE plpgsql STABLE SET search_path FROM CURRENT AS $$
723
+ DECLARE base jsonb; overrides jsonb; base_version integer; context_version integer;
724
+ owner text:=CASE WHEN p_subject IS NULL THEN 'workspace:'||p_workspace ELSE 'personal:'||p_subject END;
725
+ BEGIN
726
+ SELECT settings,version INTO base,base_version FROM agent_learning_revisions
727
+ WHERE account_id=p_account AND owner_key=owner AND context_key='defaults' AND created_at<=p_at
728
+ ORDER BY version DESC LIMIT 1;
729
+ base:=coalesce(base,'{"knowledge":"automatic","instructions":"review_first","skills":"review_first"}'::jsonb);
730
+ SELECT settings,version INTO overrides,context_version FROM agent_learning_revisions
731
+ WHERE account_id=p_account AND owner_key=owner AND context_key=p_context AND created_at<=p_at
732
+ ORDER BY version DESC LIMIT 1;
733
+ RETURN jsonb_build_object('ownerKey',owner,'defaultsVersion',coalesce(base_version,0),
734
+ 'contextKey',p_context,'contextVersion',coalesce(context_version,0),
735
+ 'defaults',base,'overrides',coalesce(overrides,'{}'::jsonb),'effective',base||coalesce(overrides,'{}'::jsonb));
736
+ END $$;
737
+
738
+ -- Backfill only policy, not authority. Existing explicit Memory opt-outs and
739
+ -- learning modes become category defaults. Per-record source exceptions remain
740
+ -- historical proof for retired proposal writers, not new task/chat policy.
741
+ ALTER TABLE agent_learning_revisions NO FORCE ROW LEVEL SECURITY;
742
+ ALTER TABLE workspace_learning_policy_heads NO FORCE ROW LEVEL SECURITY;
743
+ ALTER TABLE workspace_learning_policy_revisions NO FORCE ROW LEVEL SECURITY;
744
+ INSERT INTO agent_learning_revisions(account_id,origin_workspace_id,owner_key,version,settings,
745
+ operation_id,request_hash,actor_subject_id,created_at)
746
+ SELECT w.account_id,w.id,'workspace:'||w.id,1,
747
+ jsonb_build_object('knowledge',CASE WHEN w.settings->>'memoryEnabled'='false' THEN 'off' ELSE 'automatic' END,
748
+ 'instructions',CASE WHEN coalesce(r.workspace_mode,'suggest')='suggest' THEN 'review_first' ELSE r.workspace_mode END,
749
+ 'skills',CASE WHEN coalesce(r.workspace_mode,'suggest')='suggest' THEN 'review_first' ELSE r.workspace_mode END),
750
+ gen_random_uuid(),'migration:0461','service:knowledge-migration:0461','epoch'::timestamptz
751
+ FROM workspaces w LEFT JOIN workspace_learning_policy_heads h ON h.workspace_id=w.id AND h.account_id=w.account_id
752
+ LEFT JOIN workspace_learning_policy_revisions r ON r.id=h.revision_id AND r.account_id=w.account_id;
753
+ -- The Personal workspace's explicit settings become its owner's defaults.
754
+ -- Existing private chats keep a workspace-level opt-out as a sparse chat override.
755
+ ALTER TABLE organization_memberships NO FORCE ROW LEVEL SECURITY;
756
+ ALTER TABLE sessions NO FORCE ROW LEVEL SECURITY;
757
+ INSERT INTO agent_learning_revisions(account_id,origin_workspace_id,owner_key,subject_id,version,settings,
758
+ operation_id,request_hash,actor_subject_id,created_at)
759
+ SELECT m.account_id,w.id,'personal:'||m.subject_id,m.subject_id,1,r.settings,
760
+ gen_random_uuid(),'migration:0461:personal','service:knowledge-migration:0461','epoch'::timestamptz
761
+ FROM organization_memberships m JOIN workspaces w ON w.account_id=m.account_id AND w.id=m.personal_workspace_id
762
+ JOIN agent_learning_revisions r ON r.account_id=m.account_id AND r.owner_key='workspace:'||w.id AND r.context_key='defaults';
763
+ INSERT INTO agent_learning_revisions(account_id,origin_workspace_id,owner_key,subject_id,context_key,version,settings,
764
+ operation_id,request_hash,actor_subject_id,created_at)
765
+ SELECT s.account_id,s.workspace_id,
766
+ CASE WHEN (s.visibility='user_private' OR s.memory_scope='user' OR pm.id IS NOT NULL) AND coalesce(s.owner_subject_id,s.scope_subject_id,pm.subject_id) IS NOT NULL
767
+ THEN 'personal:'||coalesce(s.owner_subject_id,s.scope_subject_id,pm.subject_id) ELSE 'workspace:'||s.workspace_id END,
768
+ CASE WHEN s.visibility='user_private' OR s.memory_scope='user' OR pm.id IS NOT NULL THEN coalesce(s.owner_subject_id,s.scope_subject_id,pm.subject_id) END,
769
+ 'chat:'||s.id,1,'{"knowledge":"off"}',gen_random_uuid(),'migration:0461:chat','service:knowledge-migration:0461','epoch'::timestamptz
770
+ FROM sessions s JOIN workspaces w ON w.account_id=s.account_id AND w.id=s.workspace_id
771
+ LEFT JOIN organization_memberships pm ON pm.account_id=w.account_id AND pm.personal_workspace_id=w.id
772
+ WHERE s.memory_scope='off' OR (w.settings->>'memoryEnabled'='false'
773
+ AND (s.visibility='user_private' OR s.memory_scope='user' OR pm.id IS NOT NULL));
774
+ ALTER TABLE sessions FORCE ROW LEVEL SECURITY;
775
+ ALTER TABLE organization_memberships FORCE ROW LEVEL SECURITY;
776
+ ALTER TABLE workspace_learning_policy_revisions FORCE ROW LEVEL SECURITY;
777
+ ALTER TABLE workspace_learning_policy_heads FORCE ROW LEVEL SECURITY;
778
+ ALTER TABLE agent_learning_revisions FORCE ROW LEVEL SECURITY;
779
+
780
+ -- Resolve only immutable accepted-work lineage. A later settings edit cannot
781
+ -- alter a run already accepted, including its children and causal resumptions.
782
+ CREATE FUNCTION knowledge_learning_for_turn(p_account uuid,p_workspace uuid,p_turn uuid)
783
+ RETURNS jsonb LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
784
+ DECLARE t session_turns%ROWTYPE; s sessions%ROWTYPE; run scheduled_task_runs%ROWTYPE;
785
+ result jsonb; inherited jsonb; current_policy jsonb; subject text; destination text;
786
+ producer uuid; source_context text; accepted_at timestamptz;
787
+ BEGIN
788
+ SELECT snapshot INTO result FROM agent_learning_snapshots WHERE account_id=p_account AND turn_id=p_turn;
789
+ IF result IS NOT NULL THEN RETURN result; END IF;
790
+ SELECT * INTO t FROM session_turns WHERE account_id=p_account AND workspace_id=p_workspace AND id=p_turn;
791
+ IF NOT FOUND THEN RAISE EXCEPTION 'Learning accepted turn unavailable' USING ERRCODE='42501'; END IF;
792
+ SELECT * INTO s FROM sessions WHERE account_id=p_account AND workspace_id=p_workspace AND id=t.session_id;
793
+ IF NOT FOUND THEN RAISE EXCEPTION 'Learning session unavailable' USING ERRCODE='42501'; END IF;
794
+ subject:=coalesce(t.initiating_human_subject_id,CASE WHEN t.initiator_kind='subject' THEN t.initiator_subject_id END);
795
+ -- The accepted initiating-human field is authority; subject spelling is not.
796
+ -- Opaque authenticated host/local subjects retain the same personal layer.
797
+ destination:=CASE WHEN s.visibility='user_private' OR s.memory_scope='user'
798
+ OR get_workspace_kind(p_account,p_workspace)='personal' THEN 'personal' ELSE 'workspace' END;
799
+ IF destination='personal' AND (subject IS NULL OR (s.visibility='user_private' AND s.owner_subject_id IS DISTINCT FROM subject)) THEN
800
+ RAISE EXCEPTION 'Personal learning requires an initiating owner' USING ERRCODE='42501';
801
+ END IF;
802
+ IF destination='workspace' THEN subject:=NULL; END IF;
803
+ source_context:='chat:'||s.id; accepted_at:=t.created_at;
804
+ IF t.scheduled_task_run_id IS NOT NULL THEN
805
+ SELECT * INTO run FROM scheduled_task_runs WHERE id=t.scheduled_task_run_id AND account_id=p_account AND workspace_id=p_workspace;
806
+ IF NOT FOUND THEN RAISE EXCEPTION 'Learning scheduled run unavailable' USING ERRCODE='42501'; END IF;
807
+ source_context:='scheduled_task:'||run.task_id; accepted_at:=run.created_at;
808
+ ELSE
809
+ IF t.source IN ('goal','system') THEN
810
+ SELECT prior.id INTO producer FROM session_system_updates u
811
+ JOIN session_turns prior ON prior.id=CASE WHEN u.kind IN ('goal_continuation','background_command_result','session_wait_timeout')
812
+ THEN (u.lineage->>'causalTurnId')::uuid ELSE (u.lineage->>'parentTurnId')::uuid END
813
+ AND prior.account_id=p_account AND prior.workspace_id=p_workspace AND prior.session_id=s.id
814
+ WHERE u.account_id=p_account AND u.workspace_id=p_workspace AND u.session_id=s.id
815
+ AND u.delivered_turn_id=t.id AND u.state='delivered' AND u.delivered_history_item_id IS NOT NULL
816
+ AND u.kind IN ('goal_continuation','background_command_result','session_wait_timeout','child_terminal_result','child_requires_action','child_requires_action_resolved',
817
+ 'child_paused','child_waiting_capacity','child_progress')
818
+ AND prior.created_at<t.created_at
819
+ ORDER BY prior.position DESC LIMIT 1;
820
+ ELSIF s.parent_turn_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM session_turns other
821
+ WHERE other.account_id=p_account AND other.session_id=s.id AND other.position<t.position) THEN
822
+ SELECT prior.id INTO producer FROM session_turns prior JOIN sessions parent ON parent.id=prior.session_id
823
+ AND parent.account_id=prior.account_id AND parent.workspace_id=prior.workspace_id
824
+ WHERE prior.account_id=p_account AND prior.workspace_id=p_workspace AND prior.id=s.parent_turn_id
825
+ AND prior.session_id=s.parent_session_id AND prior.created_at<t.created_at AND parent.visibility=s.visibility
826
+ AND prior.initiating_human_subject_id IS NOT DISTINCT FROM t.initiating_human_subject_id;
827
+ END IF;
828
+ END IF;
829
+ current_policy:=knowledge_learning_resolve(p_account,p_workspace,subject,source_context,accepted_at);
830
+ -- Old clients may still create a session with memoryScope=off. Preserve that
831
+ -- accepted opt-out as a sparse context setting, rather than silently enabling
832
+ -- writes after cutover. A later explicit context revision (including an empty
833
+ -- Use-default reset) owns future turns. Never backfill over such a revision.
834
+ IF s.memory_scope='off' AND (current_policy->>'contextVersion')::integer=0 THEN
835
+ INSERT INTO agent_learning_revisions(account_id,origin_workspace_id,owner_key,subject_id,context_key,
836
+ version,settings,operation_id,request_hash,actor_subject_id,created_at)
837
+ SELECT p_account,p_workspace,current_policy->>'ownerKey',subject,source_context,1,'{"knowledge":"off"}'::jsonb,
838
+ gen_random_uuid(),'legacy-session-off:'||s.id,'service:knowledge-compatibility',accepted_at
839
+ WHERE NOT EXISTS(SELECT 1 FROM agent_learning_revisions r WHERE r.account_id=p_account
840
+ AND r.owner_key=current_policy->>'ownerKey' AND r.context_key=source_context)
841
+ ON CONFLICT DO NOTHING;
842
+ current_policy:=knowledge_learning_resolve(p_account,p_workspace,subject,source_context,accepted_at);
843
+ -- A setting can have been edited after this work was accepted. It must not
844
+ -- alter the accepted opt-out, even when that later row prevented the seed.
845
+ current_policy:=jsonb_set(jsonb_set(current_policy,'{effective,knowledge}','"off"'::jsonb),
846
+ '{overrides}',(current_policy->'overrides')||'{"knowledge":"off"}'::jsonb);
847
+ END IF;
848
+ IF producer IS NOT NULL THEN
849
+ inherited:=knowledge_learning_for_turn(p_account,p_workspace,producer);
850
+ -- A child may deliberately narrow workspace learning to personal or Off.
851
+ -- Keep its independently resolved owner and destination while inheriting
852
+ -- only accepted policy modes and scheduled-run provenance. An explicit
853
+ -- child/chat override still wins.
854
+ result:=current_policy||jsonb_build_object('effective',(inherited->'effective')||(current_policy->'overrides'),
855
+ 'producerTurnId',producer,'scheduledTaskRunId',inherited->'scheduledTaskRunId');
856
+ ELSE
857
+ result:=current_policy||jsonb_build_object('scheduledTaskRunId',t.scheduled_task_run_id);
858
+ END IF;
859
+ result:=result||jsonb_build_object('defaultScope',destination,'subjectId',subject,'acceptedAt',accepted_at);
860
+ INSERT INTO agent_learning_snapshots(account_id,workspace_id,session_id,turn_id,snapshot)
861
+ VALUES(p_account,p_workspace,s.id,t.id,result) ON CONFLICT(account_id,turn_id) DO NOTHING;
862
+ SELECT snapshot INTO result FROM agent_learning_snapshots WHERE account_id=p_account AND turn_id=t.id;
863
+ RETURN result;
864
+ END $$;
865
+
866
+ -- Internal authority resolver. HTTP principals are already authenticated and
867
+ -- permission checked by core; an agent receives only a host-bound attempt tuple.
868
+ -- Never expose this function as a runtime capability or accept its actor JSON
869
+ -- from a model/client request. Lock order matches the session lifecycle.
870
+ CREATE FUNCTION knowledge_resolve_actor(p_account uuid,p_workspace uuid,p_actor jsonb)
871
+ RETURNS jsonb LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
872
+ DECLARE prepared knowledge_entries%ROWTYPE; s sessions%ROWTYPE; t session_turns%ROWTYPE; a session_turn_attempts%ROWTYPE;
873
+ subject text; destination text; policy jsonb; run scheduled_task_runs%ROWTYPE;
874
+ context_key text; resolved_actor jsonb;
875
+ BEGIN
876
+ IF p_account IS DISTINCT FROM nullif(current_setting('opengeni.account_id',true),'')::uuid
877
+ OR p_workspace IS DISTINCT FROM nullif(current_setting('opengeni.workspace_id',true),'')::uuid THEN
878
+ RAISE EXCEPTION 'Knowledge requires exact workspace authority' USING ERRCODE='42501';
879
+ END IF;
880
+ PERFORM 1 FROM workspaces WHERE id=p_workspace AND account_id=p_account FOR KEY SHARE;
881
+ IF NOT FOUND THEN RAISE EXCEPTION 'Knowledge workspace unavailable' USING ERRCODE='42501'; END IF;
882
+ IF p_actor->>'kind'='source_preparation' THEN
883
+ SELECT * INTO prepared FROM knowledge_entries e WHERE e.account_id=p_account
884
+ AND e.id=(p_actor->>'entryId')::uuid AND e.origin_workspace_id=p_workspace FOR SHARE;
885
+ IF NOT FOUND OR prepared.legacy_document_id IS NULL
886
+ OR prepared.document_preparation ? 'completion'
887
+ OR prepared.document_preparation->>'leaseId' IS DISTINCT FROM p_actor->>'leaseId'
888
+ OR (prepared.document_preparation->>'leaseUntil')::timestamptz<=clock_timestamp() THEN
889
+ RAISE EXCEPTION 'Document preparation lease unavailable' USING ERRCODE='42501'; END IF;
890
+ PERFORM set_config('opengeni.subject_id',coalesce(prepared.scope_subject_id,''),true);
891
+ PERFORM set_config('opengeni.private_file_owner',coalesce(prepared.scope_subject_id,''),true);
892
+ PERFORM set_config('opengeni.knowledge_default_scope',prepared.scope,true);
893
+ PERFORM set_config('opengeni.knowledge_actor_kind','source_preparation',true);
894
+ PERFORM set_config('opengeni.knowledge_prepared_document',prepared.legacy_document_id::text,true);
895
+ RETURN jsonb_build_object('kind','source_preparation','entryId',prepared.id,'defaultScope',prepared.scope,
896
+ 'subjectId',prepared.scope_subject_id,'review',false,'policy',prepared.document_preparation->'policy',
897
+ 'scheduledTaskRunId',prepared.document_preparation->'scheduledTaskRunId');
898
+ END IF;
899
+ IF p_actor->>'kind'='service' THEN
900
+ IF p_actor->>'principalKind' NOT IN ('service','api_key','configured_key','mcp_gateway')
901
+ OR p_actor->>'principalKind' IS DISTINCT FROM nullif(current_setting('opengeni.principal_kind',true),'')
902
+ OR p_actor->>'subjectId' IS DISTINCT FROM nullif(current_setting('opengeni.subject_id',true),'') THEN
903
+ RAISE EXCEPTION 'Knowledge service authority unavailable' USING ERRCODE='42501'; END IF;
904
+ PERFORM set_config('opengeni.subject_id','',true);
905
+ PERFORM set_config('opengeni.knowledge_actor_kind','service',true);
906
+ PERFORM set_config('opengeni.private_file_owner','',true);
907
+ RETURN jsonb_build_object('kind','service','defaultScope','workspace','review',false,
908
+ 'writeScopes',CASE WHEN p_actor->>'principalKind'='mcp_gateway' THEN '[]'::jsonb
909
+ ELSE coalesce(p_actor->'writeScopes','[]'::jsonb)-'personal' END);
910
+ END IF;
911
+ IF p_actor->>'kind'='human' THEN
912
+ subject:=p_actor->>'subjectId';
913
+ IF subject IS NULL OR length(subject) NOT BETWEEN 1 AND 1024
914
+ OR subject IS DISTINCT FROM nullif(current_setting('opengeni.subject_id',true),'')
915
+ OR p_actor->>'principalKind' IS DISTINCT FROM 'human_session'
916
+ OR nullif(current_setting('opengeni.principal_kind',true),'') IS DISTINCT FROM 'human_session' THEN
917
+ RAISE EXCEPTION 'Knowledge human authority unavailable' USING ERRCODE='42501';
918
+ END IF;
919
+ PERFORM set_config('opengeni.knowledge_actor_kind','human',true);
920
+ PERFORM set_config('opengeni.private_file_owner',subject,true);
921
+ RETURN jsonb_build_object('kind','human','subjectId',subject,'defaultScope','workspace',
922
+ 'writeScopes',coalesce(p_actor->'writeScopes','[]'::jsonb),'review',coalesce((p_actor->>'review')::boolean,false));
923
+ END IF;
924
+ IF p_actor->>'kind' IS DISTINCT FROM 'agent' THEN
925
+ RAISE EXCEPTION 'Knowledge requires a human or exact agent attempt' USING ERRCODE='42501';
926
+ END IF;
927
+ SELECT * INTO s FROM sessions WHERE id=(p_actor->>'sessionId')::uuid
928
+ AND workspace_id=p_workspace AND account_id=p_account FOR SHARE;
929
+ IF NOT FOUND OR s.active_turn_id IS DISTINCT FROM (p_actor->>'turnId')::uuid THEN
930
+ RAISE EXCEPTION 'Knowledge session attempt is stale' USING ERRCODE='42501';
931
+ END IF;
932
+ SELECT * INTO t FROM session_turns WHERE id=s.active_turn_id AND session_id=s.id
933
+ AND workspace_id=p_workspace AND account_id=p_account FOR SHARE;
934
+ IF NOT FOUND OR t.active_attempt_id IS DISTINCT FROM (p_actor->>'attemptId')::uuid
935
+ OR t.execution_generation IS DISTINCT FROM (p_actor->>'executionGeneration')::integer
936
+ OR t.status NOT IN ('running','requires_action','recovering','waiting_capacity') THEN
937
+ RAISE EXCEPTION 'Knowledge turn attempt is stale' USING ERRCODE='42501';
938
+ END IF;
939
+ SELECT * INTO a FROM session_turn_attempts WHERE id=t.active_attempt_id AND turn_id=t.id
940
+ AND session_id=s.id AND account_id=p_account AND workspace_id=p_workspace FOR SHARE;
941
+ IF NOT FOUND OR a.state NOT IN ('claimed','running') OR a.execution_generation<>t.execution_generation THEN
942
+ RAISE EXCEPTION 'Knowledge attempt is stale' USING ERRCODE='42501';
943
+ END IF;
944
+ -- Fresh read after locks: interruption writers acquire the attempt FOR UPDATE.
945
+ IF EXISTS(SELECT 1 FROM session_attempt_interruptions WHERE workspace_id=p_workspace
946
+ AND attempt_id=a.id AND state IN ('pending','delivered','acknowledged')) THEN
947
+ RAISE EXCEPTION 'Knowledge attempt is interrupted' USING ERRCODE='42501';
948
+ END IF;
949
+ subject:=coalesce(t.initiating_human_subject_id,CASE WHEN t.initiator_kind='subject' THEN t.initiator_subject_id END);
950
+ -- The accepted initiating-human field is authority; subject spelling is not.
951
+ -- Opaque authenticated host/local subjects retain the same personal layer.
952
+ destination:=CASE WHEN s.visibility='user_private' OR s.memory_scope='user'
953
+ OR get_workspace_kind(p_account,p_workspace)='personal'
954
+ THEN 'personal' ELSE 'workspace' END;
955
+ IF destination='personal' AND (subject IS NULL
956
+ OR (s.visibility='user_private' AND s.owner_subject_id IS DISTINCT FROM subject)) THEN
957
+ RAISE EXCEPTION 'Personal Knowledge requires the verified initiating owner' USING ERRCODE='42501';
958
+ END IF;
959
+ -- Keep the verified subject for provider ACL checks. The independent Knowledge
960
+ -- scope selector prevents a shared agent from ambient-reading personal entries.
961
+ PERFORM set_config('opengeni.subject_id',coalesce(subject,''),true);
962
+ PERFORM set_config('opengeni.knowledge_default_scope',destination,true);
963
+ PERFORM set_config('opengeni.knowledge_actor_kind','agent',true);
964
+ PERFORM set_config('opengeni.private_file_owner',CASE WHEN destination='personal' THEN coalesce(subject,'') ELSE '' END,true);
965
+ PERFORM set_config('opengeni.knowledge_session_id',s.id::text,true);
966
+ PERFORM set_config('opengeni.knowledge_attempt_id',a.id::text,true);
967
+ PERFORM set_config('opengeni.memory_session_id',s.id::text,true);
968
+ PERFORM set_config('opengeni.memory_role_key',coalesce(s.metadata->>'memoryRoleKey',''),true);
969
+ policy:=knowledge_learning_for_turn(p_account,p_workspace,t.id);
970
+ IF policy->>'defaultScope' IS DISTINCT FROM destination OR (destination='personal' AND policy->>'subjectId' IS DISTINCT FROM subject) THEN
971
+ RAISE EXCEPTION 'Learning scope changed after acceptance' USING ERRCODE='42501';
972
+ END IF;
973
+ resolved_actor:=jsonb_build_object('kind','agent','subjectId',subject,'defaultScope',destination,
974
+ 'sessionId',s.id,'turnId',t.id,'attemptId',a.id,'executionGeneration',a.execution_generation,
975
+ 'scheduledTaskRunId',policy->'scheduledTaskRunId','retentionScope',CASE WHEN s.memory_scope='session' THEN 'session' ELSE 'normal' END,'policy',policy);
976
+ RETURN resolved_actor;
977
+ END $$;
978
+
979
+ -- Read one exact active note through its existing task-tree authority. The
980
+ -- temporary read capability is removed before returning retained source bytes.
981
+ CREATE FUNCTION knowledge_task_note_source(p_account uuid,p_workspace uuid,p_actor jsonb,p_note uuid,p_version integer)
982
+ RETURNS jsonb LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
983
+ DECLARE authority record; note task_notes%ROWTYPE; capability uuid:=gen_random_uuid();
984
+ previous text:=current_setting('opengeni.task_note_write_capability',true);
985
+ BEGIN
986
+ IF p_actor->>'kind'<>'agent' OR p_version IS DISTINCT FROM 1 THEN
987
+ RAISE EXCEPTION 'Task-note promotion requires an exact agent and note version' USING ERRCODE='42501'; END IF;
988
+ SELECT * INTO STRICT authority FROM resolve_task_note_attempt_authority(p_account,p_workspace,
989
+ (p_actor->>'sessionId')::uuid,(p_actor->>'turnId')::uuid,(p_actor->>'attemptId')::uuid,(p_actor->>'executionGeneration')::integer);
990
+ INSERT INTO task_note_write_capabilities(backend_pid,transaction_id,capability_id)
991
+ VALUES(pg_backend_pid(),pg_current_xact_id(),capability);
992
+ PERFORM set_config('opengeni.task_note_write_capability',capability::text,true);
993
+ SELECT * INTO note FROM task_notes n WHERE n.account_id=p_account AND n.workspace_id=p_workspace
994
+ AND n.root_session_id=authority.root_session_id AND n.id=p_note AND n.version=p_version
995
+ AND n.status='active' AND n.expires_at>statement_timestamp() FOR SHARE;
996
+ DELETE FROM task_note_write_capabilities c WHERE c.backend_pid=pg_backend_pid() AND c.transaction_id=pg_current_xact_id()
997
+ AND c.capability_id=capability;
998
+ PERFORM set_config('opengeni.task_note_write_capability',coalesce(previous,''),true);
999
+ IF note.id IS NULL THEN RAISE EXCEPTION 'Task note is expired, archived or outside this task tree' USING ERRCODE='42501'; END IF;
1000
+ RETURN jsonb_build_object('content',note.text,'source',jsonb_build_object('kind','task_note','noteId',note.id,
1001
+ 'sessionId',note.created_by_session_id,'version',note.version::text));
1002
+ EXCEPTION WHEN OTHERS THEN
1003
+ PERFORM set_config('opengeni.task_note_write_capability',coalesce(previous,''),true);
1004
+ RAISE;
1005
+ END $$;
1006
+
1007
+ CREATE FUNCTION knowledge_entry_apply(p_account uuid,p_workspace uuid,p_actor jsonb,p_request jsonb)
1008
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
1009
+ <<knowledge_entry_apply>>
1010
+ DECLARE actor jsonb; e knowledge_entries%ROWTYPE; r knowledge_entry_revisions%ROWTYPE;
1011
+ prior knowledge_entry_operations%ROWTYPE; body jsonb; codec integer; scope_value text;
1012
+ operation text:=p_request->>'operation'; operation_id uuid:=(p_request->>'operationId')::uuid;
1013
+ entry_id uuid:=(p_request->>'entryId')::uuid; revision_id uuid; old_revision uuid;
1014
+ batch_id uuid; owner_key text; disposition text; fingerprint text; result jsonb; next_number integer;
1015
+ mode text:='automatic'; restored_id uuid; search_text text;
1016
+ BEGIN
1017
+ IF operation IS NULL OR operation NOT IN ('save','promote_note','approve','approve_edit','reject','restore','archive')
1018
+ OR operation_id IS NULL OR entry_id IS NULL OR (p_request->>'expectedVersion')::integer<0 THEN
1019
+ RAISE EXCEPTION 'Invalid Knowledge operation' USING ERRCODE='22023';
1020
+ END IF;
1021
+ actor:=knowledge_resolve_actor(p_account,p_workspace,p_actor);
1022
+ IF actor->>'kind'='agent' AND operation IN ('approve','approve_edit','reject') THEN
1023
+ RAISE EXCEPTION 'Agents cannot approve their own Knowledge' USING ERRCODE='42501';
1024
+ END IF;
1025
+ IF operation IN ('approve','approve_edit','reject') AND actor->>'review' IS DISTINCT FROM 'true' THEN
1026
+ RAISE EXCEPTION 'Knowledge review permission required' USING ERRCODE='42501';
1027
+ END IF;
1028
+ -- Serialize publications and reference validation within an organization.
1029
+ -- No organization row is locked after a workspace: this key has no FK path.
1030
+ PERFORM pg_advisory_xact_lock(hashtextextended('knowledge-publication:'||p_account,0));
1031
+ -- Authenticate the live attempt above, but bind retries to logical work.
1032
+ -- Recovery changes the physical attempt, never the operation's session/turn.
1033
+ fingerprint:=encode(sha256(convert_to(jsonb_build_array(
1034
+ CASE WHEN p_actor->>'kind'='agent' THEN p_actor-'attemptId'-'executionGeneration' ELSE p_actor END,
1035
+ p_request)::text,'UTF8')),'hex');
1036
+ SELECT * INTO prior FROM knowledge_entry_operations o WHERE o.account_id=p_account AND o.operation_id=knowledge_entry_apply.operation_id;
1037
+ IF FOUND THEN
1038
+ IF prior.request_hash<>fingerprint OR prior.origin_workspace_id<>p_workspace THEN
1039
+ RAISE EXCEPTION 'Knowledge operation key reused' USING ERRCODE='23505';
1040
+ END IF;
1041
+ SELECT * INTO e FROM knowledge_entries h WHERE h.account_id=p_account AND h.id=prior.entry_id;
1042
+ IF NOT knowledge_scope_visible(e) THEN RAISE EXCEPTION 'Knowledge unavailable' USING ERRCODE='42501'; END IF;
1043
+ RETURN prior.receipt||jsonb_build_object('replayed',true);
1044
+ END IF;
1045
+ SELECT * INTO e FROM knowledge_entries h WHERE h.account_id=p_account AND h.id=entry_id FOR UPDATE;
1046
+ scope_value:=coalesce(e.scope,p_request->>'scope',actor->>'defaultScope');
1047
+ IF e.id IS NOT NULL AND (NOT knowledge_scope_visible(e)
1048
+ OR (p_request ? 'scope' AND p_request->>'scope'<>e.scope)) THEN
1049
+ RAISE EXCEPTION 'Knowledge scope is unavailable or immutable' USING ERRCODE='42501';
1050
+ END IF;
1051
+ IF actor->>'kind' IN ('agent','source_preparation') THEN
1052
+ IF actor->>'kind'='source_preparation' AND (operation<>'save' OR entry_id IS DISTINCT FROM (actor->>'entryId')::uuid
1053
+ OR p_request#>>'{entry,kind}' IS DISTINCT FROM 'source' OR p_request#>>'{entry,source,documentId}' IS DISTINCT FROM e.legacy_document_id::text) THEN
1054
+ RAISE EXCEPTION 'Source preparation is limited to its retained original' USING ERRCODE='42501'; END IF;
1055
+ IF scope_value IS DISTINCT FROM actor->>'defaultScope' OR (actor->>'retentionScope'='session' AND e.id IS NOT NULL
1056
+ AND (e.legacy_scope_type IS DISTINCT FROM 'session' OR e.legacy_scope_session_id IS DISTINCT FROM (actor->>'sessionId')::uuid)) THEN
1057
+ RAISE EXCEPTION 'Agent Knowledge stays in its accepted scope' USING ERRCODE='42501';
1058
+ END IF;
1059
+ mode:=actor#>>'{policy,effective,knowledge}';
1060
+ IF mode IS NULL OR mode NOT IN ('automatic','review_first') THEN
1061
+ RAISE EXCEPTION 'Knowledge learning is Off for this task' USING ERRCODE='42501';
1062
+ END IF;
1063
+ ELSIF NOT coalesce((actor->'writeScopes') ? scope_value,false) THEN
1064
+ RAISE EXCEPTION 'Knowledge write permission required for this scope' USING ERRCODE='42501';
1065
+ END IF;
1066
+ IF e.id IS NULL THEN
1067
+ IF operation NOT IN ('save','promote_note') OR (p_request->>'expectedVersion')::integer IS DISTINCT FROM 0 THEN
1068
+ RAISE EXCEPTION 'Knowledge entry changed or unavailable' USING ERRCODE='40001';
1069
+ END IF;
1070
+ INSERT INTO knowledge_entries(id,account_id,origin_workspace_id,scope,scope_workspace_id,scope_subject_id,legacy_scope_type,legacy_scope_session_id)
1071
+ VALUES(entry_id,p_account,p_workspace,scope_value,CASE WHEN scope_value='workspace' THEN p_workspace END,
1072
+ CASE WHEN scope_value='personal' THEN actor->>'subjectId' END,
1073
+ CASE WHEN actor->>'retentionScope'='session' THEN 'session' END,
1074
+ CASE WHEN actor->>'retentionScope'='session' THEN (actor->>'sessionId')::uuid END) RETURNING * INTO e;
1075
+ ELSIF e.version IS DISTINCT FROM (p_request->>'expectedVersion')::integer THEN
1076
+ RAISE EXCEPTION 'Knowledge entry changed; read its current revision' USING ERRCODE='40001';
1077
+ END IF;
1078
+ IF e.archived AND operation<>'restore' THEN
1079
+ RAISE EXCEPTION 'Restore archived Knowledge before editing it' USING ERRCODE='22023';
1080
+ END IF;
1081
+ old_revision:=e.published_revision_id;
1082
+ disposition:=CASE WHEN mode='review_first' THEN 'pending' ELSE 'published' END;
1083
+ IF operation IN ('approve','approve_edit','reject','restore','archive') THEN
1084
+ SELECT * INTO r FROM knowledge_entry_revisions v WHERE v.account_id=p_account AND v.entry_id=e.id
1085
+ AND v.id=CASE WHEN operation='archive' THEN e.latest_revision_id ELSE (p_request->>'revisionId')::uuid END;
1086
+ IF NOT FOUND THEN RAISE EXCEPTION 'Knowledge revision unavailable' USING ERRCODE='42501'; END IF;
1087
+ IF operation IN ('approve','approve_edit','reject') AND (r.id IS DISTINCT FROM e.latest_revision_id
1088
+ OR NOT EXISTS(SELECT 1 FROM knowledge_entry_decisions d WHERE d.account_id=p_account AND d.entry_id=e.id
1089
+ AND d.revision_id=r.id AND d.version=e.version AND d.outcome='pending')) THEN
1090
+ RAISE EXCEPTION 'This Knowledge review is no longer current' USING ERRCODE='40001';
1091
+ END IF;
1092
+ body:=r.body; codec:=r.body_codec_version;
1093
+ revision_id:=r.id; batch_id:=r.review_batch_id;
1094
+ IF operation IN ('restore','archive') THEN restored_id:=r.id; END IF;
1095
+ IF operation='approve_edit' THEN
1096
+ IF r.change_kind='archive' THEN RAISE EXCEPTION 'An archive proposal has no editable content' USING ERRCODE='22023'; END IF;
1097
+ body:=p_request->'entry'; codec:=(p_request->>'codecVersion')::integer;
1098
+ END IF;
1099
+ ELSIF operation='save' THEN
1100
+ body:=p_request->'entry'; codec:=(p_request->>'codecVersion')::integer;
1101
+ ELSIF operation='promote_note' THEN
1102
+ body:=knowledge_task_note_source(p_account,p_workspace,p_actor,(p_request->>'noteId')::uuid,
1103
+ (p_request->>'expectedNoteVersion')::integer)||jsonb_build_object('kind','note','title',p_request->>'title',
1104
+ 'evidence','[]'::jsonb,'relationships','[]'::jsonb,'groupIds',p_request->'groupIds');
1105
+ codec:=NULL;
1106
+ END IF;
1107
+
1108
+ IF operation IN ('save','promote_note','approve_edit','restore','archive') THEN
1109
+ IF jsonb_typeof(body) IS DISTINCT FROM 'object' OR jsonb_typeof(body->'title') IS DISTINCT FROM 'string'
1110
+ OR jsonb_typeof(body->'content') IS DISTINCT FROM 'string'
1111
+ OR body->>'kind' NOT IN ('source','fact','decision','requirement','incident','note','group')
1112
+ OR body->>'kind' IS NULL OR length(body->>'title') NOT BETWEEN 1 AND 8192
1113
+ OR (body->>'kind' NOT IN ('group','source') AND length(btrim(body->>'content'))=0)
1114
+ OR (body->>'kind'='source' AND jsonb_typeof(body->'source') IS DISTINCT FROM 'object')
1115
+ OR jsonb_typeof(body->'evidence') IS DISTINCT FROM 'array' OR (operation IN ('save','promote_note','approve_edit') AND jsonb_array_length(body->'evidence')>256)
1116
+ OR jsonb_typeof(body->'groupIds') IS DISTINCT FROM 'array' OR (operation IN ('save','promote_note','approve_edit') AND jsonb_array_length(body->'groupIds')>256)
1117
+ OR jsonb_typeof(body->'relationships') IS DISTINCT FROM 'array' OR (operation IN ('save','promote_note','approve_edit') AND jsonb_array_length(body->'relationships')>256)
1118
+ OR (codec IS NOT NULL AND codec<>1) THEN
1119
+ RAISE EXCEPTION 'Invalid Knowledge entry content' USING ERRCODE='22023';
1120
+ END IF;
1121
+ IF EXISTS(SELECT 1 FROM knowledge_entry_revisions v WHERE v.account_id=p_account AND v.id=e.latest_revision_id
1122
+ AND (v.body->>'kind'='group')<>(knowledge_entry_apply.body->>'kind'='group')) THEN
1123
+ RAISE EXCEPTION 'A Knowledge group cannot become a content entry' USING ERRCODE='22023';
1124
+ END IF;
1125
+ IF disposition='pending' THEN
1126
+ owner_key:=CASE scope_value WHEN 'personal' THEN 'personal:'||e.scope_subject_id
1127
+ WHEN 'organization' THEN 'organization:'||p_account ELSE 'workspace:'||e.scope_workspace_id END;
1128
+ SELECT id INTO batch_id FROM knowledge_review_batches b WHERE b.account_id=p_account AND b.owner_key=knowledge_entry_apply.owner_key
1129
+ AND ((actor->>'scheduledTaskRunId' IS NOT NULL AND b.scheduled_task_run_id=(actor->>'scheduledTaskRunId')::uuid)
1130
+ OR (actor->>'scheduledTaskRunId' IS NULL AND b.turn_id=(actor->>'turnId')::uuid));
1131
+ IF batch_id IS NULL THEN
1132
+ INSERT INTO knowledge_review_batches(account_id,origin_workspace_id,owner_key,session_id,turn_id,scheduled_task_run_id)
1133
+ VALUES(p_account,p_workspace,owner_key,(actor->>'sessionId')::uuid,(actor->>'turnId')::uuid,
1134
+ (actor->>'scheduledTaskRunId')::uuid) RETURNING id INTO batch_id;
1135
+ END IF;
1136
+ ELSE batch_id:=NULL; END IF;
1137
+ IF operation<>'archive' THEN
1138
+ PERFORM knowledge_validate_source(p_account,e.id,body);
1139
+ PERFORM knowledge_validate_links(p_account,e.id,body,batch_id);
1140
+ END IF;
1141
+ SELECT coalesce(max(v.number),0)+1 INTO next_number FROM knowledge_entry_revisions v WHERE v.account_id=p_account AND v.entry_id=e.id;
1142
+ INSERT INTO knowledge_entry_revisions(account_id,entry_id,number,body,body_codec_version,preview,preview_codec_version,
1143
+ previous_revision_id,restored_from_revision_id,actor,created_by_session_id,created_by_turn_id,review_batch_id,change_kind)
1144
+ VALUES(p_account,e.id,next_number,body,codec,coalesce(p_request->>'preview',r.preview,substring(body->>'content' FROM 1 FOR 512)),
1145
+ CASE WHEN p_request ? 'preview' THEN 1 ELSE r.preview_codec_version END,e.latest_revision_id,restored_id,p_actor,
1146
+ (actor->>'sessionId')::uuid,(actor->>'turnId')::uuid,batch_id,
1147
+ CASE WHEN operation='archive' THEN 'archive' ELSE 'upsert' END) RETURNING id INTO revision_id;
1148
+ INSERT INTO knowledge_entry_links(account_id,entry_id,revision_id,ordinal,target_entry_id,target_revision_id,relation)
1149
+ SELECT p_account,e.id,revision_id,(row_number() OVER ())::integer-1,links.target,links.rev,links.relation
1150
+ FROM (
1151
+ SELECT (x->>'entryId')::uuid AS target,(x->>'revisionId')::uuid AS rev,'evidence' AS relation
1152
+ FROM jsonb_array_elements(body->'evidence') x
1153
+ UNION ALL SELECT x::uuid,NULL,'group' FROM jsonb_array_elements_text(body->'groupIds') x
1154
+ UNION ALL SELECT (x->>'entryId')::uuid,NULL,x->>'relation' FROM jsonb_array_elements(body->'relationships') x
1155
+ ) links;
1156
+ IF operation='archive' AND disposition='published' THEN disposition:='archived'; END IF;
1157
+ ELSIF operation='approve' THEN
1158
+ IF r.change_kind='archive' THEN disposition:='archived';
1159
+ ELSE
1160
+ PERFORM knowledge_validate_source(p_account,e.id,body);
1161
+ PERFORM knowledge_validate_links(p_account,e.id,body,NULL);
1162
+ disposition:='published';
1163
+ END IF;
1164
+ ELSIF operation='reject' THEN disposition:='rejected';
1165
+ END IF;
1166
+ UPDATE knowledge_entries h SET version=e.version+1,latest_revision_id=revision_id,
1167
+ published_revision_id=CASE WHEN disposition='published' THEN revision_id ELSE h.published_revision_id END,
1168
+ archived=CASE WHEN disposition='archived' THEN true WHEN operation='restore' AND disposition='published' THEN false ELSE h.archived END,
1169
+ updated_at=clock_timestamp() WHERE h.account_id=p_account AND h.id=e.id;
1170
+ INSERT INTO knowledge_entry_decisions(account_id,entry_id,revision_id,version,outcome,actor,policy_snapshot)
1171
+ VALUES(p_account,e.id,revision_id,e.version+1,disposition,p_actor,actor->'policy');
1172
+ IF operation IN ('save','promote_note','approve_edit','restore','archive') THEN
1173
+ -- The adapter supplies a lossless-text search projection; it is not evidence.
1174
+ search_text:=coalesce(p_request->>'searchText',(body->>'title')||' '||(body->>'content'));
1175
+ IF restored_id IS NOT NULL THEN
1176
+ INSERT INTO knowledge_entry_search(account_id,entry_id,revision_id,chunk_index,search_vector)
1177
+ SELECT p_account,e.id,knowledge_entry_apply.revision_id,v.chunk_index,v.search_vector FROM knowledge_entry_search v
1178
+ WHERE v.account_id=p_account AND v.entry_id=e.id AND v.revision_id=restored_id;
1179
+ ELSE
1180
+ PERFORM knowledge_index_revision(p_account,e.id,revision_id,search_text);
1181
+ END IF;
1182
+ END IF;
1183
+ result:=jsonb_build_object('operationId',operation_id,'entryId',e.id,'revisionId',revision_id,
1184
+ 'version',e.version+1,'outcome',disposition,'reviewBatchId',batch_id,'replayed',false);
1185
+ INSERT INTO knowledge_entry_operations(account_id,origin_workspace_id,operation_id,entry_id,actor,request_hash,receipt)
1186
+ VALUES(p_account,p_workspace,operation_id,e.id,p_actor,fingerprint,result);
1187
+ RETURN result;
1188
+ END $$;
1189
+
1190
+ CREATE FUNCTION knowledge_entry_read(p_account uuid,p_workspace uuid,p_actor jsonb,p_request jsonb)
1191
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
1192
+ <<knowledge_entry_read>>
1193
+ DECLARE actor jsonb; result jsonb; pending boolean:=p_request->>'view'='needs_review';
1194
+ operation text:=coalesce(p_request->>'operation','list'); take integer:=coalesce((p_request->>'limit')::integer,20);
1195
+ query_embedding vector:=CASE WHEN jsonb_typeof(p_request->'embedding')='array' THEN (p_request->'embedding')::text::vector END;
1196
+ BEGIN
1197
+ actor:=knowledge_resolve_actor(p_account,p_workspace,p_actor);
1198
+ pending:=coalesce(pending,false);
1199
+ IF operation='policy' AND actor->>'kind'='agent' THEN RETURN actor->'policy'; END IF;
1200
+ IF take NOT BETWEEN 1 AND 50 THEN RAISE EXCEPTION 'Invalid Knowledge page size' USING ERRCODE='22023'; END IF;
1201
+ IF operation IN ('source_list','source_read') THEN
1202
+ IF actor->>'kind'<>'agent' OR actor#>>'{policy,scheduledTaskRunId}' IS NULL THEN
1203
+ RAISE EXCEPTION 'Source content requires its accepted agent run' USING ERRCODE='42501'; END IF;
1204
+ SELECT coalesce(jsonb_agg(value ORDER BY id),'[]'::jsonb) INTO result FROM (
1205
+ SELECT e.id,jsonb_build_object('entryId',e.id,'revisionId',r.id,'title',r.body->'title',
1206
+ 'codecVersion',r.body_codec_version,'pending',r.id IS DISTINCT FROM e.published_revision_id)
1207
+ || CASE WHEN operation='source_read' THEN jsonb_build_object('content',r.body->'content') ELSE '{}'::jsonb END AS value
1208
+ FROM scheduled_task_runs run
1209
+ JOIN knowledge_document_versions v ON v.account_id=run.account_id
1210
+ AND v.source_id=(run.accepted_execution_snapshot#>>'{task,agentConfig,knowledgeSource,sourceId}')::uuid
1211
+ JOIN knowledge_entries e ON e.account_id=v.account_id AND e.legacy_document_version_id=v.id
1212
+ JOIN knowledge_entry_revisions r ON r.account_id=e.account_id AND r.entry_id=e.id
1213
+ AND r.id=coalesce(CASE WHEN EXISTS(SELECT 1 FROM knowledge_review_batches b
1214
+ JOIN knowledge_entry_revisions candidate ON candidate.account_id=b.account_id AND candidate.review_batch_id=b.id
1215
+ JOIN knowledge_entry_decisions d ON d.account_id=e.account_id AND d.entry_id=e.id
1216
+ AND d.revision_id=candidate.id AND d.version=e.version AND d.outcome='pending'
1217
+ WHERE b.account_id=e.account_id AND b.scheduled_task_run_id=run.id AND candidate.id=e.latest_revision_id)
1218
+ THEN e.latest_revision_id END,e.published_revision_id)
1219
+ WHERE run.account_id=p_account AND run.workspace_id=p_workspace
1220
+ AND run.id=(knowledge_entry_read.actor#>>'{policy,scheduledTaskRunId}')::uuid
1221
+ AND run.action_kind='agent_turn' AND NOT e.archived AND knowledge_scope_visible(e)
1222
+ AND (operation='source_read' OR (r.created_at>=run.created_at
1223
+ AND e.document_preparation->>'scheduledTaskRunId'=run.id::text))
1224
+ AND knowledge_revision_visible(p_account,e.id,r.id,true)
1225
+ AND (NOT(p_request ? 'afterId') OR e.id>(p_request->>'afterId')::uuid)
1226
+ AND (operation<>'source_read' OR e.id=(p_request->>'entryId')::uuid)
1227
+ ORDER BY e.id LIMIT CASE WHEN operation='source_read' THEN 1 ELSE take+1 END
1228
+ ) page;
1229
+ RETURN result;
1230
+ END IF;
1231
+
1232
+ IF (operation='history' OR p_request->>'view'='rejected')
1233
+ AND (actor->>'kind'<>'human' OR actor->>'review' IS DISTINCT FROM 'true') THEN
1234
+ RAISE EXCEPTION 'Knowledge history requires human review access' USING ERRCODE='42501';
1235
+ END IF;
1236
+ IF pending AND actor->>'kind'<>'agent'
1237
+ AND (actor->>'kind'<>'human' OR actor->>'review' IS DISTINCT FROM 'true') THEN
1238
+ RAISE EXCEPTION 'Pending Knowledge requires a live agent or human reviewer' USING ERRCODE='42501';
1239
+ END IF;
1240
+ IF p_request->>'view'='archived' AND actor->>'kind'<>'human' THEN
1241
+ RAISE EXCEPTION 'Archived Knowledge is a human management view' USING ERRCODE='42501';
1242
+ END IF;
1243
+ IF operation='review_batches' THEN
1244
+ IF actor->>'kind'<>'human' OR actor->>'review' IS DISTINCT FROM 'true' THEN
1245
+ RAISE EXCEPTION 'Knowledge review groups require human review access' USING ERRCODE='42501'; END IF;
1246
+ WITH visible AS MATERIALIZED (
1247
+ SELECT b.id,e.scope,b.session_id,b.scheduled_task_run_id,b.created_at,e.id AS entry_id
1248
+ FROM knowledge_entries e JOIN knowledge_entry_revisions r
1249
+ ON r.account_id=e.account_id AND r.entry_id=e.id AND r.id=e.latest_revision_id
1250
+ JOIN knowledge_review_batches b ON b.account_id=e.account_id AND b.id=r.review_batch_id
1251
+ JOIN LATERAL (SELECT d.outcome FROM knowledge_entry_decisions d WHERE d.account_id=e.account_id
1252
+ AND d.entry_id=e.id AND d.revision_id=r.id ORDER BY d.version DESC LIMIT 1) decision ON true
1253
+ WHERE e.account_id=p_account AND decision.outcome='pending' AND knowledge_scope_visible(e)
1254
+ AND (knowledge_entry_read.actor->'writeScopes') ? e.scope AND knowledge_revision_visible(p_account,e.id,r.id,true)
1255
+ AND (NOT(p_request ? 'scope') OR e.scope=p_request->>'scope')
1256
+ ), batches AS (
1257
+ SELECT id,scope,session_id,scheduled_task_run_id,created_at,count(*) AS pending_count FROM visible
1258
+ GROUP BY id,scope,session_id,scheduled_task_run_id,created_at
1259
+ HAVING NOT(p_request ? 'afterId') OR id>(p_request->>'afterId')::uuid
1260
+ ORDER BY id LIMIT take+1
1261
+ ) SELECT coalesce(jsonb_agg(jsonb_build_object('id',b.id,'scope',b.scope,'pendingCount',b.pending_count,
1262
+ 'createdAt',b.created_at,'sessionId',s.id,'scheduledTaskId',task.id,'scheduledTaskRunId',run.id,
1263
+ 'title',coalesce(task.name,s.title)) ORDER BY b.id),'[]'::jsonb) INTO result
1264
+ FROM batches b LEFT JOIN sessions s ON s.account_id=p_account AND s.id=b.session_id
1265
+ LEFT JOIN scheduled_task_runs run ON run.account_id=p_account AND run.id=b.scheduled_task_run_id
1266
+ LEFT JOIN scheduled_tasks task ON task.account_id=p_account AND task.id=run.task_id;
1267
+ RETURN result;
1268
+ END IF;
1269
+ IF operation NOT IN ('list','get','history','file') OR (operation='file' AND NOT(p_request ? 'entryId')) THEN RAISE EXCEPTION 'Invalid Knowledge read' USING ERRCODE='22023'; END IF;
1270
+ -- Materialize access-filtered records before matching, ranking or pagination.
1271
+ WITH candidates AS MATERIALIZED (
1272
+ SELECT e.*,r.id AS revision_id,r.number,r.change_kind,r.body,r.body_codec_version,r.preview,r.preview_codec_version,
1273
+ r.previous_revision_id,r.restored_from_revision_id,r.created_by_session_id,r.review_batch_id,r.created_at AS revision_created_at,
1274
+ CASE WHEN r.id=e.published_revision_id THEN 'published'
1275
+ WHEN d.outcome='pending' AND r.id=e.latest_revision_id THEN 'pending'
1276
+ WHEN d.outcome='rejected' THEN 'rejected' ELSE 'superseded' END AS outcome
1277
+ FROM knowledge_entries e
1278
+ JOIN knowledge_entry_revisions r ON r.account_id=e.account_id AND r.entry_id=e.id
1279
+ AND (operation='history' OR r.id=CASE WHEN p_request ? 'revisionId' THEN (p_request->>'revisionId')::uuid
1280
+ WHEN pending THEN e.latest_revision_id
1281
+ WHEN p_request->>'view'='rejected' THEN e.latest_revision_id
1282
+ WHEN p_request->>'view'='archived' THEN coalesce(e.published_revision_id,e.latest_revision_id)
1283
+ ELSE e.published_revision_id END)
1284
+ LEFT JOIN LATERAL (SELECT outcome FROM knowledge_entry_decisions v WHERE v.account_id=e.account_id
1285
+ AND v.entry_id=e.id AND v.revision_id=r.id ORDER BY v.version DESC LIMIT 1) d ON true
1286
+ WHERE e.account_id=p_account AND knowledge_scope_visible(e)
1287
+ AND (operation='history' OR (p_request ? 'revisionId' AND knowledge_entry_read.actor->>'kind'='human' AND knowledge_entry_read.actor->>'review'='true')
1288
+ OR e.archived=coalesce(p_request->>'view'='archived',false))
1289
+ AND (NOT pending OR (d.outcome='pending' AND r.id=e.latest_revision_id
1290
+ AND (knowledge_entry_read.actor->>'kind'='agent' OR (knowledge_entry_read.actor->'writeScopes') ? e.scope)))
1291
+ AND (p_request->>'view' IS DISTINCT FROM 'rejected' OR d.outcome='rejected')
1292
+ AND (NOT(p_request ? 'scope') OR e.scope=p_request->>'scope')
1293
+ AND (NOT(p_request ? 'entryId') OR e.id=(p_request->>'entryId')::uuid)
1294
+ AND (NOT(p_request ? 'kind') OR r.body->>'kind'=p_request->>'kind')
1295
+ AND (NOT(p_request ? 'fileId') OR r.body#>>'{source,fileId}'=p_request->>'fileId'
1296
+ OR EXISTS(SELECT 1 FROM knowledge_entry_links l
1297
+ JOIN knowledge_entry_revisions source_revision ON source_revision.account_id=l.account_id
1298
+ AND source_revision.entry_id=l.target_entry_id AND source_revision.id=l.target_revision_id
1299
+ WHERE l.account_id=e.account_id AND l.entry_id=e.id AND l.revision_id=r.id
1300
+ AND l.relation='evidence' AND source_revision.body#>>'{source,fileId}'=p_request->>'fileId'))
1301
+ AND (NOT(p_request ? 'sessionId') OR r.created_by_session_id=(p_request->>'sessionId')::uuid)
1302
+ AND (NOT(p_request ? 'reviewBatchId') OR r.review_batch_id=(p_request->>'reviewBatchId')::uuid)
1303
+ AND (NOT(p_request ? 'groupId') OR EXISTS(SELECT 1 FROM knowledge_entry_links l
1304
+ WHERE l.account_id=e.account_id AND l.entry_id=e.id AND l.revision_id=r.id
1305
+ AND l.relation='group' AND l.target_entry_id=(p_request->>'groupId')::uuid
1306
+ AND EXISTS(SELECT 1 FROM knowledge_entries g WHERE g.account_id=p_account AND g.id=l.target_entry_id
1307
+ AND NOT g.archived AND knowledge_revision_visible(p_account,g.id,
1308
+ CASE WHEN pending THEN g.latest_revision_id ELSE g.published_revision_id END,pending))))
1309
+ -- Filter before pagination. A hidden, archived, or out-of-scope parent must
1310
+ -- never strand an otherwise readable entry outside the tree.
1311
+ AND (NOT coalesce((p_request->>'rootOnly')::boolean,false) OR NOT EXISTS(
1312
+ SELECT 1 FROM knowledge_entry_links l
1313
+ JOIN knowledge_entries g ON g.account_id=l.account_id AND g.id=l.target_entry_id
1314
+ JOIN knowledge_entry_revisions gr ON gr.account_id=g.account_id AND gr.entry_id=g.id
1315
+ AND gr.id=CASE WHEN pending THEN g.latest_revision_id ELSE g.published_revision_id END
1316
+ WHERE l.account_id=e.account_id AND l.entry_id=e.id AND l.revision_id=r.id AND l.relation='group'
1317
+ AND NOT g.archived AND gr.body->>'kind'='group'
1318
+ AND (NOT(p_request ? 'scope') OR g.scope=p_request->>'scope')
1319
+ AND knowledge_revision_visible(p_account,g.id,gr.id,pending)))
1320
+ AND (NOT(p_request ? 'beforeRevision') OR r.number<(p_request->>'beforeRevision')::integer)
1321
+ AND (knowledge_entry_read.actor->>'kind'='human' AND knowledge_entry_read.actor->>'review'='true'
1322
+ OR (pending AND knowledge_entry_read.actor->>'kind'='agent')
1323
+ OR EXISTS(SELECT 1 FROM knowledge_entry_decisions v WHERE v.account_id=e.account_id
1324
+ AND v.entry_id=e.id AND v.revision_id=r.id AND v.outcome='published'))
1325
+ AND knowledge_revision_visible(p_account,e.id,r.id,
1326
+ pending OR (knowledge_entry_read.actor->>'kind'='human' AND knowledge_entry_read.actor->>'review'='true'))
1327
+ ), scored AS (
1328
+ SELECT c.*,coalesce(lex.score,0) AS keyword_score,coalesce(semantic.similarity,0) AS similarity,
1329
+ CASE WHEN coalesce(p_request->>'query','')='' THEN 0
1330
+ WHEN coalesce(p_request->>'mode','hybrid')='vector' THEN coalesce(semantic.similarity,0)
1331
+ WHEN coalesce(p_request->>'mode','hybrid')='keyword' THEN coalesce(lex.score/(1+lex.score),0)
1332
+ ELSE coalesce(lex.score/(1+lex.score),0)+coalesce(semantic.similarity,0) END AS score,
1333
+ CASE WHEN semantic.keyword_match OR semantic.similarity>=0.2 THEN
1334
+ jsonb_build_array(jsonb_build_object('field',semantic.field,'start',semantic.start_offset,'end',semantic.end_offset,
1335
+ 'text',semantic.text,'codecVersion',semantic.text_codec_version)) ELSE '[]'::jsonb END AS excerpts
1336
+ FROM candidates c
1337
+ LEFT JOIN LATERAL (
1338
+ SELECT max(ts_rank_cd(s.search_vector,websearch_to_tsquery('simple',p_request->>'query'))) AS score
1339
+ FROM knowledge_entry_search s WHERE s.account_id=p_account AND s.entry_id=c.id AND s.revision_id=c.revision_id
1340
+ AND coalesce(p_request->>'query','')<>''
1341
+ ) lex ON true
1342
+ LEFT JOIN LATERAL (
1343
+ SELECT v.field,v.start_offset,v.end_offset,v.text,v.text_codec_version,
1344
+ v.keyword_match,max(v.similarity) OVER () AS similarity FROM (
1345
+ SELECT cache.*,
1346
+ to_tsvector('simple',cache.text)@@websearch_to_tsquery('simple',p_request->>'query') AS keyword_match,
1347
+ CASE WHEN query_embedding IS NOT NULL AND cache.dimensions=vector_dims(query_embedding)
1348
+ AND cache.model=p_request->>'embeddingModel' AND vector_norm(query_embedding)>0
1349
+ THEN greatest(0,least(1,1-(cache.embedding<=>query_embedding))) ELSE 0 END AS similarity
1350
+ FROM knowledge_entry_vectors cache JOIN knowledge_index_jobs job ON job.account_id=cache.account_id
1351
+ AND job.revision_id=cache.revision_id AND job.completed_generation=cache.generation
1352
+ WHERE cache.account_id=p_account AND cache.entry_id=c.id AND cache.revision_id=c.revision_id
1353
+ AND coalesce(p_request->>'query','')<>''
1354
+ ) v ORDER BY CASE WHEN p_request->>'mode'='vector' THEN false ELSE v.keyword_match END DESC,v.similarity DESC,v.chunk_index LIMIT 1
1355
+ ) semantic ON true
1356
+ ), page AS (
1357
+ SELECT * FROM scored s WHERE (coalesce(p_request->>'query','')=''
1358
+ OR (coalesce(p_request->>'mode','hybrid')<>'vector' AND s.keyword_score>0)
1359
+ OR (coalesce(p_request->>'mode','hybrid')<>'keyword' AND s.similarity>=0.2))
1360
+ AND (NOT(p_request ? 'afterId') OR s.score<(p_request->>'afterScore')::double precision
1361
+ OR (s.score=(p_request->>'afterScore')::double precision AND s.id>(p_request->>'afterId')::uuid))
1362
+ ORDER BY s.score DESC,s.id,s.number DESC LIMIT take+1
1363
+ ) SELECT coalesce(jsonb_agg(jsonb_build_object(
1364
+ 'score',c.score,'excerpts',c.excerpts,'id',c.id,'scope',c.scope,'version',c.version,'publishedRevisionId',c.published_revision_id,
1365
+ 'latestRevisionId',c.latest_revision_id,'archived',c.archived,'createdAt',c.created_at,'updatedAt',c.updated_at,
1366
+ 'revision',jsonb_build_object('id',c.revision_id,'entryId',c.id,'number',c.number,
1367
+ 'entry',CASE WHEN operation IN ('get','file') THEN knowledge_entry_visible_body(p_account,c.body,pending) ELSE NULL END,'change',c.change_kind,
1368
+ 'title',c.body->'title','kind',c.body->>'kind','preview',c.preview,'groupIds',knowledge_entry_visible_body(p_account,c.body,pending)->'groupIds',
1369
+ 'sourceKind',c.body#>>'{source,kind}','bodyCodecVersion',c.body_codec_version,'previewCodecVersion',c.preview_codec_version,
1370
+ 'previousRevisionId',c.previous_revision_id,'restoredFromRevisionId',c.restored_from_revision_id,
1371
+ 'createdAt',c.revision_created_at,'createdBySessionId',c.created_by_session_id,
1372
+ 'reviewBatchId',c.review_batch_id,'outcome',c.outcome)
1373
+ ) ORDER BY c.score DESC,c.id,c.number DESC),'[]'::jsonb) INTO result FROM page c;
1374
+ IF operation='file' THEN
1375
+ IF result#>>'{0,revision,entry,source,documentId}' IS NOT NULL THEN
1376
+ SELECT to_jsonb(f) INTO result FROM resolve_document_original_file(p_account,p_workspace,
1377
+ nullif(current_setting('opengeni.subject_id',true),''),(result#>>'{0,revision,entry,source,documentId}')::uuid) f;
1378
+ ELSE
1379
+ SELECT to_jsonb(f) INTO result FROM files f WHERE f.account_id=p_account AND f.status='ready'
1380
+ AND f.id=(result#>>'{0,revision,entry,source,fileId}')::uuid;
1381
+ END IF;
1382
+ END IF;
1383
+ RETURN result;
1384
+ END $$;
1385
+
1386
+ -- Source preparation is an exact-attempt operation on an existing original.
1387
+ -- The stable owner/file identity makes retries, children and scheduled re-reads
1388
+ -- converge. A rejected or archived source is never silently re-published.
1389
+ CREATE FUNCTION knowledge_entry_prepare_file(p_account uuid,p_workspace uuid,p_actor jsonb,p_request jsonb)
1390
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
1391
+ DECLARE actor jsonb; f files%ROWTYPE; e knowledge_entries%ROWTYPE;
1392
+ entry_id uuid; identity_hash text; receipt jsonb; operation text:=p_request->>'operation';
1393
+ file_id uuid:=(p_request->>'fileId')::uuid; body jsonb;
1394
+ BEGIN
1395
+ actor:=knowledge_resolve_actor(p_account,p_workspace,p_actor);
1396
+ IF actor->>'kind' IS DISTINCT FROM 'agent' OR file_id IS NULL OR operation IS NULL
1397
+ OR operation NOT IN ('inspect','complete') THEN
1398
+ RAISE EXCEPTION 'Source preparation requires an exact agent attempt' USING ERRCODE='42501'; END IF;
1399
+ IF actor#>>'{policy,effective,knowledge}'='off' THEN RETURN jsonb_build_object('status','disabled','fileId',file_id); END IF;
1400
+ SELECT * INTO f FROM files WHERE account_id=p_account AND id=file_id AND status='ready'
1401
+ AND (workspace_id=p_workspace OR (actor->>'defaultScope'='personal' AND actor->>'subjectId'=ANY(private_owner_subject_ids)));
1402
+ IF NOT FOUND OR NOT knowledge_source_visible(p_account,jsonb_build_object('source',jsonb_build_object('fileId',file_id))) THEN
1403
+ RAISE EXCEPTION 'Source file unavailable' USING ERRCODE='42501'; END IF;
1404
+ identity_hash:=md5('prepared-knowledge-file:'||p_account||':'||file_id||':'||
1405
+ CASE WHEN actor->>'defaultScope'='personal' THEN 'personal:'||(actor->>'subjectId') ELSE 'workspace:'||p_workspace END||
1406
+ CASE WHEN actor->>'retentionScope'='session' THEN ':session:'||(actor->>'sessionId') ELSE '' END);
1407
+ entry_id:=(substring(identity_hash,1,8)||'-'||substring(identity_hash,9,4)||'-5'||substring(identity_hash,14,3)||'-a'||substring(identity_hash,18,3)||'-'||substring(identity_hash,21,12))::uuid;
1408
+ -- Do not keep a transaction open while a parser or object store is running.
1409
+ -- Complete rechecks both authority and identity under the publication lock.
1410
+ IF operation='complete' THEN
1411
+ PERFORM pg_advisory_xact_lock(hashtextextended('knowledge-publication:'||p_account,0));
1412
+ END IF;
1413
+ SELECT * INTO e FROM knowledge_entries WHERE account_id=p_account AND id=entry_id;
1414
+ IF FOUND THEN
1415
+ IF e.prepared_file_id IS DISTINCT FROM file_id OR NOT knowledge_scope_visible(e) THEN
1416
+ RAISE EXCEPTION 'Prepared source identity is unavailable' USING ERRCODE='42501'; END IF;
1417
+ SELECT jsonb_build_object('operationId',o.operation_id,'entryId',e.id,'revisionId',e.latest_revision_id,
1418
+ 'version',e.version,'outcome',CASE WHEN e.archived THEN 'archived' ELSE d.outcome END,
1419
+ 'reviewBatchId',r.review_batch_id,'replayed',true) INTO receipt
1420
+ FROM knowledge_entry_decisions d JOIN knowledge_entry_revisions r ON r.account_id=d.account_id AND r.id=d.revision_id
1421
+ JOIN knowledge_entry_operations o ON o.account_id=d.account_id AND o.entry_id=d.entry_id AND o.receipt->>'revisionId'=d.revision_id::text
1422
+ WHERE d.account_id=p_account AND d.entry_id=e.id AND d.version=e.version ORDER BY o.created_at DESC LIMIT 1;
1423
+ IF receipt IS NULL THEN RAISE EXCEPTION 'Prepared source receipt unavailable' USING ERRCODE='55000'; END IF;
1424
+ RETURN jsonb_build_object('status','retained','fileId',file_id,'filename',f.filename,'receipt',receipt);
1425
+ END IF;
1426
+ IF operation='inspect' THEN
1427
+ RETURN jsonb_build_object('status','prepare','fileId',file_id,'file',to_jsonb(f));
1428
+ END IF;
1429
+ IF jsonb_typeof(p_request->'content') IS DISTINCT FROM 'string' OR p_request->>'codecVersion' IS DISTINCT FROM '1' THEN
1430
+ RAISE EXCEPTION 'Invalid prepared source content' USING ERRCODE='22023'; END IF;
1431
+ body:=jsonb_build_object('title',p_request->'title','kind','source','content',p_request->'content',
1432
+ 'source',jsonb_build_object('kind','file','fileId',f.id,'version',coalesce(p_request->>'sourceVersion',f.sha256,f.id::text),
1433
+ 'retention','full_text','capturedAt',clock_timestamp()),'evidence','[]'::jsonb,'groupIds','[]'::jsonb,'relationships','[]'::jsonb);
1434
+ receipt:=knowledge_entry_apply(p_account,p_workspace,p_actor,jsonb_build_object('operation','save','operationId',gen_random_uuid(),
1435
+ 'entryId',entry_id,'expectedVersion',0,'entry',body,'codecVersion',1,'preview',p_request->'preview','searchText',p_request->'searchText'));
1436
+ UPDATE knowledge_entries SET prepared_file_id=file_id WHERE account_id=p_account AND id=entry_id;
1437
+ RETURN jsonb_build_object('status','retained','fileId',file_id,'filename',f.filename,'receipt',receipt);
1438
+ END $$;
1439
+
1440
+ -- Compatibility adapter for existing Document indexing obligations. New chat
1441
+ -- ingestion uses exact agent attempts. This mechanical adapter can prepare only
1442
+ -- its retained original, under its existing document ownership and frozen task
1443
+ -- policy; it never gains human authority or invents agent findings.
1444
+ CREATE FUNCTION knowledge_document_prepare(p_account uuid,p_workspace uuid,p_document uuid,p_request jsonb)
1445
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
1446
+ DECLARE document documents%ROWTYPE; entry knowledge_entries%ROWTYPE; original files%ROWTYPE;
1447
+ obligation knowledge_source_sync_index_obligations%ROWTYPE; scheduled scheduled_task_runs%ROWTYPE;
1448
+ prior knowledge_entry_revisions%ROWTYPE; lease uuid; policy jsonb; body jsonb; result jsonb; actor jsonb;
1449
+ operation text:=p_request->>'operation'; accepted_at timestamptz; fingerprint text;
1450
+ BEGIN
1451
+ IF p_account IS DISTINCT FROM opengeni_private.current_account_id()
1452
+ OR p_workspace IS DISTINCT FROM opengeni_private.current_workspace_id()
1453
+ OR operation IS NULL OR operation NOT IN ('claim','complete') THEN
1454
+ RAISE EXCEPTION 'Invalid document preparation authority' USING ERRCODE='42501'; END IF;
1455
+ -- Same publication lock order as ordinary Knowledge writes.
1456
+ PERFORM pg_advisory_xact_lock(hashtextextended('knowledge-publication:'||p_account,0));
1457
+ SELECT * INTO document FROM documents d WHERE d.account_id=p_account AND d.workspace_id=p_workspace
1458
+ AND d.id=p_document FOR UPDATE;
1459
+ IF NOT FOUND OR document.status NOT IN ('indexing','ready') OR document.file_id IS DISTINCT FROM (p_request->>'fileId')::uuid THEN
1460
+ RAISE EXCEPTION 'Document original changed or is unavailable' USING ERRCODE='42501'; END IF;
1461
+ PERFORM set_config('opengeni.subject_id',coalesce(document.authority_subject_id,document.created_by,''),true);
1462
+ PERFORM set_config('opengeni.private_file_owner',coalesce(document.authority_subject_id,''),true);
1463
+ SELECT * INTO original FROM files f WHERE f.account_id=p_account AND f.id=document.file_id AND f.status='ready';
1464
+ IF NOT FOUND THEN RAISE EXCEPTION 'Document original unavailable' USING ERRCODE='42501'; END IF;
1465
+ SELECT * INTO entry FROM knowledge_entries e WHERE e.account_id=p_account AND (e.legacy_document_id=p_document
1466
+ OR e.legacy_document_version_id IN (SELECT v.id FROM knowledge_document_versions v WHERE v.account_id=p_account AND v.document_id=p_document))
1467
+ ORDER BY (e.legacy_document_id=p_document) DESC NULLS LAST,e.created_at DESC LIMIT 1 FOR UPDATE;
1468
+ SELECT * INTO obligation FROM knowledge_source_sync_index_obligations o
1469
+ WHERE o.account_id=p_account AND o.workspace_id=p_workspace AND o.document_id=p_document
1470
+ ORDER BY o.object_version_generation DESC,o.created_at DESC LIMIT 1;
1471
+ IF obligation.id IS NOT NULL AND (obligation.status='invalidated' OR NOT EXISTS(
1472
+ SELECT 1 FROM knowledge_document_versions v
1473
+ JOIN knowledge_source_objects o ON o.account_id=v.account_id AND o.id=v.object_id AND o.lifecycle_state='active'
1474
+ AND o.current_version_id=v.id AND o.version_generation=obligation.object_version_generation
1475
+ AND o.lifecycle_generation=obligation.object_lifecycle_generation
1476
+ JOIN knowledge_sources s ON s.account_id=v.account_id AND s.id=v.source_id AND s.lifecycle_state='active'
1477
+ AND s.lifecycle_generation=obligation.source_lifecycle_generation AND s.current_acl_generation=v.acl_generation
1478
+ JOIN knowledge_providers p ON p.account_id=s.account_id AND p.id=s.provider_id AND p.lifecycle_state='active'
1479
+ WHERE v.account_id=p_account AND v.id=obligation.knowledge_document_version_id
1480
+ AND (v.document_id IS NULL OR v.document_id=p_document) AND (v.file_id IS NULL OR v.file_id=document.file_id)
1481
+ )) THEN RAISE EXCEPTION 'Source observation changed before preparation' USING ERRCODE='42501'; END IF;
1482
+ IF obligation.id IS NULL AND NOT google_drive_file_authorized(p_account,p_workspace,coalesce(document.authority_subject_id,document.created_by),document.file_id) THEN
1483
+ -- A normal upload cannot borrow a source-sync processing obligation.
1484
+ RAISE EXCEPTION 'Document original is no longer authorized' USING ERRCODE='42501'; END IF;
1485
+ IF operation='claim' THEN
1486
+ IF obligation.id IS NOT NULL THEN
1487
+ SELECT * INTO scheduled FROM scheduled_task_runs r WHERE r.account_id=p_account AND r.workspace_id=p_workspace
1488
+ AND r.id=obligation.scheduled_task_run_id;
1489
+ IF NOT FOUND THEN RAISE EXCEPTION 'Source indexing run is no longer available' USING ERRCODE='42501'; END IF;
1490
+ accepted_at:=scheduled.created_at;
1491
+ SELECT snapshot.snapshot INTO policy FROM agent_learning_snapshots snapshot
1492
+ JOIN session_turns t ON t.account_id=snapshot.account_id AND t.id=snapshot.turn_id
1493
+ WHERE t.account_id=p_account AND t.workspace_id=p_workspace AND t.scheduled_task_run_id=scheduled.id
1494
+ ORDER BY t.created_at LIMIT 1;
1495
+ IF scheduled.action_kind<>'agent_turn' OR policy IS NULL THEN
1496
+ RAISE EXCEPTION 'Source preparation requires an accepted agent learning snapshot' USING ERRCODE='42501'; END IF;
1497
+ ELSE
1498
+ -- An explicit old Document upload is human/service-authored source input,
1499
+ -- not an automatic finding. Its source copy keeps that existing authority.
1500
+ policy:=jsonb_build_object('effective',jsonb_build_object('knowledge','automatic'));
1501
+ accepted_at:=document.created_at;
1502
+ END IF;
1503
+ IF policy#>>'{effective,knowledge}'='off' THEN
1504
+ RETURN jsonb_build_object('status','disabled','fileId',document.file_id);
1505
+ END IF;
1506
+ IF entry.id IS NULL THEN
1507
+ INSERT INTO knowledge_entries(account_id,origin_workspace_id,scope,scope_workspace_id,scope_subject_id,
1508
+ legacy_document_id,legacy_document_version_id,access_document_id)
1509
+ VALUES(p_account,p_workspace,document.authority_kind,
1510
+ CASE WHEN document.authority_kind='workspace' THEN document.authority_workspace_id END,
1511
+ CASE WHEN document.authority_kind='personal' THEN document.authority_subject_id END,
1512
+ p_document,obligation.knowledge_document_version_id,p_document) RETURNING * INTO entry;
1513
+ END IF;
1514
+ IF entry.scope IS DISTINCT FROM document.authority_kind
1515
+ OR entry.scope_subject_id IS DISTINCT FROM document.authority_subject_id
1516
+ OR (entry.scope='workspace' AND entry.scope_workspace_id IS DISTINCT FROM document.authority_workspace_id) THEN
1517
+ RAISE EXCEPTION 'Document ownership differs from retained Knowledge' USING ERRCODE='42501'; END IF;
1518
+ lease:=gen_random_uuid();
1519
+ UPDATE knowledge_entries SET legacy_document_id=p_document,document_preparation=jsonb_build_object('leaseId',lease,
1520
+ 'leaseUntil',clock_timestamp()+interval '15 minutes','expectedVersion',entry.version,
1521
+ 'fileId',document.file_id,'policy',policy,'acceptedAt',accepted_at,'scheduledTaskRunId',scheduled.id)
1522
+ WHERE id=entry.id;
1523
+ RETURN jsonb_build_object('status','prepare','entryId',entry.id,'leaseId',lease,'fileId',document.file_id,'file',to_jsonb(original));
1524
+ END IF;
1525
+ IF entry.id IS NULL OR entry.document_preparation->>'leaseId' IS DISTINCT FROM p_request->>'leaseId'
1526
+ OR entry.document_preparation->>'fileId' IS DISTINCT FROM document.file_id::text THEN
1527
+ RAISE EXCEPTION 'Document preparation lease changed' USING ERRCODE='40001'; END IF;
1528
+ fingerprint:=encode(sha256(convert_to(p_request::text,'UTF8')),'hex');
1529
+ IF entry.document_preparation ? 'completion' THEN
1530
+ IF entry.document_preparation->>'completionHash' IS DISTINCT FROM fingerprint THEN
1531
+ RAISE EXCEPTION 'Document preparation replay differs' USING ERRCODE='23505'; END IF;
1532
+ result:=entry.document_preparation->'completion';
1533
+ RETURN CASE WHEN result->>'status'='retained' THEN jsonb_set(result,'{receipt,replayed}','true'::jsonb) ELSE result END;
1534
+ END IF;
1535
+ IF (entry.document_preparation->>'leaseUntil')::timestamptz<=clock_timestamp() THEN
1536
+ RAISE EXCEPTION 'Document preparation lease expired' USING ERRCODE='40001'; END IF;
1537
+ IF p_request->>'codecVersion' IS DISTINCT FROM '1' OR jsonb_typeof(p_request->'content') IS DISTINCT FROM 'string'
1538
+ OR p_request->>'sourceVersion' !~ '^[0-9a-f]{64}$'
1539
+ OR (original.sha256 IS NOT NULL AND lower(original.sha256) IS DISTINCT FROM p_request->>'sourceVersion') THEN
1540
+ RAISE EXCEPTION 'Document extraction does not match its original' USING ERRCODE='22023'; END IF;
1541
+ IF entry.version IS DISTINCT FROM (entry.document_preparation->>'expectedVersion')::integer THEN
1542
+ RAISE EXCEPTION 'Knowledge changed during document processing' USING ERRCODE='40001'; END IF;
1543
+ SELECT * INTO prior FROM knowledge_entry_revisions r WHERE r.account_id=p_account AND r.id=entry.latest_revision_id;
1544
+ -- Re-reading an unchanged, rejected or archived source cannot publish it.
1545
+ IF prior.id IS NOT NULL AND (entry.archived OR (prior.body->'content'=p_request->'content'
1546
+ AND prior.body_codec_version=1 AND prior.body#>>'{source,version}'=p_request->>'sourceVersion')) THEN
1547
+ result:=jsonb_build_object('status','unchanged','entryId',entry.id,'revisionId',prior.id);
1548
+ UPDATE knowledge_entries SET document_preparation=document_preparation||jsonb_build_object('completionHash',fingerprint,'completion',result)
1549
+ WHERE id=entry.id;
1550
+ RETURN result;
1551
+ END IF;
1552
+ actor:=jsonb_build_object('kind','source_preparation','entryId',entry.id,'leaseId',p_request->'leaseId');
1553
+ UPDATE documents SET status='ready' WHERE id=p_document AND account_id=p_account;
1554
+ body:=jsonb_build_object('kind','source','title',p_request->'title','content',p_request->'content',
1555
+ 'groupIds',coalesce(prior.body->'groupIds','[]'::jsonb),'relationships',coalesce(prior.body->'relationships','[]'::jsonb),
1556
+ 'evidence',coalesce(prior.body->'evidence','[]'::jsonb),
1557
+ 'source',jsonb_build_object('kind','file','documentId',p_document,'fileId',document.file_id,
1558
+ 'version',p_request->'sourceVersion','retention','full_text'));
1559
+ result:=knowledge_entry_apply(p_account,p_workspace,actor,jsonb_build_object('operation','save',
1560
+ 'operationId',p_request->'leaseId','entryId',entry.id,'expectedVersion',entry.version,
1561
+ 'entry',body,'codecVersion',1,'preview',p_request->'preview','searchText',p_request->'searchText'));
1562
+ IF NOT knowledge_index_retention_active(p_account,entry.id,(result->>'revisionId')::uuid,p_document) THEN
1563
+ RAISE EXCEPTION 'Document source was withdrawn during processing' USING ERRCODE='42501'; END IF;
1564
+ result:=jsonb_build_object('status','retained','receipt',result);
1565
+ UPDATE knowledge_entries SET document_preparation=document_preparation||jsonb_build_object('completionHash',fingerprint,'completion',result) WHERE id=entry.id;
1566
+ RETURN result;
1567
+ END $$;
1568
+
1569
+ -- Configuration follows the same owner layer as accepted execution.
1570
+ CREATE FUNCTION knowledge_learning_context_visible(p_account uuid,p_workspace uuid,p_subject text,p_scope text,p_context text)
1571
+ RETURNS boolean LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
1572
+ DECLARE session sessions%ROWTYPE; task scheduled_tasks%ROWTYPE; target uuid;
1573
+ BEGIN
1574
+ IF p_context ~ '^chat:[0-9a-f-]{36}$' THEN
1575
+ SELECT * INTO session FROM sessions WHERE account_id=p_account AND workspace_id=p_workspace AND id=substring(p_context FROM 6)::uuid;
1576
+ IF NOT FOUND THEN RETURN false; END IF;
1577
+ IF get_workspace_kind(p_account,p_workspace)='personal' THEN RETURN p_scope='personal' AND p_subject IS NOT NULL; END IF;
1578
+ IF session.visibility='user_private' THEN RETURN coalesce(p_scope='personal' AND session.owner_subject_id=p_subject,false); END IF;
1579
+ IF session.memory_scope='user' THEN RETURN coalesce(p_scope='personal' AND session.scope_subject_id=p_subject,false); END IF;
1580
+ RETURN p_scope='workspace';
1581
+ ELSIF p_context ~ '^scheduled_task:[0-9a-f-]{36}$' THEN
1582
+ SELECT * INTO task FROM scheduled_tasks WHERE account_id=p_account AND workspace_id=p_workspace AND id=substring(p_context FROM 16)::uuid AND deleted_at IS NULL;
1583
+ IF NOT FOUND THEN RETURN false; END IF;
1584
+ IF task.agent_config#>>'{knowledgeSource,destination,kind}'='personal' THEN
1585
+ RETURN coalesce(p_scope='personal' AND p_subject=task.agent_config#>>'{knowledgeSource,destination,subjectId}',false); END IF;
1586
+ IF get_workspace_kind(p_account,p_workspace)='personal' THEN RETURN p_scope='personal' AND p_subject IS NOT NULL; END IF;
1587
+ target:=CASE WHEN task.run_mode IN ('existing_session','reusable_session') THEN task.reusable_session_id END;
1588
+ IF target IS NOT NULL THEN RETURN knowledge_learning_context_visible(p_account,p_workspace,p_subject,p_scope,'chat:'||target); END IF;
1589
+ IF task.run_mode='existing_session' THEN RETURN false; END IF;
1590
+ IF task.creator_session_policy->>'memoryScope'='user' THEN RETURN coalesce(p_scope='personal' AND task.creator_session_policy->>'scopeSubjectId'=p_subject,false); END IF;
1591
+ RETURN p_scope='workspace';
1592
+ END IF;
1593
+ RETURN false;
1594
+ END $$;
1595
+
1596
+ CREATE FUNCTION agent_learning_manage(p_account uuid,p_workspace uuid,p_actor jsonb,p_request jsonb)
1597
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
1598
+ <<agent_learning_manage>>
1599
+ DECLARE actor jsonb; owner text; subject text; context_key text:=coalesce(p_request->>'contextKey','defaults');
1600
+ scope_value text:=coalesce(p_request->>'scope','workspace'); prior agent_learning_revisions%ROWTYPE;
1601
+ current_revision agent_learning_revisions%ROWTYPE; settings jsonb; patch jsonb; key text; val text;
1602
+ request_hash text; operation_id uuid:=(p_request->>'operationId')::uuid; result jsonb;
1603
+ BEGIN
1604
+ actor:=knowledge_resolve_actor(p_account,p_workspace,p_actor);
1605
+ -- Read-only contextual scope discovery uses the same exact authority check as
1606
+ -- writes. A bounded UI session picker is never the source of owner identity.
1607
+ IF actor->>'kind'='human' AND p_request->>'operation'='read' AND scope_value='context' THEN
1608
+ IF knowledge_learning_context_visible(p_account,p_workspace,actor->>'subjectId','workspace',context_key) THEN scope_value:='workspace';
1609
+ ELSIF knowledge_learning_context_visible(p_account,p_workspace,actor->>'subjectId','personal',context_key) THEN scope_value:='personal';
1610
+ ELSE RAISE EXCEPTION 'Agent learning context is unavailable' USING ERRCODE='42501'; END IF;
1611
+ END IF;
1612
+ IF actor->>'kind'<>'human' OR scope_value NOT IN ('workspace','personal') THEN
1613
+ RAISE EXCEPTION 'Only humans configure Agent learning' USING ERRCODE='42501';
1614
+ END IF;
1615
+ subject:=CASE WHEN scope_value='personal' THEN actor->>'subjectId' END;
1616
+ owner:=CASE WHEN subject IS NULL THEN 'workspace:'||p_workspace ELSE 'personal:'||subject END;
1617
+ IF context_key<>'defaults' AND NOT knowledge_learning_context_visible(p_account,p_workspace,actor->>'subjectId',scope_value,context_key) THEN
1618
+ RAISE EXCEPTION 'Agent learning context is unavailable in this owner scope' USING ERRCODE='42501';
1619
+ END IF;
1620
+ IF p_request->>'operation'='list' THEN
1621
+ SELECT coalesce(jsonb_agg(jsonb_build_object('contextKey',r.context_key,'version',r.version,
1622
+ 'settings',r.settings,'updatedAt',r.created_at,'label',coalesce(s.title,t.name,'Untitled chat')) ORDER BY r.context_key),'[]'::jsonb) INTO result
1623
+ FROM (SELECT DISTINCT ON (v.context_key) v.* FROM agent_learning_revisions v
1624
+ WHERE v.account_id=p_account AND v.owner_key=owner AND v.origin_workspace_id=p_workspace AND v.context_key<>'defaults'
1625
+ ORDER BY v.context_key,v.version DESC) r
1626
+ LEFT JOIN sessions s ON s.account_id=p_account AND s.workspace_id=p_workspace AND r.context_key='chat:'||s.id
1627
+
1628
+ LEFT JOIN scheduled_tasks t ON t.account_id=p_account AND t.workspace_id=p_workspace AND r.context_key='scheduled_task:'||t.id
1629
+ AND t.deleted_at IS NULL
1630
+ WHERE r.settings<>'{}'::jsonb AND (s.id IS NOT NULL OR t.id IS NOT NULL)
1631
+ AND knowledge_learning_context_visible(p_account,p_workspace,agent_learning_manage.actor->>'subjectId',scope_value,r.context_key);
1632
+ RETURN result;
1633
+ END IF;
1634
+ SELECT * INTO current_revision FROM agent_learning_revisions r WHERE r.account_id=p_account
1635
+ AND r.owner_key=owner AND r.context_key=agent_learning_manage.context_key ORDER BY r.version DESC LIMIT 1;
1636
+ IF p_request->>'operation'='read' THEN
1637
+ RETURN jsonb_build_object('ownerKey',owner,'contextKey',context_key,'version',coalesce(current_revision.version,0),
1638
+ 'settings',coalesce(current_revision.settings,CASE WHEN context_key='defaults' THEN
1639
+ '{"knowledge":"automatic","instructions":"review_first","skills":"review_first"}'::jsonb ELSE '{}'::jsonb END));
1640
+ END IF;
1641
+ IF p_request->>'operation' IS DISTINCT FROM 'save' OR operation_id IS NULL
1642
+ OR NOT coalesce((p_actor->'settingsScopes') ? scope_value,false) THEN
1643
+ RAISE EXCEPTION 'Agent learning settings permission required' USING ERRCODE='42501';
1644
+ END IF;
1645
+ PERFORM pg_advisory_xact_lock(hashtextextended('agent-learning:'||p_account||':'||owner,0));
1646
+ request_hash:=encode(sha256(convert_to(jsonb_build_array(p_actor,p_request)::text,'UTF8')),'hex');
1647
+ SELECT * INTO prior FROM agent_learning_revisions r WHERE r.account_id=p_account AND r.operation_id=agent_learning_manage.operation_id;
1648
+ IF FOUND THEN
1649
+ IF prior.request_hash<>request_hash THEN RAISE EXCEPTION 'Agent learning operation reused' USING ERRCODE='23505'; END IF;
1650
+ RETURN jsonb_build_object('ownerKey',owner,'contextKey',context_key,'version',prior.version,'settings',prior.settings);
1651
+ END IF;
1652
+ SELECT * INTO current_revision FROM agent_learning_revisions r WHERE r.account_id=p_account
1653
+ AND r.owner_key=owner AND r.context_key=agent_learning_manage.context_key ORDER BY r.version DESC LIMIT 1;
1654
+ IF coalesce(current_revision.version,0) IS DISTINCT FROM (p_request->>'expectedVersion')::integer THEN
1655
+ RAISE EXCEPTION 'Agent learning settings changed' USING ERRCODE='40001';
1656
+ END IF;
1657
+ IF context_key='defaults' THEN settings:=p_request->'settings';
1658
+ ELSE
1659
+ settings:=coalesce(current_revision.settings,'{}'::jsonb); patch:=p_request->'settings';
1660
+ IF jsonb_typeof(patch) IS DISTINCT FROM 'object' OR patch-'knowledge'-'instructions'-'skills'<>'{}'::jsonb THEN
1661
+ RAISE EXCEPTION 'Invalid Agent learning override' USING ERRCODE='22023';
1662
+ END IF;
1663
+ FOR key,val IN SELECT k,v#>>'{}' FROM jsonb_each(patch) p(k,v) LOOP
1664
+ IF val='inherit' THEN settings:=settings-key;
1665
+ ELSE settings:=jsonb_set(settings,ARRAY[key],to_jsonb(val),true); END IF;
1666
+ END LOOP;
1667
+ END IF;
1668
+ IF NOT knowledge_learning_settings_valid(settings,context_key='defaults') THEN
1669
+ RAISE EXCEPTION 'Invalid Agent learning settings' USING ERRCODE='22023';
1670
+ END IF;
1671
+ INSERT INTO agent_learning_revisions(account_id,origin_workspace_id,owner_key,subject_id,context_key,version,
1672
+ settings,operation_id,request_hash,actor_subject_id)
1673
+ VALUES(p_account,p_workspace,owner,subject,context_key,coalesce(current_revision.version,0)+1,
1674
+ settings,operation_id,request_hash,actor->>'subjectId') RETURNING * INTO prior;
1675
+ RETURN jsonb_build_object('ownerKey',owner,'contextKey',context_key,'version',prior.version,'settings',settings);
1676
+ END $$;
1677
+
1678
+ -- Evidence is checked transitively. A stored reference is never an authority
1679
+ -- shortcut, and a retired/restricted source withdraws dependent retrieval.
1680
+ CREATE FUNCTION knowledge_document_visible(p_document uuid) RETURNS boolean
1681
+ LANGUAGE sql VOLATILE SET search_path FROM CURRENT AS $$
1682
+ SELECT EXISTS(SELECT 1 FROM documents d
1683
+ WHERE d.id=p_document AND d.account_id=nullif(current_setting('opengeni.account_id',true),'')::uuid
1684
+ AND d.status='ready'
1685
+ AND ((current_setting('opengeni.knowledge_actor_kind',true)='human' OR (current_setting('opengeni.knowledge_actor_kind',true)='source_preparation' AND d.id::text=current_setting('opengeni.knowledge_prepared_document',true))) OR d.agent_access)
1686
+ AND (d.authority_kind='organization'
1687
+ OR (d.authority_kind='workspace' AND d.authority_workspace_id=nullif(current_setting('opengeni.workspace_id',true),'')::uuid)
1688
+ OR (d.authority_kind='personal' AND d.authority_subject_id=nullif(current_setting('opengeni.subject_id',true),'')
1689
+ AND ((d.authority_id IS NULL AND d.authority_workspace_id=nullif(current_setting('opengeni.workspace_id',true),'')::uuid)
1690
+ OR (d.authority_id IS NOT NULL AND ((current_setting('opengeni.knowledge_actor_kind',true)='human' OR (current_setting('opengeni.knowledge_actor_kind',true)='source_preparation' AND d.id::text=current_setting('opengeni.knowledge_prepared_document',true)))
1691
+ OR d.id IN (SELECT resolve_session_attempt_personal_document_reads(d.account_id,
1692
+ nullif(current_setting('opengeni.workspace_id',true),'')::uuid,
1693
+ nullif(current_setting('opengeni.knowledge_session_id',true),'')::uuid,
1694
+ nullif(current_setting('opengeni.knowledge_attempt_id',true),'')::uuid)))))))
1695
+ AND ((current_setting('opengeni.knowledge_actor_kind',true)='source_preparation'
1696
+ AND d.id::text=current_setting('opengeni.knowledge_prepared_document',true))
1697
+ OR google_drive_file_authorized(d.account_id,nullif(current_setting('opengeni.workspace_id',true),'')::uuid,
1698
+ nullif(current_setting('opengeni.subject_id',true),''),d.file_id)))
1699
+ $$;
1700
+
1701
+ CREATE FUNCTION knowledge_source_visible(p_account uuid,p_body jsonb) RETURNS boolean
1702
+ LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
1703
+ <<knowledge_source_visible>>
1704
+ DECLARE document_id uuid:=(p_body#>>'{source,documentId}')::uuid;
1705
+ file_id uuid:=(p_body#>>'{source,fileId}')::uuid;
1706
+ BEGIN
1707
+ IF document_id IS NOT NULL THEN
1708
+ RETURN knowledge_document_visible(document_id) AND (file_id IS NULL OR EXISTS(
1709
+ SELECT 1 FROM documents d WHERE d.account_id=p_account AND d.id=document_id AND d.file_id=knowledge_source_visible.file_id));
1710
+ END IF;
1711
+ IF file_id IS NOT NULL THEN
1712
+ RETURN EXISTS(SELECT 1 FROM files f WHERE f.account_id=p_account AND f.id=knowledge_source_visible.file_id
1713
+ AND f.status='ready') AND google_drive_file_authorized(p_account,nullif(current_setting('opengeni.workspace_id',true),'')::uuid,
1714
+ nullif(current_setting('opengeni.subject_id',true),''),file_id);
1715
+ END IF;
1716
+ RETURN true;
1717
+ END $$;
1718
+
1719
+ CREATE FUNCTION knowledge_validate_source(p_account uuid,p_entry uuid,p_body jsonb) RETURNS void
1720
+ LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
1721
+ DECLARE e knowledge_entries%ROWTYPE; d documents%ROWTYPE;
1722
+ BEGIN
1723
+ IF NOT knowledge_source_visible(p_account,p_body) THEN
1724
+ RAISE EXCEPTION 'Knowledge source unavailable' USING ERRCODE='42501'; END IF;
1725
+ SELECT * INTO STRICT e FROM knowledge_entries WHERE account_id=p_account AND id=p_entry;
1726
+ IF (e.access_document_id IS NOT NULL AND (p_body#>>'{source,documentId}')::uuid IS DISTINCT FROM e.access_document_id)
1727
+ OR (e.access_file_id IS NOT NULL AND (p_body#>>'{source,fileId}')::uuid IS DISTINCT FROM e.access_file_id) THEN
1728
+ RAISE EXCEPTION 'A source identity is immutable; retain a different source as a new entry' USING ERRCODE='22023'; END IF;
1729
+ IF p_body#>>'{source,documentId}' IS NOT NULL THEN
1730
+ SELECT * INTO STRICT d FROM documents WHERE account_id=p_account AND id=(p_body#>>'{source,documentId}')::uuid;
1731
+ IF NOT (d.authority_kind='organization'
1732
+ OR (d.authority_kind='workspace' AND e.scope IN ('workspace','personal') AND d.authority_workspace_id=e.origin_workspace_id)
1733
+ OR (d.authority_kind='personal' AND e.scope='personal' AND d.authority_subject_id=e.scope_subject_id)) THEN
1734
+ RAISE EXCEPTION 'Knowledge source would widen document access' USING ERRCODE='42501'; END IF;
1735
+ ELSIF p_body#>>'{source,fileId}' IS NOT NULL THEN
1736
+ IF NOT EXISTS(SELECT 1 FROM files f WHERE f.account_id=p_account
1737
+ AND (f.workspace_id=e.origin_workspace_id OR (e.scope='personal' AND e.scope_subject_id=ANY(f.private_owner_subject_ids)))
1738
+ AND f.id=(p_body#>>'{source,fileId}')::uuid AND f.status='ready'
1739
+ AND (f.private_owner_subject_ids IS NULL OR (e.scope='personal' AND e.scope_subject_id=ANY(f.private_owner_subject_ids)))) OR e.scope='organization' THEN
1740
+ RAISE EXCEPTION 'File source requires its authorized scope' USING ERRCODE='42501'; END IF;
1741
+ END IF;
1742
+ IF e.access_document_id IS NULL AND e.access_file_id IS NULL THEN
1743
+ UPDATE knowledge_entries SET access_document_id=(p_body#>>'{source,documentId}')::uuid,
1744
+ access_file_id=CASE WHEN p_body#>>'{source,documentId}' IS NULL THEN (p_body#>>'{source,fileId}')::uuid END
1745
+ WHERE account_id=p_account AND id=p_entry;
1746
+ END IF;
1747
+ END $$;
1748
+
1749
+ -- Imported provider evidence retains both the captured ACL and current source
1750
+ -- lifecycle. Migrating text into Knowledge never turns revoked input into a
1751
+ -- workspace-wide grant. These old rows remain provenance/access authority only.
1752
+ CREATE FUNCTION knowledge_legacy_version_visible(p_account uuid,p_version uuid) RETURNS boolean
1753
+ LANGUAGE sql VOLATILE SET search_path FROM CURRENT AS $$
1754
+ SELECT EXISTS(SELECT 1 FROM knowledge_document_versions v
1755
+ JOIN knowledge_source_objects o ON o.account_id=v.account_id AND o.id=v.object_id AND o.lifecycle_state='active'
1756
+ JOIN knowledge_sources s ON s.account_id=v.account_id AND s.id=v.source_id AND s.lifecycle_state='active'
1757
+ JOIN knowledge_providers p ON p.account_id=s.account_id AND p.id=s.provider_id AND p.lifecycle_state='active'
1758
+ JOIN knowledge_source_acl_versions captured ON captured.account_id=v.account_id AND captured.id=v.acl_version_id
1759
+ AND captured.source_id=v.source_id AND captured.generation=v.acl_generation
1760
+ JOIN knowledge_source_acl_versions current_acl ON current_acl.account_id=s.account_id AND current_acl.source_id=s.id
1761
+ AND current_acl.generation=s.current_acl_generation
1762
+ WHERE v.account_id=p_account AND v.id=p_version
1763
+ AND (current_setting('opengeni.knowledge_actor_kind',true)='human' OR (captured.agent_access AND current_acl.agent_access))
1764
+ AND (v.document_id IS NULL OR knowledge_document_visible(v.document_id))
1765
+ AND (v.file_id IS NULL OR google_drive_file_authorized(p_account,
1766
+ nullif(current_setting('opengeni.workspace_id',true),'')::uuid,nullif(current_setting('opengeni.subject_id',true),''),v.file_id)))
1767
+ $$;
1768
+
1769
+ CREATE FUNCTION knowledge_revision_visible(p_account uuid,p_entry uuid,p_revision uuid,p_pending boolean)
1770
+ RETURNS boolean LANGUAGE sql VOLATILE SET search_path FROM CURRENT AS $$
1771
+ WITH RECURSIVE dependencies(entry_id,revision_id) AS (
1772
+ SELECT p_entry,p_revision
1773
+ UNION
1774
+ SELECT l.target_entry_id,l.target_revision_id FROM dependencies d
1775
+ JOIN knowledge_entry_links l ON l.account_id=p_account AND l.entry_id=d.entry_id
1776
+ AND l.revision_id=d.revision_id AND l.relation='evidence'
1777
+ ) SELECT EXISTS(SELECT 1 FROM knowledge_entry_revisions r WHERE r.account_id=p_account
1778
+ AND r.entry_id=p_entry AND r.id=p_revision)
1779
+ AND NOT EXISTS (
1780
+ SELECT 1 FROM dependencies d LEFT JOIN knowledge_entries e ON e.account_id=p_account AND e.id=d.entry_id
1781
+ LEFT JOIN knowledge_entry_revisions r ON r.account_id=p_account AND r.entry_id=d.entry_id AND r.id=d.revision_id
1782
+ WHERE e.id IS NULL OR NOT knowledge_scope_visible(e) OR (e.archived AND e.id<>p_entry)
1783
+ OR (NOT p_pending AND NOT EXISTS(SELECT 1 FROM knowledge_entry_decisions v
1784
+ WHERE v.account_id=p_account AND v.entry_id=d.entry_id AND v.revision_id=d.revision_id AND v.outcome='published'))
1785
+ OR (e.legacy_document_version_id IS NOT NULL AND NOT knowledge_legacy_version_visible(p_account,e.legacy_document_version_id))
1786
+ OR (e.legacy_document_id IS NOT NULL AND NOT knowledge_document_visible(e.legacy_document_id))
1787
+ OR (e.access_document_id IS NOT NULL AND NOT knowledge_document_visible(e.access_document_id))
1788
+ OR (e.access_file_id IS NOT NULL AND NOT google_drive_file_authorized(p_account,
1789
+ nullif(current_setting('opengeni.workspace_id',true),'')::uuid,
1790
+ nullif(current_setting('opengeni.subject_id',true),''),e.access_file_id))
1791
+ OR r.id IS NULL OR NOT knowledge_source_visible(p_account,r.body)
1792
+ )
1793
+ $$;
1794
+
1795
+ -- Relationship labels and group IDs are discovery metadata. Filter unavailable
1796
+ -- endpoints on every projection; their disappearance does not erase the retained
1797
+ -- entry or rewrite its immutable revision. Evidence remains a content dependency.
1798
+ CREATE FUNCTION knowledge_entry_visible_body(p_account uuid,p_body jsonb,p_pending boolean)
1799
+ RETURNS jsonb LANGUAGE sql VOLATILE SET search_path FROM CURRENT AS $$
1800
+ SELECT p_body || jsonb_build_object(
1801
+ 'groupIds',coalesce((SELECT jsonb_agg(g.value ORDER BY g.ordinality)
1802
+ FROM jsonb_array_elements(p_body->'groupIds') WITH ORDINALITY g(value,ordinality)
1803
+ JOIN knowledge_entries e ON e.account_id=p_account AND e.id=(g.value#>>'{}')::uuid
1804
+ WHERE NOT e.archived AND knowledge_revision_visible(p_account,e.id,
1805
+ coalesce(e.published_revision_id,CASE WHEN p_pending THEN e.latest_revision_id END),p_pending)), '[]'::jsonb),
1806
+ 'relationships',coalesce((SELECT jsonb_agg(l.value ORDER BY l.ordinality)
1807
+ FROM jsonb_array_elements(p_body->'relationships') WITH ORDINALITY l(value,ordinality)
1808
+ JOIN knowledge_entries e ON e.account_id=p_account AND e.id=(l.value->>'entryId')::uuid
1809
+ WHERE NOT e.archived AND knowledge_revision_visible(p_account,e.id,
1810
+ coalesce(e.published_revision_id,CASE WHEN p_pending THEN e.latest_revision_id END),p_pending)), '[]'::jsonb))
1811
+ $$;
1812
+
1813
+ CREATE FUNCTION knowledge_validate_links(p_account uuid,p_entry uuid,p_body jsonb,p_batch uuid)
1814
+ RETURNS void LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
1815
+ DECLARE source knowledge_entries%ROWTYPE; target knowledge_entries%ROWTYPE; link record; target_revision uuid;
1816
+ BEGIN
1817
+ SELECT * INTO source FROM knowledge_entries WHERE account_id=p_account AND id=p_entry;
1818
+ FOR link IN
1819
+ SELECT (x->>'entryId')::uuid AS id,(x->>'revisionId')::uuid AS revision,'evidence' AS relation
1820
+ FROM jsonb_array_elements(coalesce(p_body->'evidence','[]'::jsonb)) x
1821
+ UNION ALL SELECT x::uuid,NULL,'group' FROM jsonb_array_elements_text(coalesce(p_body->'groupIds','[]'::jsonb)) x
1822
+ UNION ALL SELECT (x->>'entryId')::uuid,NULL,x->>'relation'
1823
+ FROM jsonb_array_elements(coalesce(p_body->'relationships','[]'::jsonb)) x
1824
+ ORDER BY id,relation
1825
+ LOOP
1826
+ SELECT * INTO target FROM knowledge_entries WHERE account_id=p_account AND id=link.id;
1827
+ IF NOT FOUND OR target.id=source.id OR target.archived OR NOT knowledge_scope_visible(target) THEN
1828
+ RAISE EXCEPTION 'Knowledge reference unavailable' USING ERRCODE='42501';
1829
+ END IF;
1830
+ -- The destination's readers must all be eligible to see linked identities.
1831
+ IF NOT ((target.scope='organization')
1832
+ OR (target.scope='workspace' AND source.scope IN ('workspace','personal')
1833
+ AND target.scope_workspace_id=source.origin_workspace_id)
1834
+ OR (target.scope='personal' AND source.scope='personal' AND target.scope_subject_id=source.scope_subject_id))
1835
+ OR (target.legacy_scope_type IS NOT NULL AND (source.legacy_scope_type IS DISTINCT FROM target.legacy_scope_type
1836
+ OR source.legacy_scope_role_key IS DISTINCT FROM target.legacy_scope_role_key
1837
+ OR source.legacy_scope_session_id IS DISTINCT FROM target.legacy_scope_session_id)) THEN
1838
+ RAISE EXCEPTION 'Knowledge reference would widen source access' USING ERRCODE='42501';
1839
+ END IF;
1840
+ target_revision:=coalesce(link.revision,target.published_revision_id);
1841
+ IF target_revision IS NULL AND p_batch IS NOT NULL THEN
1842
+ SELECT id INTO target_revision FROM knowledge_entry_revisions r WHERE r.account_id=p_account
1843
+ AND r.entry_id=target.id AND r.id=target.latest_revision_id;
1844
+ END IF;
1845
+ IF NOT knowledge_revision_visible(p_account,target.id,target_revision,false) AND NOT (
1846
+ p_batch IS NOT NULL AND target_revision=target.latest_revision_id
1847
+ AND EXISTS(SELECT 1 FROM knowledge_entry_revisions r WHERE r.account_id=p_account
1848
+ AND r.entry_id=target.id AND r.id=target_revision)
1849
+ AND EXISTS(SELECT 1 FROM knowledge_entry_decisions d WHERE d.account_id=p_account
1850
+ AND d.entry_id=target.id AND d.revision_id=target_revision AND d.version=target.version AND d.outcome='pending')
1851
+ AND knowledge_revision_visible(p_account,target.id,target_revision,true)) THEN
1852
+ RAISE EXCEPTION 'Knowledge evidence has not been published' USING ERRCODE='42501';
1853
+ END IF;
1854
+ IF link.relation='group' AND NOT EXISTS(SELECT 1 FROM knowledge_entry_revisions r
1855
+ WHERE r.account_id=p_account AND r.entry_id=target.id AND r.id=target_revision AND r.body->>'kind'='group') THEN
1856
+ RAISE EXCEPTION 'Knowledge membership requires a group' USING ERRCODE='22023';
1857
+ END IF;
1858
+ -- Membership follows collection identities, not the revision originally
1859
+ -- linked. Check both published and outstanding proposed parents so that
1860
+ -- independently reviewed moves cannot later close a cycle. Publication
1861
+ -- and proposals share the account publication lock.
1862
+ IF link.relation='group' AND p_body->>'kind'='group' AND EXISTS(
1863
+ WITH RECURSIVE parents(id) AS (
1864
+ SELECT target.id UNION SELECT l.target_entry_id FROM parents
1865
+ JOIN knowledge_entries g ON g.account_id=p_account AND g.id=parents.id AND NOT g.archived
1866
+ JOIN knowledge_entry_links l ON l.account_id=g.account_id AND l.entry_id=g.id AND l.relation='group'
1867
+ AND (l.revision_id=g.published_revision_id OR (l.revision_id=g.latest_revision_id AND EXISTS(
1868
+ SELECT 1 FROM knowledge_entry_decisions d WHERE d.account_id=g.account_id AND d.entry_id=g.id
1869
+ AND d.revision_id=g.latest_revision_id AND d.version=g.version AND d.outcome='pending')))
1870
+ ) SELECT 1 FROM parents WHERE id=source.id
1871
+ ) THEN RAISE EXCEPTION 'Knowledge collections cannot contain a cycle' USING ERRCODE='22023'; END IF;
1872
+ IF link.relation='evidence' AND EXISTS(
1873
+ WITH RECURSIVE refs(id,rev) AS (
1874
+ SELECT target.id,target_revision UNION SELECT l.target_entry_id,l.target_revision_id FROM refs
1875
+ JOIN knowledge_entry_links l ON l.account_id=p_account AND l.entry_id=refs.id AND l.revision_id=refs.rev AND l.relation='evidence'
1876
+ ) SELECT 1 FROM refs WHERE id=source.id
1877
+ ) THEN RAISE EXCEPTION 'Knowledge evidence cannot contain a cycle' USING ERRCODE='22023'; END IF;
1878
+ END LOOP;
1879
+ END $$;
1880
+
1881
+ -- Batches are review presentation only. They grant no content or publication authority.
1882
+ CREATE FUNCTION knowledge_review_batch_for_actor(p_account uuid,p_workspace uuid,p_actor jsonb)
1883
+ RETURNS uuid LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
1884
+ DECLARE batch uuid; owner text:=CASE WHEN p_actor->>'defaultScope'='personal'
1885
+ THEN 'personal:'||(p_actor->>'subjectId') ELSE 'workspace:'||p_workspace END;
1886
+ BEGIN
1887
+ SELECT b.id INTO batch FROM knowledge_review_batches b WHERE b.account_id=p_account AND b.owner_key=owner
1888
+ AND ((p_actor->>'scheduledTaskRunId' IS NOT NULL AND b.scheduled_task_run_id=(p_actor->>'scheduledTaskRunId')::uuid)
1889
+ OR (p_actor->>'scheduledTaskRunId' IS NULL AND b.turn_id=(p_actor->>'turnId')::uuid));
1890
+ IF batch IS NULL THEN
1891
+ INSERT INTO knowledge_review_batches(account_id,origin_workspace_id,owner_key,session_id,turn_id,scheduled_task_run_id)
1892
+ VALUES(p_account,p_workspace,owner,(p_actor->>'sessionId')::uuid,(p_actor->>'turnId')::uuid,
1893
+ (p_actor->>'scheduledTaskRunId')::uuid) RETURNING id INTO batch;
1894
+ END IF;
1895
+ RETURN batch;
1896
+ END $$;
1897
+
1898
+ -- Historical proposals keep their native revision and immutable provenance.
1899
+ -- Only maintenance-imported receipts bridge them into the current review list;
1900
+ -- creating a new legacy proposal cannot become a second authoring path.
1901
+ CREATE FUNCTION knowledge_instruction_context(p_revision workspace_instruction_policy_revisions)
1902
+ RETURNS jsonb LANGUAGE sql STABLE SET search_path FROM CURRENT AS $$
1903
+ SELECT coalesce(p_revision.agent_learning_context,(SELECT op.actor->'legacyInstructionContext'
1904
+ FROM agent_instruction_operations op WHERE op.account_id=p_revision.account_id AND op.revision_id=p_revision.id
1905
+ AND op.request_hash='migration:0461:instruction-review' AND op.actor->>'kind'='migration' LIMIT 1))
1906
+ $$;
1907
+
1908
+ CREATE FUNCTION agent_instruction_apply(p_account uuid,p_workspace uuid,p_actor jsonb,p_request jsonb)
1909
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
1910
+ <<instruction_change>>
1911
+ DECLARE actor jsonb; mode text; operation text:=p_request->>'operation'; fingerprint text; result jsonb;
1912
+ operation_id uuid:=(p_request->>'operationId')::uuid; revision_id uuid; batch uuid; actor_subject text;
1913
+ target jsonb:=p_request->'target'; context jsonb; expected_revision uuid; expected_version bigint;
1914
+ head workspace_instruction_policy_heads%ROWTYPE; revision workspace_instruction_policy_revisions%ROWTYPE;
1915
+ prior agent_instruction_operations%ROWTYPE; content_hash text; outcome text; link record; version bigint;
1916
+ BEGIN
1917
+ IF p_account IS DISTINCT FROM nullif(current_setting('opengeni.account_id',true),'')::uuid
1918
+ OR p_workspace IS DISTINCT FROM nullif(current_setting('opengeni.workspace_id',true),'')::uuid THEN
1919
+ RAISE EXCEPTION 'Instruction changes require exact tenant scope' USING ERRCODE='42501';
1920
+ END IF;
1921
+ -- Keep the workspace identity stable without excluding unrelated FK readers.
1922
+ -- Publication serializes on the account advisory lock below before the
1923
+ -- instruction head, matching other Knowledge writers without an advisory /
1924
+ -- workspace-row lock inversion.
1925
+ PERFORM 1 FROM workspaces WHERE id=p_workspace AND account_id=p_account FOR KEY SHARE;
1926
+ actor:=knowledge_resolve_actor(p_account,p_workspace,p_actor);
1927
+ IF operation='get' THEN
1928
+ SELECT * INTO head FROM workspace_instruction_policy_heads h WHERE h.account_id=p_account AND h.workspace_id=p_workspace
1929
+ AND h.kind=target->>'kind' AND h.scope=target->>'scope' AND h.role_key IS NOT DISTINCT FROM target->>'roleKey';
1930
+ SELECT * INTO revision FROM workspace_instruction_policy_revisions r WHERE r.account_id=p_account AND r.workspace_id=p_workspace
1931
+ AND r.id=head.revision_id;
1932
+ RETURN jsonb_build_object('target',target,'expectedCurrentRevisionId',head.revision_id,
1933
+ 'expectedActivationVersion',coalesce(head.activation_version,(SELECT max(d.activation_version)
1934
+ FROM workspace_instruction_policy_deactivation_events d WHERE d.account_id=p_account AND d.workspace_id=p_workspace
1935
+ AND d.kind=target->>'kind' AND d.scope=target->>'scope' AND d.role_key IS NOT DISTINCT FROM target->>'roleKey'),0),
1936
+ 'content',revision.content);
1937
+ END IF;
1938
+ IF operation='save' THEN
1939
+ IF actor->>'kind'<>'agent' OR (actor->>'defaultScope'='personal' AND get_workspace_kind(p_account,p_workspace)<>'personal') THEN
1940
+ RAISE EXCEPTION 'Workspace instructions require a workspace agent context' USING ERRCODE='42501';
1941
+ END IF;
1942
+ mode:=actor#>>'{policy,effective,instructions}';
1943
+ IF mode NOT IN ('automatic','review_first') OR mode IS NULL THEN
1944
+ RAISE EXCEPTION 'Instruction learning is Off' USING ERRCODE='42501';
1945
+ END IF;
1946
+ actor_subject:='service:agent-learning:'||(p_actor->>'attemptId');
1947
+ ELSE
1948
+ IF actor->>'kind'<>'human' OR NOT coalesce((p_actor->'settingsScopes') ? 'workspace',false) THEN
1949
+ RAISE EXCEPTION 'Instruction review requires workspace administration' USING ERRCODE='42501';
1950
+ END IF;
1951
+ actor_subject:=actor->>'subjectId';
1952
+ END IF;
1953
+ IF operation='list' THEN
1954
+ RETURN (SELECT coalesce(jsonb_agg(item ORDER BY identity),'[]'::jsonb) FROM (
1955
+ SELECT r.id AS identity,jsonb_build_object('revisionId',r.id,'target',jsonb_build_object('kind',r.kind,'scope',r.scope,'roleKey',r.role_key),
1956
+ 'content',r.content,'createdAt',r.created_at,'reviewBatchId',knowledge_instruction_context(r)->'reviewBatchId',
1957
+ 'sessionId',knowledge_instruction_context(r)#>'{actor,sessionId}','reason',knowledge_instruction_context(r)->'reason') AS item
1958
+ FROM workspace_instruction_policy_revisions r WHERE r.account_id=p_account AND r.workspace_id=p_workspace
1959
+ AND knowledge_instruction_context(r) IS NOT NULL
1960
+ AND NOT EXISTS(SELECT 1 FROM workspace_instruction_policy_activation_events ev WHERE ev.account_id=p_account
1961
+ AND ev.workspace_id=p_workspace AND ev.new_revision_id=r.id)
1962
+ AND NOT EXISTS(SELECT 1 FROM agent_instruction_operations op WHERE op.account_id=p_account AND op.revision_id=r.id
1963
+ AND op.receipt->>'outcome' IN ('published','rejected'))
1964
+ AND NOT EXISTS(SELECT 1 FROM workspace_instruction_policy_revisions newer WHERE newer.account_id=p_account
1965
+ AND newer.workspace_id=p_workspace AND newer.kind=r.kind AND newer.scope=r.scope
1966
+ AND newer.role_key IS NOT DISTINCT FROM r.role_key AND newer.revision>r.revision)
1967
+ AND (NOT(p_request ? 'cursor') OR r.id>(p_request->>'cursor')::uuid)
1968
+ ORDER BY r.id LIMIT 51) visible);
1969
+ END IF;
1970
+ IF operation_id IS NULL OR operation NOT IN ('save','approve','reject') OR length(btrim(p_request->>'reason')) NOT BETWEEN 1 AND 4096 THEN
1971
+ RAISE EXCEPTION 'Invalid instruction change' USING ERRCODE='22023';
1972
+ END IF;
1973
+ fingerprint:=encode(sha256(convert_to(jsonb_build_array(
1974
+ CASE WHEN p_actor->>'kind'='agent' THEN p_actor-'attemptId'-'executionGeneration' ELSE p_actor END,
1975
+ p_request)::text,'UTF8')),'hex');
1976
+ SELECT * INTO prior FROM agent_instruction_operations op WHERE op.account_id=p_account AND op.operation_id=instruction_change.operation_id;
1977
+ IF FOUND THEN
1978
+ IF prior.request_hash<>fingerprint OR prior.origin_workspace_id<>p_workspace THEN
1979
+ RAISE EXCEPTION 'Instruction operation key reused' USING ERRCODE='23505'; END IF;
1980
+ RETURN prior.receipt||jsonb_build_object('replayed',true);
1981
+ END IF;
1982
+ IF operation<>'save' THEN
1983
+ SELECT * INTO revision FROM workspace_instruction_policy_revisions r WHERE r.account_id=p_account AND r.workspace_id=p_workspace
1984
+ AND r.id=(p_request->>'revisionId')::uuid AND knowledge_instruction_context(r) IS NOT NULL;
1985
+ IF NOT FOUND OR EXISTS(SELECT 1 FROM agent_instruction_operations op WHERE op.account_id=p_account AND op.revision_id=revision.id
1986
+ AND op.receipt->>'outcome' IN ('published','rejected')) OR EXISTS(SELECT 1 FROM workspace_instruction_policy_revisions newer
1987
+ WHERE newer.account_id=p_account AND newer.workspace_id=p_workspace AND newer.kind=revision.kind AND newer.scope=revision.scope
1988
+ AND newer.role_key IS NOT DISTINCT FROM revision.role_key AND newer.revision>revision.revision) THEN
1989
+ RAISE EXCEPTION 'Instruction review is no longer current' USING ERRCODE='40001'; END IF;
1990
+ revision_id:=revision.id; context:=knowledge_instruction_context(revision);
1991
+ target:=jsonb_build_object('kind',revision.kind,'scope',revision.scope,'roleKey',revision.role_key);
1992
+ expected_revision:=(context->>'expectedCurrentRevisionId')::uuid;
1993
+ expected_version:=(context->>'expectedActivationVersion')::bigint;
1994
+ batch:=(context->>'reviewBatchId')::uuid;
1995
+ outcome:=CASE WHEN operation='approve' THEN 'published' ELSE 'rejected' END;
1996
+ ELSE
1997
+ IF length(p_request->>'content') NOT BETWEEN 1 AND 600 OR length(btrim(p_request->>'content'))=0
1998
+ OR jsonb_typeof(p_request->'evidence') IS DISTINCT FROM 'array' OR jsonb_array_length(p_request->'evidence')>32 THEN
1999
+ RAISE EXCEPTION 'Invalid agent-authored instruction content' USING ERRCODE='22023'; END IF;
2000
+ expected_revision:=(p_request->>'expectedCurrentRevisionId')::uuid;
2001
+ expected_version:=(p_request->>'expectedActivationVersion')::bigint;
2002
+ outcome:=CASE WHEN mode='automatic' THEN 'published' ELSE 'pending' END;
2003
+ IF outcome='pending' THEN batch:=knowledge_review_batch_for_actor(p_account,p_workspace,actor); END IF;
2004
+ context:=jsonb_build_object('actor',p_actor,'policy',actor->'policy','evidence',p_request->'evidence','reason',p_request->'reason',
2005
+ 'expectedCurrentRevisionId',expected_revision,'expectedActivationVersion',expected_version,'reviewBatchId',batch);
2006
+ END IF;
2007
+ IF operation<>'reject' THEN
2008
+ PERFORM pg_advisory_xact_lock(hashtextextended('knowledge-publication:'||p_account,0));
2009
+ END IF;
2010
+ SELECT * INTO head FROM workspace_instruction_policy_heads h WHERE h.account_id=p_account AND h.workspace_id=p_workspace
2011
+ AND h.kind=target->>'kind' AND h.scope=target->>'scope' AND h.role_key IS NOT DISTINCT FROM target->>'roleKey' FOR UPDATE;
2012
+ version:=coalesce(head.activation_version,(SELECT max(d.activation_version) FROM workspace_instruction_policy_deactivation_events d
2013
+ WHERE d.account_id=p_account AND d.workspace_id=p_workspace AND d.kind=target->>'kind' AND d.scope=target->>'scope'
2014
+ AND d.role_key IS NOT DISTINCT FROM target->>'roleKey'),0);
2015
+ IF operation<>'reject' AND (head.revision_id IS DISTINCT FROM expected_revision OR version IS DISTINCT FROM expected_version) THEN
2016
+ RAISE EXCEPTION 'Instruction head changed; read its current baseline' USING ERRCODE='40001'; END IF;
2017
+ IF operation<>'reject' THEN
2018
+ FOR link IN SELECT * FROM jsonb_array_elements(context->'evidence') LOOP
2019
+ IF NOT EXISTS(SELECT 1 FROM knowledge_entries e WHERE e.account_id=p_account AND e.id=(link.value->>'entryId')::uuid
2020
+ AND NOT e.archived AND e.scope IN ('workspace','organization') AND knowledge_scope_visible(e)
2021
+ AND knowledge_revision_visible(p_account,e.id,(link.value->>'revisionId')::uuid,false)) THEN
2022
+ RAISE EXCEPTION 'Instruction evidence is unavailable to workspace readers' USING ERRCODE='42501'; END IF;
2023
+ END LOOP;
2024
+ END IF;
2025
+ IF operation='save' THEN
2026
+ content_hash:=encode(sha256(convert_to(p_request->>'content','UTF8')),'hex');
2027
+ INSERT INTO workspace_instruction_policy_revisions(account_id,workspace_id,operation_id,request_fingerprint,kind,scope,role_key,
2028
+ content,content_hash,provenance_source,provenance_source_id,created_by_subject_id,supersedes_revision_id,agent_learning_context)
2029
+ VALUES(p_account,p_workspace,operation_id,fingerprint,target->>'kind',target->>'scope',target->>'roleKey',p_request->>'content',
2030
+ content_hash,'agent_learning',p_actor->>'attemptId',actor_subject,expected_revision,context) RETURNING * INTO revision;
2031
+ revision_id:=revision.id;
2032
+ END IF;
2033
+ IF outcome='published' THEN
2034
+ INSERT INTO workspace_instruction_policy_activation_events(account_id,workspace_id,operation_id,request_fingerprint,
2035
+ kind,scope,role_key,type,activation_version,old_revision_id,old_revision,old_content_hash,new_revision_id,new_revision,new_content_hash,
2036
+ actor_subject_id,reason)
2037
+ VALUES(p_account,p_workspace,operation_id,fingerprint,revision.kind,revision.scope,revision.role_key,'activate',version+1,
2038
+ head.revision_id,head.revision,head.content_hash,revision.id,revision.revision,revision.content_hash,actor_subject,p_request->>'reason');
2039
+ IF head.id IS NULL THEN
2040
+ INSERT INTO workspace_instruction_policy_heads(account_id,workspace_id,kind,scope,role_key,revision_id,revision,content_hash,activation_version)
2041
+ VALUES(p_account,p_workspace,revision.kind,revision.scope,revision.role_key,revision.id,revision.revision,revision.content_hash,version+1);
2042
+ ELSE
2043
+ UPDATE workspace_instruction_policy_heads h SET revision_id=revision.id,revision=revision.revision,content_hash=revision.content_hash,
2044
+ activation_version=version+1,activated_at=transaction_timestamp() WHERE h.account_id=p_account AND h.id=head.id;
2045
+ END IF;
2046
+ END IF;
2047
+ result:=jsonb_build_object('operationId',operation_id,'revisionId',revision_id,'outcome',outcome,'reviewBatchId',batch,'replayed',false);
2048
+ INSERT INTO agent_instruction_operations(account_id,origin_workspace_id,operation_id,revision_id,request_hash,actor,receipt)
2049
+ VALUES(p_account,p_workspace,operation_id,revision_id,fingerprint,p_actor,result);
2050
+ RETURN result;
2051
+ END $$;
2052
+
2053
+ -- Native Skill storage remains the destination; its policy is now unified.
2054
+ CREATE OR REPLACE FUNCTION skill_apply_lifecycle(p_account_id uuid, p_workspace_id uuid, p_actor jsonb, p_request jsonb)
2055
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $body$
2056
+ <<lifecycle>>
2057
+ DECLARE
2058
+ actor_subject text; mode text := 'automatic'; operation text := p_request->>'operation';
2059
+ operation_id uuid := (p_request->>'operationId')::uuid;
2060
+ skill_id uuid := (p_request->>'skillId')::uuid;
2061
+ revision_id uuid; activation_event_id uuid; files jsonb := p_request->'files'; main_content text;
2062
+ head preference_registry_preferences%ROWTYPE; rev preference_registry_revisions%ROWTYPE;
2063
+ source record; binding skill_source_bindings%ROWTYPE;
2064
+ prior skill_write_receipts%ROWTYPE; result jsonb; fingerprint text;
2065
+ scope text := coalesce(p_request->>'scope','workspace'); next_event integer;
2066
+ title text := p_request->>'title'; description text := p_request->>'description';
2067
+ stable_key text := p_request->>'stableKey'; outcome text; source_id text;
2068
+ activation_mode text := 'workspace_managed';
2069
+ deferred_publication jsonb; source_effective boolean := true;
2070
+ confirmation_source skill_write_receipts%ROWTYPE;
2071
+ skill_review jsonb; initiating_human text; confirmation_generation integer;
2072
+ human_choice text; learning_actor jsonb; learning_policy jsonb;
2073
+ original_operation text := p_request->>'operation';
2074
+ BEGIN
2075
+ IF p_account_id IS DISTINCT FROM nullif(current_setting('opengeni.account_id',true),'')::uuid
2076
+ OR p_workspace_id IS DISTINCT FROM nullif(current_setting('opengeni.workspace_id',true),'')::uuid
2077
+ OR p_account_id IS NULL OR p_workspace_id IS NULL OR operation_id IS NULL
2078
+ OR operation NOT IN ('save','install','approve','reject','restore','confirm_response') OR operation IS NULL
2079
+ THEN RAISE EXCEPTION 'Skill lifecycle requires exact tenant context' USING ERRCODE='42501'; END IF;
2080
+ IF operation='confirm_response' THEN
2081
+ PERFORM pg_advisory_xact_lock(hashtextextended('organization-membership:'||p_account_id,0));
2082
+ END IF;
2083
+ PERFORM 1 FROM workspaces WHERE id=p_workspace_id AND account_id=p_account_id FOR KEY SHARE;
2084
+ IF NOT FOUND THEN RAISE EXCEPTION 'Skill workspace unavailable' USING ERRCODE='42501'; END IF;
2085
+ PERFORM pg_advisory_xact_lock(hashtextextended('skill-publication:'||p_workspace_id,0));
2086
+ IF p_actor->>'kind' = 'agent' THEN
2087
+ learning_actor:=knowledge_resolve_actor(p_account_id,p_workspace_id,p_actor);
2088
+ learning_policy:=learning_actor->'policy';
2089
+ scope:=CASE WHEN learning_actor->>'defaultScope'='personal' THEN 'user' ELSE 'workspace' END;
2090
+ IF operation IN ('approve','reject') OR (scope='user' AND operation='install') THEN
2091
+ RAISE EXCEPTION 'Agents cannot review Skills or install workspace plugins from personal learning' USING ERRCODE='42501'; END IF;
2092
+ PERFORM 1 FROM sessions s JOIN session_turns t ON t.id=s.active_turn_id AND t.session_id=s.id
2093
+ JOIN session_turn_attempts a ON a.id=t.active_attempt_id AND a.turn_id=t.id
2094
+ WHERE s.id=(p_actor->>'sessionId')::uuid AND s.account_id=p_account_id AND s.workspace_id=p_workspace_id
2095
+ AND t.id=(p_actor->>'turnId')::uuid AND t.account_id=p_account_id AND t.workspace_id=p_workspace_id
2096
+ AND t.status IN ('running','requires_action','recovering','waiting_capacity')
2097
+ AND a.id=(p_actor->>'attemptId')::uuid AND a.account_id=p_account_id AND a.workspace_id=p_workspace_id
2098
+ AND a.session_id=s.id AND a.execution_generation=(p_actor->>'executionGeneration')::integer
2099
+ AND t.execution_generation=a.execution_generation AND a.state IN ('claimed','running')
2100
+ AND NOT EXISTS (SELECT 1 FROM session_attempt_interruptions i WHERE i.workspace_id=p_workspace_id
2101
+ AND i.attempt_id=a.id AND i.state IN ('pending','delivered','acknowledged'))
2102
+ FOR SHARE OF s,t,a;
2103
+ IF NOT FOUND THEN RAISE EXCEPTION 'Skill write requires exact live attempt' USING ERRCODE='42501'; END IF;
2104
+ actor_subject := 'service:skill-attempt:' || (p_actor->>'attemptId');
2105
+ ELSIF p_actor->>'kind' = 'human' AND p_actor->>'principalKind' = 'human_session'
2106
+ AND p_actor->>'subjectId' = nullif(current_setting('opengeni.subject_id',true),'')
2107
+ AND current_setting('opengeni.principal_kind',true) = 'human_session' THEN
2108
+ actor_subject := p_actor->>'subjectId';
2109
+ ELSIF p_actor->>'kind' = 'service' AND operation = 'install' AND scope = 'workspace'
2110
+ AND p_actor->>'principalKind' IN ('service','api_key','configured_key')
2111
+ AND p_actor->>'subjectId' = nullif(current_setting('opengeni.subject_id',true),'')
2112
+ AND p_actor->>'principalKind' = current_setting('opengeni.principal_kind',true) THEN
2113
+ actor_subject := p_actor->>'subjectId';
2114
+ ELSE RAISE EXCEPTION 'Skill lifecycle actor is not authorized' USING ERRCODE='42501'; END IF;
2115
+
2116
+ IF operation='confirm_response' THEN
2117
+ IF p_actor->>'kind' <> 'human' THEN
2118
+ RAISE EXCEPTION 'Skill chat confirmation requires verified human response admission' USING ERRCODE='42501';
2119
+ END IF;
2120
+ SELECT * INTO confirmation_source FROM skill_write_receipts r
2121
+ WHERE r.account_id=p_account_id AND r.workspace_id=p_workspace_id
2122
+ AND r.operation_id=(p_request->>'sourceOperationId')::uuid;
2123
+ skill_review := confirmation_source.receipt->'skillReview';
2124
+ IF NOT FOUND OR skill_review IS NULL OR confirmation_source.actor->>'kind'<>'agent'
2125
+ THEN
2126
+ RAISE EXCEPTION 'Skill confirmation source unavailable' USING ERRCODE='42501';
2127
+ END IF;
2128
+ SELECT coalesce(t.initiating_human_subject_id, CASE WHEN t.initiator_kind='subject' THEN t.initiator_subject_id END),
2129
+ t.execution_generation INTO initiating_human,confirmation_generation
2130
+ FROM session_turns t JOIN sessions session ON session.active_turn_id=t.id AND session.id=t.session_id
2131
+ JOIN session_human_input_requests response_request ON response_request.session_id=t.session_id
2132
+ AND response_request.turn_id=t.id AND response_request.account_id=p_account_id
2133
+ AND response_request.workspace_id=p_workspace_id
2134
+ AND response_request.id=(p_request->>'humanInputRequestId')::uuid
2135
+ WHERE t.id=(confirmation_source.actor->>'turnId')::uuid
2136
+ AND t.session_id=(confirmation_source.actor->>'sessionId')::uuid AND t.workspace_id=p_workspace_id
2137
+ AND t.account_id=p_account_id AND session.workspace_id=p_workspace_id AND session.account_id=p_account_id
2138
+ AND t.status IN ('running','requires_action','recovering','waiting_capacity')
2139
+ FOR SHARE OF t,session;
2140
+ IF initiating_human IS NULL OR initiating_human IS DISTINCT FROM p_actor->>'subjectId' OR NOT (
2141
+ EXISTS(SELECT 1 FROM workspace_memberships m WHERE m.workspace_id=p_workspace_id AND m.subject_id=initiating_human)
2142
+ OR EXISTS(SELECT 1 FROM organization_memberships m WHERE m.account_id=p_account_id
2143
+ AND m.subject_id=initiating_human AND m.status='active' AND m.personal_workspace_id=p_workspace_id)
2144
+ ) OR EXISTS(SELECT 1 FROM organization_memberships m WHERE m.account_id=p_account_id
2145
+ AND m.subject_id=initiating_human AND m.status<>'active') THEN
2146
+ RAISE EXCEPTION 'Skill confirming human no longer has workspace authority' USING ERRCODE='42501';
2147
+ END IF;
2148
+ SELECT answer->'values'->>0 INTO human_choice
2149
+ FROM session_human_input_requests h
2150
+ CROSS JOIN LATERAL jsonb_array_elements(h.response->'answers') answer
2151
+ WHERE h.id=(p_request->>'humanInputRequestId')::uuid AND h.account_id=p_account_id AND h.workspace_id=p_workspace_id
2152
+ AND h.session_id=(confirmation_source.actor->>'sessionId')::uuid AND h.turn_id=(confirmation_source.actor->>'turnId')::uuid
2153
+ AND h.turn_generation >= (confirmation_source.actor->>'executionGeneration')::integer
2154
+ AND h.turn_generation <= confirmation_generation AND h.status='answered' AND h.responded_by=initiating_human
2155
+ AND h.skill_review_human_authorized AND h.allow_skip=false
2156
+ AND h.response->>'outcome'='answered'
2157
+ AND jsonb_array_length(h.questions)=1 AND jsonb_array_length(h.response->'answers')=1
2158
+ AND h.questions->0->>'id'='skill:'||(skill_review->>'revisionId')
2159
+ AND h.questions->0->>'kind'='single_select'
2160
+ AND h.questions->0->>'label'='Save this Skill?'
2161
+ AND h.questions->0->>'helpText'='Review the complete files before saving. Saving activates this revision immediately.'
2162
+ AND h.questions->0->'required'='true'::jsonb
2163
+ AND coalesce(h.questions->0->'allowOther','false'::jsonb) IN ('false'::jsonb,'true'::jsonb)
2164
+ AND (answer->'other' IS NULL OR answer->'other'='null'::jsonb OR answer->'other'='""'::jsonb)
2165
+ AND h.responded_at IS NOT NULL AND h.created_at >= confirmation_source.created_at
2166
+ AND (h.expires_at IS NULL OR h.responded_at <= h.expires_at)
2167
+ AND h.questions->0->>'prompt'='Save this exact Skill revision for this workspace?'
2168
+ AND h.questions->0->'skillReview'=skill_review
2169
+ AND jsonb_array_length(h.questions->0->'options')=2
2170
+ AND ((h.questions->0->'options'->0) - 'description')='{"id":"save","label":"Save"}'::jsonb
2171
+ AND ((h.questions->0->'options'->1) - 'description')='{"id":"skip","label":"Don''t save"}'::jsonb
2172
+ AND coalesce(h.questions->0->'options'->0->'description','null'::jsonb)='null'::jsonb
2173
+ AND coalesce(h.questions->0->'options'->1->'description','null'::jsonb)='null'::jsonb
2174
+ AND answer->>'questionId'='skill:'||(skill_review->>'revisionId')
2175
+ AND answer->'values' IN ('["save"]'::jsonb,'["skip"]'::jsonb)
2176
+ FOR SHARE OF h;
2177
+ IF NOT FOUND THEN RAISE EXCEPTION 'Exact human Skill confirmation unavailable' USING ERRCODE='42501'; END IF;
2178
+ skill_id := (skill_review->>'skillId')::uuid;
2179
+ p_request := p_request || skill_review || jsonb_build_object('operationId',operation_id,
2180
+ 'reason','Approved once by the initiating human in chat');
2181
+ operation := CASE WHEN human_choice='skip' THEN 'reject' ELSE 'approve' END;
2182
+ actor_subject := initiating_human;
2183
+ END IF;
2184
+ fingerprint := encode(sha256(convert_to(jsonb_build_array(p_actor,p_request)::text,'UTF8')),'hex');
2185
+ PERFORM pg_advisory_xact_lock(hashtextextended('skill-operation:'||p_workspace_id||':'||operation_id,0));
2186
+ SELECT * INTO prior FROM skill_write_receipts r WHERE r.workspace_id=p_workspace_id AND r.operation_id=lifecycle.operation_id;
2187
+ IF FOUND THEN
2188
+ IF prior.fingerprint <> fingerprint OR prior.account_id <> p_account_id THEN
2189
+ RAISE EXCEPTION 'Skill operation key reused with different input' USING ERRCODE='23505'; END IF;
2190
+ RETURN (prior.receipt - 'portableInstall' - 'deferredPublication') || jsonb_build_object('replayed',true);
2191
+ END IF;
2192
+ IF p_actor->>'kind' IN ('agent','service') THEN
2193
+ learning_policy:=coalesce(learning_policy,knowledge_learning_resolve(p_account_id,p_workspace_id,NULL,'defaults',transaction_timestamp()));
2194
+ mode:=learning_policy#>>'{effective,skills}';
2195
+ IF mode='review_first' THEN mode:='suggest'; END IF;
2196
+ IF mode = 'off' THEN RAISE EXCEPTION 'Learning is Off; durable Skill changes are refused' USING ERRCODE='42501'; END IF;
2197
+ END IF;
2198
+
2199
+ IF original_operation='confirm_response' THEN mode := 'automatic'; END IF;
2200
+
2201
+ IF operation = 'install' THEN
2202
+ SELECT f.facet_key, f.activation_mode, v.plugin_id, sf.*, coalesce(jsonb_agg(jsonb_build_object('path',ff.path,'content',ff.content) ORDER BY ff.path COLLATE "C")
2203
+ FILTER (WHERE ff.id IS NOT NULL),'[]'::jsonb) AS files INTO source
2204
+ FROM capability_skill_facets sf JOIN capability_facets f ON f.id=sf.facet_id
2205
+ JOIN capability_plugin_versions v ON v.id=f.plugin_version_id
2206
+ JOIN capability_plugin_installations i ON i.plugin_version_id=v.id AND i.plugin_id=v.plugin_id
2207
+ JOIN capability_facet_installations fi ON fi.plugin_installation_id=i.id AND fi.facet_id=f.id AND fi.status='active'
2208
+ LEFT JOIN capability_skill_files ff ON ff.skill_facet_id=sf.facet_id
2209
+ WHERE sf.facet_id=(p_request->>'skillFacetId')::uuid AND i.account_id=p_account_id
2210
+ AND i.workspace_id=p_workspace_id AND i.status='active'
2211
+ GROUP BY f.facet_key,f.activation_mode,v.plugin_id,sf.facet_id;
2212
+ IF NOT FOUND THEN RAISE EXCEPTION 'Skill source is not installed in this workspace' USING ERRCODE='42501'; END IF;
2213
+ source_effective := skill_source_has_effective_owner(p_account_id,p_workspace_id,source.facet_id);
2214
+ PERFORM pg_advisory_xact_lock(hashtextextended('skill-source:'||p_workspace_id||':'||source.plugin_id||':'||source.facet_key,0));
2215
+ SELECT * INTO binding FROM skill_source_bindings b WHERE b.workspace_id=p_workspace_id
2216
+ AND b.plugin_id=source.plugin_id AND b.facet_key=source.facet_key;
2217
+ skill_id := coalesce(binding.preference_id,gen_random_uuid());
2218
+ files := source.files; title := p_request->>'title'; description := p_request->>'description';
2219
+ stable_key := 'installed-'||replace(skill_id::text,'-',''); source_id := source.facet_id::text;
2220
+ activation_mode := source.activation_mode;
2221
+ END IF;
2222
+ SELECT * INTO head FROM preference_registry_preferences h WHERE h.id=skill_id AND h.account_id=p_account_id FOR UPDATE;
2223
+ IF FOUND THEN
2224
+ IF p_actor->>'kind'='agent' AND head.scope<>scope THEN
2225
+ RAISE EXCEPTION 'Skill outside accepted learning scope' USING ERRCODE='42501'; END IF;
2226
+ scope := head.scope;
2227
+ IF operation <> 'install' THEN
2228
+ SELECT coalesce(r.skill_activation_mode,'workspace_managed') INTO activation_mode
2229
+ FROM preference_registry_revisions r WHERE r.preference_id=head.id
2230
+ ORDER BY (r.id=head.active_revision_id) DESC NULLS LAST,r.revision DESC LIMIT 1;
2231
+ END IF;
2232
+ IF (p_actor->>'kind'='service' AND (head.scope<>'workspace' OR head.scope_workspace_id<>p_workspace_id))
2233
+ OR (p_actor->>'kind'='agent' AND ((head.scope='workspace' AND head.scope_workspace_id<>p_workspace_id)
2234
+ OR (head.scope='user' AND head.scope_subject_id IS DISTINCT FROM learning_actor->>'subjectId')))
2235
+ OR (p_actor->>'kind' = 'human' AND NOT opengeni_private.preference_registry_scope_visible(
2236
+ head.account_id,head.scope,head.scope_workspace_id,head.scope_subject_id)) THEN
2237
+ RAISE EXCEPTION 'Skill is outside authorized scope' USING ERRCODE='42501'; END IF;
2238
+ IF operation = 'install' THEN
2239
+ SELECT * INTO rev FROM preference_registry_revisions WHERE id=head.active_revision_id;
2240
+ IF head.active_revision_id IS NOT NULL AND (rev.provenance_source <> 'portable_skill' OR rev.provenance_source_id=source_id) THEN
2241
+ revision_id := head.active_revision_id; outcome := 'preserved';
2242
+ END IF;
2243
+ ELSIF head.active_revision_id IS DISTINCT FROM (p_request->>'expectedRevisionId')::uuid
2244
+ OR head.scope_version IS DISTINCT FROM (p_request->>'expectedScopeVersion')::integer THEN
2245
+ RAISE EXCEPTION 'Skill head changed' USING ERRCODE='40001';
2246
+ END IF;
2247
+ IF head.status IN ('rejected','superseded') THEN RAISE EXCEPTION 'Skill head is retired' USING ERRCODE='23514'; END IF;
2248
+ ELSE
2249
+ IF operation NOT IN ('save','install') OR (p_request->>'expectedRevisionId') IS NOT NULL
2250
+ OR (operation='save' AND (p_request->>'expectedScopeVersion')::integer IS DISTINCT FROM 1) THEN
2251
+ RAISE EXCEPTION 'Skill head missing or changed' USING ERRCODE='40001'; END IF;
2252
+ INSERT INTO preference_registry_preferences(id,account_id,stable_key,scope,scope_workspace_id,scope_subject_id,created_by_subject_id)
2253
+ VALUES(skill_id,p_account_id,stable_key,scope,CASE WHEN scope='workspace' THEN p_workspace_id END,
2254
+ CASE WHEN scope='user' THEN coalesce(learning_actor->>'subjectId',actor_subject) END,actor_subject) RETURNING * INTO head;
2255
+ END IF;
2256
+
2257
+ IF outcome IS NULL THEN
2258
+ IF operation IN ('approve','restore','reject') THEN
2259
+ SELECT * INTO rev FROM preference_registry_revisions r WHERE r.id=(p_request->>'revisionId')::uuid
2260
+ AND r.account_id=p_account_id AND r.preference_id=skill_id;
2261
+ IF NOT FOUND THEN RAISE EXCEPTION 'Skill revision unavailable' USING ERRCODE='42501'; END IF;
2262
+ IF operation IN ('approve','reject') AND EXISTS(SELECT 1 FROM preference_registry_revisions newer
2263
+ WHERE newer.preference_id=skill_id AND newer.revision>rev.revision) THEN
2264
+ RAISE EXCEPTION 'Skill changed after chat proposal' USING ERRCODE='40001';
2265
+ END IF;
2266
+ IF operation IN ('approve','reject') AND (
2267
+ (operation='reject' AND rev.id=head.active_revision_id)
2268
+ OR EXISTS(SELECT 1 FROM preference_registry_events event
2269
+ WHERE event.preference_id=skill_id AND event.new_revision_id=rev.id AND event.type='rejected')
2270
+ ) THEN RAISE EXCEPTION 'Skill proposal was already settled' USING ERRCODE='40001'; END IF;
2271
+ files := coalesce(rev.skill_files,jsonb_build_array(jsonb_build_object('path','SKILL.md','content',rev.content)));
2272
+ title := p_request->>'title'; description := p_request->>'description';
2273
+ activation_mode := coalesce(rev.skill_activation_mode,'workspace_managed');
2274
+ IF operation='approve' AND rev.provenance_source='portable_skill' AND EXISTS(
2275
+ SELECT 1 FROM skill_source_bindings b WHERE b.preference_id=skill_id AND b.account_id=p_account_id
2276
+ AND NOT skill_source_has_effective_owner(p_account_id,b.workspace_id,rev.provenance_source_id::uuid)) THEN
2277
+ RAISE EXCEPTION 'Skill approval requires a finalized source owner' USING ERRCODE='42501';
2278
+ END IF;
2279
+ END IF;
2280
+ IF NOT skill_files_valid(files) THEN RAISE EXCEPTION 'Invalid Skill text folder' USING ERRCODE='22023'; END IF;
2281
+ SELECT f->>'content' INTO main_content FROM jsonb_array_elements(files) f WHERE f->>'path'='SKILL.md';
2282
+ IF operation IN ('approve','reject') THEN revision_id := rev.id;
2283
+ ELSE
2284
+ INSERT INTO preference_registry_revisions(account_id,preference_id,title,description,content,content_hash,
2285
+ conflict_strategy,provenance_source,provenance_source_id,trust,created_by_subject_id,corrects_revision_id,skill_files,skill_activation_mode)
2286
+ VALUES(p_account_id,skill_id,title,description,main_content,encode(sha256(convert_to(main_content,'UTF8')),'hex'),
2287
+ 'override',CASE WHEN operation='install' THEN 'portable_skill' WHEN p_actor->>'kind'='agent' THEN 'agent' ELSE 'human' END,
2288
+ CASE WHEN operation='install' THEN source_id WHEN p_actor->>'kind'='agent' THEN p_actor->>'attemptId' END,
2289
+ CASE WHEN p_actor->>'kind' IN ('agent','service') THEN 'untrusted_proposal' WHEN scope='user' THEN 'personal'
2290
+ WHEN scope='organization' THEN 'organization_managed' ELSE 'workspace_managed' END,
2291
+ actor_subject,head.active_revision_id,files,activation_mode) RETURNING id INTO revision_id;
2292
+ END IF;
2293
+ SELECT coalesce(max(e.version),0)+1 INTO next_event FROM preference_registry_events e WHERE e.preference_id=skill_id;
2294
+ IF next_event=1 THEN
2295
+ INSERT INTO preference_registry_events(account_id,preference_id,type,version,new_revision_id,new_scope,new_workspace_id,new_subject_id,actor_subject_id,reason)
2296
+ VALUES(p_account_id,skill_id,'proposal_created',next_event,revision_id,head.scope,head.scope_workspace_id,head.scope_subject_id,actor_subject,p_request->>'reason');
2297
+ next_event := next_event+1;
2298
+ END IF;
2299
+ IF mode='automatic' AND operation='install' AND NOT source_effective THEN
2300
+ deferred_publication := jsonb_build_object('expectedRevisionId',head.active_revision_id,
2301
+ 'expectedScopeVersion',head.scope_version,'sourceFacetId',source.facet_id);
2302
+ END IF;
2303
+ IF operation='reject' THEN
2304
+ INSERT INTO preference_registry_events(account_id,preference_id,type,version,new_revision_id,actor_subject_id,reason)
2305
+ VALUES(p_account_id,skill_id,'rejected',next_event,revision_id,actor_subject,coalesce(p_request->>'reason','Declined during review'));
2306
+ outcome := 'preserved';
2307
+ ELSIF mode='automatic' AND deferred_publication IS NULL THEN
2308
+ PERFORM set_config('opengeni.preference_lifecycle_head_id',skill_id::text,true);
2309
+ PERFORM set_config('opengeni.preference_lifecycle_operation','activate',true);
2310
+ UPDATE preference_registry_preferences h SET status='active',active_revision_id=revision_id,
2311
+ active_revision=r.revision,active_content_hash=r.content_hash,activation_version=h.activation_version+1,updated_at=clock_timestamp()
2312
+ FROM preference_registry_revisions r WHERE h.id=skill_id AND r.id=revision_id;
2313
+ INSERT INTO preference_registry_events(account_id,preference_id,type,version,old_revision_id,new_revision_id,actor_subject_id,reason)
2314
+ VALUES(p_account_id,skill_id,'activated',next_event,head.active_revision_id,revision_id,actor_subject,p_request->>'reason') RETURNING id INTO activation_event_id;
2315
+ outcome := 'applied';
2316
+ ELSE outcome := 'pending'; END IF;
2317
+ IF operation='install' THEN
2318
+ INSERT INTO skill_source_bindings VALUES(p_account_id,p_workspace_id,source.plugin_id,source.facet_key,skill_id,source.facet_id)
2319
+ ON CONFLICT(workspace_id,plugin_id,facet_key) DO UPDATE SET skill_facet_id=excluded.skill_facet_id;
2320
+ END IF;
2321
+ END IF;
2322
+ IF operation='install' AND outcome='preserved' THEN
2323
+ UPDATE skill_source_bindings b SET skill_facet_id=source.facet_id
2324
+ WHERE b.workspace_id=p_workspace_id AND b.plugin_id=source.plugin_id AND b.facet_key=source.facet_key;
2325
+ END IF;
2326
+ result := jsonb_build_object('operationId',operation_id,'skillId',skill_id,'revisionId',revision_id,'outcome',outcome,'replayed',false);
2327
+ IF operation='reject' THEN result := result || jsonb_build_object('decision','rejected'); END IF;
2328
+ IF outcome='pending' AND deferred_publication IS NULL AND p_actor->>'kind'='agent' THEN
2329
+ result := result || jsonb_build_object('pendingReason','approval','skillReview',
2330
+ jsonb_build_object('sourceOperationId',operation_id,'skillId',skill_id,'revisionId',revision_id,
2331
+ 'expectedRevisionId',head.active_revision_id,'expectedScopeVersion',head.scope_version));
2332
+ END IF;
2333
+ IF deferred_publication IS NOT NULL THEN result := result || jsonb_build_object('pendingReason','source_finalization'); END IF;
2334
+ IF learning_policy IS NOT NULL THEN result:=result||jsonb_build_object('learningPolicy',learning_policy); END IF;
2335
+ INSERT INTO skill_write_receipts(account_id,workspace_id,operation_id,fingerprint,actor,receipt,activation_event_id)
2336
+ VALUES(p_account_id,p_workspace_id,operation_id,fingerprint,p_actor,
2337
+ (CASE WHEN operation='install' AND p_request ? 'portableInstall'
2338
+ THEN result || jsonb_build_object('portableInstall',p_request->'portableInstall') ELSE result END)
2339
+ || CASE WHEN deferred_publication IS NOT NULL THEN jsonb_build_object('deferredPublication',deferred_publication) ELSE '{}'::jsonb END,
2340
+ activation_event_id);
2341
+ RETURN result;
2342
+ END $body$;
2343
+
2344
+ CREATE OR REPLACE FUNCTION skill_publish_finalized_owner() RETURNS trigger
2345
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
2346
+ DECLARE
2347
+ publication_owner_kind text := CASE TG_TABLE_NAME WHEN 'pack_installations' THEN 'pack' ELSE 'plugin' END;
2348
+ pending record; head preference_registry_preferences%ROWTYPE; revision preference_registry_revisions%ROWTYPE;
2349
+ publication_id uuid; publication_hash text; publication_operation uuid; expected_fingerprint text; event_id uuid;
2350
+ next_event integer; result jsonb; disposition text; current_mode text; parent_target text;
2351
+ publication_actor jsonb := jsonb_build_object('kind','service','subjectId','service:skill-publication','principalKind','service');
2352
+ BEGIN
2353
+ PERFORM pg_advisory_xact_lock(hashtextextended('skill-publication:'||NEW.workspace_id,0));
2354
+ FOR pending IN
2355
+ SELECT DISTINCT receipt.* FROM skill_write_receipts receipt
2356
+ JOIN skill_source_bindings binding ON binding.preference_id=(receipt.receipt->>'skillId')::uuid
2357
+ AND binding.account_id=receipt.account_id AND binding.workspace_id=receipt.workspace_id
2358
+ JOIN capability_facet_installations fi ON fi.facet_id=binding.skill_facet_id
2359
+ AND fi.account_id=binding.account_id AND fi.workspace_id=binding.workspace_id
2360
+ JOIN capability_component_owners owner ON owner.facet_installation_id=fi.id
2361
+ AND owner.account_id=fi.account_id AND owner.workspace_id=fi.workspace_id
2362
+ WHERE receipt.account_id=NEW.account_id AND receipt.workspace_id=NEW.workspace_id
2363
+ AND receipt.receipt ? 'deferredPublication' AND owner.owner_kind=publication_owner_kind AND owner.owner_id=NEW.id::text
2364
+ ORDER BY receipt.created_at,receipt.operation_id
2365
+ LOOP
2366
+ publication_hash := md5('skill-publication:'||pending.operation_id);
2367
+ publication_id := (substr(publication_hash,1,12)||'5'||substr(publication_hash,14,3)||'a'||substr(publication_hash,18,15))::uuid;
2368
+ expected_fingerprint := encode(sha256(convert_to('skill-publication:'||pending.fingerprint,'UTF8')),'hex');
2369
+ IF EXISTS(SELECT 1 FROM skill_write_receipts existing WHERE existing.workspace_id=NEW.workspace_id AND existing.operation_id=publication_id) THEN
2370
+ IF NOT EXISTS(SELECT 1 FROM skill_write_receipts existing WHERE existing.workspace_id=NEW.workspace_id AND existing.operation_id=publication_id
2371
+ AND existing.fingerprint=expected_fingerprint AND existing.receipt->>'sourceOperationId'=pending.operation_id::text) THEN
2372
+ RAISE EXCEPTION 'Skill publication operation identity collision' USING ERRCODE='23505';
2373
+ END IF;
2374
+ CONTINUE;
2375
+ END IF;
2376
+ publication_operation := nullif(current_setting('opengeni.skill_publication_operation_id',true),'')::uuid;
2377
+ IF publication_owner_kind='pack' THEN parent_target := NEW.pack_id;
2378
+ ELSE SELECT plugin_key INTO parent_target FROM capability_plugins WHERE id=NEW.plugin_id; END IF;
2379
+ IF publication_operation IS NULL OR NOT EXISTS(SELECT 1 FROM capability_operations operation
2380
+ WHERE operation.id=publication_operation AND operation.account_id=NEW.account_id AND operation.workspace_id=NEW.workspace_id
2381
+ AND operation.target_kind=publication_owner_kind AND operation.target_id=parent_target AND operation.status='running') THEN
2382
+ RAISE EXCEPTION 'Skill publication requires a claimed parent finalization' USING ERRCODE='42501';
2383
+ END IF;
2384
+ SELECT * INTO head FROM preference_registry_preferences WHERE id=(pending.receipt->>'skillId')::uuid AND account_id=NEW.account_id FOR UPDATE;
2385
+ SELECT * INTO revision FROM preference_registry_revisions WHERE id=(pending.receipt->>'revisionId')::uuid AND account_id=NEW.account_id;
2386
+ disposition := 'preserved'; event_id := NULL; current_mode := 'automatic';
2387
+ IF pending.actor->>'kind' <> 'human' THEN
2388
+ -- Deferred publication uses its original accepted policy. Historical
2389
+ -- receipts receive no new automatic authority during migration.
2390
+ current_mode:=coalesce(pending.receipt#>>'{learningPolicy,effective,skills}','review_first');
2391
+ IF current_mode='review_first' THEN current_mode:='suggest'; END IF;
2392
+ END IF;
2393
+ IF pending.actor->>'kind'='agent' THEN
2394
+ PERFORM 1 FROM sessions session JOIN session_turns turn ON turn.id=session.active_turn_id AND turn.session_id=session.id
2395
+ JOIN session_turn_attempts attempt ON attempt.id=turn.active_attempt_id AND attempt.turn_id=turn.id
2396
+ WHERE session.id=(pending.actor->>'sessionId')::uuid AND session.account_id=NEW.account_id AND session.workspace_id=NEW.workspace_id
2397
+ AND turn.id=(pending.actor->>'turnId')::uuid AND turn.account_id=NEW.account_id AND turn.workspace_id=NEW.workspace_id
2398
+ AND turn.status IN ('running','requires_action','recovering','waiting_capacity')
2399
+ AND attempt.id=(pending.actor->>'attemptId')::uuid AND attempt.account_id=NEW.account_id AND attempt.workspace_id=NEW.workspace_id
2400
+ AND attempt.session_id=session.id AND attempt.execution_generation=(pending.actor->>'executionGeneration')::integer
2401
+ AND turn.execution_generation=attempt.execution_generation AND attempt.state IN ('claimed','running')
2402
+ AND NOT EXISTS(SELECT 1 FROM session_attempt_interruptions interruption WHERE interruption.workspace_id=NEW.workspace_id
2403
+ AND interruption.attempt_id=attempt.id AND interruption.state IN ('pending','delivered','acknowledged'))
2404
+ FOR SHARE OF session,turn,attempt;
2405
+ IF NOT FOUND THEN current_mode := 'suggest'; END IF;
2406
+ END IF;
2407
+ IF head.status NOT IN ('rejected','superseded') AND head.scope='workspace' AND head.scope_workspace_id=NEW.workspace_id
2408
+ AND head.scope_version=(pending.receipt->'deferredPublication'->>'expectedScopeVersion')::integer
2409
+ AND head.active_revision_id IS NOT DISTINCT FROM (pending.receipt->'deferredPublication'->>'expectedRevisionId')::uuid
2410
+ AND revision.revision=(SELECT max(r.revision) FROM preference_registry_revisions r WHERE r.preference_id=head.id)
2411
+ AND (revision.expires_at IS NULL OR revision.expires_at>clock_timestamp())
2412
+ AND EXISTS(SELECT 1 FROM skill_source_bindings binding WHERE binding.preference_id=head.id
2413
+ AND binding.skill_facet_id=(pending.receipt->'deferredPublication'->>'sourceFacetId')::uuid)
2414
+ AND skill_source_has_effective_owner(NEW.account_id,NEW.workspace_id,(pending.receipt->'deferredPublication'->>'sourceFacetId')::uuid)
2415
+ THEN
2416
+ IF current_mode='automatic' THEN
2417
+ PERFORM set_config('opengeni.preference_lifecycle_head_id',head.id::text,true);
2418
+ PERFORM set_config('opengeni.preference_lifecycle_operation','activate',true);
2419
+ UPDATE preference_registry_preferences SET status='active',active_revision_id=revision.id,
2420
+ active_revision=revision.revision,active_content_hash=revision.content_hash,activation_version=activation_version+1,updated_at=clock_timestamp()
2421
+ WHERE id=head.id;
2422
+ SELECT coalesce(max(version),0)+1 INTO next_event FROM preference_registry_events WHERE preference_id=head.id;
2423
+ INSERT INTO preference_registry_events(account_id,preference_id,type,version,old_revision_id,new_revision_id,actor_subject_id,reason)
2424
+ VALUES(NEW.account_id,head.id,'activated',next_event,head.active_revision_id,revision.id,'service:skill-publication',
2425
+ 'Publish previously authorized Skill after composite owner finalization') RETURNING id INTO event_id;
2426
+ disposition := 'applied';
2427
+ ELSE disposition := 'pending'; END IF;
2428
+ END IF;
2429
+ result := jsonb_build_object('operationId',publication_id,'sourceOperationId',pending.operation_id,
2430
+ 'skillId',pending.receipt->>'skillId','revisionId',pending.receipt->>'revisionId','outcome',disposition,
2431
+ 'activationEventId',event_id,'replayed',false,'publicationOperationId',publication_operation);
2432
+ IF disposition='pending' THEN result := result || jsonb_build_object('pendingReason','approval'); END IF;
2433
+ INSERT INTO skill_write_receipts(account_id,workspace_id,operation_id,fingerprint,actor,receipt,activation_event_id)
2434
+ VALUES(NEW.account_id,NEW.workspace_id,publication_id,expected_fingerprint,publication_actor,result,event_id);
2435
+ END LOOP;
2436
+ RETURN NEW;
2437
+ END $$;
2438
+
2439
+ -- Locking an immutable claim needs a narrowly scoped UPDATE policy even though
2440
+ -- no update is permitted. Only the native recovery definer's exact claim can be
2441
+ -- locked; runtime DML grants and the WITH CHECK false remain unchanged.
2442
+ DO $legacy_claim_lock$
2443
+ DECLARE table_name text; claim_column text;
2444
+ BEGIN
2445
+ FOREACH table_name IN ARRAY ARRAY['knowledge_claims','knowledge_claim_evidence','knowledge_change_proposals','knowledge_claim_reviews'] LOOP
2446
+ claim_column:=CASE WHEN table_name='knowledge_claims' THEN 'id' ELSE 'claim_id' END;
2447
+ EXECUTE format('CREATE POLICY knowledge_legacy_confirmation_lock ON %I FOR UPDATE '
2448
+ || 'USING (current_user=%L AND scope_kind=''workspace'' AND account_id=current_setting(''opengeni.account_id'',true)::uuid '
2449
+ || 'AND scope_workspace_id=current_setting(''opengeni.workspace_id'',true)::uuid '
2450
+ || 'AND %I::text=current_setting(''opengeni.knowledge_legacy_lock_claim'',true)) WITH CHECK (false)',table_name,current_user,claim_column);
2451
+ END LOOP;
2452
+ END $legacy_claim_lock$;
2453
+
2454
+ -- Owner-only bridge for answers already bound to native instruction proposals
2455
+ -- at cutover. The original confirmation capability still verifies the exact
2456
+ -- question, initiating human, Task-note evidence and current destination CAS.
2457
+ -- No rebaseline, new draft, or new human authority is synthesized here.
2458
+ CREATE FUNCTION knowledge_instruction_confirm_legacy(p_account uuid,p_workspace uuid,p_actor jsonb,p_request jsonb)
2459
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
2460
+ DECLARE actor jsonb; subject text; candidate record; result jsonb; recovered jsonb;
2461
+ revision workspace_instruction_policy_revisions%ROWTYPE; prior agent_instruction_operations%ROWTYPE;
2462
+ activation governed_learning_activation_receipts%ROWTYPE; unavailable integer:=0; receipts jsonb:='[]'::jsonb;
2463
+ legacy_operation_id uuid; fingerprint text; note_capability uuid:=gen_random_uuid();
2464
+ previous_note_capability text:=current_setting('opengeni.task_note_write_capability',true);
2465
+ previous_legacy_claim text:=current_setting('opengeni.knowledge_legacy_lock_claim',true);
2466
+ BEGIN
2467
+ IF p_actor->>'kind' IS DISTINCT FROM 'agent'
2468
+ OR p_account IS DISTINCT FROM nullif(current_setting('opengeni.account_id',true),'')::uuid
2469
+ OR p_workspace IS DISTINCT FROM nullif(current_setting('opengeni.workspace_id',true),'')::uuid THEN
2470
+ RAISE EXCEPTION 'Legacy instruction confirmation requires exact tenant and agent authority' USING ERRCODE='42501'; END IF;
2471
+ IF p_request->>'operation'='recover' THEN
2472
+ FOR candidate IN
2473
+ SELECT DISTINCT d.proposal_id,d.id AS decision_id,q.id AS request_id
2474
+ FROM agent_instruction_operations imported
2475
+ JOIN governed_learning_decision_receipts d ON d.account_id=imported.account_id AND d.workspace_id=imported.origin_workspace_id
2476
+ AND d.proposal_id::text=imported.actor#>>'{legacyInstructionContext,legacyProposalId}'
2477
+ JOIN session_human_input_requests q ON q.account_id=d.account_id AND q.workspace_id=d.workspace_id
2478
+ AND q.session_id=d.session_id AND q.turn_id=d.turn_id AND q.status='answered'
2479
+ WHERE imported.account_id=p_account AND imported.origin_workspace_id=p_workspace
2480
+ AND imported.request_hash='migration:0461:instruction-review' AND imported.actor->>'kind'='migration'
2481
+ AND d.session_id=(p_actor->>'sessionId')::uuid AND d.turn_id=(p_actor->>'turnId')::uuid
2482
+ AND q.responded_by=d.initiating_human_subject_id
2483
+ AND EXISTS(SELECT 1 FROM jsonb_array_elements(q.response->'answers') a
2484
+ WHERE a->>'questionId'='remember:'||d.proposal_id AND a->'values'='["save"]'::jsonb)
2485
+ ORDER BY d.proposal_id,d.id,q.id
2486
+ LOOP
2487
+ BEGIN
2488
+ recovered:=knowledge_instruction_confirm_legacy(p_account,p_workspace,p_actor,jsonb_build_object(
2489
+ 'operationId',overlay(overlay(md5('0461:instruction-confirm:'||candidate.request_id||':'||candidate.decision_id) placing '5' from 13 for 1) placing '8' from 17 for 1)::uuid,
2490
+ 'proposalId',candidate.proposal_id,'decisionReceiptId',candidate.decision_id,'humanInputRequestId',candidate.request_id));
2491
+ receipts:=receipts||jsonb_build_array(recovered);
2492
+ EXCEPTION WHEN insufficient_privilege OR serialization_failure OR unique_violation OR check_violation THEN unavailable:=unavailable+1;
2493
+ END;
2494
+ END LOOP;
2495
+ RETURN jsonb_build_object('receipts',receipts,'unavailable',unavailable);
2496
+ END IF;
2497
+ -- Match the native activation lock prefix before validating the live attempt.
2498
+ PERFORM 1 FROM workspaces WHERE account_id=p_account AND id=p_workspace FOR UPDATE;
2499
+ actor:=knowledge_resolve_actor(p_account,p_workspace,p_actor);
2500
+ subject:=nullif(current_setting('opengeni.subject_id',true),'');
2501
+ SELECT r.* INTO revision FROM workspace_instruction_policy_revisions r
2502
+ JOIN agent_instruction_operations imported ON imported.account_id=r.account_id AND imported.revision_id=r.id
2503
+ JOIN governed_learning_decision_receipts d ON d.account_id=r.account_id AND d.workspace_id=r.workspace_id
2504
+ AND d.proposal_id::text=imported.actor#>>'{legacyInstructionContext,legacyProposalId}'
2505
+ WHERE r.account_id=p_account AND r.workspace_id=p_workspace
2506
+ AND imported.request_hash='migration:0461:instruction-review' AND imported.actor->>'kind'='migration'
2507
+ AND d.id=(p_request->>'decisionReceiptId')::uuid AND d.proposal_id=(p_request->>'proposalId')::uuid
2508
+ AND d.initiating_human_subject_id=subject AND d.session_id=(p_actor->>'sessionId')::uuid
2509
+ AND d.turn_id=(p_actor->>'turnId')::uuid
2510
+ ORDER BY r.revision DESC,r.created_at DESC,r.id DESC LIMIT 1;
2511
+ IF NOT FOUND THEN RAISE EXCEPTION 'No imported instruction matches this confirmation' USING ERRCODE='42501'; END IF;
2512
+ legacy_operation_id:=(p_request->>'operationId')::uuid;
2513
+ fingerprint:=encode(sha256(convert_to(jsonb_build_array('legacy-instruction-confirmation',p_request,subject)::text,'UTF8')),'hex');
2514
+ SELECT * INTO prior FROM agent_instruction_operations op WHERE op.account_id=p_account AND op.operation_id=legacy_operation_id;
2515
+ IF FOUND THEN
2516
+ IF prior.request_hash IS DISTINCT FROM fingerprint OR prior.revision_id<>revision.id OR prior.origin_workspace_id<>p_workspace THEN
2517
+ RAISE EXCEPTION 'Legacy instruction confirmation operation conflicts' USING ERRCODE='23505'; END IF;
2518
+ RETURN prior.receipt||jsonb_build_object('replayed',true);
2519
+ END IF;
2520
+ IF EXISTS(SELECT 1 FROM agent_instruction_operations op WHERE op.account_id=p_account AND op.revision_id=revision.id
2521
+ AND op.receipt->>'outcome' IN ('published','rejected'))
2522
+ OR EXISTS(SELECT 1 FROM workspace_instruction_policy_revisions newer WHERE newer.account_id=p_account AND newer.workspace_id=p_workspace
2523
+ AND newer.kind=revision.kind AND newer.scope=revision.scope AND newer.role_key IS NOT DISTINCT FROM revision.role_key
2524
+ AND newer.revision>revision.revision) THEN
2525
+ RAISE EXCEPTION 'Imported instruction review is no longer current' USING ERRCODE='40001'; END IF;
2526
+ PERFORM set_config('opengeni.knowledge_legacy_lock_claim',(SELECT d.claim_id::text FROM governed_learning_decision_receipts d
2527
+ WHERE d.account_id=p_account AND d.id=(p_request->>'decisionReceiptId')::uuid),true);
2528
+ -- The exact attempt was validated above. The existing native confirmation
2529
+ -- checks the bound note/root/version/hash; its read needs the note lifecycle lease.
2530
+ INSERT INTO task_note_write_capabilities(backend_pid,transaction_id,capability_id)
2531
+ VALUES(pg_backend_pid(),pg_current_xact_id(),note_capability);
2532
+ PERFORM set_config('opengeni.task_note_write_capability',note_capability::text,true);
2533
+ SELECT * INTO activation FROM activate_human_confirmed_learning_decision(p_account,p_workspace,
2534
+ legacy_operation_id,(p_request->>'decisionReceiptId')::uuid,(p_request->>'humanInputRequestId')::uuid);
2535
+ DELETE FROM task_note_write_capabilities WHERE backend_pid=pg_backend_pid()
2536
+ AND transaction_id=pg_current_xact_id() AND capability_id=note_capability;
2537
+ PERFORM set_config('opengeni.task_note_write_capability',coalesce(previous_note_capability,''),true);
2538
+ PERFORM set_config('opengeni.knowledge_legacy_lock_claim',coalesce(previous_legacy_claim,''),true);
2539
+ IF activation.id IS NULL OR activation.destination_revision_id IS DISTINCT FROM revision.id THEN
2540
+ RAISE EXCEPTION 'Confirmation must activate the exact imported instruction' USING ERRCODE='40001'; END IF;
2541
+ result:=jsonb_build_object('operationId',legacy_operation_id,'revisionId',revision.id,'outcome','published','reviewBatchId',NULL,'replayed',false);
2542
+ INSERT INTO agent_instruction_operations(account_id,origin_workspace_id,operation_id,revision_id,request_hash,actor,receipt)
2543
+ VALUES(p_account,p_workspace,legacy_operation_id,revision.id,fingerprint,p_actor||jsonb_build_object(
2544
+ 'legacyHumanInputRequestId',p_request->'humanInputRequestId','activationReceiptId',activation.id,'initiatingHumanSubjectId',subject),result);
2545
+ RETURN result;
2546
+ END $$;
2547
+
2548
+ -- Recovery only. A migration-bound claim and the exact initiating human's
2549
+ -- canonical answer authorize publication; no legacy Memory or claim is written.
2550
+ CREATE FUNCTION knowledge_entry_confirm_legacy(p_account uuid,p_workspace uuid,p_actor jsonb,p_request jsonb)
2551
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path FROM CURRENT AS $$
2552
+ DECLARE actor jsonb; entry knowledge_entries%ROWTYPE; revision knowledge_entry_revisions%ROWTYPE;
2553
+ question session_human_input_requests%ROWTYPE; evidence knowledge_claim_evidence%ROWTYPE;
2554
+ prior knowledge_entry_operations%ROWTYPE; human_actor jsonb; note jsonb; subject text;
2555
+ candidate record; recovered jsonb; native_recovery jsonb; unavailable integer:=0;
2556
+ prior_principal text:=current_setting('opengeni.principal_kind',true); result jsonb; exact_text text;
2557
+ BEGIN
2558
+ IF p_actor->>'kind' IS DISTINCT FROM 'agent' THEN
2559
+ RAISE EXCEPTION 'Legacy confirmation requires an exact agent attempt' USING ERRCODE='42501'; END IF;
2560
+ IF p_request ? 'proposalId' THEN
2561
+ RETURN knowledge_instruction_confirm_legacy(p_account,p_workspace,p_actor,p_request);
2562
+ END IF;
2563
+ IF p_request->>'operation'='recover' THEN
2564
+ native_recovery:=knowledge_instruction_confirm_legacy(p_account,p_workspace,p_actor,p_request);
2565
+ END IF;
2566
+ actor:=knowledge_resolve_actor(p_account,p_workspace,p_actor);
2567
+ subject:=nullif(current_setting('opengeni.subject_id',true),'');
2568
+ IF p_request->>'operation'='recover' THEN
2569
+ result:='[]'::jsonb;
2570
+ FOR candidate IN
2571
+ SELECT DISTINCT e.legacy_claim_id AS claim_id,q.id AS request_id
2572
+ FROM session_human_input_requests q CROSS JOIN LATERAL jsonb_array_elements(q.questions) question_value
2573
+ JOIN knowledge_entries e ON e.legacy_claim_id::text=substring(question_value->>'id' FROM 10)
2574
+ AND e.account_id=q.account_id AND e.scope_workspace_id=q.workspace_id
2575
+ JOIN knowledge_entry_revisions r ON r.id=e.latest_revision_id AND r.account_id=e.account_id
2576
+ WHERE q.account_id=p_account AND q.workspace_id=p_workspace AND q.session_id=(p_actor->>'sessionId')::uuid
2577
+ AND q.turn_id=(p_actor->>'turnId')::uuid AND q.status='answered' AND q.responded_by=subject
2578
+ AND question_value->>'id' LIKE 'remember:%' AND r.number=1 AND r.legacy_snapshot IS NOT NULL
2579
+ ORDER BY e.legacy_claim_id,q.id
2580
+ LOOP
2581
+ BEGIN
2582
+ recovered:=knowledge_entry_confirm_legacy(p_account,p_workspace,p_actor,jsonb_build_object(
2583
+ 'operationId',overlay(overlay(md5('opengeni:legacy-confirmation:'||candidate.request_id||':'||candidate.claim_id) placing '5' from 13 for 1) placing '8' from 17 for 1)::uuid,
2584
+ 'claimId',candidate.claim_id,'humanInputRequestId',candidate.request_id));
2585
+ result:=result||jsonb_build_array(recovered);
2586
+ EXCEPTION WHEN insufficient_privilege OR serialization_failure THEN unavailable:=unavailable+1;
2587
+ END;
2588
+ END LOOP;
2589
+ RETURN jsonb_build_object('receipts',result,'instructionReceipts',native_recovery->'receipts',
2590
+ 'unavailable',unavailable+coalesce((native_recovery->>'unavailable')::integer,0));
2591
+ END IF;
2592
+
2593
+ SELECT * INTO entry FROM knowledge_entries e WHERE e.account_id=p_account
2594
+ AND e.legacy_claim_id=(p_request->>'claimId')::uuid AND e.scope='workspace' AND e.scope_workspace_id=p_workspace;
2595
+ IF NOT FOUND OR NOT knowledge_scope_visible(entry) OR subject IS NULL THEN
2596
+ RAISE EXCEPTION 'No migrated Knowledge confirmation matches this claim' USING ERRCODE='42501'; END IF;
2597
+ SELECT f.object_value#>>'{}' INTO exact_text FROM knowledge_claims c JOIN knowledge_facts f ON f.id=c.fact_id AND f.account_id=c.account_id
2598
+ WHERE c.account_id=p_account AND c.id=entry.legacy_claim_id AND c.scope_kind='workspace'
2599
+ AND c.scope_workspace_id=p_workspace AND c.initiating_human_subject_id=subject
2600
+ AND c.extraction_method='task-note-promotion-v1' AND f.object_kind='text';
2601
+ IF NOT FOUND THEN RAISE EXCEPTION 'Migrated confirmation content unavailable' USING ERRCODE='42501'; END IF;
2602
+ SELECT * INTO question FROM session_human_input_requests q WHERE q.account_id=p_account AND q.workspace_id=p_workspace
2603
+ AND q.id=(p_request->>'humanInputRequestId')::uuid AND q.session_id=(p_actor->>'sessionId')::uuid
2604
+ AND q.turn_id=(p_actor->>'turnId')::uuid AND q.turn_generation<=(p_actor->>'executionGeneration')::integer
2605
+ AND q.status='answered' AND q.responded_by=subject AND q.response->>'outcome'='answered'
2606
+ AND EXISTS(SELECT 1 FROM jsonb_array_elements(q.questions) value WHERE value->>'id'='remember:'||entry.legacy_claim_id
2607
+ AND value->>'kind'='single_select' AND value->>'prompt'='Save this as workspace knowledge for everyone in this workspace?'
2608
+ AND value->>'helpText'=left(exact_text,2000) AND value->'options'='[{"id":"save","label":"Save"},{"id":"skip","label":"Don''t save"}]'::jsonb
2609
+ AND coalesce((value->>'allowOther')::boolean,false)=false)
2610
+ AND EXISTS(SELECT 1 FROM jsonb_array_elements(q.response->'answers') value
2611
+ WHERE value->>'questionId'='remember:'||entry.legacy_claim_id AND value->'values'='["save"]'::jsonb);
2612
+ IF NOT FOUND THEN RAISE EXCEPTION 'Exact human confirmation unavailable' USING ERRCODE='42501'; END IF;
2613
+ PERFORM pg_advisory_xact_lock(hashtextextended('knowledge-publication:'||p_account,0));
2614
+ SELECT * INTO prior FROM knowledge_entry_operations o WHERE o.account_id=p_account AND o.operation_id=(p_request->>'operationId')::uuid;
2615
+ IF FOUND THEN
2616
+ IF prior.entry_id<>entry.id OR prior.actor->>'legacyHumanInputRequestId' IS DISTINCT FROM question.id::text THEN
2617
+ RAISE EXCEPTION 'Legacy confirmation operation conflicts' USING ERRCODE='23505'; END IF;
2618
+ RETURN prior.receipt||jsonb_build_object('replayed',true);
2619
+ END IF;
2620
+ SELECT * INTO entry FROM knowledge_entries e WHERE e.id=entry.id AND e.account_id=p_account FOR UPDATE;
2621
+ SELECT * INTO revision FROM knowledge_entry_revisions r WHERE r.id=entry.latest_revision_id AND r.account_id=p_account;
2622
+ IF revision.legacy_snapshot IS NULL OR revision.number<>1 THEN
2623
+ RAISE EXCEPTION 'Migrated Knowledge changed; inspect its current review' USING ERRCODE='40001'; END IF;
2624
+ IF entry.published_revision_id=revision.id THEN
2625
+ RETURN jsonb_build_object('operationId',p_request->>'operationId','entryId',entry.id,'revisionId',revision.id,
2626
+ 'version',entry.version,'outcome','published','reviewBatchId',NULL,'replayed',true);
2627
+ END IF;
2628
+ SELECT * INTO evidence FROM knowledge_claim_evidence e WHERE e.account_id=p_account AND e.claim_id=entry.legacy_claim_id
2629
+ AND e.scope_kind='workspace' AND e.scope_workspace_id=p_workspace AND e.polarity='supports'
2630
+ AND e.task_note_id IS NOT NULL AND e.initiating_human_subject_id=subject ORDER BY e.created_at,e.id LIMIT 1;
2631
+ IF NOT FOUND OR EXISTS(SELECT 1 FROM knowledge_claim_evidence e WHERE e.account_id=p_account
2632
+ AND e.claim_id=entry.legacy_claim_id AND e.polarity='contradicts') THEN
2633
+ RAISE EXCEPTION 'Migrated confirmation evidence unavailable' USING ERRCODE='42501'; END IF;
2634
+ note:=knowledge_task_note_source(p_account,p_workspace,p_actor,evidence.task_note_id,evidence.task_note_version);
2635
+ IF note->>'content' IS DISTINCT FROM exact_text OR encode(sha256(convert_to(exact_text,'UTF8')),'hex')<>evidence.content_hash THEN
2636
+ RAISE EXCEPTION 'Migrated confirmation evidence changed' USING ERRCODE='42501'; END IF;
2637
+ human_actor:=jsonb_build_object('kind','human','principalKind','human_session','subjectId',subject,
2638
+ 'writeScopes',jsonb_build_array('workspace'),'settingsScopes','[]'::jsonb,'review',true,
2639
+ 'legacyHumanInputRequestId',question.id,'confirmedFromAttempt',p_actor);
2640
+ PERFORM set_config('opengeni.principal_kind','human_session',true);
2641
+ result:=knowledge_entry_apply(p_account,p_workspace,human_actor,jsonb_build_object('operation','approve',
2642
+ 'operationId',p_request->>'operationId','entryId',entry.id,'revisionId',revision.id,'expectedVersion',entry.version));
2643
+ PERFORM set_config('opengeni.principal_kind',coalesce(prior_principal,''),true);
2644
+ RETURN result;
2645
+ EXCEPTION WHEN OTHERS THEN
2646
+ PERFORM set_config('opengeni.principal_kind',coalesce(prior_principal,''),true);
2647
+ RAISE;
2648
+ END $$;
2649
+
2650
+ -- Only the named gateways are runtime capabilities. Helpers remain owner-only,
2651
+ -- with a pinned dedicated-schema search path and no inherited default grants.
2652
+ DO $secure$
2653
+ DECLARE f record; runtime_role text;
2654
+ BEGIN
2655
+ FOR f IN SELECT p.oid::regprocedure AS identity FROM pg_proc p
2656
+ WHERE p.pronamespace=current_schema()::regnamespace AND (p.proname LIKE 'knowledge_entry_%'
2657
+ OR p.proname IN ('knowledge_reject_history_mutation','knowledge_scope_visible','knowledge_learning_settings_valid',
2658
+ 'knowledge_task_note_source','knowledge_instruction_confirm_legacy','knowledge_instruction_context','agent_instruction_apply','knowledge_review_batch_for_actor','knowledge_learning_context_visible','knowledge_learning_resolve','knowledge_learning_for_turn','knowledge_resolve_actor','knowledge_revision_visible','knowledge_document_visible','knowledge_index_revision',
2659
+ 'knowledge_enqueue_index','knowledge_source_index_wake','knowledge_index_claim','knowledge_index_work','knowledge_index_retention_active','knowledge_document_prepare',
2660
+ 'knowledge_file_owner_immutable','knowledge_media_file_owner_immutable','knowledge_legacy_memory_setting_retired','knowledge_legacy_version_visible','knowledge_validate_links','knowledge_validate_source','knowledge_source_visible','agent_learning_manage')) LOOP
2661
+ EXECUTE format('ALTER FUNCTION %s SET search_path = %I, pg_catalog, pg_temp',f.identity,current_schema());
2662
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC',f.identity);
2663
+ FOR runtime_role IN SELECT jsonb_array_elements_text(current_setting('opengeni.migration_application_roles')::jsonb) LOOP
2664
+ IF runtime_role <> current_user AND EXISTS(SELECT 1 FROM pg_roles WHERE rolname=runtime_role) THEN
2665
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I',f.identity,runtime_role);
2666
+ END IF;
2667
+ END LOOP;
2668
+ END LOOP;
2669
+ END $secure$;
2670
+
2671
+ -- The codec-aware conversion runs inside this same maintenance transaction.
2672
+ -- Published source/memory bytes and IDs are preserved; old tables remain audit
2673
+ -- evidence until their retention boundary, not active retrieval authorities.
2674
+ ALTER TABLE knowledge_review_batches NO FORCE ROW LEVEL SECURITY;
2675
+ ALTER TABLE knowledge_memory_lifecycle_events NO FORCE ROW LEVEL SECURITY;
2676
+ ALTER TABLE knowledge_entities NO FORCE ROW LEVEL SECURITY;
2677
+ ALTER TABLE knowledge_entity_aliases NO FORCE ROW LEVEL SECURITY;
2678
+ ALTER TABLE knowledge_facts NO FORCE ROW LEVEL SECURITY;
2679
+ ALTER TABLE knowledge_claims NO FORCE ROW LEVEL SECURITY;
2680
+ ALTER TABLE knowledge_claim_evidence NO FORCE ROW LEVEL SECURITY;
2681
+ ALTER TABLE knowledge_claim_reviews NO FORCE ROW LEVEL SECURITY;
2682
+ ALTER TABLE knowledge_claim_relations NO FORCE ROW LEVEL SECURITY;
2683
+ ALTER TABLE knowledge_change_proposals NO FORCE ROW LEVEL SECURITY;
2684
+ ALTER TABLE knowledge_document_versions NO FORCE ROW LEVEL SECURITY;
2685
+ ALTER TABLE knowledge_source_objects NO FORCE ROW LEVEL SECURITY;
2686
+ ALTER TABLE knowledge_sources NO FORCE ROW LEVEL SECURITY;
2687
+ ALTER TABLE knowledge_providers NO FORCE ROW LEVEL SECURITY;
2688
+ ALTER TABLE knowledge_source_acl_versions NO FORCE ROW LEVEL SECURITY;
2689
+ ALTER TABLE knowledge_memories NO FORCE ROW LEVEL SECURITY;
2690
+ ALTER TABLE knowledge_memory_relationships NO FORCE ROW LEVEL SECURITY;
2691
+ ALTER TABLE documents NO FORCE ROW LEVEL SECURITY;
2692
+ ALTER TABLE document_chunks NO FORCE ROW LEVEL SECURITY;
2693
+ ALTER TABLE document_bases NO FORCE ROW LEVEL SECURITY;
2694
+ ALTER TABLE knowledge_entries NO FORCE ROW LEVEL SECURITY;
2695
+ ALTER TABLE knowledge_entry_revisions NO FORCE ROW LEVEL SECURITY;
2696
+ ALTER TABLE knowledge_entry_decisions NO FORCE ROW LEVEL SECURITY;
2697
+ ALTER TABLE knowledge_entry_links NO FORCE ROW LEVEL SECURITY;
2698
+ ALTER TABLE knowledge_entry_search NO FORCE ROW LEVEL SECURITY;
2699
+ ALTER TABLE knowledge_index_jobs NO FORCE ROW LEVEL SECURITY;
2700
+ ALTER TABLE remember_knowledge_confirmation_receipts NO FORCE ROW LEVEL SECURITY;
2701
+ ALTER TABLE remember_knowledge_memory_materializations NO FORCE ROW LEVEL SECURITY;
2702
+ -- Keep pending instruction drafts in the one review surface without changing
2703
+ -- their text, provenance, original baseline, or native activation authority.
2704
+ ALTER TABLE workspace_instruction_policy_onboarding_proposals NO FORCE ROW LEVEL SECURITY;
2705
+ ALTER TABLE workspace_instruction_policy_revisions NO FORCE ROW LEVEL SECURITY;
2706
+ ALTER TABLE workspace_instruction_policy_activation_events NO FORCE ROW LEVEL SECURITY;
2707
+ ALTER TABLE governed_learning_decision_receipts NO FORCE ROW LEVEL SECURITY;
2708
+ ALTER TABLE agent_instruction_operations NO FORCE ROW LEVEL SECURITY;
2709
+ INSERT INTO agent_instruction_operations(account_id,origin_workspace_id,operation_id,revision_id,request_hash,actor,receipt,created_at)
2710
+ SELECT r.account_id,r.workspace_id,md5('0461:instruction-review:'||r.id)::uuid,r.id,'migration:0461:instruction-review',
2711
+ jsonb_build_object('kind','migration','subjectId','service:knowledge-migration:0461','legacyInstructionContext',
2712
+ jsonb_build_object('actor',jsonb_build_object('kind','migration','sessionId',decision.session_id),
2713
+ 'reason','Pending instruction from the previous review system','reviewBatchId',NULL,'evidence','[]'::jsonb,
2714
+ 'legacyProposalId',proposal.id,'legacyOnboardingId',onboarding.id,
2715
+ 'expectedCurrentRevisionId',onboarding.baseline_revision_id,'expectedActivationVersion',onboarding.baseline_activation_version)),
2716
+ jsonb_build_object('operationId',md5('0461:instruction-review:'||r.id)::uuid,'revisionId',r.id,
2717
+ 'outcome','pending','reviewBatchId',NULL,'replayed',false),r.created_at
2718
+ FROM workspace_instruction_policy_revisions r
2719
+ JOIN workspace_instruction_policy_onboarding_proposals onboarding ON onboarding.account_id=r.account_id
2720
+ AND onboarding.workspace_id=r.workspace_id AND onboarding.draft_revision_id=r.id AND onboarding.status='proposed'
2721
+ JOIN knowledge_change_proposals proposal ON proposal.account_id=r.account_id AND proposal.id::text=onboarding.source_id
2722
+ AND proposal.target_kind='instruction_policy' AND proposal.status='proposed' AND proposal.scope_kind='workspace'
2723
+ AND proposal.scope_workspace_id=r.workspace_id AND proposal.content_hash=onboarding.source_version
2724
+ LEFT JOIN LATERAL (SELECT d.session_id FROM governed_learning_decision_receipts d
2725
+ WHERE d.account_id=proposal.account_id AND d.workspace_id=r.workspace_id AND d.proposal_id=proposal.id
2726
+ ORDER BY d.created_at DESC,d.id DESC LIMIT 1) decision ON true
2727
+ WHERE NOT EXISTS(SELECT 1 FROM workspace_instruction_policy_activation_events ev
2728
+ WHERE ev.account_id=r.account_id AND ev.workspace_id=r.workspace_id AND ev.new_revision_id=r.id)
2729
+ AND coalesce((SELECT review.state FROM knowledge_claim_reviews review WHERE review.account_id=r.account_id
2730
+ AND review.claim_id=proposal.claim_id ORDER BY review.review_revision DESC LIMIT 1),'proposed') IN ('proposed','approved');
2731
+ ALTER TABLE agent_instruction_operations FORCE ROW LEVEL SECURITY;
2732
+ ALTER TABLE governed_learning_decision_receipts FORCE ROW LEVEL SECURITY;
2733
+ ALTER TABLE workspace_instruction_policy_activation_events FORCE ROW LEVEL SECURITY;
2734
+ ALTER TABLE workspace_instruction_policy_revisions FORCE ROW LEVEL SECURITY;
2735
+ ALTER TABLE workspace_instruction_policy_onboarding_proposals FORCE ROW LEVEL SECURITY;
2736
+ -- Native pending preference proposals become reviewable Skill folder revisions.
2737
+ ALTER TABLE preference_registry_preferences NO FORCE ROW LEVEL SECURITY;
2738
+ ALTER TABLE preference_registry_revisions NO FORCE ROW LEVEL SECURITY;
2739
+ ALTER TABLE preference_registry_events NO FORCE ROW LEVEL SECURITY;
2740
+ ALTER TABLE company_brain_preference_proposal_receipts NO FORCE ROW LEVEL SECURITY;
2741
+ ALTER TABLE skill_write_receipts NO FORCE ROW LEVEL SECURITY;
2742
+ -- opengeni:unified-knowledge-copy-v1
2743
+ SET CONSTRAINTS ALL IMMEDIATE;
2744
+ ALTER TABLE preference_registry_preferences FORCE ROW LEVEL SECURITY;
2745
+ ALTER TABLE preference_registry_revisions FORCE ROW LEVEL SECURITY;
2746
+ ALTER TABLE preference_registry_events FORCE ROW LEVEL SECURITY;
2747
+ ALTER TABLE company_brain_preference_proposal_receipts FORCE ROW LEVEL SECURITY;
2748
+ ALTER TABLE skill_write_receipts FORCE ROW LEVEL SECURITY;
2749
+ DO $copied$
2750
+ BEGIN
2751
+ IF (SELECT count(*) FROM pg_temp.knowledge_conversion_0461 WHERE completed)<>1 THEN
2752
+ RAISE EXCEPTION '0461 Knowledge conversion did not complete' USING ERRCODE='55000';
2753
+ END IF;
2754
+ END $copied$;
2755
+ -- Validate deferred head/revision references before restoring table posture.
2756
+ SET CONSTRAINTS ALL IMMEDIATE;
2757
+ ALTER TABLE knowledge_review_batches FORCE ROW LEVEL SECURITY;
2758
+ ALTER TABLE knowledge_memory_lifecycle_events FORCE ROW LEVEL SECURITY;
2759
+ ALTER TABLE knowledge_entities FORCE ROW LEVEL SECURITY;
2760
+ ALTER TABLE knowledge_entity_aliases FORCE ROW LEVEL SECURITY;
2761
+ ALTER TABLE knowledge_facts FORCE ROW LEVEL SECURITY;
2762
+ ALTER TABLE knowledge_claims FORCE ROW LEVEL SECURITY;
2763
+ ALTER TABLE knowledge_claim_evidence FORCE ROW LEVEL SECURITY;
2764
+ ALTER TABLE knowledge_claim_reviews FORCE ROW LEVEL SECURITY;
2765
+ ALTER TABLE knowledge_claim_relations FORCE ROW LEVEL SECURITY;
2766
+ ALTER TABLE knowledge_change_proposals FORCE ROW LEVEL SECURITY;
2767
+ ALTER TABLE knowledge_document_versions FORCE ROW LEVEL SECURITY;
2768
+ ALTER TABLE knowledge_source_objects FORCE ROW LEVEL SECURITY;
2769
+ ALTER TABLE knowledge_sources FORCE ROW LEVEL SECURITY;
2770
+ ALTER TABLE knowledge_providers FORCE ROW LEVEL SECURITY;
2771
+ ALTER TABLE knowledge_source_acl_versions FORCE ROW LEVEL SECURITY;
2772
+ ALTER TABLE knowledge_memories FORCE ROW LEVEL SECURITY;
2773
+ ALTER TABLE knowledge_memory_relationships FORCE ROW LEVEL SECURITY;
2774
+ ALTER TABLE documents FORCE ROW LEVEL SECURITY;
2775
+ ALTER TABLE document_chunks FORCE ROW LEVEL SECURITY;
2776
+ ALTER TABLE document_bases FORCE ROW LEVEL SECURITY;
2777
+ ALTER TABLE knowledge_entries FORCE ROW LEVEL SECURITY;
2778
+ ALTER TABLE knowledge_entry_revisions FORCE ROW LEVEL SECURITY;
2779
+ ALTER TABLE knowledge_entry_decisions FORCE ROW LEVEL SECURITY;
2780
+ ALTER TABLE knowledge_entry_links FORCE ROW LEVEL SECURITY;
2781
+ ALTER TABLE knowledge_entry_search FORCE ROW LEVEL SECURITY;
2782
+ ALTER TABLE knowledge_index_jobs FORCE ROW LEVEL SECURITY;
2783
+
2784
+ ALTER TABLE remember_knowledge_confirmation_receipts FORCE ROW LEVEL SECURITY;
2785
+ ALTER TABLE remember_knowledge_memory_materializations FORCE ROW LEVEL SECURITY;
2786
+
2787
+ -- Retired Memory is historical evidence only. Even an accidentally retained
2788
+ -- owner-definer helper cannot start a competing Memory writer after cutover.
2789
+ CREATE FUNCTION knowledge_retired_memory_read_only() RETURNS trigger
2790
+ LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
2791
+ BEGIN
2792
+ IF TG_OP='DELETE' AND pg_trigger_depth()>1 AND (
2793
+ NOT EXISTS(SELECT 1 FROM workspaces WHERE id=OLD.workspace_id AND account_id=OLD.account_id)
2794
+ OR NOT EXISTS(SELECT 1 FROM managed_accounts WHERE id=OLD.account_id)) THEN RETURN OLD; END IF;
2795
+ RAISE EXCEPTION 'Memory was replaced by Knowledge; historical Memory is read-only' USING ERRCODE='55000';
2796
+ END $$;
2797
+ CREATE TRIGGER knowledge_memories_retired BEFORE INSERT OR UPDATE OR DELETE ON knowledge_memories
2798
+ FOR EACH ROW EXECUTE FUNCTION knowledge_retired_memory_read_only();
2799
+ DO $retire_memory$
2800
+ DECLARE f record; grantee text;
2801
+ BEGIN
2802
+ FOR f IN SELECT p.oid,p.oid::regprocedure AS identity,p.proowner FROM pg_proc p
2803
+ WHERE p.pronamespace=current_schema()::regnamespace AND p.proname IN (
2804
+ 'knowledge_memory_apply_operation','knowledge_memory_revert_operation',
2805
+ 'materialize_remember_knowledge_memory','confirm_remember_knowledge_claim','knowledge_retired_memory_read_only',
2806
+ 'evaluate_governed_learning_proposal','activate_governed_learning_decision','activate_human_confirmed_learning_decision') LOOP
2807
+ EXECUTE format('ALTER FUNCTION %s SET search_path = %I, pg_catalog, pg_temp',f.identity,current_schema());
2808
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC',f.identity);
2809
+ FOR grantee IN SELECT DISTINCT r.rolname FROM pg_proc p,
2810
+ LATERAL aclexplode(coalesce(p.proacl,acldefault('f',p.proowner))) acl
2811
+ JOIN pg_roles r ON r.oid=acl.grantee WHERE p.oid=f.oid AND acl.grantee<>f.proowner LOOP
2812
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I',f.identity,grantee);
2813
+ END LOOP;
2814
+ END LOOP;
2815
+ FOR grantee IN SELECT DISTINCT r.rolname FROM pg_class t,
2816
+ LATERAL aclexplode(coalesce(t.relacl,acldefault('r',t.relowner))) acl
2817
+ JOIN pg_roles r ON r.oid=acl.grantee WHERE t.oid='knowledge_memories'::regclass AND acl.grantee<>t.relowner LOOP
2818
+ EXECUTE format('REVOKE INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER ON knowledge_memories FROM %I',grantee);
2819
+ END LOOP;
2820
+ END $retire_memory$;
2821
+
2822
+ -- Historical learning snapshots still explain old decisions. Their policy
2823
+ -- authority is frozen: all new settings mutations use agent_learning_manage.
2824
+ CREATE FUNCTION knowledge_retired_learning_read_only() RETURNS trigger
2825
+ LANGUAGE plpgsql SET search_path FROM CURRENT AS $$
2826
+ BEGIN
2827
+ IF TG_OP='DELETE' AND pg_trigger_depth()>1 AND (
2828
+ NOT EXISTS(SELECT 1 FROM workspaces WHERE id=OLD.workspace_id AND account_id=OLD.account_id)
2829
+ OR NOT EXISTS(SELECT 1 FROM managed_accounts WHERE id=OLD.account_id)) THEN RETURN OLD; END IF;
2830
+ RAISE EXCEPTION 'Learning settings moved to Agent learning' USING ERRCODE='55000';
2831
+ END $$;
2832
+ CREATE TRIGGER knowledge_learning_revisions_retired BEFORE INSERT OR UPDATE OR DELETE ON workspace_learning_policy_revisions
2833
+ FOR EACH ROW EXECUTE FUNCTION knowledge_retired_learning_read_only();
2834
+ CREATE TRIGGER knowledge_learning_heads_retired BEFORE INSERT OR UPDATE OR DELETE ON workspace_learning_policy_heads
2835
+ FOR EACH ROW EXECUTE FUNCTION knowledge_retired_learning_read_only();
2836
+ CREATE TRIGGER knowledge_learning_events_retired BEFORE INSERT OR UPDATE OR DELETE ON workspace_learning_policy_activation_events
2837
+ FOR EACH ROW EXECUTE FUNCTION knowledge_retired_learning_read_only();
2838
+ DO $retire_learning$
2839
+ DECLARE f record; grantee text; t text;
2840
+ BEGIN
2841
+ FOR f IN SELECT p.oid,p.oid::regprocedure AS identity,p.proowner FROM pg_proc p
2842
+ WHERE p.pronamespace=current_schema()::regnamespace AND p.proname IN (
2843
+ 'workspace_learning_policy_apply_activation','knowledge_retired_learning_read_only') LOOP
2844
+ EXECUTE format('ALTER FUNCTION %s SET search_path = %I, pg_catalog, pg_temp',f.identity,current_schema());
2845
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC',f.identity);
2846
+ FOR grantee IN SELECT DISTINCT r.rolname FROM pg_proc p,
2847
+ LATERAL aclexplode(coalesce(p.proacl,acldefault('f',p.proowner))) acl
2848
+ JOIN pg_roles r ON r.oid=acl.grantee WHERE p.oid=f.oid AND acl.grantee<>f.proowner LOOP
2849
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM %I',f.identity,grantee);
2850
+ END LOOP;
2851
+ END LOOP;
2852
+ FOREACH t IN ARRAY ARRAY['workspace_learning_policy_revisions','workspace_learning_policy_heads','workspace_learning_policy_activation_events'] LOOP
2853
+ FOR grantee IN SELECT DISTINCT r.rolname FROM pg_class c,
2854
+ LATERAL aclexplode(coalesce(c.relacl,acldefault('r',c.relowner))) acl
2855
+ JOIN pg_roles r ON r.oid=acl.grantee WHERE c.oid=t::regclass AND acl.grantee<>c.relowner LOOP
2856
+ EXECUTE format('REVOKE INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER ON %I FROM %I',t,grantee);
2857
+ END LOOP;
2858
+ END LOOP;
2859
+ END $retire_learning$;