Skip to content

fix(cli): every os explain catalog example parses against its own schema - #16924

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-15170-explain-catalog-examples
Sep 8, 2026
Merged

fix(cli): every os explain catalog example parses against its own schema#16924
os-project-manager merged 1 commit into
mainfrom
claude/issue-15170-explain-catalog-examples

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #15170
Fixes #15171
Fixes #15172
Fixes #15173
Fixes #15174
Fixes #15175
Fixes #15176

Clause-②: no

Re-declared from the delivered diff, not inherited from the dispatch. The diff is three files — packages/cli/src/commands/explain.ts, packages/cli/test/commands.test.ts, one changeset. It touches no *.zod.ts, no exports map, no package.json, no generated surface. Correcting an example so it parses against the schema the spec already declares narrows toward the declared contract: it relaxes no accept set and widens no published surface. The os explain catalog teaches a shape, it does not define one.

The rebuttal condition the dispatch attached to #15176 does not fire: that entry's disposition redirects to two mechanisms that already exist (hook, and a Flow of type: 'record_change') and adds no authorable concept. It in fact deletes an entry's required and optional tables rather than adding any.


The catalog is hand-maintained and does not derive from the spec, so its examples drifted behind the schemas they claim to demonstrate. The sweep landed for #14811 parses every entry's example against its real schema and pinned each failure as an it.fails xfail naming a card. Below, each card keeps its own evidence: the before is that card's own zod rejection text, reproduced on this branch's base commit against the real schema.

#15170object: field.options sampled as string[]

BeforeObjectSchema (@objectstack/spec/data) rejects:

[fields.status.options.0] invalid_type :: Invalid input: expected object, received string
[fields.status.options.1] invalid_type :: Invalid input: expected object, received string
status: { type: 'select', label: 'Status', options: ['open', 'closed'] },

After — parses. SelectOptionSchema is a strict object of { label, value } plus optional description / color / default / visibleWhen:

// Select options are OBJECTS, not bare strings: each is { label, value },
// where value is the stored lowercase machine identifier.
status: { type: 'select', label: 'Status', options: [
  { label: 'Open', value: 'open' },
  { label: 'Closed', value: 'closed' },
] },

The prose-side half of the same defect — the sweep structurally cannot see it

The field entry's optional-property table is not a parsed literal, so nothing reddens it, and it is where the example's error came from:

Before{ name: 'options', type: 'string[]', description: 'Available choices (select fields)' }

After{ name: 'options', type: 'SelectOption[]', description: 'Available choices (select/multiselect fields). Each option is an OBJECT — { label, value } plus optional description / color / default / visibleWhen — never a bare string. …' }

The field entry's own example, which parsed before and parses now, moves from a plain text field to a select field, so the real option shape is demonstrated where an author looks it up rather than only where it was broken.

#15171view: a flat literal where a CONTAINER goes

BeforeViewSchema (@objectstack/spec/ui) rejects wholesale:

[] unrecognized_keys :: Unrecognized key(s) on this view container: `type`, `layout`, `columns`.
  • `type` belongs to a single VIEW, not to the container. Wrap it:
    `defineView({ list: { type, data, columns, … } })`, or name it —
    `defineView({ listViews: { my_view: { … } } })`. The container's own keys
    are `list`, `form`, `listViews`, `formViews`.
  • `columns` belongs to a single VIEW, not to the container. [same guidance]

This is not a drifted key name — the entry taught the wrong level, so fixing the literal alone would have left the tables describing a single view. Both tables now document the container (name / label / object, plus the four view slots), and the example shows a single view's own keys inside a slot:

After — parses:

{
  // ── the CONTAINER's own keys ──
  name: 'project_task',
  object: 'project_task',
  label: 'Project Task Views',
  // ── a single VIEW lives inside a slot, never at the level above ──
  list: { type: 'grid', columns: ['title', 'status', 'assigned_to'] },
  listViews: {
    task_board: {
      label: 'Task Board', type: 'kanban',
      columns: ['title', 'status', 'assigned_to'],
      kanban: { groupByField: 'status', columns: ['title', 'assigned_to'] },
    },
  },
}

#15172agent: tools, a removed key with a migration notice attached

BeforeAgentSchema (@objectstack/spec/ai) rejects with three issues:

