From 1296288cd870de75a8764d4012976066a164aa63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:19:46 +0000 Subject: [PATCH 1/2] Strip internal issue-id references from the published skill catalog, and gate their return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skills/**` ships to customer projects and is loaded WHOLE into customer agent context windows. A `#NNNN` there resolves to nothing for that reader -- a citation-shaped token billed to every customer session, forever. Maintainer ruling 2026-08-23 (option A), resting on the standing ruling of 2026-08-12, verbatim and untranslated: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」 - Strip 129 id references across 20 hand-authored published files, keeping the teaching and dropping the citation. Where a customer-resolvable anchor existed it replaces the id (protocol version, ADR number, lint rule id). - Add a second corpus rule to `check-doc-authoring.mjs` banning bare internal ids on the customer-facing surface, with its own walker (the bare-literal walk skips `references/`, where a ninth of the population lived), a generated-artifact exemption, and a red/green self-test plus precision cases for hex colours, version numbers, HTTP codes, array indices and the `#1` ordinal. - Lower all ten shrunk token-ratchet ceilings to the re-measured readings; bundle 117943 -> 117725 tokens (-218). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ --- scripts/check-doc-authoring.mjs | 350 +++++++++++++++++- scripts/check-skills-token-ratchet.mjs | 62 +++- skills/README.md | 2 +- skills/objectstack-ai/SKILL.md | 10 +- skills/objectstack-api/SKILL.md | 12 +- skills/objectstack-automation/SKILL.md | 28 +- .../evals/approvals/test-revise-loop.md | 4 +- skills/objectstack-data/SKILL.md | 30 +- .../objectstack-data/references/data-hooks.md | 32 +- skills/objectstack-data/rules/field-types.md | 4 +- skills/objectstack-data/rules/hooks.md | 9 +- skills/objectstack-data/rules/indexing.md | 8 +- skills/objectstack-data/rules/validation.md | 4 +- skills/objectstack-formula/SKILL.md | 28 +- skills/objectstack-i18n/SKILL.md | 10 +- skills/objectstack-platform/SKILL.md | 4 +- skills/objectstack-pm-dispatch/SKILL.md | 2 +- skills/objectstack-query/SKILL.md | 16 +- skills/objectstack-query/evals/README.md | 4 +- skills/objectstack-query/rules/aggregation.md | 10 +- skills/objectstack-query/rules/pagination.md | 6 +- skills/objectstack-ui/SKILL.md | 22 +- skills/objectstack-upgrade/SKILL.md | 2 +- 23 files changed, 510 insertions(+), 149 deletions(-) diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index 34cece5c0e..7193e72631 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -236,6 +236,106 @@ const BARE = new RegExp(`^export const \\w+:\\s*${NS}(?:${DOMAINS})(?:Input)?\\s const FENCE_OPEN = /^```(?:ts|typescript|tsx)\s*$/; const FENCE_CLOSE = /^```\s*$/; +// ── Rule 2: bare internal issue ids on the CUSTOMER-FACING surface ────────── +// +// Maintainer ruling 2026-08-23, on the finding that measured this: strip the +// internal issue-id references from the published catalog and gate their +// return. It rests on the standing ruling of 2026-08-12, verbatim and +// untranslated: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」. +// +// Why this rule is scoped to `skills/**` alone, and not to the other ROOTS. +// `.claude/` and `docs/` are read INSIDE this repo, by readers who have the +// tracker, `git log` and the ADRs — an id there resolves. `skills/**` ships to +// customer projects: it is loaded WHOLE into customer agent context windows, in +// codebases that have none of those. To that reader `#4286` is not a citation, +// it is a citation-SHAPED token that resolves to nothing — and it is billed to +// their context window every session, forever, which is the cost curve +// `scripts/check-skills-token-ratchet.mjs` prices. So the ban follows the +// audience, not the file type, and widening it to the internal roots would be a +// different decision needing its own ruling. +// +// ⚠️ The scan is DELIBERATELY not the `collectFiles()` walk above. That walk +// skips every `references/` directory (SKIP_DIRS), and the published catalog +// keeps hand-authored reference companions there — +// `skills/objectstack-data/references/data-hooks.md` alone carried 15 of these +// ids when the corpus was measured. Reusing the walk would have produced a gate +// that runs, passes, and cannot see a ninth of the population it exists to +// guard: the exact failure this file's header opens with, one rule over. +const PUBLISHED_SKILLS_ROOT = 'skills'; + +// Generated artifacts under `skills/**`. Their ids are not authored here — they +// are projected from `.describe()` / TSDoc in `packages/spec`, so the fix for +// one is a spec-source edit plus a regeneration, on a surface with its own +// gates. Flagging them here would red a file no author can legally hand-edit +// ("do not edit" is in their own headers) and point the remedy at the wrong +// repo layer. They are exempt from THIS rule, not absolved: the spec-side ids +// are tracked separately. +const GENERATED_SKILL_ARTIFACTS = [ + /\/references\/_index\.md$/, + /\/references\/react-blocks\.md$/, +]; + +/** + * An id-shaped token that is FICTIONAL EXAMPLE DATA in a syntax demonstration, + * not a citation of a real internal issue. + * + * Entries are `path:line-substring` and must clear a deliberately narrow bar: + * the number denotes "an issue number you would type here", the passage teaches + * the syntax rather than sourcing a claim, and removing it would damage the + * lesson. `/pm-dispatch #128 #131` is the invocation grammar of the command the + * page documents — `#N` IS the argument — so the ids are instructive AS ids. + * + * ⛔ This is not a place to park a citation you would rather not rewrite. A + * provenance reference ("removed in #4286", "see #3447") is never example data, + * however inconvenient; rewrite it to keep the teaching and drop the id, which + * is what the ruling asks for. The list is pinned in the self-test so it cannot + * grow silently. + */ +const EXAMPLE_ID_ALLOWLIST = [ + { + file: 'skills/objectstack-pm-dispatch/SKILL.md', + contains: '/pm-dispatch #128 #131', + why: 'CLI usage line — the ids are the command\'s own argument syntax, not a citation.', + }, +]; + +/** + * A bare internal issue-id reference: `#` followed by 3–5 digits. + * + * The precision is carried by the TRAILING `(?![0-9A-Za-z])`, and it is load- + * bearing rather than decorative — it is what keeps CSS hex colours out. The + * catalog really contains `#6366f1`, `#4169E1` and `#3498db` in authored + * examples, and a rule anchored only on the leading `#` reports all three. That + * is not a hypothetical: the filing count for this cleanup was 92 and the true + * population was 90, the difference being exactly the two hex colours in + * `objectstack-ui/SKILL.md` that a `#[0-9]{3,5}` scan mistook for issue ids. + * A gate that cries wolf on a colour literal is a gate authors route around. + * + * The same lookahead rejects 6-digit all-numeric colours (`#123456`), since the + * sixth digit is a word character. + * + * `(? p.split(sep).join('/'); function walk(dir, out) { @@ -335,6 +435,61 @@ function collectFiles() { return files; } +/** + * Every hand-authored Markdown file in the PUBLISHED catalog. + * + * Its own walk, for the reason argued at {@link PUBLISHED_SKILLS_ROOT}: the + * `collectFiles()` walk skips `references/`, where hand-authored companions + * live. Generated artifacts are dropped by path. + * + * Empty is a hard error here for the same reason it is in `collectFiles` + * (#4932): "the catalog is clean" and "the catalog was never opened" are the + * same output and the same exit code, and this rule's whole job is to speak for + * a corpus the author cannot see being read. + * + * @throws {DeadRootError} `skills/` is not a directory. + * @throws {EmptyRootError} `skills/` yielded no Markdown file. + */ +function collectPublishedSkillFiles(root = PUBLISHED_SKILLS_ROOT) { + assertRootsResolvable([root]); + const files = []; + // A dedicated walker, NOT the shared `walk()`. That one honours SKIP_DIRS, + // whose `references` entry is correct for the bare-literal rule and wrong for + // this one: `references/` is where the catalog's hand-authored companions + // live. Reusing it green-lit a ninth of this rule's population unseen — the + // self-test case above is the reverse proof, and it failed until this walker + // existed. + (function descend(dir) { + for (const e of readdirSync(dir)) { + if (e === 'node_modules' || e === '.git' || e === 'dist') continue; + const p = join(dir, e); + if (statSync(p).isDirectory()) descend(p); + else if (/\.mdx?$/.test(e)) files.push(posix(p)); + } + })(root); + const kept = files.filter((p) => !GENERATED_SKILL_ARTIFACTS.some((re) => re.test(p))); + if (kept.length === 0) throw new EmptyRootError([root], 0); + return kept.sort(); +} + +/** True when `line` is an allowlisted fictional-example passage in `file`. */ +function isAllowlistedExample(file, line) { + return EXAMPLE_ID_ALLOWLIST.some((e) => e.file === file && line.includes(e.contains)); +} + +/** Bare internal issue-id references in one published file's source. */ +function findIdViolations(source, file) { + const out = []; + const lines = source.split('\n'); + for (let i = 0; i < lines.length; i++) { + const ln = lines[i]; + if (isAllowlistedExample(posix(file), ln)) continue; + const ids = ln.match(INTERNAL_ID); + if (ids) out.push({ file: posix(file), line: i + 1, ids, text: ln.trim() }); + } + return out; +} + /** Bare metadata literals inside ts/tsx fenced blocks of one file's source. */ function findViolations(source, file) { const out = []; @@ -508,6 +663,124 @@ function selfTest() { expect('a scan that finds nothing at all is red, not "0 files clean"', allEmptyErr instanceof EmptyRootError, true); expect('every empty root is named', allEmptyErr?.roots?.join(',') ?? '', ROOTS.join(',')); expect('the zero total is reported', allEmptyErr?.total ?? -1, 0); + + // ── Rule 2: internal issue ids on the published surface ────────────── + // + // The red/green PAIR is the point. "Green on the real corpus" is what a + // rule that cannot fire also looks like, so the planted id must be proven + // to turn it red, and its removal proven to turn it green again, in the + // same run, over the real collector. + const idTree = { + // The published surface — in scope, including a hand-authored companion + // under references/, which the OTHER rule's walk skips entirely. + 'skills/objectstack-demo/SKILL.md': 'The `cursor` key was removed in protocol 17.', + 'skills/objectstack-demo/references/data-hooks.md': 'Hooks fire per row.', + 'skills/objectstack-demo/rules/indexing.md': '`type` was retired.', + // Generated artifacts — exempt: their ids come from packages/spec TSDoc. + 'skills/objectstack-demo/references/_index.md': 'Driver registry (#4410).', + 'skills/objectstack-ui/references/react-blocks.md': 'Converging on the metadata tier (#11284).', + // The internal roots are NOT this rule's business. + '.claude/agents/os-dev.md': 'Lesson learned while fixing #4286.', + 'docs/adr/0049-enforce-or-remove.md': 'Superseded by #5248.', + 'content/docs/protocol/query.mdx': 'See #4286 for the removal.', + }; + const idDir = mkdtempSync(join(tmpdir(), 'doc-authoring-selftest-ids-')); + try { + for (const [rel, body] of Object.entries(idTree)) { + const full = join(idDir, ...rel.split('/')); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, body); + } + process.chdir(idDir); + + const scan = () => collectPublishedSkillFiles() + .flatMap((f) => findIdViolations(readFileSync(f, 'utf8'), f)); + + // GREEN: the corpus as stripped. + expect('a clean published corpus is green', scan().length, 0); + + // Scope: the collector reaches references/, and drops the generated files. + const seen = collectPublishedSkillFiles(); + expect('the id scan reaches hand-authored references/ (the other rule\'s walk does not)', + seen.includes('skills/objectstack-demo/references/data-hooks.md'), true); + expect('generated references/_index.md is exempt', + seen.includes('skills/objectstack-demo/references/_index.md'), false); + expect('the generated react-blocks contract page is exempt', + seen.includes('skills/objectstack-ui/references/react-blocks.md'), false); + expect('the id scan does not reach .claude/', seen.some((f) => f.startsWith('.claude/')), false); + expect('the id scan does not reach docs/', seen.some((f) => f.startsWith('docs/')), false); + expect('the id scan does not reach content/', seen.some((f) => f.startsWith('content/')), false); + + // RED: plant one id in a published file — in prose... + const planted = join(idDir, 'skills', 'objectstack-demo', 'SKILL.md'); + writeFileSync(planted, 'The `cursor` key was removed in protocol 17 (#4286).'); + let red = scan(); + expect('a planted id in published prose is RED', red.length, 1); + expect('the red names the file', red[0]?.file, 'skills/objectstack-demo/SKILL.md'); + expect('the red names the id', red[0]?.ids?.join(','), '#4286'); + + // ...and in a comment inside a code fence, which is where half the + // measured population lived. + writeFileSync(planted, ['```ts', " cursor: 'abc', // removed in #4286", '```'].join('\n')); + expect('a planted id in a fenced code comment is RED too', scan().length, 1); + + // ...and in the cross-repo spelling, which carries no space. + writeFileSync(planted, 'See framework#3582 for the token resolver.'); + expect('the `repo#NNNN` spelling is RED', scan().length, 1); + + // GREEN again, from the same collector — so the red above was the id and + // nothing else about the tree. + writeFileSync(planted, 'The `cursor` key was removed in protocol 17.'); + expect('removing the id makes it green again', scan().length, 0); + + // ── Precision: the shapes that must NEVER fire ────────────────────── + // Each is a real spelling from the catalog. A gate that reds on any of + // them is one authors learn to route around, which costs more than the + // rule earns. + const mustPass = [ + ['CSS hex colour, lowercase suffix', "color: '#6366f1'"], + ['CSS hex colour, uppercase suffix', "color: '#4169E1'"], + ['CSS hex colour, mid-string digits', "color: '#3498db'"], + ['CSS hex colour, all-numeric', "color: '#123456'"], + ['a version number', 'removed in spec 17.0.0, protocol 17, v16'], + ['an HTTP status code', 'returns `400 INVALID_FIELD`, not 404'], + ['an array index', 'read `searchableFields[0]` and `fields[12]`'], + ['the ordinal "#1"', 'The #1 authoring mistake is a bare field ref.'], + ['a markdown heading', '### 4286 things to know'], + ['an HTML numeric entity', 'an em dash — here'], + ['a two-digit id-shaped token', 'issue #42 is below the floor'], + ['a six-digit run', 'the build id is #1234567'], + ]; + for (const [label, body] of mustPass) { + writeFileSync(planted, body); + expect(`precision — ${label} does not fire`, scan().length, 0); + } + + // The allowlist fires as an exemption, and ONLY on its own passage. + mkdirSync(join(idDir, 'skills', 'objectstack-pm-dispatch'), { recursive: true }); + writeFileSync( + join(idDir, 'skills', 'objectstack-pm-dispatch', 'SKILL.md'), + ['```', '/pm-dispatch #128 #131 # two named issues, nothing else', '```'].join('\n'), + ); + writeFileSync(planted, 'The `cursor` key was removed in protocol 17.'); + expect('the allowlisted fictional-example line is exempt', scan().length, 0); + // ...but the same ids elsewhere are not exempt: the entry is pinned to + // its file AND its passage, so it cannot become a blanket file pass. + writeFileSync(planted, 'Filed as #128 and #131.'); + expect('the same ids outside the allowlisted passage are still RED', scan().length, 1); + writeFileSync(planted, 'The `cursor` key was removed in protocol 17.'); + + // Empty is a hard error, not a pass (#4932), for this rule too. + rmSync(join(idDir, 'skills'), { recursive: true, force: true }); + mkdirSync(join(idDir, 'skills'), { recursive: true }); + let idEmptyErr = null; + try { collectPublishedSkillFiles(); } catch (err) { idEmptyErr = err; } + expect('an empty published catalog is red, not "0 files clean"', + idEmptyErr instanceof EmptyRootError, true); + } finally { + process.chdir(dir); + rmSync(idDir, { recursive: true, force: true }); + } } finally { process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); @@ -547,11 +820,30 @@ function selfTest() { expect('no exemption swallows a declared root whole', [...SKIP_PATHS].some((p) => ROOTS.includes(p)), false); + // ── The example-id allowlist, pinned so it cannot grow in silence ──────── + // Enforcement cannot hold this: an entry added for the wrong reason runs + // green forever. Pinning the CONTENT is what makes a widening show up as a + // failing case here rather than as a citation quietly re-entering the + // catalog under an exemption nobody re-read. + expect('the example-id allowlist holds exactly the passages it was measured for', + EXAMPLE_ID_ALLOWLIST.map((e) => `${e.file}::${e.contains}`).join(' | '), + 'skills/objectstack-pm-dispatch/SKILL.md::/pm-dispatch #128 #131'); + expect('every allowlist entry records WHY it is example data and not a citation', + EXAMPLE_ID_ALLOWLIST.every((e) => typeof e.why === 'string' && e.why.length > 20), true); + expect('every allowlist entry names a file on the published surface', + EXAMPLE_ID_ALLOWLIST.every((e) => e.file.startsWith(`${PUBLISHED_SKILLS_ROOT}/`)), true); + // An entry must be a PASSAGE, never a bare filename — a `contains` that + // matched everything would exempt the whole file. + expect('every allowlist entry is pinned to a passage, not a whole file', + EXAMPLE_ID_ALLOWLIST.every( + (e) => e.contains.length > 8 && new RegExp(INTERNAL_ID_SOURCE).test(e.contains), + ), true); + if (failures.length) { console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`); process.exit(1); } - console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); + console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the example allowlist pinned to its passage) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); } function main() { @@ -596,18 +888,58 @@ function main() { } const violations = files.flatMap((file) => findViolations(readFileSync(file, 'utf8'), file)); - if (violations.length === 0) { - console.log(`✓ doc authoring guard: ${files.length} files clean — no bare metadata literals.`); + let published; + try { + published = collectPublishedSkillFiles(); + } catch (err) { + console.error( + `\n✗ doc authoring guard: the published catalog (${PUBLISHED_SKILLS_ROOT}/) could not be` + + `\nscanned for internal issue-id references, so this run cannot vouch for it:` + + `\n\n ${err.message}\n`, + ); + process.exit(1); return; } + const idViolations = published.flatMap((file) => findIdViolations(readFileSync(file, 'utf8'), file)); + + let failed = false; + + if (violations.length > 0) { + failed = true; + console.error(`\n✗ Bare metadata-literal authoring found in docs/skills (#2035). Use the defineX factory instead:\n`); + for (const v of violations) { + console.error(` ${v.file}:${v.line}`); + console.error(` ${v.text}`); + } + console.error(`\n${violations.length} violation(s). Author via e.g. \`definePage({ ... })\` — a value import that fails loudly, validates at parse time, and is the one pattern AI should learn. See ADR-0059.\n`); + } - console.error(`\n✗ Bare metadata-literal authoring found in docs/skills (#2035). Use the defineX factory instead:\n`); - for (const v of violations) { - console.error(` ${v.file}:${v.line}`); - console.error(` ${v.text}`); + if (idViolations.length > 0) { + failed = true; + console.error(`\n✗ Internal issue-id reference(s) in the PUBLISHED skill catalog:\n`); + for (const v of idViolations) { + console.error(` ${v.file}:${v.line} ${v.ids.join(' ')}`); + console.error(` ${v.text}`); + } + console.error( + `\n${idViolations.length} line(s). \`skills/**\` ships to customer projects and is loaded WHOLE` + + `\ninto customer agent context windows. A reader there has no tracker, no \`git log\` and no` + + `\nADRs, so \`#NNNN\` resolves to nothing for the audience actually paying for it — a` + + `\ncitation-shaped token billed to every customer session, forever.` + + `\n\nKeep the TEACHING, drop the citation. A sentence that exists only to cite an id goes` + + `\nentirely; a sentence that teaches something keeps the lesson and loses the number` + + `\n("removed in #4286" -> "removed in protocol 17", or just "removed"). Prefer a customer-` + + `\nresolvable anchor where one exists — a protocol version, an ADR number, a lint rule id.` + + `\n\nMaintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」` + + `\n\n⛔ Do NOT silence this by adding an EXAMPLE_ID_ALLOWLIST entry — that list is for` + + `\nfictional example data in a syntax demonstration, never for a provenance reference.\n`, + ); } - console.error(`\n${violations.length} violation(s). Author via e.g. \`definePage({ ... })\` — a value import that fails loudly, validates at parse time, and is the one pattern AI should learn. See ADR-0059.\n`); - process.exit(1); + + if (failed) process.exit(1); + + console.log(`✓ doc authoring guard: ${files.length} files clean — no bare metadata literals.`); + console.log(`✓ doc authoring guard: ${published.length} published skill files clean — no internal issue-id references.`); } main(); diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index 28b2192fd8..23d1d47c0a 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -130,6 +130,27 @@ export const CEILING_BASIS = { main: '465bfce90', pending10402: '7228d6c25', from10402: ['skills/objectstack-data/SKILL.md', 'skills/objectstack-platform/SKILL.md'], + /** + * Every ceiling below was RE-MEASURED after the internal issue-id strip, on + * this sha plus that change. Maintainer ruling 2026-08-23: strip the internal + * issue-id references from the published catalog, per-file ceiling drops + * landing in the same PR. + * + * The re-measure supersedes both bases above as the origin of the numbers — + * they are kept because they still explain the SHAPE of the two rows that + * carried a second basis, not because any current number is read from them. + * ⚠️ #10402's reserved headroom was already spent when this landed: + * `objectstack-data` measured 13817 against a 13817 ceiling on the base + * below — exactly zero, which is the header's "the headroom returns to zero + * on its own", observed. + * + * Several rows drop by MORE than their file shrank, because a lowering also + * reclaims whatever slack the row already carried (`objectstack-formula` + * shrank 34 and its ceiling drops 53). That is the ratchet working as + * designed: shrink-only means a ceiling may be lowered to the measurement + * whenever one is taken, not merely by the size of the day's deletion. + */ + strippedInternalIds: '3f571a6d2', }; /** @@ -141,23 +162,21 @@ export const CEILING_BASIS = { * branch head `7228d6c25` — see {@link CEILING_BASIS}. */ export const CEILINGS = new Map([ - ['skills/objectstack-ai/SKILL.md', 6824], - ['skills/objectstack-api/SKILL.md', 6342], - ['skills/objectstack-automation/SKILL.md', 12543], - // basis 7228d6c25 (PR #10402 head), not main — see CEILING_BASIS. - // +20 (13797→13817): maintainer ruling 2026-08-23 on PR #11141 — the three - // SECURITY_OWD_UNSET-required sharingModel keys. Ruling quoted in that PR's body. - ['skills/objectstack-data/SKILL.md', 13817], - ['skills/objectstack-formula/SKILL.md', 6055], - ['skills/objectstack-i18n/SKILL.md', 6349], - // basis 7228d6c25 (PR #10402 head), not main — see CEILING_BASIS. - ['skills/objectstack-platform/SKILL.md', 12716], + // Every row re-measured after the internal issue-id strip — see + // CEILING_BASIS.strippedInternalIds. `(was N)` is the ceiling this replaced. + ['skills/objectstack-ai/SKILL.md', 6806], // -18 (was 6824) + ['skills/objectstack-api/SKILL.md', 6331], // -11 (was 6342) + ['skills/objectstack-automation/SKILL.md', 12511], // -32 (was 12543) + ['skills/objectstack-data/SKILL.md', 13783], // -34 (was 13817) + ['skills/objectstack-formula/SKILL.md', 6002], // -53 (was 6055) + ['skills/objectstack-i18n/SKILL.md', 6338], // -11 (was 6349) + ['skills/objectstack-platform/SKILL.md', 12705], // -11 (was 12716) + // Unchanged: this skill's only id-shaped tokens are a CLI usage line and a + // JSON shape example. The one edit there (`#457` -> `#`) is byte-neutral. ['skills/objectstack-pm-dispatch/SKILL.md', 14239], - ['skills/objectstack-query/SKILL.md', 5569], - ['skills/objectstack-ui/SKILL.md', 25154], - // +10 (8325→8335): same 2026-08-23 ruling — crm_lead's SECURITY_OWD_UNSET-required - // sharingModel key (value mirrored from examples/app-crm per the same ruling). - ['skills/objectstack-upgrade/SKILL.md', 8335], + ['skills/objectstack-query/SKILL.md', 5552], // -17 (was 5569) + ['skills/objectstack-ui/SKILL.md', 25125], // -29 (was 25154) + ['skills/objectstack-upgrade/SKILL.md', 8333], // -2 (was 8335) ]); /** @@ -356,6 +375,17 @@ function selfTest() { 'skills/objectstack-data/SKILL.md,skills/objectstack-platform/SKILL.md'], ['both second-basis files carry a ceiling', CEILING_BASIS.from10402.every((p) => CEILINGS.has(p)), true], + // ── the internal-id strip re-measure ───────────────────────────────── + // Same reason as the two pins above: the gate never reads this sha, so a + // recorded provenance that drifts from the numbers it explains does so in + // silence. Pinned here, it moves only when someone means to move it. + ['the internal-id-strip basis sha is recorded', CEILING_BASIS.strippedInternalIds, '3f571a6d2'], + // The direction, asserted rather than assumed: this re-measure LOWERED the + // bundle. A future edit that re-measures upward has to change this number + // and meet the maintainer-ruling bar in the header while doing it. + ['the re-measure lowered the bundle total', + [...CEILINGS.values()].reduce((a, b) => a + b, 0) < 117943, true], + // ── the report line ────────────────────────────────────────────────── ['the report prints a bundle total', reportLines([{ rel, tokens: 10, ceiling: 20 }]).some((l) => l.includes('bundle total')), true], diff --git a/skills/README.md b/skills/README.md index fdc9720932..eba5382b3b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -10,7 +10,7 @@ npx skills add objectstack-ai/objectstack/skills --all ``` The `/skills` subpath matters: it is the published catalog boundary — pointing -the skills CLI at the repo root would also pick up repo-internal skills (#3101). +the skills CLI at the repo root would also pick up repo-internal skills. Each **domain** skill is self-contained: a `SKILL.md` with YAML frontmatter, plus a `references/_index.md` that points into the authoritative Zod sources in diff --git a/skills/objectstack-ai/SKILL.md b/skills/objectstack-ai/SKILL.md index ee03ce04e7..fd4b78d19c 100644 --- a/skills/objectstack-ai/SKILL.md +++ b/skills/objectstack-ai/SKILL.md @@ -169,7 +169,7 @@ To grant data exploration to your own (platform-internal) agent, add | `tools` | Direct tool references — legacy fallback | | `surface` | `'ask' \| 'build'` — the product surface this agent is (default `'ask'`) | | `model` | LLM model configuration — `provider`, `model`, `temperature`, `maxTokens`, `topP` | -| ~~`knowledge`~~ | REMOVED in protocol 17 (#3896 close-out) — declaring sources/indexes on an agent never scoped retrieval (`search_knowledge` takes `sourceIds` from the LLM's tool-call arguments). Restrict at the knowledge-service/source level; describe intended grounding in `instructions` | +| ~~`knowledge`~~ | REMOVED in protocol 17 — declaring sources/indexes on an agent never scoped retrieval (`search_knowledge` takes `sourceIds` from the LLM's tool-call arguments). Restrict at the knowledge-service/source level; describe intended grounding in `instructions` | | `guardrails` | `maxTokensPerInvocation`, `maxExecutionTimeSec`, `blockedTopics` | | `structuredOutput` | Output format (JSON schema, regex, etc.) | | `planning` | Autonomous reasoning — `maxIterations` (default 10) | @@ -298,10 +298,10 @@ A tool authored as metadata (`type: 'tool'`, `*.tool.ts`) is validated by `ToolSchema`: required `name` / `label` / `description`, a **JSON Schema** `parameters` object, plus optional `objectName` and `outputSchema`. `ToolSchema` is **strict** — an unknown key (a typo, or a retired key) is a parse error, not -a silent strip. Retired in the #3896 close-out: `category`, `permissions`, +a silent strip. Retired in protocol 17: `category`, `permissions`, `active` and `builtIn` (all were authorable and inert; `permissions` gated nothing and `active: false` withdrew nothing — the rejection message carries -each key's replacement), joining `requiresConfirmation` (#3715). +each key's replacement), joining `requiresConfirmation`. ```typescript @@ -334,7 +334,7 @@ enforced surface). > executor loads a metadata-authored tool. The runtime executes a > separately-registered `AIToolDefinition` (cloud `@objectstack/service-ai`); > tool metadata is a one-way projection for Studio / discovery. Do not expect a -> hand-authored tool to run in the open edition (liveness audit #1878/#1892). +> hand-authored tool to run in the open edition. ### Inline Agent `tools[]` (legacy) @@ -544,7 +544,7 @@ On validation failure the runtime retries by default `ai.requiresConfirmation` on the **action**, or `approval: 'always'` on an MCP tool binding. AI metadata edits are already gated: they land as drafts a human must publish (ADR-0033). - ⚠️ `requiresConfirmation` on the **tool** was REMOVED (#3715, ADR-0033 §2) — + ⚠️ `requiresConfirmation` on the **tool** was REMOVED (ADR-0033 §2) — it was read by no execution path, so it produced no pause. `ToolSchema` is strict, so authoring it now fails the parse with the migration attached. There is no `requireApprovalFor` field. diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index b975a751e5..07750be8d7 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -65,7 +65,7 @@ aggregation goes through `POST /api/v1/data/{object}/query` with > **Key rule:** If your object defines `apiMethods`, only those operations (and > what derives from them) are exposed. For example, `apiMethods: ['get', 'list']` > creates a read-only API. The authorable values are the SIX PRIMITIVES -> (`get/list/create/update/delete/bulk`, #3543); everything else (`export`, +> (`get/list/create/update/delete/bulk`); everything else (`export`, > `search`, `upsert`, …) is DERIVED from them by the server — `['list']` grants > aggregate/search/export for free, `['create','update']` grants upsert/import. > An empty array `[]` means deny-all (fully closed). @@ -149,7 +149,7 @@ The alternative — and usually the better one — is the **declarative** surfac ## Declarative Endpoints (`apis:`) — no handler code `defineStack({ apis })` declares an HTTP endpoint as **metadata**. Declared -endpoints are **live from protocol 17** (#5040): the runtime matches +endpoints are **live from protocol 17**: the runtime matches `METHOD` + `path`, runs the endpoint's policy keys, and delegates to the *same* pipelines the built-in routes use — `object_operation` to the data pipeline behind `/api/v1/data/{object}`, `flow` to the automation pipeline behind @@ -282,7 +282,7 @@ only the scoped routes are registered; with `optional`/`auto` the bare ## API Methods (Operations) -The authorable `ApiMethod` enum is the SIX PRIMITIVES (#3543). The wider +The authorable `ApiMethod` enum is the SIX PRIMITIVES. The wider EFFECTIVE operation vocabulary (`ApiOperation`, 14 values) is what gates and responses speak — the eight extra verbs are DERIVED from the primitives, never declared in `apiMethods`: @@ -306,8 +306,8 @@ declared in `apiMethods`: | `aggregate` | `list` | No dedicated route — use `POST /data/{object}/query` with `groupBy`/`aggregations` | Count, sum, avg, min, max | | `history` | `get` ∧ `trackHistory` | Gating only — no dedicated generated route today | Audit trail access | | `search` | `list` ∧ `searchable` | Global `GET /api/v1/search` (cross-object), not per-object | Full-text search | -| `restore` | never (trash retired, #2377) | Gating only | Restore a soft-deleted record (reserved — platform deletes are hard today) | -| `purge` | never (trash retired, #2377) | Gating only | Permanent deletion | +| `restore` | never (trash retired) | Gating only | Restore a soft-deleted record (reserved — platform deletes are hard today) | +| `purge` | never (trash retired) | Gating only | Permanent deletion | | `import` | `create` ∨ `update` (writeMode-precise) | `POST /data/{object}/import` | Bulk data import | | `export` | `list` | `GET /data/{object}/export` | Data export | @@ -578,7 +578,7 @@ async function firstTenAccounts() { external-facing APIs. 5. **Assuming `DELETE` is recoverable.** ObjectStack `DELETE` is a hard delete — there is no recycle bin (the dead `enable.trash` flag was removed - in 16.x, #2377). For recoverability, use per-field `trackHistory` (audit + in 16.x). For recoverability, use per-field `trackHistory` (audit trail) or a `lifecycle` archive policy instead of custom soft-delete logic. --- diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 47d9094d1e..0d00cf9cdc 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -166,7 +166,7 @@ variables: [ > **Writing a `readonly` field? Set `runAs: 'system'`.** `readonly: true` > governs the end-user surface: under the default `runAs: 'user'`, the engine > **silently strips** a `readonly` field from an `update_record` payload -> (#2948) — the step reports success but the value never lands. A flow that +> — the step reports success but the value never lands. A flow that > maintains a `readonly` field (approval stamps, conversion flags, SLA > markers, rollups) must run `runAs: 'system'`, the trusted-writer channel. > `os validate` / `os build` fail a `runAs:'user'` `update_record` that writes @@ -254,7 +254,7 @@ dead-end. label: 'Case Lifecycle', field: 'status', // the field that holds the state message: 'Invalid status transition.', - initialStates: ['new'], // states a record may be CREATED in (#3165) + initialStates: ['new'], // states a record may be CREATED in transitions: { new: ['open'], open: ['escalated', 'resolved'], @@ -268,7 +268,7 @@ dead-end. Notes: - **One rule per field.** Parallel lifecycles (e.g. `status` + `payment_status`) are N separate `state_machine` rules, one per field. -- **`initialStates`** (optional, #3165) gates INSERT: a record created with its +- **`initialStates`** (optional) gates INSERT: a record created with its state field outside this list is rejected. `transitions` only governs updates, so without it a record can be born mid-flow (e.g. created already `resolved`). Omit to keep the legacy no-check-on-insert behavior. @@ -394,7 +394,7 @@ Three pieces author it: is a *service-owned* pause (`resumeAuthority: 'service'`), so only `POST /api/v1/approvals/requests/:id/resubmit` can end it. ADR-0044 D3 first prescribed an ordinary `wait` here and its **2026-07-28 amendment reversed - that** (#3823) — a `wait` is `resumeAuthority: 'any'`, so a raw + that** — a `wait` is `resumeAuthority: 'any'`, so a raw `POST /api/v1/automation/:name/runs/:runId/resume` walked the back-edge with no submitter check and no audit row, and could destroy the run outright. The `approval_revise` node takes **no config** — there is no signal to wait on. @@ -453,7 +453,7 @@ your normal `flows: [...]`. A decision is recorded through `ApprovalService.decide()` (or the REST routes `POST /api/v1/approvals/requests/:id/approve` | `/reject`). That finalizes the `sys_approval_request` and **resumes** the suspended run down the matching -branch — you never resume the flow by hand, and since #3801 you *cannot*: the +branch — you never resume the flow by hand, and you *cannot*: the `approval` node declares `resumeAuthority: 'service'`, so `POST /api/v1/automation/:name/runs/:runId/resume` answers **403** for a run parked on one (including via a `subflow` pause) and changes nothing. @@ -481,11 +481,11 @@ still missing. | `team` | Members of a flat `sys_team` | | `department` | A department + all descendant departments | | `manager` | The submitter's manager (`sys_user.manager_id`) | -| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry (#3447), so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user | +| `field` | User id read from a record field (`value` = field name). Resolved against the record's **live** state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user | | `queue` | A data-ownership queue | | `expression` | A **CEL expression** resolved at node entry (`value` = the expression) — see **Dynamic approvers** below. Only `current.*` / `trigger.*` / `vars.*` roots are available; the optional `resolveAs: 'user'(default) \| 'department' \| 'position' \| 'team'` re-expands each resolved id through the graph | -### Dynamic approvers (`type: 'expression'`, #3447) +### Dynamic approvers (`type: 'expression'`) An `expression` approver computes WHO approves at the moment the node is entered. Its CEL source sees exactly **three roots** — nothing else: @@ -499,7 +499,7 @@ entered. Its CEL source sees exactly **three roots** — nothing else: **`record` and bare field names are NOT available and fail the node loudly.** Everywhere else on this platform `record` means "the record at event time" (flow conditions: the trigger snapshot; hook conditions: the stored record -overlaid with the write's payload, #4770) — at an +overlaid with the write's payload) — at an approval node that phrase is ambiguous between two different times, so you must say which one: `current.x` or `trigger.x`. Do not carry the `record.x` habit over from conditions. @@ -592,8 +592,8 @@ Time-word cheat sheet across surfaces (do not mix them up): `previous` / `session` / `ql` (plus `object` / `event`) — a handler reads the write payload as `ctx.input`. The bare `record` / `previous` roots are the **condition**'s CEL scope, not the handler's context object: `record` is the -stored row overlaid with this write's payload (#4770) and `previous` is the -pre-write row (#4784), both made total over the object's declared fields. See +stored row overlaid with this write's payload and `previous` is the +pre-write row, both made total over the object's declared fields. See `objectstack-formula` §5 for where `previous` is bound and where it is not. ### Node Config (`ApprovalNodeConfigSchema`) @@ -605,8 +605,8 @@ pre-write row (#4784), both made total over the object's declared fields. See | `minApprovals` | Approvals required — total for `quorum`, per group for `per_group`. Default `1`; clamped at runtime to the resolvable approver count so a misconfiguration can never deadlock | | `lockRecord` | Lock the triggering record from edits while pending. Default `true` | | `approvalStatusField` | Business-object field to mirror `pending`/`approved`/`rejected`/`recalled` onto (should be readonly) | -| `onEmptyApprovers` | #3447 — what an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) | -| `decisionOutputs` | #3447 — decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `.` variables; undeclared keys reject the decision; `decision`/`requestId` reserved | +| `onEmptyApprovers` | What an EMPTY resolved slate does: `admin_rescue` (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), `fail` (node fails — treat an empty slate as a config bug), `auto_approve` (skip the request, continue down `approve` with `output.autoApproved = true` — opt-in because it silently waves the record through). Declare it explicitly on any node with an `expression` approver (linted) | +| `decisionOutputs` | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) **or typed declarations** `{ key, label?, type: 'text'\|'user'\|'department'\|'position'\|'team', multiple? }` — a typed entry renders the matching record picker in the decision dialog (`multiple` collects an id array). Accepted outputs resume the run as `.` variables; undeclared keys reject the decision; `decision`/`requestId` reserved | | `escalation` | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\|auto_approve\|auto_reject\|notify, escalateTo?, notifySubmitter }`. `escalateTo` is a **position machine name** (expanded to its holders via `sys_user_position`, ADR-0090 D3) or a specific user id — never a membership tier. `reassign` without `escalateTo` degrades to notify (linted) | | `maxRevisions` | ADR-0044 — max **send-backs-for-revision** per run before auto-reject. Default `3`; `0` disables send-back. Only meaningful when the node has a `revise` out-edge | @@ -623,7 +623,7 @@ These are wired on the **graph**, not in node config: - **Send back for revision (ADR-0044)** — distinct from a plain reject: an Approval node can emit a third decision **`revise`** on a `revise`-labeled out-edge that routes to an **`approval_revise`** rework window (not a plain - `wait` — #3823). The submitter edits and resubmits, re-entering the node via an + `wait`). The submitter edits and resubmits, re-entering the node via an edge `type: 'back'` (a declared back-edge — traversed at run time but excluded from DAG cycle validation). `maxRevisions` (node config, default `3`) caps the loop before auto-reject. @@ -879,7 +879,7 @@ them right the first time: empty `script` node refuses at execute, and one pointing at an unregistered function fails loudly. - The other dispatch forms were retired in spec 17 (#4343) because none of them + The other dispatch forms were retired in spec 17 because none of them ran: `config.actionType: 'email' | 'slack'` were logger-backed stubs that delivered nothing (with `config.template` / `.recipients` / `.variables` feeding a message no channel sent), and inline `config.script` JS was never diff --git a/skills/objectstack-automation/evals/approvals/test-revise-loop.md b/skills/objectstack-automation/evals/approvals/test-revise-loop.md index 2178489122..64a3200b1a 100644 --- a/skills/objectstack-automation/evals/approvals/test-revise-loop.md +++ b/skills/objectstack-automation/evals/approvals/test-revise-loop.md @@ -33,7 +33,7 @@ for the revision window, and a **declared back-edge** closing the loop: config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 } }, // send-back budget // `approval_revise`, not `wait`: the window is service-owned, ended only by - // the submitter's resubmit — so it takes no config (#3823). + // the submitter's resubmit — so it takes no config. { id: 'wait_revision', type: 'approval_revise', label: 'Awaiting Revision' }, { id: 'approved', type: 'end', label: 'Approved' }, { id: 'rejected', type: 'end', label: 'Rejected' }, @@ -58,7 +58,7 @@ the framework repo. |---|---|---| | Missing `label` on the flow or on a node | `label` is required by `FlowSchema` — `FlowSchema.parse` / `registerFlow` rejects the definition before any graph validation runs | `registerFlow` (schema parse) | | Resubmit edge **without** `type: 'back'` | `registerFlow` validates the graph-minus-back-edges as a DAG, so it rejects the cycle as un-declared | `registerFlow`; lint `flow-approval-revise-unmarked-backedge` | -| `revise` edge into a plain **`wait`** node (or any other type) | The window is a service-owned pause: a `wait` is `resumeAuthority: 'any'`, so a raw run-resume walks the back-edge with no submitter check and no audit row, and can destroy the run. `sendBack` refuses this metadata (#3823, amended ADR-0044) | lint `flow-approval-revise-target-not-service-owned` (**error**) | +| `revise` edge into a plain **`wait`** node (or any other type) | The window is a service-owned pause: a `wait` is `resumeAuthority: 'any'`, so a raw run-resume walks the back-edge with no submitter check and no audit row, and can destroy the run. `sendBack` refuses this metadata (amended ADR-0044) | lint `flow-approval-revise-target-not-service-owned` (**error**) | | `revise` edge to a window that **never loops back** | A valid DAG (registerFlow accepts it), but the submitter has nowhere to resubmit — the branch dead-ends | lint `flow-approval-revise-dead-end` | | `maxRevisions: 0` together with a `revise` edge | Send-back is disabled, so every revise auto-rejects and the branch never runs | lint `flow-approval-revise-disabled` | | Re-suspending the approval node in a "revise mode" (no window node, no edge) | Hides a state machine inside one node — invisible to the canvas/run log; not the ADR-0044 model. (The 2026-07-28 amendment made the window a dedicated node TYPE, which keeps it visible; it did not move the pause inside the approval node.) | design review | diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index e55bf3119e..9eb9e04948 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -142,7 +142,7 @@ also lands in the auto-default set when the object declares no driver materializes a column for it, so a `$contains` predicate against one has nothing to scan (the SQL driver would emit a `WHERE` over a column that does not exist). CEL also only reads this record's own fields (`record.`), so a -formula cannot fetch the related title in the first place. Since #6674 the +formula cannot fetch the related title in the first place. The mistake is **refused, not silent**: a `formula` entry in any `searchableFields` — the object's own set included — is an `os validate` error (`searchable-field-unsearchable`), and a request naming one is `400 @@ -172,7 +172,7 @@ hint: 'search' scans this object's own columns, so a related record's column cannot be a search target — expand the relation and search the related object, or copy the value onto a stored text field here. Clients echo this declaration verbatim as the '$searchFields' override, so a stale entry becomes a 400 -INVALID_FIELD on list search (#4254), not just a quietly narrowed one. +INVALID_FIELD on list search, not just a quietly narrowed one. ``` A request carrying the dotted path is `400 INVALID_FIELD`: @@ -281,7 +281,7 @@ export const Invoice = ObjectSchema.create({ - **`readonly: true` governs the end-user surface, not trusted system writers.** A non-system write (REST/UI, and any `runAs:'user'` flow — the default) has the field **silently stripped** from an UPDATE payload; the write reports - success but the value never lands (#2948). System-context writes — + success but the value never lands. System-context writes — `runAs:'system'` flows, system hooks, seeds, imports, migrations — are exempt and DO write it. So the pattern "users can't edit this, but automation maintains it" is expressed by declaring the field `readonly` **and** running @@ -379,13 +379,13 @@ export default ObjectSchema.create({ The metadata→DB sync is **additive-only**: new tables/columns are created on boot, but existing columns are **never** altered or dropped. A non-additive change to an object that already has data silently diverges from the physical -schema, and the **database column wins at write time** (#2186): +schema, and the **database column wins at write time**: | Change | Existing DB on restart | |--------|------------------------| | add object / field / index | ✅ applied automatically (additive) | | `required: true → false` (relax `NOT NULL`) | dev auto-heals (`autoMigrate:'safe'`); otherwise `os migrate apply` | -| `unique` re-scoped global → per-tenant (#3696) | dev auto-heals; otherwise `os migrate apply` (`replace_unique_index`) | +| `unique` re-scoped global → per-tenant | dev auto-heals; otherwise `os migrate apply` (`replace_unique_index`) | | type / length change, drop field, rename | `os migrate apply` (`--allow-destructive` for drops / tightenings) | | declared index removed, or its columns changed | `os migrate apply` (`--allow-destructive` when it drops, or rebuilds as `UNIQUE`) | @@ -445,7 +445,7 @@ See [rules/relationships.md](./rules/relationships.md) for detailed examples. > **array of ids** on the record — reference elements positionally > (`{record.tags.0}` in flow values). It is NOT a junction table. Reach for a > **junction object** (two lookups) only when the relationship itself carries -> attributes (role, added_at, …). (#1872) +> attributes (role, added_at, …). ### Validation Patterns @@ -453,7 +453,7 @@ See [rules/relationships.md](./rules/relationships.md) for detailed examples. > On **insert**, an optional field omitted from the payload reads as `null` in a > validation predicate — so `record.due_date == null` matches an omitted field the -> same as an explicit `null` (#1871). (On update, the prior record supplies it.) +> same as an explicit `null`. (On update, the prior record supplies it.) The **complete** set of validation types (`ValidationRuleSchema` discriminators): - `script` — Formula expression (inverted logic) @@ -463,7 +463,7 @@ The **complete** set of validation types (`ValidationRuleSchema` discriminators) - `json_schema` — Validate a JSON field against a JSON Schema - `conditional` — Apply a nested rule only `when` a predicate holds -> **There is NO `unique` validation type** (removed from the spec in #1475). +> **There is NO `unique` validation type** (removed from the spec). > Enforce uniqueness — including composite — with a **unique index**, and state > its scope (ADR-0120): > `indexes: [{ fields: ['department', 'email'], unique: 'organization' }]`. @@ -484,7 +484,7 @@ indexes: [ ] ``` -> **`type` and `partial` were retired at protocol 17** (#5248, #4943): no driver +> **`type` and `partial` were retired at protocol 17**: no driver > ever read either, so an authored `type` chose no access method and an authored > `partial` produced a full index with the predicate discarded. Both are now a > `tsc` error and a parse error; `os migrate meta --from 16` strips them. Access @@ -541,7 +541,7 @@ Mirror these CRM-style patterns when designing enterprise metadata objects: | Capability gating | `src/objects/*.object.ts` | Use `enable` flags (`trackHistory`, `apiMethods`, `files`, `feeds`, `activities`) per object | | Index + validation pairing | `src/objects/*.object.ts` | Keep `indexes[]` aligned to common filters and enforce invariants with `validations[]` | | Relationship constraints | `src/objects/*.object.ts` | Use `lookup` + `lookupFilters` (`[{ field, operator, value }]`) for constrained child selection | -| Lifecycle automation | `src/objects/*.hook.ts` | Use a lifecycle **hook** (authored with `defineHook()`, registered via `defineStack({ hooks })` or the `*.hook.ts` convention scan) or a top-level `record_change` flow for field updates triggered by record changes. There is **no** object-level `workflows[]` field — authoring one is a build error (#1535). | +| Lifecycle automation | `src/objects/*.hook.ts` | Use a lifecycle **hook** (authored with `defineHook()`, registered via `defineStack({ hooks })` or the `*.hook.ts` convention scan) or a top-level `record_change` flow for field updates triggered by record changes. There is **no** object-level `workflows[]` field — authoring one is a build error. | | State transitions | `src/objects/*.object.ts` | Prefer explicit `state_machine` validation rules (one per state field) — there is **no** separate `stateMachines` map | For metadata authoring, keep expressions in CEL (`P\`...\``, `F\`...\``, @@ -720,7 +720,7 @@ A legacy SQL-style `=` / `IN (...)` predicate still compiles via a **deprecated* | Placeholder | Resolves to | |:--|:--| | `current_user.id` | the caller's user id (ownership) | -| `current_user.email` | the caller's email (ADR-0056 #2054) | +| `current_user.email` | the caller's email (ADR-0056) | | `current_user.organization_id` | the caller's tenant | | `current_user.org_user_ids` | ids of users in the same org (for `IN`) | | `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) | @@ -789,7 +789,7 @@ tenancy: { ``` - The former `shared` / `isolated` / `hybrid` mode key (`tenancy.strategy`) was - **retired** (#2763) — an unknown `tenancy` key is now a loud parse error with + **retired** — an unknown `tenancy` key is now a loud parse error with upgrade guidance, never silently stripped. - **Database-per-tenant isolation is not object metadata** — it is an environment/deployment choice (each environment carries its own database URL). @@ -974,7 +974,7 @@ export const SetupApp = defineApp({ | per-field `trackHistory` | Render a field's value changes as human-readable activity-timeline entries (pair with `enable.trackHistory`, ADR-0052 §5b) | > The former `softDelete` / `versioning` object keys were **removed** from the -> spec (#2377, ADR-0049 enforce-or-remove) — authoring them is now a build +> spec (ADR-0049 enforce-or-remove) — authoring them is now a build > error with upgrade guidance. `partitioning` / `cdc` were never schema keys, > and the `encryptionConfig` / `maskingRule` field keys were pruned (see > [Sensitive fields](#sensitive-fields--secret-type--requiredpermissions)). @@ -1064,7 +1064,7 @@ its UUID). The seed runner resolves at load time. Order seeds so parents appear before children in the exported array: > If a lookup value matches no natural key, the loader now falls back to -> resolving it as the target's `id` (#1814) — so a reference to a real existing +> resolving it as the target's `id` — so a reference to a real existing > record by internal id resolves instead of dangling to null. Natural keys > remain the portable default; rely on the id fallback only for records you > didn't seed (e.g. a system user). @@ -1193,7 +1193,7 @@ os validate # Zod schema + CEL predicates (record. existence) + bindi It catches what otherwise fails **silently at runtime**: a bare field ref in a `requiredWhen` / `readonlyWhen` / `visibleWhen`, a validation rule, a formula, or a row-level-security/sharing predicate (`done` instead of `record.done`) that -evaluates to `null` and never fires (#2183/#2185). `os lint` is a *separate* +evaluates to `null` and never fires. `os lint` is a *separate* pass that additionally checks the data model against the conventions in this skill (relationships, master-detail, roll-ups) — run it too, but it does **not** replace `os validate`. (Reminder: two consecutive `os build` runs with no source diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index b914c0ed9c..e3ecc47293 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -60,11 +60,11 @@ ObjectStack provides **8 lifecycle events** organized by operation type: > covers every read shape — there is no `beforeFindOne`/`afterFindOne`. Likewise the > write events fire on bulk `multi:true` operations, so there is no `*Many` event. A > bulk write hands hooks **no** row-scoping predicate: it lives on the engine-internal -> `OperationContext.ast` (#2982), so the RLS / sharing filters composed onto it bind +> `OperationContext.ast`, so the RLS / sharing filters composed onto it bind > the driver call itself, where no handler can widen them — scope a batch through > `options.where` at the caller. The `after*` events instead dispatch **once per > matched row**, each on a single-record-shaped context whose `input.id` names that -> row (#5038). And there is no `beforeCount`/`beforeAggregate`: read authorization and +> row. And there is no `beforeCount`/`beforeAggregate`: read authorization and > row filtering belong to **RLS / permission rules**, and field masking to > **field-level metadata** — declarative mechanisms that apply everywhere, rather than > a hook every author must remember to re-attach. @@ -88,7 +88,7 @@ preferred over a bare `: Hook` literal (the same rule as `defineDatasource`): it validates when the module is imported, so constraint-level mistakes a bare annotation can't catch — a non-`snake_case` `name`, a misspelled key routed through a spread — fail while you author instead of at deploy, and the export -carries defaults already materialized (#4269). +carries defaults already materialized. ```typescript import { P } from '@objectstack/spec'; @@ -236,11 +236,11 @@ condition: P`record.status in ['pending', 'in_review']` condition: P`record.type == 'enterprise' && record.region == 'APAC' && record.is_active == true` // A TRANSITION — fires only on the update that completes the task, -// not on later updates of an already-done record (#4784) +// not on later updates of an already-done record condition: P`previous.done != true && record.done == true` ``` -**`record` here is the RECORD, not this write's payload (#4770).** The condition +**`record` here is the RECORD, not this write's payload.** The condition is evaluated against the stored row overlaid with the fields this write carries, made total over the object's **declared** fields (`null` when a declared field is in neither). So: @@ -251,7 +251,7 @@ in neither). So: Gotcha 1.) - `record` describes the record's **state**, not the diff. `record.done == true` fires on every update of an already-done record, not only on the update that - set it. **For the transition, compare against `previous`** (#4784): + set it. **For the transition, compare against `previous`**: `previous.done != true && record.done == true`. `previous` is the stored pre-write row, made total over the same declared fields, and it is the same binding a validation predicate reads. @@ -264,8 +264,8 @@ in neither). So: (`record` is that bare payload there too, so a *declared* field this write does not set is unevaluable as well.) Reading `previous` on that dispatch is rejected **by name**, and the rejection points you at the after-type event. -- **`after*` hooks fire PER ROW, so a bulk write needs no special condition - (#5038).** A predicate (`multi: true`) update/delete dispatches its `after*` +- **`after*` hooks fire PER ROW, so a bulk write needs no special + condition.** A predicate (`multi: true`) update/delete dispatches its `after*` hooks **once per matched row**, each on a single-record-shaped context — `previous` is that row's pre-image, `record` is that row's real state (not the bare payload), and `input.id` names the row. A transition condition therefore @@ -278,7 +278,7 @@ in neither). So: `null > null`. `has()` answers "is this key declared at all", which is a question about your spelling, not about your data. -⚠️ **An unevaluable condition ABORTS the operation (#4775).** A typo'd key +⚠️ **An unevaluable condition ABORTS the operation.** A typo'd key (`record.stauts`), a `previous` reference on an insert, or a comparison CEL has no overload for does **not** degrade to "the hook did not fire" — it **fails the write**. Until protocol 17 the gate emitted a `logger.warn` and returned `false`, @@ -365,7 +365,7 @@ The sandbox is handed a **JSON snapshot** of these (built by | `ctx.previous` | object \| `undefined` | Pre-write record on update/delete. **`undefined` on insert** → use `!ctx.previous` to detect *create*. | | `ctx.result` | object \| `undefined` | `after*` only. ⚠️ **partial** on afterUpdate — see gotcha 1. | | `ctx.user` | object \| `undefined` | `{ id, name, email, organizationId }`. `undefined` for system / unauthenticated writes. | -| `ctx.session` | object \| `undefined` | `{ userId, organizationId, isSystem, … }`. **No role list** — `session.roles` was retired in 17.0.0 (#5050): it was declared but never produced, so every read was `undefined`. | +| `ctx.session` | object \| `undefined` | `{ userId, organizationId, isSystem, … }`. **No role list** — `session.roles` was retired in 17.0.0: it was declared but never produced, so every read was `undefined`. | | `ctx.event` | string | e.g. `'afterUpdate'` — dispatch on it when one hook subscribes to several events. | | `ctx.object` | string | The target object name. | | `ctx.api` | object | Cross-object CRUD. Gated by `api.read` / `api.write` — see below. | @@ -415,7 +415,7 @@ await ctx.api.object('task').find({ where: { $and: [{ done: false }, { owner: ui absent or empty predicate does not come back as `null` — it comes back as the object's **first row**: a real, plausible-looking record with nothing to do with what you asked for, which your `if (!row)` cannot catch. So `findOne()`, -`findOne({})` and `findOne({ where: {} })` **throw** (#4419). Be specific in one +`findOne({})` and `findOne({ where: {} })` **throw**. Be specific in one of three ways: ```js @@ -482,7 +482,7 @@ const full = await ctx.api.object('candidate').findOne({ where: { id: ctx.result // full.position_id is present even though this PATCH only set `stage`. ``` -(A declarative `condition` does **not** hit this wall — since #4770 it is +(A declarative `condition` does **not** hit this wall — it is evaluated against the stored record overlaid with the payload, so `record.position_id` is readable there even when the PATCH never wrote it. Guard optional values with `record.x != null`, not with `has(record.x)`.) @@ -588,9 +588,9 @@ interface HookContext { userId?: string; organizationId?: string; // Active org — the single blessed name. Matches the // `organization_id` column + `current_user.organizationId` (RLS). - // The former `tenantId` alias was removed in #3290. + // The former `tenantId` alias was removed in v16. // There is no `roles` here: `session.roles` was declared but - // never produced, and was retired in 17.0.0 (#5050). Privilege + // never produced, and was retired in 17.0.0. Privilege // is judged by the security service (permissions / positions / // posture), never by a role-name string in a hook. accessToken?: string; @@ -625,7 +625,7 @@ predicates, and in seed rows. Read it as **`organizationId`**: const org = ctx.user?.organizationId ?? ctx.session?.organizationId; ``` -> The former `ctx.session.tenantId` alias was removed in v16 (#3290) — read the +> The former `ctx.session.tenantId` alias was removed in v16 — read the > org under `organizationId`. (The generic driver-layer `execCtx.tenantId` / > `DriverOptions.tenantId` isolation knob is a separate axis and is unaffected.) @@ -1020,7 +1020,7 @@ const maskSensitiveData = defineHook({ // // ⚠️ Do NOT gate this on a role name. `ctx.session` carries no role list: // `session.roles` was declared for years, never produced by any engine - // path, and retired in 17.0.0 (#5050) — `ctx.session?.roles?.includes(…)` + // path, and retired in 17.0.0 — `ctx.session?.roles?.includes(…)` // was always `undefined`, so a mask written that way looked role-aware and // was not. A per-role exemption belongs in field-level permissions (the // callout above), which the read path applies for you. diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index f7257cddab..e15796771e 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -76,7 +76,7 @@ options: [ > **`multiple: true` lookup ≠ junction object.** A multi-value lookup is stored > and read as an **array of ids** on the record — it is NOT a junction table. > Reach for a **junction object** (two lookups) only when the relationship -> itself carries attributes (position, added_at, …). (#1872) +> itself carries attributes (position, added_at, …). ## Media @@ -264,7 +264,7 @@ grouped number (never a hardcoded `$`). The same chain backs analytics measures reference: 'account', required: true, // Structured, picker-honoured filter — the former string[] `referenceFilters` - // was removed (#2377, ADR-0049): it filtered nothing. + // was removed (ADR-0049): it filtered nothing. lookupFilters: [ { field: 'status', operator: 'eq', value: 'active' }, ], diff --git a/skills/objectstack-data/rules/hooks.md b/skills/objectstack-data/rules/hooks.md index 7269a0dc64..59e58ffcc3 100644 --- a/skills/objectstack-data/rules/hooks.md +++ b/skills/objectstack-data/rules/hooks.md @@ -47,8 +47,7 @@ Prefer `defineHook()` over a bare `: Hook` literal (the same rule as `defineDatasource`): it validates when the module is imported, so constraint-level mistakes a bare annotation can't catch — a non-`snake_case` `name`, a misspelled key routed through a spread — fail while you author -instead of at deploy, and the export carries defaults already materialized -(#4269). +instead of at deploy, and the export carries defaults already materialized. ### Logic: `body` (preferred) or `handler` (deprecated) @@ -91,7 +90,7 @@ Sandbox essentials (full contract in - **`capabilities`** (declare what the body uses, else it throws) — the five legal tokens: `api.read`, `api.write`, `api.transaction`, `crypto.uuid`, `log`. There is **no hashing capability**: `crypto.hash` was removed in spec 17 - (#4391) because the sandbox never implemented it. + because the sandbox never implemented it. - Cross-object writes obey the **target's** sharing model — a `public_read` target rejects the write with `FORBIDDEN`, and **admin is not exempt**. - No `console` (use `ctx.log`), no `fetch` (use Connectors), no `import` / @@ -114,11 +113,11 @@ Sandbox essentials (full contract in > `findOne` too (the event attaches to record materialization, not the method), and > the write events fire on bulk `multi:true` operations as well. A bulk write hands > hooks **no** row-scoping predicate: it lives on the engine-internal -> `OperationContext.ast` (#2982), so the RLS / sharing filters composed onto it bind +> `OperationContext.ast`, so the RLS / sharing filters composed onto it bind > the driver call itself, where no handler can widen them — scope a batch through > `options.where` at the caller. The `after*` events instead dispatch **once per > matched row**, each on a single-record-shaped context whose `input.id` names that -> row (#5038). There is no `beforeFindOne`, `beforeCount`, `beforeAggregate`, or +> row. There is no `beforeFindOne`, `beforeCount`, `beforeAggregate`, or > `*Many` event. > > **Don't reach for a hook when a declarative mechanism already fits:** diff --git a/skills/objectstack-data/rules/indexing.md b/skills/objectstack-data/rules/indexing.md index 2937d8afee..8116cd36da 100644 --- a/skills/objectstack-data/rules/indexing.md +++ b/skills/objectstack-data/rules/indexing.md @@ -24,7 +24,7 @@ and it is the whole surface *because* it is all the driver materializes: | `unique` | optional | Uniqueness **and its scope** — see ADR-0120 section below | | `name` | optional | Custom index name; auto-generated when omitted | -> **Retired at protocol 17 (#5248, #4943): `type` and `partial`.** Both were +> **Retired at protocol 17: `type` and `partial`.** Both were > authorable and neither was ever read by any driver — an authored `type` > selected no access method, and an authored `partial` produced a **full** > index with the predicate silently discarded. Writing either is now a `tsc` @@ -144,8 +144,8 @@ indexes: [ ```typescript indexes: [ { fields: ['status'], type: 'btree', unique: false }, // ❌ `type` retired; `unique: false` redundant - { fields: ['description'], type: 'fulltext' }, // ❌ `type` retired (#5248) - { fields: ['created_at'], partial: "status = 'active'" }, // ❌ `partial` retired (#5248) + { fields: ['description'], type: 'fulltext' }, // ❌ `type` retired + { fields: ['created_at'], partial: "status = 'active'" }, // ❌ `partial` retired ] ``` @@ -238,7 +238,7 @@ Place most **selective** (unique) fields first, then range/sort fields last. Both are real database capabilities. Neither is part of the **declaration** surface, and the keys that used to pretend otherwise (`type`, `partial`) were -retired at protocol 17 (#5248, #4943) precisely because nothing consumed them. +retired at protocol 17 precisely because nothing consumed them. **Access method (`btree` / `hash` / `gin` / `gist` / `fulltext`).** The driver and dialect decide. Postgres defaults to B-tree, which is the right choice for diff --git a/skills/objectstack-data/rules/validation.md b/skills/objectstack-data/rules/validation.md index 21bbd61e38..83fbccb4cc 100644 --- a/skills/objectstack-data/rules/validation.md +++ b/skills/objectstack-data/rules/validation.md @@ -17,7 +17,7 @@ The **complete** set of `type` discriminators accepted by `ValidationRuleSchema` There is no other type. In particular: -- **No `unique` type** (removed from the spec in #1475) — enforce uniqueness +- **No `unique` type** (removed from the spec) — enforce uniqueness with a **unique index** ([see below](#uniqueness--use-unique-indexes)). - **No `async` / `custom` type** — external checks and arbitrary validation code belong in a `beforeInsert` / `beforeUpdate` **lifecycle hook** @@ -69,7 +69,7 @@ condition: P`record.type == 'enterprise' && isBlank(record.account_manager)` > On **insert**, an optional field omitted from the payload reads as `null` > in the predicate — `record.due_date == null` matches an omitted field the -> same as an explicit `null` (#1871). Use `isBlank(v)` to catch `null` and +> same as an explicit `null`. Use `isBlank(v)` to catch `null` and > empty strings together. ## Uniqueness — Use Unique Indexes diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index c13a5092fa..c84d9ed747 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -30,7 +30,7 @@ formula / condition / predicate / dynamic-seed metadata. > The previous custom Salesforce-flavor engine was **deleted** in M9.5. > > **Predicates / formulas are bare CEL — never wrap field references in `{…}` -> braces.** The #1 authoring mistake (root cause of #1491) is a condition like +> braces.** The #1 authoring mistake is a condition like > `{record.rating} >= 4`: in CEL, `{…}` is a **map literal**, so it is a parse > error. Write bare CEL: `record.rating >= 4`. Braces are *only* for `{{ … }}` > text templates (see Template surfaces). @@ -78,7 +78,7 @@ type Expression = { | `cron` | built-in validator | Recurring schedules | `` cron`...` `` | `` cron`0 6 * * MON` `` | | `template` | built-in interpolator | `{{path}}` text interpolation (notif/prompt/title) | `` tmpl`...` `` | `` tmpl`Hello {{record.first_name}}` ``| -There is **no `js` dialect** — it was retired (#3278). Procedural JavaScript is +There is **no `js` dialect** — it was retired. Procedural JavaScript is the L2 `ScriptBody { language: 'js' }` authoring surface (hook bodies, mapping transforms — see objectstack-data), not an expression dialect. @@ -121,7 +121,7 @@ ADR-0068 — spec field docs write predicates like `current_user.positions`. `isBlank()` or compare to `null` explicitly. Every predicate reads a record that is **total over the object's declared -fields** (#4649), so `has(record.)` is uniformly `true` and +fields**, so `has(record.)` is uniformly `true` and tells you nothing at all. The idiom that reads like a guard is not one: ```text @@ -133,7 +133,7 @@ has(record.start_date) && has(record.end_date) && record.end_date < record.start record.start_date != null && record.end_date != null && record.end_date < record.start_date ``` -**This is a publish-time rejection, not advice (#4763).** `os build` / +**This is a publish-time rejection, not advice.** `os build` / `os validate` / `os lint` and the runtime publish gate reject any validation-rule or hook predicate that applies an ordering (`< <= > >=`) or arithmetic (`+ - * / %`) operator to a **declared nullable** field — no `required: true`, @@ -211,7 +211,7 @@ If you need a helper that doesn't exist, prefer adding it to the stdlib > **Only the functions above are callable.** An UNKNOWN function — `PRIOR()`, a > legacy `ISBLANK()`, a typo'd `isBlnk()` — **fails `objectstack build`** with a -> "no matching overload" type error (#1877), rather than silently no-op'ing the +> "no matching overload" type error, rather than silently no-op'ing the > predicate at run time. Use `previous.x` (not `PRIOR()`), `isBlank()` (not `ISBLANK()`). --- @@ -296,11 +296,11 @@ P`previous.status != 'escalated' && record.status == 'escalated'` ISCHANGED-style logic does not exist as a function; use explicit `previous` comparison. -`record` is the record's **state**, not this write's diff (#4770): stored row ⊕ +`record` is the record's **state**, not this write's diff: stored row ⊕ payload, so `record.status == 'escalated'` is true on *every* update of an already-escalated record. Comparing against `previous` is the only way to say "just became". Hook `condition`s and validation predicates bind the same two -roots (#4784) — one scope, one meaning, whichever surface reads it. +roots — one scope, one meaning, whichever surface reads it. **Where `previous` is bound, and where it is not:** @@ -308,11 +308,11 @@ roots (#4784) — one scope, one meaning, whichever surface reads it. |:---|:---| | Update hook `condition` (single-record write), validation rule on update | the stored pre-write row | | Insert events (`beforeInsert` / `afterInsert`), validation rule on insert | **unbound** — there is no prior state | -| **`after*` hook `condition` / record-change flow trigger on a predicate (`multi: true`) write** | **that row's pre-write row** — a bulk write fires after-hooks once PER MATCHED ROW (#5038) | -| Validation rule on a predicate bulk update | that row's pre-write row — per row since #3106 | +| **`after*` hook `condition` / record-change flow trigger on a predicate (`multi: true`) write** | **that row's pre-write row** — a bulk write fires after-hooks once PER MATCHED ROW | +| Validation rule on a predicate bulk update | that row's pre-write row — per row | | `before*` hook `condition` on a predicate (`multi: true`) write | **unbound** — a `before*` hook fires ONCE for the whole batch (it may still rewrite the shared payload), so there is no single prior record. `record` is the bare payload here too, so a *declared* field this write does not set is unevaluable as well | -⚠️ **An unevaluable condition ABORTS the operation (#4775).** Referencing +⚠️ **An unevaluable condition ABORTS the operation.** Referencing `previous` where it is unbound — like a typo'd key (`record.stauts`), a retired field, or a comparison CEL has no overload for — does **not** degrade to "the hook did not fire": it **fails the write**, with an error naming the hook and @@ -331,7 +331,7 @@ So write insert-event conditions over `record` alone — that mistake used to co you a hook that quietly never ran, and now costs you every write the hook is attached to. -**A transition condition needs no special handling for bulk writes (#5038).** +**A transition condition needs no special handling for bulk writes.** Write it once, on an `after*` event, and it means the same thing whether the write carries an id or a predicate: @@ -400,9 +400,9 @@ When migrating Salesforce-flavor metadata, apply these rules in order: > ⚠️ `OLD.x` and `ISCHANGED(x)` both land on `previous.x`, which exists only > where `previous` is **bound** — see §5. On an insert event, or in a `before*` -> hook condition on a `multi: true` predicate write, it is not; since #4775 that +> hook condition on a `multi: true` predicate write, it is not; that > does not quietly skip the hook, it **fails the write**. On `after*` events it -> IS bound, per matched row, on bulk and single-record writes alike (#5038). +> IS bound, per matched row, on bulk and single-record writes alike. --- @@ -478,7 +478,7 @@ tmpl`Deal {{ record.name }} — {{ record.amount | currency }} closes {{ record. There is no JS expression surface: procedural JS is the L2 `ScriptBody { language: 'js' }` surface (hook bodies), not an expression -dialect (#3278). +dialect. --- diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index 98232925f6..760267a080 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -267,7 +267,7 @@ Top-level groups alongside `objects`: `apps` (label, description, navigation), `metadataForms`, `settingsCommon`. > **Validation messages are not a translation group.** `validationMessages` was -> removed in spec 17.0.0 (#4667) — nothing ever read it, so a translated rule +> removed in spec 17.0.0 — nothing ever read it, so a translated rule > message was stored and never shown. Author the message on the rule itself > (`object.validations[].message`), which the engine returns on every rejected > write. @@ -359,7 +359,7 @@ A second object-first shape keyed on `o.{object_name}` (with `app`, `nav`, `dashboard`, `reports`, `notifications`, `errors`, `_globalOptions`, `_meta`, `namespace`, and `_actions.confirmMessage`) was once documented for Studio-authored translations. **No resolver ever read it**, so items authored -that way saved successfully and rendered nothing. It was removed in #3778 — +that way saved successfully and rendered nothing. It was removed — those keys are now rejected at save time with a message naming the group to use instead. Never author them, in files or at runtime. @@ -393,7 +393,7 @@ i18n.t('messages.welcome', 'en', { userName: 'Alice' }); There is no ICU MessageFormat engine — interpolation is always simple `{{variable}}` substitution (the aspirational `messageFormat` config knob was -removed in #3494). Author messages for simple substitution; ICU plural/select +removed). Author messages for simple substitution; ICU plural/select strings like `{count, plural, one {1 message} other {# messages}}` will not be evaluated. To pluralize, select the form in application code before calling `t()`. @@ -506,7 +506,7 @@ The in-memory fallback additionally resolves locale codes The contract also declares optional methods — `getCoverage`, `suggestTranslations` — that **no shipped implementation provides**. Treat them as extension points for a custom workbench or TMS adapter. (`getAppBundle` / -`loadAppBundle` were removed in #3778 along with the `o.*` shape they returned.) +`loadAppBundle` were removed along with the `o.*` shape they returned.) ### Plugin Setup @@ -595,7 +595,7 @@ before release. ### ❌ The Retired `o.*` Shape -Everything reads `objects.*`. The `o.*` dialect was removed in #3778 — it is +Everything reads `objects.*`. The `o.*` dialect was removed — it is not a "Studio format", not a secondary format, just gone. Files registered in that shape resolve to nothing; runtime items in that shape are rejected at save time. diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 11c292c621..1f4ad98d67 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -1008,7 +1008,7 @@ metadata is read-only and artifact/file backed: - Do **not** register `sys_metadata` or `sys_metadata_history` from an ObjectStack runtime plugin. Those persistence tables belong to the control plane. - (Exception, #1826: an *isolated project kernel* may opt into `sys_metadata` + (Exception: an *isolated project kernel* may opt into `sys_metadata` hydration from its own DB — the general boundary otherwise stands.) - Do **not** call `MetadataManager.setDataEngine()` automatically from `MetadataPlugin.start()`. Project databases must contain business rows only. @@ -1113,7 +1113,7 @@ be re-run when commands are added. ObjectStack metadata mistakes fail **silently at runtime**, not at edit time: a bare field ref in a predicate (`done` instead of `record.done`) evaluates to -`null` and silently hides an action/validation on every record (#2183/#2185); a +`null` and silently hides an action/validation on every record; a dangling dashboard widget binding renders an empty chart (ADR-0021). Both are caught at author time by one command: diff --git a/skills/objectstack-pm-dispatch/SKILL.md b/skills/objectstack-pm-dispatch/SKILL.md index f56e5c19c0..42cd097afe 100644 --- a/skills/objectstack-pm-dispatch/SKILL.md +++ b/skills/objectstack-pm-dispatch/SKILL.md @@ -885,7 +885,7 @@ body back to verify when a snippet is load-bearing. "open_questions": [ { "question": "…", "options": ["A …", "B …"], "recommendation": "A, because …" } ], - "out_of_scope_findings": ["filed as #457: …"] + "out_of_scope_findings": ["filed as #: …"] } ``` diff --git a/skills/objectstack-query/SKILL.md b/skills/objectstack-query/SKILL.md index 68e9347e5d..f68b4f46b1 100644 --- a/skills/objectstack-query/SKILL.md +++ b/skills/objectstack-query/SKILL.md @@ -278,7 +278,7 @@ Sort with `orderBy` — an array of sort nodes: ### Keyset Pagination (Performant) -> ⛔ **`query.cursor` was REMOVED in `@objectstack/spec` 17 (#4286).** No +> ⛔ **`query.cursor` was REMOVED in `@objectstack/spec` 17.** No > engine or driver ever read it — a query carrying `cursor` silently returned > **page 1 forever**. The key is tombstoned (a query carrying it fails to > parse with the prescription) and `QueryBuilder.cursor()` is gone. Do keyset @@ -338,7 +338,7 @@ unique or near-unique column such as `created_at` or `id`) so > fallbacks) supports all six functions plus `distinct`. For portable queries, > stick to the first five. -> **Removed in 17 (#6188).** `array_agg` and `string_agg` left this vocabulary: +> **Removed in 17.** `array_agg` and `string_agg` left this vocabulary: > declared but lowered by no SQL backend, so whether they worked depended on > which driver sat behind the object. Either one is now refused at parse. There > is no replacement — read the rows with an ordinary `fields` query and shape @@ -368,7 +368,7 @@ unique or near-unique column such as `created_at` or `id`) so ### HAVING Clause -> ✅ **Enforced since #4286.** The engine applies `having` AFTER aggregation, +> ✅ **Enforced.** The engine applies `having` AFTER aggregation, > on both the native-driver path and the in-memory fallback. It references > the **aggregated row's columns** — aggregation aliases and groupBy > projections — with the ordinary FilterCondition operators plus @@ -447,7 +447,7 @@ Load related records through lookup/master_detail fields: ## Joins -> ⛔ **REMOVED in `@objectstack/spec` 17 (#4286, ADR-0049).** `query.joins` +> ⛔ **REMOVED in `@objectstack/spec` 17 (ADR-0049).** `query.joins` > (and the `JoinNode`/`JoinType`/`JoinStrategy` vocabulary) is gone from the > `QueryAST` schema — no engine or driver ever consumed it, so it only ever > declared a capability that did not run. The key is tombstoned: authoring it @@ -500,7 +500,7 @@ Omit `fields` to search the object's declared `searchableFields` (or an auto-default of name/title + short-text fields), resolved server-side. `fields` can only **narrow** that set, never widen it: over the REST/protocol -ingress a name outside it is `400 INVALID_FIELD` (#4254), not a silent +ingress a name outside it is `400 INVALID_FIELD`, not a silent fall-back to the full scan. ### ⛔ Searching by a related record's title — mirror the value, always @@ -549,7 +549,7 @@ this (the field, the hooks, the lint wording): **objectstack-data → Search Fie axis — use a [nested relation filter](#nested-relation-filters); to *display* it, use [`expand`](#expand-related-records). -> ⚠️ **`[EXPERIMENTAL — not enforced]` (#4286):** `fuzzy`, `boost`, +> ⚠️ **`[EXPERIMENTAL — not enforced]`:** `fuzzy`, `boost`, > `operator`, `minScore`, `language`, and `highlight` validate against the > schema but are never read — their `.describe()` markers now say so. Terms > are always AND-ed; there is no relevance scoring or highlighting. @@ -558,7 +558,7 @@ use [`expand`](#expand-related-records). ## Window Functions (Analytics) -> ⛔ **REMOVED from the request surface in `@objectstack/spec` 17 (#4286).** +> ⛔ **REMOVED from the request surface in `@objectstack/spec` 17.** > `query.windowFunctions` is gone from the `QueryAST` schema — the engine > never routed it to any driver, so every OVER clause it declared was > silently dropped. The key is tombstoned (a query carrying it fails to @@ -588,7 +588,7 @@ use [`expand`](#expand-related-records). | **Keyword-search by a related record's title** | **Mirror the title into a stored field on this object and search that** — `search` never traverses (see **Full-Text Search** above) | | Simple parent→child navigation | `expand` | | Paginate/sort a parent's related records | Query the related object directly | -| Analytical queries across objects | Report/dashboard metadata, or separate queries combined in app code (`joins` was removed in #4286 — see above) | +| Analytical queries across objects | Report/dashboard metadata, or separate queries combined in app code (`joins` was removed — see above) | ### Pagination Pattern for APIs diff --git a/skills/objectstack-query/evals/README.md b/skills/objectstack-query/evals/README.md index b92e167e13..70966a0a21 100644 --- a/skills/objectstack-query/evals/README.md +++ b/skills/objectstack-query/evals/README.md @@ -12,10 +12,10 @@ subset the engine actually executes. instead of `$null`. 2. **Nested relation filter** — "Find orders where the customer's country is US." Expect a nested relation filter (`customer: { country: 'US' }`), - not a `joins` array (removed in #4286). + not a `joins` array (removed in protocol 17). 3. **Pagination pattern** — "Implement infinite scroll for a feed." Expect manual keyset pagination (`where` on the sort key + `orderBy` + `limit`); - fail if the answer uses the removed `cursor` property (#4286). + fail if the answer uses the removed `cursor` property. 4. **Aggregation correctness** — "Count deals by region and show total revenue." Expect `groupBy` + `count`/`sum` with aliases; on SQL targets the answer must stay within `count`/`sum`/`avg`/`min`/`max`. diff --git a/skills/objectstack-query/rules/aggregation.md b/skills/objectstack-query/rules/aggregation.md index dc916b229a..d532ca116a 100644 --- a/skills/objectstack-query/rules/aggregation.md +++ b/skills/objectstack-query/rules/aggregation.md @@ -20,7 +20,7 @@ Guide for building ObjectStack aggregation queries. > (driver-rest, driver-memory, timezone/date-bucket fallbacks) supports all six > functions plus `distinct`. For portable queries, stick to the first five. -> **Removed in 17 (#6188).** `array_agg` and `string_agg` are no longer part of +> **Removed in 17.** `array_agg` and `string_agg` are no longer part of > the vocabulary — they were declared and lowered by no SQL backend, so a query > using them succeeded or failed depending on which driver happened to be > behind the object. A query carrying either is refused at parse. There is no @@ -91,14 +91,14 @@ aggregations (never bucket by hand in app code): - The engine pushes bucketing down to the driver (`DATE_TRUNC` etc.) when the dialect supports that granularity, and transparently falls back to in-memory bucketing otherwise — results are correct either way, **including - the column keys** (#6401: until then the SQL drivers ignored `alias`, so an + the column keys** (earlier SQL drivers ignored `alias`, so an aliased group came back under the field name when the query was pushed down and under the alias when it fell back — decided by a capability bit and the `timezone`, neither of which the caller can see). ## HAVING Clause -> ✅ **Enforced since #4286.** The engine applies `having` AFTER aggregation +> ✅ **Enforced.** The engine applies `having` AFTER aggregation > (both the native-driver path and the in-memory fallback), referencing the > **aggregated row's columns** — aggregation aliases and groupBy projections. > Ordinary FilterCondition operators plus `$and`/`$or`/`$not`; an unknown @@ -169,7 +169,7 @@ const [active] = await engine.aggregate('user', { ## Window Functions -> ⛔ **REMOVED in `@objectstack/spec` 17 (#4286, ADR-0049).** The `QueryAST` +> ⛔ **REMOVED in `@objectstack/spec` 17 (ADR-0049).** The `QueryAST` > schema no longer declares `windowFunctions` — the engine never routed the > property to any driver, so it was silently dropped. The key is tombstoned: > a query carrying it fails to parse with the upgrade prescription. The one @@ -251,7 +251,7 @@ over the two periods and join the buckets in app code. groupBy: ['customer_id'] } -// ✅ Right: `having` references the aggregation ALIAS, after grouping (#4286) +// ✅ Right: `having` references the aggregation ALIAS, after grouping const rows = await engine.aggregate('order', { groupBy: ['customer_id'], aggregations: [{ function: 'count', alias: 'order_count' }], diff --git a/skills/objectstack-query/rules/pagination.md b/skills/objectstack-query/rules/pagination.md index 6ffa5a79e6..ea1f40c4f4 100644 --- a/skills/objectstack-query/rules/pagination.md +++ b/skills/objectstack-query/rules/pagination.md @@ -9,8 +9,8 @@ Guide for implementing pagination in ObjectStack queries. | Offset | UI page navigation, small datasets | Simple, random page access | Slow on large offsets, drift on inserts | | Keyset (manual `where`) | Infinite scroll, real-time feeds | Consistent results, O(1) performance | No random page access | -> ⛔ **The `cursor` query property was REMOVED in `@objectstack/spec` 17 -> (#4286).** No engine or driver ever read it: a query carrying `cursor` +> ⛔ **The `cursor` query property was REMOVED in `@objectstack/spec` +> 17.** No engine or driver ever read it: a query carrying `cursor` > silently returned **page 1 forever**. The key is tombstoned — a query > carrying it fails to parse with the prescription — and > `QueryBuilder.cursor()` is gone. Implement keyset pagination with a @@ -160,7 +160,7 @@ When building paginated REST endpoints: ### ❌ Wrong: Using the removed `cursor` property ```typescript -// ❌ cursor was removed in #4286 — the tombstone rejects this query outright +// ❌ cursor was removed in protocol 17 — the tombstone rejects this query outright { object: 'post', limit: 20, diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index fce396ea9f..264db4f6b7 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -322,7 +322,7 @@ Rules: Want both demos? Put them on different views. - **On an object list view (`*.view.ts` `list` / `listViews`), only `element: 'dropdown'` (value chips) is allowed — `tabs` is page-only** - (ADR-0047 amendment, framework #2679 / objectui #2338). An object view's + (ADR-0047 amendment). An object view's saved-view `ViewTabBar` already owns the tab-bar role, so a `tabs` user-filter would render a second, colliding tab bar. The spec narrows it (`ObjectUserFiltersSchema` — a `tabs` element is untypable at author time @@ -403,7 +403,7 @@ result, no result at all. | a renamed / mistyped column | `searchable-field-unknown` | `400 INVALID_FIELD` | | a dotted path (`account_id.name`) | `searchable-field-unknown` | `400 INVALID_FIELD` | | a real column outside the allowed set | `searchable-field-unsearchable` | `400 INVALID_FIELD` | -| a virtual `formula` column — nothing stored to scan (#6674) | `searchable-field-unsearchable` | `400 INVALID_FIELD` | +| a virtual `formula` column — nothing stored to scan | `searchable-field-unsearchable` | `400 INVALID_FIELD` | Both diagnostics are **errors**, not warnings — `os validate` fails the build. The two you will actually hit, verbatim: @@ -418,7 +418,7 @@ list-view searchableFields entry "status" is outside object "support_case"'s declared searchableFields (subject, case_number, description) — the set 'search' scans. Clients echo this declaration verbatim as the '$searchFields' override, and the runtime refuses an entry outside the allowed set: every toolbar search -on this list returns 400 INVALID_FIELD (#4254). +on this list returns 400 INVALID_FIELD. ``` #### `searchableFields: []` does NOT turn search off @@ -719,7 +719,7 @@ escalate to. Decide on **expressibility**; reuse/governance is Level B. |:--|:--| | one base object + **to-one** joins (`include`, ≤3 hops) | a join that **changes grain** / a **to-many** rollup onto the parent | | 0..N dimensions; date-bucket `day/week/month/quarter/year` | a **computed dimension** / CASE bucket / numeric bin | -| measures `count/sum/avg/min/max/count_distinct` | list aggregation (collect-into-array / concatenate — retired at #6188, no spelling exists) or any custom-SQL metric | +| measures `count/sum/avg/min/max/count_distinct` | list aggregation (collect-into-array / concatenate — retired in protocol 17, no spelling exists) or any custom-SQL metric | | **derived measures** — `ratio/sum/difference/product` of other measures | scalar math on raw fields (`amount*0.8`), aggregate-of-aggregate | | WHERE (`$and/$or/$not` on the base object) + measure-scoped filters | **HAVING** (filtering the aggregate result) | | `compareTo` (previous period/year) + `totals` (matrix subtotals) | **window** (rank, running total, lag/lead, %-of-total); **union**; reshaping params | @@ -748,7 +748,7 @@ Standardized answers to the recurring ambiguous cases: join); a lookup-path *filter* is not a reliable analytics-path construct. - **A dashboard filter driving several charts** (date/region) → **not** a dataset: a dashboard variable + per-chart `filterBindings` broadcast into each chart's - WHERE (#2501). A dataset is implied only when a parameter **reshapes** the query + WHERE. A dataset is implied only when a parameter **reshapes** the query (grain/window/join) — and those are beyond the envelope anyway. **Level B — naming is governance, not expressibility.** An inline dataset draft @@ -1508,7 +1508,7 @@ compareTo: { kind: 'previousPeriod' } // one dated di compareTo: { kind: 'previousYear', dimension: 'close_date' } // several — say which ``` -> **Removed in v17 (#5011):** the bare strings `compareTo: 'previousPeriod'` / +> **Removed in v17:** the bare strings `compareTo: 'previousPeriod'` / > `'previousYear'` and the `{ offset: '7d' | '1M' | '1y' }` arm. The strings and > `{ offset: '1y' }` are rewritten for you by `os migrate meta --from 16`; any > other `offset` duration has no faithful target — state the window on the @@ -1645,7 +1645,7 @@ relative-date placeholders. The canonical contract is published as `node_modules/@objectstack/spec/src/data/date-macros.zod.ts`); two resolvers consume it and must stay in lockstep with it — `resolveDateMacros` in `@object-ui/core` (before the request leaves the browser) and -`resolveFilterTokens` in `@objectstack/core` (framework#3582: the ObjectQL +`resolveFilterTokens` in `@objectstack/core` (the ObjectQL read path and the analytics dataset executor, which is what a dashboard widget's `filter` actually travels through — it never passes a renderer). @@ -1931,7 +1931,7 @@ zero relearning: const org = ctx.user?.organizationId ?? ctx.session?.organizationId; ``` -> The former `ctx.session.tenantId` alias was removed in v16 (#3290); read the +> The former `ctx.session.tenantId` alias was removed in v16; read the > caller's active org under `organizationId`. Action bodies execute **trusted** (the `ctx.engine` / `ctx.api` facade bypasses @@ -1946,7 +1946,7 @@ spelling, the same one the hook `ctx.session`, `ctx.user.positions` and the sharing service use: ```typescript -// ✅ Canonical since #5613 +// ✅ Canonical const positions = ctx.session?.positions ?? []; ``` @@ -1990,7 +1990,7 @@ export const PrintA3Action = defineAction({ #### `opensInNewTab` + `newTabUrl` — async / computed redirect (SSO) For actions whose redirect URL is **computed after a fetch** (SSO and SSO-like -handlers), set `opensInNewTab: true` (#1787). The renderer pre-opens the tab +handlers), set `opensInNewTab: true`. The renderer pre-opens the tab **synchronously** on click so popup blockers don't fire, then navigates it to the handler's returned `redirectUrl`. For external deep-links with no server round-trip, add `newTabUrl` — a direct URL template (supports the `{recordId}` @@ -2075,7 +2075,7 @@ Two UI-specific traps it catches, both **silent at runtime** otherwise: - **Action / field predicate** — a bare field ref in an action `visible` / `disabled` or a field `visibleWhen` (`done` instead of `record.done`) - evaluates to `null` and hides the control on *every* record (the #2183/#2185 + evaluates to `null` and hides the control on *every* record (the "button never shows" trap). - **Dashboard widget binding** — a widget `dataset` / `dimensions` / `values` that doesn't resolve to a declared dataset/field renders an empty chart diff --git a/skills/objectstack-upgrade/SKILL.md b/skills/objectstack-upgrade/SKILL.md index 37062eabb5..499fd7ebb3 100644 --- a/skills/objectstack-upgrade/SKILL.md +++ b/skills/objectstack-upgrade/SKILL.md @@ -531,7 +531,7 @@ you must say which you expect: ``` ✗ connectors.0.fieldMappings.0.transform invalid_type: `FieldMapping.transform` … was removed in @objectstack/spec - 17.0.0 (#5552, ADR-0049) … Delete the key. The transform pipeline that IS + 17.0.0 (ADR-0049) … Delete the key. The transform pipeline that IS enforced is the import mapping's … Run `os migrate meta --from 16` to rewrite it automatically. expected: never From 9c710e47bf3794a47cc483d69154fda79c4a0e9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:35:35 +0000 Subject: [PATCH 2/2] Replace the id rule's per-passage allowlist with a `#` placeholder, and classify the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:ratchet-remedy-authority` was RED on the first cut: the new rule's failure text handed the author a remedy that EXPANDS a registry (an EXAMPLE_ID_ALLOWLIST entry) while neither marking that path ⛔ MAINTAINER-ONLY nor turning it down. Maintainer ruling 2026-08-25 took the stronger route -- option B -- which dissolves the finding at the root rather than marking it: - `skills/objectstack-pm-dispatch/SKILL.md` line 70 now reads `/pm-dispatch # #`, the same placeholder spelling the sibling `filed as #:` site in that file already used. The `#` still teaches the argument grammar; the numbers stop impersonating a citation. Byte-neutral, so the ratchet ceiling is unmoved (14239). - EXAMPLE_ID_ALLOWLIST, its predicate, its call site, its four self-test pins and every mention in the failure text are deleted. The rule now offers no expansion path at all, and says so. - Its two self-test cases are replaced by the pair that matters: the `#` placeholder -- the remedy the failure text prescribes -- must PASS, and the concrete ids it replaced must stay RED. Also records check-doc-authoring.mjs in the control corpus of check-ratchet-remedy-authority.mjs as `excluded` (UNCLASSIFIED, its second finding): it carries no ratchet, and its path-scoped declarations are never offered to the author as something to widen. Side effect worth having: the hand-authored published corpus now contains zero concrete internal ids, with no exemption anywhere. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RMTpSRF5CjMmQBFfPtPCwJ --- scripts/check-doc-authoring.mjs | 103 +++++++++------------ scripts/check-ratchet-remedy-authority.mjs | 4 + skills/objectstack-pm-dispatch/SKILL.md | 2 +- 3 files changed, 50 insertions(+), 59 deletions(-) diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index 7193e72631..f69a938c44 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -275,29 +275,22 @@ const GENERATED_SKILL_ARTIFACTS = [ /\/references\/react-blocks\.md$/, ]; -/** - * An id-shaped token that is FICTIONAL EXAMPLE DATA in a syntax demonstration, - * not a citation of a real internal issue. - * - * Entries are `path:line-substring` and must clear a deliberately narrow bar: - * the number denotes "an issue number you would type here", the passage teaches - * the syntax rather than sourcing a claim, and removing it would damage the - * lesson. `/pm-dispatch #128 #131` is the invocation grammar of the command the - * page documents — `#N` IS the argument — so the ids are instructive AS ids. - * - * ⛔ This is not a place to park a citation you would rather not rewrite. A - * provenance reference ("removed in #4286", "see #3447") is never example data, - * however inconvenient; rewrite it to keep the teaching and drop the id, which - * is what the ruling asks for. The list is pinned in the self-test so it cannot - * grow silently. - */ -const EXAMPLE_ID_ALLOWLIST = [ - { - file: 'skills/objectstack-pm-dispatch/SKILL.md', - contains: '/pm-dispatch #128 #131', - why: 'CLI usage line — the ids are the command\'s own argument syntax, not a citation.', - }, -]; +// There is deliberately NO per-passage allowlist here, and adding one is not a +// remedy this gate offers. +// +// The first cut of this rule carried one, for a single passage: the published PM +// skill's CLI usage line, which read `/pm-dispatch #128 #131` and taught the +// command's argument grammar with two real-looking ids. Maintainer ruling +// 2026-08-25 took the other route — the line now reads `/pm-dispatch # #`, +// the same placeholder spelling the sibling `filed as #:` site in that file +// already used. The `#` still teaches the argument grammar, the numbers stop +// impersonating a citation, and the exemption it needed disappears with it. +// +// That is the general shape, not a one-off: a passage that seems to need an +// example id needs a PLACEHOLDER instead. `#` teaches the same syntax, is +// unmistakable to a customer reading it, and costs no exemption. A growable +// allowlist would have been the one place a genuine citation could come to rest +// — "it's an example" is exactly what the author of the next one would believe. /** * A bare internal issue-id reference: `#` followed by 3–5 digits. @@ -472,18 +465,12 @@ function collectPublishedSkillFiles(root = PUBLISHED_SKILLS_ROOT) { return kept.sort(); } -/** True when `line` is an allowlisted fictional-example passage in `file`. */ -function isAllowlistedExample(file, line) { - return EXAMPLE_ID_ALLOWLIST.some((e) => e.file === file && line.includes(e.contains)); -} - /** Bare internal issue-id references in one published file's source. */ function findIdViolations(source, file) { const out = []; const lines = source.split('\n'); for (let i = 0; i < lines.length; i++) { const ln = lines[i]; - if (isAllowlistedExample(posix(file), ln)) continue; const ids = ln.match(INTERNAL_ID); if (ids) out.push({ file: posix(file), line: i + 1, ids, text: ln.trim() }); } @@ -756,18 +743,21 @@ function selfTest() { expect(`precision — ${label} does not fire`, scan().length, 0); } - // The allowlist fires as an exemption, and ONLY on its own passage. + // The placeholder that replaced the one passage which used to need an + // exemption (maintainer ruling 2026-08-25). It must PASS — otherwise the + // remedy the failure text prescribes is itself a violation. mkdirSync(join(idDir, 'skills', 'objectstack-pm-dispatch'), { recursive: true }); writeFileSync( join(idDir, 'skills', 'objectstack-pm-dispatch', 'SKILL.md'), - ['```', '/pm-dispatch #128 #131 # two named issues, nothing else', '```'].join('\n'), + ['```', '/pm-dispatch # # # two named issues, nothing else', '```'].join('\n'), ); writeFileSync(planted, 'The `cursor` key was removed in protocol 17.'); - expect('the allowlisted fictional-example line is exempt', scan().length, 0); - // ...but the same ids elsewhere are not exempt: the entry is pinned to - // its file AND its passage, so it cannot become a blanket file pass. - writeFileSync(planted, 'Filed as #128 and #131.'); - expect('the same ids outside the allowlisted passage are still RED', scan().length, 1); + expect('the `#` placeholder — the prescribed remedy — passes', scan().length, 0); + // ...and the real ids it replaced would NOT have, which is what makes the + // rewrite load-bearing rather than cosmetic. + writeFileSync(planted, '/pm-dispatch #128 #131 # two named issues'); + expect('the concrete ids it replaced are RED, with no exemption to reach for', + scan().length, 1); writeFileSync(planted, 'The `cursor` key was removed in protocol 17.'); // Empty is a hard error, not a pass (#4932), for this rule too. @@ -820,30 +810,26 @@ function selfTest() { expect('no exemption swallows a declared root whole', [...SKIP_PATHS].some((p) => ROOTS.includes(p)), false); - // ── The example-id allowlist, pinned so it cannot grow in silence ──────── - // Enforcement cannot hold this: an entry added for the wrong reason runs - // green forever. Pinning the CONTENT is what makes a widening show up as a - // failing case here rather than as a citation quietly re-entering the - // catalog under an exemption nobody re-read. - expect('the example-id allowlist holds exactly the passages it was measured for', - EXAMPLE_ID_ALLOWLIST.map((e) => `${e.file}::${e.contains}`).join(' | '), - 'skills/objectstack-pm-dispatch/SKILL.md::/pm-dispatch #128 #131'); - expect('every allowlist entry records WHY it is example data and not a citation', - EXAMPLE_ID_ALLOWLIST.every((e) => typeof e.why === 'string' && e.why.length > 20), true); - expect('every allowlist entry names a file on the published surface', - EXAMPLE_ID_ALLOWLIST.every((e) => e.file.startsWith(`${PUBLISHED_SKILLS_ROOT}/`)), true); - // An entry must be a PASSAGE, never a bare filename — a `contains` that - // matched everything would exempt the whole file. - expect('every allowlist entry is pinned to a passage, not a whole file', - EXAMPLE_ID_ALLOWLIST.every( - (e) => e.contains.length > 8 && new RegExp(INTERNAL_ID_SOURCE).test(e.contains), - ), true); + // ── On the absence of a per-passage exemption ──────────────────────────── + // + // This rule has none, and that is enforced from OUTSIDE this file rather than + // asserted inside it. `scripts/check-ratchet-remedy-authority.mjs` sweeps every + // gate's author-facing text for a remedy that expands a registry; the first cut + // of this rule carried a one-entry allowlist and that gate turned it red, which + // is how the maintainer's 2026-08-25 ruling for the `#` placeholder arrived. + // Reintroducing such a list — the natural way to silence a future red, one + // plausible passage at a time — reds there again, on a gate this file cannot + // vote in. That is a better guard than a self-referential assertion here, which + // would be reading its own source to prove a claim about its own source. + // + // The behavioural half is pinned above: the concrete ids the placeholder + // replaced are RED, with nothing to add them to. if (failures.length) { console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`); process.exit(1); } - console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the example allowlist pinned to its passage) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); + console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, the dead-root hard error (red when a ROOT is renamed, green when restored), the empty-scan hard error (red when a root yields nothing and when the whole scan does, green when restored), the published-catalog internal-id rule (red on a planted id in prose, in a fenced comment and in the repo#NNNN spelling, green when removed; hex colours, version numbers, HTTP codes, array indices and the "#1" ordinal all pass; references/ reached, generated artifacts and the internal roots out; the `#` placeholder passes while the concrete ids it replaced stay red, with no exemption to reach for) and the dispatch-gates declaration (every separator-less ROOT declared as a subtree, nothing declared this gate does not walk, the over-claim bounded to SKIP_PATHS) all hold.'); } function main() { @@ -930,9 +916,10 @@ function main() { + `\nentirely; a sentence that teaches something keeps the lesson and loses the number` + `\n("removed in #4286" -> "removed in protocol 17", or just "removed"). Prefer a customer-` + `\nresolvable anchor where one exists — a protocol version, an ADR number, a lint rule id.` - + `\n\nMaintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」` - + `\n\n⛔ Do NOT silence this by adding an EXAMPLE_ID_ALLOWLIST entry — that list is for` - + `\nfictional example data in a syntax demonstration, never for a provenance reference.\n`, + + `\n\nWriting a usage example that needs an issue number? Use the placeholder \`#\`.` + + `\nIt teaches the same syntax and is unmistakable to a customer reading it.` + + `\n\nThere is no per-passage exemption to reach for, by design: this rule has none.` + + `\n\nMaintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」\n`, ); } diff --git a/scripts/check-ratchet-remedy-authority.mjs b/scripts/check-ratchet-remedy-authority.mjs index 273223a58f..af12218be3 100644 --- a/scripts/check-ratchet-remedy-authority.mjs +++ b/scripts/check-ratchet-remedy-authority.mjs @@ -690,6 +690,10 @@ const CONTROL = { expect: 'excluded', why: 'CROSS_PACKAGE_TEST_INPUTS declares which globs a package tests read. A naive-prototype false positive.', }, + 'check-doc-authoring.mjs': { + expect: 'excluded', + why: 'Carries no ratchet at all. ROOTS, SKIP_PATHS, SKIP_FILES and GENERATED_SKILL_ARTIFACTS are declarations of what the two corpus rules read and what is generated rather than authored; each is path-scoped, and none is offered to the author as something to widen. Its published-catalog id rule reached this corpus by first shipping a per-passage allowlist and being turned red here for offering it — maintainer ruling 2026-08-25 replaced that passage with a placeholder and removed the list. If this flips, that list came back.', + }, 'check-error-code-casing.mjs': { expect: 'excluded', why: 'EXEMPT_FILES records a file whose literals are not error codes.', diff --git a/skills/objectstack-pm-dispatch/SKILL.md b/skills/objectstack-pm-dispatch/SKILL.md index 42cd097afe..e9032fe4d4 100644 --- a/skills/objectstack-pm-dispatch/SKILL.md +++ b/skills/objectstack-pm-dispatch/SKILL.md @@ -67,7 +67,7 @@ separate backlog repository, or want different defaults. ``` /pm-dispatch # drain the pm:queue backlog, 3 agents at a time /pm-dispatch batch:5 # wider batch -/pm-dispatch #128 #131 # two named issues, nothing else +/pm-dispatch # # # two named issues, nothing else /pm-dispatch rounds:1 # one round, then stop and report ```