@mnmal-ai/cortext 0.0.0 → 1.0.0-alpha.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 +202 -0
- package/README.md +70 -1
- package/config.schema.json +62 -0
- package/default-config.json +10 -0
- package/dist/auth-policy.d.ts +44 -0
- package/dist/bootstrap.d.ts +36 -0
- package/dist/bootstrap.js +69 -0
- package/dist/compact.d.ts +37 -0
- package/dist/config.d.ts +40 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +5 -0
- package/dist/kernel/bootstrap.d.ts +11 -0
- package/dist/kernel/cortext-namespace.d.ts +96 -0
- package/dist/kernel/index.d.ts +15 -0
- package/dist/kernel/invariant-audit.d.ts +190 -0
- package/dist/kernel/migrations/0001_baseline.d.ts +11 -0
- package/dist/kernel/plugin.d.ts +29 -0
- package/dist/kernel/repair.d.ts +94 -0
- package/dist/kernel/rescope.d.ts +87 -0
- package/dist/kernel/substrate-contract.d.ts +19 -0
- package/dist/kernel/todo-vocab.d.ts +17 -0
- package/dist/kernel-bootstrap.js +21 -0
- package/dist/kernel-invariants.d.ts +62 -0
- package/dist/kernel.js +3 -0
- package/dist/mailbox.d.ts +80 -0
- package/dist/namespace.d.ts +159 -0
- package/dist/nl-rulings.d.ts +76 -0
- package/dist/onboard.d.ts +15 -0
- package/dist/participants.d.ts +32 -0
- package/dist/plugin-27UpMnoE.js +811 -0
- package/dist/release-surface.d.ts +206 -0
- package/dist/src-BKaGTnRw.js +1931 -0
- package/dist/todo-resolution.d.ts +18 -0
- package/kernel.config.schema.json +8 -0
- package/kernel.default-config.json +1 -0
- package/package.json +80 -4
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
//#region src/kernel/cortext-namespace.ts
|
|
2
|
+
/**
|
|
3
|
+
* The namespace the kernel invariants are scoped to.
|
|
4
|
+
*
|
|
5
|
+
* Every CHECK constraint and partial index this plugin installs carries a
|
|
6
|
+
* `namespace = <literal>` predicate, so that a kernel-wide table
|
|
7
|
+
* (`rows`) can hold rows for unrelated namespaces without inheriting
|
|
8
|
+
* cortext's controlled vocabularies. That literal has to live in exactly one
|
|
9
|
+
* place, because getting it wrong fails SILENTLY: a constraint scoped to a
|
|
10
|
+
* namespace with no rows is not an error, it is a constraint that passes
|
|
11
|
+
* trivially, and an index whose predicate no query can satisfy is not an
|
|
12
|
+
* error either — it is a heap scan.
|
|
13
|
+
*
|
|
14
|
+
* Tests that seed rows under the namespace the migrations name cannot catch a
|
|
15
|
+
* mismatch with the one production uses, so this constant is the one copy.
|
|
16
|
+
*
|
|
17
|
+
* THE CONFIGURABILITY GAP, and what is actually done about it.
|
|
18
|
+
*
|
|
19
|
+
* The coordinator's namespaces are operator-configurable, while
|
|
20
|
+
* KernelMigrationContext exposes only `sql` and `metric` — a migration
|
|
21
|
+
* cannot read config. So these constants track the DEFAULTS, and a deployment
|
|
22
|
+
* that renames a namespace would get no kernel invariants at all.
|
|
23
|
+
*
|
|
24
|
+
* Parameterising the literals does not fix that. Migrations are recorded once
|
|
25
|
+
* by `id` in migrations and `apply` runs only when the id is absent, so
|
|
26
|
+
* a later rename can never retroactively re-scope objects already created. It
|
|
27
|
+
* would produce correct FRESH installs and silently-wrong RENAMED ones, which
|
|
28
|
+
* is worse than today because it reads as solved.
|
|
29
|
+
*
|
|
30
|
+
* The fix is reconciliation against the configured namespace. `onKernelReady`
|
|
31
|
+
* carries a `sql` handle and plugin-attributed coordinator namespaces, so it
|
|
32
|
+
* is possible, but it is opt-in (`./repair.ts`): a store that ran unscoped may
|
|
33
|
+
* hold rows violating the vocabulary, so `ADD CONSTRAINT` can fail on exactly
|
|
34
|
+
* the databases a repair exists for.
|
|
35
|
+
*
|
|
36
|
+
* So what ships is REPORTING, not reconciliation, in two places that answer
|
|
37
|
+
* different questions: the kernel's `./invariant-audit.ts` reads the catalog
|
|
38
|
+
* at boot (objects absent, misscoped, or NOT VALID), and the coordinator's
|
|
39
|
+
* `onStart` compares the CONFIGURED namespaces against these constants. See
|
|
40
|
+
* `packages/cortext/src/kernel-invariants.ts`.
|
|
41
|
+
*
|
|
42
|
+
* It warns rather than REFUSES. Per-cohort context stores are a designed
|
|
43
|
+
* capability that `bootstrap-config.test.ts` exercises with `codex`, so the
|
|
44
|
+
* override keeps working; only the silence goes.
|
|
45
|
+
*/
|
|
46
|
+
const CORTEXT_NAMESPACE = "cortext";
|
|
47
|
+
/**
|
|
48
|
+
* The namespace the architecture-findings surface registers under, and which
|
|
49
|
+
* migration 0002 scopes its body normalization to.
|
|
50
|
+
*
|
|
51
|
+
* Separate from CORTEXT_NAMESPACE because the two have separate operator
|
|
52
|
+
* knobs and diverging lifecycle policies (compaction, retention, audit) — but
|
|
53
|
+
* subject to exactly the same gap, which is why it is stated here rather than
|
|
54
|
+
* left as a bare literal inside 0002.
|
|
55
|
+
*/
|
|
56
|
+
const ARCHITECTURE_NAMESPACE = "architecture";
|
|
57
|
+
/**
|
|
58
|
+
* The `name` the cortext COORDINATOR plugin registers itself under.
|
|
59
|
+
*
|
|
60
|
+
* Lives here, beside the namespace literals, because it is load-bearing for
|
|
61
|
+
* exactly the same reason they are. The kernel resolves
|
|
62
|
+
* which namespace to target by filtering
|
|
63
|
+
* `KernelLifecycleContext.coordinatorNamespacesByPlugin` on
|
|
64
|
+
* `plugin === CORTEXT_COORDINATOR_NAME` — so if the coordinator is ever
|
|
65
|
+
* renamed and this literal is not, the filter matches nothing and the audit
|
|
66
|
+
* reports "cortext declares no namespace" on a perfectly healthy deployment.
|
|
67
|
+
*
|
|
68
|
+
* A predicate that silently matches nothing is not an error, and a confidently wrong answer is
|
|
69
|
+
* worse here than no answer, because "coordinator absent" reads as a real
|
|
70
|
+
* finding rather than as a broken check.
|
|
71
|
+
*
|
|
72
|
+
* `kernel-namespace-parity.test.ts` asserts the coordinator's actual
|
|
73
|
+
* `plugin.name` equals this. Do not inline the string on either side.
|
|
74
|
+
*/
|
|
75
|
+
const CORTEXT_COORDINATOR_NAME = "cortext";
|
|
76
|
+
/**
|
|
77
|
+
* Body keys cortext's namespaces mirror into `rows` columns, so the
|
|
78
|
+
* cold-start and status filters hit an index. `0001_baseline` creates them.
|
|
79
|
+
*/
|
|
80
|
+
const CORTEXT_PROMOTED_COLUMNS = {
|
|
81
|
+
archived: {
|
|
82
|
+
column: "archived",
|
|
83
|
+
kind: "boolean"
|
|
84
|
+
},
|
|
85
|
+
project: {
|
|
86
|
+
column: "project",
|
|
87
|
+
kind: "text"
|
|
88
|
+
},
|
|
89
|
+
status: {
|
|
90
|
+
column: "status",
|
|
91
|
+
kind: "text"
|
|
92
|
+
},
|
|
93
|
+
priority: {
|
|
94
|
+
column: "priority",
|
|
95
|
+
kind: "text"
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/kernel/invariant-audit.ts
|
|
101
|
+
/**
|
|
102
|
+
* Boot-time audit of the kernel invariants this plugin installs.
|
|
103
|
+
*
|
|
104
|
+
* ## What this is, and what it deliberately is not
|
|
105
|
+
*
|
|
106
|
+
* It VERIFIES. It does not repair. Every migration here installs a CHECK
|
|
107
|
+
* constraint or a partial index carrying a `namespace = <literal>` predicate,
|
|
108
|
+
* and a migration is recorded once by `id` — `apply` runs only when that id is
|
|
109
|
+
* absent — so nothing re-examines those objects after the first boot that
|
|
110
|
+
* created them. Between then and now they can be dropped by hand, left NOT
|
|
111
|
+
* VALID by a failed validation, or point at a namespace no coordinator uses.
|
|
112
|
+
* All three fail the same way: writes succeed, queries answer, and the
|
|
113
|
+
* guarantee is simply gone.
|
|
114
|
+
*
|
|
115
|
+
* AgentIdentity.kid uniqueness is what the mailbox's recipient-only trust
|
|
116
|
+
* model rests on. A constraint scoped to a namespace with no rows is not an
|
|
117
|
+
* error; it passes trivially.
|
|
118
|
+
*
|
|
119
|
+
* ## Why this reports rather than repairs
|
|
120
|
+
*
|
|
121
|
+
* Not because repair is impossible — `./repair.ts` does it, using the `sql`
|
|
122
|
+
* handle and the plugin-attributed namespaces, the latter being what makes a
|
|
123
|
+
* rename TARGETABLE rather than merely detectable.
|
|
124
|
+
*
|
|
125
|
+
* The split is deliberate and permanent. Auditing is a read, so it runs on
|
|
126
|
+
* every boot. Repairing is DDL taking ACCESS EXCLUSIVE on `rows`, so it
|
|
127
|
+
* runs only when an operator asks via `SYNOPTIC_CORTEXT_REPAIR`. Keeping them
|
|
128
|
+
* apart means the reporting path can never surprise anyone, and the repair path
|
|
129
|
+
* has exactly one trigger to reason about.
|
|
130
|
+
*
|
|
131
|
+
* The flat `coordinatorNamespaces` this function reads answers "is our literal
|
|
132
|
+
* declared?" but never "what replaced it". `resolveCortextNamespaces` reads the
|
|
133
|
+
* attributed list — and keeps `undefined` from collapsing into `[]`, which would
|
|
134
|
+
* make "too old to answer" read as "answered: none".
|
|
135
|
+
*/
|
|
136
|
+
/**
|
|
137
|
+
* The five objects. Enumerated by hand rather than derived from the migration
|
|
138
|
+
* array, deliberately: a migration's `apply` is opaque SQL text, so anything
|
|
139
|
+
* "derived" would be a regex over it that agrees with reality only by luck.
|
|
140
|
+
* `invariant-audit.test.ts` greps the migration sources for the DDL and fails
|
|
141
|
+
* if this list and the migrations disagree — which is the check that actually
|
|
142
|
+
* holds them together.
|
|
143
|
+
*
|
|
144
|
+
* Note there is no `architecture/*` entry, and that is correct: migration 0002
|
|
145
|
+
* is a one-time body normalisation, not a standing invariant. A drifted
|
|
146
|
+
* architecture namespace means that normalisation never ran for those rows —
|
|
147
|
+
* real, but a different and lesser thing than "kid uniqueness is not held".
|
|
148
|
+
*/
|
|
149
|
+
const INVARIANT_OBJECTS = [
|
|
150
|
+
{
|
|
151
|
+
name: "rows_todo_status_check",
|
|
152
|
+
kind: "constraint",
|
|
153
|
+
migration: "0001_baseline",
|
|
154
|
+
guarantee: "Todo.status is restricted to its controlled vocabulary"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "rows_todo_priority_check",
|
|
158
|
+
kind: "constraint",
|
|
159
|
+
migration: "0001_baseline",
|
|
160
|
+
guarantee: "Todo.priority is restricted to its controlled vocabulary"
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "rows_agent_message_recipient_idx",
|
|
164
|
+
kind: "index",
|
|
165
|
+
migration: "0001_baseline",
|
|
166
|
+
guarantee: "mailbox inbox reads are index-backed rather than heap scans"
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "rows_agent_identity_kid_unique",
|
|
170
|
+
kind: "index",
|
|
171
|
+
migration: "0001_baseline",
|
|
172
|
+
guarantee: "AgentIdentity.kid is unique — the mailbox's recipient-only trust model rests on this"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "rows_agent_message_status_check",
|
|
176
|
+
kind: "constraint",
|
|
177
|
+
migration: "0001_baseline",
|
|
178
|
+
guarantee: "AgentMessage.status is restricted to its controlled vocabulary"
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "rows_host_install_key_unique",
|
|
182
|
+
kind: "index",
|
|
183
|
+
migration: "0001_baseline",
|
|
184
|
+
guarantee: "HostInstall.key is unique — one record per host, repo and harness, so the fleet view cannot show duplicates"
|
|
185
|
+
}
|
|
186
|
+
];
|
|
187
|
+
/**
|
|
188
|
+
* The namespaces the cortext COORDINATOR declared, or `null` when the host
|
|
189
|
+
* cannot say.
|
|
190
|
+
*
|
|
191
|
+
* The three states are genuinely distinct and the middle one is easy to lose:
|
|
192
|
+
*
|
|
193
|
+
* - `undefined` in -> `null` out. The host predates
|
|
194
|
+
* `coordinatorNamespacesByPlugin`. It is not telling us
|
|
195
|
+
* "none"; it is not telling us anything.
|
|
196
|
+
* - `[]` in, or no rows for our plugin -> `[]` out. The host DID attribute,
|
|
197
|
+
* and cortext contributed nothing — it is not loaded.
|
|
198
|
+
* - rows for our plugin -> those namespaces, in declaration order.
|
|
199
|
+
*
|
|
200
|
+
* The host deliberately does not default `undefined` to `[]`, which would make
|
|
201
|
+
* "too old to answer" read identically to "answered: none". Returning `null`
|
|
202
|
+
* rather than an empty array preserves that distinction.
|
|
203
|
+
*/
|
|
204
|
+
function resolveCortextNamespaces(byPlugin) {
|
|
205
|
+
if (byPlugin === void 0) return null;
|
|
206
|
+
return byPlugin.filter((p) => p.plugin === CORTEXT_COORDINATOR_NAME).map((p) => p.namespace);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Whether a coordinator declared the namespace our objects are scoped to, and
|
|
210
|
+
* — when the host attributes — which namespace replaced it.
|
|
211
|
+
*
|
|
212
|
+
* Pure, and the only half that works without a `sql` handle, so it is what runs
|
|
213
|
+
* against the in-memory dataset.
|
|
214
|
+
*
|
|
215
|
+
* Only the context-store literal is checked. `ARCHITECTURE_NAMESPACE` is
|
|
216
|
+
* exported for callers that want it but is not audited here, for the reason in
|
|
217
|
+
* `INVARIANT_OBJECTS`: no catalog object is scoped to it.
|
|
218
|
+
*/
|
|
219
|
+
function auditNamespaceCoverage(coordinatorNamespaces, byPlugin) {
|
|
220
|
+
if (coordinatorNamespaces.includes("cortext")) return [];
|
|
221
|
+
const ours = resolveCortextNamespaces(byPlugin);
|
|
222
|
+
const configured = ours?.[0];
|
|
223
|
+
if (configured !== void 0) return [{
|
|
224
|
+
kind: "namespace_drifted",
|
|
225
|
+
scopedTo: CORTEXT_NAMESPACE,
|
|
226
|
+
configured
|
|
227
|
+
}];
|
|
228
|
+
return [{
|
|
229
|
+
kind: "namespace_uncovered",
|
|
230
|
+
scopedTo: CORTEXT_NAMESPACE,
|
|
231
|
+
declared: coordinatorNamespaces,
|
|
232
|
+
reason: ours === null ? "host_does_not_attribute" : "coordinator_absent"
|
|
233
|
+
}];
|
|
234
|
+
}
|
|
235
|
+
/** The namespace a predicate is scoped to, or null if it names none. */
|
|
236
|
+
function scopedNamespace(definition) {
|
|
237
|
+
return definition.match(/namespace = '([^']*)'(?:::text)?/)?.[1] ?? null;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Read the catalog state of every object on `rows`.
|
|
241
|
+
*
|
|
242
|
+
* Both lookups are RELATION-scoped (`conrelid` / `indrelid` against
|
|
243
|
+
* `'rows'::regclass`) and not by name alone. Constraint names are unique
|
|
244
|
+
* per relation and index names per schema — neither is globally unique — so a
|
|
245
|
+
* by-name lookup silently reads an unrelated object the moment a second schema
|
|
246
|
+
* carries the same name.
|
|
247
|
+
*/
|
|
248
|
+
async function readCatalog(sql) {
|
|
249
|
+
const rows = /* @__PURE__ */ new Map();
|
|
250
|
+
const [present] = await sql`SELECT to_regclass('rows') IS NOT NULL AS ok`;
|
|
251
|
+
if (!present?.ok) return rows;
|
|
252
|
+
const constraints = await sql`
|
|
253
|
+
SELECT conname AS name, pg_get_constraintdef(oid) AS definition, convalidated AS validated
|
|
254
|
+
FROM pg_constraint
|
|
255
|
+
WHERE conrelid = 'rows'::regclass
|
|
256
|
+
`;
|
|
257
|
+
for (const r of constraints) rows.set(r.name, r);
|
|
258
|
+
const indexes = await sql`
|
|
259
|
+
SELECT c.relname AS name, pg_get_indexdef(i.indexrelid) AS definition, i.indisvalid AS validated
|
|
260
|
+
FROM pg_index i
|
|
261
|
+
JOIN pg_class c ON c.oid = i.indexrelid
|
|
262
|
+
WHERE i.indrelid = 'rows'::regclass
|
|
263
|
+
`;
|
|
264
|
+
for (const r of indexes) rows.set(r.name, r);
|
|
265
|
+
return rows;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Audit every object's presence, scope and validity.
|
|
269
|
+
*
|
|
270
|
+
* `sql` is typed `unknown` on the lifecycle context (substrate keeps the driver out
|
|
271
|
+
* of its type surface), so the cast happens here, once, exactly as the
|
|
272
|
+
* migrations do it.
|
|
273
|
+
*/
|
|
274
|
+
/**
|
|
275
|
+
* Audit the invariant objects.
|
|
276
|
+
*
|
|
277
|
+
* Returns the INSPECTED COUNT alongside the findings.
|
|
278
|
+
*
|
|
279
|
+
* A tally rather than `INVARIANT_OBJECTS.length`, so that if this loop ever
|
|
280
|
+
* gains a filter, an early return or a per-object skip, the completion metric
|
|
281
|
+
* reports what was actually inspected instead of a promise.
|
|
282
|
+
*/
|
|
283
|
+
async function auditObjects(sql) {
|
|
284
|
+
const catalog = await readCatalog(sql);
|
|
285
|
+
const findings = [];
|
|
286
|
+
let checked = 0;
|
|
287
|
+
for (const object of INVARIANT_OBJECTS) {
|
|
288
|
+
checked++;
|
|
289
|
+
const row = catalog.get(object.name);
|
|
290
|
+
if (!row) {
|
|
291
|
+
findings.push({
|
|
292
|
+
kind: "object_missing",
|
|
293
|
+
object
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const scope = scopedNamespace(row.definition);
|
|
298
|
+
if (scope !== "cortext") {
|
|
299
|
+
findings.push({
|
|
300
|
+
kind: "object_misscoped",
|
|
301
|
+
object,
|
|
302
|
+
scopedTo: scope
|
|
303
|
+
});
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!row.validated) findings.push({
|
|
307
|
+
kind: "object_not_validated",
|
|
308
|
+
object
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
findings,
|
|
313
|
+
checked
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** The operator-facing sentence for one finding. */
|
|
317
|
+
function findingMessage(finding) {
|
|
318
|
+
const head = "[cortext-kernel]";
|
|
319
|
+
switch (finding.kind) {
|
|
320
|
+
case "namespace_drifted": return `${head} the cortext coordinator is configured for namespace '${finding.configured}', but every kernel constraint and index is scoped to '${finding.scopedTo}'. So all of them are INACTIVE for '${finding.configured}': Todo/AgentMessage vocabularies unenforced, the mailbox inbox index covering no rows, and AgentIdentity.kid uniqueness NOT held. Writes succeed and queries answer, so nothing else will report this. Use '${finding.scopedTo}', or accept the loss deliberately.`;
|
|
321
|
+
case "namespace_uncovered": return `${head} invariants are scoped to namespace '${finding.scopedTo}', which no loaded coordinator declares (declared: ${finding.declared.join(", ") || "none"}). Every kernel guarantee below is therefore INACTIVE, and nothing else will report it — a constraint scoped to a namespace with no rows passes trivially. ` + (finding.reason === "host_does_not_attribute" ? "This substrate does not attribute namespaces to their plugin (added in 5.1.0), so which namespace REPLACED it cannot be determined — upgrade the binary to get a named target rather than this list." : "This substrate does attribute, and the cortext coordinator declared no namespace at all — it is not loaded. The objects are orphaned rather than misscoped.") + " Set SYNOPTIC_CORTEXT_REPAIR=1 for one restart to re-scope.";
|
|
322
|
+
case "object_missing": return `${head} ${finding.object.kind} '${finding.object.name}' (${finding.object.migration}) is ABSENT. Lost guarantee: ${finding.object.guarantee}. Migrations are recorded once by id, so this will NOT be reinstalled by a restart.`;
|
|
323
|
+
case "object_misscoped": return `${head} ${finding.object.kind} '${finding.object.name}' is scoped to ${finding.scopedTo === null ? "no namespace" : `'${finding.scopedTo}'`}, not '${CORTEXT_NAMESPACE}'. Lost guarantee: ${finding.object.guarantee}. It exists, so nothing errors; it simply matches no rows.`;
|
|
324
|
+
case "object_not_validated": return `${head} ${finding.object.kind} '${finding.object.name}' exists but is NOT VALID — enforced for new writes only, and existing rows were never checked against it. Guarantee held only going forward: ${finding.object.guarantee}.`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Run the audit and report. Returns the findings so a caller — or a test — can
|
|
329
|
+
* assert on them without capturing the console.
|
|
330
|
+
*
|
|
331
|
+
* Both sinks: `metric` is the structured
|
|
332
|
+
* channel an operator alerts on, and the console line is what actually appears
|
|
333
|
+
* in the server journal beside substrate's own loader warnings, which is
|
|
334
|
+
* where someone will see it.
|
|
335
|
+
*
|
|
336
|
+
* Never throws. A kernel plugin that aborts the boot over an OBSERVATION
|
|
337
|
+
* would take the service down for a condition the service has been running
|
|
338
|
+
* with, possibly for weeks — the whole point is that these failures are silent
|
|
339
|
+
* and survivable. A catalog read that itself fails is reported and swallowed.
|
|
340
|
+
*
|
|
341
|
+
* ## It always emits, including when it finds nothing
|
|
342
|
+
*
|
|
343
|
+
* Every OTHER metric here names a failure, so without a completion signal a
|
|
344
|
+
* clean audit and an audit that never ran would both be silence. A check whose
|
|
345
|
+
* PASS state is indistinguishable from its absence is not falsifiable.
|
|
346
|
+
*
|
|
347
|
+
* So `invariant_audit_complete` fires on every path that reaches the end,
|
|
348
|
+
* carrying `checked`. Silence now means the audit DID NOT RUN and is
|
|
349
|
+
* actionable, and a `checked` that drops from 5 to 4 is visible without
|
|
350
|
+
* anything having failed. Metric only, not a console line: a per-boot success
|
|
351
|
+
* message on every deployment is noise, and the metric sink is exactly the
|
|
352
|
+
* structured channel this belongs in.
|
|
353
|
+
*/
|
|
354
|
+
async function reportInvariantAudit(ctx, warn = console.warn) {
|
|
355
|
+
const findings = [...auditNamespaceCoverage(ctx.coordinatorNamespaces, ctx.coordinatorNamespacesByPlugin)];
|
|
356
|
+
const catalogRead = ctx.sql !== null && ctx.sql !== void 0;
|
|
357
|
+
let objectsChecked = 0;
|
|
358
|
+
if (catalogRead) try {
|
|
359
|
+
const result = await auditObjects(ctx.sql);
|
|
360
|
+
findings.push(...result.findings);
|
|
361
|
+
objectsChecked = result.checked;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
364
|
+
ctx.metric("cortext_kernel.invariant_audit_failed", { error: message });
|
|
365
|
+
warn(`[cortext-kernel] could not audit kernel invariants: ${message}. This is the audit failing, NOT the invariants — their state is now unknown.`);
|
|
366
|
+
return findings;
|
|
367
|
+
}
|
|
368
|
+
for (const finding of findings) {
|
|
369
|
+
ctx.metric("cortext_kernel.invariant_inactive", {
|
|
370
|
+
finding: finding.kind,
|
|
371
|
+
object: "object" in finding ? finding.object.name : void 0,
|
|
372
|
+
scopedTo: "scopedTo" in finding ? finding.scopedTo : void 0,
|
|
373
|
+
configured: "configured" in finding ? finding.configured : void 0,
|
|
374
|
+
reason: "reason" in finding ? finding.reason : void 0
|
|
375
|
+
});
|
|
376
|
+
warn(findingMessage(finding));
|
|
377
|
+
}
|
|
378
|
+
ctx.metric("cortext_kernel.invariant_audit_complete", {
|
|
379
|
+
checked: objectsChecked,
|
|
380
|
+
findings: findings.length,
|
|
381
|
+
catalogRead
|
|
382
|
+
});
|
|
383
|
+
return findings;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/kernel/todo-vocab.ts
|
|
388
|
+
/**
|
|
389
|
+
* Todo lifecycle and outcome, split. A single `status` doing both jobs makes
|
|
390
|
+
* `done` vs `closed` a grey distinction nobody can apply consistently.
|
|
391
|
+
*
|
|
392
|
+
* LIFECYCLE (`status`): is it live? OUTCOME (`resolution`): why did it stop?
|
|
393
|
+
* The same split GitHub (state + state_reason) and Jira (status + resolution)
|
|
394
|
+
* make.
|
|
395
|
+
*
|
|
396
|
+
* The pg CHECK admits exactly the lifecycle set; `done` / `not_started` are
|
|
397
|
+
* aliases the schema canonicalises on write.
|
|
398
|
+
*/
|
|
399
|
+
const TODO_LIFECYCLE_VOCAB = [
|
|
400
|
+
"open",
|
|
401
|
+
"in_progress",
|
|
402
|
+
"blocked",
|
|
403
|
+
"deferred",
|
|
404
|
+
"closed"
|
|
405
|
+
];
|
|
406
|
+
/** Why a closed Todo stopped. `unspecified` is for migrated rows with no clear cue. */
|
|
407
|
+
const TODO_RESOLUTION_VOCAB = [
|
|
408
|
+
"completed",
|
|
409
|
+
"wont_do",
|
|
410
|
+
"superseded",
|
|
411
|
+
"duplicate",
|
|
412
|
+
"obsolete",
|
|
413
|
+
"unspecified"
|
|
414
|
+
];
|
|
415
|
+
const TODO_PRIORITY_VOCAB = [
|
|
416
|
+
"low",
|
|
417
|
+
"medium",
|
|
418
|
+
"high"
|
|
419
|
+
];
|
|
420
|
+
/** AgentMessage delivery state; the pg CHECK and the field enum share it. */
|
|
421
|
+
const AGENT_MESSAGE_STATUS_VOCAB = [
|
|
422
|
+
"unread",
|
|
423
|
+
"read",
|
|
424
|
+
"acked"
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/kernel/rescope.ts
|
|
429
|
+
const literal = (vocab) => vocab.map((v) => `'${v}'`).join(",");
|
|
430
|
+
const VOCAB_CONSTRAINTS = [
|
|
431
|
+
{
|
|
432
|
+
constraint: "rows_todo_priority_check",
|
|
433
|
+
type: "Todo",
|
|
434
|
+
column: "priority",
|
|
435
|
+
vocab: TODO_PRIORITY_VOCAB
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
constraint: "rows_todo_status_check",
|
|
439
|
+
type: "Todo",
|
|
440
|
+
column: "status",
|
|
441
|
+
vocab: TODO_LIFECYCLE_VOCAB
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
constraint: "rows_agent_message_status_check",
|
|
445
|
+
type: "AgentMessage",
|
|
446
|
+
column: "status",
|
|
447
|
+
vocab: AGENT_MESSAGE_STATUS_VOCAB
|
|
448
|
+
}
|
|
449
|
+
];
|
|
450
|
+
/**
|
|
451
|
+
* Re-scope one vocabulary CHECK to `namespace`.
|
|
452
|
+
*
|
|
453
|
+
* Returns the number of existing rows that violate it — 0 means VALIDATE
|
|
454
|
+
* succeeded and the constraint is fully enforced; >0 means it was left NOT
|
|
455
|
+
* VALID (enforcing new writes only) and an operator has rows to reconcile.
|
|
456
|
+
*
|
|
457
|
+
* The NOT VALID -> validate-inside-a-handler shape is what makes this safe to
|
|
458
|
+
* run against a store that has been writing UNCONSTRAINED. A plain
|
|
459
|
+
* `ADD CONSTRAINT ... CHECK` validates immediately and therefore fails on
|
|
460
|
+
* exactly the databases a repair exists for — the ones that drifted long
|
|
461
|
+
* enough to accumulate bad rows.
|
|
462
|
+
*/
|
|
463
|
+
async function rescopeVocabConstraint(s, metric, { constraint, type, column, vocab }, namespace) {
|
|
464
|
+
const values = literal(vocab);
|
|
465
|
+
const [violation] = await s.unsafe(`
|
|
466
|
+
SELECT count(*)::int AS count FROM rows
|
|
467
|
+
WHERE namespace = '${namespace}'
|
|
468
|
+
AND type = '${type}'
|
|
469
|
+
AND ${column} IS NOT NULL
|
|
470
|
+
AND ${column} NOT IN (${values})
|
|
471
|
+
`);
|
|
472
|
+
const violations = violation?.count ?? 0;
|
|
473
|
+
await s.unsafe(`ALTER TABLE rows DROP CONSTRAINT IF EXISTS ${constraint}`);
|
|
474
|
+
await s.unsafe(`
|
|
475
|
+
ALTER TABLE rows
|
|
476
|
+
ADD CONSTRAINT ${constraint}
|
|
477
|
+
CHECK (
|
|
478
|
+
NOT (namespace = '${namespace}' AND type = '${type}')
|
|
479
|
+
OR ${column} IS NULL
|
|
480
|
+
OR ${column} IN (${values})
|
|
481
|
+
) NOT VALID
|
|
482
|
+
`);
|
|
483
|
+
await s.unsafe(`
|
|
484
|
+
DO $$ BEGIN
|
|
485
|
+
IF EXISTS (
|
|
486
|
+
SELECT 1 FROM pg_constraint
|
|
487
|
+
WHERE conname = '${constraint}'
|
|
488
|
+
AND conrelid = 'rows'::regclass AND NOT convalidated
|
|
489
|
+
) THEN
|
|
490
|
+
BEGIN
|
|
491
|
+
ALTER TABLE rows VALIDATE CONSTRAINT ${constraint};
|
|
492
|
+
EXCEPTION WHEN check_violation THEN
|
|
493
|
+
RAISE WARNING 'cortext-kernel: % left NOT VALID — existing rows violate it; new writes are still enforced', '${constraint}';
|
|
494
|
+
END;
|
|
495
|
+
END IF;
|
|
496
|
+
END $$
|
|
497
|
+
`);
|
|
498
|
+
if (violations > 0) metric("cortext_kernel.rescope.constraint_not_validated", {
|
|
499
|
+
constraint,
|
|
500
|
+
type,
|
|
501
|
+
column,
|
|
502
|
+
namespace,
|
|
503
|
+
violations
|
|
504
|
+
});
|
|
505
|
+
return violations;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Re-scope the mailbox inbox index (0003) to `namespace`.
|
|
509
|
+
*
|
|
510
|
+
* Dropped and rebuilt rather than left alongside a correctly-scoped sibling:
|
|
511
|
+
* two partial indexes over the same expression is one the planner can never
|
|
512
|
+
* use, plus write amplification on every AgentMessage insert.
|
|
513
|
+
*/
|
|
514
|
+
async function rescopeRecipientIndex(s, namespace) {
|
|
515
|
+
await s.unsafe("DROP INDEX IF EXISTS rows_agent_message_recipient_idx");
|
|
516
|
+
await s.unsafe(`
|
|
517
|
+
CREATE INDEX IF NOT EXISTS rows_agent_message_recipient_idx
|
|
518
|
+
ON rows ((body->>'recipient'), status)
|
|
519
|
+
WHERE namespace = '${namespace}'
|
|
520
|
+
AND type = 'AgentMessage'
|
|
521
|
+
AND deleted_at IS NULL
|
|
522
|
+
AND archived IS NOT TRUE
|
|
523
|
+
`);
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* How many kids would collide if the unique index were scoped to `namespace`.
|
|
527
|
+
*
|
|
528
|
+
* Exposed separately because a UNIQUE index has no `NOT VALID` equivalent —
|
|
529
|
+
* there is no "enforce for new writes only" state for an index. `CREATE UNIQUE
|
|
530
|
+
* INDEX` either succeeds over the whole predicate or fails outright, so the
|
|
531
|
+
* only way to avoid failing is to ask first. That asymmetry with the CHECK
|
|
532
|
+
* constraints is why repair treats the two differently.
|
|
533
|
+
*
|
|
534
|
+
* The predicate matches `rescopeKidUniqueIndex`'s exactly, including the
|
|
535
|
+
* tombstone exemption — an archived duplicate is still a conflict, and hard-
|
|
536
|
+
* deleting a retired identity frees its kid.
|
|
537
|
+
*/
|
|
538
|
+
async function countDuplicateKids(s, namespace) {
|
|
539
|
+
const [row] = await s.unsafe(`
|
|
540
|
+
SELECT count(*)::int AS count FROM (
|
|
541
|
+
SELECT body->>'kid' AS kid
|
|
542
|
+
FROM rows
|
|
543
|
+
WHERE namespace = '${namespace}'
|
|
544
|
+
AND type = 'AgentIdentity'
|
|
545
|
+
AND deleted_at IS NULL
|
|
546
|
+
GROUP BY body->>'kid'
|
|
547
|
+
HAVING count(*) > 1
|
|
548
|
+
) dupes
|
|
549
|
+
`);
|
|
550
|
+
return row?.count ?? 0;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Re-scope the AgentIdentity.kid unique index (0004) to `namespace`.
|
|
554
|
+
*
|
|
555
|
+
* THROWS on duplicate kids, and that is deliberate — it preserves 0004's
|
|
556
|
+
* fail-loud stance, because recipient resolution and the mailbox's
|
|
557
|
+
* recipient-only enforcement are both ambiguous until an operator decides
|
|
558
|
+
* which row owns the identity. Callers that must not abort should call
|
|
559
|
+
* `countDuplicateKids` first; the repair path does.
|
|
560
|
+
*/
|
|
561
|
+
async function rescopeKidUniqueIndex(s, namespace) {
|
|
562
|
+
await s.unsafe("DROP INDEX IF EXISTS rows_agent_identity_kid_unique");
|
|
563
|
+
await s.unsafe(`
|
|
564
|
+
CREATE UNIQUE INDEX IF NOT EXISTS rows_agent_identity_kid_unique
|
|
565
|
+
ON rows ((body->>'kid'))
|
|
566
|
+
WHERE namespace = '${namespace}'
|
|
567
|
+
AND type = 'AgentIdentity'
|
|
568
|
+
AND deleted_at IS NULL
|
|
569
|
+
`);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* How many HostInstall keys would collide if the unique index were scoped to
|
|
573
|
+
* `namespace`. Asked first for the same reason as `countDuplicateKids`.
|
|
574
|
+
*/
|
|
575
|
+
async function countDuplicateHostInstallKeys(s, namespace) {
|
|
576
|
+
const [row] = await s.unsafe(`
|
|
577
|
+
SELECT count(*)::int AS count FROM (
|
|
578
|
+
SELECT body->>'key' AS key
|
|
579
|
+
FROM rows
|
|
580
|
+
WHERE namespace = '${namespace}'
|
|
581
|
+
AND type = 'HostInstall'
|
|
582
|
+
AND deleted_at IS NULL
|
|
583
|
+
GROUP BY body->>'key'
|
|
584
|
+
HAVING count(*) > 1
|
|
585
|
+
) dupes
|
|
586
|
+
`);
|
|
587
|
+
return row?.count ?? 0;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Re-scope the HostInstall.key unique index (0008) to `namespace`. Throws on
|
|
591
|
+
* duplicate keys; callers that must not abort call
|
|
592
|
+
* `countDuplicateHostInstallKeys` first.
|
|
593
|
+
*/
|
|
594
|
+
async function rescopeHostInstallKeyIndex(s, namespace) {
|
|
595
|
+
await s.unsafe("DROP INDEX IF EXISTS rows_host_install_key_unique");
|
|
596
|
+
await s.unsafe(`
|
|
597
|
+
CREATE UNIQUE INDEX IF NOT EXISTS rows_host_install_key_unique
|
|
598
|
+
ON rows ((body->>'key'))
|
|
599
|
+
WHERE namespace = '${namespace}'
|
|
600
|
+
AND type = 'HostInstall'
|
|
601
|
+
AND deleted_at IS NULL
|
|
602
|
+
`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
//#endregion
|
|
606
|
+
//#region src/kernel/migrations/0001_baseline.ts
|
|
607
|
+
const cortextBaselineMigration = {
|
|
608
|
+
id: "0001_baseline",
|
|
609
|
+
apply: async ({ sql, metric }) => {
|
|
610
|
+
if (sql === null) return;
|
|
611
|
+
const s = sql;
|
|
612
|
+
await s.unsafe(`
|
|
613
|
+
ALTER TABLE rows
|
|
614
|
+
ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false,
|
|
615
|
+
ADD COLUMN IF NOT EXISTS project text NULL,
|
|
616
|
+
ADD COLUMN IF NOT EXISTS status text NULL,
|
|
617
|
+
ADD COLUMN IF NOT EXISTS priority text NULL
|
|
618
|
+
`);
|
|
619
|
+
for (const column of [
|
|
620
|
+
"archived",
|
|
621
|
+
"project",
|
|
622
|
+
"status",
|
|
623
|
+
"priority"
|
|
624
|
+
]) await s.unsafe(`
|
|
625
|
+
CREATE INDEX IF NOT EXISTS rows_ns_type_${column}_idx
|
|
626
|
+
ON rows (namespace, type, ${column}) WHERE deleted_at IS NULL
|
|
627
|
+
`);
|
|
628
|
+
await s.unsafe(`
|
|
629
|
+
CREATE INDEX IF NOT EXISTS rows_cold_start_idx
|
|
630
|
+
ON rows (namespace, type, project)
|
|
631
|
+
WHERE deleted_at IS NULL AND archived IS NOT TRUE
|
|
632
|
+
`);
|
|
633
|
+
for (const spec of VOCAB_CONSTRAINTS) await rescopeVocabConstraint(s, metric, spec, CORTEXT_NAMESPACE);
|
|
634
|
+
await rescopeRecipientIndex(s, CORTEXT_NAMESPACE);
|
|
635
|
+
await rescopeKidUniqueIndex(s, CORTEXT_NAMESPACE);
|
|
636
|
+
await rescopeHostInstallKeyIndex(s, CORTEXT_NAMESPACE);
|
|
637
|
+
},
|
|
638
|
+
rollback: async ({ sql }) => {
|
|
639
|
+
if (sql === null) return;
|
|
640
|
+
const s = sql;
|
|
641
|
+
for (const { constraint } of VOCAB_CONSTRAINTS) await s.unsafe(`ALTER TABLE rows DROP CONSTRAINT IF EXISTS ${constraint}`);
|
|
642
|
+
await s.unsafe("DROP INDEX IF EXISTS rows_agent_identity_kid_unique, rows_host_install_key_unique");
|
|
643
|
+
await s.unsafe("ALTER TABLE rows DROP COLUMN IF EXISTS archived, DROP COLUMN IF EXISTS project, DROP COLUMN IF EXISTS status, DROP COLUMN IF EXISTS priority");
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region src/kernel/repair.ts
|
|
649
|
+
/** The env var that opts a deployment in, for one restart. */
|
|
650
|
+
const REPAIR_ENV_VAR = "SYNOPTIC_CORTEXT_REPAIR";
|
|
651
|
+
/**
|
|
652
|
+
* Whether repair was asked for.
|
|
653
|
+
*
|
|
654
|
+
* Accepts only the affirmative spellings an operator would plausibly type, and
|
|
655
|
+
* treats everything else — including `0` and `false` — as off. Deliberately not
|
|
656
|
+
* a truthiness check on the raw string: `SYNOPTIC_CORTEXT_REPAIR=false` is
|
|
657
|
+
* unambiguously a request NOT to repair, and a bare presence check would read
|
|
658
|
+
* it as a yes and re-scope a production schema.
|
|
659
|
+
*/
|
|
660
|
+
function repairRequested(env) {
|
|
661
|
+
const raw = env[REPAIR_ENV_VAR]?.trim().toLowerCase();
|
|
662
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Re-scope every invariant object to `namespace`.
|
|
666
|
+
*
|
|
667
|
+
* Constraints first, then indexes — the constraints are
|
|
668
|
+
* the correctness-bearing half and should land even if an index cannot.
|
|
669
|
+
*/
|
|
670
|
+
async function repairInvariants(sql, metric, namespace) {
|
|
671
|
+
const s = sql;
|
|
672
|
+
const outcomes = [];
|
|
673
|
+
for (const spec of VOCAB_CONSTRAINTS) {
|
|
674
|
+
const violations = await rescopeVocabConstraint(s, metric, spec, namespace);
|
|
675
|
+
outcomes.push(violations > 0 ? {
|
|
676
|
+
kind: "repaired_not_validated",
|
|
677
|
+
object: spec.constraint,
|
|
678
|
+
violations
|
|
679
|
+
} : {
|
|
680
|
+
kind: "repaired",
|
|
681
|
+
object: spec.constraint
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
await rescopeRecipientIndex(s, namespace);
|
|
685
|
+
outcomes.push({
|
|
686
|
+
kind: "repaired",
|
|
687
|
+
object: "rows_agent_message_recipient_idx"
|
|
688
|
+
});
|
|
689
|
+
const duplicates = await countDuplicateKids(s, namespace);
|
|
690
|
+
if (duplicates > 0) outcomes.push({
|
|
691
|
+
kind: "skipped",
|
|
692
|
+
object: "rows_agent_identity_kid_unique",
|
|
693
|
+
reason: `${duplicates} kid(s) are held by more than one live AgentIdentity in '${namespace}', so a unique index cannot be built. Recipient resolution is ambiguous until an operator decides which row owns each identity — resolve the duplicates, then re-run repair. The existing index is left exactly as it was.`
|
|
694
|
+
});
|
|
695
|
+
else {
|
|
696
|
+
await rescopeKidUniqueIndex(s, namespace);
|
|
697
|
+
outcomes.push({
|
|
698
|
+
kind: "repaired",
|
|
699
|
+
object: "rows_agent_identity_kid_unique"
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
const duplicateKeys = await countDuplicateHostInstallKeys(s, namespace);
|
|
703
|
+
if (duplicateKeys > 0) outcomes.push({
|
|
704
|
+
kind: "skipped",
|
|
705
|
+
object: "rows_host_install_key_unique",
|
|
706
|
+
reason: `${duplicateKeys} HostInstall key(s) are held by more than one live row in '${namespace}', so a unique index cannot be built. Delete the older row for each key, then re-run repair. The existing index is left exactly as it was.`
|
|
707
|
+
});
|
|
708
|
+
else {
|
|
709
|
+
await rescopeHostInstallKeyIndex(s, namespace);
|
|
710
|
+
outcomes.push({
|
|
711
|
+
kind: "repaired",
|
|
712
|
+
object: "rows_host_install_key_unique"
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return outcomes;
|
|
716
|
+
}
|
|
717
|
+
/** The operator-facing sentence for one outcome. */
|
|
718
|
+
function repairOutcomeMessage(outcome, namespace) {
|
|
719
|
+
const head = "[cortext-kernel]";
|
|
720
|
+
switch (outcome.kind) {
|
|
721
|
+
case "repaired": return `${head} re-scoped '${outcome.object}' to namespace '${namespace}'.`;
|
|
722
|
+
case "repaired_not_validated": return `${head} re-scoped '${outcome.object}' to '${namespace}', but left it NOT VALID: ${outcome.violations} existing row(s) violate it. New writes ARE enforced; the existing rows are not, and will not be until they are corrected and the constraint validated.`;
|
|
723
|
+
case "skipped": return `${head} could NOT repair '${outcome.object}'. ${outcome.reason}`;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Run repair if asked, and report. Returns null when it did not run, so a
|
|
728
|
+
* caller can tell "declined" from "ran and found nothing to do".
|
|
729
|
+
*
|
|
730
|
+
* Never throws, for the same reason the audit does not: this is invoked during
|
|
731
|
+
* boot, and a failure here must degrade to a report rather than take down a
|
|
732
|
+
* service that was running fine — badly, but fine — a moment earlier.
|
|
733
|
+
*/
|
|
734
|
+
async function repairIfRequested(ctx, warn = console.warn) {
|
|
735
|
+
if (!repairRequested(ctx.env)) return null;
|
|
736
|
+
if (ctx.sql === null || ctx.sql === void 0) {
|
|
737
|
+
warn(`[cortext-kernel] ${REPAIR_ENV_VAR} is set, but this binary has no SQL handle (in-memory dataset). Nothing to repair.`);
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
if (ctx.namespace === null) {
|
|
741
|
+
warn(`[cortext-kernel] ${REPAIR_ENV_VAR} is set, but the target namespace cannot be determined: this substrate does not attribute namespaces to their plugin (added in 5.1.0), or the cortext coordinator is not loaded. Repair DECLINED — it will not guess which namespace to re-scope to. Upgrade the binary, or run against a host that reports coordinatorNamespacesByPlugin.`);
|
|
742
|
+
ctx.metric("cortext_kernel.repair_declined", { reason: "target_unknown" });
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
warn(`[cortext-kernel] ${REPAIR_ENV_VAR} is set — re-scoping kernel invariants to '${ctx.namespace}'. This takes ACCESS EXCLUSIVE on rows and briefly blocks writes.` + (ctx.namespace === "cortext" ? " Target matches the built-in scope literal, so this is a no-op rebuild." : ""));
|
|
746
|
+
try {
|
|
747
|
+
const outcomes = await repairInvariants(ctx.sql, ctx.metric, ctx.namespace);
|
|
748
|
+
for (const outcome of outcomes) {
|
|
749
|
+
ctx.metric("cortext_kernel.repair_outcome", {
|
|
750
|
+
outcome: outcome.kind,
|
|
751
|
+
object: outcome.object,
|
|
752
|
+
namespace: ctx.namespace
|
|
753
|
+
});
|
|
754
|
+
warn(repairOutcomeMessage(outcome, ctx.namespace));
|
|
755
|
+
}
|
|
756
|
+
warn(`[cortext-kernel] repair complete. Unset ${REPAIR_ENV_VAR} — leaving it set re-runs this DDL on every boot, which is the automatic behaviour opt-in exists to avoid.`);
|
|
757
|
+
return outcomes;
|
|
758
|
+
} catch (error) {
|
|
759
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
760
|
+
ctx.metric("cortext_kernel.repair_failed", {
|
|
761
|
+
error: message,
|
|
762
|
+
namespace: ctx.namespace
|
|
763
|
+
});
|
|
764
|
+
warn(`[cortext-kernel] repair FAILED: ${message}. Some objects may have been re-scoped and others not — re-run the audit (restart without the env var) to see the current state.`);
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
//#endregion
|
|
770
|
+
//#region src/kernel/substrate-contract.ts
|
|
771
|
+
/**
|
|
772
|
+
* The range of substrate versions both cortext plugins are built against.
|
|
773
|
+
*
|
|
774
|
+
* ONE literal, used in two places that must never disagree:
|
|
775
|
+
*
|
|
776
|
+
* 1. `contractVersion` on both plugin objects, checked by the substrate
|
|
777
|
+
* loader at boot, which refuses a plugin outside it.
|
|
778
|
+
* 2. The `@mnmal-ai/substrate` peer range in the manifest, checked by the
|
|
779
|
+
* package manager at install.
|
|
780
|
+
*
|
|
781
|
+
* Both exist because they guard different paths: `SUBSTRATE_PLUGIN_DIR` scans
|
|
782
|
+
* a directory, so a plugin dropped in by hand never meets a package manager,
|
|
783
|
+
* and the loader check is all that is left.
|
|
784
|
+
*
|
|
785
|
+
* The ceiling tracks actual plugin-facing compatibility, not the next major
|
|
786
|
+
* by reflex: widen it only after reading what the new major changed.
|
|
787
|
+
* `substrate-contract-parity.test.ts` holds the manifest to this literal.
|
|
788
|
+
*/
|
|
789
|
+
const SUBSTRATE_CONTRACT_RANGE = ">=1.0.0-alpha.0 <2.0.0";
|
|
790
|
+
|
|
791
|
+
//#endregion
|
|
792
|
+
//#region src/kernel/plugin.ts
|
|
793
|
+
function createCortextKernelPlugin() {
|
|
794
|
+
return {
|
|
795
|
+
name: "cortext-kernel",
|
|
796
|
+
contractVersion: SUBSTRATE_CONTRACT_RANGE,
|
|
797
|
+
onKernelReady: async (ctx) => {
|
|
798
|
+
await reportInvariantAudit(ctx);
|
|
799
|
+
await repairIfRequested({
|
|
800
|
+
sql: ctx.sql,
|
|
801
|
+
namespace: resolveCortextNamespaces(ctx.coordinatorNamespacesByPlugin)?.[0] ?? null,
|
|
802
|
+
env: process.env,
|
|
803
|
+
metric: ctx.metric
|
|
804
|
+
});
|
|
805
|
+
},
|
|
806
|
+
migrations: [cortextBaselineMigration]
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
//#endregion
|
|
811
|
+
export { CORTEXT_PROMOTED_COLUMNS as A, findingMessage as C, ARCHITECTURE_NAMESPACE as D, scopedNamespace as E, CORTEXT_COORDINATOR_NAME as O, auditObjects as S, resolveCortextNamespaces as T, TODO_LIFECYCLE_VOCAB as _, repairInvariants as a, INVARIANT_OBJECTS as b, cortextBaselineMigration as c, countDuplicateKids as d, rescopeHostInstallKeyIndex as f, AGENT_MESSAGE_STATUS_VOCAB as g, rescopeVocabConstraint as h, repairIfRequested as i, CORTEXT_NAMESPACE as k, VOCAB_CONSTRAINTS as l, rescopeRecipientIndex as m, SUBSTRATE_CONTRACT_RANGE as n, repairOutcomeMessage as o, rescopeKidUniqueIndex as p, REPAIR_ENV_VAR as r, repairRequested as s, createCortextKernelPlugin as t, countDuplicateHostInstallKeys as u, TODO_PRIORITY_VOCAB as v, reportInvariantAudit as w, auditNamespaceCoverage as x, TODO_RESOLUTION_VOCAB as y };
|