@gmickel/gno 1.28.0 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +33 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/browser-extension/dist/preview.html +1 -1
  8. package/browser-extension/dist/service-worker.js +6 -6
  9. package/package.json +1 -1
  10. package/spec/cli.md +41 -1
  11. package/spec/db/schema.sql +19 -0
  12. package/spec/mcp.md +66 -0
  13. package/spec/output-schemas/ask.schema.json +31 -1
  14. package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
  15. package/spec/output-schemas/collection-egress-check.schema.json +91 -0
  16. package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
  17. package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
  18. package/spec/output-schemas/context-capsule-v1.schema.json +89 -2
  19. package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
  20. package/spec/output-schemas/egress-audit-management.schema.json +88 -0
  21. package/spec/output-schemas/mcp-http-error.schema.json +113 -2
  22. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  23. package/spec/output-schemas/record-import.schema.json +30 -0
  24. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  25. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  26. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  27. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  28. package/spec/output-schemas/search-results.schema.json +30 -0
  29. package/src/app/context-runtime-contract.ts +16 -7
  30. package/src/cli/commands/ask.ts +7 -0
  31. package/src/cli/commands/collection/index.ts +5 -0
  32. package/src/cli/commands/collection/policy.ts +126 -0
  33. package/src/cli/commands/context-build.ts +5 -0
  34. package/src/cli/commands/daemon.ts +45 -3
  35. package/src/cli/commands/egress-audit.ts +68 -0
  36. package/src/cli/commands/embed.ts +2 -0
  37. package/src/cli/commands/query.ts +12 -0
  38. package/src/cli/commands/replay.ts +13 -3
  39. package/src/cli/commands/vsearch.ts +3 -1
  40. package/src/cli/program.ts +123 -0
  41. package/src/config/index.ts +9 -0
  42. package/src/config/types.ts +50 -0
  43. package/src/core/browser-clip.ts +16 -1
  44. package/src/core/collection-egress-policy-projection.ts +28 -0
  45. package/src/core/collection-egress-policy-service.ts +443 -0
  46. package/src/core/collection-egress-policy-validation.ts +242 -0
  47. package/src/core/config-mutation.ts +31 -19
  48. package/src/core/context-capsule-schema.ts +111 -1
  49. package/src/core/context-capsule-validation.ts +4 -4
  50. package/src/core/context-capsule-verification.ts +5 -1
  51. package/src/core/context-capsule.ts +177 -132
  52. package/src/core/context-evidence.ts +86 -14
  53. package/src/core/destination-classifier.ts +402 -0
  54. package/src/core/egress-audit.ts +239 -0
  55. package/src/core/egress-authorization.ts +50 -0
  56. package/src/core/egress-enforcement.ts +264 -0
  57. package/src/core/egress-policy.ts +440 -0
  58. package/src/core/egress-provenance.ts +336 -0
  59. package/src/core/job-manager.ts +37 -0
  60. package/src/core/network-boundary-inventory.ts +261 -0
  61. package/src/core/retrieval-qrels.ts +6 -0
  62. package/src/core/retrieval-replay.ts +18 -4
  63. package/src/core/retrieval-trace-export.ts +23 -4
  64. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  65. package/src/core/retrieval-trace-management-types.ts +3 -0
  66. package/src/core/retrieval-trace-management.ts +9 -1
  67. package/src/core/retrieval-trace-session.ts +94 -0
  68. package/src/core/retrieval-trace.ts +22 -0
  69. package/src/ingestion/record-adapter.ts +12 -1
  70. package/src/ingestion/record-container.ts +38 -22
  71. package/src/ingestion/sync.ts +15 -0
  72. package/src/ingestion/types.ts +2 -0
  73. package/src/llm/errors.ts +10 -0
  74. package/src/llm/http-inference.ts +175 -0
  75. package/src/llm/http-policy.ts +537 -0
  76. package/src/llm/httpEmbedding.ts +47 -28
  77. package/src/llm/httpGeneration.ts +31 -18
  78. package/src/llm/httpRerank.ts +30 -18
  79. package/src/llm/index.ts +1 -0
  80. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  81. package/src/llm/pinned-http-connection.ts +177 -0
  82. package/src/mcp/context.ts +53 -1
  83. package/src/mcp/http-egress.ts +220 -0
  84. package/src/mcp/http-security.ts +32 -21
  85. package/src/mcp/http-session.ts +13 -2
  86. package/src/mcp/http-transport.ts +96 -6
  87. package/src/mcp/sync-egress.ts +24 -0
  88. package/src/mcp/tools/add-collection.ts +4 -0
  89. package/src/mcp/tools/ask.ts +1 -1
  90. package/src/mcp/tools/context.ts +9 -2
  91. package/src/mcp/tools/egress.ts +247 -0
  92. package/src/mcp/tools/embed.ts +1 -0
  93. package/src/mcp/tools/index-cmd.ts +7 -0
  94. package/src/mcp/tools/index.ts +98 -0
  95. package/src/mcp/tools/query.ts +10 -0
  96. package/src/mcp/tools/remove-collection.ts +4 -0
  97. package/src/mcp/tools/sync.ts +6 -0
  98. package/src/mcp/tools/trace.ts +7 -1
  99. package/src/mcp/tools/vsearch.ts +1 -0
  100. package/src/pipeline/egress-lineage.ts +124 -0
  101. package/src/pipeline/hybrid.ts +14 -0
  102. package/src/pipeline/search.ts +12 -0
  103. package/src/pipeline/types.ts +3 -0
  104. package/src/pipeline/vsearch.ts +41 -20
  105. package/src/publish/artifact.ts +31 -3
  106. package/src/publish/export-service.ts +25 -0
  107. package/src/sdk/client.ts +119 -2
  108. package/src/sdk/embed.ts +1 -0
  109. package/src/sdk/types.ts +33 -0
  110. package/src/serve/clipper-capture.ts +5 -1
  111. package/src/serve/closed-json.ts +61 -0
  112. package/src/serve/config-sync.ts +4 -1
  113. package/src/serve/context.ts +1 -0
  114. package/src/serve/doc-events.ts +110 -39
  115. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  116. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  117. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  118. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  119. package/src/serve/public/globals.built.css +1 -1
  120. package/src/serve/public/pages/Collections.tsx +3 -0
  121. package/src/serve/resident-request.ts +80 -3
  122. package/src/serve/resident-runtime.ts +63 -1
  123. package/src/serve/routes/api.ts +174 -0
  124. package/src/serve/routes/clipper.ts +64 -1
  125. package/src/serve/routes/mcp.ts +6 -0
  126. package/src/serve/routes/traces.ts +27 -4
  127. package/src/serve/server.ts +118 -7
  128. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  129. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  130. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  131. package/src/store/migrations/index.ts +6 -0
  132. package/src/store/retrieval-trace-codec.ts +15 -0
  133. package/src/store/sqlite/adapter.ts +170 -7
  134. package/src/store/sqlite/change-journal-store.ts +41 -2
  135. package/src/store/sqlite/egress-audit-store.ts +485 -0
  136. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  137. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  138. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  139. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  140. package/src/store/types.ts +126 -1
  141. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip +0 -0
  142. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +0 -1
  143. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import type { EgressLineage } from "./egress-provenance";
