Skip to content

Commit 40fb55e

Browse files
os-steveclaude
andauthored
docs(kernel): list services.sms in both runtime-service indexes, and gate the chapter's index lists against the pages on disk (#9634)
* docs(kernel): list services.sms in both runtime-service index pages The runtime-services chapter list and the kernel module table both enumerated seven slots and omitted services.sms, while sms-service.mdx exists, meta.json lists it in pages, the chapter's own Source-of-Truth list names its contract, and service-sms really registers the slot (sms-plugin.ts:181). Both index pages are fixed in one change so they cannot newly disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja * ci(docs): gate the runtime-services chapter's index lists against the pages on disk The chapter's pages are enumerated in three hand-written places -- meta.json "pages", the chapter list in runtime-services/index.mdx, and the services.* table in kernel/index.mdx -- and nothing read any of them. check:docs-audit-scope derives WHICH pages the accuracy audit covers, never whether an index enumerates them, so services.sms could ship with a page, a meta.json entry, a registered slot and a canonical-source row while missing from both index lists. The pages on disk are the source of truth. The gate holds all three enumerations and the chapter list's order to them, after first checking each page declares the accessor its filename claims. The Source-of-Truth canonical-source list is deliberately out of scope: it is a superset by exactly one row (Security) whose resolution is an open maintainer question, and encoding any answer -- allowlist included -- would pre-judge it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07026cf commit 40fb55e

5 files changed

Lines changed: 279 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,21 @@ jobs:
486486
- name: Quick-reference section counts match their tables
487487
run: pnpm check:quick-reference-counts
488488

489+
# #9604: the runtime-services chapter's pages are enumerated in three
490+
# hand-written places (meta.json "pages", the chapter list in
491+
# runtime-services/index.mdx, the services.* table in kernel/index.mdx) and
492+
# nothing read any of them -- check:docs-audit-scope derives WHICH pages the
493+
# accuracy audit covers, never whether an index enumerates them. #9604 found
494+
# services.sms with a page, a meta.json entry, a registered slot and a
495+
# canonical-source row, missing from BOTH index lists; #9588 was the same
496+
# page drifting on a different line. The pages on disk are the source of
497+
# truth and the gate holds all three enumerations (and the chapter list's
498+
# order) to them. It lives in this job with the other docs guards: the edit
499+
# that breaks it is a docs edit, so a packages/** paths filter would blind
500+
# it to its own failure mode.
501+
- name: Runtime-services indexes enumerate the chapter's real pages
502+
run: pnpm check:runtime-services-index
503+
489504
# #3723 ADR anchors: code an accepted ADR governs must keep naming it.
490505
# That incident reversed three accepted ADRs with a patch-level changeset,
491506
# and the mechanism was simply that the edited file never mentioned them —

content/docs/kernel/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The kernel is ObjectStack's runtime: it loads your metadata artifact, hosts plug
1818
| [`services.data`](/docs/kernel/runtime-services/data-service) | stable | CRUD and queries with the caller's permission context |
1919
| [`services.sharing`](/docs/kernel/runtime-services/sharing-service) | stable | `buildReadFilter`, `canEdit`, `canDelete`, `canManageShares`, `grant`/`revoke`, `listShares` |
2020
| [`services.email`](/docs/kernel/runtime-services/email-service) | stable | `send`, `sendTemplate` |
21+
| [`services.sms`](/docs/kernel/runtime-services/sms-service) | stable | Outbound SMS delivery through pluggable providers (Aliyun SMS, Twilio) |
2122
| [`services.queue`](/docs/kernel/runtime-services/queue-service) | stable | Background work and queues |
2223
| [`services.settings`](/docs/kernel/runtime-services/settings-service) | stable | App/environment settings |
2324
| [`services.storage`](/docs/kernel/runtime-services/storage-service) | stable | File storage |

