@aldus-runtime/gate-engine 0.2.0-next.6 → 0.2.0-next.60
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/dist/definition.d.ts +26 -24
- package/dist/definition.d.ts.map +1 -1
- package/dist/definition.js +35 -26
- package/dist/definition.js.map +1 -1
- package/dist/engine.d.ts +17 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +47 -9
- package/dist/engine.js.map +1 -1
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/spend.d.ts +204 -9
- package/dist/spend.d.ts.map +1 -1
- package/dist/spend.js +152 -6
- package/dist/spend.js.map +1 -1
- package/package.json +3 -3
- package/src/definition.ts +60 -39
- package/src/engine.ts +81 -11
- package/src/errors.ts +15 -0
- package/src/index.ts +6 -1
- package/src/spend.ts +354 -9
package/src/definition.ts
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ActorKind } from "@aldus-runtime/core";
|
|
18
|
+
import {
|
|
19
|
+
QUALITY_ENFORCEMENTS,
|
|
20
|
+
QUALITY_LEVELS,
|
|
21
|
+
validateQualityClaim,
|
|
22
|
+
type PromotionEvidence,
|
|
23
|
+
type QualityEnforcement,
|
|
24
|
+
type QualityLevel,
|
|
25
|
+
} from "@aldus-runtime/core";
|
|
18
26
|
|
|
19
27
|
import { GateEngineErrorCodes, gateEngineError } from "./errors.js";
|
|
20
28
|
|
|
@@ -26,32 +34,29 @@ import { GateEngineErrorCodes, gateEngineError } from "./errors.js";
|
|
|
26
34
|
* does not, and a model-assisted review may do either depending on whether it has been
|
|
27
35
|
* calibrated (§12.1).
|
|
28
36
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"human_oracle",
|
|
38
|
-
] as const;
|
|
37
|
+
/**
|
|
38
|
+
* @deprecated in name only — the concept moved to Core (#115) and this is the gate-shaped alias.
|
|
39
|
+
*
|
|
40
|
+
* §12's levels are a property of any quality mechanism, not of gates. They lived here alone until
|
|
41
|
+
* an adopter's blocking evaluator turned out to be a Stage, which could declare none of this. The
|
|
42
|
+
* canonical definition is `QUALITY_LEVELS`; this name is kept because published code consumes it.
|
|
43
|
+
*/
|
|
44
|
+
export const GATE_LEVELS = QUALITY_LEVELS;
|
|
39
45
|
|
|
40
46
|
/** @see GATE_LEVELS */
|
|
41
|
-
export type GateLevel =
|
|
47
|
+
export type GateLevel = QualityLevel;
|
|
42
48
|
|
|
43
49
|
/**
|
|
44
|
-
* Whether a gate stops work or merely reports.
|
|
50
|
+
* Whether a gate stops work or merely reports. The gate-shaped alias of `QUALITY_ENFORCEMENTS`.
|
|
45
51
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* config file without producing any.
|
|
52
|
+
* Still a two-state enumeration rather than a boolean, for the reason Core records: §12.1 makes
|
|
53
|
+
* blocking a promotion with evidence behind it, and `blocking: boolean` invites someone to flip it
|
|
54
|
+
* in a config file without producing any.
|
|
50
55
|
*/
|
|
51
|
-
export const GATE_ENFORCEMENTS =
|
|
56
|
+
export const GATE_ENFORCEMENTS = QUALITY_ENFORCEMENTS;
|
|
52
57
|
|
|
53
58
|
/** @see GATE_ENFORCEMENTS */
|
|
54
|
-
export type GateEnforcement =
|
|
59
|
+
export type GateEnforcement = QualityEnforcement;
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
62
|
* Evidence that a model-assisted evaluator was calibrated before it was allowed to block.
|
|
@@ -65,14 +70,7 @@ export type GateEnforcement = (typeof GATE_ENFORCEMENTS)[number];
|
|
|
65
70
|
* scope among what promotion must consider, because an evaluator calibrated on one host says
|
|
66
71
|
* nothing about another. Dimensions are caller-supplied (§4.2), consistent with WP-09's packs.
|
|
67
72
|
*/
|
|
68
|
-
export
|
|
69
|
-
/** Identifier of the calibration report that justified promotion (WP-10). */
|
|
70
|
-
reportRef: string;
|
|
71
|
-
/** Scope the calibration covers, e.g. `{ host: "example-host", voice: "voice-a" }`. */
|
|
72
|
-
scope: Record<string, string>;
|
|
73
|
-
/** Known blind spots recorded at promotion time (§12.1, §9.3). */
|
|
74
|
-
knownBlindSpots?: string[];
|
|
75
|
-
}
|
|
73
|
+
export type { PromotionEvidence };
|
|
76
74
|
|
|
77
75
|
/**
|
|
78
76
|
* One configured gate.
|
|
@@ -119,6 +117,16 @@ export interface GateDefinition {
|
|
|
119
117
|
* Defaults to human-only for `human_oracle`, and to any actor otherwise. §13.3 keeps final
|
|
120
118
|
* performance approval human-owned "until a scoped evaluator is demonstrably reliable", and
|
|
121
119
|
* §12 forbids presenting a machine pass as semantic correctness.
|
|
120
|
+
*
|
|
121
|
+
* **A `human_oracle` gate permits exactly `["human"]`, and nothing else is accepted** (#207).
|
|
122
|
+
* Until `next.56` this field only had to *include* `human` at that level, so
|
|
123
|
+
* `["human", "agent"]` resolved — a per-definition, all-runs, all-agents widening with no record
|
|
124
|
+
* of who delegated what. The arm could never be the right answer: if the judgement needs a
|
|
125
|
+
* human, permitting an agent contradicts the declared level; if it does not, the gate is at the
|
|
126
|
+
* wrong level and widening hides that. Either move the gate to the level its judgement warrants
|
|
127
|
+
* — an objectively testable check on a signed artifact is a `hard_gate` binding that artifact —
|
|
128
|
+
* or record that it was bypassed with `aldus waive --reason`. Both leave a decision with a
|
|
129
|
+
* reason attached; widening left neither.
|
|
122
130
|
*/
|
|
123
131
|
permittedActorKinds?: readonly ActorKind[];
|
|
124
132
|
/**
|
|
@@ -196,15 +204,15 @@ export function validateGateDefinition(definition: GateDefinition): ResolvedGate
|
|
|
196
204
|
fail("A gate cannot depend on itself.");
|
|
197
205
|
}
|
|
198
206
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
207
|
+
// The shared rule, not a second copy of it (#115). A gate and an evaluator Stage making the same
|
|
208
|
+
// claim are refused for the same reasons in the same words; two implementations of one clause
|
|
209
|
+
// is how §12 came to be enforced in one place and not the other.
|
|
210
|
+
for (const problem of validateQualityClaim({
|
|
211
|
+
level: definition.level,
|
|
212
|
+
enforcement: definition.enforcement,
|
|
213
|
+
promotionEvidence: definition.promotionEvidence,
|
|
214
|
+
})) {
|
|
215
|
+
fail(problem.message, { level: definition.level, enforcement: definition.enforcement });
|
|
208
216
|
}
|
|
209
217
|
|
|
210
218
|
const permittedActorKinds =
|
|
@@ -212,10 +220,23 @@ export function validateGateDefinition(definition: GateDefinition): ResolvedGate
|
|
|
212
220
|
if (permittedActorKinds.length === 0) {
|
|
213
221
|
fail("A gate that permits no actor kind can never be decided.");
|
|
214
222
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
223
|
+
// Exactly `["human"]`, not "includes human" (#207). The includes rule accepted
|
|
224
|
+
// `["human", "agent"]` on a human-oracle gate, which widens the one gate §12 level 4 gives to a
|
|
225
|
+
// person to every agent on every run, with no record of who delegated what. If the judgement
|
|
226
|
+
// needs a human, widening contradicts the level; if it does not, the gate is at the wrong level
|
|
227
|
+
// and widening hides that. Either way the honest moves exist and this arm is not one of them.
|
|
228
|
+
if (
|
|
229
|
+
definition.level === "human_oracle" &&
|
|
230
|
+
(permittedActorKinds.length !== 1 || permittedActorKinds[0] !== "human")
|
|
231
|
+
) {
|
|
232
|
+
fail(
|
|
233
|
+
"A human-oracle gate permits exactly [human] (contract §12 level 4, §13.3). A judgement " +
|
|
234
|
+
"that needs a person cannot also be decided by an agent, and one that does not belongs at " +
|
|
235
|
+
"a lower level: move the gate to the level its judgement warrants — an objectively " +
|
|
236
|
+
"testable check on a signed artifact is a hard_gate binding that artifact — or record " +
|
|
237
|
+
"that it was bypassed with `aldus waive --reason`.",
|
|
238
|
+
{ permittedActorKinds: [...permittedActorKinds] },
|
|
239
|
+
);
|
|
219
240
|
}
|
|
220
241
|
|
|
221
242
|
return {
|
package/src/engine.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { GateEngineErrorCodes, gateEngineError } from "./errors.js";
|
|
|
35
35
|
import type { CostReader, GateDecisionStore, GateEventSink } from "./ports.js";
|
|
36
36
|
import {
|
|
37
37
|
checkSpend,
|
|
38
|
-
|
|
38
|
+
grantTermsDigest,
|
|
39
39
|
type SpendCheck,
|
|
40
40
|
type SpendGrant,
|
|
41
41
|
type SpendRequest,
|
|
@@ -107,13 +107,19 @@ export interface GateStatus {
|
|
|
107
107
|
/** Operator-facing explanation of why work may not proceed past this gate. */
|
|
108
108
|
explanation?: string;
|
|
109
109
|
/**
|
|
110
|
-
* Whether this
|
|
110
|
+
* Whether this gate is stopping work **right now** — `enforcement` is `blocking` and `state` is
|
|
111
|
+
* neither `satisfied` nor `waived`.
|
|
111
112
|
*
|
|
112
113
|
* An advisory gate is never blocking whatever its state — §12 level 2 "reports a possible issue
|
|
113
114
|
* without blocking" — which is why enforcement and state are separate fields rather than one
|
|
114
115
|
* conflated verdict.
|
|
116
|
+
*
|
|
117
|
+
* Named for the fact it holds, not the class it is derived from (#204). As `blocking` it was read
|
|
118
|
+
* as the declared enforcement and printed as `(advisory)` for every satisfied blocking gate — a
|
|
119
|
+
* word that was false in every instance for the first adopter, whose gates are all blocking. The
|
|
120
|
+
* value was right; the name invited a reader to answer the neighbouring question with it.
|
|
115
121
|
*/
|
|
116
|
-
|
|
122
|
+
currentlyBlocking: boolean;
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
/** Current digests of what each gate binds, keyed by gate. */
|
|
@@ -138,6 +144,15 @@ export interface DecideInput {
|
|
|
138
144
|
comment?: string;
|
|
139
145
|
/** Canonical Episode identity, for the emitted event (§6.4). */
|
|
140
146
|
episodeId: string;
|
|
147
|
+
/**
|
|
148
|
+
* Present when the decider did not write this record themselves (§19.2).
|
|
149
|
+
*
|
|
150
|
+
* Supplied by the composition, which knows the acting actor. The engine's job is to refuse a
|
|
151
|
+
* transcription that names the decider as its own transcriber — that is not a transcription,
|
|
152
|
+
* it is the ordinary case wearing an extra field, and letting it through would make the field
|
|
153
|
+
* mean nothing wherever it appeared.
|
|
154
|
+
*/
|
|
155
|
+
transcription?: GateDecision["transcription"];
|
|
141
156
|
/** Overrides the gate's default. Defaults to the definition's `expiresOnChange`. */
|
|
142
157
|
expiresOnChange?: boolean;
|
|
143
158
|
/** Supplied for deterministic tests; defaults to a fresh ULID-based id. */
|
|
@@ -238,6 +253,58 @@ export class GateEngine {
|
|
|
238
253
|
);
|
|
239
254
|
}
|
|
240
255
|
|
|
256
|
+
// A transcription names someone **other** than the decider. Recording the decider as their own
|
|
257
|
+
// transcriber says nothing and would make the field unreadable wherever it did appear: a reader
|
|
258
|
+
// could no longer tell a transcribed decision from one that carried the field by habit.
|
|
259
|
+
if (
|
|
260
|
+
input.transcription !== undefined &&
|
|
261
|
+
input.transcription.recordedBy.kind === input.decidedBy.kind &&
|
|
262
|
+
input.transcription.recordedBy.id === input.decidedBy.id
|
|
263
|
+
) {
|
|
264
|
+
throw gateEngineError(
|
|
265
|
+
GateEngineErrorCodes.GATE_TRANSCRIPTION_INVALID,
|
|
266
|
+
`The decision for gate "${gate.gateId}" records the decider as its own transcriber. A ` +
|
|
267
|
+
"transcription exists to say that someone else wrote the record down; naming the same " +
|
|
268
|
+
"actor for both says nothing and makes the field unreadable where it is real (§19.2).",
|
|
269
|
+
{ category: "validation", details: { gateId: gate.gateId } },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// A waiver is not an approval, and the two refusals below are what keep it from becoming one.
|
|
274
|
+
//
|
|
275
|
+
// **It must not outlive the content it was granted against.** `expiresOnChange` is a per-
|
|
276
|
+
// decision override of the gate's default, which is defensible for an *approval* whose subject
|
|
277
|
+
// cannot drift. A non-expiring **waiver** says the check stays bypassed whatever the content
|
|
278
|
+
// becomes — that is a config flag disabling a gate, reached through the decision API instead of
|
|
279
|
+
// the config file, and it is precisely what this design exists to avoid.
|
|
280
|
+
//
|
|
281
|
+
// Closing it is also what makes the rest safe. Every gate being waivable — `release.public`
|
|
282
|
+
// included — is defensible **only** because a waiver cannot survive the subjects moving. Leave
|
|
283
|
+
// the override open and every gate needs a non-waivable declaration; close it and none does.
|
|
284
|
+
//
|
|
285
|
+
// **And it must say why.** A waiver with no reason is a blank with a timestamp: the one thing a
|
|
286
|
+
// reader of the approvals log needs from it is the part that would be missing.
|
|
287
|
+
if (input.decision === "waived") {
|
|
288
|
+
if (input.expiresOnChange === false) {
|
|
289
|
+
throw gateEngineError(
|
|
290
|
+
GateEngineErrorCodes.GATE_WAIVER_INVALID,
|
|
291
|
+
`A waiver of gate "${gate.gateId}" may not be recorded as non-expiring. A waiver says ` +
|
|
292
|
+
"the check was bypassed rather than passed, so it must not outlive the content it was " +
|
|
293
|
+
"granted against (§13.1, §13.2).",
|
|
294
|
+
{ category: "policy", details: { gateId: gate.gateId } },
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (input.comment === undefined || input.comment.trim() === "") {
|
|
298
|
+
throw gateEngineError(
|
|
299
|
+
GateEngineErrorCodes.GATE_WAIVER_INVALID,
|
|
300
|
+
`A waiver of gate "${gate.gateId}" needs a reason. An approval records that the content ` +
|
|
301
|
+
"was judged; a waiver records that the check was bypassed, and without a reason the " +
|
|
302
|
+
"log carries a blank with a timestamp (§13.3, §19.2).",
|
|
303
|
+
{ category: "validation", details: { gateId: gate.gateId } },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
241
308
|
const decision: GateDecision = {
|
|
242
309
|
schemaVersion: SCHEMA_VERSION,
|
|
243
310
|
decisionId: input.decisionId ?? newGateDecisionId(),
|
|
@@ -248,7 +315,10 @@ export class GateEngine {
|
|
|
248
315
|
decidedBy: input.decidedBy,
|
|
249
316
|
decidedAt: input.decidedAt,
|
|
250
317
|
...(input.comment !== undefined ? { comment: input.comment } : {}),
|
|
251
|
-
|
|
318
|
+
...(input.transcription === undefined ? {} : { transcription: input.transcription }),
|
|
319
|
+
// Forced for a waiver, never taken from the gate default or the caller. See above.
|
|
320
|
+
expiresOnChange:
|
|
321
|
+
input.decision === "waived" ? true : (input.expiresOnChange ?? gate.expiresOnChange),
|
|
252
322
|
};
|
|
253
323
|
|
|
254
324
|
// Validate before persisting. A malformed decision written to the approvals log is worse
|
|
@@ -339,7 +409,7 @@ export class GateEngine {
|
|
|
339
409
|
return {
|
|
340
410
|
...base,
|
|
341
411
|
state: "pending",
|
|
342
|
-
|
|
412
|
+
currentlyBlocking: blocks("pending"),
|
|
343
413
|
// Naming the missing values rather than only the absence of a decision: an operator told
|
|
344
414
|
// "no recorded decision" goes looking for who forgot to approve, when the answer is that
|
|
345
415
|
// nothing has produced what the approval would bind (§13.2).
|
|
@@ -358,7 +428,7 @@ export class GateEngine {
|
|
|
358
428
|
...base,
|
|
359
429
|
state: latest.decision,
|
|
360
430
|
decision: latest,
|
|
361
|
-
|
|
431
|
+
currentlyBlocking: blocks(latest.decision),
|
|
362
432
|
explanation:
|
|
363
433
|
latest.comment ?? `Gate "${gate.gateId}" was ${latest.decision.replace("_", " ")}.`,
|
|
364
434
|
};
|
|
@@ -375,7 +445,7 @@ export class GateEngine {
|
|
|
375
445
|
state: "stale",
|
|
376
446
|
decision: latest,
|
|
377
447
|
drift,
|
|
378
|
-
|
|
448
|
+
currentlyBlocking: blocks("stale"),
|
|
379
449
|
explanation:
|
|
380
450
|
`Gate "${gate.gateId}" was ${latest.decision}, but ` +
|
|
381
451
|
`${drift.changed.length > 0 ? `[${drift.changed.join(", ")}] changed` : "its bound inputs changed"}` +
|
|
@@ -384,7 +454,7 @@ export class GateEngine {
|
|
|
384
454
|
}
|
|
385
455
|
|
|
386
456
|
const state: GateState = latest.decision === "waived" ? "waived" : "satisfied";
|
|
387
|
-
return { ...base, state, decision: latest,
|
|
457
|
+
return { ...base, state, decision: latest, currentlyBlocking: blocks(state) };
|
|
388
458
|
}
|
|
389
459
|
|
|
390
460
|
/**
|
|
@@ -407,7 +477,7 @@ export class GateEngine {
|
|
|
407
477
|
|
|
408
478
|
const blockedBy = gate.dependsOn.filter((dependency) => {
|
|
409
479
|
const upstream = result.get(dependency);
|
|
410
|
-
return upstream !== undefined && upstream.
|
|
480
|
+
return upstream !== undefined && upstream.currentlyBlocking;
|
|
411
481
|
});
|
|
412
482
|
if (blockedBy.length === 0) continue;
|
|
413
483
|
if (current.blockedBy !== undefined && sameIds(current.blockedBy, blockedBy)) continue;
|
|
@@ -433,7 +503,7 @@ export class GateEngine {
|
|
|
433
503
|
...current,
|
|
434
504
|
state,
|
|
435
505
|
blockedBy,
|
|
436
|
-
|
|
506
|
+
currentlyBlocking: gate.enforcement === "blocking",
|
|
437
507
|
explanation: ownStateIsInformative
|
|
438
508
|
? `${current.explanation ?? `Gate "${gate.gateId}" is ${current.state}.`} Additionally, ${upstreamNote}`
|
|
439
509
|
: `Gate "${gate.gateId}" cannot be relied on because ${upstreamNote}`,
|
|
@@ -551,7 +621,7 @@ export class GateEngine {
|
|
|
551
621
|
};
|
|
552
622
|
}
|
|
553
623
|
|
|
554
|
-
if (!decision.subjectHashes.includes(
|
|
624
|
+
if (!decision.subjectHashes.includes(grantTermsDigest(grant))) {
|
|
555
625
|
return {
|
|
556
626
|
authorized: false,
|
|
557
627
|
statuses: [status],
|
package/src/errors.ts
CHANGED
|
@@ -32,6 +32,21 @@ export const GateEngineErrorCodes = {
|
|
|
32
32
|
GATE_ACTOR_NOT_PERMITTED: "ALDUS_GATE_ACTOR_NOT_PERMITTED",
|
|
33
33
|
/** A decision was submitted that does not bind the subjects its gate requires. */
|
|
34
34
|
GATE_SUBJECTS_INCOMPLETE: "ALDUS_GATE_SUBJECTS_INCOMPLETE",
|
|
35
|
+
/**
|
|
36
|
+
* A waiver was recorded without a reason, or asked not to expire when its subjects change.
|
|
37
|
+
*
|
|
38
|
+
* Both refusals exist because a waiver is not an approval. An approval says the content was
|
|
39
|
+
* judged and passed; a waiver says the check was **bypassed** — so it must say why, and it must
|
|
40
|
+
* not outlive the content it was granted against.
|
|
41
|
+
*/
|
|
42
|
+
GATE_WAIVER_INVALID: "ALDUS_GATE_WAIVER_INVALID",
|
|
43
|
+
/**
|
|
44
|
+
* A decision named the decider as its own transcriber.
|
|
45
|
+
*
|
|
46
|
+
* `transcription` exists to record that someone **else** wrote the decision down. Naming the
|
|
47
|
+
* same actor for both says nothing, and it would make the field unreadable where it is real.
|
|
48
|
+
*/
|
|
49
|
+
GATE_TRANSCRIPTION_INVALID: "ALDUS_GATE_TRANSCRIPTION_INVALID",
|
|
35
50
|
/**
|
|
36
51
|
* An operation requiring authorization was attempted without a valid one.
|
|
37
52
|
*
|
package/src/index.ts
CHANGED
|
@@ -66,11 +66,16 @@ export {
|
|
|
66
66
|
|
|
67
67
|
export {
|
|
68
68
|
SPEND_LIMIT_SUBJECT_KEY,
|
|
69
|
+
availableAuthorization,
|
|
70
|
+
checkSpendScope,
|
|
71
|
+
unestimatedPolicyIsSatisfiable,
|
|
69
72
|
checkSpend,
|
|
70
73
|
computeLedger,
|
|
71
74
|
consumesBudget,
|
|
72
75
|
costRecordDraw,
|
|
73
|
-
|
|
76
|
+
grantTermsDigest,
|
|
77
|
+
type SpendAvailability,
|
|
78
|
+
type SpendScopeRefusal,
|
|
74
79
|
type SpendCheck,
|
|
75
80
|
type SpendGrant,
|
|
76
81
|
type SpendLedger,
|