@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,1931 @@
1
+ import { A as CORTEXT_PROMOTED_COLUMNS, D as ARCHITECTURE_NAMESPACE, O as CORTEXT_COORDINATOR_NAME, _ as TODO_LIFECYCLE_VOCAB, g as AGENT_MESSAGE_STATUS_VOCAB, k as CORTEXT_NAMESPACE, n as SUBSTRATE_CONTRACT_RANGE, v as TODO_PRIORITY_VOCAB, y as TODO_RESOLUTION_VOCAB } from "./plugin-27UpMnoE.js";
2
+ import { JSON_HEADERS, RESERVED_AUTOCRUD_FIELDS, ResolverFrameError, and, applyUpdatePatch, createResolverWithEdges, defineNamespace, deleteResolver, denyAll, denyVerbs, field, formatQualifiedKey, getResolver, grantOf, grantedIn, jsonResponse, listResolver, makeCtx, or, queryAll, requireIdentity, resolveEntityRow, resolveIdentity, selectAll, updateResolver } from "@mnmal-ai/substrate/sdk";
3
+
4
+ //#region src/auth-policy.ts
5
+ /**
6
+ * The default: anonymous read/create/update under
7
+ * attribute mode, identity-gated hard-deletes. Archive stays the open soft path.
8
+ */
9
+ const DEFAULT_AUTH = Object.freeze({
10
+ reads: "open",
11
+ writes: "open",
12
+ deletes: "identity"
13
+ });
14
+ const rank = (mode) => mode === "identity" ? 1 : 0;
15
+ /** Strictness must be non-decreasing along reads → writes → deletes. */
16
+ function isMonotonicAuth(auth) {
17
+ return rank(auth.reads) <= rank(auth.writes) && rank(auth.writes) <= rank(auth.deletes);
18
+ }
19
+ /**
20
+ * Map a posture to the plugin-level policy list. Combinator order matters:
21
+ * `or()` returns the LAST deny verbatim, so the deny arm's actionable reason
22
+ * surfaces on denial instead of requireIdentity's terse default.
23
+ *
24
+ * Monotonicity makes the mapping a ladder — the strictest gated surface
25
+ * determines the single composed policy:
26
+ * reads gated → everything requires identity
27
+ * writes gated → create/update/delete require identity, reads stay open
28
+ * deletes gated → only the destructive verbs require identity
29
+ * fully open → no policy contributed at all
30
+ */
31
+ function buildAuthPolicies(auth) {
32
+ if (!isMonotonicAuth(auth)) throw new Error(`cortext: auth posture must be monotonic (reads ≤ writes ≤ deletes in strictness); got reads=${auth.reads}, writes=${auth.writes}, deletes=${auth.deletes}`);
33
+ if (auth.reads === "identity") return [or(requireIdentity(), denyAll("cortext: all access requires an authenticated identity (auth.reads=identity)"))];
34
+ if (auth.writes === "identity") return [or(requireIdentity(), denyVerbs([
35
+ "create",
36
+ "update",
37
+ "delete"
38
+ ], "cortext: writes require an authenticated identity (auth.writes=identity; reads stay open)"))];
39
+ if (auth.deletes === "identity") return [or(requireIdentity(), denyVerbs(["delete"], "cortext: deletes require an authenticated identity (auth.deletes=identity)"))];
40
+ return [];
41
+ }
42
+
43
+ //#endregion
44
+ //#region src/compact.ts
45
+ /**
46
+ * Acked agent mail older than this is archived by the compact sweep (fan-out
47
+ * writes one row per recipient, and without a policy the
48
+ * acked majority accumulates in the mailbox scan forever). 30 days keeps a
49
+ * generous correspondence-history window; `acked` means the recipient
50
+ * explicitly actioned it, and `archived` only drops it from inbox-shaped
51
+ * queries — the row remains queryable.
52
+ */
53
+ const MAIL_ARCHIVE_AFTER_MS = 2592e6;
54
+ /** The ids a Decision's `supersedes` names, whichever shape it was stored in. */
55
+ function supersededIds(d) {
56
+ const v = d.supersedes;
57
+ if (typeof v === "string" && v !== "") return [v];
58
+ if (Array.isArray(v)) return v.filter((x) => typeof x === "string" && x !== "");
59
+ return [];
60
+ }
61
+ async function mutateBatch(engine, ns, mutationName, paramsList, fields) {
62
+ if (paramsList.length === 0) return;
63
+ const key = formatQualifiedKey(ns, mutationName);
64
+ const selected = selectAll(fields);
65
+ const failed = (await engine.mutate({
66
+ frame: { [key]: paramsList.map((params) => ({
67
+ params,
68
+ fields: selected
69
+ })) },
70
+ ctx: {
71
+ ...makeCtx(ns, "mutate"),
72
+ identity: {
73
+ kind: "system",
74
+ id: "cortext:compact",
75
+ grants: { [ns]: [
76
+ "read",
77
+ "mutate",
78
+ "admin"
79
+ ] }
80
+ }
81
+ }
82
+ })).errors?.[0];
83
+ if (failed !== void 0) throw new Error(`cortext compact: batched ${mutationName} failed: ${failed.message}`);
84
+ }
85
+ /**
86
+ * Hard ceiling on per-call compaction reads. Without this, queryAll
87
+ * pulls every SessionLog + Decision row into Node.js memory regardless of
88
+ * corpus size — fine at 100s of rows, an OOM risk at 10k+ once
89
+ * multi-project use accumulates. 10k is well above any realistic active
90
+ * corpus and still bounds memory at single-MB. Beyond it, compaction
91
+ * processes the most-recent 10k window per call; subsequent invocations
92
+ * pick up the rest. Project-scoped compaction (passing a `where: { project }`
93
+ * filter) is the proper long-term fix.
94
+ */
95
+ const MAX_COMPACT_SCAN = 1e4;
96
+ async function runCompact(engine, ns, retention, dryRun, opts = {}) {
97
+ const sessions = await queryAll(engine, ns, "SessionLog", {
98
+ where: { archived: { eq: false } },
99
+ limit: MAX_COMPACT_SCAN,
100
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }]
101
+ }, ["id", "project"]);
102
+ const keptPerProject = /* @__PURE__ */ new Map();
103
+ const retained = [];
104
+ const toArchive = [];
105
+ for (const session of sessions) {
106
+ const group = session.project ?? "";
107
+ const kept = keptPerProject.get(group) ?? 0;
108
+ if (kept < retention) {
109
+ keptPerProject.set(group, kept + 1);
110
+ retained.push(session);
111
+ } else toArchive.push(session);
112
+ }
113
+ const archivedSessionIds = new Set(toArchive.map((s) => s.id));
114
+ const decisions = await queryAll(engine, ns, "Decision", {
115
+ where: { archived: { eq: false } },
116
+ limit: MAX_COMPACT_SCAN
117
+ }, [
118
+ "id",
119
+ "sessionId",
120
+ "supersedes"
121
+ ]);
122
+ const superseded = new Set(decisions.flatMap(supersededIds));
123
+ const decisionsToArchive = decisions.filter((d) => d.sessionId != null && archivedSessionIds.has(d.sessionId) || superseded.has(d.id));
124
+ if (!dryRun) {
125
+ await mutateBatch(engine, ns, "updateDecision", decisionsToArchive.map((d) => ({
126
+ id: d.id,
127
+ archived: true
128
+ })), ["id"]);
129
+ await mutateBatch(engine, ns, "updateSessionLog", toArchive.map((s) => ({
130
+ id: s.id,
131
+ archived: true
132
+ })), ["id"]);
133
+ }
134
+ const mailCutoff = new Date(Date.now() - (opts.mailMaxAgeMs ?? MAIL_ARCHIVE_AFTER_MS));
135
+ const ackedMail = await queryAll(engine, ns, "AgentMessage", {
136
+ where: {
137
+ status: { eq: "acked" },
138
+ archived: { eq: false },
139
+ updatedAt: { lt: mailCutoff.toISOString() }
140
+ },
141
+ limit: MAX_COMPACT_SCAN
142
+ }, ["id"]);
143
+ if (!dryRun) await mutateBatch(engine, ns, "updateAgentMessage", ackedMail.map((m) => ({
144
+ id: m.id,
145
+ archived: true
146
+ })), ["id"]);
147
+ return {
148
+ dryRun,
149
+ sessionsArchived: toArchive.length,
150
+ decisionsArchived: decisionsToArchive.length,
151
+ sessionsRetained: retained.length,
152
+ messagesArchived: ackedMail.length
153
+ };
154
+ }
155
+ function parseBody(req) {
156
+ const raw = req.body;
157
+ if (raw === null || raw.length === 0) return { dryRun: false };
158
+ const text = raw.toString("utf8");
159
+ if (text.trim().length === 0) return { dryRun: false };
160
+ let parsed;
161
+ try {
162
+ parsed = JSON.parse(text);
163
+ } catch (err) {
164
+ return { error: err instanceof Error ? err.message : "invalid JSON" };
165
+ }
166
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return { dryRun: false };
167
+ return { dryRun: parsed.dryRun === true };
168
+ }
169
+ function registerCompactRoute(router, ctx, retention, namespace, auth = DEFAULT_AUTH) {
170
+ router.post("/compact", async (req) => {
171
+ if (auth.writes === "identity") {
172
+ const caps = grantedIn(grantOf(req.identity ?? null), namespace);
173
+ if (!caps.includes("admin") && !caps.includes("*")) return jsonResponse(403, {
174
+ code: "operator_only",
175
+ message: `POST /compact archives across every project and needs an operator (admin in '${namespace}')`
176
+ });
177
+ }
178
+ const parsed = parseBody(req);
179
+ if ("error" in parsed) return jsonResponse(400, {
180
+ code: "bad_request",
181
+ message: parsed.error
182
+ });
183
+ const result = await runCompact(ctx.engine, namespace, retention, parsed.dryRun);
184
+ return jsonResponse(200, result);
185
+ });
186
+ }
187
+
188
+ //#endregion
189
+ //#region src/kernel-invariants.ts
190
+ /**
191
+ * Boot-time reporting for the namespaces the kernel cannot follow.
192
+ *
193
+ * The cortext kernel plugin scopes every CHECK constraint and partial index
194
+ * with a `namespace = <literal>` predicate, baked in at authoring time because
195
+ * a migration cannot read config (`KernelMigrationContext` is `{ sql, metric }`).
196
+ * So a deployment that renames a namespace gets NO invariants, silently: a
197
+ * constraint scoped to a namespace with no rows passes trivially, and an index
198
+ * whose predicate no query can satisfy is just a heap scan.
199
+ *
200
+ * This warns rather than refuses. Per-cohort context stores are a designed
201
+ * capability (`bootstrap-config.test.ts` names `codex` as an override target),
202
+ * so the knob keeps working and the consequence is stated at boot.
203
+ *
204
+ * The kernel's own `invariant-audit.ts` reads the catalog too, but answers a
205
+ * different question. HERE (coordinator): "the namespace I am CONFIGURED for
206
+ * is not the one the kernel scopes to"; only the coordinator knows
207
+ * `cfg.namespace`. THERE (kernel): "my scope literal is declared by no
208
+ * coordinator", plus objects dropped, left NOT VALID, or re-scoped.
209
+ *
210
+ * Neither re-scopes automatically; that is the opt-in `./repair.ts`, because a
211
+ * store that ran unscoped may hold rows violating the vocabulary, so
212
+ * `ADD CONSTRAINT` can fail on exactly the databases a repair exists for.
213
+ * Parameterising the literals alone would not help either: migrations are
214
+ * recorded once by `id`, so a later rename never re-scopes existing objects.
215
+ */
216
+ /**
217
+ * Which configured namespaces the kernel does not cover. Empty is the
218
+ * healthy case.
219
+ *
220
+ * Pure and separately exported so the condition can be tested directly rather
221
+ * than through a lifecycle harness — the reporting below is a thin shell over
222
+ * it.
223
+ */
224
+ function uncoveredNamespaces(cfg) {
225
+ return [{
226
+ field: "namespace",
227
+ configured: cfg.namespace,
228
+ scopedTo: CORTEXT_NAMESPACE
229
+ }, {
230
+ field: "architectureNamespace",
231
+ configured: cfg.architectureNamespace,
232
+ scopedTo: ARCHITECTURE_NAMESPACE
233
+ }].filter((c) => c.configured !== c.scopedTo);
234
+ }
235
+ /** The operator-facing sentence for one uncovered namespace. */
236
+ function uncoveredNamespaceMessage({ field, configured, scopedTo }) {
237
+ return `[cortext] ${field} is '${configured}', but the cortext kernel scopes its constraints and indexes to '${scopedTo}'. Every kernel invariant for '${configured}' is therefore INACTIVE — Todo/AgentMessage vocabularies are unenforced, the mailbox inbox index covers no rows, and AgentIdentity.kid uniqueness is NOT held. Writes succeed and queries answer, so nothing else will report this. Use '${scopedTo}', or accept the loss deliberately.`;
238
+ }
239
+ /**
240
+ * Emit the warnings at plugin start. Returns what it reported, so a caller (or
241
+ * a test) can assert on it without capturing the console.
242
+ *
243
+ * Both sinks on purpose: `metric` is the
244
+ * structured channel an operator can alert on, and the console line is what
245
+ * actually appears in the server journal next to substrate's own loader
246
+ * warnings, where someone will see it.
247
+ */
248
+ function reportUncoveredNamespaces(cfg, metric, warn = console.warn) {
249
+ const uncovered = uncoveredNamespaces(cfg);
250
+ for (const entry of uncovered) {
251
+ metric("cortext.kernel_invariants_inactive", { ...entry });
252
+ warn(uncoveredNamespaceMessage(entry));
253
+ }
254
+ return uncovered;
255
+ }
256
+
257
+ //#endregion
258
+ //#region src/participants.ts
259
+ /** Whether the caller holds `admin` (or `*`) in the namespace it is calling. */
260
+ function isOperator(ctx) {
261
+ const caps = grantedIn(grantOf(ctx.identity), ctx.namespace);
262
+ return caps.includes("admin") || caps.includes("*");
263
+ }
264
+ function rowId(args) {
265
+ const raw = args.params?.id;
266
+ return typeof raw === "string" || typeof raw === "number" ? raw : void 0;
267
+ }
268
+ function callerKid(ctx) {
269
+ const id = ctx.identity?.id;
270
+ return typeof id === "string" && id !== "" ? id : null;
271
+ }
272
+ /**
273
+ * AgentMessage update/delete: the sender, the recipient, or an operator.
274
+ * The sender is the key that signed the send (`_metadata.createdBy`); the
275
+ * recipient is the kid on the addressed directory row. Archiving someone
276
+ * else's mail hides it from their inbox, and deleting it destroys it, so
277
+ * neither is a bystander's call.
278
+ */
279
+ function messageParticipantsOnly(installCtx, name, inner) {
280
+ return async (ctx, args) => {
281
+ const id = rowId(args);
282
+ if (id === void 0 || isOperator(ctx)) return inner(ctx, args);
283
+ const ds = args.dataset ?? installCtx.dataset;
284
+ const message = await resolveEntityRow(ds, "AgentMessage", id);
285
+ if (message === null) return inner(ctx, args);
286
+ const caller = callerKid(ctx);
287
+ const sender = message._metadata?.createdBy?.id;
288
+ let recipient = null;
289
+ if (typeof message.recipient === "string" && message.recipient !== "") {
290
+ const { rows } = await ds.read("AgentIdentity", { id: message.recipient });
291
+ recipient = rows[0]?.kid ?? null;
292
+ }
293
+ if (caller !== null && (caller === sender || caller === recipient)) return inner(ctx, args);
294
+ throw new ResolverFrameError("not_participant", `${name}: only this message's sender ('${String(sender ?? "unknown")}') or recipient ('${String(recipient ?? "unknown")}'), or an operator, may change or delete it`, { id: message.id });
295
+ };
296
+ }
297
+ /**
298
+ * AgentIdentity update: the identity itself (the caller's kid is the row's
299
+ * kid) or an operator. `kid` is the row's identity, so changing it is an
300
+ * operator act even on your own row: otherwise a row could be renamed onto a
301
+ * kid whose mail it would then receive.
302
+ */
303
+ function identitySelfOnly(installCtx, name, inner) {
304
+ return async (ctx, args) => {
305
+ const id = rowId(args);
306
+ if (id === void 0 || isOperator(ctx)) return inner(ctx, args);
307
+ const ds = args.dataset ?? installCtx.dataset;
308
+ const row = await resolveEntityRow(ds, "AgentIdentity", id);
309
+ if (row === null) return inner(ctx, args);
310
+ const params = args.params ?? {};
311
+ if ("kid" in params && params.kid !== row.kid) throw new ResolverFrameError("operator_only", `${name}: 'kid' is the row's identity and only an operator may change it`, { field: "kid" });
312
+ if (callerKid(ctx) === row.kid) return inner(ctx, args);
313
+ throw new ResolverFrameError("not_self", `${name}: only '${String(row.kid)}' itself, or an operator, may edit this identity row`, { id: row.id });
314
+ };
315
+ }
316
+ /** AgentIdentity delete: operators only. Mail hangs off these rows. */
317
+ function operatorOnly(name, inner) {
318
+ return async (ctx, args) => {
319
+ if (isOperator(ctx)) return inner(ctx, args);
320
+ throw new ResolverFrameError("operator_only", `${name}: only an operator may delete an identity row; retire it with active: false instead`, {});
321
+ };
322
+ }
323
+
324
+ //#endregion
325
+ //#region src/mailbox.ts
326
+ const TYPE = "AgentMessage";
327
+ const DIRECTORY_TYPE = "AgentIdentity";
328
+ /**
329
+ * Fields whose writes are reserved to the transition mutations. Checked by
330
+ * `mailboxWriteGuard` against update params; there is no create verb to deny
331
+ * wholesale — the mutation is not registered.
332
+ */
333
+ const TRANSITION_FIELDS = [
334
+ "status",
335
+ "ackedBy",
336
+ "ackedAt"
337
+ ];
338
+ /**
339
+ * Fields written once, by sendAgentMessage, and never edited. `instance`
340
+ * is the sender's own account of which writer it was; letting a later update
341
+ * change it would make it say whatever the last editor wanted.
342
+ */
343
+ const SEND_ONLY_FIELDS = ["instance"];
344
+ /**
345
+ * The only fields updateAgentMessage may write. Everything else a
346
+ * message carries is fixed at send; a denylist would let any agent rewrite
347
+ * `recipient` to its own identity row and then ack, or edit `body` after delivery.
348
+ * `id` addresses the row; `_`-prefixed keys are request controls, not fields.
349
+ */
350
+ const UPDATABLE_FIELDS = /* @__PURE__ */ new Set([
351
+ "id",
352
+ "archived",
353
+ "tags"
354
+ ]);
355
+ /**
356
+ * sendAgentMessage's introspection/registration param schema. `to`
357
+ * wire-accepts a single kid string, a list of kids, or '*' (broadcast to
358
+ * every active identity except the sender); declared as list-of-string for
359
+ * introspection, the resolver normalises at runtime.
360
+ */
361
+ const SEND_PARAMS = {
362
+ to: field.list(field.string()),
363
+ subject: field.string(),
364
+ body: field.string({ indexed: false }),
365
+ inReplyTo: field.string({ nullable: true }),
366
+ tags: field.list(field.string(), { nullable: true }),
367
+ instance: field.string({ nullable: true })
368
+ };
369
+ /**
370
+ * Runtime allowlist derived from the declared params — one
371
+ * authoritative declaration, so the schema can never advertise a param the
372
+ * resolver rejects (or vice versa).
373
+ */
374
+ const SEND_PARAM_NAMES = new Set(Object.keys(SEND_PARAMS));
375
+ /**
376
+ * Upper bound on a directory read. The cohort is ~10 identities; 1000 exists
377
+ * so a `'*'` fan-out can never be silently truncated by a kernel default
378
+ * limit, not because that scale is anticipated.
379
+ */
380
+ const DIRECTORY_READ_LIMIT = 1e3;
381
+ /**
382
+ * Policy over AgentMessage's hand-registered surface — `installMailboxMutations`
383
+ * composes it with the plugin policy, the way defineNamespace composes type
384
+ * policies for autoCRUD types. Retires the direct write paths:
385
+ * - create → always denied. The verb is not registered (autoCrud:false), so
386
+ * this branch only fires if something re-registers it; the schema never
387
+ * advertises it.
388
+ * - update → only archived and tags may change; a transition field
389
+ * or anything fixed at send is denied, each with its own reason.
390
+ */
391
+ const mailboxWriteGuard = (ctx, args) => {
392
+ const ns = ctx.namespace;
393
+ if (ctx.verb === "create") return {
394
+ allow: false,
395
+ reason: `${ns}/AgentMessage: direct createAgentMessage is retired — use ${ns}/sendAgentMessage (kid-addressed; to: '<kid>' | ['<kid>', …] | '*')`
396
+ };
397
+ if (ctx.verb === "update") {
398
+ const params = args.params ?? {};
399
+ for (const fieldName of TRANSITION_FIELDS) if (fieldName in params) return {
400
+ allow: false,
401
+ reason: `${ns}/AgentMessage: '${fieldName}' transitions only via ${ns}/markAgentMessageRead / ${ns}/ackAgentMessage (recipient-only)`
402
+ };
403
+ for (const fieldName of SEND_ONLY_FIELDS) if (fieldName in params) return {
404
+ allow: false,
405
+ reason: `${ns}/AgentMessage: '${fieldName}' is set once by ${ns}/sendAgentMessage and cannot be edited`
406
+ };
407
+ for (const fieldName of Object.keys(params)) {
408
+ if (UPDATABLE_FIELDS.has(fieldName) || fieldName.startsWith("_")) continue;
409
+ return {
410
+ allow: false,
411
+ reason: `${ns}/AgentMessage: '${fieldName}' is fixed at send; updateAgentMessage may change only archived and tags. Reply with ${ns}/sendAgentMessage instead.`
412
+ };
413
+ }
414
+ }
415
+ return { allow: true };
416
+ };
417
+ /**
418
+ * Resolve the `to` param into directory rows, validate-first so a bad address
419
+ * rejects before any row is written:
420
+ * - wildcard (`['*']` canonical; bare `'*'` accepted as sugar) → every
421
+ * active identity except the sender; empty → no_recipients. Mixing the
422
+ * wildcard with explicit kids is ambiguous → wildcard_mixed.
423
+ * - kid / kid[] → deduped; unknown kid → unknown_recipient; inactive →
424
+ * inactive_recipient.
425
+ */
426
+ /**
427
+ * Whether `to:'*'` should fan a copy out to this identity.
428
+ *
429
+ * ABSENT AND NULL BOTH MEAN ELIGIBLE, and that is the whole reason this is a
430
+ * predicate rather than a `where` clause. `broadcastEligible` is declared
431
+ * `default: true`, but a default is applied on WRITE — every AgentIdentity row
432
+ * created before the field existed carries no such key and reads back as
433
+ * `null`.
434
+ *
435
+ * So `where: { broadcastEligible: { eq: true } }` would match NONE of them
436
+ * (and `eq: null` matches nothing either, under SQL three-valued logic),
437
+ * turning every broadcast into `no_recipients`. Opting OUT has to be an
438
+ * explicit `false`.
439
+ *
440
+ * Exported because it cannot be exercised through `createAgentIdentity`: that
441
+ * path applies the declared default, so a row written through the schema always
442
+ * carries the key. The pre-field shape is only reachable directly, which is
443
+ * what `mailbox-broadcast-eligibility.test.ts` does.
444
+ */
445
+ function isBroadcastEligible(row) {
446
+ return row.broadcastEligible !== false;
447
+ }
448
+ async function resolveRecipients(ds, to, senderId, ns) {
449
+ const list = Array.isArray(to) ? to : [to];
450
+ if (list.includes("*")) {
451
+ if (list.length > 1) throw new ResolverFrameError("wildcard_mixed", "sendAgentMessage: the '*' wildcard cannot be combined with explicit kids — it already reaches every active identity", { field: "to" });
452
+ const { rows } = await ds.read(DIRECTORY_TYPE, {
453
+ where: { active: { eq: true } },
454
+ limit: DIRECTORY_READ_LIMIT
455
+ });
456
+ const others = rows.filter((r) => r.kid !== senderId);
457
+ const eligible = others.filter(isBroadcastEligible);
458
+ if (eligible.length === 0) throw new ResolverFrameError("no_recipients", others.length === 0 ? "sendAgentMessage: to:'*' found no active cortext/AgentIdentity besides the sender" : `sendAgentMessage: to:'*' found ${others.length} active cortext/AgentIdentity besides the sender, but all of them have broadcastEligible: false`, {
459
+ sender: senderId,
460
+ active: others.length
461
+ });
462
+ return eligible;
463
+ }
464
+ if (list.length === 0 || !list.every((k) => typeof k === "string" && k.length > 0)) throw new ResolverFrameError("missing_required_field", "sendAgentMessage: params.to must be a kid, a non-empty list of kids, or ['*']", { field: "to" });
465
+ const kids = [...new Set(list)];
466
+ const { rows } = await ds.read(DIRECTORY_TYPE, {
467
+ where: { kid: { in: kids } },
468
+ limit: DIRECTORY_READ_LIMIT
469
+ });
470
+ const byKid = /* @__PURE__ */ new Map();
471
+ for (const r of rows) {
472
+ const kid = String(r.kid);
473
+ if (byKid.has(kid)) throw new ResolverFrameError("ambiguous_recipient", `sendAgentMessage: kid '${kid}' matches more than one ${ns}/AgentIdentity row — directory uniqueness is violated; resolve the duplicate before sending`, { kid });
474
+ byKid.set(kid, r);
475
+ }
476
+ return kids.map((kid) => {
477
+ const row = byKid.get(kid);
478
+ if (row === void 0) throw new ResolverFrameError("unknown_recipient", `sendAgentMessage: no ${ns}/AgentIdentity row with kid '${kid}'`, { kid });
479
+ if (row.active !== true) throw new ResolverFrameError("inactive_recipient", `sendAgentMessage: identity '${kid}' is inactive — messages to retired identities are refused`, { kid });
480
+ return row;
481
+ });
482
+ }
483
+ function sendResolver(installCtx, createMessage) {
484
+ return async (ctx, args) => {
485
+ const params = args.params ?? {};
486
+ for (const key of Object.keys(params)) if (!SEND_PARAM_NAMES.has(key)) throw new ResolverFrameError("unknown_field", `sendAgentMessage: unknown param '${key}'. Allowed: ${[...SEND_PARAM_NAMES].sort().join(", ")}.`, { field: key });
487
+ const ds = args.dataset ?? installCtx.dataset;
488
+ const actor = resolveIdentity(ctx);
489
+ const recipients = await resolveRecipients(ds, params.to, actor.id, ctx.namespace);
490
+ return Promise.all(recipients.map((recipient) => {
491
+ const rowParams = {
492
+ recipient: recipient.id,
493
+ subject: params.subject,
494
+ body: params.body
495
+ };
496
+ if (params.inReplyTo !== void 0) rowParams.inReplyTo = params.inReplyTo;
497
+ if (params.tags !== void 0) rowParams.tags = params.tags;
498
+ if (params.instance !== void 0) rowParams.instance = params.instance;
499
+ return createMessage(ctx, {
500
+ ...args,
501
+ params: rowParams
502
+ });
503
+ }));
504
+ };
505
+ }
506
+ /**
507
+ * One entry per transition: the Record forces exhaustiveness —
508
+ * adding a TransitionTarget without a spec is a compile error rather than a
509
+ * silent no-op.
510
+ */
511
+ const TRANSITIONS = {
512
+ read: {
513
+ mutationName: "markAgentMessageRead",
514
+ isNoOp: (status) => status !== "unread",
515
+ stamp: () => ({})
516
+ },
517
+ acked: {
518
+ mutationName: "ackAgentMessage",
519
+ isNoOp: (status) => status === "acked",
520
+ stamp: (actor, at) => ({
521
+ ackedBy: actor.id,
522
+ ackedAt: at
523
+ })
524
+ }
525
+ };
526
+ function transitionResolver(installCtx, target) {
527
+ const { mutationName: name, isNoOp, stamp } = TRANSITIONS[target];
528
+ return async (ctx, args) => {
529
+ const params = args.params ?? {};
530
+ for (const key of Object.keys(params)) if (key !== "id") throw new ResolverFrameError("unknown_param", `${name}: unknown param '${key}'. Allowed: id.`, { field: key });
531
+ const ds = args.dataset ?? installCtx.dataset;
532
+ const raw = params.id;
533
+ const id = typeof raw === "string" || typeof raw === "number" ? raw : void 0;
534
+ if (id === void 0) throw new ResolverFrameError("missing_id", `${name}: params.id is required`, { field: "id" });
535
+ const message = await resolveEntityRow(ds, TYPE, id);
536
+ if (message === null) return null;
537
+ const recipientId = message.recipient;
538
+ if (typeof recipientId !== "string" || recipientId.length === 0) throw new ResolverFrameError("no_recipient", `${name}: message has no resolvable recipient (broadcast-era or orphaned row) — transitions are recipient-only`, { id: message.id });
539
+ const { rows: identityRows } = await ds.read(DIRECTORY_TYPE, { id: recipientId });
540
+ const identityRow = identityRows[0];
541
+ if (identityRow === void 0) throw new ResolverFrameError("no_recipient", `${name}: the recipient identity row no longer exists`, { id: message.id });
542
+ const actor = resolveIdentity(ctx);
543
+ if (actor.id !== identityRow.kid) throw new ResolverFrameError("not_recipient", `${name}: only the recipient ('${String(identityRow.kid)}') may transition this message`, { recipient: identityRow.kid });
544
+ if (isNoOp(message.status)) return message;
545
+ const at = ctx.now.toISOString();
546
+ return applyUpdatePatch(ds, TYPE, message.id, {
547
+ status: target,
548
+ ...stamp(actor, at)
549
+ }, ctx, args.onWrite);
550
+ };
551
+ }
552
+ /**
553
+ * Register AgentMessage's whole surface. The type declares `autoCrud: false`,
554
+ * so defineNamespace installed nothing for it and this function owns the lot:
555
+ * the type entry, get/update/delete — same resolvers and same params/returns
556
+ * installAutoCrud would have emitted — plus the custom family (sendAgentMessage,
557
+ * markAgentMessageRead, ackAgentMessage). What is deliberately absent is
558
+ * `createAgentMessage`: an advertised mutation the route only denies teaches
559
+ * callers to build a shape that cannot work, and the refusal reads like a
560
+ * grant problem. Field defs arrive pre-augmentation; the reserved engine
561
+ * fields are added here, the way defineNamespace adds them for autoCRUD types.
562
+ */
563
+ function installMailboxMutations(ctx, messageFields) {
564
+ const fields = {
565
+ ...messageFields,
566
+ ...RESERVED_AUTOCRUD_FIELDS
567
+ };
568
+ const writePolicy = ctx.pluginPolicy === void 0 ? mailboxWriteGuard : and(ctx.pluginPolicy, mailboxWriteGuard);
569
+ ctx.registry.register(TYPE, {
570
+ resolver: listResolver(TYPE, ctx.dataset, { fields }),
571
+ kind: "type",
572
+ policy: writePolicy,
573
+ fields
574
+ });
575
+ ctx.registry.register(`get${TYPE}`, {
576
+ resolver: getResolver(TYPE, ctx.dataset),
577
+ kind: "mutation",
578
+ verb: "get",
579
+ params: { id: field.string() },
580
+ returns: field.struct(fields, { nullable: true }),
581
+ policy: writePolicy,
582
+ fields
583
+ });
584
+ ctx.registry.register(`update${TYPE}`, {
585
+ resolver: messageParticipantsOnly(ctx, `update${TYPE}`, updateResolver(TYPE, ctx.dataset, ctx.edges, { fields })),
586
+ kind: "mutation",
587
+ verb: "update",
588
+ params: {
589
+ id: field.string(),
590
+ ...fields
591
+ },
592
+ returns: field.struct(fields, { nullable: true }),
593
+ policy: writePolicy,
594
+ fields
595
+ });
596
+ ctx.registry.register(`delete${TYPE}`, {
597
+ resolver: messageParticipantsOnly(ctx, `delete${TYPE}`, deleteResolver(TYPE, ctx.dataset, ctx.edges)),
598
+ kind: "mutation",
599
+ verb: "delete",
600
+ params: { id: field.string() },
601
+ returns: field.struct({
602
+ id: field.string(),
603
+ deleted: field.boolean()
604
+ }),
605
+ policy: writePolicy
606
+ });
607
+ const createMessage = createResolverWithEdges(TYPE, ctx.dataset, ctx.edges, { fields });
608
+ const requireSender = or(requireIdentity(), denyAll("sendAgentMessage requires an authenticated identity — the sender is the signed principal (spec §3)"));
609
+ const sendPolicy = ctx.pluginPolicy !== void 0 ? and(ctx.pluginPolicy, requireSender) : requireSender;
610
+ const transitionPolicy = ctx.pluginPolicy;
611
+ const messageStruct = field.struct(fields);
612
+ ctx.registry.register("sendAgentMessage", {
613
+ resolver: sendResolver(ctx, createMessage),
614
+ kind: "mutation",
615
+ verb: "create",
616
+ params: SEND_PARAMS,
617
+ returns: field.list(messageStruct),
618
+ policy: sendPolicy,
619
+ fields
620
+ });
621
+ for (const [target, spec] of Object.entries(TRANSITIONS)) ctx.registry.register(spec.mutationName, {
622
+ resolver: transitionResolver(ctx, target),
623
+ kind: "mutation",
624
+ verb: "update",
625
+ params: { id: field.string() },
626
+ returns: field.struct(fields, { nullable: true }),
627
+ ...transitionPolicy !== void 0 ? { policy: transitionPolicy } : {},
628
+ fields
629
+ });
630
+ }
631
+
632
+ //#endregion
633
+ //#region src/nl-rulings.ts
634
+ /**
635
+ * How people talk about cortext, for substrate's NL parser and judge. Curated
636
+ * operator rulings, kept here so the eval and production read one copy, owned
637
+ * by the namespace. Declared via `defineNamespace` annotations; substrate's NL
638
+ * layer validates them and the runtime can override per key.
639
+ */
640
+ const CORTEXT_NL_RULINGS = {
641
+ unscopedTypes: [
642
+ "AgentIdentity",
643
+ "AgentMessage",
644
+ "Skill"
645
+ ],
646
+ liveStatus: { Todo: "open" },
647
+ timeField: { SessionLog: "startedAt" },
648
+ typeAliases: {
649
+ inbox: "AgentMessage",
650
+ mail: "AgentMessage",
651
+ agents: "AgentIdentity",
652
+ agent: "AgentIdentity",
653
+ session: "SessionLog",
654
+ sessions: "SessionLog",
655
+ anchor: "AnchorIntent",
656
+ rule: "AgentRule",
657
+ rules: "AgentRule",
658
+ task: "Todo",
659
+ tasks: "Todo",
660
+ decide: "Decision",
661
+ decided: "Decision"
662
+ },
663
+ fieldAliases: {
664
+ repo: "project",
665
+ repository: "project"
666
+ },
667
+ valueAliases: {
668
+ "in force": [{
669
+ type: "Decision",
670
+ field: "status",
671
+ eq: "active"
672
+ }],
673
+ "signal-gated": [{
674
+ type: "Todo",
675
+ field: "title",
676
+ startsWith: "[SG]"
677
+ }],
678
+ ship: [{
679
+ type: "Todo",
680
+ field: "resolution",
681
+ eq: "completed"
682
+ }],
683
+ shipped: [{
684
+ type: "Todo",
685
+ field: "resolution",
686
+ eq: "completed"
687
+ }],
688
+ drop: [{
689
+ type: "Todo",
690
+ field: "resolution",
691
+ in: ["wont_do", "obsolete"]
692
+ }],
693
+ dropped: [{
694
+ type: "Todo",
695
+ field: "resolution",
696
+ in: ["wont_do", "obsolete"]
697
+ }]
698
+ },
699
+ typeNotes: {
700
+ SessionLog: "a record of one working session: what was done, when, on which branch",
701
+ Decision: "a choice that was made, with its rationale",
702
+ Todo: "a tracked piece of work",
703
+ Memory: "a durable fact an agent recorded to remember",
704
+ AgentMessage: "mail between agents; the sender is createdById",
705
+ AgentIdentity: "a registered agent: kid (name@host), display name, project",
706
+ Concept: "an operational concept, with a kind",
707
+ AgentRule: "a standing rule agents follow"
708
+ }
709
+ };
710
+
711
+ //#endregion
712
+ //#region src/namespace.ts
713
+ /**
714
+ * Decision lifecycle states this vocabulary DOCUMENTS. ADVISORY, not enforced,
715
+ * like `AgentRule.category`. This is a list, not a census: nothing reconciles
716
+ * it against the store, so it never claims what is "in use".
717
+ *
718
+ * A free string is the honest encoding of a taxonomy nobody designed. An enum
719
+ * freezes it, and agents then either invent undeclared synonyms or pick the
720
+ * nearest legal word. `open` names a decision recorded before it was made.
721
+ *
722
+ * Nothing branches on `status` today (cold-start projects and renders it), but
723
+ * that is not the argument: `status` is a conventionally mechanical name, so a
724
+ * future consumer is likely to branch on it.
725
+ *
726
+ * THE DIRECTION MATTERS. Widening cannot invalidate stored data; it only
727
+ * surprises readers. Narrowing STRANDS rows — readable, not round-trippable,
728
+ * and selectively so, since a partial update avoiding the field still succeeds
729
+ * while a read-modify-write client gets `invalid_enum_value` on a row it never
730
+ * touched. So re-adding `field.enum` here later is not a symmetric undo. Do
731
+ * not do it.
732
+ *
733
+ * Add to this list when a genuinely NEW state earns its place; it feeds the
734
+ * onboarding contract, so what is here is what an agent is taught. `accepted`
735
+ * is deliberately an alias of `active` rather than a state of its own.
736
+ */
737
+ const DECISION_STATUS_VOCAB = [
738
+ "open",
739
+ "active",
740
+ "superseded",
741
+ "deferred"
742
+ ];
743
+ /**
744
+ * Synonyms the server normalises on every write and filter. `field.vocab` with
745
+ * `strict: false` is NOT the enum warned against above: unknown values are
746
+ * still admitted, so nothing narrows and nothing strands. It only maps the
747
+ * words agents reach for onto the state they mean. `proposed` is a decision
748
+ * recorded before it was made, which is what `open` documents.
749
+ */
750
+ const DECISION_STATUS_ALIASES = {
751
+ accepted: "active",
752
+ adopted: "active",
753
+ decided: "active",
754
+ proposed: "open"
755
+ };
756
+ /**
757
+ * Words agents reach for when a Todo stops, mapped onto lifecycle + outcome.
758
+ * Each closing word sets `status: closed` AND the resolution it
759
+ * means, so `status: done` still works and now records WHY. A caller that
760
+ * also sends a contradicting `resolution` is refused (`conflicting_implication`).
761
+ */
762
+ const TODO_STATUS_ALIASES = {
763
+ done: "closed",
764
+ finished: "closed",
765
+ completed: "closed",
766
+ shipped: "closed",
767
+ cancelled: "closed",
768
+ dropped: "closed",
769
+ superseded: "closed",
770
+ not_started: "open"
771
+ };
772
+ const TODO_STATUS_IMPLIES = {
773
+ done: { resolution: "completed" },
774
+ finished: { resolution: "completed" },
775
+ completed: { resolution: "completed" },
776
+ shipped: { resolution: "completed" },
777
+ cancelled: { resolution: "wont_do" },
778
+ dropped: { resolution: "wont_do" },
779
+ superseded: { resolution: "superseded" }
780
+ };
781
+ const TODO_RESOLUTION_ALIASES = {
782
+ done: "completed",
783
+ shipped: "completed",
784
+ wontfix: "wont_do",
785
+ declined: "wont_do",
786
+ dup: "duplicate",
787
+ moot: "obsolete"
788
+ };
789
+ /**
790
+ * AgentRule severity — CLOSED, unlike `category`. Cold-start branches on it:
791
+ * `must` rules render in the binding brief. `high` and `medium` are aliases
792
+ * rather than stranded values.
793
+ */
794
+ const AGENT_RULE_SEVERITY_VOCAB = ["must", "should"];
795
+ const AGENT_RULE_SEVERITY_ALIASES = {
796
+ high: "must",
797
+ medium: "should"
798
+ };
799
+ /**
800
+ * AgentRule categories. ADVISORY, not enforced.
801
+ *
802
+ * An enum here arrives narrower than the data and freezes the taxonomy. The
803
+ * damage is not a write rejection anyone notices but SILENT SUBSTITUTION:
804
+ * agents pick the nearest legal word and move on, and the taxonomy flattens
805
+ * toward whatever is allowed. Nothing consumes `category` mechanically —
806
+ * cold-start projects it and renders it, never groups or filters on it — and
807
+ * `ArchitectureConcern.category` in this same file is a free string too.
808
+ * Widening an enum would only move the wall to the next category.
809
+ *
810
+ * Add to this list when a genuinely new category earns its place; it feeds the
811
+ * onboarding contract so agents see the real taxonomy. Do NOT re-add
812
+ * `field.enum` over it.
813
+ */
814
+ const AGENT_RULE_CATEGORY_VOCAB = [
815
+ "git",
816
+ "testing",
817
+ "style",
818
+ "process",
819
+ "tooling",
820
+ "communication",
821
+ "investigation",
822
+ "migration-patterns",
823
+ "permissions",
824
+ "scope-discipline",
825
+ "side-findings",
826
+ "verification"
827
+ ];
828
+ /**
829
+ * HostInstall.fragmentState: the cold-start fragment drift check's own verdict,
830
+ * plus `source` for the repo that ships the fragment.
831
+ */
832
+ const HOST_INSTALL_FRAGMENT_STATES = [
833
+ "current",
834
+ "stale",
835
+ "missing",
836
+ "undigested",
837
+ "uncomparable",
838
+ "source",
839
+ "absent"
840
+ ];
841
+ /** HostInstall's per-check verdicts: the worst finding each check produced. */
842
+ const HOST_INSTALL_CHECK_STATES = [
843
+ "ok",
844
+ "warn",
845
+ "unknown"
846
+ ];
847
+ const CONCEPT_NAME_TTL_MS = 6e4;
848
+ /** Long-form length past which `embed.source` appends the long form to the synopsis. */
849
+ const LONGFORM_EMBED_CHARS = 1200;
850
+ function buildClaudeNamespace(ns) {
851
+ /**
852
+ * Concept names are near-static (concepts evolve rarely) but `Decision.embed.source`
853
+ * reads one on every Decision write — the most common write in this namespace —
854
+ * purely to inline a string into the embedding text. Cache the
855
+ * (conceptId → name) lookup with a short TTL: collapses repeated reads on the
856
+ * write path; a renamed Concept re-embeds on the next Decision write past the TTL.
857
+ *
858
+ * The cache lives in this closure — not at module level — so each namespace
859
+ * instance owns its cache lifecycle and `createCortextPlugin` stays the pure
860
+ * factory index.ts claims: no state shared across engines or
861
+ * surviving engine teardown in multi-engine processes.
862
+ */
863
+ const conceptNameCache = /* @__PURE__ */ new Map();
864
+ async function resolveConceptName(ctx, conceptId) {
865
+ const now = Date.now();
866
+ const hit = conceptNameCache.get(conceptId);
867
+ if (hit !== void 0 && hit.expiresAt > now) return hit.name;
868
+ for (const [key, entry] of conceptNameCache) if (entry.expiresAt <= now) conceptNameCache.delete(key);
869
+ const concept = await ctx.read(ns, "Concept", { id: conceptId });
870
+ const name = concept !== null && typeof concept.name === "string" ? concept.name : null;
871
+ conceptNameCache.set(conceptId, {
872
+ name,
873
+ expiresAt: now + CONCEPT_NAME_TTL_MS
874
+ });
875
+ return name;
876
+ }
877
+ /**
878
+ * Embedding text for a synopsis-bearing row: the synopsis, followed by
879
+ * `long` once `long` passes `LONGFORM_EMBED_CHARS`; `long` alone when there
880
+ * is no synopsis. A long row's synopsis is a handle, not a summary (median
881
+ * 10% of the long form), so embedding it alone leaves the body unreachable
882
+ * by dense recall.
883
+ */
884
+ function embedText(synopsis, long) {
885
+ const syn = typeof synopsis === "string" ? synopsis : "";
886
+ if (syn === "") return long;
887
+ return long.length > LONGFORM_EMBED_CHARS ? `${syn}\n\n${long}` : syn;
888
+ }
889
+ /**
890
+ * Join the parts of a `lexical.source` into one indexable string.
891
+ *
892
+ * Empty parts are dropped rather than left as blank paragraphs, and an
893
+ * all-empty result becomes `null` — the same un-indexable sentinel
894
+ * `embed.source` uses, so a row with no long-form text is left out of the
895
+ * index rather than entered at the empty string.
896
+ */
897
+ function lexicalText(...parts) {
898
+ const kept = parts.filter((p) => typeof p === "string" && p.length > 0);
899
+ return kept.length === 0 ? null : kept.join("\n\n");
900
+ }
901
+ const types = {
902
+ SessionLog: {
903
+ fields: {
904
+ branch: field.string(),
905
+ startedAt: field.date(),
906
+ summary: field.string({ indexed: false }),
907
+ synopsis: field.string({ nullable: true }),
908
+ decisionsCount: field.number(),
909
+ packages: field.string(),
910
+ body: field.string({
911
+ nullable: true,
912
+ indexed: false
913
+ }),
914
+ tags: field.list(field.string(), { nullable: true }),
915
+ archived: field.boolean({ default: false }),
916
+ project: field.string({ nullable: true })
917
+ },
918
+ embed: { source: (r) => embedText(r.synopsis, typeof r.summary === "string" ? r.summary : "") },
919
+ /**
920
+ * The LEXICAL index takes the long form, where `embed` above takes the
921
+ * handle. That asymmetry is the whole point of the arm: `embed.source`
922
+ * is conventionally `synopsis || <long form>`, and a dense vector cannot
923
+ * recover the proper nouns the synopsis drops — most synopses drop a
924
+ * capitalised token that IS present in the long form.
925
+ *
926
+ * The synopsis is included ALONGSIDE the long form rather than replaced by
927
+ * it. A rare token in a synopsis is worth finding too, and unlike vector
928
+ * concatenation this is not dilution: a lexical index has no centroid to
929
+ * pull off-target, so more text only adds reachable tokens and costs index
930
+ * size.
931
+ *
932
+ * Note `lexical.source` takes `(row)` only — there is no `ctx`, so unlike
933
+ * `embed.source` it cannot follow a crossRef to inline related text.
934
+ */
935
+ lexical: { source: (r) => lexicalText(r.synopsis, r.summary, r.body) }
936
+ },
937
+ Decision: {
938
+ fields: {
939
+ topic: field.string(),
940
+ choice: field.string(),
941
+ rationale: field.string({ indexed: false }),
942
+ sessionId: field.crossRef(ns, "SessionLog", {
943
+ nullable: true,
944
+ onDelete: "set_null"
945
+ }),
946
+ supersedes: field.crossRef(ns, "Decision", {
947
+ nullable: true,
948
+ onDelete: "set_null",
949
+ spread: true
950
+ }),
951
+ status: field.vocab(DECISION_STATUS_VOCAB, {
952
+ aliases: DECISION_STATUS_ALIASES,
953
+ strict: false,
954
+ default: "active"
955
+ }),
956
+ tags: field.list(field.string(), { nullable: true }),
957
+ revisitWhen: field.string({
958
+ nullable: true,
959
+ indexed: false
960
+ }),
961
+ establishesConcept: field.crossRef(ns, "Concept", {
962
+ nullable: true,
963
+ onDelete: "set_null",
964
+ spread: true,
965
+ expandCrossType: true
966
+ }),
967
+ archived: field.boolean({ default: false }),
968
+ project: field.string({ nullable: true })
969
+ },
970
+ embed: { source: async (r, ctx) => {
971
+ const base = `${typeof r.topic === "string" ? r.topic : ""}\n\n${typeof r.choice === "string" ? r.choice : ""}\n\n${typeof r.rationale === "string" ? r.rationale : ""}`;
972
+ if (ctx === void 0 || typeof r.establishesConcept !== "string") return base;
973
+ const conceptName = await resolveConceptName(ctx, r.establishesConcept);
974
+ return conceptName !== null ? `${base}\n\n[concept] ${conceptName}` : base;
975
+ } },
976
+ /**
977
+ * Decision is the one type whose `embed.source` is ALREADY the long form,
978
+ * so it gains least here — declared anyway, deliberately, so that "which
979
+ * types are lexically indexed" has no per-type exception to remember.
980
+ *
981
+ * It is not a no-op: `embed.source` inlines the linked Concept's NAME via
982
+ * `ctx.read`, and `lexical.source` gets no `ctx`, so the two texts differ
983
+ * on any Decision carrying `establishesConcept`.
984
+ */
985
+ lexical: { source: (r) => lexicalText(r.topic, r.choice, r.rationale) }
986
+ },
987
+ Todo: { fields: {
988
+ title: field.string(),
989
+ area: field.string(),
990
+ status: field.vocab(TODO_LIFECYCLE_VOCAB, {
991
+ aliases: TODO_STATUS_ALIASES,
992
+ implies: TODO_STATUS_IMPLIES
993
+ }),
994
+ resolution: field.vocab(TODO_RESOLUTION_VOCAB, {
995
+ aliases: TODO_RESOLUTION_ALIASES,
996
+ nullable: true
997
+ }),
998
+ priority: field.enum(TODO_PRIORITY_VOCAB),
999
+ notes: field.string({
1000
+ nullable: true,
1001
+ indexed: false
1002
+ }),
1003
+ blockedBy: field.string({
1004
+ nullable: true,
1005
+ indexed: false
1006
+ }),
1007
+ revisitWhen: field.string({
1008
+ nullable: true,
1009
+ indexed: false
1010
+ }),
1011
+ body: field.string({
1012
+ nullable: true,
1013
+ indexed: false
1014
+ }),
1015
+ tags: field.list(field.string(), {
1016
+ nullable: true,
1017
+ indexed: true
1018
+ }),
1019
+ archived: field.boolean({ default: false }),
1020
+ targetDate: field.date({ nullable: true }),
1021
+ implementsDecision: field.crossRef(ns, "Decision", {
1022
+ nullable: true,
1023
+ onDelete: "set_null"
1024
+ }),
1025
+ owner: field.crossRef(ns, "AgentIdentity", {
1026
+ nullable: true,
1027
+ onDelete: "set_null"
1028
+ }),
1029
+ project: field.string({ nullable: true })
1030
+ } },
1031
+ AgentRule: {
1032
+ fields: {
1033
+ rule: field.string({ indexed: false }),
1034
+ category: field.vocab(AGENT_RULE_CATEGORY_VOCAB, { strict: false }),
1035
+ severity: field.vocab(AGENT_RULE_SEVERITY_VOCAB, {
1036
+ aliases: AGENT_RULE_SEVERITY_ALIASES,
1037
+ nullable: true
1038
+ }),
1039
+ active: field.boolean(),
1040
+ pinned: field.boolean({ nullable: true }),
1041
+ synopsis: field.string({ nullable: true }),
1042
+ rationale: field.string({ indexed: false }),
1043
+ expressesConcept: field.crossRef(ns, "Concept", {
1044
+ nullable: true,
1045
+ onDelete: "set_null"
1046
+ }),
1047
+ archived: field.boolean({ default: false }),
1048
+ project: field.string({ nullable: true })
1049
+ },
1050
+ embed: { source: (r) => {
1051
+ return `${embedText(r.synopsis, typeof r.rule === "string" ? r.rule : "")}${typeof r.category === "string" && r.category.length > 0 ? ` [${r.category}]` : ""}`;
1052
+ } },
1053
+ /**
1054
+ * `rationale` is included beside `rule` on the same reasoning as
1055
+ * SessionLog.body: it is long-form unindexed
1056
+ * prose carrying proper nouns, and the no-dilution argument above means
1057
+ * including it costs only index size.
1058
+ *
1059
+ * `category` is deliberately NOT folded in the way `embed.source` folds
1060
+ * it. There it is a topical anchor that steers a vector; here it would
1061
+ * just be a token repeated across every rule in the category, which is
1062
+ * what a filter on the indexed `category` field already does better.
1063
+ */
1064
+ lexical: { source: (r) => lexicalText(r.synopsis, r.rule, r.rationale) }
1065
+ },
1066
+ CouncilSession: { fields: {
1067
+ question: field.string({ indexed: false }),
1068
+ slug: field.string(),
1069
+ context: field.string({ indexed: false }),
1070
+ options: field.string({ indexed: false }),
1071
+ agreement: field.string({ indexed: false }),
1072
+ disagreement: field.string({ indexed: false }),
1073
+ blindSpots: field.string({ indexed: false }),
1074
+ verdict: field.string({ indexed: false }),
1075
+ verdictAlignment: field.string(),
1076
+ sessionLogId: field.crossRef(ns, "SessionLog", {
1077
+ nullable: true,
1078
+ onDelete: "set_null"
1079
+ }),
1080
+ date: field.date(),
1081
+ project: field.string({ nullable: true })
1082
+ } },
1083
+ CouncilAdvisor: { fields: {
1084
+ councilSessionId: field.crossRef(ns, "CouncilSession", {
1085
+ nullable: true,
1086
+ onDelete: "cascade"
1087
+ }),
1088
+ role: field.string(),
1089
+ response: field.string({ indexed: false }),
1090
+ recommendation: field.string({ indexed: false })
1091
+ } },
1092
+ Memory: {
1093
+ fields: {
1094
+ name: field.string(),
1095
+ description: field.string(),
1096
+ type: field.string(),
1097
+ tags: field.list(field.string(), { nullable: true }),
1098
+ body: field.string({ indexed: false }),
1099
+ synopsis: field.string({ nullable: true }),
1100
+ archived: field.boolean({ default: false }),
1101
+ project: field.string({ nullable: true })
1102
+ },
1103
+ embed: { source: (r) => {
1104
+ const desc = typeof r.description === "string" ? r.description : "";
1105
+ const body = typeof r.body === "string" ? r.body : "";
1106
+ return embedText(r.synopsis, `${desc}\n\n${body}`);
1107
+ } },
1108
+ lexical: { source: (r) => lexicalText(r.synopsis, r.description, r.body) }
1109
+ },
1110
+ AnchorIntent: {
1111
+ fields: {
1112
+ synopsis: field.string(),
1113
+ body: field.string({
1114
+ nullable: true,
1115
+ indexed: false
1116
+ }),
1117
+ authoredBy: field.struct({ kind: field.string() }),
1118
+ project: field.string({ nullable: true }),
1119
+ archived: field.boolean({ default: false })
1120
+ },
1121
+ embed: { source: (r) => embedText(r.synopsis, typeof r.body === "string" ? r.body : "") },
1122
+ lexical: { source: (r) => lexicalText(r.synopsis, r.body) }
1123
+ },
1124
+ Concept: {
1125
+ fields: {
1126
+ name: field.string(),
1127
+ description: field.string(),
1128
+ kind: field.string(),
1129
+ body: field.string({ indexed: false }),
1130
+ tags: field.list(field.string(), { nullable: true }),
1131
+ synopsis: field.string({ nullable: true }),
1132
+ archived: field.boolean({ default: false }),
1133
+ project: field.string({ nullable: true })
1134
+ },
1135
+ embed: { source: (r) => {
1136
+ const desc = typeof r.description === "string" ? r.description : "";
1137
+ const body = typeof r.body === "string" ? r.body : "";
1138
+ return embedText(r.synopsis, `${desc}\n\n${body}`);
1139
+ } },
1140
+ lexical: { source: (r) => lexicalText(r.synopsis, r.description, r.body) }
1141
+ },
1142
+ ConceptLink: { fields: {
1143
+ fromConcept: field.crossRef(ns, "Concept", { onDelete: "cascade" }),
1144
+ toConcept: field.crossRef(ns, "Concept", { onDelete: "cascade" }),
1145
+ kind: field.string(),
1146
+ note: field.string({
1147
+ nullable: true,
1148
+ indexed: false
1149
+ }),
1150
+ archived: field.boolean({ default: false }),
1151
+ project: field.string({ nullable: true })
1152
+ } },
1153
+ Skill: { fields: {
1154
+ name: field.string(),
1155
+ description: field.string(),
1156
+ body: field.string({ indexed: false }),
1157
+ version: field.string(),
1158
+ active: field.boolean()
1159
+ } },
1160
+ AgentIdentity: { fields: {
1161
+ kid: field.string(),
1162
+ project: field.string({ nullable: true }),
1163
+ host: field.string(),
1164
+ displayName: field.string({ nullable: true }),
1165
+ description: field.string({
1166
+ nullable: true,
1167
+ indexed: false
1168
+ }),
1169
+ active: field.boolean({ default: true }),
1170
+ broadcastEligible: field.boolean({ default: true }),
1171
+ roaming: field.boolean({ default: false })
1172
+ } },
1173
+ AgentMessage: {
1174
+ autoCrud: false,
1175
+ fields: {
1176
+ shortId: field.number({
1177
+ indexed: true,
1178
+ readonly: true
1179
+ }),
1180
+ recipient: field.crossRef(ns, "AgentIdentity", {
1181
+ nullable: true,
1182
+ onDelete: "set_null"
1183
+ }),
1184
+ subject: field.string(),
1185
+ body: field.string({ indexed: false }),
1186
+ status: field.enum(AGENT_MESSAGE_STATUS_VOCAB, { default: "unread" }),
1187
+ ackedBy: field.string({ nullable: true }),
1188
+ ackedAt: field.date({
1189
+ nullable: true,
1190
+ serverSet: "both"
1191
+ }),
1192
+ inReplyTo: field.crossRef(ns, "AgentMessage", {
1193
+ nullable: true,
1194
+ onDelete: "set_null",
1195
+ spread: true
1196
+ }),
1197
+ tags: field.list(field.string(), { nullable: true }),
1198
+ instance: field.string({ nullable: true }),
1199
+ archived: field.boolean({ default: false })
1200
+ }
1201
+ },
1202
+ HostInstall: { fields: {
1203
+ key: field.string(),
1204
+ host: field.string(),
1205
+ hostReported: field.string(),
1206
+ project: field.string(),
1207
+ kid: field.string({ nullable: true }),
1208
+ harness: field.string(),
1209
+ pluginVersion: field.string({ nullable: true }),
1210
+ fragmentVersion: field.string({ nullable: true }),
1211
+ fragmentDigest: field.string({ nullable: true }),
1212
+ fragmentState: field.enum(HOST_INSTALL_FRAGMENT_STATES),
1213
+ refreshedAt: field.date(),
1214
+ pinState: field.enum(HOST_INSTALL_CHECK_STATES, { nullable: true }),
1215
+ pinNote: field.string({
1216
+ nullable: true,
1217
+ indexed: false
1218
+ }),
1219
+ mcpJsonState: field.enum(HOST_INSTALL_CHECK_STATES, { nullable: true }),
1220
+ mcpJsonNote: field.string({
1221
+ nullable: true,
1222
+ indexed: false
1223
+ }),
1224
+ authorityState: field.enum(HOST_INSTALL_CHECK_STATES, { nullable: true }),
1225
+ authorityNote: field.string({
1226
+ nullable: true,
1227
+ indexed: false
1228
+ }),
1229
+ settingsState: field.enum(HOST_INSTALL_CHECK_STATES, { nullable: true }),
1230
+ settingsNote: field.string({
1231
+ nullable: true,
1232
+ indexed: false
1233
+ }),
1234
+ settingsEnv: field.json({ nullable: true })
1235
+ } }
1236
+ };
1237
+ const definition = defineNamespace(ns, types, {
1238
+ annotations: { nl: CORTEXT_NL_RULINGS },
1239
+ promotedColumns: CORTEXT_PROMOTED_COLUMNS
1240
+ });
1241
+ const baseInstall = definition.install.bind(definition);
1242
+ return {
1243
+ ...definition,
1244
+ install(ctx) {
1245
+ baseInstall(ctx);
1246
+ installMailboxMutations(ctx, definition.types.AgentMessage.fields);
1247
+ for (const [name, wrap] of [["updateAgentIdentity", (r) => identitySelfOnly(ctx, "updateAgentIdentity", r)], ["deleteAgentIdentity", (r) => operatorOnly("deleteAgentIdentity", r)]]) {
1248
+ const entry = ctx.registry.get(name);
1249
+ if (entry === void 0) throw new Error(`cortext: autoCRUD did not register ${name}`);
1250
+ ctx.registry.replace(name, {
1251
+ ...entry,
1252
+ resolver: wrap(entry.resolver)
1253
+ });
1254
+ }
1255
+ }
1256
+ };
1257
+ }
1258
+ /** The canonical ordered list of type names registered by this namespace. */
1259
+ const CLAUDE_TYPE_NAMES = [
1260
+ "SessionLog",
1261
+ "Decision",
1262
+ "Todo",
1263
+ "AgentRule",
1264
+ "CouncilSession",
1265
+ "CouncilAdvisor",
1266
+ "Memory",
1267
+ "AnchorIntent",
1268
+ "Concept",
1269
+ "ConceptLink",
1270
+ "Skill",
1271
+ "AgentIdentity",
1272
+ "AgentMessage",
1273
+ "HostInstall"
1274
+ ];
1275
+ /**
1276
+ * Architecture namespace — Claude-lineage code-review findings. Kept separate
1277
+ * from the claude context namespace so lifecycle (compaction, retention,
1278
+ * audit) differs: architectural findings outlive session-scoped rationale.
1279
+ *
1280
+ * Single type today (ArchitectureConcern). Notes:
1281
+ * - `archived` is `field.boolean()` to match every other type that carries
1282
+ * archived state.
1283
+ * - `supersedes` is a self-ref `field.crossRef` edge with `set_null` —
1284
+ * a concern survives the one it superseded going away.
1285
+ */
1286
+ function buildArchitectureNamespace(ns) {
1287
+ return defineNamespace(ns, { ArchitectureConcern: { fields: {
1288
+ title: field.string(),
1289
+ status: field.string(),
1290
+ archived: field.boolean({ default: false }),
1291
+ category: field.string(),
1292
+ evidence: field.string({ indexed: false }),
1293
+ proposal: field.string({ indexed: false }),
1294
+ severity: field.string(),
1295
+ supersedes: field.crossRef(ns, "ArchitectureConcern", {
1296
+ nullable: true,
1297
+ onDelete: "set_null"
1298
+ }),
1299
+ revisitWhen: field.string({
1300
+ nullable: true,
1301
+ indexed: false
1302
+ }),
1303
+ affectsTypes: field.string(),
1304
+ discoveredIn: field.string(),
1305
+ affectsPackages: field.string(),
1306
+ project: field.string({ nullable: true })
1307
+ } } }, { promotedColumns: CORTEXT_PROMOTED_COLUMNS });
1308
+ }
1309
+ const ARCHITECTURE_TYPE_NAMES = ["ArchitectureConcern"];
1310
+
1311
+ //#endregion
1312
+ //#region src/onboard.ts
1313
+ /**
1314
+ * Render a controlled vocabulary as the agent-facing union string the onboard
1315
+ * surface uses (e.g. `'open' | 'done'`). Sourced from the kernel's exported
1316
+ * vocab constants so this description can't drift from the `field.enum` + pg
1317
+ * CHECK that actually enforce it.
1318
+ */
1319
+ function vocabUnion(values) {
1320
+ return values.map((v) => `'${v}'`).join(" | ");
1321
+ }
1322
+ /** Render an alias table as `'a' → 'b', …` for the onboarding contract. */
1323
+ function aliasList(aliases) {
1324
+ return Object.entries(aliases).map(([alias, canonical]) => `'${alias}' → '${canonical}'`).join(", ");
1325
+ }
1326
+ /**
1327
+ * The AgentMessage write surface is its custom mutation family, NOT autoCRUD:
1328
+ * the type declares `autoCrud: false`, so `createAgentMessage` is never
1329
+ * registered and the schema never advertises it (sendAgentMessage is the only
1330
+ * send path, with kid addressing + '*' fan-out), and status transitions go
1331
+ * through markAgentMessageRead / ackAgentMessage exclusively. updateAgentMessage survives for
1332
+ * non-transition fields (tags, archived); deleteAgentMessage is standard.
1333
+ */
1334
+ const AGENT_MESSAGE_MUTATIONS = [
1335
+ "sendAgentMessage",
1336
+ "markAgentMessageRead",
1337
+ "ackAgentMessage",
1338
+ "updateAgentMessage",
1339
+ "deleteAgentMessage"
1340
+ ];
1341
+ /**
1342
+ * Derived from CLAUDE_TYPE_NAMES so a newly registered type cannot be
1343
+ * silently missing from the advertised mutation surface — the same
1344
+ * drift-proofing move vocabUnion makes for vocabularies.
1345
+ */
1346
+ function buildMutations() {
1347
+ const mutations = {};
1348
+ for (const typeName of CLAUDE_TYPE_NAMES) mutations[typeName] = typeName === "AgentMessage" ? AGENT_MESSAGE_MUTATIONS : [
1349
+ `create${typeName}`,
1350
+ `update${typeName}`,
1351
+ `delete${typeName}`
1352
+ ];
1353
+ return mutations;
1354
+ }
1355
+ function buildBody(namespace, retention) {
1356
+ return {
1357
+ plugin: "cortext",
1358
+ namespace,
1359
+ description: "Persistent context store for Claude Code sessions",
1360
+ retention,
1361
+ types: {
1362
+ SessionLog: {
1363
+ purpose: "One entry per development session or stable milestone",
1364
+ fields: {
1365
+ id: "auto-generated",
1366
+ branch: "git branch name",
1367
+ startedAt: "ISO 8601 UTC datetime",
1368
+ summary: "one-paragraph session summary (not indexed)",
1369
+ decisionsCount: "count of decisions made this session",
1370
+ packages: "comma-separated list of packages touched",
1371
+ archived: "true if compacted"
1372
+ },
1373
+ writeWhen: ["At each stable milestone (PR merged, feature complete, significant fix)", "Not just at session end — a dropped session should lose at most one milestone"]
1374
+ },
1375
+ Decision: {
1376
+ purpose: "Architectural and design choices worth preserving across sessions",
1377
+ fields: {
1378
+ id: "auto-generated",
1379
+ topic: "short topic label",
1380
+ choice: "what was decided",
1381
+ rationale: "why (not indexed)",
1382
+ sessionId: "UUID crossRef to SessionLog, set_null on delete (nullable)",
1383
+ createdAt: "ISO 8601 UTC datetime",
1384
+ supersedes: "UUID crossRef to the Decision this replaces, set_null on delete (nullable)",
1385
+ status: `open vocabulary, not enforced; states this vocabulary documents (not a reading of what is stored): ${DECISION_STATUS_VOCAB.join(", ")}; synonyms normalised by the server: ${aliasList(DECISION_STATUS_ALIASES)}`,
1386
+ revisitWhen: "trigger condition for revisiting a deferred decision (not indexed)",
1387
+ archived: "true if compacted"
1388
+ },
1389
+ writeWhen: ["Immediately when a decision is made — do not defer to session end"]
1390
+ },
1391
+ Todo: {
1392
+ purpose: "Tracked work items and deferred tasks",
1393
+ fields: {
1394
+ id: "auto-generated",
1395
+ title: "short description",
1396
+ area: "free-text area/component label (e.g. 'substrate-core', 'infrastructure'); not schema-enforced",
1397
+ status: `${vocabUnion(TODO_LIFECYCLE_VOCAB)} (lifecycle only); closing words set the resolution too: ${aliasList(TODO_STATUS_ALIASES)}`,
1398
+ resolution: `${vocabUnion(TODO_RESOLUTION_VOCAB)} (nullable): why a closed Todo stopped`,
1399
+ priority: vocabUnion(TODO_PRIORITY_VOCAB),
1400
+ notes: "additional detail (not indexed)",
1401
+ blockedBy: "free-text description of the blocker (not indexed)",
1402
+ implementsDecision: "UUID crossRef to Decision, set_null on delete (nullable)",
1403
+ createdAt: "ISO 8601 UTC datetime",
1404
+ archived: "true if compacted"
1405
+ },
1406
+ writeWhen: ["On creation — do not batch to session end", "Freely without asking — when a gap, improvement, or deferred item is identified"]
1407
+ },
1408
+ AgentRule: {
1409
+ purpose: "Operational rule for agent behaviour, loaded into context at session start",
1410
+ fields: {
1411
+ id: "auto-generated",
1412
+ rule: "full rule body (not indexed)",
1413
+ category: `open vocabulary; categories in use: ${AGENT_RULE_CATEGORY_VOCAB.join(", ")}`,
1414
+ severity: `${vocabUnion(AGENT_RULE_SEVERITY_VOCAB)} (nullable); synonyms normalised by the server: ${aliasList(AGENT_RULE_SEVERITY_ALIASES)}`,
1415
+ active: "only active rules load at cold-start",
1416
+ pinned: "always-on rules exempt from salience ranking; absent = unpinned (nullable)",
1417
+ synopsis: "short retrieval-optimized summary; primary embedding source (nullable)",
1418
+ rationale: "why the rule exists (not indexed)",
1419
+ expressesConcept: "UUID crossRef to Concept, set_null on delete (nullable)",
1420
+ archived: "true if retired"
1421
+ },
1422
+ writeWhen: ["When the user gives durable operational guidance — corrections and confirmed approaches alike"]
1423
+ },
1424
+ CouncilSession: {
1425
+ purpose: "One pressure-test deliberation: question, advisor synthesis, verdict",
1426
+ fields: {
1427
+ id: "auto-generated",
1428
+ question: "the decision under pressure-test (not indexed)",
1429
+ slug: "short kebab-case handle",
1430
+ context: "framing given to the advisors (not indexed)",
1431
+ options: "candidate options considered (not indexed)",
1432
+ agreement: "where the advisors agreed (not indexed)",
1433
+ disagreement: "where the advisors split (not indexed)",
1434
+ blindSpots: "risks no option addresses (not indexed)",
1435
+ verdict: "final synthesis (not indexed)",
1436
+ verdictAlignment: "how aligned the verdict is with advisor consensus",
1437
+ sessionLogId: "UUID crossRef to SessionLog, set_null on delete (nullable)",
1438
+ date: "ISO 8601 date of the deliberation"
1439
+ },
1440
+ writeWhen: ["At the end of a council/pressure-test run, with one CouncilAdvisor row per advisor"]
1441
+ },
1442
+ CouncilAdvisor: {
1443
+ purpose: "One advisor's response within a CouncilSession",
1444
+ fields: {
1445
+ id: "auto-generated",
1446
+ councilSessionId: "UUID crossRef to CouncilSession, cascade on delete (nullable)",
1447
+ role: "advisor persona / thinking style",
1448
+ response: "full advisor response (not indexed)",
1449
+ recommendation: "the advisor's bottom-line recommendation (not indexed)"
1450
+ },
1451
+ writeWhen: ["Alongside its parent CouncilSession — an advisor row is meaningless without one"]
1452
+ },
1453
+ Memory: {
1454
+ purpose: "Persistent agent note that survives across conversations",
1455
+ fields: {
1456
+ id: "auto-generated",
1457
+ name: "canonical human label; deduped by convention, not schema",
1458
+ description: "one-line hook shown in the session-start index",
1459
+ type: "category: 'user' | 'feedback' | 'project' | 'reference' (convention, not schema-enforced)",
1460
+ body: "full memory body (not indexed)",
1461
+ synopsis: "short retrieval-optimized compression; preferred embedding source (nullable)",
1462
+ archived: "true if retired"
1463
+ },
1464
+ writeWhen: ["When a durable fact, preference, or lesson surfaces that the repo itself does not record"]
1465
+ },
1466
+ AnchorIntent: {
1467
+ purpose: "Topical anchor for hybrid recall — \"what I am focused on right now\"",
1468
+ fields: {
1469
+ id: "auto-generated",
1470
+ synopsis: "short intent summary (1-3 sentences); primary embedding source",
1471
+ body: "optional long-form continuation (not indexed, nullable)",
1472
+ authoredBy: "provenance struct, starts with kind: 'user' | 'agent'",
1473
+ project: "project scope; null = cross-project",
1474
+ archived: "true if retired"
1475
+ },
1476
+ writeWhen: ["At session boot if the newest anchor is stale, and at any material focus shift", "The newest non-archived row in scope IS the current anchor — there is no active flag"]
1477
+ },
1478
+ Concept: {
1479
+ purpose: "Operational semantics of a domain entity — the conceptual layer above types",
1480
+ fields: {
1481
+ id: "auto-generated",
1482
+ name: "canonical human label; deduped by write-path convention",
1483
+ description: "one-line hook for the cold-start frame",
1484
+ kind: "domain classifier (e.g. 'type', 'protocol', 'pattern', 'role', 'convention'); free text",
1485
+ body: "full operational semantics (not indexed)",
1486
+ synopsis: "short embedding-side compression (nullable)",
1487
+ archived: "true if retired"
1488
+ },
1489
+ writeWhen: ["When operational semantics stabilise around an entity worth teaching future sessions"]
1490
+ },
1491
+ ConceptLink: {
1492
+ purpose: "Typed edge between two Concepts",
1493
+ fields: {
1494
+ id: "auto-generated",
1495
+ fromConcept: "UUID crossRef to the source Concept, cascade on delete",
1496
+ toConcept: "UUID crossRef to the target Concept, cascade on delete",
1497
+ kind: "edge kind: 'depends_on' | 'specializes' | 'see_also' | 'extends' (convention, not schema-enforced)",
1498
+ note: "optional free-text annotation (not indexed, nullable)"
1499
+ },
1500
+ writeWhen: ["When a relationship between concepts carries operational meaning"]
1501
+ },
1502
+ Skill: {
1503
+ purpose: "Reusable agent-behaviour definition (cross-project)",
1504
+ fields: {
1505
+ id: "auto-generated",
1506
+ name: "skill name referenced by disk-side pointer stubs",
1507
+ description: "when to invoke",
1508
+ body: "full skill body (not indexed)",
1509
+ version: "skill version string",
1510
+ active: "inactive skills are retired"
1511
+ },
1512
+ writeWhen: ["When authoring or revising a reusable agent behaviour"]
1513
+ },
1514
+ AgentIdentity: {
1515
+ purpose: "Directory of registered agent identities — the social/discovery layer over _system/Key auth",
1516
+ fields: {
1517
+ id: "auto-generated",
1518
+ kid: "wire identity (e.g. 'peer-claude@host-a'); matches a registered _system/Key kid by convention",
1519
+ project: "repo basename; null = cross-cutting/fleet identity",
1520
+ host: "machine hostname",
1521
+ displayName: "optional human-friendly name (nullable)",
1522
+ description: "optional blurb (not indexed, nullable)",
1523
+ active: "retired/revoked identities flip false",
1524
+ broadcastEligible: "false = a to:'*' broadcast skips this identity",
1525
+ roaming: "true = works whichever repo it is sent into on its own host (no project); the fleet audit accepts it as any repo's HostInstall writer there"
1526
+ },
1527
+ writeWhen: ["On identity registration (normally via /substrate-setup or substrate-admin register)"]
1528
+ },
1529
+ AgentMessage: {
1530
+ purpose: "Durable agent-to-agent mail (1:1 + cohort fan-out)",
1531
+ fields: {
1532
+ id: "auto-generated",
1533
+ recipient: "UUID crossRef to AgentIdentity, set_null on delete (nullable). Address by kid via sendAgentMessage; the sender is NOT a field — it is _metadata.createdBy, server-stamped from the signed principal",
1534
+ subject: "message subject",
1535
+ body: "message body (not indexed)",
1536
+ status: `${vocabUnion(AGENT_MESSAGE_STATUS_VOCAB)} — recipient-only lifecycle, transitioned via markAgentMessageRead / ackAgentMessage exclusively`,
1537
+ ackedBy: "ack attribution kid, server-stamped by ackAgentMessage (nullable)",
1538
+ ackedAt: "ack timestamp, server-stamped by ackAgentMessage (nullable)",
1539
+ inReplyTo: "UUID crossRef to the AgentMessage this replies to, set_null on delete (nullable)",
1540
+ archived: "true once compacted (acked mail past the retention window) or manually archived"
1541
+ },
1542
+ writeWhen: ["Send via sendAgentMessage with `to` as a kid list ('*' fans out to all active identities except the sender)", "Recipients mark lifecycle with markAgentMessageRead / ackAgentMessage"]
1543
+ },
1544
+ HostInstall: {
1545
+ purpose: "What one host has installed for one repo under one harness, upserted by the cold-start each session; check refreshedAt before trusting it (fresh < 24h, quiet < 7d, stale beyond)",
1546
+ fields: {
1547
+ id: "auto-generated",
1548
+ key: "`<host>/<project>/<harness>`, unique; the upsert key",
1549
+ host: "normalised hostname (lowercase, domain dropped)",
1550
+ hostReported: "raw hostname as the host reported it",
1551
+ project: "repo basename",
1552
+ kid: "the identity the repo signs as (nullable)",
1553
+ harness: "claude-code | opencode | hook",
1554
+ pluginVersion: "plugin version that served the session (nullable)",
1555
+ fragmentVersion: "installed conventions fragment version (nullable)",
1556
+ fragmentDigest: "installed conventions fragment digest (nullable)",
1557
+ fragmentState: `${vocabUnion(HOST_INSTALL_FRAGMENT_STATES)} — the cold-start's fragment drift verdict`,
1558
+ refreshedAt: "last cold-start report, written only by the cold-start; the liveness signal",
1559
+ pinState: `${vocabUnion(HOST_INSTALL_CHECK_STATES)} — marketplace pin vs the served plugin (nullable; claude-code only)`,
1560
+ pinNote: "the pin check's worst finding (nullable)",
1561
+ mcpJsonState: `${vocabUnion(HOST_INSTALL_CHECK_STATES)} — the repo's .mcp.json (nullable)`,
1562
+ mcpJsonNote: "the .mcp.json check's worst finding (nullable)",
1563
+ authorityState: `${vocabUnion(HOST_INSTALL_CHECK_STATES)} — whether the key works and its grant covers cortext (nullable)`,
1564
+ authorityNote: "the authority check's worst finding (nullable)",
1565
+ settingsState: `${vocabUnion(HOST_INSTALL_CHECK_STATES)} — settings.local.json parses and enables the MCP server (nullable)`,
1566
+ settingsNote: "the settings check's worst finding (nullable)",
1567
+ settingsEnv: "json — allowlisted non-secret switch values, plus whether the identity keys are set (nullable)"
1568
+ },
1569
+ writeWhen: ["Written by the cold-start of every session with an agent identity; agents read it, they do not write it"]
1570
+ }
1571
+ },
1572
+ mutations: buildMutations(),
1573
+ endpoints: {
1574
+ query: {
1575
+ method: "POST",
1576
+ path: "/substrate/v1/query"
1577
+ },
1578
+ mutate: {
1579
+ method: "POST",
1580
+ path: "/substrate/v1/mutate"
1581
+ },
1582
+ compact: {
1583
+ method: "POST",
1584
+ path: "/substrate/v1/plugins/cortext/compact"
1585
+ },
1586
+ ws: {
1587
+ path: "/substrate/v1/ws",
1588
+ namespace
1589
+ }
1590
+ },
1591
+ contextLoading: { onSessionStart: {
1592
+ description: "Load recent context at the start of each session",
1593
+ query: {
1594
+ SessionLog: {
1595
+ params: { limit: 3 },
1596
+ fields: {
1597
+ id: true,
1598
+ branch: true,
1599
+ startedAt: true,
1600
+ summary: true,
1601
+ archived: true
1602
+ }
1603
+ },
1604
+ Decision: {
1605
+ params: { limit: 80 },
1606
+ fields: {
1607
+ id: true,
1608
+ topic: true,
1609
+ choice: true,
1610
+ rationale: true,
1611
+ sessionId: true,
1612
+ status: true,
1613
+ revisitWhen: true,
1614
+ archived: true
1615
+ }
1616
+ },
1617
+ Todo: {
1618
+ params: { limit: 50 },
1619
+ fields: {
1620
+ id: true,
1621
+ title: true,
1622
+ area: true,
1623
+ status: true,
1624
+ priority: true,
1625
+ notes: true,
1626
+ blockedBy: true,
1627
+ archived: true
1628
+ }
1629
+ }
1630
+ },
1631
+ surfaceToUser: [
1632
+ "The 3 most recent non-archived SessionLog entries",
1633
+ "Non-archived Decisions",
1634
+ "Todos with status 'open' or 'blocked'",
1635
+ "Skip archived records silently"
1636
+ ]
1637
+ } },
1638
+ compaction: {
1639
+ description: "Archives old sessions and their decisions, keeping the most recent N live",
1640
+ default: {
1641
+ retention,
1642
+ route: "POST /substrate/v1/plugins/cortext/compact"
1643
+ },
1644
+ dryRun: {
1645
+ body: { dryRun: true },
1646
+ description: "Preview without committing"
1647
+ }
1648
+ }
1649
+ };
1650
+ }
1651
+ function registerOnboardRoute(router, _ctx, retention, namespace) {
1652
+ const body = buildBody(namespace, retention);
1653
+ const serialised = Buffer.from(JSON.stringify(body), "utf8");
1654
+ router.get("/onboard", () => {
1655
+ return {
1656
+ status: 200,
1657
+ headers: JSON_HEADERS,
1658
+ body: serialised
1659
+ };
1660
+ });
1661
+ }
1662
+
1663
+ //#endregion
1664
+ //#region src/release-surface.ts
1665
+ /**
1666
+ * The plugin-facing surface, snapshotted so a release can be SIZED rather than
1667
+ * judged.
1668
+ *
1669
+ * ## Why this exists
1670
+ *
1671
+ * A release-class guard that defines the surface as a list of paths passes a
1672
+ * major as a patch with a green check the moment a type moves out of those
1673
+ * paths: `git diff` on the stale path is empty, so it approves.
1674
+ *
1675
+ * cortext's propagation is heavy — a
1676
+ * `packages/cortext/src` change reaches consumers as an npm tarball loaded by
1677
+ * the substrate runtime from `SUBSTRATE_PLUGIN_DIR`, needing a publish **and** a
1678
+ * runtime deploy. npm publishes are immutable, so a mis-sized release cannot be
1679
+ * withdrawn, only superseded.
1680
+ *
1681
+ * ## Why it resolves through the code, not through paths
1682
+ *
1683
+ * A path list rots silently. This calls
1684
+ * `buildClaudeNamespace()` and walks what it returns, so the surface is
1685
+ * DERIVED from the thing it describes. A type or field cannot move out from
1686
+ * under it, because there is no "under it" — moving a declaration between files
1687
+ * changes nothing here, and that is the point.
1688
+ *
1689
+ * The committed `release-surface.json` is kept honest by
1690
+ * `test/release-surface.test.ts`, exactly as `config.schema.json` is kept
1691
+ * honest by `schema-parity.test.ts`. So the release script never has to execute
1692
+ * TypeScript or parse a declaration: it diffs two committed JSON snapshots.
1693
+ *
1694
+ * ## What counts as breaking, and why `indexed` is in here
1695
+ *
1696
+ * Removing a type or field, making an optional field REQUIRED, narrowing an
1697
+ * enum, or moving the substrate contract range are all breaks for someone holding
1698
+ * data or code against this schema.
1699
+ *
1700
+ * `indexed` is included and it is not cosmetic: un-indexing a field turns a
1701
+ * working `where` filter into an `unindexed_field` rejection. That is a
1702
+ * runtime break for a caller who changed nothing, and it would otherwise be
1703
+ * invisible to a version-bump decision.
1704
+ */
1705
+ function reduceField(raw) {
1706
+ const nullable = raw.nullable === true;
1707
+ const hasDefault = Object.hasOwn(raw, "default");
1708
+ const readonly = raw.readonly === true;
1709
+ const serverSet = raw.serverSet !== void 0 && raw.serverSet !== false;
1710
+ const out = {
1711
+ kind: typeof raw.kind === "string" ? raw.kind : "unknown",
1712
+ nullable,
1713
+ hasDefault,
1714
+ readonly,
1715
+ serverSet,
1716
+ indexed: raw.indexed === true,
1717
+ required: !(nullable || hasDefault || readonly || serverSet)
1718
+ };
1719
+ if (typeof raw.type === "string") {
1720
+ if (out.kind === "crossRef") out.target = raw.type;
1721
+ else out.type = raw.type;
1722
+ }
1723
+ if (Array.isArray(raw.enum)) out.enum = [...raw.enum].sort();
1724
+ if (out.kind === "list" && raw.of !== null && typeof raw.of === "object") {
1725
+ const inner = raw.of;
1726
+ if (typeof inner.type === "string") out.type = `list<${inner.type}>`;
1727
+ }
1728
+ return out;
1729
+ }
1730
+ /**
1731
+ * Build the canonical surface.
1732
+ *
1733
+ * Keys are sorted so the committed JSON is stable — an unstable snapshot would
1734
+ * produce diff noise on every regeneration and train its reader to skim, which
1735
+ * is the failure this whole file is guarding against one level down.
1736
+ */
1737
+ function buildReleaseSurface(namespace = "cortext") {
1738
+ const ns = buildClaudeNamespace(namespace);
1739
+ const types = {};
1740
+ for (const typeName of Object.keys(ns.types).sort()) {
1741
+ const declared = ns.types[typeName]?.fields ?? {};
1742
+ const fields = {};
1743
+ for (const fieldName of Object.keys(declared).sort()) {
1744
+ const raw = declared[fieldName];
1745
+ if (raw === void 0) continue;
1746
+ fields[fieldName] = reduceField(raw);
1747
+ }
1748
+ types[typeName] = fields;
1749
+ }
1750
+ return {
1751
+ contractVersion: SUBSTRATE_CONTRACT_RANGE,
1752
+ types
1753
+ };
1754
+ }
1755
+ const CLASS_RANK = {
1756
+ patch: 0,
1757
+ minor: 1,
1758
+ major: 2
1759
+ };
1760
+ /**
1761
+ * Classify the difference between two surfaces.
1762
+ *
1763
+ * Reports EVERY reason rather than the first, because a release usually carries
1764
+ * several and a guard that stops at one teaches its reader to fix-and-rerun
1765
+ * instead of reading.
1766
+ */
1767
+ function classifySurfaceChange(before, after) {
1768
+ const breaking = [];
1769
+ const additive = [];
1770
+ const review = [];
1771
+ if (before.contractVersion !== after.contractVersion) breaking.push(`substrate contract range moved ${before.contractVersion} -> ${after.contractVersion}`);
1772
+ for (const typeName of Object.keys(before.types)) {
1773
+ if (!Object.hasOwn(after.types, typeName)) {
1774
+ breaking.push(`type removed: ${typeName}`);
1775
+ continue;
1776
+ }
1777
+ const b = before.types[typeName] ?? {};
1778
+ const a = after.types[typeName] ?? {};
1779
+ for (const fieldName of Object.keys(b)) {
1780
+ const bf = b[fieldName];
1781
+ const af = a[fieldName];
1782
+ const at = `${typeName}.${fieldName}`;
1783
+ if (bf === void 0) continue;
1784
+ if (af === void 0) {
1785
+ breaking.push(`field removed: ${at}`);
1786
+ continue;
1787
+ }
1788
+ if (bf.kind !== af.kind) breaking.push(`field kind changed: ${at} ${bf.kind} -> ${af.kind}`);
1789
+ if (bf.type !== af.type && (bf.type ?? af.type) !== void 0) breaking.push(`field type changed: ${at} ${bf.type ?? "?"} -> ${af.type ?? "?"}`);
1790
+ if (bf.target !== af.target && (bf.target ?? af.target) !== void 0) breaking.push(`crossRef target changed: ${at} ${bf.target ?? "?"} -> ${af.target ?? "?"}`);
1791
+ if (!bf.required && af.required) breaking.push(`field became REQUIRED: ${at}`);
1792
+ if (bf.required && !af.required) additive.push(`field relaxed to optional: ${at}`);
1793
+ if (bf.nullable && !af.nullable) breaking.push(`field no longer nullable: ${at}`);
1794
+ if (bf.indexed && !af.indexed) breaking.push(`field no longer indexed: ${at}`);
1795
+ if (!bf.indexed && af.indexed) additive.push(`field now indexed: ${at}`);
1796
+ const bEnum = bf.enum ?? null;
1797
+ const aEnum = af.enum ?? null;
1798
+ if (bEnum !== null && aEnum === null) additive.push(`enum constraint dropped: ${at}`);
1799
+ if (bEnum === null && aEnum !== null) breaking.push(`enum constraint added: ${at}`);
1800
+ if (bEnum !== null && aEnum !== null) {
1801
+ const removed = bEnum.filter((v) => !aEnum.includes(v));
1802
+ const added = aEnum.filter((v) => !bEnum.includes(v));
1803
+ if (removed.length > 0) breaking.push(`enum narrowed: ${at} lost ${removed.join(", ")}`);
1804
+ if (added.length > 0) additive.push(`enum widened: ${at} gained ${added.join(", ")}`);
1805
+ }
1806
+ }
1807
+ for (const fieldName of Object.keys(a)) {
1808
+ if (Object.hasOwn(b, fieldName)) continue;
1809
+ const af = a[fieldName];
1810
+ if (af === void 0) continue;
1811
+ const at = `${typeName}.${fieldName}`;
1812
+ if (af.required) breaking.push(`field added as REQUIRED: ${at}`);
1813
+ else additive.push(`field added: ${at}`);
1814
+ }
1815
+ }
1816
+ for (const typeName of Object.keys(after.types)) if (!Object.hasOwn(before.types, typeName)) additive.push(`type added: ${typeName}`);
1817
+ const bApi = before.api ?? null;
1818
+ const aApi = after.api ?? null;
1819
+ const bFormat = before.surfaceFormat ?? 1;
1820
+ const aFormat = after.surfaceFormat ?? 1;
1821
+ if (bApi !== null && aApi !== null && bFormat !== aFormat) review.push(`published-API comparison SKIPPED: surface format ${bFormat} -> ${aFormat}. The export naming scheme changed, so a diff would be noise rather than signal. Size this release from the type section above and from reading the .d.ts diff by hand.`);
1822
+ else if (bApi !== null && aApi !== null) {
1823
+ for (const pkg of Object.keys(bApi)) {
1824
+ const b = bApi[pkg];
1825
+ const a = aApi[pkg];
1826
+ if (b === void 0) continue;
1827
+ if (a === void 0) {
1828
+ breaking.push(`package no longer published: ${pkg}`);
1829
+ continue;
1830
+ }
1831
+ for (const name of b.exports) if (!a.exports.includes(name)) breaking.push(`export removed: ${pkg}#${name}`);
1832
+ for (const name of a.exports) if (!b.exports.includes(name)) additive.push(`export added: ${pkg}#${name}`);
1833
+ if (b.dtsDigest !== a.dtsDigest) review.push(`${pkg}: emitted declarations changed (${b.dtsDigest} -> ${a.dtsDigest}). A signature moved somewhere in this package. Read the .d.ts diff and decide whether it breaks a caller — this cannot tell a widened return type from a narrowed one.`);
1834
+ }
1835
+ for (const pkg of Object.keys(aApi)) if (!Object.hasOwn(bApi, pkg)) additive.push(`package now published: ${pkg}`);
1836
+ }
1837
+ return {
1838
+ class: breaking.length > 0 ? "major" : additive.length > 0 || review.length > 0 ? "minor" : "patch",
1839
+ breaking,
1840
+ additive,
1841
+ review
1842
+ };
1843
+ }
1844
+ /** Is `proposed` at least as large as `required`? */
1845
+ function classSatisfies(proposed, required) {
1846
+ return CLASS_RANK[proposed] >= CLASS_RANK[required];
1847
+ }
1848
+ /** The class implied by a version bump. Returns null when it is not a bump. */
1849
+ function bumpClass(from, to) {
1850
+ const p = (v) => {
1851
+ const [a = NaN, b = NaN, c = NaN] = v.trim().replace(/^v/, "").split(".").map(Number);
1852
+ return [
1853
+ a,
1854
+ b,
1855
+ c
1856
+ ];
1857
+ };
1858
+ const [fa, fb, fc] = p(from);
1859
+ const [ta, tb, tc] = p(to);
1860
+ if ([
1861
+ fa,
1862
+ fb,
1863
+ fc,
1864
+ ta,
1865
+ tb,
1866
+ tc
1867
+ ].some((n) => !Number.isFinite(n))) return null;
1868
+ if (ta > fa) return "major";
1869
+ if (ta === fa && tb > fb) return "minor";
1870
+ if (ta === fa && tb === fb && tc > fc) return "patch";
1871
+ return null;
1872
+ }
1873
+
1874
+ //#endregion
1875
+ //#region src/todo-resolution.ts
1876
+ /** How much of the notes / body opening is read for a closure cue. */
1877
+ const OPENING = 240;
1878
+ const CUES = [
1879
+ ["duplicate", /\b(duplicate of|dup(?:e)? of|duplicates #|same (?:defect|bug|issue) as #)/i],
1880
+ ["superseded", /\b(superseded|folded into|replaced by|subsumed (?:by|into)|rolled into|merged into #)/i],
1881
+ ["obsolete", /\b(obsolete|moot|no longer (?:relevant|needed|applies|an issue)|overtaken by events|as stale\b|already fixed)/i],
1882
+ ["wont_do", /\b(won'?t[ -](?:do|fix)|wontfix|not (?:needed|worth (?:it|doing))|declined|cancell?ed|abandoned|not doing|invalid|not[ -]a[ -]bug)\b/i],
1883
+ ["completed", /\b(done|merged|shipped|landed|released|fixed|implemented|deployed|resolved|verified live|complete|completed|delivered|(?:by|via|in) PR #?\d+)\b/i]
1884
+ ];
1885
+ function inferTodoResolution(todo) {
1886
+ const opening = (t) => typeof t === "string" ? t.slice(0, OPENING) : "";
1887
+ const text = [
1888
+ /^\s*\[([^\]]*)\]/.exec(todo.title ?? "")?.[1] ?? "",
1889
+ opening(todo.notes),
1890
+ opening(todo.body)
1891
+ ].join("\n");
1892
+ let best = null;
1893
+ for (const [resolution, cue] of CUES) {
1894
+ const m = cue.exec(text);
1895
+ if (m !== null && (best === null || m.index < best.at)) best = {
1896
+ at: m.index,
1897
+ resolution
1898
+ };
1899
+ }
1900
+ return best?.resolution ?? "unspecified";
1901
+ }
1902
+
1903
+ //#endregion
1904
+ //#region src/index.ts
1905
+ function createCortextPlugin(cfg) {
1906
+ return {
1907
+ name: CORTEXT_COORDINATOR_NAME,
1908
+ contractVersion: SUBSTRATE_CONTRACT_RANGE,
1909
+ onStart: (ctx) => {
1910
+ reportUncoveredNamespaces(cfg, ctx.metric);
1911
+ },
1912
+ namespaces: [buildClaudeNamespace(cfg.namespace), buildArchitectureNamespace(cfg.architectureNamespace)],
1913
+ policies: buildAuthPolicies(cfg.auth ?? DEFAULT_AUTH),
1914
+ schedule: cfg.autoCompactIntervalMs !== void 0 ? [{
1915
+ name: `${cfg.namespace}-auto-compact`,
1916
+ intervalMs: cfg.autoCompactIntervalMs,
1917
+ runOnStart: false,
1918
+ handler: async (taskCtx) => {
1919
+ await runCompact(taskCtx.engine, cfg.namespace, cfg.compactRetention, false);
1920
+ taskCtx.metric("compact_applied", { namespace: cfg.namespace });
1921
+ }
1922
+ }] : [],
1923
+ routes: (router, routeCtx) => {
1924
+ registerOnboardRoute(router, routeCtx, cfg.compactRetention, cfg.namespace);
1925
+ registerCompactRoute(router, routeCtx, cfg.compactRetention, cfg.namespace, cfg.auth ?? DEFAULT_AUTH);
1926
+ }
1927
+ };
1928
+ }
1929
+
1930
+ //#endregion
1931
+ export { runCompact as C, isMonotonicAuth as E, registerCompactRoute as S, buildAuthPolicies as T, TODO_RESOLUTION_ALIASES as _, classSatisfies as a, buildArchitectureNamespace as b, AGENT_RULE_CATEGORY_VOCAB as c, ARCHITECTURE_TYPE_NAMES as d, CLAUDE_TYPE_NAMES as f, HOST_INSTALL_FRAGMENT_STATES as g, HOST_INSTALL_CHECK_STATES as h, bumpClass as i, AGENT_RULE_SEVERITY_ALIASES as l, DECISION_STATUS_VOCAB as m, inferTodoResolution as n, classifySurfaceChange as o, DECISION_STATUS_ALIASES as p, buildReleaseSurface as r, registerOnboardRoute as s, createCortextPlugin as t, AGENT_RULE_SEVERITY_VOCAB as u, TODO_STATUS_ALIASES as v, DEFAULT_AUTH as w, buildClaudeNamespace as x, TODO_STATUS_IMPLIES as y };