[label] invalid_type :: Invalid input: expected string, received undefined
[model] invalid_type :: Invalid input: expected object, received string
[tools] invalid_type :: `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_NAME` 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.

After — parses. Because the removal is not a rename, the replacement is authored rather than mapped:

{
  name: 'support_agent',
  label: 'Support Assistant',
  role: 'Customer Support Assistant',
  instructions: 'Help users resolve issues by searching the knowledge base.',
  skills: ['knowledge_lookup'],
  model: { provider: 'openai', model: 'gpt-4o', temperature: 0.2 },
}

The tables took the pass too, as the card asked. label and instructions move to required, where AgentSchema has them. Three optional rows are gone: tools (removed), objects (measured absent from AgentSchema entirely), and a top-level temperature (the schema answers it with model settings live under model — write model: { temperature: … }). skills, surface, access, permissions, avatar and active are documented in their place.

#15173app: nav items rejected wholesale

BeforeAppSchema (@objectstack/spec/ui) rejects with six issues, three per item:

[navigation.0.id]          invalid_type :: expected string, received undefined
[navigation.0.objectName]  invalid_type :: expected string, received undefined
[navigation.0]             unrecognized_keys :: Unrecognized key(s) on this `object`
    navigation item: `object`. Until this shape was closed these were dropped
    silently — the entry still parsed, so a mis-spelled config shipped as a nav
    item that quietly ignored it (a stripped `visible` renders an entry that
    should have been gated).
[navigation.1.id]            invalid_type :: expected string, received undefined
[navigation.1.dashboardName] invalid_type :: expected string, received undefined
[navigation.1]               unrecognized_keys :: Unrecognized key(s) on this
    `dashboard` navigation item: `dashboard`. [same guidance]

After — parses:

navigation: [
  // A nav item is discriminated on `type`, and each arm names its target
  // with its OWN key — never a bare `object` / `dashboard`.
  { id: 'nav_tasks', type: 'object', objectName: 'project_task', label: 'Tasks' },
  { id: 'nav_overview', type: 'dashboard', dashboardName: 'project_overview', label: 'Overview' },
]

Bounded in-place fix, same entry, same defect class — the navigation row was only type-name deep, as the card says, but two neighbouring rows on this same table named keys AppSchema rejects. Measured on this branch:

[] unrecognized_keys :: Unrecognized key(s) on this app: `logo`, `defaultRoute`.
   Did you mean `logo` → `branding`? …

logo and defaultRoute are dropped; branding stays and its description says it carries the logo. label also moves to required, where the schema has it.

#15174dashboard: the pre-ADR-0021 inline analytics shape

BeforeDashboardSchema (@objectstack/spec/ui) rejects with nine issues:

[widgets.0.id]      invalid_type  :: expected string, received undefined
[widgets.0.type]    invalid_value :: Invalid option: expected one of "bar"|"horizontal-bar"|
    "column"|"line"|"area"|"pie"|"donut"|"funnel"|"scatter"|"treemap"|"sankey"|"combo"|
    "gauge"|"solid-gauge"|"metric"|"kpi"|"bullet"|"radar"|"table"|"pivot"
[widgets.0.dataset] invalid_type  :: expected string, received undefined
[widgets.0.values]  invalid_type  :: expected array, received undefined
[widgets.0]         unrecognized_keys :: Unrecognized key(s) on this dashboard widget:
    `object`, `groupBy`.
    • The pre-ADR-0021 inline analytics shape (`object` + `categoryField` + `valueField`
      + `aggregate`, pivot `rowField`/`columnField`) was removed — bind a `dataset` and
      select `dimensions` + `values` by name. …
[widgets.1.id]      invalid_type  :: expected string, received undefined
[widgets.1.dataset] invalid_type  :: expected string, received undefined
[widgets.1.values]  invalid_type  :: expected array, received undefined
[widgets.1]         unrecognized_keys :: Unrecognized key(s) on this dashboard widget:
    `object`, `aggregate`. [same guidance]

As the card notes, this is the entry least suited to a mechanical fix: 'chart' is not a widget type at all, and the rewrite needs a real dataset to point at. The sample follows the shape the repo's own shipped dashboard uses (packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts).

After — parses:

widgets: [
  {
    id: 'tasks_by_status', type: 'column', title: 'Tasks by Status',
    dataset: 'project_task_metrics',
    dimensions: ['status'], values: ['task_count'],
    layout: { x: 0, y: 0, w: 6, h: 4 },
  },
  {
    id: 'open_tasks', type: 'metric', title: 'Open Tasks',
    dataset: 'project_task_metrics', values: ['task_count'],
    layout: { x: 6, y: 0, w: 3, h: 2 },
  },
]

Bounded in-place fix, same entry, same defect class — the entry's optional table carried { name: 'layout', type: 'GridLayout', description: 'Widget positioning' }. Measured:

[] unrecognized_keys :: Unrecognized key(s) on this dashboard: `layout`.
   • a dashboard has no layout template — each widget carries its own
     `layout: { x, y, w, h }`, and a widget with none is auto-flowed into the grid

The row is dropped for the per-widget layout; columns, gap and globalFilters (real keys) are documented; label and widgets move to required, where the schema has them.

#15175action: three keys, and one with no suggestion

BeforeActionSchema (@objectstack/spec/ui) rejects:

[] unrecognized_keys :: Unrecognized key(s) on this action: `object`, `flow`, `confirmation`.
   Did you mean `object` → `objectName`, `confirmation` → `confirmText`? Until this
   shape was closed these were dropped silently — the action still registered and still
   ran, without whatever the key was meant to configure or gate.

Two of the three are renames the spec itself names. The third is not, and that is the card's point: flow is rejected with no suggestion, so os explain action was teaching a type: 'flow' action whose flow could not be named. Read from ui/action.zod.ts: a flow action names its flow in target, which TARGET_REQUIRED_TYPES makes mandatory for every type except script.

After — parses:

{
  name: 'close_task',
  type: 'flow',
  label: 'Close Task',
  // `objectName`, not `object`; `target` carries the flow id (there is no
  // `flow` key); `confirmText`, not `confirmation`.
  objectName: 'project_task',
  target: 'close_task_flow',
  confirmText: 'Are you sure you want to close this task?',
}

The card notes the tables carry the same stale spellings, so they take the pass with the literal: object / flow / url / confirmation become objectName / target / confirmText, and target is documented as required-except-script.

Bounded in-place fix, same entry, same defect class — the required table advertised type: '"button" | "url" | "flow" | "api"'. ActionType is z.enum(['script', 'url', 'modal', 'flow', 'api', 'form']): "button" is not a member (an author writing it gets invalid_value), and script / modal / form were missing. The row now names the enum, and says a button is a location, not a kind. label also moves to required.

#15176trigger: an entry with no type to check against

This one is different in kind, and it is not fixed the same way. Disposition chosen: the explicit redirect, mirroring the workflow entry. Not a rename.

BeforeTriggerSchema is absent from all four metadata-authoring subpaths (measured: 'TriggerSchema' in specSurface === false), and the sample is not a Hook either:

[events] invalid_type :: Invalid input: expected array, received undefined
[] unrecognized_keys :: Unrecognized key(s) on this hook: `event`, `flow`.
   Did you mean `event` → `events`? Until this shape was closed, these were dropped
   silently — the hook still registered and ran.

Why redirect, and why this was not the escape hatch. The dispatch's escape hatch fires if "rename vs redirect" is a product naming decision. It is not one here, because the decision has already been made and recorded. MetadataTypeSchema in packages/spec/src/kernel/metadata-plugin.zod.ts carries it inline:

// ADR-0088: there is no `trigger` metadata type — sync data-layer logic is a
// `hook` (24 lifecycle events); async automation is a `record_change` flow.
// (The `triggers` capability token in `requires:` is a different namespace.)

ADR-0088 §1 is Accepted (2026-07-05), retires the kind, states that "its enum comment referenced a TriggerSchema that never existed", and writes the author-facing prescription outright: Authors: use hook for sync data-layer logic, a record_change flow for async automation. Under Prime Directive #13 that binds until a superseding ADR says otherwise, so choosing the redirect executes a recorded decision; choosing the rename would reverse one, which needs an ADR and not a catalog edit. It would also be the wrong shape twice over: hook is a real metadata kind in its own right, so renaming this entry to it would add a catalog entry for an authorable concept rather than pointing at existing ones — the exact condition the dispatch said to stop and re-declare on.

After — the entry redirects, its required / optional tables are emptied like workflow's, and its docsPath moves off automation/trigger (no such page in content/docs/) to automation/hooks (which exists — the same control the workflow entry's automation/workflows passes):

◆ Schema: Trigger (no standalone type)

  ObjectStack has no standalone Trigger authoring type — ADR-0088 retired the kind,
  and the `TriggerSchema` its registry comment once referenced never existed. Use a
  `hook` for synchronous, in-transaction data-layer logic (24 lifecycle events), and
  a Flow of `type: 'record_change'` for asynchronous, observable/pausable automation.

  Example:
    // No trigger metadata exists (ADR-0088). Use the delivered mechanisms:
    //
    // - hook — sync, data-layer, in-transaction (HookSchema):
    //     { name: 'notify_on_task_create',
    //       object: 'project_task',
    //       events: ['afterInsert'],      // `events` is an ARRAY; `event` is an alias, not a key
    //       body: { ... } }               // a hook's code slot is `body` — there is no `flow` key
    //
    // - Flow (type: 'record_change') — async, business-layer, observable/pausable.
    //     It binds its object on the START node's config, not at the top level.
    //
    // The `triggers` capability token in a package's `requires:` is a DIFFERENT
    // namespace and is unaffected by the retirement.
    // See: os explain flow

The entry's guard test changes shape with it. It still asserts TriggerSchema is absent; it drops the "not a Hook either" leg (there is no literal left to rule out) and gains the two the workflow entry uses — that the entry names itself a redirect, and that its example throws when evaluated, so "nothing was parsed here" is a property of the file. It also keeps a HookSchema resolvability assertion, now defending the other half: that the redirect points at a mechanism that exists.


The xfail promotion — the fence that makes this stick

it.fails is green on any failure, so a corrected entry left as an xfail would let the identical error return silently. All six were promoted to plain it(...) assertions in the same change; the ledger is now empty. The machinery stays, with a comment, so the next entry that arrives broken gets a card marker rather than a quiet skip.

before after
pnpm --filter @objectstack/cli exec vitest run test/commands.test.ts Tests 25 passed | 6 expected fail (31) Tests 31 passed (31)

xfail → assertion promotions: 6. Final pass count: 31 / 31, 0 xfail. Final run at 9aafd2b501.

Ablation — the promoted assertion is load-bearing