content/docs/kernel/runtime-services/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This chapter documents the runtime `services.*` APIs used in hook/action/flow/pl
2121
- `services.audit`
2222
- `services.queue`
2323
- `services.email`
24+
- `services.sms`
2425
- `services.settings`
2526
- `services.storage`
2627

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"check:docs-image-tag-sync": "node scripts/sync-docs-image-tags.mjs --self-test",
4444
"check:role-word": "node scripts/check-role-word.mjs --self-test && node scripts/check-role-word.mjs",
4545
"check:quick-reference-counts": "node scripts/check-quick-reference-counts.mjs --self-test && node scripts/check-quick-reference-counts.mjs",
46+
"check:runtime-services-index": "node scripts/check-runtime-services-index.mjs --self-test && node scripts/check-runtime-services-index.mjs",
4647
"check:skill-frame-sync": "node scripts/check-skill-frame-sync.mjs --self-test && node scripts/check-skill-frame-sync.mjs",
4748
"check:skill-frame-freshness": "node scripts/check-skill-frame-freshness.mjs --self-test && node scripts/check-skill-frame-freshness.mjs",
4849
"check:skill-compatibility": "node scripts/check-skill-compatibility-version.mjs --self-test && node scripts/check-skill-compatibility-version.mjs",
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
#!/usr/bin/env node
2+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
3+
//
4+
// check-runtime-services-index (#9604) -- hold the runtime-services chapter's
5+
// two INDEX lists to the pages that actually exist.
6+
//
7+
// node scripts/check-runtime-services-index.mjs
8+
// node scripts/check-runtime-services-index.mjs --self-test # verify the checker itself
9+
//
10+
// ## What it guards
11+
//
12+
// `content/docs/kernel/runtime-services/` publishes one `<name>-service.mdx`
13+
// page per documented `services.<name>` accessor, and THREE hand-written places
14+
// claim to enumerate them:
15+
//
16+
// 1. `runtime-services/meta.json` -> `pages` (chapter nav order)
17+
// 2. `runtime-services/index.mdx` -> "This chapter documents ..." bullets
18+
// 3. `kernel/index.mdx` -> the `services.*` table
19+
//
20+
// None of the three is generated, so each drifts from the tree one edit at a
21+
// time, and nothing reads them: `check:docs-audit-scope` derives WHICH pages the
22+
// docs-accuracy audit covers, never whether an index enumerates them. #9604
23+
// measured the result -- `services.sms` had a page, a `meta.json` entry, a
24+
// registered slot (`sms-plugin.ts:181`) and a canonical-source row, and was
25+
// still missing from BOTH index lists. It shipped that way and every gate was
26+
// green. #9588 was the same page drifting on a different line.
27+
//
28+
// Nothing breaks at runtime; the cost is that an index page's whole job is to be
29+
// a trustworthy map. A reader who does not find SMS in the list concludes the
30+
// chapter has no SMS page. `content/docs/` is also the corpus humans and AIs
31+
// copy from, so a short list is read as a fact about the platform's surface.
32+
// Declared = enforced.
33+
//
34+
// ## What "derived" means here
35+
//
36+
// The pages on disk are the source of truth -- they are the thing a reader can
37+
// actually open. Each page also has to declare its own accessor
38+
// (`title: services.<name>` matching its filename), which is checked first: it
39+
// is the premise the other three comparisons rest on, so a page that lies about
40+
// its own name must go red here rather than silently redefine the expected set.
41+
//
42+
// Order is enforced too, not just membership. The chapter list currently
43+
// follows `meta.json`'s `pages` order exactly, and that convention is the only
44+
// thing that tells the next author WHERE a new bullet goes. Membership-only
45+
// checking would have accepted `services.sms` appended at the end, next to a
46+
// list whose order encodes the nav -- so the gate keeps the answer mechanical.
47+
//
48+
// ## Deliberately NOT checked: the "Source of Truth" list
49+
//
50+
// `index.mdx` carries a fourth list -- `- <Label>: \`<path>\`` canonical-source
51+
// rows. This gate does not compare it with the pages, for two reasons:
52+
//
53+
// - It is a superset by exactly one row on purpose-of-record: `Security:
54+
// packages/spec/src/contracts/security-service.ts` names a real, registered
55+
// slot (`security-plugin.ts:1157`) that this chapter has no page for, and
56+
// `services.security` is documented NOWHERE under `content/docs/`. Whether
57+
// that row should become a page, move, or be dropped is a product-surface
58+
// question for the maintainer (#9604 explicitly declines to guess). Encoding
59+
// any of the three answers here -- including as an allowlist entry -- would
60+
// pre-judge it.
61+
// - Its row labels are prose, not accessors (`Audit bridge`, `Data`), and that
62+
// line is under active edit.
63+
//
64+
// When the Security question is settled, extending this gate to that list is the
65+
// natural follow-up; until then a green here means "the three enumerations agree
66+
// with the tree", which is exactly what the summary line says.
67+
68+
import { readFileSync, readdirSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
69+
import { join, dirname } from 'node:path';
70+
import { tmpdir } from 'node:os';
71+
import { fileURLToPath } from 'node:url';
72+
73+
const HERE = dirname(fileURLToPath(import.meta.url));
74+
const repoRoot = () => join(HERE, '..');
75+
76+
const CHAPTER_DIR = 'content/docs/kernel/runtime-services';
77+
const KERNEL_INDEX = 'content/docs/kernel/index.mdx';
78+
const PAGE_SUFFIX = '-service.mdx';
79+
const META_SUFFIX = '-service';
80+
81+
// ---------------------------------------------------------------------------
82+
// Derivation
83+
84+
/** Accessor names from the pages that exist, plus each page's declared title. */
85+
export function readPages(chapterDir) {
86+
return readdirSync(chapterDir)
87+
.filter((f) => f.endsWith(PAGE_SUFFIX))
88+
.sort()
89+
.map((file) => {
90+
const name = file.slice(0, -PAGE_SUFFIX.length);
91+
const text = readFileSync(join(chapterDir, file), 'utf8');
92+
const m = /^title:\s*(.+?)\s*$/m.exec(text);
93+
return { name, file, title: m ? m[1] : null };
94+
});
95+
}
96+
97+
/** `pages` entries that name a service page, in nav order. */
98+
export function readMetaOrder(chapterDir) {
99+
const raw = JSON.parse(readFileSync(join(chapterDir, 'meta.json'), 'utf8'));
100+
const pages = Array.isArray(raw.pages) ? raw.pages : [];
101+
return pages.filter((p) => typeof p === 'string' && p.endsWith(META_SUFFIX)).map((p) => p.slice(0, -META_SUFFIX.length));
102+
}
103+
104+
/** The "This chapter documents ..." bullets, in page order. */
105+
export function readChapterList(indexText) {
106+
return [...indexText.matchAll(/^-\s+`services\.([A-Za-z0-9_]+)`\s*$/gm)].map((m) => m[1]);
107+
}
108+
109+
/** Accessors linked from the `services.*` table in kernel/index.mdx, in order. */
110+
export function readKernelTable(kernelText) {
111+
return [...kernelText.matchAll(/\[`services\.([A-Za-z0-9_]+)`\]\(\/docs\/kernel\/runtime-services\/([A-Za-z0-9_-]+)\)/g)]
112+
.map((m) => ({ accessor: m[1], href: m[2] }));
113+
}
114+
115+
// ---------------------------------------------------------------------------
116+
// Comparison
117+
118+
const missing = (expected, actual) => expected.filter((n) => !actual.includes(n));
119+
const extra = (expected, actual) => actual.filter((n) => !expected.includes(n));
120+
121+
export function check({ pages, metaOrder, chapterList, kernelTable }) {
122+
const findings = [];
123+
const add = (where, msg) => findings.push({ where, msg });
124+
125+
// 0. The premise: every page declares the accessor its filename claims.
126+
for (const p of pages) {
127+
const want = `services.${p.name}`;
128+
if (p.title !== want) {
129+
add(`${CHAPTER_DIR}/${p.file}`, `frontmatter title is ${p.title === null ? '(absent)' : `"${p.title}"`}, expected "${want}" to match the filename`);
130+
}
131+
}
132+
// A page that lies about its own name makes every set below meaningless.
133+
if (findings.length) return findings;
134+
135+
const onDisk = pages.map((p) => p.name);
136+
137+
// 1. meta.json <-> disk
138+
for (const n of missing(onDisk, metaOrder)) add(`${CHAPTER_DIR}/meta.json`, `"pages" omits "${n}${META_SUFFIX}" (${n}${PAGE_SUFFIX} exists)`);
139+
for (const n of extra(onDisk, metaOrder)) add(`${CHAPTER_DIR}/meta.json`, `"pages" lists "${n}${META_SUFFIX}" but ${n}${PAGE_SUFFIX} does not exist`);
140+
141+
// 2. chapter list <-> disk
142+
for (const n of missing(onDisk, chapterList)) add(`${CHAPTER_DIR}/index.mdx`, `chapter list omits \`services.${n}\` (${n}${PAGE_SUFFIX} exists)`);
143+
for (const n of extra(onDisk, chapterList)) add(`${CHAPTER_DIR}/index.mdx`, `chapter list names \`services.${n}\` but ${n}${PAGE_SUFFIX} does not exist`);
144+
145+
// 3. chapter list order == meta.json nav order
146+
const navOrder = metaOrder.filter((n) => chapterList.includes(n));
147+
const listed = chapterList.filter((n) => metaOrder.includes(n));
148+
if (navOrder.join() !== listed.join()) {
149+
add(`${CHAPTER_DIR}/index.mdx`, `chapter list order ${JSON.stringify(listed)} does not follow meta.json "pages" order ${JSON.stringify(navOrder)}`);
150+
}
151+
152+
// 4. kernel/index.mdx table <-> disk, and each row's href resolves
153+
const linked = kernelTable.map((r) => r.accessor);
154+
for (const n of missing(onDisk, linked)) add(KERNEL_INDEX, `\`services.*\` table has no row for \`services.${n}\` (${n}${PAGE_SUFFIX} exists)`);
155+
for (const n of extra(onDisk, linked)) add(KERNEL_INDEX, `\`services.*\` table has a row for \`services.${n}\` but ${n}${PAGE_SUFFIX} does not exist`);
156+
for (const r of kernelTable) {
157+
if (r.href !== `${r.accessor}${META_SUFFIX}`) {
158+
add(KERNEL_INDEX, `\`services.${r.accessor}\` links to "${r.href}", expected "${r.accessor}${META_SUFFIX}"`);
159+
}
160+
}
161+
162+
return findings;
163+
}
164+
165+
export function summarise({ pages, chapterList, kernelTable }) {
166+
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)`;
167+
}
168+
169+
// ---------------------------------------------------------------------------
170+
171+
function run(root) {
172+
const chapterDir = join(root, CHAPTER_DIR);
173+
const pages = readPages(chapterDir);
174+
const metaOrder = readMetaOrder(chapterDir);
175+
const chapterList = readChapterList(readFileSync(join(chapterDir, 'index.mdx'), 'utf8'));
176+
const kernelTable = readKernelTable(readFileSync(join(root, KERNEL_INDEX), 'utf8'));
177+
if (pages.length === 0) throw new Error(`no ${PAGE_SUFFIX} pages found under ${CHAPTER_DIR} -- refusing to report OK over an empty set`);
178+
const input = { pages, metaOrder, chapterList, kernelTable };
179+
return { findings: check(input), summary: summarise(input) };
180+
}
181+
182+
function main() {
183+
const root = repoRoot();
184+
if (!existsSync(join(root, CHAPTER_DIR))) {
185+
console.error(`✗ check-runtime-services-index -- ${CHAPTER_DIR} not found`);
186+
process.exit(1);
187+
}
188+
const { findings, summary } = run(root);
189+
if (findings.length) {
190+
console.error(`✗ check-runtime-services-index -- ${findings.length} drift(s) between the runtime-services indexes and the pages on disk\n`);
191+
for (const f of findings) console.error(` • ${f.where}: ${f.msg}`);
192+
console.error('\n The pages on disk are the source of truth. Add the missing entry (or delete the stale');
193+
console.error(' one) so all three enumerations agree; the chapter list follows meta.json "pages" order.');
194+
console.error(' The "Source of Truth" canonical-source list is deliberately not checked -- see the header.');
195+
process.exit(1);
196+
}
197+
console.log(`✓ check-runtime-services-index: ${summary} -- all three enumerations agree (Source-of-Truth list not in scope).`);
198+
}
199+
200+
// ---------------------------------------------------------------------------
201+
// Self-test: every limb observed FAILING on a synthetic tree, and observed silent.
202+
203+
function selfTest() {
204+
const failures = [];
205+
let checked = 0;
206+
const assert = (cond, why) => { checked++; if (!cond) failures.push(why); };
207+
208+
const dir = mkdtempSync(join(tmpdir(), 'rt-services-index-'));
209+
try {
210+
const chapter = join(dir, CHAPTER_DIR);
211+
mkdirSync(chapter, { recursive: true });
212+
mkdirSync(join(dir, 'content/docs/kernel'), { recursive: true });
213+
214+
const names = ['data', 'email', 'sms'];
215+
const writeTree = ({ pages = names, meta = names, list = names, table = names, titleFor = (n) => `services.${n}`, hrefFor = (n) => `${n}-service` } = {}) => {
216+
for (const f of readdirSync(chapter)) rmSync(join(chapter, f), { force: true });
217+
for (const n of pages) writeFileSync(join(chapter, `${n}${PAGE_SUFFIX}`), `---\ntitle: ${titleFor(n)}\n---\n`);
218+
writeFileSync(join(chapter, 'meta.json'), JSON.stringify({ pages: ['index', ...meta.map((n) => `${n}${META_SUFFIX}`), 'examples'] }));
219+
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`);
220+
writeFileSync(join(dir, KERNEL_INDEX), `# k\n\n${table.map((n) => `| [\`services.${n}\`](/docs/kernel/runtime-services/${hrefFor(n)}) | stable | d |`).join('\n')}\n`);
221+
};
222+
const findingsFor = (opts) => { writeTree(opts); return run(dir).findings; };
223+
224+
// ── The clean tree is silent ────────────────────────────────────────────
225+
const clean = findingsFor();
226+
assert(clean.length === 0, `a consistent tree reports nothing -- got ${JSON.stringify(clean)}`);
227+
assert(summarise({ pages: readPages(chapter), chapterList: names, kernelTable: names.map((n) => ({ accessor: n })) }).includes('3 chapter page(s)'), 'the summary names the counts, so a green can be read for its scope');
228+
229+
// ── Each limb observed FAILING ──────────────────────────────────────────
230+
// This is the #9604 defect itself: page + meta entry, absent from both lists.
231+
const sms = findingsFor({ list: ['data', 'email'], table: ['data', 'email'] });
232+
assert(sms.some((f) => f.where.endsWith('runtime-services/index.mdx') && f.msg.includes('omits `services.sms`')), `the chapter list omitting a real page is caught -- got ${JSON.stringify(sms)}`);
233+
assert(sms.some((f) => f.where === KERNEL_INDEX && f.msg.includes('no row for `services.sms`')), `the kernel table omitting a real page is caught -- got ${JSON.stringify(sms)}`);
234+
235+
assert(findingsFor({ meta: ['data', 'email'] }).some((f) => f.msg.includes('omits "sms-service"')), 'meta.json omitting a real page is caught');
236+
assert(findingsFor({ pages: ['data', 'email'] }).some((f) => f.msg.includes('does not exist')), 'an enumeration naming a page that does not exist is caught');
237+
assert(findingsFor({ list: ['data', 'sms', 'email'] }).some((f) => f.msg.includes('does not follow meta.json')), 'a chapter list in the wrong order is caught (membership alone would pass)');
238+
assert(findingsFor({ hrefFor: (n) => (n === 'sms' ? 'sms-svc' : `${n}-service`) }).some((f) => f.msg.includes('links to "sms-svc"')), 'a kernel table row whose href does not match its accessor is caught');
239+
240+
const lying = findingsFor({ titleFor: (n) => (n === 'sms' ? 'services.text' : `services.${n}`) });
241+
assert(lying.some((f) => f.msg.includes('expected "services.sms"')), 'a page whose title contradicts its filename is caught');
242+
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');
243+
244+
// ── Refuses to report OK over nothing ───────────────────────────────────
245+
let threw = false;
246+
try { findingsFor({ pages: [], meta: [], list: [], table: [] }); } catch { threw = true; }
247+
assert(threw, 'an EMPTY chapter is rejected, never reported OK');
248+
} finally {
249+
rmSync(dir, { recursive: true, force: true });
250+
}
251+
252+
if (failures.length) {
253+
console.error(`✗ check-runtime-services-index --self-test -- ${failures.length} failure(s)\n`);
254+
for (const f of failures) console.error(` • ${f}`);
255+
process.exit(1);
256+
}
257+
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.`);
258+
}
259+
260+
if (process.argv.includes('--self-test')) selfTest();
261+
else main();

0 commit comments

Comments
 (0)