@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/binding.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding a decision to exact inputs (architecture contract §13).
|
|
3
|
+
*
|
|
4
|
+
* Contract §3.6: a decision must be "tied to exact inputs". §13.1 and §13.2 turn that into a
|
|
5
|
+
* requirement — a Content Freeze is void once the content changes, and a TTS authorization "MUST
|
|
6
|
+
* be invalidated if any bound value changes". `GateDecision.subjectHashes` is the mechanism.
|
|
7
|
+
*
|
|
8
|
+
* Two design choices here are load-bearing.
|
|
9
|
+
*
|
|
10
|
+
* **The stored hashes are the raw content digests, not digests of `key + value`.** Hashing the
|
|
11
|
+
* pair would bind the key association more tightly, but it would also make a `subjectHash`
|
|
12
|
+
* unmatchable against an `ArtifactRef.sha256`, and §20 requires production trace to answer which
|
|
13
|
+
* inputs produced a result. Interoperability with the artifact record is worth more than the
|
|
14
|
+
* marginal strength, because the key association is recovered below anyway.
|
|
15
|
+
*
|
|
16
|
+
* **Comparison is over a sorted multiset, and there is no sidecar record of key-to-hash.** A
|
|
17
|
+
* separate record mapping keys to hashes could drift from the decision it describes, and a
|
|
18
|
+
* safety check that depends on two records agreeing fails open the day they disagree. Instead the
|
|
19
|
+
* check is a direct comparison against `subjectHashes`, and the key-level explanation is
|
|
20
|
+
* *derived* by diffing current subjects against that same list. If the explanation is imperfect
|
|
21
|
+
* the check is still exact — the ordering that matters.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
|
|
26
|
+
import type { GateDecision } from "@aldus-runtime/core";
|
|
27
|
+
|
|
28
|
+
import type { ResolvedGateDefinition } from "./definition.js";
|
|
29
|
+
import { GateEngineErrorCodes, gateEngineError } from "./errors.js";
|
|
30
|
+
|
|
31
|
+
/** One named input a gate binds, and the digest of its current value. */
|
|
32
|
+
export interface GateSubject {
|
|
33
|
+
/**
|
|
34
|
+
* What this subject is, e.g. a spoken-text hash or a request plan (contract §13.2).
|
|
35
|
+
*
|
|
36
|
+
* An OPEN string. What a gate binds is adopter process (§4.3), so Core names no subject keys.
|
|
37
|
+
*/
|
|
38
|
+
key: string;
|
|
39
|
+
/** Lowercase hex SHA-256 of the subject's current value. */
|
|
40
|
+
sha256: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Matches Core's `sha256Hex`: lowercase only, so digests compare by equality. */
|
|
44
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Digest an arbitrary value for use as a subject.
|
|
48
|
+
*
|
|
49
|
+
* Serialisation is canonical — object keys sorted at every depth — so that two structurally
|
|
50
|
+
* identical settings objects produce the same digest regardless of how they were built. Without
|
|
51
|
+
* that, re-serialising an unchanged voice-settings object in a different key order would read as
|
|
52
|
+
* a changed bound value and void a valid authorization (§13.2).
|
|
53
|
+
*/
|
|
54
|
+
export function digestSubjectValue(value: unknown): string {
|
|
55
|
+
return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Digest raw bytes or text for use as a subject. */
|
|
59
|
+
export function digestBytes(value: string | Uint8Array): string {
|
|
60
|
+
return createHash("sha256").update(value).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** JSON with object keys sorted at every depth. */
|
|
64
|
+
function canonicalJson(value: unknown): string {
|
|
65
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
66
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
67
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
68
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
69
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
70
|
+
.map(([key, entryValue]) => `${JSON.stringify(key)}:${canonicalJson(entryValue)}`);
|
|
71
|
+
return `{${entries.join(",")}}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check that the supplied subjects cover exactly what the gate binds, and are well formed.
|
|
76
|
+
*
|
|
77
|
+
* A missing subject is refused rather than treated as unchanged. §13.2 requires the operator to
|
|
78
|
+
* approve every listed value, so an authorization that silently omitted one would bind less than
|
|
79
|
+
* the contract requires while still reading as valid.
|
|
80
|
+
*
|
|
81
|
+
* @throws {AldusError} `ALDUS_GATE_SUBJECTS_INCOMPLETE`
|
|
82
|
+
*/
|
|
83
|
+
export function assertSubjectsCover(
|
|
84
|
+
gate: ResolvedGateDefinition,
|
|
85
|
+
subjects: readonly GateSubject[],
|
|
86
|
+
): void {
|
|
87
|
+
const supplied = new Map<string, string>();
|
|
88
|
+
for (const subject of subjects) {
|
|
89
|
+
if (!SHA256_PATTERN.test(subject.sha256)) {
|
|
90
|
+
throw gateEngineError(
|
|
91
|
+
GateEngineErrorCodes.GATE_SUBJECTS_INCOMPLETE,
|
|
92
|
+
`Subject "${subject.key}" for gate "${gate.gateId}" is not a lowercase hex SHA-256. ` +
|
|
93
|
+
"Mixed-case or truncated digests would compare unequal to an identical value.",
|
|
94
|
+
{ category: "validation", details: { gateId: gate.gateId, key: subject.key } },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (supplied.has(subject.key)) {
|
|
98
|
+
throw gateEngineError(
|
|
99
|
+
GateEngineErrorCodes.GATE_SUBJECTS_INCOMPLETE,
|
|
100
|
+
`Subject "${subject.key}" was supplied twice for gate "${gate.gateId}".`,
|
|
101
|
+
{ category: "validation", details: { gateId: gate.gateId, key: subject.key } },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
supplied.set(subject.key, subject.sha256);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const missing = gate.binds.filter((key) => !supplied.has(key));
|
|
108
|
+
const unexpected = [...supplied.keys()].filter((key) => !gate.binds.includes(key));
|
|
109
|
+
|
|
110
|
+
if (missing.length > 0 || unexpected.length > 0) {
|
|
111
|
+
throw gateEngineError(
|
|
112
|
+
GateEngineErrorCodes.GATE_SUBJECTS_INCOMPLETE,
|
|
113
|
+
`Gate "${gate.gateId}" binds [${gate.binds.join(", ")}], but was given ` +
|
|
114
|
+
`[${[...supplied.keys()].join(", ")}]. Contract §13.2 requires an authorization to bind ` +
|
|
115
|
+
"every listed value; binding a different set is not the same approval.",
|
|
116
|
+
{
|
|
117
|
+
category: "validation",
|
|
118
|
+
details: { gateId: gate.gateId, missing, unexpected, binds: [...gate.binds] },
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The canonical `subjectHashes` for a set of subjects.
|
|
126
|
+
*
|
|
127
|
+
* Sorted so that the order subjects were supplied in cannot change the stored value, and kept as
|
|
128
|
+
* a list rather than a set so two subjects that happen to share a value stay two subjects.
|
|
129
|
+
*/
|
|
130
|
+
export function toSubjectHashes(subjects: readonly GateSubject[]): string[] {
|
|
131
|
+
return subjects.map((subject) => subject.sha256).sort();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Why a decision no longer binds the current inputs. */
|
|
135
|
+
export interface SubjectDrift {
|
|
136
|
+
/** Subject keys whose current digest is not among the approved hashes. */
|
|
137
|
+
changed: string[];
|
|
138
|
+
/** Approved hashes no longer matched by any current subject. */
|
|
139
|
+
orphanedHashes: string[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compare current subjects against what a decision approved.
|
|
144
|
+
*
|
|
145
|
+
* Returns `undefined` when they match exactly. Otherwise names the keys whose values moved, which
|
|
146
|
+
* is what an operator needs in order to know what to re-approve.
|
|
147
|
+
*
|
|
148
|
+
* The key attribution is best-effort by design: it is derived from the hashes rather than stored
|
|
149
|
+
* alongside them, so an exotic case — two subjects sharing one value, one of which changed — may
|
|
150
|
+
* name both. The *detection* is exact regardless, and detection is what §13.2 requires.
|
|
151
|
+
*/
|
|
152
|
+
export function detectDrift(
|
|
153
|
+
decision: GateDecision,
|
|
154
|
+
subjects: readonly GateSubject[],
|
|
155
|
+
): SubjectDrift | undefined {
|
|
156
|
+
const approved = [...decision.subjectHashes].sort();
|
|
157
|
+
const current = toSubjectHashes(subjects);
|
|
158
|
+
|
|
159
|
+
if (approved.length === current.length && approved.every((hash, i) => hash === current[i])) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const approvedCounts = new Map<string, number>();
|
|
164
|
+
for (const hash of approved) approvedCounts.set(hash, (approvedCounts.get(hash) ?? 0) + 1);
|
|
165
|
+
|
|
166
|
+
const changed: string[] = [];
|
|
167
|
+
for (const subject of subjects) {
|
|
168
|
+
const remaining = approvedCounts.get(subject.sha256) ?? 0;
|
|
169
|
+
if (remaining > 0) approvedCounts.set(subject.sha256, remaining - 1);
|
|
170
|
+
else changed.push(subject.key);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const orphanedHashes: string[] = [];
|
|
174
|
+
for (const [hash, count] of approvedCounts) {
|
|
175
|
+
for (let i = 0; i < count; i += 1) orphanedHashes.push(hash);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { changed, orphanedHashes: orphanedHashes.sort() };
|
|
179
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate definitions (architecture contract §12, §13).
|
|
3
|
+
*
|
|
4
|
+
* A definition is **configuration**: what a gate binds to, what it depends on, how strongly it
|
|
5
|
+
* blocks, and who may decide it. A {@link GateDecision} is a **record**: what someone decided,
|
|
6
|
+
* when, and against which exact inputs. Keeping them apart is what lets a decision stay a
|
|
7
|
+
* faithful historical fact while the configuration around it evolves.
|
|
8
|
+
*
|
|
9
|
+
* Contract §13 names four gates — Content Freeze (§13.1), Performance Freeze (§13.2), Human Ear
|
|
10
|
+
* (§13.3), Final Release (§13.4). None of them is hardcoded here. §4.2 keeps show-specific
|
|
11
|
+
* process out of Core's reach and §4.3 gives adopters their own gates, so `gateId` and the
|
|
12
|
+
* subject keys are open strings and the contract's four gates are simply the definitions an
|
|
13
|
+
* adopter is most likely to write. The tests construct them to show the model expresses §13, not
|
|
14
|
+
* because the engine knows their names.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ActorKind } from "@aldus-runtime/core";
|
|
18
|
+
|
|
19
|
+
import { GateEngineErrorCodes, gateEngineError } from "./errors.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The four quality levels of contract §12.
|
|
23
|
+
*
|
|
24
|
+
* These describe *what kind of judgement* a gate represents, which is independent of how
|
|
25
|
+
* strongly it blocks. §12's own table pairs them freely: a hard gate blocks, an advisory signal
|
|
26
|
+
* does not, and a model-assisted review may do either depending on whether it has been
|
|
27
|
+
* calibrated (§12.1).
|
|
28
|
+
*/
|
|
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;
|
|
39
|
+
|
|
40
|
+
/** @see GATE_LEVELS */
|
|
41
|
+
export type GateLevel = (typeof GATE_LEVELS)[number];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a gate stops work or merely reports.
|
|
45
|
+
*
|
|
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.
|
|
50
|
+
*/
|
|
51
|
+
export const GATE_ENFORCEMENTS = ["blocking", "advisory"] as const;
|
|
52
|
+
|
|
53
|
+
/** @see GATE_ENFORCEMENTS */
|
|
54
|
+
export type GateEnforcement = (typeof GATE_ENFORCEMENTS)[number];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Evidence that a model-assisted evaluator was calibrated before it was allowed to block.
|
|
58
|
+
*
|
|
59
|
+
* Contract §12.1: "An evaluator MAY become blocking only after it is calibrated against
|
|
60
|
+
* human-labeled examples." The metrics themselves belong to WP-10; this is the reference a
|
|
61
|
+
* definition must carry to claim they exist, and {@link validateGateDefinition} refuses a
|
|
62
|
+
* blocking model-assisted gate without one.
|
|
63
|
+
*
|
|
64
|
+
* `scope` matters as much as the numbers. §12.1 lists show, host, voice, model, and script-form
|
|
65
|
+
* scope among what promotion must consider, because an evaluator calibrated on one host says
|
|
66
|
+
* nothing about another. Dimensions are caller-supplied (§4.2), consistent with WP-09's packs.
|
|
67
|
+
*/
|
|
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
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One configured gate.
|
|
79
|
+
*
|
|
80
|
+
* @see GateDecision for the record a decision on this gate produces.
|
|
81
|
+
*/
|
|
82
|
+
export interface GateDefinition {
|
|
83
|
+
/**
|
|
84
|
+
* Identity of the gate.
|
|
85
|
+
*
|
|
86
|
+
* An OPEN string. Contract §13's four gates are examples an adopter configures, not a set Core
|
|
87
|
+
* fixes (§4.2). Do not narrow this to a union.
|
|
88
|
+
*/
|
|
89
|
+
gateId: string;
|
|
90
|
+
/** Operator-facing name. */
|
|
91
|
+
title?: string;
|
|
92
|
+
/** What kind of judgement this gate represents (§12). */
|
|
93
|
+
level: GateLevel;
|
|
94
|
+
/** Whether it stops work or merely reports (§12). */
|
|
95
|
+
enforcement: GateEnforcement;
|
|
96
|
+
/**
|
|
97
|
+
* Named subjects a decision on this gate must bind.
|
|
98
|
+
*
|
|
99
|
+
* For a Performance Freeze these are §13.2's list — spoken-text hash, PerformanceScript hash,
|
|
100
|
+
* voice/model/settings, request plan or segment scope, maximum authorized cost — but the keys
|
|
101
|
+
* are caller-supplied strings, because what a gate binds is adopter process (§4.3).
|
|
102
|
+
*
|
|
103
|
+
* A decision that does not cover every key here is refused: §13.2 requires the operator to
|
|
104
|
+
* approve *all* of the listed values, and an authorization missing one of them binds less than
|
|
105
|
+
* the contract requires.
|
|
106
|
+
*/
|
|
107
|
+
binds: readonly string[];
|
|
108
|
+
/**
|
|
109
|
+
* Gates whose invalidation invalidates this one (contract §13.1).
|
|
110
|
+
*
|
|
111
|
+
* §13.1 requires a content-changing edit to invalidate the Content Freeze "and downstream
|
|
112
|
+
* approvals". This edge is what "downstream" means, stated per gate rather than inferred, so
|
|
113
|
+
* an adopter's own gates participate in the cascade without the engine guessing an order.
|
|
114
|
+
*/
|
|
115
|
+
dependsOn?: readonly string[];
|
|
116
|
+
/**
|
|
117
|
+
* Actor kinds permitted to decide this gate.
|
|
118
|
+
*
|
|
119
|
+
* Defaults to human-only for `human_oracle`, and to any actor otherwise. §13.3 keeps final
|
|
120
|
+
* performance approval human-owned "until a scoped evaluator is demonstrably reliable", and
|
|
121
|
+
* §12 forbids presenting a machine pass as semantic correctness.
|
|
122
|
+
*/
|
|
123
|
+
permittedActorKinds?: readonly ActorKind[];
|
|
124
|
+
/**
|
|
125
|
+
* Default for {@link GateDecision.expiresOnChange} on this gate.
|
|
126
|
+
*
|
|
127
|
+
* Defaults to `true`. §13.1 and §13.2 both require invalidation on change, and a gate that
|
|
128
|
+
* silently defaulted to carrying a stale approval forward would be the failure those sections
|
|
129
|
+
* exist to prevent.
|
|
130
|
+
*/
|
|
131
|
+
expiresOnChange?: boolean;
|
|
132
|
+
/** Calibration evidence, required when a model-assisted gate is blocking (§12.1). */
|
|
133
|
+
promotionEvidence?: PromotionEvidence;
|
|
134
|
+
/**
|
|
135
|
+
* Operations this gate authorizes, if any.
|
|
136
|
+
*
|
|
137
|
+
* Contract §13.4: "Uploading and making public SHOULD be separate operations." Naming the
|
|
138
|
+
* operations a gate grants is how that separation is expressed — an approval on one gate
|
|
139
|
+
* authorizes exactly the operations it names and nothing else, so a single decision cannot
|
|
140
|
+
* quietly cover both upload and publication.
|
|
141
|
+
*/
|
|
142
|
+
grants?: readonly string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A definition with every default resolved. */
|
|
146
|
+
export interface ResolvedGateDefinition extends GateDefinition {
|
|
147
|
+
dependsOn: readonly string[];
|
|
148
|
+
permittedActorKinds: readonly ActorKind[];
|
|
149
|
+
expiresOnChange: boolean;
|
|
150
|
+
grants: readonly string[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Actor kinds a gate accepts when the definition does not say. */
|
|
154
|
+
function defaultPermittedActorKinds(level: GateLevel): readonly ActorKind[] {
|
|
155
|
+
// §12 level 4 is "human oracle — owns subjective judgment or asymmetric-risk decisions", and
|
|
156
|
+
// §13.3 keeps final performance approval human-owned. Defaulting these to any actor would let
|
|
157
|
+
// an agent satisfy the one gate the contract most insists a person owns.
|
|
158
|
+
return level === "human_oracle" ? ["human"] : ["human", "agent", "worker", "system"];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve defaults and refuse an internally inconsistent definition.
|
|
163
|
+
*
|
|
164
|
+
* @throws {AldusError} `ALDUS_GATE_DEFINITION_INVALID`
|
|
165
|
+
*/
|
|
166
|
+
export function validateGateDefinition(definition: GateDefinition): ResolvedGateDefinition {
|
|
167
|
+
const fail = (message: string, details: Record<string, unknown> = {}): never => {
|
|
168
|
+
throw gateEngineError(GateEngineErrorCodes.GATE_DEFINITION_INVALID, message, {
|
|
169
|
+
category: "validation",
|
|
170
|
+
details: { gateId: definition.gateId, ...details },
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (definition.gateId.trim().length === 0) fail("A gate definition needs a non-empty gateId.");
|
|
175
|
+
|
|
176
|
+
if (definition.binds.length === 0) {
|
|
177
|
+
// A gate binding nothing cannot be invalidated by anything, which makes its approval
|
|
178
|
+
// permanent — the precise failure §13.1 and §13.2 exist to prevent.
|
|
179
|
+
fail(
|
|
180
|
+
"A gate must bind at least one subject. A gate that binds nothing can never be " +
|
|
181
|
+
"invalidated by a change, so its approval would outlive the content it approved " +
|
|
182
|
+
"(contract §13.1, §13.2).",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const duplicates = definition.binds.filter(
|
|
187
|
+
(key, index) => definition.binds.indexOf(key) !== index,
|
|
188
|
+
);
|
|
189
|
+
if (duplicates.length > 0) {
|
|
190
|
+
fail(`A gate cannot bind the same subject twice: ${[...new Set(duplicates)].join(", ")}.`, {
|
|
191
|
+
duplicates: [...new Set(duplicates)],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (definition.dependsOn?.includes(definition.gateId) === true) {
|
|
196
|
+
fail("A gate cannot depend on itself.");
|
|
197
|
+
}
|
|
198
|
+
|
|
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
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const permittedActorKinds =
|
|
211
|
+
definition.permittedActorKinds ?? defaultPermittedActorKinds(definition.level);
|
|
212
|
+
if (permittedActorKinds.length === 0) {
|
|
213
|
+
fail("A gate that permits no actor kind can never be decided.");
|
|
214
|
+
}
|
|
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
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
...definition,
|
|
223
|
+
dependsOn: definition.dependsOn ?? [],
|
|
224
|
+
permittedActorKinds,
|
|
225
|
+
expiresOnChange: definition.expiresOnChange ?? true,
|
|
226
|
+
grants: definition.grants ?? [],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A validated set of gates and the dependency graph between them.
|
|
232
|
+
*
|
|
233
|
+
* Built once and reused: cycle detection and unknown-dependency checks run at construction, so a
|
|
234
|
+
* misconfiguration surfaces when the registry is assembled rather than when an operator is
|
|
235
|
+
* waiting on an approval.
|
|
236
|
+
*/
|
|
237
|
+
export class GateRegistry {
|
|
238
|
+
readonly #gates: Map<string, ResolvedGateDefinition>;
|
|
239
|
+
|
|
240
|
+
private constructor(gates: Map<string, ResolvedGateDefinition>) {
|
|
241
|
+
this.#gates = gates;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Validate a set of definitions and the graph they form.
|
|
246
|
+
*
|
|
247
|
+
* @throws {AldusError} `ALDUS_GATE_DEFINITION_INVALID` for an invalid or duplicate definition,
|
|
248
|
+
* or an edge naming a gate that does not exist.
|
|
249
|
+
* @throws {AldusError} `ALDUS_GATE_DEPENDENCY_CYCLE` if the dependency edges form a cycle.
|
|
250
|
+
*/
|
|
251
|
+
static from(definitions: readonly GateDefinition[]): GateRegistry {
|
|
252
|
+
const gates = new Map<string, ResolvedGateDefinition>();
|
|
253
|
+
for (const definition of definitions) {
|
|
254
|
+
const resolved = validateGateDefinition(definition);
|
|
255
|
+
if (gates.has(resolved.gateId)) {
|
|
256
|
+
throw gateEngineError(
|
|
257
|
+
GateEngineErrorCodes.GATE_DEFINITION_INVALID,
|
|
258
|
+
`Gate "${resolved.gateId}" is defined more than once.`,
|
|
259
|
+
{ category: "validation", details: { gateId: resolved.gateId } },
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
gates.set(resolved.gateId, resolved);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const gate of gates.values()) {
|
|
266
|
+
for (const dependency of gate.dependsOn) {
|
|
267
|
+
if (!gates.has(dependency)) {
|
|
268
|
+
throw gateEngineError(
|
|
269
|
+
GateEngineErrorCodes.GATE_DEFINITION_INVALID,
|
|
270
|
+
`Gate "${gate.gateId}" depends on "${dependency}", which is not defined. An edge to ` +
|
|
271
|
+
"a missing gate would silently drop out of the invalidation cascade (§13.1).",
|
|
272
|
+
{ category: "validation", details: { gateId: gate.gateId, dependency } },
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const cycle = findCycle(gates);
|
|
279
|
+
if (cycle !== undefined) {
|
|
280
|
+
throw gateEngineError(
|
|
281
|
+
GateEngineErrorCodes.GATE_DEPENDENCY_CYCLE,
|
|
282
|
+
`Gate dependencies form a cycle: ${cycle.join(" → ")}. Contract §13.1's cascade is only ` +
|
|
283
|
+
'meaningful over an acyclic graph; with a cycle, "what does this invalidate" has no answer.',
|
|
284
|
+
{ category: "validation", details: { cycle } },
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return new GateRegistry(gates);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Every gate, in definition order. */
|
|
292
|
+
list(): ResolvedGateDefinition[] {
|
|
293
|
+
return [...this.#gates.values()];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** True if the gate is registered. */
|
|
297
|
+
has(gateId: string): boolean {
|
|
298
|
+
return this.#gates.has(gateId);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A gate definition, or `undefined` if it is not registered. */
|
|
302
|
+
get(gateId: string): ResolvedGateDefinition | undefined {
|
|
303
|
+
return this.#gates.get(gateId);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A gate definition.
|
|
308
|
+
*
|
|
309
|
+
* @throws {AldusError} `ALDUS_GATE_NOT_FOUND`
|
|
310
|
+
*/
|
|
311
|
+
require(gateId: string): ResolvedGateDefinition {
|
|
312
|
+
const gate = this.#gates.get(gateId);
|
|
313
|
+
if (gate === undefined) {
|
|
314
|
+
throw gateEngineError(
|
|
315
|
+
GateEngineErrorCodes.GATE_NOT_FOUND,
|
|
316
|
+
`Gate "${gateId}" is not registered.`,
|
|
317
|
+
{ category: "not_found", details: { gateId } },
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return gate;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Gates that directly depend on `gateId`. */
|
|
324
|
+
dependentsOf(gateId: string): ResolvedGateDefinition[] {
|
|
325
|
+
return this.list().filter((gate) => gate.dependsOn.includes(gateId));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Gates that transitively depend on `gateId`, nearest first, excluding `gateId` itself.
|
|
330
|
+
*
|
|
331
|
+
* This is the reach of contract §13.1's cascade: invalidating a Content Freeze invalidates
|
|
332
|
+
* every approval downstream of it.
|
|
333
|
+
*/
|
|
334
|
+
downstreamOf(gateId: string): string[] {
|
|
335
|
+
const seen = new Set<string>();
|
|
336
|
+
const ordered: string[] = [];
|
|
337
|
+
let frontier = [gateId];
|
|
338
|
+
while (frontier.length > 0) {
|
|
339
|
+
const next: string[] = [];
|
|
340
|
+
for (const current of frontier) {
|
|
341
|
+
for (const dependent of this.dependentsOf(current)) {
|
|
342
|
+
if (seen.has(dependent.gateId)) continue;
|
|
343
|
+
seen.add(dependent.gateId);
|
|
344
|
+
ordered.push(dependent.gateId);
|
|
345
|
+
next.push(dependent.gateId);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
frontier = next;
|
|
349
|
+
}
|
|
350
|
+
return ordered;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Depth-first cycle search, returning the offending path if there is one. */
|
|
355
|
+
function findCycle(gates: ReadonlyMap<string, ResolvedGateDefinition>): string[] | undefined {
|
|
356
|
+
const visiting = new Set<string>();
|
|
357
|
+
const done = new Set<string>();
|
|
358
|
+
const path: string[] = [];
|
|
359
|
+
|
|
360
|
+
const walk = (gateId: string): string[] | undefined => {
|
|
361
|
+
if (done.has(gateId)) return undefined;
|
|
362
|
+
if (visiting.has(gateId)) return [...path.slice(path.indexOf(gateId)), gateId];
|
|
363
|
+
visiting.add(gateId);
|
|
364
|
+
path.push(gateId);
|
|
365
|
+
for (const dependency of gates.get(gateId)?.dependsOn ?? []) {
|
|
366
|
+
const found = walk(dependency);
|
|
367
|
+
if (found !== undefined) return found;
|
|
368
|
+
}
|
|
369
|
+
path.pop();
|
|
370
|
+
visiting.delete(gateId);
|
|
371
|
+
done.add(gateId);
|
|
372
|
+
return undefined;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
for (const gateId of gates.keys()) {
|
|
376
|
+
const found = walk(gateId);
|
|
377
|
+
if (found !== undefined) return found;
|
|
378
|
+
}
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|