@aldus-runtime/gate-engine 0.2.0-next.4 → 0.2.0-next.41
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 +16 -24
- package/dist/definition.d.ts.map +1 -1
- package/dist/definition.js +23 -22
- package/dist/definition.js.map +1 -1
- package/dist/engine.d.ts +20 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +60 -4
- 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 +33 -35
- package/src/engine.ts +98 -4
- package/src/errors.ts +15 -0
- package/src/index.ts +6 -1
- package/src/spend.ts +354 -9
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,
|
|
@@ -73,6 +73,13 @@ export const GATE_STATES = [
|
|
|
73
73
|
export type GateState = (typeof GATE_STATES)[number];
|
|
74
74
|
|
|
75
75
|
/** The evaluated state of one gate. */
|
|
76
|
+
/** `"a"`, `"a and b"`, `"a, b and c"` — for a sentence an operator reads, not a log line. */
|
|
77
|
+
function formatKeys(keys: readonly string[]): string {
|
|
78
|
+
const quoted = keys.map((key) => `"${key}"`);
|
|
79
|
+
if (quoted.length <= 1) return quoted[0] ?? "";
|
|
80
|
+
return `${quoted.slice(0, -1).join(", ")} and ${quoted[quoted.length - 1] as string}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
export interface GateStatus {
|
|
77
84
|
gateId: string;
|
|
78
85
|
state: GateState;
|
|
@@ -86,6 +93,17 @@ export interface GateStatus {
|
|
|
86
93
|
drift?: SubjectDrift;
|
|
87
94
|
/** Gates that blocked this one, when `state` is `blocked_upstream`. */
|
|
88
95
|
blockedBy?: string[];
|
|
96
|
+
/**
|
|
97
|
+
* Bound values nobody has supplied yet, when the gate is otherwise decidable (#91).
|
|
98
|
+
*
|
|
99
|
+
* §13.2 requires an authorization to bind every listed value, so a gate whose subjects are
|
|
100
|
+
* only partly supplied cannot be approved — `assertSubjectsCover` refuses it. Reported here so
|
|
101
|
+
* that a caller can say why before offering the decision, rather than recommending a command
|
|
102
|
+
* that will be rejected.
|
|
103
|
+
*
|
|
104
|
+
* Absent when every bound value is present, which is the ordinary case.
|
|
105
|
+
*/
|
|
106
|
+
missingSubjects?: string[];
|
|
89
107
|
/** Operator-facing explanation of why work may not proceed past this gate. */
|
|
90
108
|
explanation?: string;
|
|
91
109
|
/**
|
|
@@ -120,6 +138,15 @@ export interface DecideInput {
|
|
|
120
138
|
comment?: string;
|
|
121
139
|
/** Canonical Episode identity, for the emitted event (§6.4). */
|
|
122
140
|
episodeId: string;
|
|
141
|
+
/**
|
|
142
|
+
* Present when the decider did not write this record themselves (§19.2).
|
|
143
|
+
*
|
|
144
|
+
* Supplied by the composition, which knows the acting actor. The engine's job is to refuse a
|
|
145
|
+
* transcription that names the decider as its own transcriber — that is not a transcription,
|
|
146
|
+
* it is the ordinary case wearing an extra field, and letting it through would make the field
|
|
147
|
+
* mean nothing wherever it appeared.
|
|
148
|
+
*/
|
|
149
|
+
transcription?: GateDecision["transcription"];
|
|
123
150
|
/** Overrides the gate's default. Defaults to the definition's `expiresOnChange`. */
|
|
124
151
|
expiresOnChange?: boolean;
|
|
125
152
|
/** Supplied for deterministic tests; defaults to a fresh ULID-based id. */
|
|
@@ -220,6 +247,58 @@ export class GateEngine {
|
|
|
220
247
|
);
|
|
221
248
|
}
|
|
222
249
|
|
|
250
|
+
// A transcription names someone **other** than the decider. Recording the decider as their own
|
|
251
|
+
// transcriber says nothing and would make the field unreadable wherever it did appear: a reader
|
|
252
|
+
// could no longer tell a transcribed decision from one that carried the field by habit.
|
|
253
|
+
if (
|
|
254
|
+
input.transcription !== undefined &&
|
|
255
|
+
input.transcription.recordedBy.kind === input.decidedBy.kind &&
|
|
256
|
+
input.transcription.recordedBy.id === input.decidedBy.id
|
|
257
|
+
) {
|
|
258
|
+
throw gateEngineError(
|
|
259
|
+
GateEngineErrorCodes.GATE_TRANSCRIPTION_INVALID,
|
|
260
|
+
`The decision for gate "${gate.gateId}" records the decider as its own transcriber. A ` +
|
|
261
|
+
"transcription exists to say that someone else wrote the record down; naming the same " +
|
|
262
|
+
"actor for both says nothing and makes the field unreadable where it is real (§19.2).",
|
|
263
|
+
{ category: "validation", details: { gateId: gate.gateId } },
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A waiver is not an approval, and the two refusals below are what keep it from becoming one.
|
|
268
|
+
//
|
|
269
|
+
// **It must not outlive the content it was granted against.** `expiresOnChange` is a per-
|
|
270
|
+
// decision override of the gate's default, which is defensible for an *approval* whose subject
|
|
271
|
+
// cannot drift. A non-expiring **waiver** says the check stays bypassed whatever the content
|
|
272
|
+
// becomes — that is a config flag disabling a gate, reached through the decision API instead of
|
|
273
|
+
// the config file, and it is precisely what this design exists to avoid.
|
|
274
|
+
//
|
|
275
|
+
// Closing it is also what makes the rest safe. Every gate being waivable — `release.public`
|
|
276
|
+
// included — is defensible **only** because a waiver cannot survive the subjects moving. Leave
|
|
277
|
+
// the override open and every gate needs a non-waivable declaration; close it and none does.
|
|
278
|
+
//
|
|
279
|
+
// **And it must say why.** A waiver with no reason is a blank with a timestamp: the one thing a
|
|
280
|
+
// reader of the approvals log needs from it is the part that would be missing.
|
|
281
|
+
if (input.decision === "waived") {
|
|
282
|
+
if (input.expiresOnChange === false) {
|
|
283
|
+
throw gateEngineError(
|
|
284
|
+
GateEngineErrorCodes.GATE_WAIVER_INVALID,
|
|
285
|
+
`A waiver of gate "${gate.gateId}" may not be recorded as non-expiring. A waiver says ` +
|
|
286
|
+
"the check was bypassed rather than passed, so it must not outlive the content it was " +
|
|
287
|
+
"granted against (§13.1, §13.2).",
|
|
288
|
+
{ category: "policy", details: { gateId: gate.gateId } },
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (input.comment === undefined || input.comment.trim() === "") {
|
|
292
|
+
throw gateEngineError(
|
|
293
|
+
GateEngineErrorCodes.GATE_WAIVER_INVALID,
|
|
294
|
+
`A waiver of gate "${gate.gateId}" needs a reason. An approval records that the content ` +
|
|
295
|
+
"was judged; a waiver records that the check was bypassed, and without a reason the " +
|
|
296
|
+
"log carries a blank with a timestamp (§13.3, §19.2).",
|
|
297
|
+
{ category: "validation", details: { gateId: gate.gateId } },
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
223
302
|
const decision: GateDecision = {
|
|
224
303
|
schemaVersion: SCHEMA_VERSION,
|
|
225
304
|
decisionId: input.decisionId ?? newGateDecisionId(),
|
|
@@ -230,7 +309,10 @@ export class GateEngine {
|
|
|
230
309
|
decidedBy: input.decidedBy,
|
|
231
310
|
decidedAt: input.decidedAt,
|
|
232
311
|
...(input.comment !== undefined ? { comment: input.comment } : {}),
|
|
233
|
-
|
|
312
|
+
...(input.transcription === undefined ? {} : { transcription: input.transcription }),
|
|
313
|
+
// Forced for a waiver, never taken from the gate default or the caller. See above.
|
|
314
|
+
expiresOnChange:
|
|
315
|
+
input.decision === "waived" ? true : (input.expiresOnChange ?? gate.expiresOnChange),
|
|
234
316
|
};
|
|
235
317
|
|
|
236
318
|
// Validate before persisting. A malformed decision written to the approvals log is worse
|
|
@@ -314,12 +396,24 @@ export class GateEngine {
|
|
|
314
396
|
// machines with disagreeing clocks must not be able to reorder which approval is current.
|
|
315
397
|
const latest = [...decisions].reverse().find((entry) => entry.gateId === gate.gateId);
|
|
316
398
|
|
|
399
|
+
const suppliedKeys = new Set(subjects.map((entry) => entry.key));
|
|
400
|
+
const missingSubjects = gate.binds.filter((key) => !suppliedKeys.has(key));
|
|
401
|
+
|
|
317
402
|
if (latest === undefined) {
|
|
318
403
|
return {
|
|
319
404
|
...base,
|
|
320
405
|
state: "pending",
|
|
321
406
|
blocking: blocks("pending"),
|
|
322
|
-
|
|
407
|
+
// Naming the missing values rather than only the absence of a decision: an operator told
|
|
408
|
+
// "no recorded decision" goes looking for who forgot to approve, when the answer is that
|
|
409
|
+
// nothing has produced what the approval would bind (§13.2).
|
|
410
|
+
explanation:
|
|
411
|
+
missingSubjects.length > 0
|
|
412
|
+
? `Gate "${gate.gateId}" has no recorded decision, and ` +
|
|
413
|
+
`${formatKeys(missingSubjects)} ${missingSubjects.length === 1 ? "has" : "have"} ` +
|
|
414
|
+
"not been supplied, so it cannot be decided yet."
|
|
415
|
+
: `Gate "${gate.gateId}" has no recorded decision.`,
|
|
416
|
+
...(missingSubjects.length > 0 ? { missingSubjects } : {}),
|
|
323
417
|
};
|
|
324
418
|
}
|
|
325
419
|
|
|
@@ -521,7 +615,7 @@ export class GateEngine {
|
|
|
521
615
|
};
|
|
522
616
|
}
|
|
523
617
|
|
|
524
|
-
if (!decision.subjectHashes.includes(
|
|
618
|
+
if (!decision.subjectHashes.includes(grantTermsDigest(grant))) {
|
|
525
619
|
return {
|
|
526
620
|
authorized: false,
|
|
527
621
|
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,
|
package/src/spend.ts
CHANGED
|
@@ -11,13 +11,19 @@
|
|
|
11
11
|
* `GateDecision` stores only digests. Carrying the limit in a record beside the decision would
|
|
12
12
|
* let someone raise the ceiling without touching the approval.
|
|
13
13
|
*
|
|
14
|
-
* So a grant does both: it holds the values, and {@link
|
|
14
|
+
* So a grant does both: it holds the values, and {@link grantTermsDigest} is included among the
|
|
15
15
|
* gate's bound subjects. Raising a limit changes that digest, which drifts from `subjectHashes`,
|
|
16
16
|
* which voids the authorization exactly as §13.2 requires. The ceiling cannot move without an
|
|
17
17
|
* operator re-approving it.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import
|
|
20
|
+
import {
|
|
21
|
+
reservationExposureIsBounded,
|
|
22
|
+
reservationIsActive,
|
|
23
|
+
type CostRecord,
|
|
24
|
+
type Money,
|
|
25
|
+
type SpendReservation,
|
|
26
|
+
} from "@aldus-runtime/core";
|
|
21
27
|
|
|
22
28
|
import { digestSubjectValue } from "./binding.js";
|
|
23
29
|
import {
|
|
@@ -25,6 +31,7 @@ import {
|
|
|
25
31
|
compareMoney,
|
|
26
32
|
formatMoney,
|
|
27
33
|
isNegativeMoney,
|
|
34
|
+
isPositiveMoney,
|
|
28
35
|
subtractMoney,
|
|
29
36
|
zeroMoney,
|
|
30
37
|
} from "./money.js";
|
|
@@ -53,20 +60,95 @@ export interface SpendGrant {
|
|
|
53
60
|
* back to the approval that permitted it (§19.3 "explicit spend authorization").
|
|
54
61
|
*/
|
|
55
62
|
decisionId: string;
|
|
56
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* What this grant authorizes spending **on** (§13.2, §4.2).
|
|
65
|
+
*
|
|
66
|
+
* Operation names are adopter-defined open strings — `"agent.execute"`, `"tts.synthesize"`.
|
|
67
|
+
* Core names none.
|
|
68
|
+
*
|
|
69
|
+
* Present so that handing the wrong grant to an execution gateway cannot authorize an unrelated
|
|
70
|
+
* operation. Without it, "a grant" is a pool of money with no statement about what it is for,
|
|
71
|
+
* and the only thing keeping agent spend out of a synthesis ledger is which `decisionId` each
|
|
72
|
+
* happened to carry.
|
|
73
|
+
*/
|
|
74
|
+
scope: {
|
|
75
|
+
operations: readonly string[];
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Whether an execution with no estimate may be dispatched (§13.2, §19.3; ADR-0044).
|
|
79
|
+
*
|
|
80
|
+
* Operator-approved policy, so it lives on the grant and is bound by
|
|
81
|
+
* {@link grantTermsDigest} — changing it invalidates the approval exactly as changing a ceiling
|
|
82
|
+
* or the scope does. It must never be supplied by an execution input or an adapter: a caller
|
|
83
|
+
* that could assert its own permission is the shape #107 exists to prevent.
|
|
84
|
+
*
|
|
85
|
+
* A closed pair rather than a boolean. `permitUnestimated: true` invites being flipped in a
|
|
86
|
+
* config file; naming the reservation it implies makes the consequence part of the decision.
|
|
87
|
+
*
|
|
88
|
+
* Absent reads as `"refuse"`.
|
|
89
|
+
*/
|
|
90
|
+
unestimatedExecution?: "refuse" | "reserve_max_per_request";
|
|
91
|
+
/**
|
|
92
|
+
* Maximum total spend authorized across the Run (§13.2 "maximum authorized cost").
|
|
93
|
+
*
|
|
94
|
+
* **Two different things consume it**, and confusing them mis-sizes a grant in either direction:
|
|
95
|
+
*
|
|
96
|
+
* - **settled charges**, permanently — the actual `CostRecord` amounts;
|
|
97
|
+
* - **active and unresolved reservations**, at their *reserved* amount until they settle.
|
|
98
|
+
*
|
|
99
|
+
* `availableAuthorization` is `maxTotal − settled − active`, and on settlement a reservation
|
|
100
|
+
* stops being counted at what it reserved and starts being counted at what it actually cost —
|
|
101
|
+
* so **unused headroom returns**.
|
|
102
|
+
*
|
|
103
|
+
* That matters under `unestimatedExecution: "reserve_max_per_request"`, where each unestimated
|
|
104
|
+
* dispatch reserves the *whole* per-request ceiling because there is no smaller truthful number.
|
|
105
|
+
* `maxTotal ÷ maxPerRequest` bounds how many such dispatches can be **outstanding at once** — in
|
|
106
|
+
* flight, or with billing still unresolved. It does **not** bound the run: once they settle
|
|
107
|
+
* below the ceiling the difference is available again, and a $25 / $3 grant can authorize far
|
|
108
|
+
* more than eight sequential dispatches when each settles cheaply.
|
|
109
|
+
*
|
|
110
|
+
* Both readings mis-size a grant. Read as a lifetime pool it ignores that eight worst-case
|
|
111
|
+
* reservations can be outstanding before any of them settles; read as a concurrency bound alone
|
|
112
|
+
* it over-provisions a run whose charges are small. Measured by an adopter: a $3 / $3 grant
|
|
113
|
+
* permitted one dispatch and refused a case dispatching twice, with the budget almost untouched
|
|
114
|
+
* — because the first had not settled yet.
|
|
115
|
+
*/
|
|
57
116
|
maxTotal: Money;
|
|
58
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Maximum spend authorized for any single request (§19.3 "per-request ... limits").
|
|
119
|
+
*
|
|
120
|
+
* **What this means changed with ADR-0044 while its type did not.** It was a statement about
|
|
121
|
+
* what a *backend* enforces, so leaving it unset where the backend enforces nothing was the
|
|
122
|
+
* honest choice — binding a ceiling would have recorded a protection that did not exist.
|
|
123
|
+
*
|
|
124
|
+
* It is now what the *runtime reserves* before dispatch. Unset is still valid and still
|
|
125
|
+
* compiles, and under `unestimatedExecution: "reserve_max_per_request"` it makes every
|
|
126
|
+
* unestimated dispatch refuse, because there is no truthful amount to reserve
|
|
127
|
+
* (see {@link unestimatedPolicyIsSatisfiable}).
|
|
128
|
+
*
|
|
129
|
+
* Called out because the type system has nothing to say about a field whose meaning moved: an
|
|
130
|
+
* adopter migrating hit exactly this, and the comment in their own file argued for the choice
|
|
131
|
+
* that is now wrong.
|
|
132
|
+
*/
|
|
59
133
|
maxPerRequest?: Money;
|
|
60
134
|
}
|
|
61
135
|
|
|
62
136
|
/**
|
|
63
|
-
* The digest a gate must bind for the grant's
|
|
137
|
+
* The digest a gate must bind for the grant's **terms** to be tamper-evident (§13.2).
|
|
138
|
+
*
|
|
139
|
+
* Was `grantTermsDigest`, covering the ceilings alone. Scope is now a term too: changing a grant
|
|
140
|
+
* from agent-only to TTS-capable widens what an approval permits exactly as raising its ceiling
|
|
141
|
+
* does, and an approval that survives that change did not bind what it appeared to bind.
|
|
64
142
|
*
|
|
65
|
-
* Only the
|
|
66
|
-
*
|
|
143
|
+
* Only the terms are digested, not the grant's identity: re-issuing an identical grant under a new
|
|
144
|
+
* `grantId` should not read as the operator having approved something different.
|
|
67
145
|
*/
|
|
68
|
-
export function
|
|
146
|
+
export function grantTermsDigest(grant: SpendGrant): string {
|
|
69
147
|
return digestSubjectValue({
|
|
148
|
+
// Sorted, because the *set* of authorized operations is the term, not the order an adopter
|
|
149
|
+
// listed them in — the same reason ADR-0033 sorts release input hashes.
|
|
150
|
+
scope: { operations: [...grant.scope.operations].sort() },
|
|
151
|
+
unestimatedExecution: grant.unestimatedExecution ?? "refuse",
|
|
70
152
|
maxTotal: grant.maxTotal,
|
|
71
153
|
maxPerRequest: grant.maxPerRequest ?? null,
|
|
72
154
|
});
|
|
@@ -109,6 +191,36 @@ export interface SpendLedger {
|
|
|
109
191
|
counted: CostRecord[];
|
|
110
192
|
/** Cost records excluded because they were voided. */
|
|
111
193
|
excluded: CostRecord[];
|
|
194
|
+
/**
|
|
195
|
+
* Charges whose amount nobody knows yet (§19.3; #150).
|
|
196
|
+
*
|
|
197
|
+
* A provider may charge a request and withhold or delay the figure. While one of these stands
|
|
198
|
+
* against a grant, **Aldus cannot prove how much authorization remains** — the charge is real
|
|
199
|
+
* and its size is not yet a fact.
|
|
200
|
+
*
|
|
201
|
+
* A record here is never treated as free, voided, or a zero draw. Zero is a numerical assertion;
|
|
202
|
+
* this is an uncertainty state, and the two are not interchangeable.
|
|
203
|
+
*/
|
|
204
|
+
unresolvedUnknown: CostRecord[];
|
|
205
|
+
/**
|
|
206
|
+
* Whether {@link SpendLedger.remaining} is a number anyone may spend against.
|
|
207
|
+
*
|
|
208
|
+
* `false` while any unresolved unknown charge stands. `remaining` still reports the arithmetic
|
|
209
|
+
* over what is known, because an operator wants the figure — but presenting it as headroom
|
|
210
|
+
* would state a safe amount that nothing establishes.
|
|
211
|
+
*/
|
|
212
|
+
remainingIsDeterminate: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether a record is a charge of unknown size (§19.3; #150).
|
|
217
|
+
*
|
|
218
|
+
* An estimate does not resolve it. An estimate is evidence about what a request was expected to
|
|
219
|
+
* cost, and the ruling on #150 is explicit that it does not confirm the final charge — so a record
|
|
220
|
+
* carrying both an estimate and `billingStatus: "unknown"` is still unresolved.
|
|
221
|
+
*/
|
|
222
|
+
export function isUnresolvedUnknownCharge(record: CostRecord): boolean {
|
|
223
|
+
return record.billingStatus === "unknown";
|
|
112
224
|
}
|
|
113
225
|
|
|
114
226
|
/**
|
|
@@ -136,12 +248,214 @@ export function computeLedger(grant: SpendGrant, costs: readonly CostRecord[]):
|
|
|
136
248
|
|
|
137
249
|
const headroom = subtractMoney(grant.maxTotal, consumed);
|
|
138
250
|
const overspent = isNegativeMoney(headroom);
|
|
251
|
+
const unresolvedUnknown = counted.filter(isUnresolvedUnknownCharge);
|
|
139
252
|
return {
|
|
140
253
|
consumed,
|
|
141
254
|
remaining: overspent ? zeroMoney(currency) : headroom,
|
|
142
255
|
overspent,
|
|
143
256
|
counted,
|
|
144
257
|
excluded,
|
|
258
|
+
unresolvedUnknown,
|
|
259
|
+
remainingIsDeterminate: unresolvedUnknown.length === 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Authorization available to commit, derived rather than maintained (ADR-0044; #155).
|
|
265
|
+
*
|
|
266
|
+
* ```text
|
|
267
|
+
* available = authorized maximum − settled charges − active reservations
|
|
268
|
+
* ```
|
|
269
|
+
*
|
|
270
|
+
* Derived on every read, never stored as a balance. A maintained counter is a second source of
|
|
271
|
+
* truth about money, reconciled by hand against the records it summarises — and every defect this
|
|
272
|
+
* repository has fixed in the cost path has been a value asserting more than what established it.
|
|
273
|
+
*/
|
|
274
|
+
export interface SpendAvailability {
|
|
275
|
+
/** The ceiling the operator approved. */
|
|
276
|
+
authorized: Money;
|
|
277
|
+
/** Charges already recorded against it. */
|
|
278
|
+
settled: Money;
|
|
279
|
+
/** Authorization committed to effects that have not settled. */
|
|
280
|
+
reserved: Money;
|
|
281
|
+
/**
|
|
282
|
+
* What may still be committed. Never negative.
|
|
283
|
+
*
|
|
284
|
+
* **Read {@link SpendAvailability.determinate} before spending against this.** The figure is the
|
|
285
|
+
* arithmetic over what is known, and while an unresolved charge of unknown size stands, what is
|
|
286
|
+
* known is not all there is.
|
|
287
|
+
*/
|
|
288
|
+
available: Money;
|
|
289
|
+
/** Whether {@link SpendAvailability.available} is an amount anyone may commit against. */
|
|
290
|
+
determinate: boolean;
|
|
291
|
+
/**
|
|
292
|
+
* Why it is not, where it is not.
|
|
293
|
+
*
|
|
294
|
+
* Two independent sources, kept apart because they carry different evidence:
|
|
295
|
+
*
|
|
296
|
+
* - reservations in `billing_unknown` whose exposure is not bounded by an enforced ceiling;
|
|
297
|
+
* - cost records of unknown size with no reservation at all, which is every such record written
|
|
298
|
+
* before this protocol existed (#150).
|
|
299
|
+
*/
|
|
300
|
+
indeterminate: {
|
|
301
|
+
unboundedReservations: SpendReservation[];
|
|
302
|
+
unreservedUnknownCharges: CostRecord[];
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* Reserved-but-unsettled amounts, per **authorization** currency (ADR-0044).
|
|
306
|
+
*
|
|
307
|
+
* Not the provider's billing currency. This states what Aldus set aside; what a provider charged,
|
|
308
|
+
* and in what currency, is a separate fact that may not exist yet. A report may say "USD 2.00
|
|
309
|
+
* remains reserved because billing is unresolved"; it must never restate that as "the provider
|
|
310
|
+
* made an unknown USD charge".
|
|
311
|
+
*/
|
|
312
|
+
reservedUnknownByCurrency: Record<string, string>;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Derive what a grant still authorizes (§19.3; ADR-0044).
|
|
317
|
+
*
|
|
318
|
+
* Composes with #150 rather than replacing it. A charge of unknown size still makes the grant
|
|
319
|
+
* indeterminate — what a reservation adds is the possibility of a *bound*, and only when the
|
|
320
|
+
* execution that produced the charge was actually dispatched under an enforced ceiling. A backend
|
|
321
|
+
* that declares enforcement today is not evidence about a request dispatched by an earlier version
|
|
322
|
+
* (ADR-0030).
|
|
323
|
+
*/
|
|
324
|
+
export function availableAuthorization(
|
|
325
|
+
grant: SpendGrant,
|
|
326
|
+
costs: readonly CostRecord[],
|
|
327
|
+
reservations: readonly SpendReservation[] = [],
|
|
328
|
+
): SpendAvailability {
|
|
329
|
+
const currency = grant.maxTotal.currency;
|
|
330
|
+
const ledger = computeLedger(grant, costs);
|
|
331
|
+
|
|
332
|
+
// Per grant, never per decision. The grant is the budget pool; the decision is who authorized
|
|
333
|
+
// its terms. One decision may establish several grants — an episode-level ceiling above an agent
|
|
334
|
+
// grant and a TTS grant — and deriving availability per decision would silently aggregate them.
|
|
335
|
+
const mine = reservations.filter((reservation) => reservation.grantId === grant.grantId);
|
|
336
|
+
const active = mine.filter(reservationIsActive);
|
|
337
|
+
|
|
338
|
+
// Settled reservations are already represented by the cost records that settled them. Counting
|
|
339
|
+
// both would double-count the same money against the ceiling.
|
|
340
|
+
let reserved = zeroMoney(currency);
|
|
341
|
+
const reservedUnknownByCurrency = new Map<string, Money>();
|
|
342
|
+
for (const reservation of active) {
|
|
343
|
+
if (reservation.reserved.currency === currency) {
|
|
344
|
+
reserved = addMoney(reserved, reservation.reserved);
|
|
345
|
+
}
|
|
346
|
+
if (reservation.status === "billing_unknown") {
|
|
347
|
+
const running =
|
|
348
|
+
reservedUnknownByCurrency.get(reservation.reserved.currency) ??
|
|
349
|
+
zeroMoney(reservation.reserved.currency);
|
|
350
|
+
reservedUnknownByCurrency.set(
|
|
351
|
+
reservation.reserved.currency,
|
|
352
|
+
addMoney(running, reservation.reserved),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const unboundedReservations = active.filter(
|
|
358
|
+
(reservation) =>
|
|
359
|
+
reservation.status === "billing_unknown" && !reservationExposureIsBounded(reservation),
|
|
360
|
+
);
|
|
361
|
+
// #150's rule, narrowed by what a reservation can now establish. A record of unknown size whose
|
|
362
|
+
// reservation is bounded is accounted for; one with no reservation is not, and that is every
|
|
363
|
+
// such record written before this protocol.
|
|
364
|
+
const boundedReservationIds = new Set(
|
|
365
|
+
active
|
|
366
|
+
.filter((reservation) => reservationExposureIsBounded(reservation))
|
|
367
|
+
.map((reservation) => reservation.reservationId),
|
|
368
|
+
);
|
|
369
|
+
// `computeLedger` filters cost records by `authorizationId`, which is the legacy link and stays
|
|
370
|
+
// correct: a record written before reservations existed has no `reservationId` to reach a grant
|
|
371
|
+
// through, and its decision is the only thing tying it to an authorization (#155).
|
|
372
|
+
const unreservedUnknownCharges = ledger.counted.filter(
|
|
373
|
+
(record) =>
|
|
374
|
+
isUnresolvedUnknownCharge(record) &&
|
|
375
|
+
(record.reservationId === undefined || !boundedReservationIds.has(record.reservationId)),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const headroom = subtractMoney(subtractMoney(grant.maxTotal, ledger.consumed), reserved);
|
|
379
|
+
return {
|
|
380
|
+
authorized: grant.maxTotal,
|
|
381
|
+
settled: ledger.consumed,
|
|
382
|
+
reserved,
|
|
383
|
+
available: isNegativeMoney(headroom) ? zeroMoney(currency) : headroom,
|
|
384
|
+
determinate: unboundedReservations.length === 0 && unreservedUnknownCharges.length === 0,
|
|
385
|
+
indeterminate: { unboundedReservations, unreservedUnknownCharges },
|
|
386
|
+
reservedUnknownByCurrency: Object.fromEntries(
|
|
387
|
+
[...reservedUnknownByCurrency.entries()]
|
|
388
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
389
|
+
.map(([code, money]) => [code, money.amount]),
|
|
390
|
+
),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Whether a grant can truthfully permit an unestimated execution (ADR-0044; #155).
|
|
396
|
+
*
|
|
397
|
+
* `reserve_max_per_request` promises to reserve the per-request ceiling. A grant that permits it
|
|
398
|
+
* and states no such ceiling promises an amount it does not have — and reserving zero instead
|
|
399
|
+
* would make unestimated executions invisible to concurrency control, which is the case most
|
|
400
|
+
* likely to be dispatched in a loop.
|
|
401
|
+
*
|
|
402
|
+
* Checked at decode **and** before dispatch: a grant assembled from configuration never passes
|
|
403
|
+
* through the constructor that would have caught it.
|
|
404
|
+
*/
|
|
405
|
+
export function unestimatedPolicyIsSatisfiable(grant: SpendGrant): string | undefined {
|
|
406
|
+
if ((grant.unestimatedExecution ?? "refuse") !== "reserve_max_per_request") return undefined;
|
|
407
|
+
const ceiling = grant.maxPerRequest;
|
|
408
|
+
if (ceiling === undefined) {
|
|
409
|
+
return (
|
|
410
|
+
`Grant "${grant.grantId}" permits unestimated execution by reserving its per-request ` +
|
|
411
|
+
"ceiling, and states no ceiling. There is no truthful amount to reserve, so this cannot be " +
|
|
412
|
+
"dispatched (§13.2, §19.3)."
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
if (!isPositiveMoney(ceiling)) {
|
|
416
|
+
return (
|
|
417
|
+
`Grant "${grant.grantId}" permits unestimated execution by reserving its per-request ` +
|
|
418
|
+
`ceiling, and that ceiling is ${formatMoney(ceiling)}. Reserving zero would make an ` +
|
|
419
|
+
"unestimated execution invisible to concurrency control."
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (ceiling.currency !== grant.maxTotal.currency) {
|
|
423
|
+
return (
|
|
424
|
+
`Grant "${grant.grantId}" states a per-request ceiling in ${ceiling.currency} and a total ` +
|
|
425
|
+
`in ${grant.maxTotal.currency}. A reservation is denominated in the grant's currency, and ` +
|
|
426
|
+
"converting implicitly is not something this runtime does (ADR-0044)."
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Why a grant does not authorize an operation. */
|
|
433
|
+
export interface SpendScopeRefusal {
|
|
434
|
+
operation: string;
|
|
435
|
+
authorized: readonly string[];
|
|
436
|
+
explanation: string;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Whether a grant authorizes an operation (§13.2, §4.2; #155).
|
|
441
|
+
*
|
|
442
|
+
* Checked before reserving, so handing the wrong grant to an execution gateway cannot authorize
|
|
443
|
+
* unrelated work. Without it a grant is a pool of money with no statement about what it is for,
|
|
444
|
+
* and the only thing separating agent spend from a synthesis ledger is which decision each
|
|
445
|
+
* happened to name.
|
|
446
|
+
*/
|
|
447
|
+
export function checkSpendScope(
|
|
448
|
+
grant: SpendGrant,
|
|
449
|
+
operation: string,
|
|
450
|
+
): SpendScopeRefusal | undefined {
|
|
451
|
+
if (grant.scope.operations.includes(operation)) return undefined;
|
|
452
|
+
return {
|
|
453
|
+
operation,
|
|
454
|
+
authorized: [...grant.scope.operations],
|
|
455
|
+
explanation:
|
|
456
|
+
`Grant "${grant.grantId}" authorizes ${grant.scope.operations.map((entry) => `"${entry}"`).join(", ") || "nothing"} ` +
|
|
457
|
+
`and this operation is "${operation}". A grant states what it may be spent on as well as ` +
|
|
458
|
+
"how much, so passing the wrong one to a gateway cannot authorize unrelated work (§13.2).",
|
|
145
459
|
};
|
|
146
460
|
}
|
|
147
461
|
|
|
@@ -155,7 +469,19 @@ export interface SpendRequest {
|
|
|
155
469
|
|
|
156
470
|
/** Why a spend was refused. */
|
|
157
471
|
export type SpendRefusalReason =
|
|
158
|
-
"per-request-limit"
|
|
472
|
+
| "per-request-limit"
|
|
473
|
+
| "total-limit"
|
|
474
|
+
| "already-overspent"
|
|
475
|
+
| "negative-amount"
|
|
476
|
+
/**
|
|
477
|
+
* A charge of unknown size stands against this grant (§19.3; #150).
|
|
478
|
+
*
|
|
479
|
+
* Refused rather than allowed-with-a-warning: while the size of a real charge is unknown, the
|
|
480
|
+
* remaining headroom is not a fact, and spending against a figure nobody can establish is how a
|
|
481
|
+
* ceiling is exceeded without any single decision being wrong. Resolution is a reconciled amount
|
|
482
|
+
* or a new authorization under an explicit policy — both human acts.
|
|
483
|
+
*/
|
|
484
|
+
| "billing-unconfirmed";
|
|
159
485
|
|
|
160
486
|
/** The outcome of a stop-on-budget check. */
|
|
161
487
|
export type SpendCheck =
|
|
@@ -197,6 +523,25 @@ export function checkSpend(
|
|
|
197
523
|
};
|
|
198
524
|
}
|
|
199
525
|
|
|
526
|
+
// Before the arithmetic, because the arithmetic is what cannot be trusted. While a charge of
|
|
527
|
+
// unknown size stands against this grant, `remaining` is the total over what is *known* — and
|
|
528
|
+
// spending against it would treat an unresolved charge as a zero draw, which is the one thing
|
|
529
|
+
// the ruling on #150 forbids (§19.3).
|
|
530
|
+
if (!ledger.remainingIsDeterminate) {
|
|
531
|
+
return {
|
|
532
|
+
allowed: false,
|
|
533
|
+
reason: "billing-unconfirmed",
|
|
534
|
+
ledger,
|
|
535
|
+
explanation:
|
|
536
|
+
`${ledger.unresolvedUnknown.length} charge(s) against this authorization have an ` +
|
|
537
|
+
"unconfirmed amount, so the remaining budget is indeterminate rather than " +
|
|
538
|
+
`${formatMoney(ledger.remaining)}. Automatic spend is refused until the amount is ` +
|
|
539
|
+
"reconciled or an operator issues a new authorization: an unknown charge is neither free " +
|
|
540
|
+
"nor zero, and drawing against a figure nobody can establish is how a ceiling is exceeded " +
|
|
541
|
+
"without any single decision being wrong (§19.3).",
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
200
545
|
if (ledger.overspent) {
|
|
201
546
|
return {
|
|
202
547
|
allowed: false,
|