@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/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
- export const GATE_LEVELS = [
30
- /** Blocks on an objectively testable failure (§12 level 1). */
31
- "hard_gate",
32
- /** Reports a possible issue without blocking (§12 level 2). */
33
- "advisory_signal",
34
- /** Evaluates meaning, stance, style, or claims under uncertainty (§12 level 3). */
35
- "model_assisted",
36
- /** A human owns the judgement, because it is subjective or asymmetric-risk (§12 level 4). */
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 = (typeof GATE_LEVELS)[number];
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
- * Deliberately a two-state enumeration rather than a boolean. Contract §12.1 permits an
47
- * evaluator to *become* blocking only after calibration, which makes this a promotion with
48
- * evidence behind it — and a field named `blocking: boolean` invites someone to flip it in a
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 = ["blocking", "advisory"] as const;
56
+ export const GATE_ENFORCEMENTS = QUALITY_ENFORCEMENTS;
52
57
 
53
58
  /** @see GATE_ENFORCEMENTS */
54
- export type GateEnforcement = (typeof GATE_ENFORCEMENTS)[number];
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 interface PromotionEvidence {
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
- if (definition.level === "model_assisted" && definition.enforcement === "blocking") {
200
- if (definition.promotionEvidence === undefined) {
201
- fail(
202
- "A model-assisted gate may only block once it has been calibrated against human-labeled " +
203
- "examples (contract §12.1). Set `promotionEvidence`, or leave the gate advisory. " +
204
- "Contract §12 forbids presenting a machine pass as semantic correctness.",
205
- { level: definition.level, enforcement: definition.enforcement },
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
- if (definition.level === "human_oracle" && !permittedActorKinds.includes("human")) {
216
- fail("A human-oracle gate must permit a human actor (contract §12 level 4, §13.3).", {
217
- permittedActorKinds: [...permittedActorKinds],
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
- grantLimitsDigest,
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 state stops work.
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
- blocking: boolean;
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
- expiresOnChange: input.expiresOnChange ?? gate.expiresOnChange,
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
- blocking: blocks("pending"),
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
- blocking: blocks(latest.decision),
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
- blocking: blocks("stale"),
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, blocking: blocks(state) };
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.blocking;
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
- blocking: gate.enforcement === "blocking",
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(grantLimitsDigest(grant))) {
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
- grantLimitsDigest,
76
+ grantTermsDigest,
77
+ type SpendAvailability,
78
+ type SpendScopeRefusal,
74
79
  type SpendCheck,
75
80
  type SpendGrant,
76
81
  type SpendLedger,