@fourier-labs/harbour-contracts 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/dist/gen-ts/ai-callsite-inventory.d.ts +49 -0
  3. package/dist/gen-ts/ai-gateway-key-lookup.d.ts +20 -0
  4. package/dist/gen-ts/ai-gateway-key-token.d.ts +4 -0
  5. package/dist/gen-ts/ai-gateway-key-token.js +6 -0
  6. package/dist/gen-ts/ai-governance-decision.d.ts +74 -0
  7. package/dist/gen-ts/ai-policy-snapshot.d.ts +429 -0
  8. package/dist/gen-ts/ai-runtime-contract.d.ts +27 -0
  9. package/dist/gen-ts/ai-trace-event.d.ts +68 -0
  10. package/dist/gen-ts/ai-transform-event.d.ts +66 -0
  11. package/dist/gen-ts/ai-transform-result.d.ts +32 -0
  12. package/dist/gen-ts/ai-verification-evidence.d.ts +49 -0
  13. package/dist/gen-ts/data-dependency.d.ts +42 -0
  14. package/dist/gen-ts/data-grant.d.ts +58 -0
  15. package/dist/gen-ts/data-grant.js +12 -0
  16. package/dist/gen-ts/data-source.d.ts +74 -0
  17. package/dist/{deploy-request.d.ts → gen-ts/deploy-request.d.ts} +30 -0
  18. package/dist/gen-ts/deploy-response.js +3 -0
  19. package/dist/gen-ts/deployment-environment.js +3 -0
  20. package/dist/gen-ts/deployment-source.js +3 -0
  21. package/dist/gen-ts/deployment-status-failure.js +3 -0
  22. package/dist/gen-ts/deployment-status-request.js +3 -0
  23. package/dist/{deployment-status-response.d.ts → gen-ts/deployment-status-response.d.ts} +2 -2
  24. package/dist/gen-ts/deployment-status-response.js +3 -0
  25. package/dist/gen-ts/env-bindings.js +3 -0
  26. package/dist/{error-category-registry.d.ts → gen-ts/error-category-registry.d.ts} +7 -0
  27. package/dist/{error-category-registry.js → gen-ts/error-category-registry.js} +14 -0
  28. package/dist/gen-ts/error-category.js +3 -0
  29. package/dist/gen-ts/error-envelope.js +3 -0
  30. package/dist/{index.d.ts → gen-ts/index.d.ts} +16 -0
  31. package/dist/{index.js → gen-ts/index.js} +16 -0
  32. package/dist/gen-ts/kvs-routing-entry.js +3 -0
  33. package/dist/gen-ts/promotion-request.d.ts +17 -0
  34. package/dist/gen-ts/promotion-request.js +3 -0
  35. package/dist/gen-ts/promotion-response.d.ts +17 -0
  36. package/dist/gen-ts/promotion-response.js +3 -0
  37. package/dist/gen-ts/secret-reference.d.ts +36 -0
  38. package/dist/gen-ts/secret-reference.js +3 -0
  39. package/dist/{version-state-machine.d.ts → gen-ts/version-state-machine.d.ts} +7 -2
  40. package/dist/gen-ts/version-state-machine.js +45 -0
  41. package/dist/gen-ts/version-state.js +3 -0
  42. package/dist/resolver/index.d.ts +256 -0
  43. package/dist/resolver/index.js +679 -0
  44. package/package.json +14 -4
  45. package/schema/ai-callsite-inventory.schema.json +138 -0
  46. package/schema/ai-gateway-key-lookup.schema.json +53 -0
  47. package/schema/ai-governance-decision.schema.json +105 -0
  48. package/schema/ai-policy-snapshot.schema.json +340 -0
  49. package/schema/ai-runtime-contract.schema.json +85 -0
  50. package/schema/ai-trace-event.schema.json +178 -0
  51. package/schema/ai-transform-event.schema.json +72 -0
  52. package/schema/ai-transform-result.schema.json +46 -0
  53. package/schema/ai-verification-evidence.schema.json +57 -0
  54. package/schema/data-dependency.schema.json +89 -0
  55. package/schema/data-grant.schema.json +107 -0
  56. package/schema/data-source.schema.json +151 -0
  57. package/schema/deploy-request.schema.json +60 -0
  58. package/schema/deploy-response.schema.json +1 -1
  59. package/schema/deployment-status-request.schema.json +1 -1
  60. package/schema/deployment-status-response.schema.json +2 -2
  61. package/schema/error-category.enum.json +7 -0
  62. package/schema/promotion-request.schema.json +26 -0
  63. package/schema/promotion-response.schema.json +50 -0
  64. package/schema/secret-reference.schema.json +70 -0
  65. package/schema/version-state.schema.json +25 -24
  66. package/dist/version-state-machine.js +0 -35
  67. /package/dist/{api-error-response.js → gen-ts/ai-callsite-inventory.js} +0 -0
  68. /package/dist/{artifact-contract.js → gen-ts/ai-gateway-key-lookup.js} +0 -0
  69. /package/dist/{deploy-request.js → gen-ts/ai-governance-decision.js} +0 -0
  70. /package/dist/{deploy-response.js → gen-ts/ai-policy-snapshot.js} +0 -0
  71. /package/dist/{deployment-environment.js → gen-ts/ai-runtime-contract.js} +0 -0
  72. /package/dist/{deployment-source.js → gen-ts/ai-trace-event.js} +0 -0
  73. /package/dist/{deployment-status-failure.js → gen-ts/ai-transform-event.js} +0 -0
  74. /package/dist/{deployment-status-request.js → gen-ts/ai-transform-result.js} +0 -0
  75. /package/dist/{deployment-status-response.js → gen-ts/ai-verification-evidence.js} +0 -0
  76. /package/dist/{api-error-response.d.ts → gen-ts/api-error-response.d.ts} +0 -0
  77. /package/dist/{env-bindings.js → gen-ts/api-error-response.js} +0 -0
  78. /package/dist/{artifact-contract.d.ts → gen-ts/artifact-contract.d.ts} +0 -0
  79. /package/dist/{error-category.js → gen-ts/artifact-contract.js} +0 -0
  80. /package/dist/{error-envelope.js → gen-ts/data-dependency.js} +0 -0
  81. /package/dist/{kvs-routing-entry.js → gen-ts/data-source.js} +0 -0
  82. /package/dist/{version-state.js → gen-ts/deploy-request.js} +0 -0
  83. /package/dist/{deploy-response.d.ts → gen-ts/deploy-response.d.ts} +0 -0
  84. /package/dist/{deployment-environment.d.ts → gen-ts/deployment-environment.d.ts} +0 -0
  85. /package/dist/{deployment-source.d.ts → gen-ts/deployment-source.d.ts} +0 -0
  86. /package/dist/{deployment-status-failure.d.ts → gen-ts/deployment-status-failure.d.ts} +0 -0
  87. /package/dist/{deployment-status-request.d.ts → gen-ts/deployment-status-request.d.ts} +0 -0
  88. /package/dist/{env-bindings.d.ts → gen-ts/env-bindings.d.ts} +0 -0
  89. /package/dist/{error-category.d.ts → gen-ts/error-category.d.ts} +0 -0
  90. /package/dist/{error-envelope.d.ts → gen-ts/error-envelope.d.ts} +0 -0
  91. /package/dist/{kvs-routing-entry.d.ts → gen-ts/kvs-routing-entry.d.ts} +0 -0
  92. /package/dist/{version-state.d.ts → gen-ts/version-state.d.ts} +0 -0
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Harbour AI policy resolver — the ONE hardened five-layer merge, shipped with
3
+ * the contracts package so governance and the gateway resolve identically
4
+ * (completion-spec D-6). Extracted from
5
+ * `harbour-governance-control-plane/src/ai-governance.ts` (merge machinery)
6
+ * and `src/ai-platform-controls.ts` (platform baseline + classification
7
+ * floor) as PURE functions over the contracted snapshot types: no store, no
8
+ * service, no I/O, no crypto.
9
+ *
10
+ * Five-layer resolution per LLD §6.3 (lines 525-542): platform baseline →
11
+ * data-classification minimum → tenant published defaults → application
12
+ * published assignment → version runtime contract. Later layers narrow
13
+ * earlier ones; an application snapshot never replaces the tenant snapshot.
14
+ *
15
+ * The merge is TENANT-WINS by default. Three structural defenses carried over
16
+ * from the governance hardening rounds:
17
+ *
18
+ * - `narrowField` — ONE CONTROL, ONE GUARD. The clamp is the single statement
19
+ * of each control and the rejection is DERIVED from it (a value the clamp
20
+ * had to change is an attempted widening, which §6.3 line 540 requires to be
21
+ * rejected rather than silently narrowed), so deleting the clamp always
22
+ * changes an observable outcome and every control is mutation-testable.
23
+ * - `narrowEntry` closes the FIELD-level default: only fields on an explicit
24
+ * per-family opt-in list may differ from the tenant entry; every other field
25
+ * — including any field a future contract revision adds — is tenant-locked.
26
+ * - `mergeKeyedFamily` closes the ENTRY-level default: both directions of the
27
+ * key set (a tenant key the application omits, and an application key the
28
+ * tenant never published) must be stated per family; there is no permissive
29
+ * fallthrough for an unenumerated key.
30
+ *
31
+ * The `Narrowed` brand is the registry's type-level counterpart: a family
32
+ * merge must RETURN a `Narrowed` value, and the brand is granted only by the
33
+ * narrowing combinators, so a passthrough `(tenant, application) =>
34
+ * application` fails to compile and a deliberate escape requires a visible
35
+ * cast.
36
+ */
37
+ /** Version of the resolver ALGORITHM (merge rules + floor artifacts), not of
38
+ * the package. Consumers record it beside the composite policy digest so a
39
+ * behavioral divergence between two deployments is attributable to a resolver
40
+ * revision rather than guessed at. Bump on ANY change to the merge rules, the
41
+ * platform baseline, or the classification minimums. */
42
+ export const RESOLVER_VERSION = "1.0.0";
43
+ const PII_INPUT_GUARDRAIL = { ruleId: "platform.pii.input", detectorId: "harbour.pii", detectorVersion: "1.0", phase: "input", action: "REDACT" };
44
+ const PII_OUTPUT_GUARDRAIL = { ruleId: "platform.pii.output", detectorId: "harbour.pii", detectorVersion: "1.0", phase: "output", action: "REDACT" };
45
+ const SECRET_OUTPUT_GUARDRAIL = { ruleId: "platform.secret.output", detectorId: "harbour.secret", detectorVersion: "1.0", phase: "output", action: "BLOCK" };
46
+ export const AI_PLATFORM_BASELINE = {
47
+ schema: "harbour.ai-platform-baseline/1.0",
48
+ artifactVersion: "1.0.0",
49
+ controls: {
50
+ permittedProviderTypes: ["anthropic", "gemini", "openai", "openrouter"],
51
+ requireApprovedModels: false,
52
+ maxCaptureMode: "BEST_EFFORT",
53
+ maxRetentionDays: 90,
54
+ maxRequestBytes: 1_000_000,
55
+ maxInputTokens: 200_000,
56
+ maxOutputTokens: 32_000,
57
+ maxRequestsPerMinute: 6_000,
58
+ maxTokensPerMinute: 10_000_000,
59
+ maxMonthlyBudgetMicroUsd: 1_000_000_000_000,
60
+ permittedRequestFields: { temperature: true, maxOutputTokens: true, responseFormat: true, tools: true },
61
+ requiredGuardrails: [],
62
+ },
63
+ };
64
+ /** LLD §17 item 11 (line 1225) leaves the per-class values to be agreed with
65
+ * the security-assurance workstream; these are the initial code-owned values
66
+ * and are versioned by `artifactVersion` plus `RESOLVER_VERSION`. */
67
+ export const AI_CLASSIFICATION_MINIMUMS = {
68
+ schema: "harbour.ai-classification-minimums/1.0",
69
+ artifactVersion: "1.0.0",
70
+ defaultClassification: "Yellow",
71
+ classes: {
72
+ Green: {
73
+ permittedProviderTypes: ["anthropic", "gemini", "openai", "openrouter"],
74
+ requireApprovedModels: false,
75
+ maxCaptureMode: "BEST_EFFORT",
76
+ maxRetentionDays: 90,
77
+ maxRequestBytes: 1_000_000,
78
+ maxInputTokens: 200_000,
79
+ maxOutputTokens: 32_000,
80
+ maxRequestsPerMinute: 6_000,
81
+ maxTokensPerMinute: 10_000_000,
82
+ maxMonthlyBudgetMicroUsd: 1_000_000_000_000,
83
+ permittedRequestFields: { temperature: true, maxOutputTokens: true, responseFormat: true, tools: true },
84
+ requiredGuardrails: [],
85
+ },
86
+ Yellow: {
87
+ permittedProviderTypes: ["anthropic", "gemini", "openai", "openrouter"],
88
+ requireApprovedModels: true,
89
+ maxCaptureMode: "BEST_EFFORT",
90
+ maxRetentionDays: 60,
91
+ maxRequestBytes: 1_000_000,
92
+ maxInputTokens: 200_000,
93
+ maxOutputTokens: 32_000,
94
+ maxRequestsPerMinute: 6_000,
95
+ maxTokensPerMinute: 10_000_000,
96
+ maxMonthlyBudgetMicroUsd: 1_000_000_000_000,
97
+ permittedRequestFields: { temperature: true, maxOutputTokens: true, responseFormat: true, tools: true },
98
+ requiredGuardrails: [],
99
+ },
100
+ Orange: {
101
+ permittedProviderTypes: ["anthropic", "gemini", "openai"],
102
+ requireApprovedModels: true,
103
+ maxCaptureMode: "BEST_EFFORT",
104
+ maxRetentionDays: 30,
105
+ maxRequestBytes: 500_000,
106
+ maxInputTokens: 100_000,
107
+ maxOutputTokens: 16_000,
108
+ maxRequestsPerMinute: 1_200,
109
+ maxTokensPerMinute: 2_000_000,
110
+ maxMonthlyBudgetMicroUsd: 100_000_000_000,
111
+ permittedRequestFields: { temperature: true, maxOutputTokens: true, responseFormat: true, tools: false },
112
+ requiredGuardrails: [PII_OUTPUT_GUARDRAIL],
113
+ },
114
+ Red: {
115
+ permittedProviderTypes: ["anthropic", "openai"],
116
+ requireApprovedModels: true,
117
+ maxCaptureMode: "OFF",
118
+ maxRetentionDays: 7,
119
+ maxRequestBytes: 200_000,
120
+ maxInputTokens: 50_000,
121
+ maxOutputTokens: 8_000,
122
+ maxRequestsPerMinute: 300,
123
+ maxTokensPerMinute: 500_000,
124
+ maxMonthlyBudgetMicroUsd: 10_000_000_000,
125
+ permittedRequestFields: { temperature: false, maxOutputTokens: true, responseFormat: false, tools: false },
126
+ requiredGuardrails: [PII_INPUT_GUARDRAIL, PII_OUTPUT_GUARDRAIL, SECRET_OUTPUT_GUARDRAIL],
127
+ },
128
+ },
129
+ };
130
+ export const CAPTURE_MODE_RANK = { OFF: 0, BEST_EFFORT: 1, REQUIRED: 2 };
131
+ /** Ascending strictness. Used to fail closed on the classification layer: an
132
+ * application-published snapshot may raise the class but never lower it. */
133
+ export const CLASSIFICATION_RANK = { Green: 0, Yellow: 1, Orange: 2, Red: 3 };
134
+ export function strictestClassification(left, right) {
135
+ return CLASSIFICATION_RANK[left] >= CLASSIFICATION_RANK[right] ? left : right;
136
+ }
137
+ export const GUARDRAIL_ACTION_RANK = { ALLOW: 0, OBSERVE: 1, REDACT: 2, BLOCK: 3 };
138
+ /** Element-wise most restrictive of two control sets. Used to fold the
139
+ * classification minimum into the platform baseline before any tenant or
140
+ * application layer is applied. */
141
+ export function narrowControls(left, right) {
142
+ const guardrails = new Map();
143
+ for (const guardrail of [...left.requiredGuardrails, ...right.requiredGuardrails]) {
144
+ const existing = guardrails.get(guardrail.ruleId);
145
+ if (!existing || GUARDRAIL_ACTION_RANK[guardrail.action] > GUARDRAIL_ACTION_RANK[existing.action])
146
+ guardrails.set(guardrail.ruleId, guardrail);
147
+ }
148
+ return {
149
+ permittedProviderTypes: left.permittedProviderTypes.filter(type => right.permittedProviderTypes.includes(type)).sort(),
150
+ requireApprovedModels: left.requireApprovedModels || right.requireApprovedModels,
151
+ maxCaptureMode: CAPTURE_MODE_RANK[left.maxCaptureMode] <= CAPTURE_MODE_RANK[right.maxCaptureMode] ? left.maxCaptureMode : right.maxCaptureMode,
152
+ maxRetentionDays: Math.min(left.maxRetentionDays, right.maxRetentionDays),
153
+ maxRequestBytes: Math.min(left.maxRequestBytes, right.maxRequestBytes),
154
+ maxInputTokens: Math.min(left.maxInputTokens, right.maxInputTokens),
155
+ maxOutputTokens: Math.min(left.maxOutputTokens, right.maxOutputTokens),
156
+ maxRequestsPerMinute: Math.min(left.maxRequestsPerMinute, right.maxRequestsPerMinute),
157
+ maxTokensPerMinute: Math.min(left.maxTokensPerMinute, right.maxTokensPerMinute),
158
+ maxMonthlyBudgetMicroUsd: Math.min(left.maxMonthlyBudgetMicroUsd, right.maxMonthlyBudgetMicroUsd),
159
+ permittedRequestFields: {
160
+ temperature: left.permittedRequestFields.temperature && right.permittedRequestFields.temperature,
161
+ maxOutputTokens: left.permittedRequestFields.maxOutputTokens && right.permittedRequestFields.maxOutputTokens,
162
+ responseFormat: left.permittedRequestFields.responseFormat && right.permittedRequestFields.responseFormat,
163
+ tools: left.permittedRequestFields.tools && right.permittedRequestFields.tools,
164
+ },
165
+ requiredGuardrails: [...guardrails.values()].sort((first, second) => first.ruleId.localeCompare(second.ruleId)),
166
+ };
167
+ }
168
+ /** Platform baseline folded with one classification's minimum control set:
169
+ * layers 1 and 2 of §6.3 as a single floor that later layers may only narrow. */
170
+ export function classificationFloor(classification) {
171
+ return narrowControls(AI_PLATFORM_BASELINE.controls, AI_CLASSIFICATION_MINIMUMS.classes[classification]);
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // Errors and stable codes.
175
+ // ---------------------------------------------------------------------------
176
+ /** Every rejection carries a stable code and HTTP-shaped status so both
177
+ * resolver consumers surface identical errors for identical escapes. */
178
+ export class AiPolicyResolutionError extends Error {
179
+ status;
180
+ code;
181
+ constructor(status, code, message) {
182
+ super(message);
183
+ this.name = "AiPolicyResolutionError";
184
+ this.status = status;
185
+ this.code = code;
186
+ }
187
+ }
188
+ /** Stable codes for a later layer that tried to widen, not narrow, an earlier
189
+ * one (LLD §6.3 line 540). The audit acceptance criterion is rejection, never
190
+ * silent narrowing. */
191
+ export const WIDENS_TENANT_ALLOWLIST = "configuration_widens_tenant_allowlist";
192
+ export const RAISES_TENANT_CEILING = "configuration_raises_tenant_ceiling";
193
+ export const WEAKENS_TENANT_GUARDRAIL = "configuration_weakens_tenant_guardrail";
194
+ /** An application entry tried to change a field only the tenant may set. */
195
+ export const OVERRIDES_TENANT_LOCKED = "configuration_overrides_tenant_locked_field";
196
+ /** The merge output outgrew a `maxItems` bound of the snapshot contract. */
197
+ export const EXCEEDS_CONTRACT_BOUNDS = "configuration_exceeds_contract_bounds";
198
+ function narrowingConflict(code, message) {
199
+ return new AiPolicyResolutionError(409, code, message);
200
+ }
201
+ /** Canonical JSON with sorted object keys — the equality the tenant-locked
202
+ * comparisons are stated in, so key order can never disguise a difference. */
203
+ export function canonicalJson(value) {
204
+ return JSON.stringify(sortValue(value));
205
+ }
206
+ function sortValue(value) {
207
+ if (Array.isArray(value))
208
+ return value.map(sortValue);
209
+ if (value && typeof value === "object") {
210
+ const output = {};
211
+ for (const key of Object.keys(value).sort()) {
212
+ output[key] = sortValue(value[key]);
213
+ }
214
+ return output;
215
+ }
216
+ return value;
217
+ }
218
+ function asNarrowed(value) {
219
+ return value;
220
+ }
221
+ /** ONE CONTROL, ONE GUARD: the clamp is the single statement of the control
222
+ * and the rejection is derived from it, so deleting the clamp always changes
223
+ * an observable outcome and the control is mutation-testable. */
224
+ export function narrowField(tenant, application, clamp, code, describe) {
225
+ const narrowed = clamp(tenant, application);
226
+ if (canonicalJson(narrowed) !== canonicalJson(application))
227
+ throw narrowingConflict(code, describe());
228
+ return narrowed;
229
+ }
230
+ /** Merges one application entry over its tenant counterpart so that
231
+ * TENANT-WINS is the DEFAULT: only fields named in `narrowable` are replaced
232
+ * by the caller-computed `narrowed` values; every other field — including any
233
+ * field a future contract revision adds — must equal the tenant value or the
234
+ * entry is rejected. Spreading the application entry is never correct here. */
235
+ export function narrowEntry(tenant, application, narrowable, narrowed, describe) {
236
+ const tenantRecord = tenant;
237
+ const applicationRecord = application;
238
+ for (const field of new Set([...Object.keys(tenantRecord), ...Object.keys(applicationRecord)])) {
239
+ if (narrowable.includes(field))
240
+ continue;
241
+ if (canonicalJson(tenantRecord[field]) !== canonicalJson(applicationRecord[field]))
242
+ throw narrowingConflict(OVERRIDES_TENANT_LOCKED, describe(field));
243
+ }
244
+ return asNarrowed({ ...tenant, ...narrowed });
245
+ }
246
+ /** The single path by which any key-addressed family merges: no per-family
247
+ * loop can quietly grow its own `if (!base)` branch. */
248
+ export function mergeKeyedFamily(tenant, application, rules) {
249
+ const permitted = new Map(tenant.map(entry => [rules.key(entry), entry]));
250
+ const merged = rules.omittedByApplication === "keep" ? new Map(permitted) : new Map();
251
+ for (const entry of application) {
252
+ const key = rules.key(entry);
253
+ const base = permitted.get(key);
254
+ if (base) {
255
+ merged.set(key, rules.narrow(base, entry));
256
+ continue;
257
+ }
258
+ if (rules.added.decision === "reject")
259
+ throw narrowingConflict(rules.added.code, rules.added.describe(entry));
260
+ merged.set(key, rules.added.admit(entry));
261
+ }
262
+ // Sorted, not insertion-ordered: the merged set feeds the composite
263
+ // policyDigest, so two runs that differ only in the order the application
264
+ // listed its entries must produce the same digest.
265
+ return asNarrowed([...merged.values()].sort((first, second) => rules.key(first).localeCompare(rules.key(second))));
266
+ }
267
+ // ---------------------------------------------------------------------------
268
+ // The assigned capability set (a GRANT, not a control value).
269
+ // ---------------------------------------------------------------------------
270
+ /** A grant can only flow down: an application may drop a capability, but may
271
+ * neither assign one the tenant did not assign nor repoint an assignment at
272
+ * another route. `{ ...tenant, ...application }` made the application the
273
+ * authority for both. */
274
+ export function mergeModelAliases(tenant, application) {
275
+ const merged = ["generation", "embeddings"].map(capability => {
276
+ const assigned = application[capability];
277
+ if (assigned === undefined)
278
+ return {};
279
+ const granted = tenant[capability];
280
+ if (granted === undefined) {
281
+ throw narrowingConflict(WIDENS_TENANT_ALLOWLIST, `The application configuration assigns the "${capability}" capability, which the tenant configuration does not assign.`);
282
+ }
283
+ if (assigned !== granted) {
284
+ throw narrowingConflict(OVERRIDES_TENANT_LOCKED, `The application configuration points the "${capability}" model alias at route "${assigned}" rather than the tenant route "${granted}".`);
285
+ }
286
+ return { [capability]: granted };
287
+ });
288
+ // The "no capability is assigned at all" case is deliberately not thrown
289
+ // here: the caller states it once on the returned value, so the guard stays
290
+ // reachable and testable in exactly one place.
291
+ const aliases = Object.assign({}, ...merged);
292
+ return aliases;
293
+ }
294
+ // ---------------------------------------------------------------------------
295
+ // Per-family merges (layers 3 and 4).
296
+ // ---------------------------------------------------------------------------
297
+ const minimum = (tenantValue, applicationValue) => Math.min(tenantValue, applicationValue);
298
+ const both = (tenantValue, applicationValue) => tenantValue && applicationValue;
299
+ const either = (tenantValue, applicationValue) => tenantValue || applicationValue;
300
+ const intersect = (tenantValue, applicationValue) => applicationValue.filter(item => tenantValue.includes(item));
301
+ /** Only `enabled` may be narrowed. `baseUrl` and `credentialSecretRef` in
302
+ * particular are tenant-locked: an application that could set them would
303
+ * repoint a tenant-approved provider id at its own endpoint or credential. */
304
+ const PROVIDER_NARROWABLE = ["enabled"];
305
+ function mergeProviders(tenant, application) {
306
+ return mergeKeyedFamily(tenant, application, {
307
+ key: entry => entry.id,
308
+ omittedByApplication: "drop",
309
+ added: { decision: "reject", code: WIDENS_TENANT_ALLOWLIST, describe: entry => `The application configuration permits provider "${entry.id}", which the tenant configuration does not permit.` },
310
+ narrow: (base, entry) => narrowEntry(base, entry, PROVIDER_NARROWABLE, {
311
+ enabled: narrowField(base.enabled, entry.enabled, both, WIDENS_TENANT_ALLOWLIST, () => `The application configuration enables provider "${entry.id}", which the tenant configuration disables.`),
312
+ }, field => `The application configuration changes the tenant-locked field "${field}" of provider "${entry.id}".`),
313
+ });
314
+ }
315
+ /** `providerModel` is tenant-locked: the allowlist keys on `id`, so a swappable
316
+ * `providerModel` would let an application send a different wire model behind
317
+ * an approved identifier. `pricing` is tenant-locked too, because every cost
318
+ * and budget ceiling is computed from it. */
319
+ const MODEL_NARROWABLE = ["approved", "capabilities"];
320
+ function mergeModels(tenant, application) {
321
+ return mergeKeyedFamily(tenant, application, {
322
+ key: entry => entry.id,
323
+ omittedByApplication: "drop",
324
+ added: { decision: "reject", code: WIDENS_TENANT_ALLOWLIST, describe: entry => `The application configuration permits model "${entry.id}", which the tenant configuration does not permit.` },
325
+ narrow: (base, entry) => narrowEntry(base, entry, MODEL_NARROWABLE, {
326
+ approved: narrowField(base.approved, entry.approved, both, WIDENS_TENANT_ALLOWLIST, () => `The application configuration approves model "${entry.id}", which the tenant configuration does not approve.`),
327
+ // The contracted capabilities type is a bounded tuple; the intersection
328
+ // preserves the application's order and can only shrink, so the cast
329
+ // back to the tuple union is sound.
330
+ capabilities: narrowField(base.capabilities, entry.capabilities, (tenantValue, applicationValue) => intersect(tenantValue, applicationValue), WIDENS_TENANT_ALLOWLIST, () => `The application configuration adds a capability to model "${entry.id}" that the tenant configuration does not permit.`),
331
+ }, field => `The application configuration changes the tenant-locked field "${field}" of model "${entry.id}".`),
332
+ });
333
+ }
334
+ /** An application may re-target a shared alias only within the tenant route's
335
+ * own target set, and `retryCount`/`timeoutMs` are ceilings it may only lower.
336
+ * `capability` is tenant-locked. */
337
+ const ROUTE_NARROWABLE = ["version", "targets", "retryCount", "timeoutMs"];
338
+ /** A route alias is a named path with its own retry, timeout and target
339
+ * ceilings. An alias the tenant never published is a path the tenant never
340
+ * bounded, so publishing one is a widening even when every model it reaches is
341
+ * on the intersected allowlist. */
342
+ function mergeRoutes(tenant, application) {
343
+ return mergeKeyedFamily(tenant, application, {
344
+ key: route => route.alias,
345
+ omittedByApplication: "keep",
346
+ added: { decision: "reject", code: WIDENS_TENANT_ALLOWLIST, describe: route => `The application configuration publishes route "${route.alias}", which the tenant configuration does not publish.` },
347
+ narrow: (base, route) => narrowEntry(base, route, ROUTE_NARROWABLE, {
348
+ // `version` is snapshot metadata rather than a control, so it is the
349
+ // later of the two rather than a ceiling the application may not raise.
350
+ version: Math.max(base.version, route.version),
351
+ targets: mergeRouteTargets(base, route),
352
+ retryCount: narrowField(base.retryCount, route.retryCount, minimum, RAISES_TENANT_CEILING, () => `The application route "${route.alias}" raises retryCount above the tenant ceiling.`),
353
+ timeoutMs: narrowField(base.timeoutMs, route.timeoutMs, minimum, RAISES_TENANT_CEILING, () => `The application route "${route.alias}" raises timeoutMs above the tenant ceiling.`),
354
+ }, field => `The application configuration changes the tenant-locked field "${field}" of route "${route.alias}".`),
355
+ });
356
+ }
357
+ /** Per §7.4 the ORDER of a target list is the control: lower `priority` is
358
+ * attempted first and `weight` shares traffic inside a priority group, so an
359
+ * application keeping exactly the tenant's target set can still redirect every
360
+ * request by renumbering. The only narrowing a target list admits is DROPPING
361
+ * targets: survivors keep the tenant's weight and the tenant's relative order
362
+ * (priorities compared as dense ranks), and any reordering, regrouping,
363
+ * repetition or re-weighting is rejected. */
364
+ function mergeRouteTargets(tenant, application) {
365
+ const permitted = new Map(tenant.targets.map(target => [target.modelId, target]));
366
+ const kept = new Set();
367
+ for (const target of application.targets) {
368
+ const base = permitted.get(target.modelId);
369
+ if (!base)
370
+ throw narrowingConflict(WIDENS_TENANT_ALLOWLIST, `The application route "${application.alias}" targets model "${target.modelId}", which the tenant route does not target.`);
371
+ if (kept.has(target.modelId))
372
+ throw narrowingConflict(OVERRIDES_TENANT_LOCKED, `The application route "${application.alias}" repeats target model "${target.modelId}", which shifts traffic the tenant route did not.`);
373
+ kept.add(target.modelId);
374
+ if (target.weight !== base.weight)
375
+ throw narrowingConflict(OVERRIDES_TENANT_LOCKED, `The application route "${application.alias}" changes the tenant traffic weight of target model "${target.modelId}".`);
376
+ }
377
+ const tenantOrder = new Map(priorityRanks(tenant.targets.filter(target => kept.has(target.modelId))));
378
+ for (const [modelId, rank] of priorityRanks(application.targets)) {
379
+ if (tenantOrder.get(modelId) !== rank) {
380
+ throw narrowingConflict(OVERRIDES_TENANT_LOCKED, `The application route "${application.alias}" reorders target model "${modelId}" against the tenant route's attempt order.`);
381
+ }
382
+ }
383
+ return application.targets;
384
+ }
385
+ /** Each target's priority as a dense rank over the distinct priorities present,
386
+ * so `0,1` and `0,9` describe the same attempt order and `0,0` does not. */
387
+ function priorityRanks(targets) {
388
+ const distinct = [...new Set(targets.map(target => target.priority))].sort((first, second) => first - second);
389
+ return targets.map(target => [target.modelId, distinct.indexOf(target.priority)]);
390
+ }
391
+ /** A rule set is a named ceiling and permitted-field set that a request
392
+ * selects by name; a name the tenant never published is a selectable ceiling
393
+ * set the tenant never bounded. */
394
+ function mergePolicies(tenant, application) {
395
+ return mergeKeyedFamily(tenant, application, {
396
+ key: entry => entry.name,
397
+ omittedByApplication: "keep",
398
+ added: { decision: "reject", code: WIDENS_TENANT_ALLOWLIST, describe: entry => `The application configuration publishes policy "${entry.name}", which the tenant configuration does not publish.` },
399
+ narrow: mergePolicyEntry,
400
+ });
401
+ }
402
+ const PERMITTED_REQUEST_FIELDS = ["temperature", "maxOutputTokens", "responseFormat", "tools"];
403
+ function mergePolicyEntry(tenant, application) {
404
+ const ceiling = (field) => narrowField(tenant[field], application[field], minimum, RAISES_TENANT_CEILING, () => `The application policy "${application.name}" raises ${field} above the tenant ceiling.`);
405
+ const permittedRequestFields = {};
406
+ for (const field of PERMITTED_REQUEST_FIELDS) {
407
+ permittedRequestFields[field] = narrowField(tenant.permittedRequestFields[field], application.permittedRequestFields[field], both, WIDENS_TENANT_ALLOWLIST, () => `The application policy "${application.name}" permits the request field "${field}", which the tenant policy forbids.`);
408
+ }
409
+ return narrowEntry(tenant, application, POLICY_NARROWABLE, {
410
+ // Snapshot metadata rather than a control, so the later revision wins.
411
+ version: Math.max(tenant.version, application.version),
412
+ maxRequestBytes: ceiling("maxRequestBytes"),
413
+ maxInputTokens: ceiling("maxInputTokens"),
414
+ maxOutputTokens: ceiling("maxOutputTokens"),
415
+ // Buffering the response is a restriction (nothing reaches the caller
416
+ // before the output guardrails have run), so it unions like the enabled
417
+ // state of a limit; turning the tenant's buffering off is a weakening.
418
+ bufferedDelivery: narrowField(tenant.bufferedDelivery, application.bufferedDelivery, either, RAISES_TENANT_CEILING, () => `The application policy "${application.name}" disables the buffered delivery the tenant policy requires.`),
419
+ permittedRequestFields,
420
+ guardrails: mergeGuardrails(tenant.guardrails, application.guardrails, application.name),
421
+ }, field => `The application policy "${application.name}" changes the tenant-locked field "${field}".`);
422
+ }
423
+ /** Every rule-set field except the `name` key is explicitly merged above; a
424
+ * field a later contract revision adds is tenant-locked until it is listed. */
425
+ const POLICY_NARROWABLE = ["version", "maxRequestBytes", "maxInputTokens", "maxOutputTokens", "bufferedDelivery", "permittedRequestFields", "guardrails"];
426
+ const GUARDRAIL_NARROWABLE = ["action", "parameters"];
427
+ /** Guardrails are the one family whose merge semantics is union-of-
428
+ * restrictions, so an application rule the tenant never published is genuinely
429
+ * narrowing — but only if the rule is itself a restriction:
430
+ *
431
+ * - Only `REDACT` and `BLOCK` restrict. `ALLOW` is the identity action and
432
+ * `OBSERVE` records-and-permits (the trace contract maps it to `matched`,
433
+ * never `blocked`/`redacted`), so a new ruleId carrying either exists only
434
+ * to occupy that id.
435
+ * - An added rule may carry no `parameters`: on a rule the tenant never
436
+ * published there is no tenant bound to merge them against, and both
437
+ * `allowedTools` (an allowlist) and `jsonSchema`/`jsonSchemaRef` (detector
438
+ * configuration) would be application authority over an unbounded surface.
439
+ *
440
+ * A shared rule keeps the stronger action; `detectorId`, `detectorVersion`
441
+ * and `phase` are tenant-locked. */
442
+ function mergeGuardrails(tenant, application, policyName) {
443
+ return mergeKeyedFamily(tenant, application, {
444
+ key: rule => rule.ruleId,
445
+ omittedByApplication: "keep",
446
+ added: {
447
+ decision: "permit_when_restrictive",
448
+ admit: rule => {
449
+ if (GUARDRAIL_ACTION_RANK[rule.action] < GUARDRAIL_ACTION_RANK.REDACT) {
450
+ throw narrowingConflict(WEAKENS_TENANT_GUARDRAIL, `The application policy "${policyName}" publishes guardrail "${rule.ruleId}" with the non-restricting action "${rule.action}", which the tenant configuration does not publish.`);
451
+ }
452
+ if (rule.parameters !== undefined) {
453
+ throw narrowingConflict(WIDENS_TENANT_ALLOWLIST, `The application policy "${policyName}" publishes guardrail "${rule.ruleId}" with parameters the tenant configuration never bounded.`);
454
+ }
455
+ return rule;
456
+ },
457
+ },
458
+ narrow: (base, rule) => {
459
+ const action = narrowField(base.action, rule.action, (tenantAction, applicationAction) => GUARDRAIL_ACTION_RANK[applicationAction] >= GUARDRAIL_ACTION_RANK[tenantAction] ? applicationAction : tenantAction, WEAKENS_TENANT_GUARDRAIL, () => `The application policy "${policyName}" weakens guardrail "${rule.ruleId}" below the tenant action.`);
460
+ const parameters = mergeGuardrailParameters(base, rule, policyName);
461
+ return narrowEntry(base, rule, GUARDRAIL_NARROWABLE, {
462
+ action,
463
+ ...(parameters === undefined ? {} : { parameters }),
464
+ }, field => `The application policy "${policyName}" changes the tenant-locked field "${field}" of guardrail "${rule.ruleId}".`);
465
+ },
466
+ });
467
+ }
468
+ /** The merged `parameters` of a shared guardrail. `allowedTools` is an
469
+ * allowlist and intersects; `jsonSchema`/`jsonSchemaRef` are detector
470
+ * configuration (the exact output shape an `output.json_schema` rule
471
+ * enforces) and are tenant-locked — an application that could swap the schema
472
+ * would replace what the tenant's blocking detector enforces while keeping
473
+ * its action rank. The tenant value always survives; an application may only
474
+ * repeat it verbatim or stay silent. */
475
+ function mergeGuardrailParameters(tenant, application, policyName) {
476
+ const allowedTools = mergeAllowedTools(tenant, application, policyName);
477
+ const jsonSchema = tenantLockedParameter(tenant, application, "jsonSchema", policyName);
478
+ const jsonSchemaRef = tenantLockedParameter(tenant, application, "jsonSchemaRef", policyName);
479
+ const merged = {
480
+ ...(allowedTools === undefined ? {} : { allowedTools }),
481
+ ...(jsonSchema === undefined ? {} : { jsonSchema }),
482
+ ...(jsonSchemaRef === undefined ? {} : { jsonSchemaRef }),
483
+ };
484
+ return Object.keys(merged).length === 0 ? undefined : merged;
485
+ }
486
+ function tenantLockedParameter(tenant, application, field, policyName) {
487
+ const granted = tenant.parameters?.[field];
488
+ const requested = application.parameters?.[field];
489
+ if (requested !== undefined && canonicalJson(requested) !== canonicalJson(granted)) {
490
+ throw narrowingConflict(OVERRIDES_TENANT_LOCKED, `The application policy "${policyName}" changes the tenant-locked parameter "${field}" of guardrail "${application.ruleId}".`);
491
+ }
492
+ return granted;
493
+ }
494
+ function mergeAllowedTools(tenant, application, policyName) {
495
+ const permitted = tenant.parameters?.allowedTools;
496
+ const requested = application.parameters?.allowedTools;
497
+ if (requested === undefined)
498
+ return permitted;
499
+ if (permitted === undefined) {
500
+ // FAIL CLOSED. `allowedTools` is an allowlist, so tenant SILENCE is not
501
+ // tenant CONSENT: a tenant guardrail that names no tool has permitted no
502
+ // tool by name, and an application list is a widening of nothing.
503
+ throw narrowingConflict(WIDENS_TENANT_ALLOWLIST, `The application policy "${policyName}" permits tool "${requested[0]}" for guardrail "${application.ruleId}", which the tenant policy does not permit.`);
504
+ }
505
+ return narrowField(permitted, requested, (tenantValue, applicationValue) => intersect(tenantValue, applicationValue), WIDENS_TENANT_ALLOWLIST, () => `The application policy "${policyName}" permits tool "${requested.find(tool => !permitted.includes(tool))}" for guardrail "${application.ruleId}", which the tenant policy does not permit.`);
506
+ }
507
+ const LIMIT_NARROWABLE = ["enabled", "requestsPerMinute", "tokensPerMinute", "monthlyBudgetMicroUsd"];
508
+ /** A limit is a named budget bucket that the runtime selects by `scope`, so a
509
+ * name the tenant never published is not an extra restriction: it is a second
510
+ * bucket at the same scope whose ceiling no tenant entry bounds. */
511
+ function mergeLimits(tenant, application) {
512
+ return mergeKeyedFamily(tenant, application, {
513
+ key: entry => entry.name,
514
+ omittedByApplication: "keep",
515
+ added: { decision: "reject", code: WIDENS_TENANT_ALLOWLIST, describe: entry => `The application configuration publishes limit "${entry.name}", which the tenant configuration does not publish.` },
516
+ // `scope` is tenant-locked: re-scoping a tenant-wide budget to the
517
+ // application would give every application its own copy of the ceiling.
518
+ narrow: (base, entry) => {
519
+ const ceiling = (field) => narrowField(base[field], entry[field], minimum, RAISES_TENANT_CEILING, () => `The application limit "${entry.name}" raises ${field} above the tenant ceiling.`);
520
+ return narrowEntry(base, entry, LIMIT_NARROWABLE, {
521
+ enabled: narrowField(base.enabled, entry.enabled, either, RAISES_TENANT_CEILING, () => `The application configuration disables the enabled tenant limit "${entry.name}".`),
522
+ requestsPerMinute: ceiling("requestsPerMinute"),
523
+ tokensPerMinute: ceiling("tokensPerMinute"),
524
+ monthlyBudgetMicroUsd: ceiling("monthlyBudgetMicroUsd"),
525
+ }, field => `The application configuration changes the tenant-locked field "${field}" of limit "${entry.name}".`);
526
+ },
527
+ });
528
+ }
529
+ const RETENTION_NARROWABLE = ["captureMode", "retentionDays", "revealRoles"];
530
+ function mergeRetention(tenant, application) {
531
+ return narrowEntry(tenant, application, RETENTION_NARROWABLE, {
532
+ captureMode: narrowField(tenant.captureMode, application.captureMode, (tenantMode, applicationMode) => CAPTURE_MODE_RANK[applicationMode] <= CAPTURE_MODE_RANK[tenantMode] ? applicationMode : tenantMode, RAISES_TENANT_CEILING, () => "The application configuration raises the capture mode above the tenant ceiling."),
533
+ retentionDays: narrowField(tenant.retentionDays, application.retentionDays, minimum, RAISES_TENANT_CEILING, () => "The application configuration raises retentionDays above the tenant ceiling."),
534
+ // The contracted reveal-role list is a bounded tuple; an intersection can
535
+ // only shrink it, so the cast back to the tuple union is sound.
536
+ revealRoles: narrowField(tenant.revealRoles, application.revealRoles, (tenantRoles, applicationRoles) => intersect(tenantRoles, applicationRoles), WIDENS_TENANT_ALLOWLIST, () => `The application configuration permits reveal role "${application.revealRoles.find(role => !tenant.revealRoles.includes(role))}", which the tenant configuration does not permit.`),
537
+ }, field => `The application configuration changes the tenant-locked retention field "${field}".`);
538
+ }
539
+ const SCOPE_FAMILY_MERGES = {
540
+ providers: mergeProviders,
541
+ models: mergeModels,
542
+ routes: mergeRoutes,
543
+ policies: mergePolicies,
544
+ limits: mergeLimits,
545
+ retention: mergeRetention,
546
+ };
547
+ export function scopeFamilies() {
548
+ return Object.keys(SCOPE_FAMILY_MERGES);
549
+ }
550
+ /** The single-scope projection: the snapshot's policy families with the
551
+ * identity fields subtracted, re-checked against the contract bounds. */
552
+ export function scopePolicySet(snapshot) {
553
+ const set = {};
554
+ for (const family of scopeFamilies())
555
+ set[family] = snapshot[family];
556
+ return assertContractBounds(set);
557
+ }
558
+ /** Layers 3 and 4 of §6.3: the application/environment snapshot narrows the
559
+ * tenant snapshot per family — intersection for allowlists, minimum for
560
+ * numeric ceilings, union for guardrail restrictions — and any attempt to
561
+ * widen is rejected. Driven off `SCOPE_FAMILY_MERGES` rather than written out,
562
+ * so there is no place left for a family to be answered by omission. */
563
+ export function mergeScopeSnapshots(tenant, application) {
564
+ const merged = {};
565
+ for (const family of scopeFamilies()) {
566
+ const merge = SCOPE_FAMILY_MERGES[family];
567
+ merged[family] = merge(tenant[family], application[family]);
568
+ }
569
+ return assertContractBounds(merged);
570
+ }
571
+ /** An application entry the merge admits — today only a restricting guardrail
572
+ * the tenant never published — is the one way the merge OUTPUT can be larger
573
+ * than either input, so the merged set and the effective set are both
574
+ * re-validated against every `maxItems` in
575
+ * `schema/ai-policy-snapshot.schema.json`. */
576
+ export function assertContractBounds(set) {
577
+ const counted = [
578
+ ["providers", set.providers.length, 64],
579
+ ["models", set.models.length, 512],
580
+ ["routes", set.routes.length, 256],
581
+ ["policies", set.policies.length, 256],
582
+ ["limits", set.limits.length, 128],
583
+ ["retention reveal roles", set.retention.revealRoles.length, 16],
584
+ ...set.routes.map(route => [`targets of route "${route.alias}"`, route.targets.length, 16]),
585
+ ...set.policies.map(policy => [`guardrails of policy "${policy.name}"`, policy.guardrails.length, 128]),
586
+ ];
587
+ for (const [what, size, maximum] of counted) {
588
+ if (size > maximum) {
589
+ throw new AiPolicyResolutionError(409, EXCEEDS_CONTRACT_BOUNDS, `The resolved configuration carries ${size} ${what}, above the contract maximum of ${maximum}.`);
590
+ }
591
+ }
592
+ return set;
593
+ }
594
+ // ---------------------------------------------------------------------------
595
+ // The classification floor (layers 1 and 2).
596
+ // ---------------------------------------------------------------------------
597
+ /** The floor is not negotiable, so it clamps the merged tenant/application
598
+ * set instead of rejecting it: a stamped classification arrives after
599
+ * publication and must still bind. */
600
+ export function applyControlFloor(merged, floor, classification) {
601
+ const providers = merged.providers.filter(entry => floor.permittedProviderTypes.includes(entry.provider));
602
+ const usableProviders = new Set(providers.filter(entry => entry.enabled).map(entry => entry.id));
603
+ const models = merged.models.filter(entry => usableProviders.has(entry.providerId) && (!floor.requireApprovedModels || entry.approved));
604
+ const modelsById = new Map(models.map(entry => [entry.id, entry]));
605
+ const routes = merged.routes
606
+ // Filtering a bounded tuple can only shrink it; the cast back is sound.
607
+ .map(route => ({ ...route, targets: route.targets.filter(target => modelsById.get(target.modelId)?.capabilities.includes(route.capability)) }))
608
+ .filter(route => route.targets.length > 0);
609
+ const policies = merged.policies.map(policy => {
610
+ const permittedRequestFields = {};
611
+ for (const field of PERMITTED_REQUEST_FIELDS) {
612
+ permittedRequestFields[field] = policy.permittedRequestFields[field] && floor.permittedRequestFields[field];
613
+ }
614
+ return {
615
+ ...policy,
616
+ maxRequestBytes: Math.min(policy.maxRequestBytes, floor.maxRequestBytes),
617
+ maxInputTokens: Math.min(policy.maxInputTokens, floor.maxInputTokens),
618
+ maxOutputTokens: Math.min(policy.maxOutputTokens, floor.maxOutputTokens),
619
+ permittedRequestFields,
620
+ guardrails: applyRequiredGuardrails(policy.guardrails, floor.requiredGuardrails),
621
+ };
622
+ });
623
+ const limits = merged.limits.map(limit => ({
624
+ ...limit,
625
+ requestsPerMinute: Math.min(limit.requestsPerMinute, floor.maxRequestsPerMinute),
626
+ tokensPerMinute: Math.min(limit.tokensPerMinute, floor.maxTokensPerMinute),
627
+ monthlyBudgetMicroUsd: Math.min(limit.monthlyBudgetMicroUsd, floor.maxMonthlyBudgetMicroUsd),
628
+ }));
629
+ return assertContractBounds({
630
+ classification,
631
+ providers,
632
+ models,
633
+ routes,
634
+ policies,
635
+ limits,
636
+ retention: {
637
+ captureMode: CAPTURE_MODE_RANK[merged.retention.captureMode] <= CAPTURE_MODE_RANK[floor.maxCaptureMode] ? merged.retention.captureMode : floor.maxCaptureMode,
638
+ retentionDays: Math.min(merged.retention.retentionDays, floor.maxRetentionDays),
639
+ revealRoles: merged.retention.revealRoles,
640
+ },
641
+ });
642
+ }
643
+ /** A platform-required rule's IDENTITY is non-negotiable and replaces whatever
644
+ * held the id (a snapshot could otherwise pre-occupy a required rule id with a
645
+ * NOOP detector of its own choosing); only `action` is negotiable, and only
646
+ * upward. `parameters` go with the displaced entry, because they tune that
647
+ * entry's detector rather than the platform's. */
648
+ export function applyRequiredGuardrails(guardrails, required) {
649
+ const byRule = new Map(guardrails.map(rule => [rule.ruleId, rule]));
650
+ for (const minimumRule of required) {
651
+ const existing = byRule.get(minimumRule.ruleId);
652
+ const action = existing && GUARDRAIL_ACTION_RANK[existing.action] > GUARDRAIL_ACTION_RANK[minimumRule.action] ? existing.action : minimumRule.action;
653
+ byRule.set(minimumRule.ruleId, { ...minimumRule, action });
654
+ }
655
+ return [...byRule.values()].sort((first, second) => first.ruleId.localeCompare(second.ruleId));
656
+ }
657
+ // ---------------------------------------------------------------------------
658
+ // Composed resolution over a published scope pair.
659
+ // ---------------------------------------------------------------------------
660
+ /** The snapshot half of five-layer resolution, composed: classification from
661
+ * the published minimums (strictest wins; the platform default applies when no
662
+ * layer states one) optionally raised by a stamped classification, the
663
+ * tenant/application merge (a single present scope resolves alone), then the
664
+ * classification floor. Version controls (mode, gateway endpoint, alias
665
+ * grant) live on the published-configuration wrapper, which is not part of
666
+ * the snapshot contract — both consumers resolve them beside this call, using
667
+ * `mergeModelAliases` for the grant. */
668
+ export function resolveEffectiveScopePolicy(input) {
669
+ const { tenant, application, stampedClassification } = input;
670
+ if (!tenant && !application) {
671
+ throw new AiPolicyResolutionError(404, "configuration_not_published", "No published configuration exists for this scope.");
672
+ }
673
+ const tenantClassification = tenant?.classificationMinimum?.classification ?? AI_CLASSIFICATION_MINIMUMS.defaultClassification;
674
+ let classification = strictestClassification(tenantClassification, application?.classificationMinimum?.classification ?? tenantClassification);
675
+ if (stampedClassification)
676
+ classification = strictestClassification(classification, stampedClassification);
677
+ const merged = tenant && application ? mergeScopeSnapshots(tenant, application) : scopePolicySet((tenant ?? application));
678
+ return { classification, effective: applyControlFloor(merged, classificationFloor(classification), classification) };
679
+ }