Skip to content

Commit b7eaf6a

Browse files
os-billclaude
andauthored
ci(docs-drift): state the same-name-different-shape trap where the hand-grep remedy is prescribed (#19110)
Fixes #19093 Clause-②: no — the diff is workflow prose plus its pin. No schema, no accept set, no public surface. ## What this lands The Docs Drift Check's fold already carries one unconditional boundary line, and it ends by **prescribing a remedy**: "a rule your change carries has to be re-read by hand in the pages that restate it". That remedy is a name-based search, and this card is about the fact that a name does not identify a key. So the remedy's own failure mode now renders **beside the remedy**, on every run, with the censused names as its payload and the discriminating parse as its fix — plus a byte-exact pin in `scripts/docs-audit/check-drift-comment.mjs`, the same shape the neighbouring unconditional line takes. ## The census — first deliverable, and it chose the site Measured on this branch by walking the liveness ledger's governed types through the same registry and the same shape helpers `check-liveness.mts` uses, and reading each key's `[REMOVED]` marker. **Names, never a count** — on this card a count is structurally not a reading, because the whole defect is that a count cannot tell two identically-named keys apart. A key **tombstoned on one governed type and authorable on another**: | name | tombstoned on | authorable on | |---|---|---| | `active` | `flow` | `agent`, `skill`, `datasource`, `app`, `email_template`, `validation`, `sharing_rule` | | `aria` | `dashboard`, `app` | `action`, `page` | | `joins` | `query` | `analytics_cube` | | `objects` | `app` | `permission`, `translation`, `manifest` | | `template` | `flow` | `page` | | `tools` | `agent` | `skill` | | `version` | `app` | `flow`, `manifest` | So `tools` is **one of seven**, not a singleton — which settles the card's "what is NOT established: how many other keys carry the same collision". **Radius of the instrument.** Top-level keys of the governed types, resolved through `getMetadataTypeSchema()` plus the `SPEC_ONLY_SCHEMAS` overrides, tombstone detected as a `[REMOVED]` description prefix. Two things follow from that radius and both are stated in the landed text: - Drilling the same walk to depth 2 widens the class well past these seven (`timeoutMs` is tombstoned at `flow.nodes.waitEventConfig.timeoutMs` while `flow.nodes.timeoutMs` is authorable — a collision between **siblings on one type**); depth 3 and beyond adds only `query.expand.*` self-recursion, so depth 2 is where the widening stops being new. - **One known target outside the radius, measured:** a key retired through the `.strict()` guidance-map route instead of `retiredKey()` leaves **no tombstone in the walked shape at all**, so this instrument cannot see it in either direction. `tool.category` is retired that way (`TOOL_RETIRED_KEY_GUIDANCE` in `packages/spec/src/ai/tool.zod.ts`) while `AIToolDefinition.category` in `packages/spec/src/contracts/ai-service.ts` stays live and is documented as carrying `action.ai.category` — the identical trap, invisible here. The same route hides a second instance of a name this census already flagged: `tool.active`. ## The discriminating parse, re-derived first-hand The card records that the dual-parse was measured by the reporting dev and **not re-run** by the filing seat. It has now been re-run on this branch, against `SkillSchema` and `AgentSchema` imported from source, with the object literal taken verbatim from `content/docs/ai/agents.mdx:155-162`. - as a **`SkillSchema`**: **PASS**. - as an **`AgentSchema`**: **FAIL**, with an issue at `["tools"]` whose message opens, verbatim: > `agent.tools` was removed in @objectstack/spec 17 — use `skills`. An agent reaches exactly > the tools its surface-compatible skills declare (ADR-0064), so move each reference into a > skill: a platform tool by its registered name, or `action_ACTIONNAME` for one of your own > AI-exposed Actions. This is NOT a rename — there is no key the value moves to: the migration > DELETES the key and emits a notice naming each tool that was listed, and you re-declare each > one in a skill by hand. (One edit to that quotation: the message spells its placeholder with angle brackets, which this repo's body-sanitizer discipline keeps out of a PR body — it is written `action_ACTIONNAME` above. Everything else is byte-for-byte what the parse emitted.) **One correction to the card, from that re-run.** The card says the agent parse fails "with exactly one issue". On the literal **as written on the page** it fails with **two**: `["tools"]` and `["role"]` (`invalid_type`, required and absent — `role` is required on `AgentSchema` and the page's skill block naturally has none). The "exactly one" reading holds only once every `AgentSchema`-required key is supplied, which is presumably what the reporting dev did. Two controls settle it: - **Control A** — literal + `role`, parsed as an agent: **FAIL, exactly 1 issue, at `["tools"]`**. - **Control B** — the same object with `tools` deleted, parsed as an agent: **PASS, 0 issues**. So `tools` is the sole cause, and the direction is the one the card claims. The substance is confirmed; only the issue count was off. **Control C**, on the card's claimed hard discriminator: `SkillSchema` declares `triggerConditions` and `AgentSchema` does not. Also worth recording because it is the mechanism of the whole trap: `AgentSchema` **does** declare `tools` — the tombstone keeps the key in the walked shape, which is exactly why a name-based search finds it there. **Page structure, re-read first-hand:** the page's only `tools:` authoring occurrence is `:161`, inside the `defineSkill({` block opened at `:155`; the four `defineAgent(` blocks at `:256`, `:292`, `:325`, `:361` write zero `tools`. ## Why this site, and why not the other The card offers two candidate sites, marked as input and not a prescription. This PR takes the Docs Drift Check blind-spot text and deliberately does **not** write the liveness ledger's `_note`. 1. **The discriminator has to be on the path the trapped reader is already walking.** The measured instance is a reviewer of PR #19059 who grepped `content/docs` and read the hit. That reviewer meets the Docs Drift Check comment on the PR, unconditionally, at the moment the hand-grep method is prescribed to them. They do not open a ledger file to decide whether a grep hit is real. 2. **The free half of the ledger site is the wrong half.** A reader alarmed about `agent.tools` opens `agent.json`. `agent.json` is held by open PR #19059 and is fenced for this card, so the only writable ledger site is `skill.json` — the half that reader has no reason to open. A discriminator there is a warning posted on the door nobody walks through. 3. **The remedy and its failure mode belong in one place.** The existing unconditional line is what sends a reader to grep by hand. Documenting that method's known failure anywhere else splits a prescription from its caveat. 4. `.github/workflows/**` is not a PD-14 governed surface, and — measured — it publishes nothing. `packages/spec/liveness` **is** in `@objectstack/spec`'s `files[]`, so the ledger route would also have shipped this text into the npm tarball and taken a changeset. Per the card's fence, exactly one site is written. ## Class Reviewer/tooling trap, **not class (c)**. An author cannot reach the tombstoned key: the parse door refuses it (the FAIL above **is** that door), and the ADR-0087 `agent-tools-to-skills` conversion deletes it at rehydration. Nothing here is written up as a live authoring trap. ## Verification - `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0 — `✓ check-drift-comment: 66 cases pass across 5 fixture diff(s)`. The new assertion pair runs on all 5 fixture cases, evidenced by the ablations below failing exactly 5 of the 66. - **Ablation, both legs, mutation proven on disk and restored byte-exact** via `scripts/ablation-replace.mjs` (anchor must hit; blob hashes compared against HEAD): - alter one censused name in the rendered line (`joins` → `JOINS`) — mutation landed (`anchor 1 -> 0, blob f050c7c -> 79f4b52e58d0`), pin **RED** on all 5 cases, restore verified `blob == HEAD (f050c7c)` and `git diff HEAD` empty. - make the line conditional (`if (false) limits.push(...)`) — mutation landed, pin **RED** on all 5 cases, restored byte-exact. - Expected direction was "turns red", and that is what both legs produced. - Gate families derived from the real change set with `node scripts/pm/dispatch-gates.mjs --commands` (no hand-written path list): **47 derived, 47 run**, reconciled with `--ran`. 46 exit 0. `pnpm check:pm-dispatch-gates` is the checker's own repo-wide self-test suite and ran past two foreground budgets without reaching a verdict — recorded as NOT MEASURED, left to CI, never read as green. - Control-byte self-scan over both edited files beyond `check:nul-bytes`: no hits. - All readings taken at `549936fe47`. ## Acceptance notes Out of scope for this PR, noted and not filed: - The census instrument is a one-shot measurement, not a shipped script. A census script with no gate reading it is the "code `grep` finds and the runtime never runs" shape from Route & surface ownership §1, and wiring a new gate would be a new verification surface this card does not authorize. The names are durable in the landed text and in the pin; the method is written down here. Successor: none — no open PR or queued card walks this file. - `packages/spec/liveness/agent.json` and `packages/spec/liveness/README.md` were **not written**, per the card's fence: both are held by open PR #19059 and both carry `merge=os-regen`, where a conflict resolves silently. Independently re-derived here over all 32 open PRs (371 file rows; lit control: #19102 holds exactly 3 files) — those two are the only held paths among this card's candidates. --- _Generated by [Claude Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1124c45 commit b7eaf6a

2 files changed

Lines changed: 81 additions & 0 deletions

File tree

‎.github/workflows/docs-drift-check.yml‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,49 @@ jobs:
355355
// run by construction, exactly like the SDK bridge line above, and stating it
356356
// only sometimes would be the lie.
357357
limits.push('a page that states a rule by its **inputs** shares no identifier with the **emitter** that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on #11430: `content/docs/protocol/objectql/types.mdx` documents the text-family column mapping by the ObjectQL type names it maps FROM (`text` / `textarea` / `html`) while the diff changed `createColumn`; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.');
358+
// ── A KEY NAME IS NOT A KEY — THE SECOND BOUNDARY OF THE HAND-GREP REMEDY (#19093)
359+
//
360+
// The line above ends by prescribing a remedy: re-read the rule by hand in the
361+
// pages that restate it. This one states that remedy's OWN failure mode, because
362+
// the remedy is a name-based search and a name does not identify a key.
363+
//
364+
// The same spelling can be authorable on one schema and a `[REMOVED]` tombstone
365+
// on another, and a search result carries nothing that tells the two apart. So a
366+
// hand-grep that lands on a page's LIVE example reads it as evidence about the
367+
// DEAD key — a false alarm that is indistinguishable from a real find until
368+
// someone parses the value. That is not hypothetical: on #19059 a reviewer
369+
// grepping `content/docs` for `tools` reported `content/docs/ai/agents.mdx` as
370+
// contradicting the `agent.tools` tombstone, citing an example at `:161` that is
371+
// inside a `defineSkill({` block opened at `:155`. The page was already correct;
372+
// the cost was one stop-the-line alarm and a round of the implementing dev's
373+
// attention. The grep was the RIGHT method — it is what found the genuinely
374+
// falsified page on #19080, in exactly the blind spot the line above names.
375+
//
376+
// The names are the payload, and they are a census rather than the one instance:
377+
// measured on #19093 by walking the liveness ledger's governed types and reading
378+
// each top-level key's `[REMOVED]` marker. ⛔ A COUNT IS NOT A READING HERE — the
379+
// whole defect is that a count cannot tell two identically-named keys apart, so
380+
// this line prints the names and any re-measure must do the same.
381+
//
382+
// ⛔ Its radius is stated because it is NOT the whole class. Top-level keys of
383+
// governed types only: a key retired through the `.strict()` guidance-map route
384+
// instead of `retiredKey()` leaves NO tombstone in the walked shape and so cannot
385+
// appear here at all — `tool.category` is retired that way while
386+
// `AIToolDefinition.category` stays live, the identical trap one radius out.
387+
//
388+
// ⛔ Sited here, not in the liveness ledger's `_note`, and that was a choice
389+
// between two candidates. The ledger records the verdict; this comment reaches
390+
// the reader AT THE MOMENT THE METHOD IS PRESCRIBED, on every run, in the same
391+
// fold as the prescription. A reader alarmed about `agent.tools` opens the AGENT
392+
// ledger — so a discriminator written on the SKILL ledger sits on the half they
393+
// have no reason to open, which is the failure this card exists to stop.
394+
//
395+
// Unconditional by construction, like the line above and for the same reason:
396+
// the collision is a property of the schemas, not of any diff. Pinned byte-exact
397+
// on every case in `scripts/docs-audit/check-drift-comment.mjs`
398+
// (`SAME_NAME_DIFFERENT_SHAPE`) — an unconditional line nothing pins is one that
399+
// can vanish silently, which is the defect that file was written for.
400+
limits.push('a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a `[REMOVED]` tombstone on another for each of `active`, `aria`, `joins`, `objects`, `template`, `tools` and `version` (censused on #19093 over the liveness ledger\'s governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on #19059: `content/docs/ai/agents.mdx` was reported as contradicting the `agent.tools` tombstone over its `tools:` example at `:161`, which is inside the `defineSkill({` block opened at `:155` — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES `SkillSchema`, and as an `AgentSchema` it FAILS at `tools` with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a `.strict()` guidance map leaves no tombstone in the walked shape and none of them here (`tool.category`, live as `AIToolDefinition.category`).');
358401
// A broken scan is not a coverage number and must not read like one — see
359402
// `bridgeCoverageFrom`. Rendered outside the collapsed section below, because
360403
// "the bridge could not read the ledgers" is not a limit of this run, it is a

‎scripts/docs-audit/check-drift-comment.mjs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,37 @@ const RULE_CARRYING_BLIND_SPOT =
230230
+ ' detect this on, so a rule your change carries has to be re-read by hand in the pages'
231231
+ ' that restate it.';
232232

233+
/**
234+
* The #19093 boundary line, byte-exact — the SECOND fold entry that is not a report about
235+
* the run, and the second asserted on every case.
236+
*
237+
* The class: the line above prescribes a hand re-read, and a hand re-read is a name-based
238+
* search. A name does not identify a key — `tools` is authorable on `SkillSchema` and a
239+
* `[REMOVED]` tombstone on `AgentSchema` — so a grep hit on a live example is
240+
* indistinguishable from evidence about the dead key. Measured on #19059, where
241+
* `content/docs/ai/agents.mdx` was reported as contradicting the `agent.tools` tombstone
242+
* over an example that sits inside a `defineSkill({` block.
243+
*
244+
* ⛔ The payload is the NAMES, never a count: the defect is precisely that a count cannot
245+
* tell two identically-named keys apart. A re-measure that replaces them with a number has
246+
* removed the reading, and this pin is byte-exact so it cannot happen quietly.
247+
*/
248+
const SAME_NAME_DIFFERENT_SHAPE =
249+
'a key NAME is not a key, so the hand re-read the line above prescribes can land on the'
250+
+ ' wrong schema. The same spelling is authorable on one governed type and a `[REMOVED]`'
251+
+ ' tombstone on another for each of `active`, `aria`, `joins`, `objects`, `template`,'
252+
+ ' `tools` and `version` (censused on #19093 over the liveness ledger\'s governed types,'
253+
+ ' top-level keys); nothing in a search result distinguishes the two, so a grep hit on a'
254+
+ ' LIVE example reads as evidence about the DEAD key. Measured on #19059:'
255+
+ ' `content/docs/ai/agents.mdx` was reported as contradicting the `agent.tools` tombstone'
256+
+ ' over its `tools:` example at `:161`, which is inside the `defineSkill({` block opened'
257+
+ ' at `:155` — the page was already correct. Settle ownership by PARSING the value against'
258+
+ ' both schemas, never by the name: that literal PASSES `SkillSchema`, and as an'
259+
+ ' `AgentSchema` it FAILS at `tools` with the tombstone prescription. ⛔ These names are'
260+
+ ' not the whole class — a key retired through a `.strict()` guidance map leaves no'
261+
+ ' tombstone in the walked shape and none of them here (`tool.category`, live as'
262+
+ ' `AIToolDefinition.category`).';
263+
233264
/**
234265
* `want` is the mapper contract each case rides on — asserted before any text is, so a
235266
* case that silently stopped exercising its branch fails here instead of passing there.
@@ -348,6 +379,13 @@ try {
348379
true, body.includes(`- ${RULE_CARRYING_BLIND_SPOT}`));
349380
check(c.id, 'and it stays out of the headline, which reports this run only',
350381
false, headline.includes('shares no identifier with the **emitter**'));
382+
// #19093 — the same opposite pin, for the same reason, on the line that states the
383+
// hand-grep remedy's own failure mode. Byte-exact so the NAMES cannot be quietly
384+
// replaced by a count: on this class a count is structurally not a reading.
385+
check(c.id, 'the same-name-different-shape boundary is stated in the fold, byte-exact',
386+
true, body.includes(`- ${SAME_NAME_DIFFERENT_SHAPE}`));
387+
check(c.id, 'and it stays out of the headline, which reports this run only',
388+
false, headline.includes('a key NAME is not a key'));
351389
c.expect(headline, body);
352390
}
353391
} finally {

0 commit comments

Comments
 (0)