diff --git a/content/docs/kernel/runtime-services/audit-service.mdx b/content/docs/kernel/runtime-services/audit-service.mdx index d7445a5e6c..88841bc725 100644 --- a/content/docs/kernel/runtime-services/audit-service.mdx +++ b/content/docs/kernel/runtime-services/audit-service.mdx @@ -7,6 +7,7 @@ description: Write ingress for audit events that are not CRUD — today, auth se - **Stability:** `experimental` - **Canonical source:** `packages/plugins/plugin-audit/src/auth-event-audit.ts` +- **Registry slot:** `audit` — resolve with `ctx.getService('audit')`. `services.audit` is the kernel service slot that `@objectstack/plugin-audit` registers during `init()`. It is the **write ingress for audit events that are not CRUD** — today, diff --git a/content/docs/kernel/runtime-services/data-service.mdx b/content/docs/kernel/runtime-services/data-service.mdx index 6fdff27be2..97d7bf031a 100644 --- a/content/docs/kernel/runtime-services/data-service.mdx +++ b/content/docs/kernel/runtime-services/data-service.mdx @@ -9,6 +9,7 @@ description: CRUD runtime helper API for records (`query`, `get`, `find`, `creat - **Canonical source:** `packages/client/src/index.ts` — the `ObjectStackClient.data` surface (see [Canonical source](#canonical-source) for why this page names the SDK rather than a `contracts/*-service.ts` interface) +- **Registry slot:** `data` — resolve with `ctx.getService('data')`. diff --git a/content/docs/kernel/runtime-services/email-service.mdx b/content/docs/kernel/runtime-services/email-service.mdx index 111994c822..e1200cb603 100644 --- a/content/docs/kernel/runtime-services/email-service.mdx +++ b/content/docs/kernel/runtime-services/email-service.mdx @@ -7,6 +7,7 @@ description: Outbound email delivery and template rendering APIs. - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/email-service.ts` +- **Registry slot:** `email` — resolve with `ctx.getService('email')`. ## Methods diff --git a/content/docs/kernel/runtime-services/index.mdx b/content/docs/kernel/runtime-services/index.mdx index fbabe9c299..e2387fce74 100644 --- a/content/docs/kernel/runtime-services/index.mdx +++ b/content/docs/kernel/runtime-services/index.mdx @@ -12,6 +12,15 @@ repo's runtime, hook bodies reach scoped data operations through `ctx.api`, while plugin code resolves any registered service through `ctx.getService(...)` — a literal `services.*` object is not injected into hook contexts by the open framework today. Managed runtimes provide the `services.*` binding directly. + +**`services.*` is the accessor spelling, not necessarily the registry slot.** The name +after `services.` is what this chapter documents each surface under; the string you pass +to `ctx.getService(...)` is the slot the implementation is registered by. They are the +same word for every service below **except storage**, whose slot is `file-storage` +(there is no `storage` alias — see +[`services.storage`](/docs/kernel/runtime-services/storage-service)). Each page states +its own slot in a **Registry slot** bullet; `scripts/check-runtime-services-index.mjs` +holds those to a real `registerService` call. This chapter documents the runtime `services.*` APIs used in hook/action/flow/plugin code: diff --git a/content/docs/kernel/runtime-services/queue-service.mdx b/content/docs/kernel/runtime-services/queue-service.mdx index dbd1d43ac1..82d3213c68 100644 --- a/content/docs/kernel/runtime-services/queue-service.mdx +++ b/content/docs/kernel/runtime-services/queue-service.mdx @@ -7,6 +7,7 @@ description: Async queue publish/subscribe and DLQ operations. - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/queue-service.ts` +- **Registry slot:** `queue` — resolve with `ctx.getService('queue')`. ## Methods diff --git a/content/docs/kernel/runtime-services/settings-service.mdx b/content/docs/kernel/runtime-services/settings-service.mdx index 0d0eadebb3..e75d0c23a6 100644 --- a/content/docs/kernel/runtime-services/settings-service.mdx +++ b/content/docs/kernel/runtime-services/settings-service.mdx @@ -7,6 +7,7 @@ description: Namespace-based configuration service with OS env/global/tenant/use - **Stability:** `stable` - **Canonical source:** `packages/services/service-settings/src/settings-service.ts` +- **Registry slot:** `settings` — resolve with `ctx.getService('settings')`. ## Key Methods diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx index b6cde6390b..e043ff9094 100644 --- a/content/docs/kernel/runtime-services/sharing-service.mdx +++ b/content/docs/kernel/runtime-services/sharing-service.mdx @@ -7,6 +7,7 @@ description: Record-level sharing and editability checks. - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/sharing-service.ts` +- **Registry slot:** `sharing` — resolve with `ctx.getService('sharing')`. ## Methods diff --git a/content/docs/kernel/runtime-services/sms-service.mdx b/content/docs/kernel/runtime-services/sms-service.mdx index 8992e07fc0..66d78a5796 100644 --- a/content/docs/kernel/runtime-services/sms-service.mdx +++ b/content/docs/kernel/runtime-services/sms-service.mdx @@ -7,6 +7,7 @@ description: Outbound SMS delivery through pluggable providers (Aliyun SMS, Twil - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/sms-service.ts` +- **Registry slot:** `sms` — resolve with `ctx.getService('sms')`. ## Methods diff --git a/content/docs/kernel/runtime-services/storage-service.mdx b/content/docs/kernel/runtime-services/storage-service.mdx index 6c71dfb783..deaa75867c 100644 --- a/content/docs/kernel/runtime-services/storage-service.mdx +++ b/content/docs/kernel/runtime-services/storage-service.mdx @@ -7,6 +7,46 @@ description: File/object storage contract for upload/download and presigned URL - **Stability:** `stable` - **Canonical source:** `packages/spec/src/contracts/storage-service.ts` +- **Registry slot:** `file-storage` — **not** `storage`; resolve with + `ctx.getService('file-storage')`. See [Accessor name vs registry + slot](#accessor-name-vs-registry-slot). + +## Accessor name vs registry slot + +`services.storage` is this chapter's **accessor spelling** — the name the contract surface +is documented under. The **registry slot** is the string the kernel actually keys the +implementation by, and for storage the two differ: + +| | value | +|:--|:--| +| Documented accessor | `services.storage` | +| Registry slot | `file-storage` | +| Resolve with | `ctx.getService('file-storage')` | + +This is the **only** service in this chapter where they differ; for the other seven the +accessor and the slot are the same word. There is no `storage` alias — nothing calls +`registerService('storage', ...)` anywhere in the platform — so resolving the accessor +spelling throws rather than returning an empty value: + +```ts +ctx.getService('storage'); // ✗ throws: [Kernel] Service 'storage' not found +ctx.getService('file-storage'); // ✓ the IStorageService documented below +``` + +`file-storage` is the canonical spelling, not an implementation detail: it is the member +listed in `CoreServiceName` (`packages/spec/src/system/core-services.zod.ts`), +`CORE_SERVICE_PROVIDER` maps it to `@objectstack/service-storage`, and it is the key the +`/api/v1/discovery` document reports this service's availability under. + + +A second `storage` accessor exists and is easy to reach for by mistake. +`ObjectStackClient.storage` (`packages/client/src/index.ts`) is the **browser/HTTP client** +surface — `upload(file, scope)`, `getDownloadUrl(fileId)`, `getPresignedUrl(req)`, +`initChunkedUpload(req)` — which calls `/api/v1/storage` over the wire. It is a different +shape from the server-side `IStorageService` documented on this page, which takes storage +**keys** and returns `Buffer`s. Neither one is reachable through +`ctx.getService('storage')`. + ## Core Methods diff --git a/scripts/check-runtime-services-index.mjs b/scripts/check-runtime-services-index.mjs index 689dc761b7..facfee0243 100644 --- a/scripts/check-runtime-services-index.mjs +++ b/scripts/check-runtime-services-index.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// check-runtime-services-index (#9604) -- hold the runtime-services chapter's -// two INDEX lists to the pages that actually exist. +// check-runtime-services-index (#9604, #9630) -- hold the runtime-services +// chapter's two INDEX lists to the pages that actually exist, and each page's +// documented registry slot to a real `registerService` call. // // node scripts/check-runtime-services-index.mjs // node scripts/check-runtime-services-index.mjs --self-test # verify the checker itself @@ -17,6 +18,9 @@ // 2. `runtime-services/index.mdx` -> "This chapter documents ..." bullets // 3. `kernel/index.mdx` -> the `services.*` table // +// and each page additionally claims the registry slot it is resolved by (see +// "The fourth claim" below). +// // None of the three is generated, so each drifts from the tree one edit at a // time, and nothing reads them: `check:docs-audit-scope` derives WHICH pages the // docs-accuracy audit covers, never whether an index enumerates them. #9604 @@ -31,6 +35,40 @@ // copy from, so a short list is read as a fact about the platform's surface. // Declared = enforced. // +// ## The fourth claim: which key `ctx.getService()` actually takes (#9630) +// +// The three enumerations above are all about page EXISTENCE and ORDER. None of +// them reads a line of `packages/`, so every one of them stayed green over a +// page that documented an accessor the kernel cannot resolve -- and one did: +// +// * `storage-service.mdx` spelled its whole surface `services.storage.*`; +// * the chapter's own binding note tells the reader that no literal +// `services.*` object is injected and that plugin code goes through +// `ctx.getService(...)`; +// * the registered slot is `file-storage` (`storage-service-plugin.ts:237`), +// and NOTHING registers `storage`. +// +// Following both instructions together produced `ctx.getService('storage')`, +// which throws `[Kernel] Service 'storage' not found`. Seven of the eight pages +// were fine only because their accessor and their slot happened to be the same +// word, so the chapter had exactly one unannounced exception and no way to +// notice a second one. +// +// So each page now declares `- **Registry slot:** \`\``, and this gate holds +// that declaration to a production `registerService`/`registerServiceFactory` +// call under `packages/`. Note what is deliberately NOT required: the slot does +// not have to equal the accessor. `file-storage` is canonical -- it is the +// `CoreServiceName` member, `CORE_SERVICE_PROVIDER` maps it, and the CLI, email +// plugin and HTTP dispatcher all resolve it -- so a docs defect must never be +// "fixed" by renaming the slot or adding a `registerService('storage', ...)` +// alias. The rule is only: say which key you mean, and be right. +// +// Test files are excluded from the sweep on purpose: a slot only a fixture +// registers is not a platform surface a reader can resolve. The sweep is also +// multiline-aware, because `plugin-audit` puts its key on the line after the +// `(` -- a line-at-a-time grep reports a confident zero for it, and a wrongly +// measured absence is precisely the failure this check exists to rule out. +// // ## What "derived" means here // // The pages on disk are the source of truth -- they are the thing a reader can @@ -65,8 +103,8 @@ // natural follow-up; until then a green here means "the three enumerations agree // with the tree", which is exactly what the summary line says. -import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { readFileSync, readdirSync, statSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname, relative, sep } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -75,13 +113,29 @@ const repoRoot = () => join(HERE, '..'); const CHAPTER_DIR = 'content/docs/kernel/runtime-services'; const KERNEL_INDEX = 'content/docs/kernel/index.mdx'; +const PACKAGES_DIR = 'packages'; const PAGE_SUFFIX = '-service.mdx'; const META_SUFFIX = '-service'; +/** Directories that never hold a production registration. */ +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage']); +/** A registration in one of these is a fixture, not a platform surface. */ +const IS_TEST_PATH = (rel) => + /\.(test|spec|conformance|e2e)\./.test(rel) || /(^|\/)(test|tests|__tests__|__mocks__|mocks|fixtures)\//.test(rel); + +// MULTILINE-AWARE ON PURPOSE. `\s*` spans newlines, so a registration whose key +// sits on the line after the `(` is still found -- `plugin-audit` really is +// written that way (`audit-plugin.ts:117`), and a `^.*registerService\('x'` style +// line-at-a-time grep reports a confident ZERO for it. An absence measured with +// the wrong tool is the failure mode this whole check exists to rule out, so the +// self-test pins the split-line form explicitly. +const REGISTER_RE = /register(?:Service|ServiceFactory)\s*(?:<[^>]*>)?\s*\(\s*['"`]([^'"`]+)['"`]/g; + // --------------------------------------------------------------------------- // Derivation -/** Accessor names from the pages that exist, plus each page's declared title. */ +/** Accessor names from the pages that exist, plus each page's declared title + * and its declared registry slot. */ export function readPages(chapterDir) { return readdirSync(chapterDir) .filter((f) => f.endsWith(PAGE_SUFFIX)) @@ -90,10 +144,37 @@ export function readPages(chapterDir) { const name = file.slice(0, -PAGE_SUFFIX.length); const text = readFileSync(join(chapterDir, file), 'utf8'); const m = /^title:\s*(.+?)\s*$/m.exec(text); - return { name, file, title: m ? m[1] : null }; + const slot = /^-\s+\*\*Registry slot:\*\*\s+`([^`]+)`/m.exec(text); + return { name, file, title: m ? m[1] : null, slot: slot ? slot[1] : null }; }); } +/** Every service key a PRODUCTION `registerService` / `registerServiceFactory` + * call registers under `packages/`, mapped to its call sites. */ +export function readRegisteredSlots(root) { + const found = new Map(); + const pkgRoot = join(root, PACKAGES_DIR); + if (!existsSync(pkgRoot)) return found; + const walk = (dir) => { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { walk(full); continue; } + if (!/\.(ts|tsx|mts|js|mjs)$/.test(entry) || entry.endsWith('.d.ts')) continue; + const rel = relative(root, full).split(sep).join('/'); + if (IS_TEST_PATH(rel)) continue; + const text = readFileSync(full, 'utf8'); + for (const m of text.matchAll(REGISTER_RE)) { + const line = text.slice(0, m.index).split('\n').length; + if (!found.has(m[1])) found.set(m[1], []); + found.get(m[1]).push(`${rel}:${line}`); + } + } + }; + walk(pkgRoot); + return found; +} + /** `pages` entries that name a service page, in nav order. */ export function readMetaOrder(chapterDir) { const raw = JSON.parse(readFileSync(join(chapterDir, 'meta.json'), 'utf8')); @@ -118,7 +199,7 @@ export function readKernelTable(kernelText) { const missing = (expected, actual) => expected.filter((n) => !actual.includes(n)); const extra = (expected, actual) => actual.filter((n) => !expected.includes(n)); -export function check({ pages, metaOrder, chapterList, kernelTable }) { +export function check({ pages, metaOrder, chapterList, kernelTable, registeredSlots }) { const findings = []; const add = (where, msg) => findings.push({ where, msg }); @@ -159,11 +240,46 @@ export function check({ pages, metaOrder, chapterList, kernelTable }) { } } + // 5. Each page names the registry slot it is really resolved by, and that + // slot is a key something actually registers (#9630). The four checks + // above hold page EXISTENCE and ORDER to each other; none of them reads a + // single line of `packages/`, so a page could document a slot nothing has + // ever registered and stay green -- which is exactly what shipped: + // `services.storage` was resolvable only as `file-storage`, the accessor + // and the slot differed on that one page alone, and the page never said + // so. A reader following the chapter's own binding note wrote + // `ctx.getService('storage')` and got a throw. + // + // The accessor is NOT required to equal the slot -- `file-storage` is + // canonical (`CoreServiceName`, `CORE_SERVICE_PROVIDER`, three internal + // consumers) and renaming it to satisfy a docs page would be backwards. + // What is required is that the page SAYS which one it is, and that what it + // says is true. + if (registeredSlots) { + for (const p of pages) { + if (!p.slot) { + add(`${CHAPTER_DIR}/${p.file}`, `no \`- **Registry slot:** \`\`\` bullet -- state the key \`ctx.getService()\` resolves this surface by (it is \`${p.name}\` unless the page says otherwise)`); + continue; + } + if (!registeredSlots.has(p.slot)) { + const near = [...registeredSlots.keys()].filter((k) => k.includes(p.name) || p.name.includes(k)); + add( + `${CHAPTER_DIR}/${p.file}`, + `declares registry slot \`${p.slot}\`, but no production registerService() call under ${PACKAGES_DIR}/ registers that key` + + (near.length ? ` -- did you mean ${near.map((k) => `\`${k}\``).join(' / ')}?` : ''), + ); + } + } + } + return findings; } -export function summarise({ pages, chapterList, kernelTable }) { - return `${pages.length} chapter page(s) vs meta.json "pages", ${chapterList.length} chapter-list bullet(s) and ${kernelTable.length} kernel/index.mdx table row(s)`; +export function summarise({ pages, chapterList, kernelTable, registeredSlots }) { + const slots = registeredSlots + ? `, and ${pages.filter((p) => p.slot).length} declared registry slot(s) vs ${registeredSlots.size} registered key(s)` + : ''; + return `${pages.length} chapter page(s) vs meta.json "pages", ${chapterList.length} chapter-list bullet(s) and ${kernelTable.length} kernel/index.mdx table row(s)${slots}`; } // --------------------------------------------------------------------------- @@ -174,8 +290,9 @@ function run(root) { const metaOrder = readMetaOrder(chapterDir); const chapterList = readChapterList(readFileSync(join(chapterDir, 'index.mdx'), 'utf8')); const kernelTable = readKernelTable(readFileSync(join(root, KERNEL_INDEX), 'utf8')); + const registeredSlots = readRegisteredSlots(root); if (pages.length === 0) throw new Error(`no ${PAGE_SUFFIX} pages found under ${CHAPTER_DIR} -- refusing to report OK over an empty set`); - const input = { pages, metaOrder, chapterList, kernelTable }; + const input = { pages, metaOrder, chapterList, kernelTable, registeredSlots }; return { findings: check(input), summary: summarise(input) }; } @@ -191,10 +308,12 @@ function main() { for (const f of findings) console.error(` • ${f.where}: ${f.msg}`); console.error('\n The pages on disk are the source of truth. Add the missing entry (or delete the stale'); console.error(' one) so all three enumerations agree; the chapter list follows meta.json "pages" order.'); - console.error(' The "Source of Truth" canonical-source list is deliberately not checked -- see the header.'); + console.error(' A "Registry slot" finding is different: the page must name the key ctx.getService() really'); + console.error(' resolves it by. Fix the PAGE, never the slot -- the registered key is the contract (#9630).'); + console.error(' The "Source of Truth" canonical-source list is deliberately not checked -- see the header.'); process.exit(1); } - console.log(`✓ check-runtime-services-index: ${summary} -- all three enumerations agree (Source-of-Truth list not in scope).`); + console.log(`✓ check-runtime-services-index: ${summary} -- all enumerations agree and every declared slot is really registered (Source-of-Truth list not in scope).`); } // --------------------------------------------------------------------------- @@ -212,9 +331,34 @@ function selfTest() { mkdirSync(join(dir, 'content/docs/kernel'), { recursive: true }); const names = ['data', 'email', 'sms']; - const writeTree = ({ pages = names, meta = names, list = names, table = names, titleFor = (n) => `services.${n}`, hrefFor = (n) => `${n}-service` } = {}) => { + // A synthetic `packages/` tree so check 5 has a registry to compare against. + // `email` is registered on the SAME line; `sms` is registered with its key on + // the line AFTER the `(` -- the `plugin-audit` shape that defeats a + // line-at-a-time grep. Both must be found, or the "no such slot" finding + // below would be a false positive rather than a measurement. + const pkg = join(dir, PACKAGES_DIR, 'svc/src'); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(pkg, 'plugin.ts'), [ + "ctx.registerService('data', engine);", + "ctx.registerService('email', mailer);", + 'ctx.registerService(', + " 'sms',", + ' texter,', + ');', + "ctx.registerServiceFactory('file-storage', async () => adapter);", + ].join('\n')); + // Registered ONLY by a test file: must NOT count as a platform surface. + mkdirSync(join(dir, PACKAGES_DIR, 'svc/src/__tests__'), { recursive: true }); + writeFileSync(join(dir, PACKAGES_DIR, 'svc/src/__tests__/fake.ts'), "ctx.registerService('testonly', {});"); + + const slotFor = (n) => n; + const writeTree = ({ pages = names, meta = names, list = names, table = names, titleFor = (n) => `services.${n}`, hrefFor = (n) => `${n}-service`, slot = slotFor } = {}) => { for (const f of readdirSync(chapter)) rmSync(join(chapter, f), { force: true }); - for (const n of pages) writeFileSync(join(chapter, `${n}${PAGE_SUFFIX}`), `---\ntitle: ${titleFor(n)}\n---\n`); + for (const n of pages) { + const declared = slot(n); + const bullet = declared === null ? '' : `\n- **Registry slot:** \`${declared}\` — resolve with \`ctx.getService('${declared}')\`.\n`; + writeFileSync(join(chapter, `${n}${PAGE_SUFFIX}`), `---\ntitle: ${titleFor(n)}\n---\n${bullet}`); + } writeFileSync(join(chapter, 'meta.json'), JSON.stringify({ pages: ['index', ...meta.map((n) => `${n}${META_SUFFIX}`), 'examples'] })); writeFileSync(join(chapter, 'index.mdx'), `# x\n\n${list.map((n) => `- \`services.${n}\``).join('\n')}\n\n## Source of Truth\n\n- Security: \`packages/spec/src/contracts/security-service.ts\`\n`); writeFileSync(join(dir, KERNEL_INDEX), `# k\n\n${table.map((n) => `| [\`services.${n}\`](/docs/kernel/runtime-services/${hrefFor(n)}) | stable | d |`).join('\n')}\n`); @@ -241,6 +385,38 @@ function selfTest() { assert(lying.some((f) => f.msg.includes('expected "services.sms"')), 'a page whose title contradicts its filename is caught'); assert(lying.every((f) => f.where.endsWith(`sms${PAGE_SUFFIX}`)), 'the title premise short-circuits: no set comparison runs over a page that lies about its name'); + // ── Check 5: declared registry slot vs the real registry (#9630) ──────── + // The defect itself: a page documenting an accessor that resolves to nothing. + const ghost = findingsFor({ slot: (n) => (n === 'sms' ? 'storage' : n) }); + assert( + ghost.some((f) => f.where.endsWith(`sms${PAGE_SUFFIX}`) && f.msg.includes('no production registerService() call')), + `a page declaring a slot nothing registers is caught -- got ${JSON.stringify(ghost)}`, + ); + assert(ghost.some((f) => f.msg.includes('did you mean')), 'the finding suggests the near-miss key rather than only rejecting'); + + // An accessor that legitimately differs from its slot is ACCEPTED -- the rule + // is "say which key you mean and be right", never "the two must match". + assert( + findingsFor({ slot: (n) => (n === 'sms' ? 'file-storage' : n) }).length === 0, + 'a page whose slot deliberately differs from its accessor is silent when that slot is really registered', + ); + + // The split-line registration is REACHED, not merely tolerated: `sms` is only + // registered in the multiline form, so a line-at-a-time scan would flag it. + assert(findingsFor().every((f) => !f.where.endsWith(`sms${PAGE_SUFFIX}`)), 'a registration whose key sits on the next line is found (the plugin-audit shape)'); + + // A test-only registration does not make a slot real. + assert( + findingsFor({ slot: (n) => (n === 'sms' ? 'testonly' : n) }).some((f) => f.msg.includes('no production registerService() call')), + 'a slot registered only under a test path is not accepted as a platform surface', + ); + + // A page that declares no slot at all is caught -- silence is not a pass. + assert( + findingsFor({ slot: (n) => (n === 'sms' ? null : n) }).some((f) => f.where.endsWith(`sms${PAGE_SUFFIX}`) && f.msg.includes('Registry slot')), + 'a page with no Registry slot bullet is caught', + ); + // ── Refuses to report OK over nothing ─────────────────────────────────── let threw = false; try { findingsFor({ pages: [], meta: [], list: [], table: [] }); } catch { threw = true; } @@ -254,7 +430,7 @@ function selfTest() { for (const f of failures) console.error(` • ${f}`); process.exit(1); } - console.log(`✓ check-runtime-services-index --self-test: ${checked} assertions over a temp fixture (real run() path); every limb -- chapter list, kernel table, meta.json, order, href, title premise, empty tree -- observed FAILING and observed silent.`); + console.log(`✓ check-runtime-services-index --self-test: ${checked} assertions over a temp fixture (real run() path); every limb -- chapter list, kernel table, meta.json, order, href, title premise, registry slot (incl. the split-line registration), empty tree -- observed FAILING and observed silent.`); } if (process.argv.includes('--self-test')) selfTest();