Skip to content

feat(spec): refuse unknown top-level stack keys — ObjectStackDefinitionSchema goes strict (#8687) - #9186

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8687-stack-top-level-strict
Aug 17, 2026
Merged

feat(spec): refuse unknown top-level stack keys — ObjectStackDefinitionSchema goes strict (#8687)#9186
os-zhuang merged 2 commits into
mainfrom
claude/issue-8687-stack-top-level-strict

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#8687

Maintainer-ruled Shape B (2026-08-16, recorded 13:03Z on the card): top-level .strict() on ObjectStackDefinitionSchema, keeping the already-written near-miss resolver. B subsumes Shape A — a strict parse failure fails os validate outright, so no warning-accounting change is made. This also closes the root cause behind objectstack-ai/hotcrm#1141 (plain reference — that card is in a different repo and is not closed by this PR).

What changed

packages/spec/src/stack.zod.ts — the top-level stack door is now the shared strictObject template (the #4001 campaign idiom, same as IndexSchema's #9045 close), .superRefine(applyApiEndpointGates) retained:

  • An unknown top-level key REFUSES at parse (unrecognized_keys, path []), naming the surface and the key. The card's own measured control inverts: flow, approvalProcesses and a stray key now fail parse instead of adding zero warnings with exit 0.
  • Near-miss resolver survives, relocated into the refusal: strictObject's edit-distance suggester answers objectz with Did you mean `objectz` → `objects`? (and flowflows). lintUnknownStackKeys goes quiet on the strict surface by its own posture rule — one voice, not two; the generic lint machinery is unchanged and re-pinned against a synthetic strip-mode schema.
  • Curated prescriptions (no rename suggestion, per the campaign's finding-7 discipline): storage (deployment config, OS_STORAGE_* / Setup → Settings → Storage), approvals/approvalProcesses (Approval-node flows, ADR-0019), workflows (state_machine validation rules, ADR-0020), portals (removed, [P2] PortalSchema disposition: prune the dead schema or build a portal runtime #3464), onDisable (never invoked, Four of the five declared plugin lifecycle hooks are never invoked — and the metadata-type registry docs tell plugins to use one of them #4212). The history sentence names where the legal keys are enumerated.
  • onEnable is now DECLARED (z.function().optional()). It was undeclared-but-honoured (STACK_RUNTIME_MEMBERS): the parse stripped it while AppPlugin executed it off the authored bundle — a strict close of an undeclared onEnable would have refused the pattern examples/app-todo and app-showcase ship. Declared = honoured, in both directions; Config-booted apps lose their onEnable hook — every script action handler goes unregistered and 404s at dispatch (examples/app-todo: all 8) #4095 artifact-boot grafting is unchanged (GRAFTABLE_RUNTIME_MEMBERS still derives from STACK_RUNTIME_MEMBERS). composeStacks gives it a 'single' disposition: same value passes, disagreement refuses naming both stacks.

Contract plumbing (campaign shipping shape):

  • Protocol-18 semantic entry packages/spec/src/migrations/entries/semantic/18.stack-top-level-unknown-keys-refused.ts; registry.ts regenerated via gen:migration-registry (never hand-edited).
  • Changeset: @objectstack/specminor with BREAKING annotation, FROM → TO guidance, adr-0087: registered stack-top-level-unknown-keys-refused marker — matching the feat(spec): close IndexSchema against unknown keys — #4001 批 20 site 14, after objectui#4772 #9045-family changesets.
  • scripts/check-stack-collection-maps.mjs: re-anchored on the new declaration shape (the gate failed loudly by design when the z.object anchor vanished; its own error text prescribes fixing the anchor). Self-test updated to the new shape + a missing-shape-argument case (11 → 12 assertions).

Tests

  • packages/spec/src/stack-top-level-strict.test.ts (new): rejection pins assert issue code + path + keys (status is the publish door's uniform ADR-0112 wrap — stated in the docblock as the family convention, since this layer is pre-HTTP); near-miss pin; curated-prescription pins; accept-side pins (all 44 declared keys still accepted; representative stack keeps its top-level key set exactly — item-level defaults are pre-existing ADR-0122 behaviour); onEnable accepted AND retained in parsed output; lint-quiescence pin; defineStack throw-path pin.
  • packages/cli/test/validate-top-level-strict.e2e.test.ts (new): real-CLI exit-status pin (bin/run-dev.js + tsx, the migrate-exit-code pattern) — a stack with flow: exits non-zero without --strict, naming the key and the near-miss; the identical stack spelled flows: exits 0.
  • metadata-authoring-lint.test.ts: the stack section graduated — real-schema tests now pin silence (the refusal side moved to the new file); the lint's own machinery re-pinned against injected strip schemas; the runtime-member rot pin now asserts every STACK_RUNTIME_MEMBERS member is declared.

Fixture/consumer sweep

Consumers swept (downstream of @objectstack/spec, pnpm --filter '<pkg>...' closure built first): spec, objectql, metadata, metadata-protocol, lint, runtime, cli test suites + examples build (app-todo / app-crm / app-showcase / embed-objectql). 13 repo-internal fixtures fixed — every one carried authoring that was never spec-valid and had been silently stripped:

  • packages/metadata (3): artifacts with manifest fields flattened at top level (incl. scope: 'app', not even a manifest scope) → nested under manifest:.
  • packages/cli (7): five schema-migrate/stored-flow integration artifacts + migrate-meta.e2e pre-17 config (top-level name/label) + emit-json-pipe (stray top-level name would have added a 901st error to an exact-count assertion).
  • packages/lint (2): ...MANIFEST spread flat into the stack → manifest: MANIFEST; the org-axis test that PINNED the strip behaviour rewritten to pin the refusal.
  • packages/spec (1): connector reachability probe carried a stray top-level name.

composeStacks' #5005 completeness pin forced the onEnable disposition (the type-level half of that gate working as designed).

Verification (union run at final head 9b89f58da)

  • Suites: spec 407 files / 10827 tests green; cli 123 / 1360; lint 73 files; metadata-protocol 115; objectql 213; runtime 165; metadata 31 — all green. Examples build green (64 turbo tasks).
  • Typecheck: spec (src + scripts + test-layer), cli, lint green. @objectstack/metadata declares no typecheck script (ledgered) — verified as a zero-match rather than read as green; its edits are test-only and runtime-verified.
  • Gate union (re-derived from actual changed paths via dispatch-gates.mjs, run at 9b89f58da): changeset-gate-self-tests, cross-package-test-inputs, doc-formula-expressions, durability-log-level, merge-driver, objectui-changeset, spec-parsed-alias, stack-collection-maps, type-source-resolution, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs, query-options-erasure, engine-double-contract, where-matcher, nul-bytes, type-check-coverage, type-check-debt (--re-measure, full built closure), migration-registry, check:generated, check:authorable-surface, strictness-ledger — all green.
  • Reverse verification (fix committed first; ablation = git checkout of the pre-change stack.zod.ts, spec tests run on source so no dist rebuild involved): exactly the new pins went red — 10 tests (all rejection/near-miss/curated/onEnable-survival/lint-quiescence pins) — while the 30 accept-side controls stayed green; restore is byte-identical to the commit and the two files are 40/40 green again.

Behaviour notes for review

  • defineStack(config, { strict: false }) skips the parse entirely (unchanged); such a stack now gets NO top-level unknown-key warning (the lint yields to a strict schema it cannot know was bypassed). The key is no longer dropped on that path either (no parse ⇒ no strip) — the warning it loses was always slightly wrong there ("dropped at load" while nothing dropped). Called out rather than hidden.
  • Stored artifacts from the drift window that carry a stray top-level key now fail artifact ingestion loudly (_parseAndRegisterArtifact) — the population the ruling accepts as "breaking exactly for those already silently broken".

Generated by Claude Code


Generated by Claude Code

…onSchema goes strict (#8687)
The outermost authoring door was the last strip-mode surface of the #4001
campaign: an unknown top-level stack key parsed green and was silently
dropped, adding zero warnings to 'os validate' (exit 0 even under --strict).
Maintainer-ruled Shape B (2026-08-16): top-level strictObject, keeping the
near-miss resolver — the did-you-mean now rides the refusal itself, and
lintUnknownStackKeys goes quiet on the strict surface by its own posture
rule. onEnable is now declared rather than undeclared-but-honoured
(declared = honoured; #4095 grafting unchanged), with a 'single' composition
disposition. Curated prescriptions answer storage / approvals /
approvalProcesses / workflows / portals / onDisable. Protocol-18 semantic
migration entry stack-top-level-unknown-keys-refused; registry regenerated
the scripted way. Closes the root of hotcrm#1141.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq
…ject declaration (#8687)
The gate slices the stack-collection set out of ObjectStackDefinitionSchema's
source text, anchored on the literal declaration line. #8687 rewrote that
declaration from z.object({shape}) to strictObject({options}, {shape}), which
emptied the anchor — and the gate failed loudly by design instead of
reconciling against nothing. The extractor now slices the options body first,
then the shape body that follows it; the self-test synthetic source moved to
the new shape and gained a missing-shape-argument case.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01225pUjnCKWqxcc1PeqKFUq
@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 — list omitted above 15 rows. Re-derive: node scripts/docs-audit/affected-docs.mjs --json origin/main.

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

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.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationprotocol:datasize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude