Skip to content

feat(spec): 未在手写文档中出现的 schema 变体让 CI 失败(#4165 反向漂移闸门) - #4177

Merged
os-zhuang merged 1 commit into
mainfrom
claude/unknown-key-stripping-strictness-h3a3zt
Jul 30, 2026
Merged

feat(spec): 未在手写文档中出现的 schema 变体让 CI 失败(#4165 反向漂移闸门)#4177
os-zhuang merged 1 commit into
mainfrom
claude/unknown-key-stripping-strictness-h3a3zt

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

补一道现有闸门族里缺失的方向。liveness 闸门问「这个属性做不做事」,check-doc-authoring 问「文档用的授权形式对不对」——没有一个问:schema 声明的变体,手写文档里到底提没提过。

content/docs/references/build-docs.ts 从 schema 生成,天然不会漂移;手写页面是人打出来的,会漂。

缘起:一条漂了几个月的漏检

apps.mdx 写导航树「supports eight item types」并列举了八个。schema 有九个——separator 是当年为对齐 objectui 渲染器的 item.type === 'separator' 分支加进来的(#1878/#1891/#1894),而没有任何手写页面知道它存在。没有任何闸门失败,因为没有闸门朝这个方向看。

#4165 把这个缺口的代价抬高了:NavigationItemSchema 现在按 type 判别,写错判别键会回一句「Invalid discriminator value. Expected ...」——而文档里那个枚举,正是作者拿来核对这份清单的东西。同时 SeparatorNavItemSchema 是只含 type/id/order.strict(),文档承诺「所有导航项共享」的基础属性,写在 separator 上现在是硬错误。

(该文档已在 #4165 修好,所以本 PR 是纯增闸门、零文档改动。)

身份与棘轮

条目身份是 <判别键>:<变体排序后拼接>刻意不用源码路径——一个 union 可由多条路径抵达,遍历顺序决定你看到哪条,路径会抖动。实测:导航 union 的发现路径是 api/metadata#AppDefinitionResponseSchema.data.navigation[],而不是 ui/app

以变体集合为身份的好处正是棘轮效应:增删任何一个变体都会改变 key,账本随即对不上,CI 把作者推回 variant-docs.json,从而推回文档。

  • 棘轮 A:发现了但账本没声明的 union → 失败(不留未申报面)
  • 棘轮 B:账本有但已不存在的 union → 失败(不留过期声明)

分类

反射扫出 20 个判别 union,与源码 grep -c 'z.discriminatedUnion(' 的计数完全吻合(两种方法互为交叉验证)。

  • 治理 5 条(每个变体必须在绑定文档中出现):app 导航项、view data provider、validation rule、hook body language、knowledge source kind
  • 豁免 15 条,两类理由:
    • generated-reference-only——只在 references/ 下有文档,而那是生成的(widget implementation、job schedule、connector auth ×2、tenant strategy、sync mapping transform、settings-manifest handler)
    • not-authorable——线协议 / 引擎 RPC / 加载器派生判别键(websocket、data-engine ×2、device-token status、CRDT ×2、migration changeset,以及 viewKind:作者写的是 list/listViews/formViews 容器,viewKind 是加载器展开后盖上去的,没有作者会手打它)

验证它真的会咬人,而不只是会通过

绿灯本身不算证据,三条失败路径逐条实测:

场景 结果
apps.mdx 移除 separator 提及 失败,退出码 1,点名该变体
删掉一条账本条目 失败:undeclared union(棘轮 A)
改动某条 key(等同新增变体) 同时报 orphan + undeclared——正是作者加变体时会看到的那一对

已知局限(已写进脚本头注,不藏着)

变体算「已记录」的判定是:文档中出现 <判别键>: '<变体>'出现 `<变体>`。第二种形态是宽松的,对 object/api/value 这类通用短词可能匹配到无关句子——通过只代表页面提到了这个词,不代表页面写对了。收紧它带来的误报成本高于精度收益。这道闸门抓的是「漂了几个月的彻底遗漏」,不替代人读文档。

接入与影响

  • pnpm --filter @objectstack/spec check:variant-docs(脚本自包含,直接 tsx 跑也对)
  • 挂在既有的 Spec Liveness Check workflow 上(复用其 install),并把 content/docs/** 加进触发路径——改文档同样可能打破它
  • scripts/ 不在 spec 的 tsconfig include 内(与现有 liveness .mts 一致),不影响类型检查
  • 无运行时代码改动

Refs #4001, #4165

🤖 Generated with Claude Code

https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4


Generated by Claude Code

…itten doc

The liveness gate asks whether a property does anything; check-doc-authoring asks
whether docs use the right authoring form. Neither asked the inverse-drift
question: does a variant the schema DECLARES appear in the prose at all?
content/docs/references/ is generated from these schemas and cannot drift, but the
hand-written pages are typed by humans and do.

The founding case shipped for months. apps.mdx said the navigation tree "supports
eight item types" and enumerated eight; the schema had nine — `separator` was added
to match the objectui renderer (#1878/#1891/#1894) and no hand-written page ever
learned about it. Nothing failed, because nothing was looking in this direction.
#4165 raised the cost: a mistyped discriminator now answers with the list of valid
values, and the doc's enumeration is exactly what an author checks it against.

Identity is `<discriminator>:<variants sorted>`, deliberately not a source path — a
union is reachable by several paths and the walk order picks one, so a path would
churn. Keying on the variant set means adding or removing a variant CHANGES THE KEY,
failing the ratchet and sending the author back through the ledger to the doc.

variant-docs.json classifies all 20 unions found by reflection: 5 governed, 15 exempt
as generated-reference-only (documented solely under references/, which is generated)
or not-authorable (wire protocol, engine RPC, or a loader-derived discriminator such
as `viewKind`, which no author ever types).

Verified the gate actually bites, not just that it passes: removing `separator` from
apps.mdx fails with exit 1 naming the variant; deleting a ledger entry fails as an
undeclared union; mutating a key fails as an orphan plus an undeclared union — which
is precisely the pair an author sees when they add a variant.

Scope limit stated in the script header: a variant counts as documented if the page
contains `<disc>: '<variant>'` or `` `<variant>` ``. The second form is loose, and for
short generic variants it can match an unrelated sentence — a pass means the page says
the word, not that it documents it correctly. This catches the omission that shipped
for months; it does not replace reading the page.

Refs #4001, #4165

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 2:05pm

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file tooling labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang marked this pull request as ready for review July 30, 2026 14:52
@os-zhuang
os-zhuang merged commit 8c2db68 into main Jul 30, 2026
19 checks passed
@os-zhuang
os-zhuang deleted the claude/unknown-key-stripping-strictness-h3a3zt branch July 30, 2026 14:52
os-zhuang pushed a commit that referenced this pull request Jul 30, 2026
Two conflicts, both in code #4165 ("reject unknown keys across the app shell
and navigation tree") rewrote underneath this branch:

- `packages/spec/src/ui/app.zod.ts` — main turned the nav union into a
  `z.discriminatedUnion('type', …)` with `.strict()` members and kept the
  `z.ZodType<any>` annotation; this branch replaced that annotation with the
  real `NavigationItem`. Both are kept. Reconciling them needed one addition:
  main widens the member array (`as unknown as readonly [ZodObject<ZodRawShape>,
  …]`) because the members are lazySchema Proxies and a superRefine-wrapped
  variant, and `discriminatedUnion` then reports the union's output as
  `Record<string, unknown>` — which fits `z.ZodType<any>` and nothing sharper.
  The union therefore takes a matching cast back to `z.ZodType<NavigationItem>`,
  documented at the declaration: every branch of the type is still
  `z.infer<typeof XNavItemSchema>`, so only the MEMBERSHIP of the list is
  unchecked, and app.test.ts parses all nine variants.

- `AGENTS.md` — main added the `check:generated` aggregate section; this branch
  added `check:exported-any` to the "pure checks with no generator" list. Both
  are kept.

Also registers `check:exported-any` in `check-generated.ts`'s ledger, which
main added and which reconciles itself against package.json on every run.

Registers `check:variant-docs` in the same ledger while here: #4177 added the
script and #4183 added the reconciliation, neither PR could see the other, so
`check:generated` was already failing on `main` itself. One line, in a file this
branch was editing anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY3swmCsdYx6AMGiRmFt1H
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…d config with what the executors read (#4045) (#4210)

The notify/http/connector step of the declared-vs-read worklist:

- notify/http gain executor-derived Zod contracts (NotifyConfigSchema,
  HttpConfigSchema) written from the executors, not the form literals,
  plus a bidirectional form <-> Zod key-set ledger test. Both reconcile
  clean, with no deliberately-shallow entries.
- connector_action's configSchema is retired: the executor reads only
  the declared FlowNodeSchema.connectorConfig sibling, but the published
  schema declared the trio as config keys - and the schema-driven Studio
  form roots every field at config.<key>, replacing the hand-written
  connectorConfig form, so online authoring produced nodes that refused
  to dispatch. Schemaless drops the designer back onto the correct form.
- flow-node-connector-config-lift (ADR-0087 D2, protocol 17) heals
  stored flows carrying the mis-taught config.{connectorId,actionId,
  input} shape, with a completeness guard so a step-time refusal never
  becomes a load failure.
- connectorConfig.input is optional, matching the executor (input ?? {})
  and the designer's keyValue editor, which omits an empty map.
- check:variant-docs (new in #4177) is classified in the check:generated
  ledger (NO_GENERATOR) - unclassified, it failed every gate run.


Claude-Session: https://claude.ai/code/session_01UDhMtxPLLoFaGtdpNA7xTU

Co-authored-by: Claude <noreply@anthropic.com>
os-zhuang pushed a commit that referenced this pull request Jul 31, 2026
Check Changeset failed: the gate wants a changeset on every PR, and its message
says how — "an empty changeset is fine for changes that release nothing".

I had asserted in the PR description that a docs-only change needs no changeset.
That was wrong about this repo's convention: 72 empty changesets already exist,
and they are how documentation work records itself without bumping a package.
Added one in that form (empty frontmatter, description only).

The alternative the message offers — a `skip-changeset` label — would suppress
the record instead of writing it, which is the wrong trade for a change that has
something worth saying in the release notes.

Refs #4001, #4177

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…checked (#4244)

Follow-up to the #4177 variant/doc gate, using the signal it produced. That gate
passes a variant if the page contains either `<disc>: '<variant>'` or a bare
`` `variant` ``, and its header says plainly that the second form is loose: a
pass means the page says the word, not that it documents the thing. Six variants
were passing on the loose form alone — the gate's own "weak pass" list, and a
precise worklist.

apps.mdx enumerated nine navigation item types but only documented six. `report`,
`action` and `component` shared one sentence — "the spec also defines …" — while
the other six each had a section with an example. They now have their own, and
the intro no longer implies a second tier of half-supported types.

The `action` section earns its place: `actionName` lives inside a nested
`actionDef` block, not at the top level, which is exactly the shape a reader
guesses wrong. Both it and `component` note which sub-object stays OPEN by design
(`actionDef.params`, `component.params`) — the sibling contract owns those, and
after #4165 everything around them is strict.

knowledge.mdx had a table naming `object` / `file` / `http` and no example of
any, so the per-kind keys (`contentFields` vs `prefix` vs `urls`) appeared
nowhere. Added one example each.

Every example was parsed against the real schema before committing —
NavigationItemSchema and KnowledgeSourceKindSchema, six for six. Writing docs
from memory is how the region example in #4218 ended up using an `id` key that
does not exist.

Anchored-form coverage for these two unions: 9/12 → 12/12.

Carries an empty changeset. The first push asserted a docs-only PR needs none;
Check Changeset disagreed and named the fix — an empty changeset is the repo's
way for work that releases nothing, and 72 of them already exist.

Refs #4001, #4177
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…4255) (#4257)

The aggregate reconciles its GATED/NO_GENERATOR ledger against package.json
on every run, in both directions — but only where the aggregate runs, which
was locally. CI runs the gates as individual steps, so a PR adding a
check:/gen: script without classifying it kept every CI gate green while the
wrapper AGENTS.md prescribes exited red on main at the reconcile stage,
running zero gates.

Twice in three days. #4177 added check:variant-docs and main stayed red for
local wrappers until #4194 collided with the same wall (#4203). Then #4232
added check:strictness-ledger — found unclassified while wiring this fix, so
the wrapper was red on main again at the moment of writing. Classified here
as NO_GENERATOR: it audits the hand-written strictness ledger (docs/audits/)
against the code it describes; there is no generator to name.

The gate is a --reconcile-only mode (reconcile, report, exit — no gates;
reads package.json only, no build, sub-second) wired into lint.yml's
"TypeScript Type Check" job. Deliberately not ci.yml's check-generated job:
its `generated` paths filter does not watch packages/spec/package.json — the
one file every offending PR must touch — and both offenders' changed files
missed every entry in that filter, so both skipped the job entirely. The
typecheck job is unfiltered and required, so the meta-gate cannot go
dormant — the reasoning that already placed check:docs, check:skill-refs and
check:react-blocks there.

Verified: --reconcile-only exits 0 on this tree (16 check: + 10 gen:, all
classified) after catching #4232's script pre-classification; an injected
unclassified script and a deleted ledger-named script both fail with the
existing diagnostics; the default mode still reconciles and then runs the
gates. AGENTS.md's wrapper section now states the CI backstop.

Closes #4255
Refs #4203, #4232

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCrKazC5o5a77yXd7ykaZg
os-zhuang added a commit that referenced this pull request Jul 31, 2026
…ative `connectors:` path gets its page (#4289) (#4293)

ADR-0097 made connectors tenant-authored metadata, but no hand-written page
taught how to write an entry. New content/docs/automation/connectors.mdx
covers the three entry shapes, the per-provider `providerConfig` contracts
(rest / openapi / mcp — factory-validated at boot, invisible to `os validate`),
credentialRef-based auth incl. why `oauth2` is deliberately absent from the
declarative shape (enterprise tier, ADR-0015), the configuration-vs-operational
failure split, and the showcase as the runnable reference.

The two connector-auth exemptions in packages/spec/variant-docs.json flip to
governed docs bindings per their own "when one is written, bind it" note, so
the #4177 gate now enforces all five auth variants against the guide.

Every example parses against the real schemas: the four full examples and the
auth fragments via DeclarativeConnectorEntrySchema (including the documented
rejections: inline `authentication` on a provider-bound instance, auth /
providerConfig without provider, authored actions), the dispatch snippet via
defineFlow, and the four marked blocks type-check under check:skill-examples
(202 examples). check:variant-docs green at 8 governed / 12 exempt;
check-doc-authoring clean.

Closes #4289.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/m tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants