@aldus-runtime/gate-engine 0.1.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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/dist/binding.d.ts +82 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +129 -0
- package/dist/binding.js.map +1 -0
- package/dist/definition.d.ts +187 -0
- package/dist/definition.d.ts.map +1 -0
- package/dist/definition.js +231 -0
- package/dist/definition.js.map +1 -0
- package/dist/engine.d.ts +194 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +361 -0
- package/dist/engine.js.map +1 -0
- package/dist/errors.d.ts +61 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/money.d.ts +52 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +116 -0
- package/dist/money.js.map +1 -0
- package/dist/ports.d.ts +60 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +51 -0
- package/dist/ports.js.map +1 -0
- package/dist/spend.d.ts +127 -0
- package/dist/spend.d.ts.map +1 -0
- package/dist/spend.js +154 -0
- package/dist/spend.js.map +1 -0
- package/package.json +48 -0
- package/src/binding.ts +179 -0
- package/src/definition.ts +380 -0
- package/src/engine.ts +544 -0
- package/src/errors.ts +66 -0
- package/src/index.ts +102 -0
- package/src/money.ts +146 -0
- package/src/ports.ts +87 -0
- package/src/spend.ts +240 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate evaluation, decision recording, and authorization (architecture contract §12, §13, §19.3).
|
|
3
|
+
*
|
|
4
|
+
* The central design choice: **invalidation is derived, never stored.**
|
|
5
|
+
*
|
|
6
|
+
* Contract §13.1 requires a content-changing edit to invalidate the Content Freeze "and
|
|
7
|
+
* downstream approvals". The obvious implementation writes invalidation records and walks the
|
|
8
|
+
* graph marking approvals dead. That implementation has a failure mode the contract cannot
|
|
9
|
+
* tolerate — if the cascade is ever interrupted, or a gate is added after the fact, some approval
|
|
10
|
+
* stays marked valid while the thing it approved has moved underneath it, and §13.2 forbids
|
|
11
|
+
* exactly that.
|
|
12
|
+
*
|
|
13
|
+
* So nothing is marked. A gate's state is computed on every evaluation from three inputs: its
|
|
14
|
+
* latest decision, the current digests of what it binds, and the state of the gates it depends
|
|
15
|
+
* on. A stale approval cannot survive because there is no stored "valid" flag for it to survive
|
|
16
|
+
* in. Adding a dependency edge invalidates downstream approvals immediately, with no migration.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ActorRef, AldusEvent, CostRecord, GateDecision } from "@aldus-runtime/core";
|
|
20
|
+
import { SCHEMA_VERSION, newEventId, newGateDecisionId, validate } from "@aldus-runtime/core";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
assertSubjectsCover,
|
|
24
|
+
detectDrift,
|
|
25
|
+
type GateSubject,
|
|
26
|
+
type SubjectDrift,
|
|
27
|
+
} from "./binding.js";
|
|
28
|
+
import type {
|
|
29
|
+
GateEnforcement,
|
|
30
|
+
GateLevel,
|
|
31
|
+
GateRegistry,
|
|
32
|
+
ResolvedGateDefinition,
|
|
33
|
+
} from "./definition.js";
|
|
34
|
+
import { GateEngineErrorCodes, gateEngineError } from "./errors.js";
|
|
35
|
+
import type { CostReader, GateDecisionStore, GateEventSink } from "./ports.js";
|
|
36
|
+
import {
|
|
37
|
+
checkSpend,
|
|
38
|
+
grantLimitsDigest,
|
|
39
|
+
type SpendCheck,
|
|
40
|
+
type SpendGrant,
|
|
41
|
+
type SpendRequest,
|
|
42
|
+
} from "./spend.js";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a gate currently is.
|
|
46
|
+
*
|
|
47
|
+
* `stale` is deliberately distinct from `pending`: a gate that was approved and then drifted is
|
|
48
|
+
* not the same operator situation as one nobody has looked at, and §13.1 wants the difference
|
|
49
|
+
* visible. `blocked_upstream` is likewise distinct from both — the gate itself may be perfectly
|
|
50
|
+
* approved while something it depends on is not.
|
|
51
|
+
*/
|
|
52
|
+
export const GATE_STATES = [
|
|
53
|
+
/** No decision has been recorded. */
|
|
54
|
+
"pending",
|
|
55
|
+
// NOTE: `blocked_upstream` below is used only when a gate is otherwise fine. A gate that is
|
|
56
|
+
// itself stale or rejected keeps that state and carries `blockedBy` alongside it, because the
|
|
57
|
+
// more specific label is the one an operator can act on.
|
|
58
|
+
/** Approved, and still bound to the current inputs. */
|
|
59
|
+
"satisfied",
|
|
60
|
+
/** Approved, but a bound value has changed since (§13.1, §13.2). */
|
|
61
|
+
"stale",
|
|
62
|
+
/** The operator rejected it. */
|
|
63
|
+
"rejected",
|
|
64
|
+
/** The operator asked for changes. */
|
|
65
|
+
"changes_requested",
|
|
66
|
+
/** The operator deliberately bypassed the check (§13, distinct from approval). */
|
|
67
|
+
"waived",
|
|
68
|
+
/** A blocking gate this one depends on is not satisfied (§13.1 cascade). */
|
|
69
|
+
"blocked_upstream",
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
/** @see GATE_STATES */
|
|
73
|
+
export type GateState = (typeof GATE_STATES)[number];
|
|
74
|
+
|
|
75
|
+
/** The evaluated state of one gate. */
|
|
76
|
+
export interface GateStatus {
|
|
77
|
+
gateId: string;
|
|
78
|
+
state: GateState;
|
|
79
|
+
/** What kind of judgement this gate represents (§12). */
|
|
80
|
+
level: GateLevel;
|
|
81
|
+
/** Whether it stops work or merely reports (§12). */
|
|
82
|
+
enforcement: GateEnforcement;
|
|
83
|
+
/** The decision this state was computed from, if any. */
|
|
84
|
+
decision?: GateDecision;
|
|
85
|
+
/** Which bound values moved, when `state` is `stale`. */
|
|
86
|
+
drift?: SubjectDrift;
|
|
87
|
+
/** Gates that blocked this one, when `state` is `blocked_upstream`. */
|
|
88
|
+
blockedBy?: string[];
|
|
89
|
+
/** Operator-facing explanation of why work may not proceed past this gate. */
|
|
90
|
+
explanation?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Whether this state stops work.
|
|
93
|
+
*
|
|
94
|
+
* An advisory gate is never blocking whatever its state — §12 level 2 "reports a possible issue
|
|
95
|
+
* without blocking" — which is why enforcement and state are separate fields rather than one
|
|
96
|
+
* conflated verdict.
|
|
97
|
+
*/
|
|
98
|
+
blocking: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Current digests of what each gate binds, keyed by gate. */
|
|
102
|
+
export type SubjectsByGate = Readonly<Record<string, readonly GateSubject[]>>;
|
|
103
|
+
|
|
104
|
+
/** True if two id lists hold the same ids in the same order. */
|
|
105
|
+
function sameIds(a: readonly string[], b: readonly string[]): boolean {
|
|
106
|
+
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What {@link GateEngine.decide} needs. */
|
|
110
|
+
export interface DecideInput {
|
|
111
|
+
runId: string;
|
|
112
|
+
gateId: string;
|
|
113
|
+
decision: GateDecision["decision"];
|
|
114
|
+
/** Current digests of everything the gate binds. */
|
|
115
|
+
subjects: readonly GateSubject[];
|
|
116
|
+
/** Who decided (§19.2). */
|
|
117
|
+
decidedBy: ActorRef;
|
|
118
|
+
/** ISO-8601 timestamp with offset. */
|
|
119
|
+
decidedAt: string;
|
|
120
|
+
comment?: string;
|
|
121
|
+
/** Canonical Episode identity, for the emitted event (§6.4). */
|
|
122
|
+
episodeId: string;
|
|
123
|
+
/** Overrides the gate's default. Defaults to the definition's `expiresOnChange`. */
|
|
124
|
+
expiresOnChange?: boolean;
|
|
125
|
+
/** Supplied for deterministic tests; defaults to a fresh ULID-based id. */
|
|
126
|
+
decisionId?: string;
|
|
127
|
+
/** Supplied for deterministic tests; defaults to a fresh ULID-based id. */
|
|
128
|
+
eventId?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Why an operation was refused. */
|
|
132
|
+
export interface AuthorizationRefusal {
|
|
133
|
+
authorized: false;
|
|
134
|
+
/** Gates that could have authorized the operation, and why none did. */
|
|
135
|
+
statuses: GateStatus[];
|
|
136
|
+
explanation: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** An operation the engine permits. */
|
|
140
|
+
export interface AuthorizationGrant {
|
|
141
|
+
authorized: true;
|
|
142
|
+
/** The gate whose approval authorized it. */
|
|
143
|
+
gateId: string;
|
|
144
|
+
decision: GateDecision;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Outcome of an authorization check. */
|
|
148
|
+
export type AuthorizationResult = AuthorizationGrant | AuthorizationRefusal;
|
|
149
|
+
|
|
150
|
+
/** Outcome of a spend authorization: the gate check and the budget check together. */
|
|
151
|
+
export type SpendAuthorization =
|
|
152
|
+
| {
|
|
153
|
+
authorized: true;
|
|
154
|
+
gateId: string;
|
|
155
|
+
decision: GateDecision;
|
|
156
|
+
check: Extract<SpendCheck, { allowed: true }>;
|
|
157
|
+
}
|
|
158
|
+
| { authorized: false; explanation: string; statuses?: GateStatus[]; check?: SpendCheck };
|
|
159
|
+
|
|
160
|
+
/** Wiring for a {@link GateEngine}. */
|
|
161
|
+
export interface GateEngineOptions {
|
|
162
|
+
registry: GateRegistry;
|
|
163
|
+
decisions: GateDecisionStore;
|
|
164
|
+
events: GateEventSink;
|
|
165
|
+
costs?: CostReader;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Evaluates gates, records decisions, and authorizes operations and spend.
|
|
170
|
+
*/
|
|
171
|
+
export class GateEngine {
|
|
172
|
+
readonly #registry: GateRegistry;
|
|
173
|
+
readonly #decisions: GateDecisionStore;
|
|
174
|
+
readonly #events: GateEventSink;
|
|
175
|
+
readonly #costs: CostReader | undefined;
|
|
176
|
+
|
|
177
|
+
constructor(options: GateEngineOptions) {
|
|
178
|
+
this.#registry = options.registry;
|
|
179
|
+
this.#decisions = options.decisions;
|
|
180
|
+
this.#events = options.events;
|
|
181
|
+
this.#costs = options.costs;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The gate definitions this engine evaluates. */
|
|
185
|
+
get registry(): GateRegistry {
|
|
186
|
+
return this.#registry;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Record a human decision (contract §3.6).
|
|
191
|
+
*
|
|
192
|
+
* §3.6: "Human review MUST create a durable `GateDecision`. A chat message saying 'looks good'
|
|
193
|
+
* is not enough unless it is translated into a recorded decision tied to exact inputs." This is
|
|
194
|
+
* that translation, and it refuses anything that would produce a decision tied to less than the
|
|
195
|
+
* gate binds.
|
|
196
|
+
*
|
|
197
|
+
* @throws {AldusError} `ALDUS_GATE_NOT_FOUND` if the gate is not registered.
|
|
198
|
+
* @throws {AldusError} `ALDUS_GATE_SUBJECTS_INCOMPLETE` if the subjects do not cover the gate.
|
|
199
|
+
* @throws {AldusError} `ALDUS_GATE_ACTOR_NOT_PERMITTED` if the actor may not decide this gate.
|
|
200
|
+
*/
|
|
201
|
+
async decide(input: DecideInput): Promise<GateDecision> {
|
|
202
|
+
const gate = this.#registry.require(input.gateId);
|
|
203
|
+
assertSubjectsCover(gate, input.subjects);
|
|
204
|
+
|
|
205
|
+
if (!gate.permittedActorKinds.includes(input.decidedBy.kind)) {
|
|
206
|
+
throw gateEngineError(
|
|
207
|
+
GateEngineErrorCodes.GATE_ACTOR_NOT_PERMITTED,
|
|
208
|
+
`Gate "${gate.gateId}" accepts decisions from [${gate.permittedActorKinds.join(", ")}], ` +
|
|
209
|
+
`but "${input.decidedBy.id}" is a ${input.decidedBy.kind}. Contract §12 forbids ` +
|
|
210
|
+
"presenting a machine pass as semantic correctness, and §13.3 keeps final performance " +
|
|
211
|
+
"approval human-owned.",
|
|
212
|
+
{
|
|
213
|
+
category: "policy",
|
|
214
|
+
details: {
|
|
215
|
+
gateId: gate.gateId,
|
|
216
|
+
actorKind: input.decidedBy.kind,
|
|
217
|
+
permitted: [...gate.permittedActorKinds],
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const decision: GateDecision = {
|
|
224
|
+
schemaVersion: SCHEMA_VERSION,
|
|
225
|
+
decisionId: input.decisionId ?? newGateDecisionId(),
|
|
226
|
+
gateId: gate.gateId,
|
|
227
|
+
runId: input.runId,
|
|
228
|
+
decision: input.decision,
|
|
229
|
+
subjectHashes: [...input.subjects].map((subject) => subject.sha256).sort(),
|
|
230
|
+
decidedBy: input.decidedBy,
|
|
231
|
+
decidedAt: input.decidedAt,
|
|
232
|
+
...(input.comment !== undefined ? { comment: input.comment } : {}),
|
|
233
|
+
expiresOnChange: input.expiresOnChange ?? gate.expiresOnChange,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Validate before persisting. A malformed decision written to the approvals log is worse
|
|
237
|
+
// than a rejected call, because §13 treats what is written there as authoritative.
|
|
238
|
+
const validated = validate("GateDecision", decision);
|
|
239
|
+
if (!validated.ok) {
|
|
240
|
+
throw gateEngineError(
|
|
241
|
+
GateEngineErrorCodes.GATE_DEFINITION_INVALID,
|
|
242
|
+
`The decision built for gate "${gate.gateId}" is not a valid GateDecision.`,
|
|
243
|
+
{ category: "internal", details: { issues: validated.error.details } },
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await this.#decisions.append(input.runId, decision);
|
|
248
|
+
await this.#emitDecisionEvent(input, decision);
|
|
249
|
+
return decision;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Emit the §6.4 event for a recorded decision. */
|
|
253
|
+
async #emitDecisionEvent(input: DecideInput, decision: GateDecision): Promise<void> {
|
|
254
|
+
const event: AldusEvent = {
|
|
255
|
+
schemaVersion: SCHEMA_VERSION,
|
|
256
|
+
eventId: input.eventId ?? newEventId(),
|
|
257
|
+
occurredAt: decision.decidedAt,
|
|
258
|
+
episodeId: input.episodeId,
|
|
259
|
+
runId: decision.runId,
|
|
260
|
+
action: `gate.${decision.decision}`,
|
|
261
|
+
actor: decision.decidedBy,
|
|
262
|
+
inputRefs: [],
|
|
263
|
+
outputRefs: [],
|
|
264
|
+
details: {
|
|
265
|
+
gateId: decision.gateId,
|
|
266
|
+
decisionId: decision.decisionId,
|
|
267
|
+
subjectCount: decision.subjectHashes.length,
|
|
268
|
+
expiresOnChange: decision.expiresOnChange,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
await this.#events.emit(event);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Evaluate every registered gate for a Run.
|
|
276
|
+
*
|
|
277
|
+
* `subjects` supplies the current digests of what each gate binds. A gate absent from it is
|
|
278
|
+
* evaluated as having no current inputs, which reads as `pending` — never as satisfied.
|
|
279
|
+
*/
|
|
280
|
+
async evaluate(runId: string, subjects: SubjectsByGate): Promise<Map<string, GateStatus>> {
|
|
281
|
+
const decisions = await this.#decisions.list(runId);
|
|
282
|
+
return this.evaluateWith(decisions, subjects);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Evaluate against a decision list already in hand.
|
|
287
|
+
*
|
|
288
|
+
* Separated from {@link GateEngine.evaluate} so the whole cascade is a pure function of its
|
|
289
|
+
* inputs — which is what makes it testable without a store and impossible to get into a
|
|
290
|
+
* partially-updated state.
|
|
291
|
+
*/
|
|
292
|
+
evaluateWith(
|
|
293
|
+
decisions: readonly GateDecision[],
|
|
294
|
+
subjects: SubjectsByGate,
|
|
295
|
+
): Map<string, GateStatus> {
|
|
296
|
+
const own = new Map<string, GateStatus>();
|
|
297
|
+
for (const gate of this.#registry.list()) {
|
|
298
|
+
own.set(gate.gateId, this.#evaluateOne(gate, decisions, subjects[gate.gateId] ?? []));
|
|
299
|
+
}
|
|
300
|
+
return this.#applyCascade(own);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** A gate's state from its own decision and subjects, before the cascade. */
|
|
304
|
+
#evaluateOne(
|
|
305
|
+
gate: ResolvedGateDefinition,
|
|
306
|
+
decisions: readonly GateDecision[],
|
|
307
|
+
subjects: readonly GateSubject[],
|
|
308
|
+
): GateStatus {
|
|
309
|
+
const base = { gateId: gate.gateId, level: gate.level, enforcement: gate.enforcement };
|
|
310
|
+
const blocks = (state: GateState): boolean =>
|
|
311
|
+
gate.enforcement === "blocking" && state !== "satisfied" && state !== "waived";
|
|
312
|
+
|
|
313
|
+
// Append order is authoritative, not `decidedAt`: the log is the durable fact, and two
|
|
314
|
+
// machines with disagreeing clocks must not be able to reorder which approval is current.
|
|
315
|
+
const latest = [...decisions].reverse().find((entry) => entry.gateId === gate.gateId);
|
|
316
|
+
|
|
317
|
+
if (latest === undefined) {
|
|
318
|
+
return {
|
|
319
|
+
...base,
|
|
320
|
+
state: "pending",
|
|
321
|
+
blocking: blocks("pending"),
|
|
322
|
+
explanation: `Gate "${gate.gateId}" has no recorded decision.`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (latest.decision === "rejected" || latest.decision === "changes_requested") {
|
|
327
|
+
return {
|
|
328
|
+
...base,
|
|
329
|
+
state: latest.decision,
|
|
330
|
+
decision: latest,
|
|
331
|
+
blocking: blocks(latest.decision),
|
|
332
|
+
explanation:
|
|
333
|
+
latest.comment ?? `Gate "${gate.gateId}" was ${latest.decision.replace("_", " ")}.`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const drift = detectDrift(latest, subjects);
|
|
338
|
+
|
|
339
|
+
// A waiver records that a check was bypassed (§13). It still binds: waiving a gate for one
|
|
340
|
+
// version of the content says nothing about the next, so drift voids it exactly as it voids
|
|
341
|
+
// an approval.
|
|
342
|
+
if (drift !== undefined && latest.expiresOnChange) {
|
|
343
|
+
return {
|
|
344
|
+
...base,
|
|
345
|
+
state: "stale",
|
|
346
|
+
decision: latest,
|
|
347
|
+
drift,
|
|
348
|
+
blocking: blocks("stale"),
|
|
349
|
+
explanation:
|
|
350
|
+
`Gate "${gate.gateId}" was ${latest.decision}, but ` +
|
|
351
|
+
`${drift.changed.length > 0 ? `[${drift.changed.join(", ")}] changed` : "its bound inputs changed"}` +
|
|
352
|
+
" since. Contract §13.1 voids an approval once what it approved has moved.",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const state: GateState = latest.decision === "waived" ? "waived" : "satisfied";
|
|
357
|
+
return { ...base, state, decision: latest, blocking: blocks(state) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Propagate §13.1's cascade over the dependency graph.
|
|
362
|
+
*
|
|
363
|
+
* Only **blocking** gates propagate. §12 level 2 defines an advisory signal as one that
|
|
364
|
+
* "reports a possible issue without blocking", so an un-run advisory check must not halt the
|
|
365
|
+
* gates downstream of it — otherwise every advisory would be a hard gate wearing a different
|
|
366
|
+
* label, and §12's four levels would collapse into two.
|
|
367
|
+
*/
|
|
368
|
+
#applyCascade(own: Map<string, GateStatus>): Map<string, GateStatus> {
|
|
369
|
+
const result = new Map(own);
|
|
370
|
+
// Registry construction already refused a cycle, so a fixpoint loop terminates. Bounding it
|
|
371
|
+
// by the gate count keeps a future graph bug from hanging an operator's session regardless.
|
|
372
|
+
for (let pass = 0; pass < result.size + 1; pass += 1) {
|
|
373
|
+
let changed = false;
|
|
374
|
+
for (const gate of this.#registry.list()) {
|
|
375
|
+
const current = result.get(gate.gateId);
|
|
376
|
+
if (current === undefined) continue;
|
|
377
|
+
|
|
378
|
+
const blockedBy = gate.dependsOn.filter((dependency) => {
|
|
379
|
+
const upstream = result.get(dependency);
|
|
380
|
+
return upstream !== undefined && upstream.blocking;
|
|
381
|
+
});
|
|
382
|
+
if (blockedBy.length === 0) continue;
|
|
383
|
+
if (current.blockedBy !== undefined && sameIds(current.blockedBy, blockedBy)) continue;
|
|
384
|
+
|
|
385
|
+
// A gate can be broken on its own account *and* blocked by something upstream. When it
|
|
386
|
+
// has a decision that is itself broken, that state wins: an operator told only "blocked
|
|
387
|
+
// upstream" would fix the upstream gate and be surprised this one still needs
|
|
388
|
+
// re-approving. `blockedBy` is set either way, so the cascade is never lost.
|
|
389
|
+
//
|
|
390
|
+
// A `pending` gate has no such decision to preserve, and there "blocked upstream" is the
|
|
391
|
+
// more useful label — it says the gate cannot even be started on yet, rather than merely
|
|
392
|
+
// that nobody has.
|
|
393
|
+
const ownStateIsInformative =
|
|
394
|
+
current.state === "stale" ||
|
|
395
|
+
current.state === "rejected" ||
|
|
396
|
+
current.state === "changes_requested";
|
|
397
|
+
const state: GateState = ownStateIsInformative ? current.state : "blocked_upstream";
|
|
398
|
+
const upstreamNote =
|
|
399
|
+
`[${blockedBy.join(", ")}] is not satisfied, so contract §13.1 invalidates this ` +
|
|
400
|
+
"approval along with the gate it depends on.";
|
|
401
|
+
|
|
402
|
+
result.set(gate.gateId, {
|
|
403
|
+
...current,
|
|
404
|
+
state,
|
|
405
|
+
blockedBy,
|
|
406
|
+
blocking: gate.enforcement === "blocking",
|
|
407
|
+
explanation: ownStateIsInformative
|
|
408
|
+
? `${current.explanation ?? `Gate "${gate.gateId}" is ${current.state}.`} Additionally, ${upstreamNote}`
|
|
409
|
+
: `Gate "${gate.gateId}" cannot be relied on because ${upstreamNote}`,
|
|
410
|
+
});
|
|
411
|
+
changed = true;
|
|
412
|
+
}
|
|
413
|
+
if (!changed) break;
|
|
414
|
+
}
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Whether an operation is authorized (contract §13.4).
|
|
420
|
+
*
|
|
421
|
+
* An approval authorizes exactly the operations its gate names in `grants` and nothing else.
|
|
422
|
+
* That is what keeps §13.4's "Uploading and making public SHOULD be separate operations"
|
|
423
|
+
* enforceable: they are two gates granting two operations, and approving one leaves the other
|
|
424
|
+
* refused.
|
|
425
|
+
*
|
|
426
|
+
* Returns a refusal rather than throwing, because a caller needs to display why an operation is
|
|
427
|
+
* unavailable.
|
|
428
|
+
*/
|
|
429
|
+
async authorize(
|
|
430
|
+
runId: string,
|
|
431
|
+
operation: string,
|
|
432
|
+
subjects: SubjectsByGate,
|
|
433
|
+
): Promise<AuthorizationResult> {
|
|
434
|
+
const statuses = await this.evaluate(runId, subjects);
|
|
435
|
+
const candidates = this.#registry.list().filter((gate) => gate.grants.includes(operation));
|
|
436
|
+
|
|
437
|
+
if (candidates.length === 0) {
|
|
438
|
+
return {
|
|
439
|
+
authorized: false,
|
|
440
|
+
statuses: [],
|
|
441
|
+
explanation:
|
|
442
|
+
`No registered gate grants "${operation}". An operation nobody authorizes is refused ` +
|
|
443
|
+
"rather than allowed, so that adding a gate is what enables an action, never omitting one.",
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const relevant = candidates.flatMap((gate) => {
|
|
448
|
+
const status = statuses.get(gate.gateId);
|
|
449
|
+
return status === undefined ? [] : [status];
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const granted = relevant.find(
|
|
453
|
+
(status) => status.state === "satisfied" && status.decision?.decision === "approved",
|
|
454
|
+
);
|
|
455
|
+
if (granted?.decision !== undefined) {
|
|
456
|
+
return { authorized: true, gateId: granted.gateId, decision: granted.decision };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
authorized: false,
|
|
461
|
+
statuses: relevant,
|
|
462
|
+
explanation:
|
|
463
|
+
`"${operation}" is not authorized. ` +
|
|
464
|
+
relevant
|
|
465
|
+
.map(
|
|
466
|
+
(status) =>
|
|
467
|
+
`${status.gateId}: ${status.state}${status.explanation === undefined ? "" : ` — ${status.explanation}`}`,
|
|
468
|
+
)
|
|
469
|
+
.join("; "),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Whether a paid request may proceed (contract §13.2, §19.3).
|
|
475
|
+
*
|
|
476
|
+
* Three things must hold, and all three are checked here because any one of them alone is
|
|
477
|
+
* insufficient:
|
|
478
|
+
*
|
|
479
|
+
* 1. the gate is satisfied — §13.2 forbids paid synthesis before the operator approves;
|
|
480
|
+
* 2. the grant's limits are among what that decision bound — otherwise the ceiling could be
|
|
481
|
+
* raised after approval without voiding it;
|
|
482
|
+
* 3. the spend fits the remaining budget — §19.3 stop-on-budget.
|
|
483
|
+
*/
|
|
484
|
+
async authorizeSpend(
|
|
485
|
+
runId: string,
|
|
486
|
+
grant: SpendGrant,
|
|
487
|
+
request: SpendRequest,
|
|
488
|
+
subjects: SubjectsByGate,
|
|
489
|
+
costs?: readonly CostRecord[],
|
|
490
|
+
): Promise<SpendAuthorization> {
|
|
491
|
+
const statuses = await this.evaluate(runId, subjects);
|
|
492
|
+
const status = statuses.get(grant.gateId);
|
|
493
|
+
|
|
494
|
+
if (status === undefined) {
|
|
495
|
+
return {
|
|
496
|
+
authorized: false,
|
|
497
|
+
explanation: `Gate "${grant.gateId}" is not registered, so it cannot authorize spend.`,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (status.state !== "satisfied" || status.decision?.decision !== "approved") {
|
|
502
|
+
return {
|
|
503
|
+
authorized: false,
|
|
504
|
+
statuses: [status],
|
|
505
|
+
explanation:
|
|
506
|
+
`Paid work is refused: gate "${grant.gateId}" is ${status.state}. Contract §13.2 ` +
|
|
507
|
+
"forbids paid synthesis until the operator has approved, and voids that approval once " +
|
|
508
|
+
`any bound value changes.${status.explanation === undefined ? "" : ` ${status.explanation}`}`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const decision = status.decision;
|
|
513
|
+
if (decision.decisionId !== grant.decisionId) {
|
|
514
|
+
return {
|
|
515
|
+
authorized: false,
|
|
516
|
+
statuses: [status],
|
|
517
|
+
explanation:
|
|
518
|
+
`The grant cites decision "${grant.decisionId}", but the current decision on gate ` +
|
|
519
|
+
`"${grant.gateId}" is "${decision.decisionId}". A grant from a superseded decision ` +
|
|
520
|
+
"does not carry forward (§13.2).",
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!decision.subjectHashes.includes(grantLimitsDigest(grant))) {
|
|
525
|
+
return {
|
|
526
|
+
authorized: false,
|
|
527
|
+
statuses: [status],
|
|
528
|
+
explanation:
|
|
529
|
+
`The authorized maximum of ${grant.maxTotal.amount} ${grant.maxTotal.currency} is not ` +
|
|
530
|
+
`among what decision "${decision.decisionId}" bound. Contract §13.2 requires the ` +
|
|
531
|
+
"operator to approve a maximum authorized cost; a limit the approval never covered is " +
|
|
532
|
+
"not an authorization.",
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const recorded = costs ?? (this.#costs === undefined ? [] : await this.#costs.list(runId));
|
|
537
|
+
const check = checkSpend(grant, recorded, request);
|
|
538
|
+
if (!check.allowed) {
|
|
539
|
+
return { authorized: false, explanation: check.explanation, statuses: [status], check };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { authorized: true, gateId: grant.gateId, decision, check };
|
|
543
|
+
}
|
|
544
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failures specific to gate evaluation and spend authorization.
|
|
3
|
+
*
|
|
4
|
+
* Aldus Core deliberately keeps no central error-code registry, so that a package can name a new
|
|
5
|
+
* failure without forking Core. These codes are this package's contribution; they carry the same
|
|
6
|
+
* `ALDUS_` prefix and `SCREAMING_SNAKE_CASE` shape so production trace (contract §20) stays
|
|
7
|
+
* uniform across packages.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AldusError, type ErrorCategory } from "@aldus-runtime/core";
|
|
11
|
+
|
|
12
|
+
/** Error codes raised by the gate engine. */
|
|
13
|
+
export const GateEngineErrorCodes = {
|
|
14
|
+
/** A gate was referenced that is not registered. */
|
|
15
|
+
GATE_NOT_FOUND: "ALDUS_GATE_NOT_FOUND",
|
|
16
|
+
/** A gate definition is internally inconsistent and was refused at registration. */
|
|
17
|
+
GATE_DEFINITION_INVALID: "ALDUS_GATE_DEFINITION_INVALID",
|
|
18
|
+
/**
|
|
19
|
+
* Gate dependencies form a cycle.
|
|
20
|
+
*
|
|
21
|
+
* A configuration fault, not a data fault: contract §13.1's cascade is only meaningful over a
|
|
22
|
+
* directed acyclic graph, and a cycle would make "what does this invalidate" unanswerable.
|
|
23
|
+
*/
|
|
24
|
+
GATE_DEPENDENCY_CYCLE: "ALDUS_GATE_DEPENDENCY_CYCLE",
|
|
25
|
+
/**
|
|
26
|
+
* A decision was submitted by an actor the gate does not accept.
|
|
27
|
+
*
|
|
28
|
+
* Contract §13.3 keeps final performance approval human-owned, and §12 reserves the human
|
|
29
|
+
* oracle level for subjective and asymmetric-risk judgements. A machine actor satisfying such
|
|
30
|
+
* a gate would present a machine pass as semantic correctness, which §12 forbids outright.
|
|
31
|
+
*/
|
|
32
|
+
GATE_ACTOR_NOT_PERMITTED: "ALDUS_GATE_ACTOR_NOT_PERMITTED",
|
|
33
|
+
/** A decision was submitted that does not bind the subjects its gate requires. */
|
|
34
|
+
GATE_SUBJECTS_INCOMPLETE: "ALDUS_GATE_SUBJECTS_INCOMPLETE",
|
|
35
|
+
/**
|
|
36
|
+
* An operation requiring authorization was attempted without a valid one.
|
|
37
|
+
*
|
|
38
|
+
* Contract §13.2: paid TTS MUST NOT run until the operator approves. This is the refusal that
|
|
39
|
+
* enforces it.
|
|
40
|
+
*/
|
|
41
|
+
AUTHORIZATION_MISSING: "ALDUS_AUTHORIZATION_MISSING",
|
|
42
|
+
/**
|
|
43
|
+
* An authorization exists but no longer binds the current inputs.
|
|
44
|
+
*
|
|
45
|
+
* Contract §13.2: "The authorization MUST be invalidated if any bound value changes."
|
|
46
|
+
*/
|
|
47
|
+
AUTHORIZATION_STALE: "ALDUS_AUTHORIZATION_STALE",
|
|
48
|
+
/** A spend request would exceed the authorized maximum (contract §19.3 stop-on-budget). */
|
|
49
|
+
SPEND_LIMIT_EXCEEDED: "ALDUS_SPEND_LIMIT_EXCEEDED",
|
|
50
|
+
/** Two monetary values in different currencies were combined or compared. */
|
|
51
|
+
CURRENCY_MISMATCH: "ALDUS_CURRENCY_MISMATCH",
|
|
52
|
+
/** A monetary amount was not a well-formed decimal string. */
|
|
53
|
+
MONEY_MALFORMED: "ALDUS_MONEY_MALFORMED",
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
/** @see GateEngineErrorCodes */
|
|
57
|
+
export type GateEngineErrorCode = (typeof GateEngineErrorCodes)[keyof typeof GateEngineErrorCodes];
|
|
58
|
+
|
|
59
|
+
/** Construct an {@link AldusError} with a gate-engine code. */
|
|
60
|
+
export function gateEngineError(
|
|
61
|
+
code: GateEngineErrorCode,
|
|
62
|
+
message: string,
|
|
63
|
+
options: { category: ErrorCategory; retryable?: boolean; details?: Record<string, unknown> },
|
|
64
|
+
): AldusError {
|
|
65
|
+
return new AldusError(code, message, options);
|
|
66
|
+
}
|