@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.
- package/README.md +1 -1
- package/dist/gen-ts/ai-callsite-inventory.d.ts +49 -0
- package/dist/gen-ts/ai-gateway-key-lookup.d.ts +20 -0
- package/dist/gen-ts/ai-gateway-key-token.d.ts +4 -0
- package/dist/gen-ts/ai-gateway-key-token.js +6 -0
- package/dist/gen-ts/ai-governance-decision.d.ts +74 -0
- package/dist/gen-ts/ai-policy-snapshot.d.ts +429 -0
- package/dist/gen-ts/ai-runtime-contract.d.ts +27 -0
- package/dist/gen-ts/ai-trace-event.d.ts +68 -0
- package/dist/gen-ts/ai-transform-event.d.ts +66 -0
- package/dist/gen-ts/ai-transform-result.d.ts +32 -0
- package/dist/gen-ts/ai-verification-evidence.d.ts +49 -0
- package/dist/gen-ts/data-dependency.d.ts +42 -0
- package/dist/gen-ts/data-grant.d.ts +58 -0
- package/dist/gen-ts/data-grant.js +12 -0
- package/dist/gen-ts/data-source.d.ts +74 -0
- package/dist/{deploy-request.d.ts → gen-ts/deploy-request.d.ts} +30 -0
- package/dist/gen-ts/deploy-response.js +3 -0
- package/dist/gen-ts/deployment-environment.js +3 -0
- package/dist/gen-ts/deployment-source.js +3 -0
- package/dist/gen-ts/deployment-status-failure.js +3 -0
- package/dist/gen-ts/deployment-status-request.js +3 -0
- package/dist/{deployment-status-response.d.ts → gen-ts/deployment-status-response.d.ts} +2 -2
- package/dist/gen-ts/deployment-status-response.js +3 -0
- package/dist/gen-ts/env-bindings.js +3 -0
- package/dist/{error-category-registry.d.ts → gen-ts/error-category-registry.d.ts} +7 -0
- package/dist/{error-category-registry.js → gen-ts/error-category-registry.js} +14 -0
- package/dist/gen-ts/error-category.js +3 -0
- package/dist/gen-ts/error-envelope.js +3 -0
- package/dist/{index.d.ts → gen-ts/index.d.ts} +16 -0
- package/dist/{index.js → gen-ts/index.js} +16 -0
- package/dist/gen-ts/kvs-routing-entry.js +3 -0
- package/dist/gen-ts/promotion-request.d.ts +17 -0
- package/dist/gen-ts/promotion-request.js +3 -0
- package/dist/gen-ts/promotion-response.d.ts +17 -0
- package/dist/gen-ts/promotion-response.js +3 -0
- package/dist/gen-ts/secret-reference.d.ts +36 -0
- package/dist/gen-ts/secret-reference.js +3 -0
- package/dist/{version-state-machine.d.ts → gen-ts/version-state-machine.d.ts} +7 -2
- package/dist/gen-ts/version-state-machine.js +45 -0
- package/dist/gen-ts/version-state.js +3 -0
- package/dist/resolver/index.d.ts +256 -0
- package/dist/resolver/index.js +679 -0
- package/package.json +14 -4
- package/schema/ai-callsite-inventory.schema.json +138 -0
- package/schema/ai-gateway-key-lookup.schema.json +53 -0
- package/schema/ai-governance-decision.schema.json +105 -0
- package/schema/ai-policy-snapshot.schema.json +340 -0
- package/schema/ai-runtime-contract.schema.json +85 -0
- package/schema/ai-trace-event.schema.json +178 -0
- package/schema/ai-transform-event.schema.json +72 -0
- package/schema/ai-transform-result.schema.json +46 -0
- package/schema/ai-verification-evidence.schema.json +57 -0
- package/schema/data-dependency.schema.json +89 -0
- package/schema/data-grant.schema.json +107 -0
- package/schema/data-source.schema.json +151 -0
- package/schema/deploy-request.schema.json +60 -0
- package/schema/deploy-response.schema.json +1 -1
- package/schema/deployment-status-request.schema.json +1 -1
- package/schema/deployment-status-response.schema.json +2 -2
- package/schema/error-category.enum.json +7 -0
- package/schema/promotion-request.schema.json +26 -0
- package/schema/promotion-response.schema.json +50 -0
- package/schema/secret-reference.schema.json +70 -0
- package/schema/version-state.schema.json +25 -24
- package/dist/version-state-machine.js +0 -35
- /package/dist/{api-error-response.js → gen-ts/ai-callsite-inventory.js} +0 -0
- /package/dist/{artifact-contract.js → gen-ts/ai-gateway-key-lookup.js} +0 -0
- /package/dist/{deploy-request.js → gen-ts/ai-governance-decision.js} +0 -0
- /package/dist/{deploy-response.js → gen-ts/ai-policy-snapshot.js} +0 -0
- /package/dist/{deployment-environment.js → gen-ts/ai-runtime-contract.js} +0 -0
- /package/dist/{deployment-source.js → gen-ts/ai-trace-event.js} +0 -0
- /package/dist/{deployment-status-failure.js → gen-ts/ai-transform-event.js} +0 -0
- /package/dist/{deployment-status-request.js → gen-ts/ai-transform-result.js} +0 -0
- /package/dist/{deployment-status-response.js → gen-ts/ai-verification-evidence.js} +0 -0
- /package/dist/{api-error-response.d.ts → gen-ts/api-error-response.d.ts} +0 -0
- /package/dist/{env-bindings.js → gen-ts/api-error-response.js} +0 -0
- /package/dist/{artifact-contract.d.ts → gen-ts/artifact-contract.d.ts} +0 -0
- /package/dist/{error-category.js → gen-ts/artifact-contract.js} +0 -0
- /package/dist/{error-envelope.js → gen-ts/data-dependency.js} +0 -0
- /package/dist/{kvs-routing-entry.js → gen-ts/data-source.js} +0 -0
- /package/dist/{version-state.js → gen-ts/deploy-request.js} +0 -0
- /package/dist/{deploy-response.d.ts → gen-ts/deploy-response.d.ts} +0 -0
- /package/dist/{deployment-environment.d.ts → gen-ts/deployment-environment.d.ts} +0 -0
- /package/dist/{deployment-source.d.ts → gen-ts/deployment-source.d.ts} +0 -0
- /package/dist/{deployment-status-failure.d.ts → gen-ts/deployment-status-failure.d.ts} +0 -0
- /package/dist/{deployment-status-request.d.ts → gen-ts/deployment-status-request.d.ts} +0 -0
- /package/dist/{env-bindings.d.ts → gen-ts/env-bindings.d.ts} +0 -0
- /package/dist/{error-category.d.ts → gen-ts/error-category.d.ts} +0 -0
- /package/dist/{error-envelope.d.ts → gen-ts/error-envelope.d.ts} +0 -0
- /package/dist/{kvs-routing-entry.d.ts → gen-ts/kvs-routing-entry.d.ts} +0 -0
- /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
|
+
}
|