One corrected entry (object, #15170) reverted to the rejected shape, mutation and restore both proven by observing state rather than by reading an exit code. Verdict by test count on both legs.

leg on-disk proof Tests line
baseline HEAD blob dde84bcebf531eb32acd1b334558634f221cd90d
mutated blob 5285ee579205ceb51dbdd5eae85ec30a4b751bb9 (differs); corrected-shape marker count 1 → 0, rejected-shape marker count 0 → 1 Tests 1 failed | 30 passed (31)
restored blob back to dde84bce… = HEAD blob; markers back to 1 / 0; git diff HEAD --name-only empty Tests 31 passed (31)

The one failure is the promoted assertion itself, by name:

× os explain object — example parses as ObjectSchema
FAIL  |unit| test/commands.test.ts > os explain — every catalog entry swept against
      its spec schema (#14811) > os explain object — example parses as ObjectSchema

The script carries trap 'restore' EXIT INT TERM (restore being its own shell function) with an absolute REPO_ROOT-anchored path, restores with git checkout HEAD -- PATH (never the bare form, which would take the mutation back out of the index), and refuses loudly on an empty or unchanged blob hash. No dist preflight applies: the sweep imports the subject relatively (../src/commands/explain), not through a package exports map, so the mutated source is the resolution path — stated rather than assumed.

Verification

  • Sweeppnpm --filter @objectstack/cli exec vitest run test/commands.test.tsTests 31 passed (31), exit 0.
  • Typecheckpnpm --filter @objectstack/cli typecheck → exit 0 (tsc --noEmit + check:test-typecheck; the test layer compiles under tsconfig.test.json with its debt ledger unchanged).
  • Tierpackages/cli unit layer only. The diff touches no integration-layer file, no spawn entry point (bin/, test/helpers/serve-process.ts) and no driver/kernel boot path, so the integration layer is declared to CI rather than run here.
  • Gate families — derived from the change set with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (2 paths at first, re-derived after the changeset landed: 49 → 58 families). All 58 run, each exit code captured before any pipe. Reconciled: 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN.
    • Four gates first answered PREREQUISITE NOT MET / NOTHING was measured rather than a finding — check:dual-build-cjs-loads (exit 3), check:i18n (3), check:i18n-coverage (3), check:i18n-walk-parity (1). All four read built output that did not exist yet. After pnpm --filter @objectstack/cli build all four are green, including check:dual-build-cjs-loads104 published require entry point(s) across 67 package(s) load. Reported as the re-measurement it is, not as four failures that were argued away.
  • Lint — a narrowed run, with the three readings that make the narrowing a measurement rather than a skip. ① Population read from ESLint's own config, not guessed: ESLint#isPathIgnored over every tracked lintable file → 6377 linted, 0 ignored. ② This run's file count read from --format json: 3 files (the changeset .md is warned as unconfigured, which is not a finding), 0 errors, 0 warnings on the two TypeScript files, at 9aafd2b501. ③ Invariance: eslint.config.mjs never enables type-aware linting for any file — parserOptions.project / projectService occur 0 times, and the config's own header records the measured positive control — so a diff confined to these 3 files cannot move the verdict on any of the other 6374. The repo-wide pnpm lint is CI's run.
  • Control characterspnpm check:nul-bytes green, plus a hand scan of all three changed files with the gate's own class of pattern: no match.
  • Changeset — present; @objectstack/cli publishes dist, and explain.ts compiles into it, so operator-visible output moves. check:empty-changeset, check:changeset-no-major and check:changeset-gate-self-tests all green.

Docs-drift reading — done by hand, not delegated to the zero

node scripts/docs-audit/affected-docs.mjs --json, from a worktree whose git status --porcelain was empty. The tool's own computedOn reports dirty: false, diffBase 44c849c7d66977ed28814f15853d3e9f984275b7. It named 80 docs (not a zero) across 32 anchors — an anchor list, which finds sites and does not judge them, so every hit below was read before it was scored.

Hand token sweep of content/, each result paired with a control sharing the failing query's vocabulary:

token hits control verdict after reading
os explain / objectstack explain 1 os validate = 133 the one hit is release-owned (releases/v16.mdx), a historical note about #3244 and ownership — accurate, unaffected. Read, not edited.
options: ['…'] on a select 8 options: = 84 all 8 legal. Every one is Field.select({ options: [strings] }), the factory's documented shorthand — executed to confirm: it normalises to [{label,value}] and the result parses as FieldSchema. Not drift.
tools: [ … ] 4 skills: = 7 all 4 legal — every one is on defineSkill(...), where tools is the live key (ADR-0064). Not agent.tools.
nav item with a bare object: / dashboard: 0 objectName: = 77 clean
groupBy: / aggregate: 'count' 23 dataset: = 45 22 legal — dataset measures[] entries, ObjectQL query groupBy, one React prop. 1 observation, below.
action confirmation: / flow: 0 confirmText = 39 clean
TriggerSchema / defineTrigger / *.trigger.ts 12 HookSchema = 1 no live drift — the hits are different schemas (TimeRelativeTriggerSchema, ConnectorTriggerSchema) or the plugin-spec.mdx passage, which labels itself. Observation below.
flat view literal in content/docs/ 3 listViews = 33 2 legal (an Action.create with type: 'form'; an ASCII diagram), 1 explicitly-labelled conceptual snippet. Observation below.

All six release-owned pages the tool listed were read and none is falsified — releases/v17.mdx already tells authors to "move anything declared in agent.tools[] onto skills", which is what this change now teaches, so the catalog moves into agreement with the published notes. ⛔ No release-owned page edited.

One measurement failed and is reported as failed, not as a clean bill: probing the three docsPath URLs live (objectstack.dev/docs/…) returned 000 for all three including the control that should exist, so the egress path is dead from this container and the live-site reading is unavailable. The docsPath conclusion below rests on repo-side evidence only.

验收备注 — out of scope, noted, not filed

  1. ⚠️ A finding I am filing separately, not fixing here: os explain query teaches two keys the runtime SILENTLY DROPS. Its example passes the sweep today, and that green is false. BaseQuerySchema is a plain z.object, not strict, and has where / orderBy — not filters / sort. Measured on this branch: the entry's own example parses true, and the parsed output's keys are object, fields, limit. The filter and the ordering are gone with no error anywhere, so an author who copies it gets unfiltered, unordered rows. This is a worse failure mode than the seven fixed here, which all fail loudly. Not fixed in this PR: it is a different entry, no card covers it, and the sweep would need a new key-retention assertion — a new verification surface, so the bounded in-place-fix test fails on its fourth condition. Deduplicated first over all 113 domain:cli + finding cards, all states, full pagination to a short page, with a positive control.
  2. noted, not filedcontent/docs/protocol/kernel/plugin-spec.mdx still presents defineTrigger() and src/triggers/**/*.trigger.ts, and ADR-0088 dropped that suffix from OPS_FILE_SUFFIX_REGEX. Read in full before scoring: the passage labels itself "part of the proposed ergonomic authoring surface — there is no such helper today" and points readers at hooks, i.e. it already makes this PR's redirect. Whether "proposed" survives a retirement is a documentation-audit judgement, not a mechanical defect. Successor: the docs-accuracy-audit lane — the page is inside scripts/docs-audit/handwritten-docs.json.
  3. noted, not filedcontent/docs/concepts/metadata-driven.mdx:122 shows const taskForm = { type: 'form', object: 'task' }, a flat view literal of the [finding] os explain view's example teaches a flat view literal — ViewSchema is a CONTAINER (list / form / listViews / formViews) #15171 shape, under a comment that marks it "Conceptual". Same lane and same file set as the row above.
  4. noted, not filed, successor: nonecontent/blog/protocol-first-development.mdx:717 carries a flat kanban view literal that ViewSchema would reject. content/blog/ is outside the docs-audit scope (0 of its 190 entries are blog pages), so no standing lane covers it, and the whole post is pre-spec illustrative prose rather than one drifted key.
  5. noted, not filed — every docsPath in this catalog except workflow's points at a page that does not exist in content/docs/ and is not covered by apps/docs/redirects.mjs: data/object, data/field, ui/view, ui/action, ui/dashboard, ui/app, automation/flow, ai/agent, data/query. The real pages are data-modeling/objects, ui/views, and so on. I moved only trigger's, because that entry's rewrite required a real target; the rest is a whole-catalog link question with its own review, and it fails the bounded in-place-fix test on condition ② (each target needs judgement, not a mechanical rename). The live-site confirmation is the failed measurement noted above; the repo-side reading has its control — workflow's automation/workflows does exist.
  6. A conflict between the dispatch word and the repo, surfaced rather than quietly resolved. The dispatch and triage tables attribute the agent.tools removal to ADR-0106; ADR-0106 in this repo is metadata-plane FLS object schema masking, unrelated. The zod rejection an author actually receives cites ADR-0064, and releases/v17.mdx cites ADR-0109. I carried the zod message's own citation into the catalog, because that is the text the author is reading when they need it. Recording the divergence; not chasing it.

Not done here

⛔ No merge, no approval, no auto-merge, no draft flip. ⛔ No test skipped, disabled or quarantined. ⛔ No file under content/docs/releases/ edited.


Generated by Claude Code

…chema

The catalog in `packages/cli/src/commands/explain.ts` is hand-maintained and
does not derive from the spec, so its examples drifted behind the schemas they
claim to demonstrate. The sweep landed for #14811 parses every entry's
`example` against its real schema and pinned each failure as an `it.fails`
xfail. This corrects the six entries that carried one, disposes of a seventh
that had no type to check against at all, and promotes every xfail to a plain
assertion — the ledger is now empty.

- object      select `options` are objects (`{ label, value }`), not strings
- field       the prose row that was the source of that error: `string[]` ->
              `SelectOption[]`, and the example demonstrates the real shape
- view        `ViewSchema` is the per-object CONTAINER; a single view's keys
              live inside a `list` / `form` / `listViews` / `formViews` slot
- agent       `tools` was removed in spec 17 (ADR-0064) with no key its value
              moves to — teach `skills`, `model` as an object, and the
              required `label` / `instructions`
- app         nav items need `id` plus the discriminant's own target key
              (`objectName` / `dashboardName`); `logo` / `defaultRoute` are
              not AppSchema keys
- dashboard   widgets bind a `dataset` and select `dimensions` / `values`
              (ADR-0021); `'chart'` is not a widget type; there is no
              dashboard-level `layout`
- action      `objectName`, `confirmText`, and `target` for a flow action;
              `"button"` is not in the type enum
- trigger     ADR-0088 retired the kind and its `TriggerSchema` never existed
              — the entry becomes an explicit redirect to `hook` and a
              `record_change` flow, the shape `workflow` already uses

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added the size/m label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 32 documentable anchor(s).

74 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 70f7d6d735505c03a80bdb279262af5aa7c77ff1.

6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 anchor(s) matched too much of the corpus to be a work list: objectName (symbol, 34 pages), objectName (literal, 34 pages)
  • 16 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys 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.

Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 70f7d6d735505c03a80bdb279262af5aa7c77ff1packageMentionDocs.

Which tree this was computed on

This run read content/docs from 71880ed93e76698c43e824d72a84a1cff2383cba — the merge of head 9aafd2b501fefb790c0406c83fb2b8607fe98b3f into base 70f7d6d735505c03a80bdb279262af5aa7c77ff1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 71880ed93e76698c43e824d72a84a1cff2383cba && git checkout 71880ed93e76698c43e824d72a84a1cff2383cba
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 70f7d6d735505c03a80bdb279262af5aa7c77ff1 9aafd2b501fefb790c0406c83fb2b8607fe98b3f && git checkout -B drift-repro 70f7d6d735505c03a80bdb279262af5aa7c77ff1 && git merge --no-ff 9aafd2b501fefb790c0406c83fb2b8607fe98b3f

node scripts/docs-audit/affected-docs.mjs --json 70f7d6d735505c03a80bdb279262af5aa7c77ff1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 70f7d6d735505c03a80bdb279262af5aa7c77ff1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 8, 2026

Copy link
Copy Markdown
Collaborator

PM review — seven cards, and every load-bearing claim I could check from source holds. ⚠️ Acceptance is conditional on CI, which is still running.

Read at head 9aafd2b501 against merge-base 44c849c7d6. Three files: packages/cli/src/commands/explain.ts, packages/cli/test/commands.test.ts, one @objectstack/cli patch changeset. ⛔ I did not re-run the suite or the ablation — those are the delivering seat's readings and CI is the independent signal.

The clause-② limb, re-run here: 7 ✓ / 0 ✗, exit 0

node scripts/pm/check-clause2-carriers.mjs --pair 16924 was exit 4 an hour ago with six of the seven cards unreadable. It now passes for all seven. I re-ran it myself rather than taking the report's word.

And the diagnosis is worth more than the fix. The six pointer claims were never missing the judgement — they carried it on a shared line:

Domain: `domain:cli` · Clause-②: no

CLAUSE2_KEY_LINE (at scripts/pm/check-clause2-carriers.mjs:436) is anchored at ^ and tolerates only leading whitespace, a blockquote marker, a list bullet, and backtick/bold wrapping on the key. A line beginning Domain: cannot match however the key is spelled later in it. ⇒ the near-miss was placement, not spelling — and the two render identically to a human reader.

⚠️ The checker's own remedy text sends the reader the wrong way: it says "carries no Clause-②: line in the fixed spelling" and ⛔ "do not relax the spelling to accept the prose", and its docblock enumerates only spelling variants as the near misses it catches (a different key, a different case, a full-width colon, the prose form Clause ②:). A correctly-spelled key that is simply not at the start of its own line is neither read nor quoted back. I will file that as its own card against the checker — ⛔ not fixed here, and ⛔ the spelling is not to be relaxed; the remedy text is what is incomplete.

Verified from source, ⛔ not from the report

# claim reading
1 the xfail ledger is empty BOUND carries 9 entries and not one card: field — every bound entry is a plain assertion. The card?: number field survives as machinery, documented for "the next entry that arrives broken"
2 nothing is silently unswept UNBOUND carries 2 (workflow, trigger), each with a test that ASSERTS its reason, plus a classifies every entry in SCHEMAS guard that reds when a new catalog entry appears unclassified
3 the guard is guarded a separate harness-health test resolves every bound entry to a real schema and every example to an object — with its reason stated: "it.fails is green on ANY failure, so a broken subpath export or an unevaluable example would otherwise keep six xfails passing while measuring nothing at all"
4 #15176 is a redirect, not a rename grounded — see below
5 #15170's prose half explain.ts:74 now reads { name: 'options', type: 'SelectOption[]', description: '… Each option is an OBJECT — { label, value } … never a bare string.' }
6 file surface 3 files, exactly as declared. No *.zod.ts, no exports map, no package.json, no generated surface

⭐ On (1): my first grep for the promoted assertions returned 1 hit for their name, against a report claiming six. ⛔ I did not publish that. Read out, the assertions are generated in a for loop over BOUND with a template name — one literal, nine tests. An anchor finds sites; it does not judge them.

#15176 — I verified ADR-0088 §1 verbatim, and the disposition is right

docs/adr/0088-metadata-kind-admission-and-retirement.md, **Status**: Accepted (2026-07-05), §1:

trigger had no stack collection, no defineTrigger, no FS loader consuming **/*.trigger.ts, no executor — and its enum comment referenced a TriggerSchema that never existed. … Authors: use hook for sync data-layer logic, a record_change flow for async automation.

⇒ Redirecting executes a recorded decision; renaming would reverse one and needs an ADR, not a catalog edit. ⭐ The needs-user-decision escape hatch was available and correctly not taken.

⚠️ I asked the seat to check §1's closing caveat — "(The triggers capability token in requires: — the FlowTrigger plugin family — is a different namespace and is unaffected.)" — because a redirect that reads as retiring the token too would be a new error. It was already handled: explain.ts:377 scopes the retirement to "the kind", and the example at :391 carries "The triggers capability token in a package's requires: is a DIFFERENT namespace and is unaffected by the retirement." Nothing changed for it, correctly.

One correction on this family is mine

The dispatch word and both triage comments attribute the agent.tools removal to ADR-0106, which is metadata-plane field-level security and unrelated. That error came through my dispatch; corrected on #15170 (5588724679). The real citations are ADR-0064 (Proposed, cloud-owned, part-superseded — the one the zod rejection quotes) and ADR-0109 (Accepted, implemented — the one the release notes lead with). ⭐ The seat's choice to carry the zod message's own number into the catalog stands: one number for the author who hits the rejection and then runs os explain agent. The citation-authority question is filed separately as #16927.

⭐ The out-of-scope finding is sharper than the seven this PR fixes

#16925, filed by the seat: os explain query teaches filters / sort, which QuerySchema silently drops — the example parses true and the parsed keys come back as only object, fields, limit. A copied query loses its filter and its ordering with no error, and the #14811 sweep is green on it by construction. The seven corrected here all fail loudly; that one does not. Correctly not folded in — closing that class needs a key-retention assertion, i.e. a new verification surface.

Status

CI at 16:56Z: 33 check runs, 0 duplicated names, 0 red, with only Lint & Repo Gates still in progress (started 16:41:47).

⚠️ If it comes back green, this is accepted — verdict, then ready-for-review, then auto-merge, in that order. If it reds, it goes back to the delivering seat; ⛔ the PM writes no code.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Accepted — CI is fully green on 9aafd2b501

Lint & Repo Gates completed success at 17:05:20Z; it was the last one outstanding. This discharges the condition on my review above (5588816029).

raw check-run rows 33
names carrying a superseded earlier row 0 — every name appears exactly once, so the latest-per-name collapse is a no-op here
success 30
skipped 3 (Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in))
failure / cancelled / timed_out 0
queued / in_progress 0

⭐ Stating the collapse even where it changes nothing: on this head there is no duplicated name, so the raw list and the collapsed list are the same 33. On #16921 an hour ago they were 38 and 34, which is why the step is taken rather than assumed.

Landing precheck — all seven cards

Two things recorded rather than fixed, restated so they survive the merge

Arming

Ready-for-review first, auto-merge second — a draft conversion after arming destroys both. ⚠️ Auto-merge will record merge_method: merge whatever is requested; that field is inert here and the queue squashes. Queue membership will be read from the enqueued event, and enqueue can lag arming by around 90s.

⛔ I neither approve nor merge. On merge I will verify the landing on origin/main and strip pm:dispatched from all seven cards, reading each card's current labels first.


Generated by Claude Code

@os-project-manager
os-project-manager marked this pull request as ready for review September 8, 2026 17:09
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit aae8843 Sep 8, 2026
35 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-15170-explain-catalog-examples branch September 8, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment