@mnmal-ai/cortext 0.0.0 → 1.0.0-alpha.1

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.
@@ -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 };