@qmilab/lodestar-core 0.1.5 → 0.2.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/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/action.d.ts +31 -13
- package/dist/schemas/action.d.ts.map +1 -1
- package/dist/schemas/action.js +20 -1
- package/dist/schemas/action.js.map +1 -1
- package/dist/schemas/approval.d.ts +271 -0
- package/dist/schemas/approval.d.ts.map +1 -0
- package/dist/schemas/approval.js +119 -0
- package/dist/schemas/approval.js.map +1 -0
- package/dist/schemas/belief.d.ts.map +1 -1
- package/dist/schemas/belief.js +7 -1
- package/dist/schemas/belief.js.map +1 -1
- package/dist/schemas/calibration.d.ts +977 -0
- package/dist/schemas/calibration.d.ts.map +1 -0
- package/dist/schemas/calibration.js +187 -0
- package/dist/schemas/calibration.js.map +1 -0
- package/dist/schemas/claim.d.ts.map +1 -1
- package/dist/schemas/claim.js +4 -2
- package/dist/schemas/claim.js.map +1 -1
- package/dist/schemas/common.d.ts.map +1 -1
- package/dist/schemas/common.js +11 -5
- package/dist/schemas/common.js.map +1 -1
- package/dist/schemas/policy.d.ts +768 -0
- package/dist/schemas/policy.d.ts.map +1 -0
- package/dist/schemas/policy.js +200 -0
- package/dist/schemas/policy.js.map +1 -0
- package/dist/schemas/probe-pack.d.ts +152 -0
- package/dist/schemas/probe-pack.d.ts.map +1 -0
- package/dist/schemas/probe-pack.js +140 -0
- package/dist/schemas/probe-pack.js.map +1 -0
- package/dist/schemas/reflection.d.ts +405 -0
- package/dist/schemas/reflection.d.ts.map +1 -0
- package/dist/schemas/reflection.js +154 -0
- package/dist/schemas/reflection.js.map +1 -0
- package/dist/schemas/revision.d.ts.map +1 -1
- package/dist/schemas/revision.js.map +1 -1
- package/dist/schemas/sentinel.d.ts +134 -0
- package/dist/schemas/sentinel.d.ts.map +1 -0
- package/dist/schemas/sentinel.js +97 -0
- package/dist/schemas/sentinel.js.map +1 -0
- package/package.json +2 -7
- package/src/index.ts +18 -0
- package/src/schemas/action.ts +20 -1
- package/src/schemas/approval.ts +136 -0
- package/src/schemas/belief.ts +7 -1
- package/src/schemas/calibration.ts +212 -0
- package/src/schemas/claim.ts +15 -8
- package/src/schemas/common.ts +16 -10
- package/src/schemas/policy.ts +231 -0
- package/src/schemas/probe-pack.ts +169 -0
- package/src/schemas/reflection.ts +166 -0
- package/src/schemas/revision.ts +7 -5
- package/src/schemas/sentinel.ts +104 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { TimestampSchema } from "./common.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calibration wire format.
|
|
6
|
+
*
|
|
7
|
+
* These schemas describe what the harness `Calibrator` measures —
|
|
8
|
+
* per-class ECE / Brier / calibration-gap tables and the flagged classes.
|
|
9
|
+
* They lived in `@qmilab/lodestar-harness` while the calibrator was a
|
|
10
|
+
* return-value-only surface; they **graduated to `@qmilab/lodestar-core`**
|
|
11
|
+
* when the durable `calibration.computed@1` event landed (ADR-0011), so
|
|
12
|
+
* the event payload can embed the report (core is the dependency root and
|
|
13
|
+
* cannot import the harness). The harness re-exports them unchanged, so
|
|
14
|
+
* harness consumers are unaffected.
|
|
15
|
+
*
|
|
16
|
+
* The Calibrator stays measure-only: it returns a {@link CalibrationReport}
|
|
17
|
+
* and never writes. Recording a report as a `calibration.computed@1` event
|
|
18
|
+
* is a separate publish step (`lodestar harness calibrate` / the harness
|
|
19
|
+
* `eventLogCalibrationSink`), the same measure/record split the sentinels
|
|
20
|
+
* follow (a `Sentinel` returns findings; `eventLogAlertSink` writes them).
|
|
21
|
+
*
|
|
22
|
+
* Everything here is validated at the calibrator and event-sink boundaries,
|
|
23
|
+
* the same discipline the probe-run observation and sentinel-alert builders
|
|
24
|
+
* hold.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which signal in the event log produced a calibration sample.
|
|
29
|
+
* - `action_outcome`: a belief → decision → action chain where the
|
|
30
|
+
* action's realised result (terminal phase or an explicit Outcome) is
|
|
31
|
+
* the label.
|
|
32
|
+
* - `truth_status`: the firewall transitioned the belief's `truth_status`
|
|
33
|
+
* to `supported` / `contradicted` — the world adjudicating the belief.
|
|
34
|
+
*/
|
|
35
|
+
export const SampleSourceSchema = z.enum(["action_outcome", "truth_status"])
|
|
36
|
+
export type SampleSource = z.infer<typeof SampleSourceSchema>
|
|
37
|
+
|
|
38
|
+
/** The scored metrics for a set of samples (one class, or the pool). */
|
|
39
|
+
export const CalibrationMetricsSchema = z.object({
|
|
40
|
+
n: z.number().int().nonnegative(),
|
|
41
|
+
/** mean of stated confidence */
|
|
42
|
+
mean_confidence: z.number().min(0).max(1),
|
|
43
|
+
/** realised positive rate, mean(correct) */
|
|
44
|
+
empirical_accuracy: z.number().min(0).max(1),
|
|
45
|
+
/** mean((p - y)^2); 0 is perfect, lower is better */
|
|
46
|
+
brier_score: z.number().min(0).max(1),
|
|
47
|
+
/** expected calibration error over equal-width confidence bins */
|
|
48
|
+
ece: z.number().min(0).max(1),
|
|
49
|
+
/** signed mean_confidence - empirical_accuracy; > 0 is overconfident */
|
|
50
|
+
calibration_gap: z.number().min(-1).max(1),
|
|
51
|
+
overconfident: z.boolean(),
|
|
52
|
+
})
|
|
53
|
+
export type CalibrationMetrics = z.infer<typeof CalibrationMetricsSchema>
|
|
54
|
+
|
|
55
|
+
/** One non-empty bin of a reliability diagram. */
|
|
56
|
+
export const ReliabilityBinSchema = z.object({
|
|
57
|
+
lower: z.number().min(0).max(1),
|
|
58
|
+
upper: z.number().min(0).max(1),
|
|
59
|
+
n: z.number().int().positive(),
|
|
60
|
+
mean_confidence: z.number().min(0).max(1),
|
|
61
|
+
empirical_accuracy: z.number().min(0).max(1),
|
|
62
|
+
})
|
|
63
|
+
export type ReliabilityBin = z.infer<typeof ReliabilityBinSchema>
|
|
64
|
+
|
|
65
|
+
/** Per-class result: metrics, the reliability bins, and the verdict. */
|
|
66
|
+
export const CalibrationClassResultSchema = z.object({
|
|
67
|
+
calibration_class: z.string(),
|
|
68
|
+
metrics: CalibrationMetricsSchema,
|
|
69
|
+
/** non-empty bins only, ascending by `lower` */
|
|
70
|
+
reliability_bins: z.array(ReliabilityBinSchema),
|
|
71
|
+
flagged: z.boolean(),
|
|
72
|
+
/** human-legible reason when flagged; `null` when not */
|
|
73
|
+
flag_reason: z.string().nullable(),
|
|
74
|
+
})
|
|
75
|
+
export type CalibrationClassResult = z.infer<typeof CalibrationClassResultSchema>
|
|
76
|
+
|
|
77
|
+
/** The thresholds and toggles actually applied, echoed for reproducibility. */
|
|
78
|
+
export const ResolvedCalibratorConfigSchema = z.object({
|
|
79
|
+
bins: z.number().int().positive(),
|
|
80
|
+
min_samples: z.number().int().positive(),
|
|
81
|
+
ece_threshold: z.number().min(0).max(1),
|
|
82
|
+
gap_threshold: z.number().min(0).max(1),
|
|
83
|
+
outcome_sources: z.array(SampleSourceSchema).min(1),
|
|
84
|
+
include_synthetic_authority: z.boolean(),
|
|
85
|
+
})
|
|
86
|
+
export type ResolvedCalibratorConfig = z.infer<typeof ResolvedCalibratorConfigSchema>
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The calibrator's output: per-class tables, a pooled `overall` block,
|
|
90
|
+
* the flagged class names, and the config that produced it. A pure
|
|
91
|
+
* function of `(events, config)` — no clock, no scope inference — so it
|
|
92
|
+
* is deterministic and testable, and re-running it over the same event
|
|
93
|
+
* window reproduces the report (the property the `cursor` on
|
|
94
|
+
* {@link CalibrationComputedPayloadSchema} makes auditable).
|
|
95
|
+
*/
|
|
96
|
+
export const CalibrationReportSchema = z.object({
|
|
97
|
+
/** total samples resolved and included (after exclusions) */
|
|
98
|
+
sample_count: z.number().int().nonnegative(),
|
|
99
|
+
classes: z.array(CalibrationClassResultSchema),
|
|
100
|
+
overall: CalibrationMetricsSchema,
|
|
101
|
+
flagged_classes: z.array(z.string()),
|
|
102
|
+
config: ResolvedCalibratorConfigSchema,
|
|
103
|
+
})
|
|
104
|
+
export type CalibrationReport = z.infer<typeof CalibrationReportSchema>
|
|
105
|
+
|
|
106
|
+
// ── The calibration.computed@1 governed event (ADR-0011) ─────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What invoked a calibration pass.
|
|
110
|
+
*
|
|
111
|
+
* `cli` — a human ran `lodestar harness calibrate --session <id>`.
|
|
112
|
+
* `programmatic` — a host computed and recorded calibration from its own
|
|
113
|
+
* code (e.g. a guarded loop at a deliberate checkpoint).
|
|
114
|
+
*/
|
|
115
|
+
export const CalibrationTriggerSchema = z.enum(["cli", "programmatic"])
|
|
116
|
+
export type CalibrationTrigger = z.infer<typeof CalibrationTriggerSchema>
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The seq window a calibration pass measured.
|
|
120
|
+
*
|
|
121
|
+
* Replayability is by cursor: re-running `calibrate` over the same events
|
|
122
|
+
* in this window — those with `seq` strictly greater than `from_seq` and
|
|
123
|
+
* less than or equal to `to_seq`, within the event's own session slice —
|
|
124
|
+
* reproduces the embedded `report` (the calibrator is a pure function of
|
|
125
|
+
* `(events, config)`). This is what makes calibration drift auditable
|
|
126
|
+
* across time — two `calibration.computed@1` events with overlapping
|
|
127
|
+
* windows can be diffed, and either can be recomputed from the log to
|
|
128
|
+
* verify it was not tampered with. (`seq` is per-project, so the session
|
|
129
|
+
* slice is the natural replay scope; a v0 calibration pass reads one
|
|
130
|
+
* session.)
|
|
131
|
+
*/
|
|
132
|
+
export const CalibrationCursorSchema = z
|
|
133
|
+
.object({
|
|
134
|
+
from_seq: z
|
|
135
|
+
.number()
|
|
136
|
+
.int()
|
|
137
|
+
.min(-1)
|
|
138
|
+
.describe(
|
|
139
|
+
"Exclusive lower bound. The pass measured events with seq strictly greater than this; " +
|
|
140
|
+
"-1 means from the start of the partition.",
|
|
141
|
+
),
|
|
142
|
+
to_seq: z
|
|
143
|
+
.number()
|
|
144
|
+
.int()
|
|
145
|
+
.min(-1)
|
|
146
|
+
.describe(
|
|
147
|
+
"Inclusive upper bound: the highest event seq included. Equal to from_seq when the " +
|
|
148
|
+
"window is empty (the pass ran but observed no events).",
|
|
149
|
+
),
|
|
150
|
+
})
|
|
151
|
+
// An inverted window `(from_seq, to_seq]` with `to_seq < from_seq` selects
|
|
152
|
+
// no events and cannot reproduce a non-empty report — it would persist a
|
|
153
|
+
// `calibration.computed@1` whose replay guarantee is a lie. Reject it at the
|
|
154
|
+
// boundary; the empty window `from_seq === to_seq` is the only equality case
|
|
155
|
+
// (the pass ran but observed nothing), and it satisfies this.
|
|
156
|
+
.refine((c) => c.to_seq >= c.from_seq, {
|
|
157
|
+
message: "to_seq must be >= from_seq (an inverted cursor selects no events)",
|
|
158
|
+
path: ["to_seq"],
|
|
159
|
+
})
|
|
160
|
+
export type CalibrationCursor = z.infer<typeof CalibrationCursorSchema>
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The payload of a `calibration.computed@1` event.
|
|
164
|
+
*
|
|
165
|
+
* The durable record of one calibration pass: the verdict (`report`), the
|
|
166
|
+
* window it measured (`cursor`, for replay), and provenance (`computed_at`,
|
|
167
|
+
* `triggered_by`, `computation_id`). It does NOT enforce anything — the
|
|
168
|
+
* Policy Kernel's arbitrate hook reads an in-process `CalibrationReport`
|
|
169
|
+
* snapshot, not this event (see `docs/architecture/calibrator.md` and
|
|
170
|
+
* ADR-0011). This event exists so calibration drift is auditable and
|
|
171
|
+
* replayable, the way a probe run or a sentinel finding already is.
|
|
172
|
+
*
|
|
173
|
+
* Not signed in v0: the event inherits the log's canonical-hash
|
|
174
|
+
* tamper-evidence, and nothing un-parks a held action on the strength of a
|
|
175
|
+
* calibration *event* (the gate only ever escalates — the conservative
|
|
176
|
+
* direction). If a future slice makes the gate consume persisted
|
|
177
|
+
* calibration events as an authority, signing graduates then, the same
|
|
178
|
+
* staged path the approval resolution followed (ADR-0010).
|
|
179
|
+
*/
|
|
180
|
+
export const CalibrationComputedPayloadSchema = z
|
|
181
|
+
.object({
|
|
182
|
+
/** Stable id for this pass, so the audit chain can reference it. */
|
|
183
|
+
computation_id: z.string().min(1),
|
|
184
|
+
triggered_by: CalibrationTriggerSchema,
|
|
185
|
+
/** The seq window measured — re-running `calibrate` over it reproduces `report`. */
|
|
186
|
+
cursor: CalibrationCursorSchema,
|
|
187
|
+
/** The verdict: the full report this pass produced. */
|
|
188
|
+
report: CalibrationReportSchema,
|
|
189
|
+
computed_at: TimestampSchema,
|
|
190
|
+
})
|
|
191
|
+
// The cursor is only an honest replay key if it is *consistent* with the
|
|
192
|
+
// report. An empty window `(n, n]` selects zero events, so it can reproduce
|
|
193
|
+
// only a zero-sample report — and a zero-sample report can only have come
|
|
194
|
+
// from an empty window. Enforce the biconditional: empty window ⟺
|
|
195
|
+
// `sample_count === 0`. This rejects the two non-replayable shapes a
|
|
196
|
+
// programmatic caller could otherwise persist — an empty window carrying a
|
|
197
|
+
// populated report (replaying it yields nothing), and a populated window
|
|
198
|
+
// carrying a zero-sample report (the window's events would yield samples).
|
|
199
|
+
.refine((p) => (p.cursor.to_seq === p.cursor.from_seq) === (p.report.sample_count === 0), {
|
|
200
|
+
message:
|
|
201
|
+
"an empty cursor window (to_seq === from_seq) must accompany a zero-sample report and vice " +
|
|
202
|
+
"versa — otherwise the recorded window cannot reproduce the report it claims as its replay key",
|
|
203
|
+
path: ["cursor", "to_seq"],
|
|
204
|
+
})
|
|
205
|
+
export type CalibrationComputedPayload = z.infer<typeof CalibrationComputedPayloadSchema>
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Event-type literal. Use this constant rather than the bare string so a
|
|
209
|
+
* future rename is grep-safe. Mirrors `reflection.completed@1`.
|
|
210
|
+
*/
|
|
211
|
+
export const CALIBRATION_COMPUTED_EVENT_TYPE = "calibration.computed" as const
|
|
212
|
+
export const CALIBRATION_COMPUTED_SCHEMA_VERSION = "1" as const
|
package/src/schemas/claim.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { z } from "zod"
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PredicateSchema,
|
|
4
|
+
ResourceScopeSchema,
|
|
5
|
+
SensitivitySchema,
|
|
6
|
+
TimestampSchema,
|
|
7
|
+
} from "./common.js"
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* How a claim was extracted from observation(s).
|
|
@@ -36,7 +41,9 @@ export const ClaimSchema = z.object({
|
|
|
36
41
|
id: z.string(),
|
|
37
42
|
statement: z.string().describe("human-readable claim"),
|
|
38
43
|
structured_predicate: PredicateSchema.optional().describe("for queryable claims"),
|
|
39
|
-
source_observation_ids: z
|
|
44
|
+
source_observation_ids: z
|
|
45
|
+
.array(z.string())
|
|
46
|
+
.min(1, "a claim must reference at least one observation"),
|
|
40
47
|
extraction_method: ExtractionMethodSchema,
|
|
41
48
|
extracted_by: z.string().describe("actor_id of the extractor"),
|
|
42
49
|
status: ClaimStatusSchema,
|
|
@@ -60,12 +67,12 @@ export type Claim = z.infer<typeof ClaimSchema>
|
|
|
60
67
|
* a scoring function.
|
|
61
68
|
*/
|
|
62
69
|
export const EvidenceQualitySchema = z.enum([
|
|
63
|
-
"direct_observation",
|
|
64
|
-
"tool_result",
|
|
65
|
-
"human_assertion",
|
|
66
|
-
"model_inference",
|
|
67
|
-
"external_document",
|
|
68
|
-
"synthetic_probe",
|
|
70
|
+
"direct_observation", // tool output describing world state
|
|
71
|
+
"tool_result", // computed result from a tool
|
|
72
|
+
"human_assertion", // user said so
|
|
73
|
+
"model_inference", // an LLM concluded so from other context
|
|
74
|
+
"external_document", // file, webpage, email — high risk for poisoning
|
|
75
|
+
"synthetic_probe", // from a Harness probe; never affects real beliefs
|
|
69
76
|
])
|
|
70
77
|
export type EvidenceQuality = z.infer<typeof EvidenceQualitySchema>
|
|
71
78
|
|
package/src/schemas/common.ts
CHANGED
|
@@ -15,21 +15,25 @@ export type Sensitivity = z.infer<typeof SensitivitySchema>
|
|
|
15
15
|
* Scope a claim, belief, memory, or action applies to. Hierarchical
|
|
16
16
|
* from broadest (global) to narrowest (session).
|
|
17
17
|
*/
|
|
18
|
-
export const ResourceScopeSchema = z
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
export const ResourceScopeSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
level: z.enum(["global", "organization", "user", "project", "repo", "session"]),
|
|
21
|
+
identifier: z.string().describe("identifier within the scope level, e.g. project_id"),
|
|
22
|
+
})
|
|
23
|
+
.describe("Scope that bounds where a claim, belief, or memory applies")
|
|
22
24
|
export type ResourceScope = z.infer<typeof ResourceScopeSchema>
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Structured predicate for queryable claims and beliefs.
|
|
26
28
|
* Free-form for v0; refined in later versions as the planner matures.
|
|
27
29
|
*/
|
|
28
|
-
export const PredicateSchema = z
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
export const PredicateSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
subject: z.string(),
|
|
33
|
+
relation: z.string(),
|
|
34
|
+
object: z.unknown(),
|
|
35
|
+
})
|
|
36
|
+
.describe("Structured form of a claim suitable for queries")
|
|
33
37
|
export type Predicate = z.infer<typeof PredicateSchema>
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -41,7 +45,9 @@ export type Timestamp = z.infer<typeof TimestampSchema>
|
|
|
41
45
|
/**
|
|
42
46
|
* ISO 8601 duration string (e.g. "P30D", "PT1H").
|
|
43
47
|
*/
|
|
44
|
-
export const DurationSchema = z
|
|
48
|
+
export const DurationSchema = z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(/^P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/)
|
|
45
51
|
export type Duration = z.infer<typeof DurationSchema>
|
|
46
52
|
|
|
47
53
|
/**
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import {
|
|
3
|
+
BlastRadiusSchema,
|
|
4
|
+
DataSensitivityForActionSchema,
|
|
5
|
+
ReversibilitySchema,
|
|
6
|
+
TrustLevelSchema,
|
|
7
|
+
} from "./action.js"
|
|
8
|
+
import { SignatureSchema } from "./actor.js"
|
|
9
|
+
import { ResourceScopeSchema, SensitivitySchema } from "./common.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Action policy — the wire format for what actions may touch the world.
|
|
13
|
+
*
|
|
14
|
+
* Design lock: `docs/architecture/policy-kernel.md`. The short version:
|
|
15
|
+
*
|
|
16
|
+
* - Policy is a *declarative document*, not a function. Today's enforcement
|
|
17
|
+
* is an opaque `PolicyGate` closure (the `autoApprovePolicy` preset). The
|
|
18
|
+
* Policy Kernel compiles a `Policy` document into that gate, so the verdict
|
|
19
|
+
* becomes data — addressable, hashable, signable, and citable by
|
|
20
|
+
* `Decision.policy_dependencies`.
|
|
21
|
+
* - Rules are evaluated *in order*; the first decisive rule wins, over a
|
|
22
|
+
* *structural* deny default. There is deliberately no `default` field and
|
|
23
|
+
* no expressible `default: allow` — the safe outcome is structural, not a
|
|
24
|
+
* rule someone can forget to add ("no silent defaults for security-relevant
|
|
25
|
+
* settings", root CLAUDE.md).
|
|
26
|
+
* - The *trust-ladder floor* (L5 deny, L4 always require_approval) is a
|
|
27
|
+
* non-overridable pre-check applied *before* the rule list, in the engine —
|
|
28
|
+
* it is NOT expressed as a rule, so no broad earlier `allow` can lift it.
|
|
29
|
+
*
|
|
30
|
+
* Not to be confused with `ContextPolicy` (`belief.ts`), which governs what
|
|
31
|
+
* beliefs may enter model context. This `Policy` governs what actions may
|
|
32
|
+
* touch the world — a different gate on a different chain link.
|
|
33
|
+
*
|
|
34
|
+
* Core owns the wire format only. The engine — `compile(policy) → PolicyGate`,
|
|
35
|
+
* the three-valued gate, signature verification, the arbitrate hook — lives in
|
|
36
|
+
* `@qmilab/lodestar-policy-kernel`.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The declarative effect of a matched rule.
|
|
41
|
+
*
|
|
42
|
+
* `require_approval` is the *declarative* counterpart of the gate's runtime
|
|
43
|
+
* `hold` verdict: a matched `require_approval` rule causes the engine to park
|
|
44
|
+
* the action at `pending_approval` and open an `ApprovalRequest`. (The runtime
|
|
45
|
+
* three-valued verdict `allow | deny | hold` is an engine type and lives in
|
|
46
|
+
* `@qmilab/lodestar-policy-kernel`, not in the wire format.)
|
|
47
|
+
*/
|
|
48
|
+
export const PolicyEffectSchema = z.enum(["allow", "deny", "require_approval"])
|
|
49
|
+
export type PolicyEffect = z.infer<typeof PolicyEffectSchema>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The match clause of a rule. All present fields must hold (AND); an absent
|
|
53
|
+
* field is a wildcard. A rule with an empty `match` matches every action.
|
|
54
|
+
*
|
|
55
|
+
* The fields constrain an `ActionContract` (`action.ts`):
|
|
56
|
+
* - `tool` is a glob over the tool registry key (e.g. `"git.*"`).
|
|
57
|
+
* - `max_blast_radius` matches contracts at or below this radius on the
|
|
58
|
+
* ordering self < session < project < external (the comparison is engine
|
|
59
|
+
* logic; the schema stores the ceiling).
|
|
60
|
+
* - `reversibility` is the set the contract's reversibility must be a member
|
|
61
|
+
* of (e.g. `["reversible", "compensable"]` excludes `irreversible`).
|
|
62
|
+
* - `scope` constrains the contract's `ResourceScope`.
|
|
63
|
+
* - `data_sensitivity` matches the contract's 3-value action sensitivity.
|
|
64
|
+
* - `required_level_lte` matches contracts whose `required_level` is at or
|
|
65
|
+
* below this trust level.
|
|
66
|
+
*/
|
|
67
|
+
export const PolicyMatchSchema = z.object({
|
|
68
|
+
tool: z.string().min(1).optional().describe("glob over the tool registry key, e.g. 'git.*'"),
|
|
69
|
+
max_blast_radius: BlastRadiusSchema.optional().describe(
|
|
70
|
+
"matches contracts at or below this blast radius (self < session < project < external)",
|
|
71
|
+
),
|
|
72
|
+
reversibility: z
|
|
73
|
+
.array(ReversibilitySchema)
|
|
74
|
+
.min(1)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("the set the contract's reversibility must be a member of"),
|
|
77
|
+
scope: ResourceScopeSchema.optional().describe("constrains the contract's ResourceScope"),
|
|
78
|
+
data_sensitivity: DataSensitivityForActionSchema.optional().describe(
|
|
79
|
+
"matches the contract's 3-value action sensitivity",
|
|
80
|
+
),
|
|
81
|
+
required_level_lte: TrustLevelSchema.optional().describe(
|
|
82
|
+
"matches contracts whose required_level is at or below this",
|
|
83
|
+
),
|
|
84
|
+
})
|
|
85
|
+
export type PolicyMatch = z.infer<typeof PolicyMatchSchema>
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Constraints an approver must satisfy to resolve a held action. *Data, not a
|
|
89
|
+
* callback* — it says *what* an approver must be, checked against the
|
|
90
|
+
* resolver's `Actor`. This is what lets a team approval surface route a
|
|
91
|
+
* request to the right person without the Policy Kernel knowing anything
|
|
92
|
+
* about people. All fields optional; an empty object means "any actor the
|
|
93
|
+
* host has configured as a resolver may approve".
|
|
94
|
+
*
|
|
95
|
+
* The clearance check spans two alphabets: an action's `data_sensitivity` is
|
|
96
|
+
* the 3-value `public | private | secret`, an `Actor.sensitivity_clearance`
|
|
97
|
+
* is the 4-value `Sensitivity`. `sensitivity_clearance` here is the *4-value*
|
|
98
|
+
* `Sensitivity` — the action's sensitivity *mapped* via the Action Kernel's
|
|
99
|
+
* `sensitivityForContract` (`public→public`, `private→internal`,
|
|
100
|
+
* `secret→secret`) — so the approver-side comparison happens in one alphabet.
|
|
101
|
+
*/
|
|
102
|
+
export const RequiredAuthoritySchema = z.object({
|
|
103
|
+
min_trust_baseline: z
|
|
104
|
+
.number()
|
|
105
|
+
.min(0)
|
|
106
|
+
.max(1)
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("floor on an approver's Actor.trust_baseline"),
|
|
109
|
+
sensitivity_clearance: SensitivitySchema.optional().describe(
|
|
110
|
+
"4-value clearance the approver must hold; the action's data_sensitivity mapped via sensitivityForContract",
|
|
111
|
+
),
|
|
112
|
+
scope: ResourceScopeSchema.optional().describe("ResourceScope the approver must hold"),
|
|
113
|
+
})
|
|
114
|
+
export type RequiredAuthority = z.infer<typeof RequiredAuthoritySchema>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The approval requirement carried by a `require_approval` rule. When the rule
|
|
118
|
+
* fires, its `required_authority` becomes the opened `ApprovalRequest`'s
|
|
119
|
+
* `required_authority`. Omitting `required_authority` (or the whole
|
|
120
|
+
* `approval` object) means any configured resolver may approve.
|
|
121
|
+
*
|
|
122
|
+
* A thin wrapper today; it is the seam where multi-approver / N-of-M
|
|
123
|
+
* constraints attach when the team approval surface is built (deferred —
|
|
124
|
+
* `policy-kernel.md`, "a separate team surface").
|
|
125
|
+
*/
|
|
126
|
+
export const ApprovalRequirementSchema = z.object({
|
|
127
|
+
required_authority: RequiredAuthoritySchema.optional().describe(
|
|
128
|
+
"constraints an approver must satisfy; omitted means any configured resolver may approve",
|
|
129
|
+
),
|
|
130
|
+
})
|
|
131
|
+
export type ApprovalRequirement = z.infer<typeof ApprovalRequirementSchema>
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One match → effect rule. Evaluated in document order; the first rule whose
|
|
135
|
+
* `match` holds is decisive. `reason` is surfaced verbatim in the
|
|
136
|
+
* `PolicyDecision` (and, for a held action, in the `ApprovalRequest`).
|
|
137
|
+
*
|
|
138
|
+
* `approval` may be present only on a `require_approval` rule (enforced
|
|
139
|
+
* below). It is not *required* there — an absent `approval` means the hold has
|
|
140
|
+
* no authority constraints (any configured resolver may approve), which is a
|
|
141
|
+
* meaningful default, so it is left optional rather than forced to an empty
|
|
142
|
+
* object.
|
|
143
|
+
*/
|
|
144
|
+
export const PolicyRuleSchema = z
|
|
145
|
+
.object({
|
|
146
|
+
match: PolicyMatchSchema,
|
|
147
|
+
effect: PolicyEffectSchema,
|
|
148
|
+
approval: ApprovalRequirementSchema.optional().describe(
|
|
149
|
+
"present only on a require_approval rule; carries the authority an approver must hold",
|
|
150
|
+
),
|
|
151
|
+
reason: z
|
|
152
|
+
.string()
|
|
153
|
+
.min(1)
|
|
154
|
+
.describe("surfaced verbatim in the PolicyDecision and ApprovalRequest"),
|
|
155
|
+
})
|
|
156
|
+
.superRefine((rule, ctx) => {
|
|
157
|
+
if (rule.approval !== undefined && rule.effect !== "require_approval") {
|
|
158
|
+
ctx.addIssue({
|
|
159
|
+
code: z.ZodIssueCode.custom,
|
|
160
|
+
path: ["approval"],
|
|
161
|
+
message: "approval may only be set on a rule whose effect is 'require_approval'",
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
export type PolicyRule = z.infer<typeof PolicyRuleSchema>
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A signed, packageable action-policy document.
|
|
169
|
+
*
|
|
170
|
+
* `version` is the monotonic string that `Decision.policy_dependencies` cites,
|
|
171
|
+
* so an audit can resolve exactly which policy version arbitrated an action.
|
|
172
|
+
*
|
|
173
|
+
* `signature` / `signed_by` are *optional at the schema level* so that
|
|
174
|
+
* unsigned **drafts** parse, but `v02-delta.md` §5 lists policy versions among
|
|
175
|
+
* the artifacts that *require* Ed25519 signatures: the Policy Kernel rejects an
|
|
176
|
+
* unsigned (or invalid-signature) policy at the gate, except under an explicit,
|
|
177
|
+
* logged `allow_unsigned: true` development opt-in. The signer is
|
|
178
|
+
* `signature.signer_id`; `signed_by` is a top-level convenience that must
|
|
179
|
+
* equal it when a signature is present (enforced below) — never a second,
|
|
180
|
+
* divergeable source of truth.
|
|
181
|
+
*
|
|
182
|
+
* The signature is computed over the *canonical document without the
|
|
183
|
+
* signature* — `{ id, version, rules }` — since a document cannot sign over
|
|
184
|
+
* its own signature. That canonical hash is what `signature.payload_hash`
|
|
185
|
+
* carries and what `Decision.policy_dependencies` ultimately pins.
|
|
186
|
+
*/
|
|
187
|
+
export const PolicySchema = z
|
|
188
|
+
.object({
|
|
189
|
+
id: z.string().min(1).describe("stable policy id"),
|
|
190
|
+
version: z
|
|
191
|
+
.string()
|
|
192
|
+
.min(1)
|
|
193
|
+
.describe("monotonic version; this is the string Decision.policy_dependencies cites"),
|
|
194
|
+
rules: z
|
|
195
|
+
.array(PolicyRuleSchema)
|
|
196
|
+
.describe("evaluated in order; first decisive rule wins, over a structural deny default"),
|
|
197
|
+
signature: SignatureSchema.optional().describe(
|
|
198
|
+
"Ed25519 over the canonical document { id, version, rules }; required at the gate for an active policy",
|
|
199
|
+
),
|
|
200
|
+
signed_by: z
|
|
201
|
+
.string()
|
|
202
|
+
.min(1)
|
|
203
|
+
.optional()
|
|
204
|
+
.describe(
|
|
205
|
+
"actor_id of the signer; present iff signature is present, and equals signature.signer_id",
|
|
206
|
+
),
|
|
207
|
+
})
|
|
208
|
+
.superRefine((policy, ctx) => {
|
|
209
|
+
if (policy.signature !== undefined) {
|
|
210
|
+
if (policy.signed_by === undefined) {
|
|
211
|
+
ctx.addIssue({
|
|
212
|
+
code: z.ZodIssueCode.custom,
|
|
213
|
+
path: ["signed_by"],
|
|
214
|
+
message: "signed_by must be present when signature is present",
|
|
215
|
+
})
|
|
216
|
+
} else if (policy.signed_by !== policy.signature.signer_id) {
|
|
217
|
+
ctx.addIssue({
|
|
218
|
+
code: z.ZodIssueCode.custom,
|
|
219
|
+
path: ["signed_by"],
|
|
220
|
+
message: "signed_by must equal signature.signer_id",
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
} else if (policy.signed_by !== undefined) {
|
|
224
|
+
ctx.addIssue({
|
|
225
|
+
code: z.ZodIssueCode.custom,
|
|
226
|
+
path: ["signed_by"],
|
|
227
|
+
message: "signed_by must be omitted when signature is absent (unsigned draft)",
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
export type Policy = z.infer<typeof PolicySchema>
|