@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.
Files changed (56) hide show
  1. package/dist/index.d.ts +6 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +12 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/schemas/action.d.ts +31 -13
  6. package/dist/schemas/action.d.ts.map +1 -1
  7. package/dist/schemas/action.js +20 -1
  8. package/dist/schemas/action.js.map +1 -1
  9. package/dist/schemas/approval.d.ts +271 -0
  10. package/dist/schemas/approval.d.ts.map +1 -0
  11. package/dist/schemas/approval.js +119 -0
  12. package/dist/schemas/approval.js.map +1 -0
  13. package/dist/schemas/belief.d.ts.map +1 -1
  14. package/dist/schemas/belief.js +7 -1
  15. package/dist/schemas/belief.js.map +1 -1
  16. package/dist/schemas/calibration.d.ts +977 -0
  17. package/dist/schemas/calibration.d.ts.map +1 -0
  18. package/dist/schemas/calibration.js +187 -0
  19. package/dist/schemas/calibration.js.map +1 -0
  20. package/dist/schemas/claim.d.ts.map +1 -1
  21. package/dist/schemas/claim.js +4 -2
  22. package/dist/schemas/claim.js.map +1 -1
  23. package/dist/schemas/common.d.ts.map +1 -1
  24. package/dist/schemas/common.js +11 -5
  25. package/dist/schemas/common.js.map +1 -1
  26. package/dist/schemas/policy.d.ts +768 -0
  27. package/dist/schemas/policy.d.ts.map +1 -0
  28. package/dist/schemas/policy.js +200 -0
  29. package/dist/schemas/policy.js.map +1 -0
  30. package/dist/schemas/probe-pack.d.ts +152 -0
  31. package/dist/schemas/probe-pack.d.ts.map +1 -0
  32. package/dist/schemas/probe-pack.js +140 -0
  33. package/dist/schemas/probe-pack.js.map +1 -0
  34. package/dist/schemas/reflection.d.ts +405 -0
  35. package/dist/schemas/reflection.d.ts.map +1 -0
  36. package/dist/schemas/reflection.js +154 -0
  37. package/dist/schemas/reflection.js.map +1 -0
  38. package/dist/schemas/revision.d.ts.map +1 -1
  39. package/dist/schemas/revision.js.map +1 -1
  40. package/dist/schemas/sentinel.d.ts +134 -0
  41. package/dist/schemas/sentinel.d.ts.map +1 -0
  42. package/dist/schemas/sentinel.js +97 -0
  43. package/dist/schemas/sentinel.js.map +1 -0
  44. package/package.json +2 -7
  45. package/src/index.ts +18 -0
  46. package/src/schemas/action.ts +20 -1
  47. package/src/schemas/approval.ts +136 -0
  48. package/src/schemas/belief.ts +7 -1
  49. package/src/schemas/calibration.ts +212 -0
  50. package/src/schemas/claim.ts +15 -8
  51. package/src/schemas/common.ts +16 -10
  52. package/src/schemas/policy.ts +231 -0
  53. package/src/schemas/probe-pack.ts +169 -0
  54. package/src/schemas/reflection.ts +166 -0
  55. package/src/schemas/revision.ts +7 -5
  56. 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
@@ -1,5 +1,10 @@
1
1
  import { z } from "zod"
2
- import { PredicateSchema, ResourceScopeSchema, SensitivitySchema, TimestampSchema } from "./common.js"
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.array(z.string()).min(1, "a claim must reference at least one observation"),
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", // tool output describing world state
64
- "tool_result", // computed result from a tool
65
- "human_assertion", // user said so
66
- "model_inference", // an LLM concluded so from other context
67
- "external_document", // file, webpage, email — high risk for poisoning
68
- "synthetic_probe", // from a Harness probe; never affects real beliefs
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
 
@@ -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.object({
19
- level: z.enum(["global", "organization", "user", "project", "repo", "session"]),
20
- identifier: z.string().describe("identifier within the scope level, e.g. project_id"),
21
- }).describe("Scope that bounds where a claim, belief, or memory applies")
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.object({
29
- subject: z.string(),
30
- relation: z.string(),
31
- object: z.unknown(),
32
- }).describe("Structured form of a claim suitable for queries")
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.string().regex(/^P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/)
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>