@qmilab/lodestar-core 0.1.4 → 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,169 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The probe-pack format spec version. This is the version of the
|
|
5
|
+
* *manifest schema itself*, not of any given pack. A loader declares
|
|
6
|
+
* which spec versions it understands; a manifest declares which one it
|
|
7
|
+
* was written against. v0 of the harness understands spec "1" only.
|
|
8
|
+
*
|
|
9
|
+
* Bump this when the manifest shape changes in a way older loaders
|
|
10
|
+
* cannot read. Adding an optional field is not a bump; removing or
|
|
11
|
+
* re-typing a field is.
|
|
12
|
+
*/
|
|
13
|
+
export const PROBE_PACK_SPEC_VERSION = "1" as const
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where a pack's probe files come from.
|
|
17
|
+
*
|
|
18
|
+
* `local` — the pack lives on the filesystem; probe `file` paths are
|
|
19
|
+
* resolved relative to the directory containing the manifest.
|
|
20
|
+
* `npm` — the pack ships as a published package; the loader reads the
|
|
21
|
+
* manifest from the package's `./lodestar.probe-pack.json` export and
|
|
22
|
+
* resolves probe files relative to the package root.
|
|
23
|
+
*
|
|
24
|
+
* Both source types are part of the spec from day one so external
|
|
25
|
+
* authors can target a stable schema. The v0 loader resolves `local`
|
|
26
|
+
* only; `npm` resolution follows the first external pack that needs it
|
|
27
|
+
* (see docs/architecture/reflection-pass.md Q6).
|
|
28
|
+
*/
|
|
29
|
+
export const ProbePackSourceTypeSchema = z.enum(["local", "npm"])
|
|
30
|
+
export type ProbePackSourceType = z.infer<typeof ProbePackSourceTypeSchema>
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One probe entry in a pack manifest.
|
|
34
|
+
*
|
|
35
|
+
* `name` is the probe's stable identifier, unique within the pack — the
|
|
36
|
+
* harness runner reports results under it and external references
|
|
37
|
+
* address probes by `<pack>/<name>`. `file` is the probe source,
|
|
38
|
+
* relative to the pack root.
|
|
39
|
+
*/
|
|
40
|
+
export const ProbeEntrySchema = z.object({
|
|
41
|
+
name: z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.regex(
|
|
45
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
46
|
+
"probe name must be kebab-case (lowercase alphanumerics separated by single hyphens)",
|
|
47
|
+
)
|
|
48
|
+
.describe("Stable probe identifier, unique within the pack."),
|
|
49
|
+
file: z
|
|
50
|
+
.string()
|
|
51
|
+
.min(1)
|
|
52
|
+
// Must be relative to the pack root: a pack that names an absolute
|
|
53
|
+
// path loads on its author's machine but breaks once moved or
|
|
54
|
+
// published. Reject POSIX (`/`), UNC/Windows-root (`\`), and
|
|
55
|
+
// drive-letter (`C:`) prefixes so the contract holds cross-platform.
|
|
56
|
+
.refine((f) => !/^([/\\]|[A-Za-z]:)/.test(f), {
|
|
57
|
+
message:
|
|
58
|
+
"probe file must be a relative path inside the pack (absolute paths are not allowed)",
|
|
59
|
+
})
|
|
60
|
+
.describe(
|
|
61
|
+
"Probe source file, relative to the pack root (the directory containing the manifest).",
|
|
62
|
+
),
|
|
63
|
+
})
|
|
64
|
+
export type ProbeEntry = z.infer<typeof ProbeEntrySchema>
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One sentinel entry in a pack manifest.
|
|
68
|
+
*
|
|
69
|
+
* Unlike a probe — a `bun run`-able script the pack carries as a `file` —
|
|
70
|
+
* a sentinel is a stateful in-process class the harness instantiates and
|
|
71
|
+
* feeds the event stream. There is no subprocess contract for it, so the
|
|
72
|
+
* manifest references a sentinel by a stable `id` and the harness resolves
|
|
73
|
+
* that id against its built-in registry of first-party sentinels
|
|
74
|
+
* (`FIRST_PARTY_SENTINELS` in `@qmilab/lodestar-harness`). A pack thus
|
|
75
|
+
* *declares* which built-in sentinels it ships rather than carrying their
|
|
76
|
+
* source.
|
|
77
|
+
*
|
|
78
|
+
* Per-pack construction-option overrides and third-party (file-referenced)
|
|
79
|
+
* sentinels are a deliberate later refinement — see the harness loader and
|
|
80
|
+
* `docs/architecture/sentinels.md`. v0 resolves first-party ids only.
|
|
81
|
+
*/
|
|
82
|
+
export const SentinelEntrySchema = z.object({
|
|
83
|
+
id: z
|
|
84
|
+
.string()
|
|
85
|
+
.min(1)
|
|
86
|
+
.regex(
|
|
87
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
88
|
+
"sentinel id must be kebab-case (lowercase alphanumerics separated by single hyphens)",
|
|
89
|
+
)
|
|
90
|
+
.describe(
|
|
91
|
+
"Stable id of a first-party sentinel, resolved by the harness against its built-in registry. Matches the sentinel's own `name`.",
|
|
92
|
+
),
|
|
93
|
+
})
|
|
94
|
+
export type SentinelEntry = z.infer<typeof SentinelEntrySchema>
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A `lodestar.probe-pack.json` manifest.
|
|
98
|
+
*
|
|
99
|
+
* This is the on-disk / on-wire contract every probe pack — first-party
|
|
100
|
+
* and external — is written against. It is deliberately declarative:
|
|
101
|
+
* the manifest names probes and their files but contains no executable
|
|
102
|
+
* logic. The harness loader (in `@qmilab/lodestar-harness`) reads it,
|
|
103
|
+
* validates it against this schema, and resolves the probe files; the
|
|
104
|
+
* runner (Batch 4 step 5) executes them.
|
|
105
|
+
*
|
|
106
|
+
* `coverage_areas` and `invariants` are free-form taxonomy tags the
|
|
107
|
+
* pack author declares. They are not validated against a closed list —
|
|
108
|
+
* the harness uses them for `lodestar harness list` grouping and for
|
|
109
|
+
* answering "which pack exercises invariant X?", not for gating. Keeping
|
|
110
|
+
* them open lets external packs name coverage the core taxonomy has not
|
|
111
|
+
* yet enumerated.
|
|
112
|
+
*/
|
|
113
|
+
export const ProbePackManifestSchema = z.object({
|
|
114
|
+
name: z
|
|
115
|
+
.string()
|
|
116
|
+
.min(1)
|
|
117
|
+
.regex(
|
|
118
|
+
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
119
|
+
"pack name must be kebab-case (lowercase alphanumerics separated by single hyphens)",
|
|
120
|
+
)
|
|
121
|
+
.describe("Pack identifier, e.g. 'lodestar-core'."),
|
|
122
|
+
version: z
|
|
123
|
+
.string()
|
|
124
|
+
.min(1)
|
|
125
|
+
.describe("Pack version (the author's, not the spec version). Conventionally semver."),
|
|
126
|
+
spec_version: z
|
|
127
|
+
.literal(PROBE_PACK_SPEC_VERSION)
|
|
128
|
+
.describe(
|
|
129
|
+
"Manifest-schema spec version. v0 loaders accept only '1' and reject unknown versions with a clear error rather than guessing.",
|
|
130
|
+
),
|
|
131
|
+
source_type: ProbePackSourceTypeSchema.describe(
|
|
132
|
+
"How the loader resolves probe files. The v0 loader resolves 'local' only.",
|
|
133
|
+
),
|
|
134
|
+
description: z
|
|
135
|
+
.string()
|
|
136
|
+
.min(1)
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Human-readable one-liner shown by `lodestar harness list`."),
|
|
139
|
+
coverage_areas: z
|
|
140
|
+
.array(z.string().min(1))
|
|
141
|
+
.describe("Free-form tags naming the threat-model / subsystem areas this pack covers."),
|
|
142
|
+
invariants: z
|
|
143
|
+
.array(z.string().min(1))
|
|
144
|
+
.describe("Free-form tags naming the Lodestar invariants this pack's probes exercise."),
|
|
145
|
+
probes: z
|
|
146
|
+
.array(ProbeEntrySchema)
|
|
147
|
+
.min(1, "a pack must declare at least one probe")
|
|
148
|
+
.describe("The probes this pack ships."),
|
|
149
|
+
// `.optional()` rather than `.default([])` on purpose: a default makes the
|
|
150
|
+
// field REQUIRED in the `z.infer` *output* type, so external TS code that
|
|
151
|
+
// constructs a manifest without `sentinels` would fail to compile — breaking
|
|
152
|
+
// the "additive optional field is free" promise. Keeping it optional leaves
|
|
153
|
+
// the public type backward-compatible; the loader treats an absent value as
|
|
154
|
+
// "no sentinels". Do not reintroduce `.default([])`.
|
|
155
|
+
sentinels: z
|
|
156
|
+
.array(SentinelEntrySchema)
|
|
157
|
+
.optional()
|
|
158
|
+
.describe(
|
|
159
|
+
"The sentinels this pack ships, referenced by stable id and resolved by the harness against its built-in registry. Optional; an absent field means the pack ships no sentinels. Additive since spec '1' — a manifest without it still loads.",
|
|
160
|
+
),
|
|
161
|
+
})
|
|
162
|
+
export type ProbePackManifest = z.infer<typeof ProbePackManifestSchema>
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The manifest filename a `local` pack carries at its root, and the
|
|
166
|
+
* export key an `npm` pack exposes it under. Defined as a constant so
|
|
167
|
+
* loaders and pack authors agree on the spelling.
|
|
168
|
+
*/
|
|
169
|
+
export const PROBE_PACK_MANIFEST_FILENAME = "lodestar.probe-pack.json" as const
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { TruthStatusSchema } from "./belief.js"
|
|
3
|
+
import { TimestampSchema } from "./common.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What triggered a reflection pass.
|
|
7
|
+
*
|
|
8
|
+
* `cli` — invoked by `lodestar reflect` from a human operator.
|
|
9
|
+
* `programmatic` — invoked from host code (e.g. runGuarded, the MCP
|
|
10
|
+
* proxy) at a deliberate point.
|
|
11
|
+
* `tail_cascade` — a `belief.transitioned` event recorded a transition
|
|
12
|
+
* to `truth_status: contradicted`; the tail watcher dispatched a
|
|
13
|
+
* pass to look for dependent fall-out.
|
|
14
|
+
* `tail_batch` — N `belief.adopted` events have accrued since the
|
|
15
|
+
* last pass for the partition (configurable batch size).
|
|
16
|
+
* `sentinel` — a `sentinel.alerted` event named a `belief_id` as
|
|
17
|
+
* subject; the sentinel asked reflection to follow up.
|
|
18
|
+
*/
|
|
19
|
+
export const ReflectionTriggerSchema = z.enum([
|
|
20
|
+
"cli",
|
|
21
|
+
"programmatic",
|
|
22
|
+
"tail_cascade",
|
|
23
|
+
"tail_batch",
|
|
24
|
+
"sentinel",
|
|
25
|
+
])
|
|
26
|
+
export type ReflectionTrigger = z.infer<typeof ReflectionTriggerSchema>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The lifecycle axes a reflection proposal can target. Mirrors
|
|
30
|
+
* `LifecycleAxis` in `@qmilab/lodestar-memory-firewall` deliberately —
|
|
31
|
+
* the core package cannot import from downstream packages, so the
|
|
32
|
+
* literal union is duplicated here. Keep these in sync.
|
|
33
|
+
*/
|
|
34
|
+
export const ReflectionLifecycleAxisSchema = z.enum([
|
|
35
|
+
"truth_status",
|
|
36
|
+
"retrieval_status",
|
|
37
|
+
"security_status",
|
|
38
|
+
"freshness_status",
|
|
39
|
+
])
|
|
40
|
+
export type ReflectionLifecycleAxis = z.infer<typeof ReflectionLifecycleAxisSchema>
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Subject of a `no_op` proposal — reflection looked at this thing
|
|
44
|
+
* and decided no change. Recorded so the audit trail distinguishes
|
|
45
|
+
* "reflection considered X and did nothing" from "reflection did
|
|
46
|
+
* not consider X."
|
|
47
|
+
*/
|
|
48
|
+
export const ReflectionSubjectSchema = z.object({
|
|
49
|
+
kind: z.enum(["belief", "claim", "decision"]),
|
|
50
|
+
id: z.string(),
|
|
51
|
+
})
|
|
52
|
+
export type ReflectionSubject = z.infer<typeof ReflectionSubjectSchema>
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One typed proposal from a reflection pass.
|
|
56
|
+
*
|
|
57
|
+
* Proposals are *suggestions*. They do not mutate state. When a
|
|
58
|
+
* proposal is acted on, the runner calls the existing MemoryFirewall
|
|
59
|
+
* API with `by_authority: "reflection"`, and the firewall emits its
|
|
60
|
+
* own normal `belief.adopted` / `belief.transitioned` event whose
|
|
61
|
+
* `causal_parent_ids` includes the reflection pass's event id.
|
|
62
|
+
*
|
|
63
|
+
* The `rationale_id` on every variant points to an Explanation the
|
|
64
|
+
* runner generated alongside the proposal — same shape as the
|
|
65
|
+
* Explanations the firewall consumes for transitions.
|
|
66
|
+
*/
|
|
67
|
+
export const ReflectionProposalSchema = z.discriminatedUnion("kind", [
|
|
68
|
+
z.object({
|
|
69
|
+
kind: z.literal("claim_promotion"),
|
|
70
|
+
claim_id: z.string(),
|
|
71
|
+
target_truth_status: TruthStatusSchema,
|
|
72
|
+
evidence_id: z.string(),
|
|
73
|
+
rationale_id: z.string(),
|
|
74
|
+
}),
|
|
75
|
+
z.object({
|
|
76
|
+
kind: z.literal("belief_transition"),
|
|
77
|
+
belief_id: z.string(),
|
|
78
|
+
axis: ReflectionLifecycleAxisSchema,
|
|
79
|
+
from_value: z.string(),
|
|
80
|
+
to_value: z.string(),
|
|
81
|
+
evidence_id: z.string().optional(),
|
|
82
|
+
rationale_id: z.string(),
|
|
83
|
+
}),
|
|
84
|
+
z.object({
|
|
85
|
+
kind: z.literal("belief_supersession"),
|
|
86
|
+
old_belief_id: z.string(),
|
|
87
|
+
new_belief_id: z.string(),
|
|
88
|
+
rationale_id: z.string(),
|
|
89
|
+
}),
|
|
90
|
+
/**
|
|
91
|
+
* The decision-dependency cascade. When a belief that a past
|
|
92
|
+
* Decision depended on transitions to `truth_status: contradicted`,
|
|
93
|
+
* reflection proposes flagging that Decision as having a contradicted
|
|
94
|
+
* dependency. Applying the proposal emits a Revision event with
|
|
95
|
+
* `target_type: "decision"` so the decision's epistemic status
|
|
96
|
+
* change is recorded in the audit chain. (Closes the Batch-2-deferred
|
|
97
|
+
* "contradicted belief flags dependent decisions" invariant.)
|
|
98
|
+
*/
|
|
99
|
+
z.object({
|
|
100
|
+
kind: z.literal("decision_dependency_flagged"),
|
|
101
|
+
decision_id: z.string(),
|
|
102
|
+
contradicted_belief_id: z.string(),
|
|
103
|
+
/**
|
|
104
|
+
* The contradicted belief's truth_status BEFORE the transition.
|
|
105
|
+
* Captured from the firing `belief.transitioned` payload's
|
|
106
|
+
* `from_value` (defensively a TruthStatus, but stored as the raw
|
|
107
|
+
* string the transition emitted). Used by the application step
|
|
108
|
+
* to record an accurate `old_value` in the dependent Decision's
|
|
109
|
+
* Revision — a belief can transition `unverified → contradicted`
|
|
110
|
+
* directly, not only from `supported`.
|
|
111
|
+
*/
|
|
112
|
+
previous_truth_status: TruthStatusSchema,
|
|
113
|
+
rationale_id: z.string(),
|
|
114
|
+
}),
|
|
115
|
+
z.object({
|
|
116
|
+
kind: z.literal("no_op"),
|
|
117
|
+
subject: ReflectionSubjectSchema,
|
|
118
|
+
rationale_id: z.string(),
|
|
119
|
+
}),
|
|
120
|
+
])
|
|
121
|
+
export type ReflectionProposal = z.infer<typeof ReflectionProposalSchema>
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The payload of a `reflection.completed@1` event.
|
|
125
|
+
*
|
|
126
|
+
* Idempotence is by cursor: a pass over events with `seq` strictly
|
|
127
|
+
* greater than `cursor.from_seq` and less than or equal to
|
|
128
|
+
* `cursor.to_seq` produces the same proposals on re-run. Re-running
|
|
129
|
+
* is safe — proposals are typed, no state has been mutated, and the
|
|
130
|
+
* harness can compare proposal sets across runs.
|
|
131
|
+
*
|
|
132
|
+
* `observed_event_ids` lists every event the pass actually read.
|
|
133
|
+
* `proposals` is non-empty in v0 — a pass that found nothing to act
|
|
134
|
+
* on emits a `no_op` proposal for at least one subject it inspected,
|
|
135
|
+
* so the harness can distinguish "ran and silent" from "did not run."
|
|
136
|
+
*/
|
|
137
|
+
export const ReflectionCompletedPayloadSchema = z.object({
|
|
138
|
+
pass_id: z.string(),
|
|
139
|
+
triggered_by: ReflectionTriggerSchema,
|
|
140
|
+
cursor: z.object({
|
|
141
|
+
from_seq: z.number().int().min(-1).describe("-1 if this is the first pass for the partition"),
|
|
142
|
+
to_seq: z
|
|
143
|
+
.number()
|
|
144
|
+
.int()
|
|
145
|
+
.min(-1)
|
|
146
|
+
.describe(
|
|
147
|
+
"Equal to from_seq when the window is empty — the pass ran but observed no new events. " +
|
|
148
|
+
"Encoded explicitly (rather than skipping emission) so the audit chain can distinguish " +
|
|
149
|
+
"'reflection ran and was silent' from 'reflection did not run.'",
|
|
150
|
+
),
|
|
151
|
+
}),
|
|
152
|
+
observed_event_ids: z.array(z.string()),
|
|
153
|
+
proposals: z
|
|
154
|
+
.array(ReflectionProposalSchema)
|
|
155
|
+
.min(1, "every reflection pass emits at least one proposal (no_op counts)"),
|
|
156
|
+
started_at: TimestampSchema,
|
|
157
|
+
finished_at: TimestampSchema,
|
|
158
|
+
})
|
|
159
|
+
export type ReflectionCompletedPayload = z.infer<typeof ReflectionCompletedPayloadSchema>
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Event-type literal. Use this constant rather than the bare string
|
|
163
|
+
* so a future rename is grep-safe.
|
|
164
|
+
*/
|
|
165
|
+
export const REFLECTION_COMPLETED_EVENT_TYPE = "reflection.completed" as const
|
|
166
|
+
export const REFLECTION_COMPLETED_SCHEMA_VERSION = "1" as const
|
package/src/schemas/revision.ts
CHANGED
|
@@ -13,11 +13,13 @@ export const RevisionSchema = z.object({
|
|
|
13
13
|
id: z.string(),
|
|
14
14
|
target_type: z.enum(["claim", "belief", "decision"]),
|
|
15
15
|
target_id: z.string(),
|
|
16
|
-
changes: z.array(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
changes: z.array(
|
|
17
|
+
z.object({
|
|
18
|
+
field: z.string(),
|
|
19
|
+
old_value: z.unknown(),
|
|
20
|
+
new_value: z.unknown(),
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
21
23
|
triggered_by: z.string().describe("actor_id or event_id"),
|
|
22
24
|
rationale_id: z.string().describe("Explanation id"),
|
|
23
25
|
at: TimestampSchema,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { TimestampSchema } from "./common.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sentinel alerts — the wire format a Sentinel emits when it pattern-matches
|
|
6
|
+
* a suspicious shape in the event stream.
|
|
7
|
+
*
|
|
8
|
+
* Design lock: `docs/architecture/sentinels.md` (and Q7 of
|
|
9
|
+
* `docs/architecture/reflection-pass.md`, which settled the execution model).
|
|
10
|
+
* The short version:
|
|
11
|
+
*
|
|
12
|
+
* - Sentinels are an async tail of the event stream. They never block the
|
|
13
|
+
* Action Kernel; they emit `sentinel.alerted@1` events and a future
|
|
14
|
+
* (additive) `arbitrate` hook honours them on the *next* action that
|
|
15
|
+
* depends on a flagged subject.
|
|
16
|
+
* - This is a governance event, not an Observation. Like
|
|
17
|
+
* `reflection.completed@1`, the payload is the event payload directly and
|
|
18
|
+
* is NOT registered in the observation schema registry.
|
|
19
|
+
*
|
|
20
|
+
* Core owns the wire format only. The base class, the runner, and the
|
|
21
|
+
* concrete sentinels live in `@qmilab/lodestar-harness`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a sentinel alert is *about*. Kept deliberately small — the four
|
|
26
|
+
* epistemic-chain nouns a v0 sentinel can point at.
|
|
27
|
+
*
|
|
28
|
+
* `belief` is the load-bearing kind for the eventual kernel hook: the
|
|
29
|
+
* `arbitrate` lookup scopes recent alerts to a candidate action's
|
|
30
|
+
* `belief_dependencies`, so a sentinel that names a belief gates the next
|
|
31
|
+
* action that leans on it. `tool_sequence` is a synthetic subject — its
|
|
32
|
+
* `id` identifies the *completion* of a matched sequence (the id of the
|
|
33
|
+
* final action in the run), since no single chain-noun owns the pattern.
|
|
34
|
+
*/
|
|
35
|
+
export const SentinelSubjectSchema = z.object({
|
|
36
|
+
kind: z.enum(["belief", "action", "decision", "tool_sequence"]),
|
|
37
|
+
id: z.string().min(1),
|
|
38
|
+
})
|
|
39
|
+
export type SentinelSubject = z.infer<typeof SentinelSubjectSchema>
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Triage weight. `critical` is reserved for patterns that, left unflagged,
|
|
43
|
+
* map onto a concrete attack (e.g. the read → external-egress → write
|
|
44
|
+
* exfiltration shape). `warning` is the default for "under-supported but
|
|
45
|
+
* not obviously hostile". `info` is for advisory signal a calibrator may
|
|
46
|
+
* later promote or demote.
|
|
47
|
+
*/
|
|
48
|
+
export const SentinelSeveritySchema = z.enum(["info", "warning", "critical"])
|
|
49
|
+
export type SentinelSeverity = z.infer<typeof SentinelSeveritySchema>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The payload of a `sentinel.alerted@1` event.
|
|
53
|
+
*
|
|
54
|
+
* `observed_event_ids` lists exactly the events the sentinel read to reach
|
|
55
|
+
* this conclusion; they are also the alert envelope's `causal_parent_ids`,
|
|
56
|
+
* so `lodestar report` can walk back from an alert to the events that
|
|
57
|
+
* triggered it.
|
|
58
|
+
*
|
|
59
|
+
* `detail` is rule-specific structured context (offending belief ids, the
|
|
60
|
+
* matched tool run, the confidence that tripped the floor, …). It is a
|
|
61
|
+
* record rather than a discriminated union so a new sentinel can ship
|
|
62
|
+
* without a core schema bump; the human-readable `message` is always
|
|
63
|
+
* present so an alert is legible without decoding `detail`.
|
|
64
|
+
*
|
|
65
|
+
* Every field is required (no optionals besides `rationale_id`) so the
|
|
66
|
+
* event-log writer's canonical hash and `JSON.stringify` never disagree on
|
|
67
|
+
* a dropped `undefined` key — the same discipline the firewall audit
|
|
68
|
+
* events follow.
|
|
69
|
+
*/
|
|
70
|
+
export const SentinelAlertPayloadSchema = z.object({
|
|
71
|
+
alert_id: z.string().min(1),
|
|
72
|
+
sentinel_name: z.string().min(1).describe("Stable name of the emitting sentinel."),
|
|
73
|
+
rule: z
|
|
74
|
+
.string()
|
|
75
|
+
.min(1)
|
|
76
|
+
.describe("Stable id of the specific rule/pattern that fired within the sentinel."),
|
|
77
|
+
severity: SentinelSeveritySchema,
|
|
78
|
+
subject: SentinelSubjectSchema,
|
|
79
|
+
message: z.string().min(1).describe("Human-readable account of what tripped the sentinel."),
|
|
80
|
+
observed_event_ids: z
|
|
81
|
+
.array(z.string())
|
|
82
|
+
.min(1)
|
|
83
|
+
.describe("The events the sentinel read to reach this alert; also the alert's causal parents."),
|
|
84
|
+
detail: z
|
|
85
|
+
.record(z.string(), z.unknown())
|
|
86
|
+
.describe("Rule-specific structured context. May be empty; never undefined."),
|
|
87
|
+
detected_at: TimestampSchema,
|
|
88
|
+
/**
|
|
89
|
+
* Reserved. A sentinel may attach a generated Explanation id here, the
|
|
90
|
+
* way reflection proposals carry one. v0 sentinels rely on `message` +
|
|
91
|
+
* `detail` and leave this unset (omitted entirely, not `undefined`, so
|
|
92
|
+
* the canonical hash stays stable).
|
|
93
|
+
*/
|
|
94
|
+
rationale_id: z.string().optional(),
|
|
95
|
+
})
|
|
96
|
+
export type SentinelAlertPayload = z.infer<typeof SentinelAlertPayloadSchema>
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Event-type literal and version. Use the constants rather than the bare
|
|
100
|
+
* string so a future rename is grep-safe — same convention as
|
|
101
|
+
* `REFLECTION_COMPLETED_EVENT_TYPE`.
|
|
102
|
+
*/
|
|
103
|
+
export const SENTINEL_ALERTED_EVENT_TYPE = "sentinel.alerted" as const
|
|
104
|
+
export const SENTINEL_ALERTED_SCHEMA_VERSION = "1" as const
|