4
+
3
5
  import { canonicalize } from "../converters/canonicalize";
4
6
  import {
5
7
  BROWSER_CLIP_MAX_BYTES,
@@ -15,6 +17,10 @@ import {
15
17
  type CaptureInput,
16
18
  type CaptureSource,
17
19
  } from "./capture";
20
+ import {
21
+ egressLineageSchema,
22
+ legacyLocalOnlyEgressLineage,
23
+ } from "./egress-provenance";
18
24
 
19
25
  export {
20
26
  BROWSER_CLIP_MAX_BYTES,
@@ -206,12 +212,14 @@ export type BrowserClipBlock = z.infer<typeof readerBlockSchema>;
206
212
  export interface PreparedBrowserClip {
207
213
  payload: BrowserClipPayload;
208
214
  captureInput: CaptureInput;
215
+ egressLineage: EgressLineage;
209
216
  provenance: BrowserClipProvenance;
210
217
  preview: {
211
218
  body: string;
212
219
  digest: string;
213
220
  source: CaptureSource;
214
221
  destination: BrowserClipPayload["destination"];
222
+ egressLineage: EgressLineage;
215
223
  tags: string[];
216
224
  };
217
225
  }
@@ -330,9 +338,13 @@ const dedupeWarnings = (
330
338
 
331
339
  export const prepareBrowserClip = (
332
340
  input: unknown,
333
- options: { now?: Date } = {}
341
+ options: { now?: Date; egressLineage?: EgressLineage } = {}
334
342
  ): PreparedBrowserClip => {
335
343
  const payload = browserClipPayloadSchema.parse(input);
344
+ const egressLineage = egressLineageSchema.parse(
345
+ options.egressLineage ??
346
+ legacyLocalOnlyEgressLineage(payload.destination.collection)
347
+ );
336
348
  const capturedAt = (options.now ?? new Date()).toISOString();
337
349
  const sourceUrl = normalizeUrl(payload.sourceUrl);
338
350
  const canonicalUrl =
@@ -411,6 +423,7 @@ export const prepareBrowserClip = (
411
423
  stableJson({
412
424
  body,
413
425
  destination: payload.destination,
426
+ egressLineage,
414
427
  extraction: payload.extraction,
415
428
  provenance: previewProvenance,
416
429
  source: previewSource,
@@ -461,12 +474,14 @@ export const prepareBrowserClip = (
461
474
  return {
462
475
  payload,
463
476
  captureInput,
477
+ egressLineage,
464
478
  provenance,
465
479
  preview: {
466
480
  body,
467
481
  digest: previewDigest,
468
482
  source,
469
483
  destination: payload.destination,
484
+ egressLineage,
470
485
  tags: payload.tags,
471
486
  },
472
487
  };
@@ -0,0 +1,28 @@
1
+ /** Targeted config-to-store projection for one egress policy mutation. */
2
+
3
+ import type { Config } from "../config/types";
4
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
5
+
6
+ import { parseCollectionName } from "./collection-egress-policy-validation";
7
+
8
+ export const projectCollectionEgressPolicy = async (
9
+ store: SqliteAdapter,
10
+ config: Config,
11
+ collectionValue: unknown
12
+ ): Promise<{ ok: true } | { ok: false; error: string }> => {
13
+ const parsed = parseCollectionName(collectionValue);
14
+ if (!parsed.ok) return { ok: false, error: parsed.error };
15
+ const collection = config.collections.find(
16
+ ({ name }) => name === parsed.value
17
+ );
18
+ if (!collection) {
19
+ return {
20
+ ok: false,
21
+ error: "Collection policy projection target is missing",
22
+ };
23
+ }
24
+ const projected = await store.upsertCollections([collection]);
25
+ return projected.ok
26
+ ? { ok: true }
27
+ : { ok: false, error: projected.error.message };
28
+ };
@@ -0,0 +1,443 @@
1
+ /** Shared collection egress policy management and explanation contract. */
2
+
3
+ import type { Config, EgressPolicy, EgressPolicySource } from "../config/types";
4
+ import type { ApplyConfigResult, MutationResult } from "./config-mutation";
5
+ import type {
6
+ EgressAction,
7
+ EgressCallerContext,
8
+ EgressContentClass,
9
+ EgressDecision,
10
+ EgressDestinationZone,
11
+ } from "./egress-policy";
12
+
13
+ import { resolveConfiguredEgressPolicy } from "../config/types";
14
+ import { hashTraceCanonical } from "../store/retrieval-trace-codec";
15
+ import {
16
+ parseCollectionName,
17
+ parsePolicyCheckInput,
18
+ parsePolicySetInput,
19
+ } from "./collection-egress-policy-validation";
20
+ import { EgressDeniedError, planCollectionEgress } from "./egress-enforcement";
21
+ import { evaluateEgressPolicy } from "./egress-policy";
22
+ import {
23
+ EgressProvenanceError,
24
+ resolveEgressLineage,
25
+ } from "./egress-provenance";
26
+
27
+ const POLICY_ORDER: Readonly<Record<EgressPolicy, number>> = {
28
+ local_only: 0,
29
+ lan: 1,
30
+ remote: 2,
31
+ };
32
+
33
+ export interface CollectionEgressPolicyState {
34
+ schemaVersion: "1.0";
35
+ collection: string;
36
+ configuredPolicy: EgressPolicy | null;
37
+ effectivePolicy: EgressPolicy;
38
+ source: Extract<EgressPolicySource, "explicit" | "config_default">;
39
+ revision: number;
40
+ version: string;
41
+ }
42
+
43
+ export interface EgressRelaxationConfirmation {
44
+ collection: string;
45
+ currentPolicy: EgressPolicy;
46
+ currentRevision: number;
47
+ targetPolicy: EgressPolicy;
48
+ acknowledged: true;
49
+ }
50
+
51
+ export interface CollectionEgressPolicySetResult {
52
+ schemaVersion: "1.0";
53
+ previous: CollectionEgressPolicyState;
54
+ current: CollectionEgressPolicyState;
55
+ change: "tightened" | "relaxed" | "source_changed" | "unchanged";
56
+ invalidation: null | {
57
+ policyEpoch: string;
58
+ queuedJobsInvalidated: number;
59
+ sessionsInvalidated: number;
60
+ staleWorkMustRetry: true;
61
+ };
62
+ }
63
+
64
+ export interface CollectionEgressCheckInput {
65
+ collections?: readonly string[];
66
+ action: EgressAction;
67
+ destinationZone: EgressDestinationZone;
68
+ caller: EgressCallerContext;
69
+ contentClass: EgressContentClass;
70
+ partialResults?: "deny" | "explicit";
71
+ }
72
+
73
+ export interface CollectionEgressCheckResult {
74
+ schemaVersion: "1.0";
75
+ mode: "complete" | "partial" | "denied";
76
+ allowedCollections: string[];
77
+ omittedCollections: Array<{
78
+ collection: string;
79
+ reason: EgressDecision["reason"];
80
+ }>;
81
+ disclosure: null | {
82
+ code: "EGRESS_PARTIAL_RESULT";
83
+ omittedCount: number;
84
+ omittedCollections: string[];
85
+ };
86
+ lineage: ReturnType<typeof resolveEgressLineage>;
87
+ decision: EgressDecision;
88
+ remediation: null | {
89
+ code: EgressDecision["reason"];
90
+ message: string;
91
+ };
92
+ }
93
+
94
+ export interface CollectionEgressPolicyServiceDeps {
95
+ getConfig: () => Config;
96
+ mutateConfig?: (
97
+ mutate: (config: Config) => MutationResult<CollectionEgressPolicySetResult>
98
+ ) => Promise<ApplyConfigResult<CollectionEgressPolicySetResult>>;
99
+ onPolicyChanged?: (
100
+ result: CollectionEgressPolicySetResult
101
+ ) =>
102
+ | Promise<NonNullable<CollectionEgressPolicySetResult["invalidation"]>>
103
+ | NonNullable<CollectionEgressPolicySetResult["invalidation"]>;
104
+ }
105
+
106
+ export type CollectionEgressPolicyServiceResult<T> =
107
+ | { ok: true; value: T }
108
+ | { ok: false; code: string; error: string };
109
+
110
+ const stateVersion = (input: {
111
+ collection: string;
112
+ effectivePolicy: EgressPolicy;
113
+ source: EgressPolicySource;
114
+ revision: number;
115
+ }): string =>
116
+ `egress-policy-v1:${hashTraceCanonical({
117
+ collection: input.collection,
118
+ effectivePolicy: input.effectivePolicy,
119
+ source: input.source,
120
+ revision: input.revision,
121
+ })}`;
122
+
123
+ export const collectionEgressPolicyEpoch = (config: Config): string =>
124
+ `egress-epoch-v1:${hashTraceCanonical(
125
+ config.collections
126
+ .map((collection) => {
127
+ const effective = resolveConfiguredEgressPolicy(collection);
128
+ return {
129
+ collection: collection.name,
130
+ policy: effective.policy,
131
+ source: effective.source,
132
+ revision: collection.egressPolicyRevision ?? 0,
133
+ };
134
+ })
135
+ .sort((left, right) => left.collection.localeCompare(right.collection))
136
+ )}`;
137
+
138
+ /** Rebind immutable source membership to current policies at execution time. */
139
+ export const currentEgressSources = (
140
+ config: { collections: readonly Config["collections"][number][] },
141
+ collectionNames: readonly string[]
142
+ ) =>
143
+ collectionNames.map((name) => {
144
+ const collection = config.collections.find(
145
+ (candidate) => candidate.name === name
146
+ );
147
+ const effective = collection
148
+ ? resolveConfiguredEgressPolicy(collection)
149
+ : {
150
+ policy: "local_only" as const,
151
+ source: "legacy_default" as const,
152
+ };
153
+ return {
154
+ collection: name,
155
+ policy: effective.policy,
156
+ source: effective.source,
157
+ };
158
+ });
159
+
160
+ const policyState = (
161
+ config: Config,
162
+ name: string
163
+ ): CollectionEgressPolicyState | null => {
164
+ const collection = config.collections.find(
165
+ (candidate) => candidate.name === name.toLowerCase()
166
+ );
167
+ if (!collection) return null;
168
+ const effective = resolveConfiguredEgressPolicy(collection);
169
+ return {
170
+ schemaVersion: "1.0",
171
+ collection: collection.name,
172
+ configuredPolicy: collection.egressPolicy ?? null,
173
+ effectivePolicy: effective.policy,
174
+ source: effective.source,
175
+ revision: collection.egressPolicyRevision ?? 0,
176
+ version: stateVersion({
177
+ collection: collection.name,
178
+ effectivePolicy: effective.policy,
179
+ source: effective.source,
180
+ revision: collection.egressPolicyRevision ?? 0,
181
+ }),
182
+ };
183
+ };
184
+
185
+ const remediationFor = (
186
+ decision: EgressDecision
187
+ ): CollectionEgressCheckResult["remediation"] => {
188
+ if (decision.allowed) return null;
189
+ const messages: Partial<Record<EgressDecision["reason"], string>> = {
190
+ AUTHENTICATION_REQUIRED:
191
+ "Authenticate the destination without changing collection policy.",
192
+ CALLER_NOT_AUTHORIZED:
193
+ "Authorize this operation separately from collection policy.",
194
+ POLICY_LOCAL_ONLY:
195
+ "Keep the action local or explicitly relax every source collection.",
196
+ POLICY_LAN_ONLY:
197
+ "Use an authenticated LAN destination or explicitly allow remote egress.",
198
+ };
199
+ return {
200
+ code: decision.reason,
201
+ message:
202
+ messages[decision.reason] ??
203
+ "Correct the action, destination, caller, content class, or collection scope.",
204
+ };
205
+ };
206
+
207
+ export class CollectionEgressPolicyService {
208
+ constructor(private readonly deps: CollectionEgressPolicyServiceDeps) {}
209
+
210
+ get(
211
+ name: unknown
212
+ ): CollectionEgressPolicyServiceResult<CollectionEgressPolicyState> {
213
+ const parsed = parseCollectionName(name);
214
+ if (!parsed.ok) return parsed;
215
+ const state = policyState(this.deps.getConfig(), parsed.value);
216
+ return state
217
+ ? { ok: true, value: state }
218
+ : {
219
+ ok: false,
220
+ code: "NOT_FOUND",
221
+ error: `Collection not found: ${parsed.value}`,
222
+ };
223
+ }
224
+
225
+ async set(
226
+ input: unknown
227
+ ): Promise<
228
+ CollectionEgressPolicyServiceResult<CollectionEgressPolicySetResult>
229
+ > {
230
+ const parsed = parsePolicySetInput(input);
231
+ if (!parsed.ok) return parsed;
232
+ if (!this.deps.mutateConfig) {
233
+ return {
234
+ ok: false,
235
+ code: "READ_ONLY",
236
+ error: "Collection policy mutation is not available",
237
+ };
238
+ }
239
+ const changed = await this.deps.mutateConfig((config) => {
240
+ const previous = policyState(config, parsed.value.collection);
241
+ if (!previous) {
242
+ return {
243
+ ok: false,
244
+ code: "NOT_FOUND",
245
+ error: `Collection not found: ${parsed.value.collection}`,
246
+ };
247
+ }
248
+ const orderDelta =
249
+ POLICY_ORDER[parsed.value.policy] -
250
+ POLICY_ORDER[previous.effectivePolicy];
251
+ const sourceChanged = previous.source !== "explicit";
252
+ const confirmation = parsed.value.confirmation;
253
+ const confirmationMatches =
254
+ confirmation?.acknowledged === true &&
255
+ confirmation.collection === previous.collection &&
256
+ confirmation.currentPolicy === previous.effectivePolicy &&
257
+ confirmation.currentRevision === previous.revision &&
258
+ confirmation.targetPolicy === parsed.value.policy;
259
+ if (
260
+ (confirmation && !confirmationMatches) ||
261
+ (orderDelta > 0 && !confirmationMatches)
262
+ ) {
263
+ return {
264
+ ok: false,
265
+ code: "EGRESS_RELAXATION_CONFIRMATION_REQUIRED",
266
+ error:
267
+ "Relaxing collection egress requires confirmation bound to the current policy revision",
268
+ };
269
+ }
270
+ if (orderDelta === 0 && !sourceChanged) {
271
+ return {
272
+ ok: true,
273
+ config,
274
+ skipSave: true,
275
+ value: {
276
+ schemaVersion: "1.0",
277
+ previous,
278
+ current: previous,
279
+ change: "unchanged",
280
+ invalidation: null,
281
+ },
282
+ };
283
+ }
284
+ if (previous.revision >= Number.MAX_SAFE_INTEGER) {
285
+ return {
286
+ ok: false,
287
+ code: "CONSTRAINT_VIOLATION",
288
+ error: "Collection egress policy revision is exhausted",
289
+ };
290
+ }
291
+ const collections = config.collections.map((collection) =>
292
+ collection.name === previous.collection
293
+ ? {
294
+ ...collection,
295
+ egressPolicy: parsed.value.policy,
296
+ egressPolicyRevision: previous.revision + 1,
297
+ }
298
+ : collection
299
+ );
300
+ const nextConfig = { ...config, collections };
301
+ const current = policyState(nextConfig, previous.collection);
302
+ if (!current) {
303
+ return {
304
+ ok: false,
305
+ code: "NOT_FOUND",
306
+ error: `Collection not found: ${parsed.value.collection}`,
307
+ };
308
+ }
309
+ return {
310
+ ok: true,
311
+ config: nextConfig,
312
+ value: {
313
+ schemaVersion: "1.0",
314
+ previous,
315
+ current,
316
+ change:
317
+ orderDelta > 0
318
+ ? "relaxed"
319
+ : orderDelta < 0
320
+ ? "tightened"
321
+ : "source_changed",
322
+ invalidation: null,
323
+ },
324
+ };
325
+ });
326
+ if (!changed.ok) return changed;
327
+ const value = changed.value;
328
+ if (!value) {
329
+ return {
330
+ ok: false,
331
+ code: "RUNTIME",
332
+ error: "Policy mutation returned no result",
333
+ };
334
+ }
335
+ if (value.change !== "unchanged") {
336
+ const invalidation = await this.deps.onPolicyChanged?.(value);
337
+ value.invalidation = invalidation ?? {
338
+ policyEpoch: collectionEgressPolicyEpoch(changed.config),
339
+ queuedJobsInvalidated: 0,
340
+ sessionsInvalidated: 0,
341
+ staleWorkMustRetry: true,
342
+ };
343
+ }
344
+ return { ok: true, value };
345
+ }
346
+
347
+ check(
348
+ input: unknown
349
+ ): CollectionEgressPolicyServiceResult<CollectionEgressCheckResult> {
350
+ const parsed = parsePolicyCheckInput(input);
351
+ if (!parsed.ok) return parsed;
352
+ const config = this.deps.getConfig();
353
+ const names =
354
+ parsed.value.collections ??
355
+ config.collections
356
+ .map(({ name }) => name)
357
+ .sort((a, b) => a.localeCompare(b));
358
+ let deniedLineage: ReturnType<typeof resolveEgressLineage> | null = null;
359
+ let deniedDecision: EgressDecision | null = null;
360
+ try {
361
+ const sources = names.map((name) => {
362
+ const state = policyState(config, name);
363
+ if (!state) {
364
+ return {
365
+ collection: name,
366
+ policy: "local_only" as const,
367
+ source: "legacy_default" as const,
368
+ };
369
+ }
370
+ return {
371
+ collection: state.collection,
372
+ policy: state.effectivePolicy,
373
+ source: state.source,
374
+ };
375
+ });
376
+ const lineage = resolveEgressLineage(sources, names);
377
+ const decision = evaluateEgressPolicy({
378
+ collections: lineage.sources,
379
+ action: parsed.value.action,
380
+ destination: { zone: parsed.value.destinationZone },
381
+ caller: parsed.value.caller,
382
+ contentClass: parsed.value.contentClass,
383
+ });
384
+ deniedLineage = lineage;
385
+ deniedDecision = decision;
386
+ const plan = planCollectionEgress({
387
+ collections: config.collections,
388
+ collectionNames: names,
389
+ action: parsed.value.action,
390
+ destinationZone: parsed.value.destinationZone,
391
+ caller: parsed.value.caller,
392
+ contentClass: parsed.value.contentClass,
393
+ partialResults: parsed.value.partialResults,
394
+ });
395
+ return {
396
+ ok: true,
397
+ value: {
398
+ schemaVersion: "1.0",
399
+ mode: plan.mode,
400
+ allowedCollections: plan.allowedCollections,
401
+ omittedCollections: plan.omittedCollections,
402
+ disclosure: plan.disclosure,
403
+ lineage,
404
+ decision,
405
+ remediation: remediationFor(decision),
406
+ },
407
+ };
408
+ } catch (error) {
409
+ if (error instanceof EgressProvenanceError) {
410
+ return {
411
+ ok: false,
412
+ code: "VALIDATION",
413
+ error: "Invalid collection egress scope",
414
+ };
415
+ }
416
+ if (!(error instanceof EgressDeniedError)) throw error;
417
+ if (!deniedLineage || !deniedDecision) throw error;
418
+ const decision = deniedDecision;
419
+ return {
420
+ ok: true,
421
+ value: {
422
+ schemaVersion: "1.0",
423
+ mode: "denied",
424
+ allowedCollections: [],
425
+ omittedCollections: deniedLineage.sources.map(({ collection }) => ({
426
+ collection,
427
+ reason: decision.reason,
428
+ })),
429
+ disclosure: null,
430
+ lineage: deniedLineage,
431
+ decision,
432
+ remediation: remediationFor(decision),
433
+ },
434
+ };
435
+ }
436
+ }
437
+
438
+ explain(
439
+ input: unknown
440
+ ): CollectionEgressPolicyServiceResult<CollectionEgressCheckResult> {
441
+ return this.check(input);
442
+ }
443
+ }