feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down) - #14714

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys
Sep 2, 2026
Merged

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down)#14714
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Contract-review tier (clause ②): the accept set of a published spec schema moves. needs:contract-review is parked on this PR and on the card; the seat runs the review. Nothing here is marked ready or armed.

1. Measurement — every door that reaches ManifestSchema (taken FIRST, before any tightening)

Taken on origin/main at bd4aa4e49 (the branch base), from packages/spec/src and once more through the built packages/spec/dist — the two readings are byte-identical (diff of the two harness outputs is empty). Harness: one script feeding each door's own entry schema a legal manifest plus one probe at a time (zzzBogusManifestKey, a transposed namesapce with namespace absent, specVersion, main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shipped manifest: block through a plain .strict() preview, then the real composeStacks(…, { manifest: 'preserve' }) output of examples/app-multi-package through the assembled entry.

doorvalue originundeclared keys observed at the doorverdict beforeverdict after
D1 ObjectStackDefinitionSchema.manifest (stack.zod.ts) — defineStack (parses with strict: true by default), os validate, os compile / os build, the metadata service's artifact doorauthored stack source (objectstack.config.ts, every os init template, the four example apps)none in any shipped block (13/13 blocks carry only declared keys); a bogus key, namesapce, specVersion, main and every bookkeeping key parsed green and were DROPPED; namespace came out undefined for the typostriprefused, unrecognized_keys at path manifest, keys named, rename suggested
D2 stack.devPlugins[]z.union([ManifestSchema, z.string()])authored stack sourcesame as D1 (dropped silently)striprefused; the union reports invalid_union at devPlugins.N with the named unrecognized_keys issue nested in its errors (the state-machine row's known flattening shape — loud, one level deeper)
D3 ArtifactPackageEntrySchema.manifest — the exported authoring-time packages[] entryauthored (no runtime caller; exported and pinned by stack-artifact-packages.test.ts)dropped silentlystriprefused at manifest
D4 ArtifactPackageSchema.manifest = AssembledPackageBodySchema = ManifestSchema.extend(collections)resolveArtifactPackageOrder (@objectstack/core since #14643; called by the ObjectQL load path and, after the merge, by the metadata door), and os compile's parse of the composed stackartifact bundle (objectstack.json); bodies are produced by assemblePackageBody in spec, { ...manifest, ...collections } — the two real bodies of examples/app-multi-package carry 0 keys outside manifest ∪ collectionsdropped silently on the body (zod 4.4.3 .extend() inherits the parent's strip posture)striprefused at manifest.extend() inherits the closed posture AND the error map (measured on zod 4.4.3 before editing), so the load gate refuses an undeclared body key without a second declaration
D5 ManifestSchema.safeParse direct — os plugin build (objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsx at the pinned sha)authored plugin manifest file; a form draft (create) or the package record's manifest (edit)main — read off the raw manifest by build.ts to pick the esbuild entry, and written back into the compiled manifest as dist/index.mjs; authored by three CLI fixtures (osplugin.test.ts, plugin-sign.test.ts, plugin-publish.test.ts). Everything else dropped silentlystriprefused for undeclared keys; main DECLARED (see §4)
D6 InstalledPackageSchema.manifestregistry.validate('package')registry item — but registerItem('package', …) has NO caller: installPackage writes the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys on InstalledPackageSchema itself are stripped by that (unchanged) row schemanone reach itstriprefused inside manifest if ever reached; the row schema is untouched
D6b registry.validate('plugin')ManifestSchema.parseregistry item via SchemaRegistry.registerPlugin — which has no caller in objectql / runtime / core / rest / metadatanone reach itstripunreachable; unchanged semantics (diagnostic-only, registers anyway)
D7 the six wire shapes embedding the manifest — InstallPackageRequestSchema, UpgradePackageRequestSchema, UpgradeSnapshotSchema.previousManifest, PackageInstallRequestSchema, PackageUpgradeRequestSchema, ResolveDependenciesRequestSchemarequest / snapshot declarations; no runtime parse site (package-routes.ts hands req.body.manifest to protocol.installPackage unparsed; the durable sys_packages row is re-registered through registerApp without a parse)nonestriprefused inside manifest if ever parsed; no behaviour change measured anywhere
stored-row check (the api/ row's peelStoredEnvelope question)sys_metadata hydration goes through applyConversionsToStoredItem / peelStoredEnvelope and never through this schema; the package / plugin registry branches above have no callerno packageId / state / published* / package key reaches any doorno envelope debt to pay first; no bookkeeping key taught to the schema
cloudno checkout in this container; the repo-bound token refuses /search/code (403)NOT MEASUREDif cloud parses a published .osplugin manifest through this schema, the compiled manifest it receives carries only declared keys (main, integrity included) by construction of os plugin build

Nested blocks, same doors: contributes.kind (for kinds), contributes.kinds[].glob (for the retired globs) and engines.protocl (for protocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence. engine.objectstak was already refused by the required objectstack sibling.

Example apps (numbers): 4/4 example-app manifest: blocks (app-crm, app-todo, app-showcase, app-multi-package core + orders = 5 blocks) parse green under the strict preview with 0 undeclared keys; so do the 3 os init templates, the create-objectstack blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails is packages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (no id, no type) — filed as #14705, not touched here. Post-close, through the rebuilt dist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declared main), both real assembled bodies parse, the composed multi-package stack parses.

2. The tightening the measurement licensed — branch (a)

ManifestSchema is strictObject({ surface: 'this package manifest', history, guidance }) — the house helper #8687 uses one door up, no second helper. Curated guidance: exactly one entry, specVersion, the retired CLI-advisory axis that os doctor / os lint used to read (the protocol-version-gap pin was the measurement) — it points at engines.protocol. No bookkeeping key was taught to the authoring schema (the trade the api/ row refused), because none reaches a door.

The retired keys (loading, capabilities, configuration, extensions, the ten retired contributes members, kinds[].globs) stay retiredKey() tombstones rather than becoming guidance lines: a tombstone types the key never for tsc and raises its prescription at parse — strictly stronger — and strictObject's acceptsNothing keeps them out of the rename candidates, so capabilitis is refused without being pointed at the dead capabilities (pinned). Every strip pin flipped to a refusal pin, none deleted: assembled-package-body.test.ts ("the body schema is not strict") and packages/cli/src/utils/protocol-version-gap.test.ts ("specVersion is accepted and dropped") now assert the refusal; the retirement pins in manifest.test.ts / plugin-loading-retirement.test.ts keep bearing weight unchanged (their prose about "not strict" updated).

3. Nested blocks — closed under the same measurement, not silently

The card's words are "inside manifest:", and contributes, contributes.kinds[], engine and engines are hand-authored in that same block and reachable ONLY through the doors above (no other user of PluginEnginesSchema in-repo beyond the type-alias pin; cloud unmeasured, stated). All four are strictObject now, each with its own surface name and one-sentence history; PluginEnginesSchema stays exported. Pinned: kind gets Did you mean kinds, protocl gets Did you mean protocol, glob is refused without a rename to the tombstone.

4. main — declared, not curated (the one measured widening)

os plugin build reads rawManifest.main to choose the module esbuild bundles and writes main: 'dist/index.mjs' into the compiled manifest it packs; ADR-0025 §3.2/§3.4 describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a naked strictObject would have refused every plugin that names its entry (the shape the api/ row records as refused). A guidance line telling authors to delete it would describe neither the source file nor the compiled one. So it is declared: main: z.string().optional() with a describe, a liveness row (live, evidence packages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and the plugin-spec.mdx callout regenerated/updated. This is additive (accept-set widening on one optional key), flagged here for the contract review.

5. The ledger

docs/audits/2026-07-unknown-key-strictness-ledger.md: the kernel/ cell is now mixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in the api/ row's form. The counts file did not move (the same six sites, re-postured strip → strict; check:strictness-ledger green — it reads the coarse table for form only). stack.zod.ts was read-only for this card (in-flight #14124): its AssembledPackageBodySchema docblock sentence "NOT strictObject: ManifestSchema is an open object" is now stale, as is the compile.ts comment "ManifestSchema is an open object" — both reported for the seat rather than edited here (one-line follow-ups; the ledger row says so).

6. Changeset level

@objectstack/spec: minor with a **BREAKING** accept-set note — the lockstep launch-window convention (check-changeset-no-major), as #8687 and #5384 did. ADR-0087 disposition: not-required (no-migration-prescription), the #5384 reasoning: no key is retired, only the unknown-key posture moves, an undeclared key was never honoured so no stored or working shape becomes invalid, and there is no single rewrite rule a ledger entry could state; the schema rejection is the upgrade channel. #8687 registered a D3 entry because it ALSO declared onEnable and had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*). check:adr-0087-registration and check:changeset-no-major are green on this body.

7. Verification (all on the final head 1efaac767, after merging origin/main at c616c2cc2)

Under scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.

  • @objectstack/spec full suite on f3946663c (pre-merge, spec src identical to the final head): Test Files 456 passed (456) · Tests 12246 passed (12246) · VERDICT command-exit 0. Re-run on 1efaac767 of src/kernel + assembled-package-body + stack-artifact-packages + stack-top-level-strict + compose-stacks + shared/strict-object + shared/alias-integrity: Test Files 54 passed (54) · Tests 1052 passed (1052) · VERDICT command-exit 0.
  • @objectstack/spec typecheck (tsc + scripts + check:test-typecheck): check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) · VERDICT command-exit 0 (re-run on the final head after the merge, since check-liveness.mts moved). The new test file is inside the tsconfig.test.json program (--listFiles count 1).
  • check:generated: ✓ All 15 generated artifacts are up to date. on the final head (spec rebuilt from the identical src after the ablation restore bumped the mtime — content hash-identical). check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:liveness (manifest 39 classified (live 23, dead 15, live-elsewhere 1)) all green.
  • Reverse verification (ablation, on committed f3946663c):manifest.zod.ts opened on disk by appending .strip() to the ManifestSchema declaration under a trap … EXIT INT TERM with absolute paths; mutation confirmed on disk by marker count 0 → 1 and blob d069b01ba… ≠ HEAD blob 8d138e453…; the pins resolve src by relative import, so no dist rebuild was needed for this leg (stated, not assumed). Result: Test Files 2 failed | 1 passed · Tests 8 failed | 60 passed — the 7 root/door refusal pins and the assembled-body pin RED, the nested-block, accept-side and tombstone pins GREEN (the predicted direction: a root .strip() does not open the nested closures). Restore by git checkout HEAD -- <absolute path>: RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.
  • Consumer readings (filter direction: downstream, by file): @objectstack/cli (plugin-commands, osplugin, plugin-sign, plugin-publish, compile-artifact-packages.e2e, init, init-scaffold-authoring-rules, init-created-files-summary.e2e, lint-protocol-range, protocol-version-gap): Test Files 10 passed (10) · Tests 106 passed (106), exit 0 (both heads). @objectstack/objectql (artifact-load-path + registry*, and artifact-packages* before its move to core): Test Files 26 passed (26) · Tests 357 passed (357), exit 0. @objectstack/core (artifact-packages* after the move + plugin-type-closed-set): 3 passed, exit 0. @objectstack/metadataplugin-artifact-packages-attribution.test.ts (the new packages[] door from fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643): 7 passed, exit 0. @objectstack/client typecheck: check:test-typecheck: OK, exit 0. Example apps: example-crm 5 files / 45 tests, example-todo 4 / 106, example-showcase 27 / 372 (after building its plugin closure; the earlier reds were unbuilt dist, not the change), all exit 0; example-multi-package has no test script — its composed config is parsed by the harness and by assembled-package-body.test.ts (NOT MEASURED as a suite).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1efaac767 derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them): check-dev-prereqs (exit 1, wants the whole-repo pnpm build), check-test-completeness (exit 3, needs a saved turbo run test log), check:dual-build-cjs-loads (exit 3, 8 packages unbuilt), check:type-check-debt (exit 3, PREREQUISITE NOT MET — the DEBT closure needs the turbo build). check:skill-examples is green after building client-react: ✅ 256 prose examples type-check across 3 surface(s). check:nul-bytes green; control-character scan of every edited file empty.

8. Reported, not changed

  • objectui (pinned 67dadd602): the Studio package form derives its JSON schema from ManifestSchema via z.toJSONSchema and validates its draft with ManifestSchema.safeParse; create drafts carry only form fields, edit drafts seed from the record's manifest which the server stores verbatim from what the form sent. Read at the pinned sha; NOT MEASURED at runtime.
  • devPlugins refusal is nested under invalid_union (pinned as observed); the flattening limitation the ledger's state-machine row already records.
  • stack.zod.ts:1023 and packages/cli/src/commands/compile.ts:82 still say the manifest is an open object — stale sentences for their owners (stack.zod.ts read-only for this card).
  • Cloud reachability NOT MEASURED (no checkout; code search refused by the repo-bound token).

Out of scope, filed: #14705 (os create template manifest fails the open schema — no id, no type).

Fixes#14192

Generated by Claude Code

🤖 Generated with Claude Code


Generated by Claude Code

…s strict (#14192)
Measurement first (all seven doors that reach ManifestSchema, from src and
through the built dist, identical): an unknown key inside `manifest:` parsed
green and was dropped at every one; `namesapce` left manifest.namespace
undefined with exit 0; the same held one level down for contributes.kind,
contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the
schema and no bookkeeping key reaches any door, so no envelope debt had to be
paid first. Exactly one undeclared key was found at a real door — `main`,
read by `os plugin build` off objectstack.plugin.json and written back into
the compiled manifest — and it is declared rather than refused.
ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema
are strictObject now; retired keys keep their retiredKey() tombstones; one
curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a
measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal
pins (assembled-package-body, cli protocol-version-gap); liveness row for main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ss counts for the declared main key (#14192)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx(via ManifestSchema (symbol, a top-level const object))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via PluginEnginesSchema (symbol, a top-level const object))
  • content/docs/releases/v15.mdx(via ManifestSchema (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52packageMentionDocs.

Which tree this was computed on

This run read content/docs from a7009c9c8b5fca47b53897cebd5c1d0e899342f5 — the merge of head 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e into base 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52, 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 a7009c9c8b5fca47b53897cebd5c1d0e899342f5 && git checkout a7009c9c8b5fca47b53897cebd5c1d0e899342f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e && git checkout -B drift-repro 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 && git merge --no-ff 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e
node scripts/docs-audit/affected-docs.mjs --json 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52

⚠️ 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 20:45
@os-sam
os-sam enabled auto-merge September 2, 2026 20:46
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 4d0d944Sep 2, 2026
43 checks passed
@os-sam
os-sam deleted the claude/issue-14192-manifest-schema-unknown-keys branch September 2, 2026 21:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ManifestSchema strips unknown keys silently — a namespace typo inside manifest: parses green and the namespace comes out undefined

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down) - #14714

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys
Sep 2, 2026
Merged

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down)#14714
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Contract-review tier (clause ②): the accept set of a published spec schema moves. needs:contract-review is parked on this PR and on the card; the seat runs the review. Nothing here is marked ready or armed.

1. Measurement — every door that reaches ManifestSchema (taken FIRST, before any tightening)

Taken on origin/main at bd4aa4e49 (the branch base), from packages/spec/src and once more through the built packages/spec/dist — the two readings are byte-identical (diff of the two harness outputs is empty). Harness: one script feeding each door's own entry schema a legal manifest plus one probe at a time (zzzBogusManifestKey, a transposed namesapce with namespace absent, specVersion, main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shipped manifest: block through a plain .strict() preview, then the real composeStacks(…, { manifest: 'preserve' }) output of examples/app-multi-package through the assembled entry.

doorvalue originundeclared keys observed at the doorverdict beforeverdict after
D1 ObjectStackDefinitionSchema.manifest (stack.zod.ts) — defineStack (parses with strict: true by default), os validate, os compile / os build, the metadata service's artifact doorauthored stack source (objectstack.config.ts, every os init template, the four example apps)none in any shipped block (13/13 blocks carry only declared keys); a bogus key, namesapce, specVersion, main and every bookkeeping key parsed green and were DROPPED; namespace came out undefined for the typostriprefused, unrecognized_keys at path manifest, keys named, rename suggested
D2 stack.devPlugins[]z.union([ManifestSchema, z.string()])authored stack sourcesame as D1 (dropped silently)striprefused; the union reports invalid_union at devPlugins.N with the named unrecognized_keys issue nested in its errors (the state-machine row's known flattening shape — loud, one level deeper)
D3 ArtifactPackageEntrySchema.manifest — the exported authoring-time packages[] entryauthored (no runtime caller; exported and pinned by stack-artifact-packages.test.ts)dropped silentlystriprefused at manifest
D4 ArtifactPackageSchema.manifest = AssembledPackageBodySchema = ManifestSchema.extend(collections)resolveArtifactPackageOrder (@objectstack/core since #14643; called by the ObjectQL load path and, after the merge, by the metadata door), and os compile's parse of the composed stackartifact bundle (objectstack.json); bodies are produced by assemblePackageBody in spec, { ...manifest, ...collections } — the two real bodies of examples/app-multi-package carry 0 keys outside manifest ∪ collectionsdropped silently on the body (zod 4.4.3 .extend() inherits the parent's strip posture)striprefused at manifest.extend() inherits the closed posture AND the error map (measured on zod 4.4.3 before editing), so the load gate refuses an undeclared body key without a second declaration
D5 ManifestSchema.safeParse direct — os plugin build (objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsx at the pinned sha)authored plugin manifest file; a form draft (create) or the package record's manifest (edit)main — read off the raw manifest by build.ts to pick the esbuild entry, and written back into the compiled manifest as dist/index.mjs; authored by three CLI fixtures (osplugin.test.ts, plugin-sign.test.ts, plugin-publish.test.ts). Everything else dropped silentlystriprefused for undeclared keys; main DECLARED (see §4)
D6 InstalledPackageSchema.manifestregistry.validate('package')registry item — but registerItem('package', …) has NO caller: installPackage writes the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys on InstalledPackageSchema itself are stripped by that (unchanged) row schemanone reach itstriprefused inside manifest if ever reached; the row schema is untouched
D6b registry.validate('plugin')ManifestSchema.parseregistry item via SchemaRegistry.registerPlugin — which has no caller in objectql / runtime / core / rest / metadatanone reach itstripunreachable; unchanged semantics (diagnostic-only, registers anyway)
D7 the six wire shapes embedding the manifest — InstallPackageRequestSchema, UpgradePackageRequestSchema, UpgradeSnapshotSchema.previousManifest, PackageInstallRequestSchema, PackageUpgradeRequestSchema, ResolveDependenciesRequestSchemarequest / snapshot declarations; no runtime parse site (package-routes.ts hands req.body.manifest to protocol.installPackage unparsed; the durable sys_packages row is re-registered through registerApp without a parse)nonestriprefused inside manifest if ever parsed; no behaviour change measured anywhere
stored-row check (the api/ row's peelStoredEnvelope question)sys_metadata hydration goes through applyConversionsToStoredItem / peelStoredEnvelope and never through this schema; the package / plugin registry branches above have no callerno packageId / state / published* / package key reaches any doorno envelope debt to pay first; no bookkeeping key taught to the schema
cloudno checkout in this container; the repo-bound token refuses /search/code (403)NOT MEASUREDif cloud parses a published .osplugin manifest through this schema, the compiled manifest it receives carries only declared keys (main, integrity included) by construction of os plugin build

Nested blocks, same doors: contributes.kind (for kinds), contributes.kinds[].glob (for the retired globs) and engines.protocl (for protocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence. engine.objectstak was already refused by the required objectstack sibling.

Example apps (numbers): 4/4 example-app manifest: blocks (app-crm, app-todo, app-showcase, app-multi-package core + orders = 5 blocks) parse green under the strict preview with 0 undeclared keys; so do the 3 os init templates, the create-objectstack blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails is packages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (no id, no type) — filed as #14705, not touched here. Post-close, through the rebuilt dist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declared main), both real assembled bodies parse, the composed multi-package stack parses.

2. The tightening the measurement licensed — branch (a)

ManifestSchema is strictObject({ surface: 'this package manifest', history, guidance }) — the house helper #8687 uses one door up, no second helper. Curated guidance: exactly one entry, specVersion, the retired CLI-advisory axis that os doctor / os lint used to read (the protocol-version-gap pin was the measurement) — it points at engines.protocol. No bookkeeping key was taught to the authoring schema (the trade the api/ row refused), because none reaches a door.

The retired keys (loading, capabilities, configuration, extensions, the ten retired contributes members, kinds[].globs) stay retiredKey() tombstones rather than becoming guidance lines: a tombstone types the key never for tsc and raises its prescription at parse — strictly stronger — and strictObject's acceptsNothing keeps them out of the rename candidates, so capabilitis is refused without being pointed at the dead capabilities (pinned). Every strip pin flipped to a refusal pin, none deleted: assembled-package-body.test.ts ("the body schema is not strict") and packages/cli/src/utils/protocol-version-gap.test.ts ("specVersion is accepted and dropped") now assert the refusal; the retirement pins in manifest.test.ts / plugin-loading-retirement.test.ts keep bearing weight unchanged (their prose about "not strict" updated).

3. Nested blocks — closed under the same measurement, not silently

The card's words are "inside manifest:", and contributes, contributes.kinds[], engine and engines are hand-authored in that same block and reachable ONLY through the doors above (no other user of PluginEnginesSchema in-repo beyond the type-alias pin; cloud unmeasured, stated). All four are strictObject now, each with its own surface name and one-sentence history; PluginEnginesSchema stays exported. Pinned: kind gets Did you mean kinds, protocl gets Did you mean protocol, glob is refused without a rename to the tombstone.

4. main — declared, not curated (the one measured widening)

os plugin build reads rawManifest.main to choose the module esbuild bundles and writes main: 'dist/index.mjs' into the compiled manifest it packs; ADR-0025 §3.2/§3.4 describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a naked strictObject would have refused every plugin that names its entry (the shape the api/ row records as refused). A guidance line telling authors to delete it would describe neither the source file nor the compiled one. So it is declared: main: z.string().optional() with a describe, a liveness row (live, evidence packages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and the plugin-spec.mdx callout regenerated/updated. This is additive (accept-set widening on one optional key), flagged here for the contract review.

5. The ledger

docs/audits/2026-07-unknown-key-strictness-ledger.md: the kernel/ cell is now mixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in the api/ row's form. The counts file did not move (the same six sites, re-postured strip → strict; check:strictness-ledger green — it reads the coarse table for form only). stack.zod.ts was read-only for this card (in-flight #14124): its AssembledPackageBodySchema docblock sentence "NOT strictObject: ManifestSchema is an open object" is now stale, as is the compile.ts comment "ManifestSchema is an open object" — both reported for the seat rather than edited here (one-line follow-ups; the ledger row says so).

6. Changeset level

@objectstack/spec: minor with a **BREAKING** accept-set note — the lockstep launch-window convention (check-changeset-no-major), as #8687 and #5384 did. ADR-0087 disposition: not-required (no-migration-prescription), the #5384 reasoning: no key is retired, only the unknown-key posture moves, an undeclared key was never honoured so no stored or working shape becomes invalid, and there is no single rewrite rule a ledger entry could state; the schema rejection is the upgrade channel. #8687 registered a D3 entry because it ALSO declared onEnable and had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*). check:adr-0087-registration and check:changeset-no-major are green on this body.

7. Verification (all on the final head 1efaac767, after merging origin/main at c616c2cc2)

Under scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.

  • @objectstack/spec full suite on f3946663c (pre-merge, spec src identical to the final head): Test Files 456 passed (456) · Tests 12246 passed (12246) · VERDICT command-exit 0. Re-run on 1efaac767 of src/kernel + assembled-package-body + stack-artifact-packages + stack-top-level-strict + compose-stacks + shared/strict-object + shared/alias-integrity: Test Files 54 passed (54) · Tests 1052 passed (1052) · VERDICT command-exit 0.
  • @objectstack/spec typecheck (tsc + scripts + check:test-typecheck): check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) · VERDICT command-exit 0 (re-run on the final head after the merge, since check-liveness.mts moved). The new test file is inside the tsconfig.test.json program (--listFiles count 1).
  • check:generated: ✓ All 15 generated artifacts are up to date. on the final head (spec rebuilt from the identical src after the ablation restore bumped the mtime — content hash-identical). check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:liveness (manifest 39 classified (live 23, dead 15, live-elsewhere 1)) all green.
  • Reverse verification (ablation, on committed f3946663c):manifest.zod.ts opened on disk by appending .strip() to the ManifestSchema declaration under a trap … EXIT INT TERM with absolute paths; mutation confirmed on disk by marker count 0 → 1 and blob d069b01ba… ≠ HEAD blob 8d138e453…; the pins resolve src by relative import, so no dist rebuild was needed for this leg (stated, not assumed). Result: Test Files 2 failed | 1 passed · Tests 8 failed | 60 passed — the 7 root/door refusal pins and the assembled-body pin RED, the nested-block, accept-side and tombstone pins GREEN (the predicted direction: a root .strip() does not open the nested closures). Restore by git checkout HEAD -- <absolute path>: RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.
  • Consumer readings (filter direction: downstream, by file): @objectstack/cli (plugin-commands, osplugin, plugin-sign, plugin-publish, compile-artifact-packages.e2e, init, init-scaffold-authoring-rules, init-created-files-summary.e2e, lint-protocol-range, protocol-version-gap): Test Files 10 passed (10) · Tests 106 passed (106), exit 0 (both heads). @objectstack/objectql (artifact-load-path + registry*, and artifact-packages* before its move to core): Test Files 26 passed (26) · Tests 357 passed (357), exit 0. @objectstack/core (artifact-packages* after the move + plugin-type-closed-set): 3 passed, exit 0. @objectstack/metadataplugin-artifact-packages-attribution.test.ts (the new packages[] door from fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643): 7 passed, exit 0. @objectstack/client typecheck: check:test-typecheck: OK, exit 0. Example apps: example-crm 5 files / 45 tests, example-todo 4 / 106, example-showcase 27 / 372 (after building its plugin closure; the earlier reds were unbuilt dist, not the change), all exit 0; example-multi-package has no test script — its composed config is parsed by the harness and by assembled-package-body.test.ts (NOT MEASURED as a suite).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1efaac767 derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them): check-dev-prereqs (exit 1, wants the whole-repo pnpm build), check-test-completeness (exit 3, needs a saved turbo run test log), check:dual-build-cjs-loads (exit 3, 8 packages unbuilt), check:type-check-debt (exit 3, PREREQUISITE NOT MET — the DEBT closure needs the turbo build). check:skill-examples is green after building client-react: ✅ 256 prose examples type-check across 3 surface(s). check:nul-bytes green; control-character scan of every edited file empty.

8. Reported, not changed

  • objectui (pinned 67dadd602): the Studio package form derives its JSON schema from ManifestSchema via z.toJSONSchema and validates its draft with ManifestSchema.safeParse; create drafts carry only form fields, edit drafts seed from the record's manifest which the server stores verbatim from what the form sent. Read at the pinned sha; NOT MEASURED at runtime.
  • devPlugins refusal is nested under invalid_union (pinned as observed); the flattening limitation the ledger's state-machine row already records.
  • stack.zod.ts:1023 and packages/cli/src/commands/compile.ts:82 still say the manifest is an open object — stale sentences for their owners (stack.zod.ts read-only for this card).
  • Cloud reachability NOT MEASURED (no checkout; code search refused by the repo-bound token).

Out of scope, filed: #14705 (os create template manifest fails the open schema — no id, no type).

Fixes#14192

Generated by Claude Code

🤖 Generated with Claude Code


Generated by Claude Code

…s strict (#14192)
Measurement first (all seven doors that reach ManifestSchema, from src and
through the built dist, identical): an unknown key inside `manifest:` parsed
green and was dropped at every one; `namesapce` left manifest.namespace
undefined with exit 0; the same held one level down for contributes.kind,
contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the
schema and no bookkeeping key reaches any door, so no envelope debt had to be
paid first. Exactly one undeclared key was found at a real door — `main`,
read by `os plugin build` off objectstack.plugin.json and written back into
the compiled manifest — and it is declared rather than refused.
ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema
are strictObject now; retired keys keep their retiredKey() tombstones; one
curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a
measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal
pins (assembled-package-body, cli protocol-version-gap); liveness row for main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ss counts for the declared main key (#14192)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx(via ManifestSchema (symbol, a top-level const object))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via PluginEnginesSchema (symbol, a top-level const object))
  • content/docs/releases/v15.mdx(via ManifestSchema (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52packageMentionDocs.

Which tree this was computed on

This run read content/docs from a7009c9c8b5fca47b53897cebd5c1d0e899342f5 — the merge of head 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e into base 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52, 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 a7009c9c8b5fca47b53897cebd5c1d0e899342f5 && git checkout a7009c9c8b5fca47b53897cebd5c1d0e899342f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e && git checkout -B drift-repro 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 && git merge --no-ff 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e
node scripts/docs-audit/affected-docs.mjs --json 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52

⚠️ 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 20:45
@os-sam
os-sam enabled auto-merge September 2, 2026 20:46
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 4d0d944Sep 2, 2026
43 checks passed
@os-sam
os-sam deleted the claude/issue-14192-manifest-schema-unknown-keys branch September 2, 2026 21:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ManifestSchema strips unknown keys silently — a namespace typo inside manifest: parses green and the namespace comes out undefined

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down) - #14714

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys
Sep 2, 2026
Merged

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down)#14714
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Contract-review tier (clause ②): the accept set of a published spec schema moves. needs:contract-review is parked on this PR and on the card; the seat runs the review. Nothing here is marked ready or armed.

1. Measurement — every door that reaches ManifestSchema (taken FIRST, before any tightening)

Taken on origin/main at bd4aa4e49 (the branch base), from packages/spec/src and once more through the built packages/spec/dist — the two readings are byte-identical (diff of the two harness outputs is empty). Harness: one script feeding each door's own entry schema a legal manifest plus one probe at a time (zzzBogusManifestKey, a transposed namesapce with namespace absent, specVersion, main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shipped manifest: block through a plain .strict() preview, then the real composeStacks(…, { manifest: 'preserve' }) output of examples/app-multi-package through the assembled entry.

doorvalue originundeclared keys observed at the doorverdict beforeverdict after
D1 ObjectStackDefinitionSchema.manifest (stack.zod.ts) — defineStack (parses with strict: true by default), os validate, os compile / os build, the metadata service's artifact doorauthored stack source (objectstack.config.ts, every os init template, the four example apps)none in any shipped block (13/13 blocks carry only declared keys); a bogus key, namesapce, specVersion, main and every bookkeeping key parsed green and were DROPPED; namespace came out undefined for the typostriprefused, unrecognized_keys at path manifest, keys named, rename suggested
D2 stack.devPlugins[]z.union([ManifestSchema, z.string()])authored stack sourcesame as D1 (dropped silently)striprefused; the union reports invalid_union at devPlugins.N with the named unrecognized_keys issue nested in its errors (the state-machine row's known flattening shape — loud, one level deeper)
D3 ArtifactPackageEntrySchema.manifest — the exported authoring-time packages[] entryauthored (no runtime caller; exported and pinned by stack-artifact-packages.test.ts)dropped silentlystriprefused at manifest
D4 ArtifactPackageSchema.manifest = AssembledPackageBodySchema = ManifestSchema.extend(collections)resolveArtifactPackageOrder (@objectstack/core since #14643; called by the ObjectQL load path and, after the merge, by the metadata door), and os compile's parse of the composed stackartifact bundle (objectstack.json); bodies are produced by assemblePackageBody in spec, { ...manifest, ...collections } — the two real bodies of examples/app-multi-package carry 0 keys outside manifest ∪ collectionsdropped silently on the body (zod 4.4.3 .extend() inherits the parent's strip posture)striprefused at manifest.extend() inherits the closed posture AND the error map (measured on zod 4.4.3 before editing), so the load gate refuses an undeclared body key without a second declaration
D5 ManifestSchema.safeParse direct — os plugin build (objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsx at the pinned sha)authored plugin manifest file; a form draft (create) or the package record's manifest (edit)main — read off the raw manifest by build.ts to pick the esbuild entry, and written back into the compiled manifest as dist/index.mjs; authored by three CLI fixtures (osplugin.test.ts, plugin-sign.test.ts, plugin-publish.test.ts). Everything else dropped silentlystriprefused for undeclared keys; main DECLARED (see §4)
D6 InstalledPackageSchema.manifestregistry.validate('package')registry item — but registerItem('package', …) has NO caller: installPackage writes the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys on InstalledPackageSchema itself are stripped by that (unchanged) row schemanone reach itstriprefused inside manifest if ever reached; the row schema is untouched
D6b registry.validate('plugin')ManifestSchema.parseregistry item via SchemaRegistry.registerPlugin — which has no caller in objectql / runtime / core / rest / metadatanone reach itstripunreachable; unchanged semantics (diagnostic-only, registers anyway)
D7 the six wire shapes embedding the manifest — InstallPackageRequestSchema, UpgradePackageRequestSchema, UpgradeSnapshotSchema.previousManifest, PackageInstallRequestSchema, PackageUpgradeRequestSchema, ResolveDependenciesRequestSchemarequest / snapshot declarations; no runtime parse site (package-routes.ts hands req.body.manifest to protocol.installPackage unparsed; the durable sys_packages row is re-registered through registerApp without a parse)nonestriprefused inside manifest if ever parsed; no behaviour change measured anywhere
stored-row check (the api/ row's peelStoredEnvelope question)sys_metadata hydration goes through applyConversionsToStoredItem / peelStoredEnvelope and never through this schema; the package / plugin registry branches above have no callerno packageId / state / published* / package key reaches any doorno envelope debt to pay first; no bookkeeping key taught to the schema
cloudno checkout in this container; the repo-bound token refuses /search/code (403)NOT MEASUREDif cloud parses a published .osplugin manifest through this schema, the compiled manifest it receives carries only declared keys (main, integrity included) by construction of os plugin build

Nested blocks, same doors: contributes.kind (for kinds), contributes.kinds[].glob (for the retired globs) and engines.protocl (for protocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence. engine.objectstak was already refused by the required objectstack sibling.

Example apps (numbers): 4/4 example-app manifest: blocks (app-crm, app-todo, app-showcase, app-multi-package core + orders = 5 blocks) parse green under the strict preview with 0 undeclared keys; so do the 3 os init templates, the create-objectstack blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails is packages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (no id, no type) — filed as #14705, not touched here. Post-close, through the rebuilt dist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declared main), both real assembled bodies parse, the composed multi-package stack parses.

2. The tightening the measurement licensed — branch (a)

ManifestSchema is strictObject({ surface: 'this package manifest', history, guidance }) — the house helper #8687 uses one door up, no second helper. Curated guidance: exactly one entry, specVersion, the retired CLI-advisory axis that os doctor / os lint used to read (the protocol-version-gap pin was the measurement) — it points at engines.protocol. No bookkeeping key was taught to the authoring schema (the trade the api/ row refused), because none reaches a door.

The retired keys (loading, capabilities, configuration, extensions, the ten retired contributes members, kinds[].globs) stay retiredKey() tombstones rather than becoming guidance lines: a tombstone types the key never for tsc and raises its prescription at parse — strictly stronger — and strictObject's acceptsNothing keeps them out of the rename candidates, so capabilitis is refused without being pointed at the dead capabilities (pinned). Every strip pin flipped to a refusal pin, none deleted: assembled-package-body.test.ts ("the body schema is not strict") and packages/cli/src/utils/protocol-version-gap.test.ts ("specVersion is accepted and dropped") now assert the refusal; the retirement pins in manifest.test.ts / plugin-loading-retirement.test.ts keep bearing weight unchanged (their prose about "not strict" updated).

3. Nested blocks — closed under the same measurement, not silently

The card's words are "inside manifest:", and contributes, contributes.kinds[], engine and engines are hand-authored in that same block and reachable ONLY through the doors above (no other user of PluginEnginesSchema in-repo beyond the type-alias pin; cloud unmeasured, stated). All four are strictObject now, each with its own surface name and one-sentence history; PluginEnginesSchema stays exported. Pinned: kind gets Did you mean kinds, protocl gets Did you mean protocol, glob is refused without a rename to the tombstone.

4. main — declared, not curated (the one measured widening)

os plugin build reads rawManifest.main to choose the module esbuild bundles and writes main: 'dist/index.mjs' into the compiled manifest it packs; ADR-0025 §3.2/§3.4 describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a naked strictObject would have refused every plugin that names its entry (the shape the api/ row records as refused). A guidance line telling authors to delete it would describe neither the source file nor the compiled one. So it is declared: main: z.string().optional() with a describe, a liveness row (live, evidence packages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and the plugin-spec.mdx callout regenerated/updated. This is additive (accept-set widening on one optional key), flagged here for the contract review.

5. The ledger

docs/audits/2026-07-unknown-key-strictness-ledger.md: the kernel/ cell is now mixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in the api/ row's form. The counts file did not move (the same six sites, re-postured strip → strict; check:strictness-ledger green — it reads the coarse table for form only). stack.zod.ts was read-only for this card (in-flight #14124): its AssembledPackageBodySchema docblock sentence "NOT strictObject: ManifestSchema is an open object" is now stale, as is the compile.ts comment "ManifestSchema is an open object" — both reported for the seat rather than edited here (one-line follow-ups; the ledger row says so).

6. Changeset level

@objectstack/spec: minor with a **BREAKING** accept-set note — the lockstep launch-window convention (check-changeset-no-major), as #8687 and #5384 did. ADR-0087 disposition: not-required (no-migration-prescription), the #5384 reasoning: no key is retired, only the unknown-key posture moves, an undeclared key was never honoured so no stored or working shape becomes invalid, and there is no single rewrite rule a ledger entry could state; the schema rejection is the upgrade channel. #8687 registered a D3 entry because it ALSO declared onEnable and had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*). check:adr-0087-registration and check:changeset-no-major are green on this body.

7. Verification (all on the final head 1efaac767, after merging origin/main at c616c2cc2)

Under scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.

  • @objectstack/spec full suite on f3946663c (pre-merge, spec src identical to the final head): Test Files 456 passed (456) · Tests 12246 passed (12246) · VERDICT command-exit 0. Re-run on 1efaac767 of src/kernel + assembled-package-body + stack-artifact-packages + stack-top-level-strict + compose-stacks + shared/strict-object + shared/alias-integrity: Test Files 54 passed (54) · Tests 1052 passed (1052) · VERDICT command-exit 0.
  • @objectstack/spec typecheck (tsc + scripts + check:test-typecheck): check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) · VERDICT command-exit 0 (re-run on the final head after the merge, since check-liveness.mts moved). The new test file is inside the tsconfig.test.json program (--listFiles count 1).
  • check:generated: ✓ All 15 generated artifacts are up to date. on the final head (spec rebuilt from the identical src after the ablation restore bumped the mtime — content hash-identical). check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:liveness (manifest 39 classified (live 23, dead 15, live-elsewhere 1)) all green.
  • Reverse verification (ablation, on committed f3946663c):manifest.zod.ts opened on disk by appending .strip() to the ManifestSchema declaration under a trap … EXIT INT TERM with absolute paths; mutation confirmed on disk by marker count 0 → 1 and blob d069b01ba… ≠ HEAD blob 8d138e453…; the pins resolve src by relative import, so no dist rebuild was needed for this leg (stated, not assumed). Result: Test Files 2 failed | 1 passed · Tests 8 failed | 60 passed — the 7 root/door refusal pins and the assembled-body pin RED, the nested-block, accept-side and tombstone pins GREEN (the predicted direction: a root .strip() does not open the nested closures). Restore by git checkout HEAD -- <absolute path>: RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.
  • Consumer readings (filter direction: downstream, by file): @objectstack/cli (plugin-commands, osplugin, plugin-sign, plugin-publish, compile-artifact-packages.e2e, init, init-scaffold-authoring-rules, init-created-files-summary.e2e, lint-protocol-range, protocol-version-gap): Test Files 10 passed (10) · Tests 106 passed (106), exit 0 (both heads). @objectstack/objectql (artifact-load-path + registry*, and artifact-packages* before its move to core): Test Files 26 passed (26) · Tests 357 passed (357), exit 0. @objectstack/core (artifact-packages* after the move + plugin-type-closed-set): 3 passed, exit 0. @objectstack/metadataplugin-artifact-packages-attribution.test.ts (the new packages[] door from fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643): 7 passed, exit 0. @objectstack/client typecheck: check:test-typecheck: OK, exit 0. Example apps: example-crm 5 files / 45 tests, example-todo 4 / 106, example-showcase 27 / 372 (after building its plugin closure; the earlier reds were unbuilt dist, not the change), all exit 0; example-multi-package has no test script — its composed config is parsed by the harness and by assembled-package-body.test.ts (NOT MEASURED as a suite).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1efaac767 derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them): check-dev-prereqs (exit 1, wants the whole-repo pnpm build), check-test-completeness (exit 3, needs a saved turbo run test log), check:dual-build-cjs-loads (exit 3, 8 packages unbuilt), check:type-check-debt (exit 3, PREREQUISITE NOT MET — the DEBT closure needs the turbo build). check:skill-examples is green after building client-react: ✅ 256 prose examples type-check across 3 surface(s). check:nul-bytes green; control-character scan of every edited file empty.

8. Reported, not changed

  • objectui (pinned 67dadd602): the Studio package form derives its JSON schema from ManifestSchema via z.toJSONSchema and validates its draft with ManifestSchema.safeParse; create drafts carry only form fields, edit drafts seed from the record's manifest which the server stores verbatim from what the form sent. Read at the pinned sha; NOT MEASURED at runtime.
  • devPlugins refusal is nested under invalid_union (pinned as observed); the flattening limitation the ledger's state-machine row already records.
  • stack.zod.ts:1023 and packages/cli/src/commands/compile.ts:82 still say the manifest is an open object — stale sentences for their owners (stack.zod.ts read-only for this card).
  • Cloud reachability NOT MEASURED (no checkout; code search refused by the repo-bound token).

Out of scope, filed: #14705 (os create template manifest fails the open schema — no id, no type).

Fixes#14192

Generated by Claude Code

🤖 Generated with Claude Code


Generated by Claude Code

…s strict (#14192)
Measurement first (all seven doors that reach ManifestSchema, from src and
through the built dist, identical): an unknown key inside `manifest:` parsed
green and was dropped at every one; `namesapce` left manifest.namespace
undefined with exit 0; the same held one level down for contributes.kind,
contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the
schema and no bookkeeping key reaches any door, so no envelope debt had to be
paid first. Exactly one undeclared key was found at a real door — `main`,
read by `os plugin build` off objectstack.plugin.json and written back into
the compiled manifest — and it is declared rather than refused.
ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema
are strictObject now; retired keys keep their retiredKey() tombstones; one
curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a
measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal
pins (assembled-package-body, cli protocol-version-gap); liveness row for main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ss counts for the declared main key (#14192)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx(via ManifestSchema (symbol, a top-level const object))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via PluginEnginesSchema (symbol, a top-level const object))
  • content/docs/releases/v15.mdx(via ManifestSchema (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52packageMentionDocs.

Which tree this was computed on

This run read content/docs from a7009c9c8b5fca47b53897cebd5c1d0e899342f5 — the merge of head 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e into base 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52, 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 a7009c9c8b5fca47b53897cebd5c1d0e899342f5 && git checkout a7009c9c8b5fca47b53897cebd5c1d0e899342f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e && git checkout -B drift-repro 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 && git merge --no-ff 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e
node scripts/docs-audit/affected-docs.mjs --json 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52

⚠️ 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 20:45
@os-sam
os-sam enabled auto-merge September 2, 2026 20:46
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 4d0d944Sep 2, 2026
43 checks passed
@os-sam
os-sam deleted the claude/issue-14192-manifest-schema-unknown-keys branch September 2, 2026 21:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ManifestSchema strips unknown keys silently — a namespace typo inside manifest: parses green and the namespace comes out undefined

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down) - #14714

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys
Sep 2, 2026
Merged

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down)#14714
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Contract-review tier (clause ②): the accept set of a published spec schema moves. needs:contract-review is parked on this PR and on the card; the seat runs the review. Nothing here is marked ready or armed.

1. Measurement — every door that reaches ManifestSchema (taken FIRST, before any tightening)

Taken on origin/main at bd4aa4e49 (the branch base), from packages/spec/src and once more through the built packages/spec/dist — the two readings are byte-identical (diff of the two harness outputs is empty). Harness: one script feeding each door's own entry schema a legal manifest plus one probe at a time (zzzBogusManifestKey, a transposed namesapce with namespace absent, specVersion, main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shipped manifest: block through a plain .strict() preview, then the real composeStacks(…, { manifest: 'preserve' }) output of examples/app-multi-package through the assembled entry.

doorvalue originundeclared keys observed at the doorverdict beforeverdict after
D1 ObjectStackDefinitionSchema.manifest (stack.zod.ts) — defineStack (parses with strict: true by default), os validate, os compile / os build, the metadata service's artifact doorauthored stack source (objectstack.config.ts, every os init template, the four example apps)none in any shipped block (13/13 blocks carry only declared keys); a bogus key, namesapce, specVersion, main and every bookkeeping key parsed green and were DROPPED; namespace came out undefined for the typostriprefused, unrecognized_keys at path manifest, keys named, rename suggested
D2 stack.devPlugins[]z.union([ManifestSchema, z.string()])authored stack sourcesame as D1 (dropped silently)striprefused; the union reports invalid_union at devPlugins.N with the named unrecognized_keys issue nested in its errors (the state-machine row's known flattening shape — loud, one level deeper)
D3 ArtifactPackageEntrySchema.manifest — the exported authoring-time packages[] entryauthored (no runtime caller; exported and pinned by stack-artifact-packages.test.ts)dropped silentlystriprefused at manifest
D4 ArtifactPackageSchema.manifest = AssembledPackageBodySchema = ManifestSchema.extend(collections)resolveArtifactPackageOrder (@objectstack/core since #14643; called by the ObjectQL load path and, after the merge, by the metadata door), and os compile's parse of the composed stackartifact bundle (objectstack.json); bodies are produced by assemblePackageBody in spec, { ...manifest, ...collections } — the two real bodies of examples/app-multi-package carry 0 keys outside manifest ∪ collectionsdropped silently on the body (zod 4.4.3 .extend() inherits the parent's strip posture)striprefused at manifest.extend() inherits the closed posture AND the error map (measured on zod 4.4.3 before editing), so the load gate refuses an undeclared body key without a second declaration
D5 ManifestSchema.safeParse direct — os plugin build (objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsx at the pinned sha)authored plugin manifest file; a form draft (create) or the package record's manifest (edit)main — read off the raw manifest by build.ts to pick the esbuild entry, and written back into the compiled manifest as dist/index.mjs; authored by three CLI fixtures (osplugin.test.ts, plugin-sign.test.ts, plugin-publish.test.ts). Everything else dropped silentlystriprefused for undeclared keys; main DECLARED (see §4)
D6 InstalledPackageSchema.manifestregistry.validate('package')registry item — but registerItem('package', …) has NO caller: installPackage writes the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys on InstalledPackageSchema itself are stripped by that (unchanged) row schemanone reach itstriprefused inside manifest if ever reached; the row schema is untouched
D6b registry.validate('plugin')ManifestSchema.parseregistry item via SchemaRegistry.registerPlugin — which has no caller in objectql / runtime / core / rest / metadatanone reach itstripunreachable; unchanged semantics (diagnostic-only, registers anyway)
D7 the six wire shapes embedding the manifest — InstallPackageRequestSchema, UpgradePackageRequestSchema, UpgradeSnapshotSchema.previousManifest, PackageInstallRequestSchema, PackageUpgradeRequestSchema, ResolveDependenciesRequestSchemarequest / snapshot declarations; no runtime parse site (package-routes.ts hands req.body.manifest to protocol.installPackage unparsed; the durable sys_packages row is re-registered through registerApp without a parse)nonestriprefused inside manifest if ever parsed; no behaviour change measured anywhere
stored-row check (the api/ row's peelStoredEnvelope question)sys_metadata hydration goes through applyConversionsToStoredItem / peelStoredEnvelope and never through this schema; the package / plugin registry branches above have no callerno packageId / state / published* / package key reaches any doorno envelope debt to pay first; no bookkeeping key taught to the schema
cloudno checkout in this container; the repo-bound token refuses /search/code (403)NOT MEASUREDif cloud parses a published .osplugin manifest through this schema, the compiled manifest it receives carries only declared keys (main, integrity included) by construction of os plugin build

Nested blocks, same doors: contributes.kind (for kinds), contributes.kinds[].glob (for the retired globs) and engines.protocl (for protocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence. engine.objectstak was already refused by the required objectstack sibling.

Example apps (numbers): 4/4 example-app manifest: blocks (app-crm, app-todo, app-showcase, app-multi-package core + orders = 5 blocks) parse green under the strict preview with 0 undeclared keys; so do the 3 os init templates, the create-objectstack blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails is packages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (no id, no type) — filed as #14705, not touched here. Post-close, through the rebuilt dist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declared main), both real assembled bodies parse, the composed multi-package stack parses.

2. The tightening the measurement licensed — branch (a)

ManifestSchema is strictObject({ surface: 'this package manifest', history, guidance }) — the house helper #8687 uses one door up, no second helper. Curated guidance: exactly one entry, specVersion, the retired CLI-advisory axis that os doctor / os lint used to read (the protocol-version-gap pin was the measurement) — it points at engines.protocol. No bookkeeping key was taught to the authoring schema (the trade the api/ row refused), because none reaches a door.

The retired keys (loading, capabilities, configuration, extensions, the ten retired contributes members, kinds[].globs) stay retiredKey() tombstones rather than becoming guidance lines: a tombstone types the key never for tsc and raises its prescription at parse — strictly stronger — and strictObject's acceptsNothing keeps them out of the rename candidates, so capabilitis is refused without being pointed at the dead capabilities (pinned). Every strip pin flipped to a refusal pin, none deleted: assembled-package-body.test.ts ("the body schema is not strict") and packages/cli/src/utils/protocol-version-gap.test.ts ("specVersion is accepted and dropped") now assert the refusal; the retirement pins in manifest.test.ts / plugin-loading-retirement.test.ts keep bearing weight unchanged (their prose about "not strict" updated).

3. Nested blocks — closed under the same measurement, not silently

The card's words are "inside manifest:", and contributes, contributes.kinds[], engine and engines are hand-authored in that same block and reachable ONLY through the doors above (no other user of PluginEnginesSchema in-repo beyond the type-alias pin; cloud unmeasured, stated). All four are strictObject now, each with its own surface name and one-sentence history; PluginEnginesSchema stays exported. Pinned: kind gets Did you mean kinds, protocl gets Did you mean protocol, glob is refused without a rename to the tombstone.

4. main — declared, not curated (the one measured widening)

os plugin build reads rawManifest.main to choose the module esbuild bundles and writes main: 'dist/index.mjs' into the compiled manifest it packs; ADR-0025 §3.2/§3.4 describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a naked strictObject would have refused every plugin that names its entry (the shape the api/ row records as refused). A guidance line telling authors to delete it would describe neither the source file nor the compiled one. So it is declared: main: z.string().optional() with a describe, a liveness row (live, evidence packages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and the plugin-spec.mdx callout regenerated/updated. This is additive (accept-set widening on one optional key), flagged here for the contract review.

5. The ledger

docs/audits/2026-07-unknown-key-strictness-ledger.md: the kernel/ cell is now mixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in the api/ row's form. The counts file did not move (the same six sites, re-postured strip → strict; check:strictness-ledger green — it reads the coarse table for form only). stack.zod.ts was read-only for this card (in-flight #14124): its AssembledPackageBodySchema docblock sentence "NOT strictObject: ManifestSchema is an open object" is now stale, as is the compile.ts comment "ManifestSchema is an open object" — both reported for the seat rather than edited here (one-line follow-ups; the ledger row says so).

6. Changeset level

@objectstack/spec: minor with a **BREAKING** accept-set note — the lockstep launch-window convention (check-changeset-no-major), as #8687 and #5384 did. ADR-0087 disposition: not-required (no-migration-prescription), the #5384 reasoning: no key is retired, only the unknown-key posture moves, an undeclared key was never honoured so no stored or working shape becomes invalid, and there is no single rewrite rule a ledger entry could state; the schema rejection is the upgrade channel. #8687 registered a D3 entry because it ALSO declared onEnable and had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*). check:adr-0087-registration and check:changeset-no-major are green on this body.

7. Verification (all on the final head 1efaac767, after merging origin/main at c616c2cc2)

Under scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.

  • @objectstack/spec full suite on f3946663c (pre-merge, spec src identical to the final head): Test Files 456 passed (456) · Tests 12246 passed (12246) · VERDICT command-exit 0. Re-run on 1efaac767 of src/kernel + assembled-package-body + stack-artifact-packages + stack-top-level-strict + compose-stacks + shared/strict-object + shared/alias-integrity: Test Files 54 passed (54) · Tests 1052 passed (1052) · VERDICT command-exit 0.
  • @objectstack/spec typecheck (tsc + scripts + check:test-typecheck): check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) · VERDICT command-exit 0 (re-run on the final head after the merge, since check-liveness.mts moved). The new test file is inside the tsconfig.test.json program (--listFiles count 1).
  • check:generated: ✓ All 15 generated artifacts are up to date. on the final head (spec rebuilt from the identical src after the ablation restore bumped the mtime — content hash-identical). check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:liveness (manifest 39 classified (live 23, dead 15, live-elsewhere 1)) all green.
  • Reverse verification (ablation, on committed f3946663c):manifest.zod.ts opened on disk by appending .strip() to the ManifestSchema declaration under a trap … EXIT INT TERM with absolute paths; mutation confirmed on disk by marker count 0 → 1 and blob d069b01ba… ≠ HEAD blob 8d138e453…; the pins resolve src by relative import, so no dist rebuild was needed for this leg (stated, not assumed). Result: Test Files 2 failed | 1 passed · Tests 8 failed | 60 passed — the 7 root/door refusal pins and the assembled-body pin RED, the nested-block, accept-side and tombstone pins GREEN (the predicted direction: a root .strip() does not open the nested closures). Restore by git checkout HEAD -- <absolute path>: RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.
  • Consumer readings (filter direction: downstream, by file): @objectstack/cli (plugin-commands, osplugin, plugin-sign, plugin-publish, compile-artifact-packages.e2e, init, init-scaffold-authoring-rules, init-created-files-summary.e2e, lint-protocol-range, protocol-version-gap): Test Files 10 passed (10) · Tests 106 passed (106), exit 0 (both heads). @objectstack/objectql (artifact-load-path + registry*, and artifact-packages* before its move to core): Test Files 26 passed (26) · Tests 357 passed (357), exit 0. @objectstack/core (artifact-packages* after the move + plugin-type-closed-set): 3 passed, exit 0. @objectstack/metadataplugin-artifact-packages-attribution.test.ts (the new packages[] door from fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643): 7 passed, exit 0. @objectstack/client typecheck: check:test-typecheck: OK, exit 0. Example apps: example-crm 5 files / 45 tests, example-todo 4 / 106, example-showcase 27 / 372 (after building its plugin closure; the earlier reds were unbuilt dist, not the change), all exit 0; example-multi-package has no test script — its composed config is parsed by the harness and by assembled-package-body.test.ts (NOT MEASURED as a suite).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1efaac767 derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them): check-dev-prereqs (exit 1, wants the whole-repo pnpm build), check-test-completeness (exit 3, needs a saved turbo run test log), check:dual-build-cjs-loads (exit 3, 8 packages unbuilt), check:type-check-debt (exit 3, PREREQUISITE NOT MET — the DEBT closure needs the turbo build). check:skill-examples is green after building client-react: ✅ 256 prose examples type-check across 3 surface(s). check:nul-bytes green; control-character scan of every edited file empty.

8. Reported, not changed

  • objectui (pinned 67dadd602): the Studio package form derives its JSON schema from ManifestSchema via z.toJSONSchema and validates its draft with ManifestSchema.safeParse; create drafts carry only form fields, edit drafts seed from the record's manifest which the server stores verbatim from what the form sent. Read at the pinned sha; NOT MEASURED at runtime.
  • devPlugins refusal is nested under invalid_union (pinned as observed); the flattening limitation the ledger's state-machine row already records.
  • stack.zod.ts:1023 and packages/cli/src/commands/compile.ts:82 still say the manifest is an open object — stale sentences for their owners (stack.zod.ts read-only for this card).
  • Cloud reachability NOT MEASURED (no checkout; code search refused by the repo-bound token).

Out of scope, filed: #14705 (os create template manifest fails the open schema — no id, no type).

Fixes#14192

Generated by Claude Code

🤖 Generated with Claude Code


Generated by Claude Code

…s strict (#14192)
Measurement first (all seven doors that reach ManifestSchema, from src and
through the built dist, identical): an unknown key inside `manifest:` parsed
green and was dropped at every one; `namesapce` left manifest.namespace
undefined with exit 0; the same held one level down for contributes.kind,
contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the
schema and no bookkeeping key reaches any door, so no envelope debt had to be
paid first. Exactly one undeclared key was found at a real door — `main`,
read by `os plugin build` off objectstack.plugin.json and written back into
the compiled manifest — and it is declared rather than refused.
ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema
are strictObject now; retired keys keep their retiredKey() tombstones; one
curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a
measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal
pins (assembled-package-body, cli protocol-version-gap); liveness row for main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ss counts for the declared main key (#14192)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx(via ManifestSchema (symbol, a top-level const object))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via PluginEnginesSchema (symbol, a top-level const object))
  • content/docs/releases/v15.mdx(via ManifestSchema (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52packageMentionDocs.

Which tree this was computed on

This run read content/docs from a7009c9c8b5fca47b53897cebd5c1d0e899342f5 — the merge of head 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e into base 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52, 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 a7009c9c8b5fca47b53897cebd5c1d0e899342f5 && git checkout a7009c9c8b5fca47b53897cebd5c1d0e899342f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e && git checkout -B drift-repro 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 && git merge --no-ff 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e
node scripts/docs-audit/affected-docs.mjs --json 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52

⚠️ 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 20:45
@os-sam
os-sam enabled auto-merge September 2, 2026 20:46
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 4d0d944Sep 2, 2026
43 checks passed
@os-sam
os-sam deleted the claude/issue-14192-manifest-schema-unknown-keys branch September 2, 2026 21:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ManifestSchema strips unknown keys silently — a namespace typo inside manifest: parses green and the namespace comes out undefined

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down) - #14714

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys
Sep 2, 2026
Merged

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down)#14714
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Contract-review tier (clause ②): the accept set of a published spec schema moves. needs:contract-review is parked on this PR and on the card; the seat runs the review. Nothing here is marked ready or armed.

1. Measurement — every door that reaches ManifestSchema (taken FIRST, before any tightening)

Taken on origin/main at bd4aa4e49 (the branch base), from packages/spec/src and once more through the built packages/spec/dist — the two readings are byte-identical (diff of the two harness outputs is empty). Harness: one script feeding each door's own entry schema a legal manifest plus one probe at a time (zzzBogusManifestKey, a transposed namesapce with namespace absent, specVersion, main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shipped manifest: block through a plain .strict() preview, then the real composeStacks(…, { manifest: 'preserve' }) output of examples/app-multi-package through the assembled entry.

doorvalue originundeclared keys observed at the doorverdict beforeverdict after
D1 ObjectStackDefinitionSchema.manifest (stack.zod.ts) — defineStack (parses with strict: true by default), os validate, os compile / os build, the metadata service's artifact doorauthored stack source (objectstack.config.ts, every os init template, the four example apps)none in any shipped block (13/13 blocks carry only declared keys); a bogus key, namesapce, specVersion, main and every bookkeeping key parsed green and were DROPPED; namespace came out undefined for the typostriprefused, unrecognized_keys at path manifest, keys named, rename suggested
D2 stack.devPlugins[]z.union([ManifestSchema, z.string()])authored stack sourcesame as D1 (dropped silently)striprefused; the union reports invalid_union at devPlugins.N with the named unrecognized_keys issue nested in its errors (the state-machine row's known flattening shape — loud, one level deeper)
D3 ArtifactPackageEntrySchema.manifest — the exported authoring-time packages[] entryauthored (no runtime caller; exported and pinned by stack-artifact-packages.test.ts)dropped silentlystriprefused at manifest
D4 ArtifactPackageSchema.manifest = AssembledPackageBodySchema = ManifestSchema.extend(collections)resolveArtifactPackageOrder (@objectstack/core since #14643; called by the ObjectQL load path and, after the merge, by the metadata door), and os compile's parse of the composed stackartifact bundle (objectstack.json); bodies are produced by assemblePackageBody in spec, { ...manifest, ...collections } — the two real bodies of examples/app-multi-package carry 0 keys outside manifest ∪ collectionsdropped silently on the body (zod 4.4.3 .extend() inherits the parent's strip posture)striprefused at manifest.extend() inherits the closed posture AND the error map (measured on zod 4.4.3 before editing), so the load gate refuses an undeclared body key without a second declaration
D5 ManifestSchema.safeParse direct — os plugin build (objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsx at the pinned sha)authored plugin manifest file; a form draft (create) or the package record's manifest (edit)main — read off the raw manifest by build.ts to pick the esbuild entry, and written back into the compiled manifest as dist/index.mjs; authored by three CLI fixtures (osplugin.test.ts, plugin-sign.test.ts, plugin-publish.test.ts). Everything else dropped silentlystriprefused for undeclared keys; main DECLARED (see §4)
D6 InstalledPackageSchema.manifestregistry.validate('package')registry item — but registerItem('package', …) has NO caller: installPackage writes the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys on InstalledPackageSchema itself are stripped by that (unchanged) row schemanone reach itstriprefused inside manifest if ever reached; the row schema is untouched
D6b registry.validate('plugin')ManifestSchema.parseregistry item via SchemaRegistry.registerPlugin — which has no caller in objectql / runtime / core / rest / metadatanone reach itstripunreachable; unchanged semantics (diagnostic-only, registers anyway)
D7 the six wire shapes embedding the manifest — InstallPackageRequestSchema, UpgradePackageRequestSchema, UpgradeSnapshotSchema.previousManifest, PackageInstallRequestSchema, PackageUpgradeRequestSchema, ResolveDependenciesRequestSchemarequest / snapshot declarations; no runtime parse site (package-routes.ts hands req.body.manifest to protocol.installPackage unparsed; the durable sys_packages row is re-registered through registerApp without a parse)nonestriprefused inside manifest if ever parsed; no behaviour change measured anywhere
stored-row check (the api/ row's peelStoredEnvelope question)sys_metadata hydration goes through applyConversionsToStoredItem / peelStoredEnvelope and never through this schema; the package / plugin registry branches above have no callerno packageId / state / published* / package key reaches any doorno envelope debt to pay first; no bookkeeping key taught to the schema
cloudno checkout in this container; the repo-bound token refuses /search/code (403)NOT MEASUREDif cloud parses a published .osplugin manifest through this schema, the compiled manifest it receives carries only declared keys (main, integrity included) by construction of os plugin build

Nested blocks, same doors: contributes.kind (for kinds), contributes.kinds[].glob (for the retired globs) and engines.protocl (for protocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence. engine.objectstak was already refused by the required objectstack sibling.

Example apps (numbers): 4/4 example-app manifest: blocks (app-crm, app-todo, app-showcase, app-multi-package core + orders = 5 blocks) parse green under the strict preview with 0 undeclared keys; so do the 3 os init templates, the create-objectstack blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails is packages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (no id, no type) — filed as #14705, not touched here. Post-close, through the rebuilt dist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declared main), both real assembled bodies parse, the composed multi-package stack parses.

2. The tightening the measurement licensed — branch (a)

ManifestSchema is strictObject({ surface: 'this package manifest', history, guidance }) — the house helper #8687 uses one door up, no second helper. Curated guidance: exactly one entry, specVersion, the retired CLI-advisory axis that os doctor / os lint used to read (the protocol-version-gap pin was the measurement) — it points at engines.protocol. No bookkeeping key was taught to the authoring schema (the trade the api/ row refused), because none reaches a door.

The retired keys (loading, capabilities, configuration, extensions, the ten retired contributes members, kinds[].globs) stay retiredKey() tombstones rather than becoming guidance lines: a tombstone types the key never for tsc and raises its prescription at parse — strictly stronger — and strictObject's acceptsNothing keeps them out of the rename candidates, so capabilitis is refused without being pointed at the dead capabilities (pinned). Every strip pin flipped to a refusal pin, none deleted: assembled-package-body.test.ts ("the body schema is not strict") and packages/cli/src/utils/protocol-version-gap.test.ts ("specVersion is accepted and dropped") now assert the refusal; the retirement pins in manifest.test.ts / plugin-loading-retirement.test.ts keep bearing weight unchanged (their prose about "not strict" updated).

3. Nested blocks — closed under the same measurement, not silently

The card's words are "inside manifest:", and contributes, contributes.kinds[], engine and engines are hand-authored in that same block and reachable ONLY through the doors above (no other user of PluginEnginesSchema in-repo beyond the type-alias pin; cloud unmeasured, stated). All four are strictObject now, each with its own surface name and one-sentence history; PluginEnginesSchema stays exported. Pinned: kind gets Did you mean kinds, protocl gets Did you mean protocol, glob is refused without a rename to the tombstone.

4. main — declared, not curated (the one measured widening)

os plugin build reads rawManifest.main to choose the module esbuild bundles and writes main: 'dist/index.mjs' into the compiled manifest it packs; ADR-0025 §3.2/§3.4 describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a naked strictObject would have refused every plugin that names its entry (the shape the api/ row records as refused). A guidance line telling authors to delete it would describe neither the source file nor the compiled one. So it is declared: main: z.string().optional() with a describe, a liveness row (live, evidence packages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and the plugin-spec.mdx callout regenerated/updated. This is additive (accept-set widening on one optional key), flagged here for the contract review.

5. The ledger

docs/audits/2026-07-unknown-key-strictness-ledger.md: the kernel/ cell is now mixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in the api/ row's form. The counts file did not move (the same six sites, re-postured strip → strict; check:strictness-ledger green — it reads the coarse table for form only). stack.zod.ts was read-only for this card (in-flight #14124): its AssembledPackageBodySchema docblock sentence "NOT strictObject: ManifestSchema is an open object" is now stale, as is the compile.ts comment "ManifestSchema is an open object" — both reported for the seat rather than edited here (one-line follow-ups; the ledger row says so).

6. Changeset level

@objectstack/spec: minor with a **BREAKING** accept-set note — the lockstep launch-window convention (check-changeset-no-major), as #8687 and #5384 did. ADR-0087 disposition: not-required (no-migration-prescription), the #5384 reasoning: no key is retired, only the unknown-key posture moves, an undeclared key was never honoured so no stored or working shape becomes invalid, and there is no single rewrite rule a ledger entry could state; the schema rejection is the upgrade channel. #8687 registered a D3 entry because it ALSO declared onEnable and had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*). check:adr-0087-registration and check:changeset-no-major are green on this body.

7. Verification (all on the final head 1efaac767, after merging origin/main at c616c2cc2)

Under scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.

  • @objectstack/spec full suite on f3946663c (pre-merge, spec src identical to the final head): Test Files 456 passed (456) · Tests 12246 passed (12246) · VERDICT command-exit 0. Re-run on 1efaac767 of src/kernel + assembled-package-body + stack-artifact-packages + stack-top-level-strict + compose-stacks + shared/strict-object + shared/alias-integrity: Test Files 54 passed (54) · Tests 1052 passed (1052) · VERDICT command-exit 0.
  • @objectstack/spec typecheck (tsc + scripts + check:test-typecheck): check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) · VERDICT command-exit 0 (re-run on the final head after the merge, since check-liveness.mts moved). The new test file is inside the tsconfig.test.json program (--listFiles count 1).
  • check:generated: ✓ All 15 generated artifacts are up to date. on the final head (spec rebuilt from the identical src after the ablation restore bumped the mtime — content hash-identical). check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:liveness (manifest 39 classified (live 23, dead 15, live-elsewhere 1)) all green.
  • Reverse verification (ablation, on committed f3946663c):manifest.zod.ts opened on disk by appending .strip() to the ManifestSchema declaration under a trap … EXIT INT TERM with absolute paths; mutation confirmed on disk by marker count 0 → 1 and blob d069b01ba… ≠ HEAD blob 8d138e453…; the pins resolve src by relative import, so no dist rebuild was needed for this leg (stated, not assumed). Result: Test Files 2 failed | 1 passed · Tests 8 failed | 60 passed — the 7 root/door refusal pins and the assembled-body pin RED, the nested-block, accept-side and tombstone pins GREEN (the predicted direction: a root .strip() does not open the nested closures). Restore by git checkout HEAD -- <absolute path>: RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.
  • Consumer readings (filter direction: downstream, by file): @objectstack/cli (plugin-commands, osplugin, plugin-sign, plugin-publish, compile-artifact-packages.e2e, init, init-scaffold-authoring-rules, init-created-files-summary.e2e, lint-protocol-range, protocol-version-gap): Test Files 10 passed (10) · Tests 106 passed (106), exit 0 (both heads). @objectstack/objectql (artifact-load-path + registry*, and artifact-packages* before its move to core): Test Files 26 passed (26) · Tests 357 passed (357), exit 0. @objectstack/core (artifact-packages* after the move + plugin-type-closed-set): 3 passed, exit 0. @objectstack/metadataplugin-artifact-packages-attribution.test.ts (the new packages[] door from fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643): 7 passed, exit 0. @objectstack/client typecheck: check:test-typecheck: OK, exit 0. Example apps: example-crm 5 files / 45 tests, example-todo 4 / 106, example-showcase 27 / 372 (after building its plugin closure; the earlier reds were unbuilt dist, not the change), all exit 0; example-multi-package has no test script — its composed config is parsed by the harness and by assembled-package-body.test.ts (NOT MEASURED as a suite).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1efaac767 derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them): check-dev-prereqs (exit 1, wants the whole-repo pnpm build), check-test-completeness (exit 3, needs a saved turbo run test log), check:dual-build-cjs-loads (exit 3, 8 packages unbuilt), check:type-check-debt (exit 3, PREREQUISITE NOT MET — the DEBT closure needs the turbo build). check:skill-examples is green after building client-react: ✅ 256 prose examples type-check across 3 surface(s). check:nul-bytes green; control-character scan of every edited file empty.

8. Reported, not changed

  • objectui (pinned 67dadd602): the Studio package form derives its JSON schema from ManifestSchema via z.toJSONSchema and validates its draft with ManifestSchema.safeParse; create drafts carry only form fields, edit drafts seed from the record's manifest which the server stores verbatim from what the form sent. Read at the pinned sha; NOT MEASURED at runtime.
  • devPlugins refusal is nested under invalid_union (pinned as observed); the flattening limitation the ledger's state-machine row already records.
  • stack.zod.ts:1023 and packages/cli/src/commands/compile.ts:82 still say the manifest is an open object — stale sentences for their owners (stack.zod.ts read-only for this card).
  • Cloud reachability NOT MEASURED (no checkout; code search refused by the repo-bound token).

Out of scope, filed: #14705 (os create template manifest fails the open schema — no id, no type).

Fixes#14192

Generated by Claude Code

🤖 Generated with Claude Code


Generated by Claude Code

…s strict (#14192)
Measurement first (all seven doors that reach ManifestSchema, from src and
through the built dist, identical): an unknown key inside `manifest:` parsed
green and was dropped at every one; `namesapce` left manifest.namespace
undefined with exit 0; the same held one level down for contributes.kind,
contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the
schema and no bookkeeping key reaches any door, so no envelope debt had to be
paid first. Exactly one undeclared key was found at a real door — `main`,
read by `os plugin build` off objectstack.plugin.json and written back into
the compiled manifest — and it is declared rather than refused.
ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema
are strictObject now; retired keys keep their retiredKey() tombstones; one
curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a
measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal
pins (assembled-package-body, cli protocol-version-gap); liveness row for main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ss counts for the declared main key (#14192)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx(via ManifestSchema (symbol, a top-level const object))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via PluginEnginesSchema (symbol, a top-level const object))
  • content/docs/releases/v15.mdx(via ManifestSchema (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52packageMentionDocs.

Which tree this was computed on

This run read content/docs from a7009c9c8b5fca47b53897cebd5c1d0e899342f5 — the merge of head 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e into base 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52, 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 a7009c9c8b5fca47b53897cebd5c1d0e899342f5 && git checkout a7009c9c8b5fca47b53897cebd5c1d0e899342f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e && git checkout -B drift-repro 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 && git merge --no-ff 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e
node scripts/docs-audit/affected-docs.mjs --json 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52

⚠️ 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 20:45
@os-sam
os-sam enabled auto-merge September 2, 2026 20:46
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 4d0d944Sep 2, 2026
43 checks passed
@os-sam
os-sam deleted the claude/issue-14192-manifest-schema-unknown-keys branch September 2, 2026 21:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ManifestSchema strips unknown keys silently — a namespace typo inside manifest: parses green and the namespace comes out undefined

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down) - #14714

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys
Sep 2, 2026
Merged

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down)#14714
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Contract-review tier (clause ②): the accept set of a published spec schema moves. needs:contract-review is parked on this PR and on the card; the seat runs the review. Nothing here is marked ready or armed.

1. Measurement — every door that reaches ManifestSchema (taken FIRST, before any tightening)

Taken on origin/main at bd4aa4e49 (the branch base), from packages/spec/src and once more through the built packages/spec/dist — the two readings are byte-identical (diff of the two harness outputs is empty). Harness: one script feeding each door's own entry schema a legal manifest plus one probe at a time (zzzBogusManifestKey, a transposed namesapce with namespace absent, specVersion, main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shipped manifest: block through a plain .strict() preview, then the real composeStacks(…, { manifest: 'preserve' }) output of examples/app-multi-package through the assembled entry.

doorvalue originundeclared keys observed at the doorverdict beforeverdict after
D1 ObjectStackDefinitionSchema.manifest (stack.zod.ts) — defineStack (parses with strict: true by default), os validate, os compile / os build, the metadata service's artifact doorauthored stack source (objectstack.config.ts, every os init template, the four example apps)none in any shipped block (13/13 blocks carry only declared keys); a bogus key, namesapce, specVersion, main and every bookkeeping key parsed green and were DROPPED; namespace came out undefined for the typostriprefused, unrecognized_keys at path manifest, keys named, rename suggested
D2 stack.devPlugins[]z.union([ManifestSchema, z.string()])authored stack sourcesame as D1 (dropped silently)striprefused; the union reports invalid_union at devPlugins.N with the named unrecognized_keys issue nested in its errors (the state-machine row's known flattening shape — loud, one level deeper)
D3 ArtifactPackageEntrySchema.manifest — the exported authoring-time packages[] entryauthored (no runtime caller; exported and pinned by stack-artifact-packages.test.ts)dropped silentlystriprefused at manifest
D4 ArtifactPackageSchema.manifest = AssembledPackageBodySchema = ManifestSchema.extend(collections)resolveArtifactPackageOrder (@objectstack/core since #14643; called by the ObjectQL load path and, after the merge, by the metadata door), and os compile's parse of the composed stackartifact bundle (objectstack.json); bodies are produced by assemblePackageBody in spec, { ...manifest, ...collections } — the two real bodies of examples/app-multi-package carry 0 keys outside manifest ∪ collectionsdropped silently on the body (zod 4.4.3 .extend() inherits the parent's strip posture)striprefused at manifest.extend() inherits the closed posture AND the error map (measured on zod 4.4.3 before editing), so the load gate refuses an undeclared body key without a second declaration
D5 ManifestSchema.safeParse direct — os plugin build (objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsx at the pinned sha)authored plugin manifest file; a form draft (create) or the package record's manifest (edit)main — read off the raw manifest by build.ts to pick the esbuild entry, and written back into the compiled manifest as dist/index.mjs; authored by three CLI fixtures (osplugin.test.ts, plugin-sign.test.ts, plugin-publish.test.ts). Everything else dropped silentlystriprefused for undeclared keys; main DECLARED (see §4)
D6 InstalledPackageSchema.manifestregistry.validate('package')registry item — but registerItem('package', …) has NO caller: installPackage writes the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys on InstalledPackageSchema itself are stripped by that (unchanged) row schemanone reach itstriprefused inside manifest if ever reached; the row schema is untouched
D6b registry.validate('plugin')ManifestSchema.parseregistry item via SchemaRegistry.registerPlugin — which has no caller in objectql / runtime / core / rest / metadatanone reach itstripunreachable; unchanged semantics (diagnostic-only, registers anyway)
D7 the six wire shapes embedding the manifest — InstallPackageRequestSchema, UpgradePackageRequestSchema, UpgradeSnapshotSchema.previousManifest, PackageInstallRequestSchema, PackageUpgradeRequestSchema, ResolveDependenciesRequestSchemarequest / snapshot declarations; no runtime parse site (package-routes.ts hands req.body.manifest to protocol.installPackage unparsed; the durable sys_packages row is re-registered through registerApp without a parse)nonestriprefused inside manifest if ever parsed; no behaviour change measured anywhere
stored-row check (the api/ row's peelStoredEnvelope question)sys_metadata hydration goes through applyConversionsToStoredItem / peelStoredEnvelope and never through this schema; the package / plugin registry branches above have no callerno packageId / state / published* / package key reaches any doorno envelope debt to pay first; no bookkeeping key taught to the schema
cloudno checkout in this container; the repo-bound token refuses /search/code (403)NOT MEASUREDif cloud parses a published .osplugin manifest through this schema, the compiled manifest it receives carries only declared keys (main, integrity included) by construction of os plugin build

Nested blocks, same doors: contributes.kind (for kinds), contributes.kinds[].glob (for the retired globs) and engines.protocl (for protocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence. engine.objectstak was already refused by the required objectstack sibling.

Example apps (numbers): 4/4 example-app manifest: blocks (app-crm, app-todo, app-showcase, app-multi-package core + orders = 5 blocks) parse green under the strict preview with 0 undeclared keys; so do the 3 os init templates, the create-objectstack blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails is packages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (no id, no type) — filed as #14705, not touched here. Post-close, through the rebuilt dist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declared main), both real assembled bodies parse, the composed multi-package stack parses.

2. The tightening the measurement licensed — branch (a)

ManifestSchema is strictObject({ surface: 'this package manifest', history, guidance }) — the house helper #8687 uses one door up, no second helper. Curated guidance: exactly one entry, specVersion, the retired CLI-advisory axis that os doctor / os lint used to read (the protocol-version-gap pin was the measurement) — it points at engines.protocol. No bookkeeping key was taught to the authoring schema (the trade the api/ row refused), because none reaches a door.

The retired keys (loading, capabilities, configuration, extensions, the ten retired contributes members, kinds[].globs) stay retiredKey() tombstones rather than becoming guidance lines: a tombstone types the key never for tsc and raises its prescription at parse — strictly stronger — and strictObject's acceptsNothing keeps them out of the rename candidates, so capabilitis is refused without being pointed at the dead capabilities (pinned). Every strip pin flipped to a refusal pin, none deleted: assembled-package-body.test.ts ("the body schema is not strict") and packages/cli/src/utils/protocol-version-gap.test.ts ("specVersion is accepted and dropped") now assert the refusal; the retirement pins in manifest.test.ts / plugin-loading-retirement.test.ts keep bearing weight unchanged (their prose about "not strict" updated).

3. Nested blocks — closed under the same measurement, not silently

The card's words are "inside manifest:", and contributes, contributes.kinds[], engine and engines are hand-authored in that same block and reachable ONLY through the doors above (no other user of PluginEnginesSchema in-repo beyond the type-alias pin; cloud unmeasured, stated). All four are strictObject now, each with its own surface name and one-sentence history; PluginEnginesSchema stays exported. Pinned: kind gets Did you mean kinds, protocl gets Did you mean protocol, glob is refused without a rename to the tombstone.

4. main — declared, not curated (the one measured widening)

os plugin build reads rawManifest.main to choose the module esbuild bundles and writes main: 'dist/index.mjs' into the compiled manifest it packs; ADR-0025 §3.2/§3.4 describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a naked strictObject would have refused every plugin that names its entry (the shape the api/ row records as refused). A guidance line telling authors to delete it would describe neither the source file nor the compiled one. So it is declared: main: z.string().optional() with a describe, a liveness row (live, evidence packages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and the plugin-spec.mdx callout regenerated/updated. This is additive (accept-set widening on one optional key), flagged here for the contract review.

5. The ledger

docs/audits/2026-07-unknown-key-strictness-ledger.md: the kernel/ cell is now mixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in the api/ row's form. The counts file did not move (the same six sites, re-postured strip → strict; check:strictness-ledger green — it reads the coarse table for form only). stack.zod.ts was read-only for this card (in-flight #14124): its AssembledPackageBodySchema docblock sentence "NOT strictObject: ManifestSchema is an open object" is now stale, as is the compile.ts comment "ManifestSchema is an open object" — both reported for the seat rather than edited here (one-line follow-ups; the ledger row says so).

6. Changeset level

@objectstack/spec: minor with a **BREAKING** accept-set note — the lockstep launch-window convention (check-changeset-no-major), as #8687 and #5384 did. ADR-0087 disposition: not-required (no-migration-prescription), the #5384 reasoning: no key is retired, only the unknown-key posture moves, an undeclared key was never honoured so no stored or working shape becomes invalid, and there is no single rewrite rule a ledger entry could state; the schema rejection is the upgrade channel. #8687 registered a D3 entry because it ALSO declared onEnable and had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*). check:adr-0087-registration and check:changeset-no-major are green on this body.

7. Verification (all on the final head 1efaac767, after merging origin/main at c616c2cc2)

Under scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.

  • @objectstack/spec full suite on f3946663c (pre-merge, spec src identical to the final head): Test Files 456 passed (456) · Tests 12246 passed (12246) · VERDICT command-exit 0. Re-run on 1efaac767 of src/kernel + assembled-package-body + stack-artifact-packages + stack-top-level-strict + compose-stacks + shared/strict-object + shared/alias-integrity: Test Files 54 passed (54) · Tests 1052 passed (1052) · VERDICT command-exit 0.
  • @objectstack/spec typecheck (tsc + scripts + check:test-typecheck): check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) · VERDICT command-exit 0 (re-run on the final head after the merge, since check-liveness.mts moved). The new test file is inside the tsconfig.test.json program (--listFiles count 1).
  • check:generated: ✓ All 15 generated artifacts are up to date. on the final head (spec rebuilt from the identical src after the ablation restore bumped the mtime — content hash-identical). check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:liveness (manifest 39 classified (live 23, dead 15, live-elsewhere 1)) all green.
  • Reverse verification (ablation, on committed f3946663c):manifest.zod.ts opened on disk by appending .strip() to the ManifestSchema declaration under a trap … EXIT INT TERM with absolute paths; mutation confirmed on disk by marker count 0 → 1 and blob d069b01ba… ≠ HEAD blob 8d138e453…; the pins resolve src by relative import, so no dist rebuild was needed for this leg (stated, not assumed). Result: Test Files 2 failed | 1 passed · Tests 8 failed | 60 passed — the 7 root/door refusal pins and the assembled-body pin RED, the nested-block, accept-side and tombstone pins GREEN (the predicted direction: a root .strip() does not open the nested closures). Restore by git checkout HEAD -- <absolute path>: RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.
  • Consumer readings (filter direction: downstream, by file): @objectstack/cli (plugin-commands, osplugin, plugin-sign, plugin-publish, compile-artifact-packages.e2e, init, init-scaffold-authoring-rules, init-created-files-summary.e2e, lint-protocol-range, protocol-version-gap): Test Files 10 passed (10) · Tests 106 passed (106), exit 0 (both heads). @objectstack/objectql (artifact-load-path + registry*, and artifact-packages* before its move to core): Test Files 26 passed (26) · Tests 357 passed (357), exit 0. @objectstack/core (artifact-packages* after the move + plugin-type-closed-set): 3 passed, exit 0. @objectstack/metadataplugin-artifact-packages-attribution.test.ts (the new packages[] door from fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643): 7 passed, exit 0. @objectstack/client typecheck: check:test-typecheck: OK, exit 0. Example apps: example-crm 5 files / 45 tests, example-todo 4 / 106, example-showcase 27 / 372 (after building its plugin closure; the earlier reds were unbuilt dist, not the change), all exit 0; example-multi-package has no test script — its composed config is parsed by the harness and by assembled-package-body.test.ts (NOT MEASURED as a suite).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1efaac767 derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them): check-dev-prereqs (exit 1, wants the whole-repo pnpm build), check-test-completeness (exit 3, needs a saved turbo run test log), check:dual-build-cjs-loads (exit 3, 8 packages unbuilt), check:type-check-debt (exit 3, PREREQUISITE NOT MET — the DEBT closure needs the turbo build). check:skill-examples is green after building client-react: ✅ 256 prose examples type-check across 3 surface(s). check:nul-bytes green; control-character scan of every edited file empty.

8. Reported, not changed

  • objectui (pinned 67dadd602): the Studio package form derives its JSON schema from ManifestSchema via z.toJSONSchema and validates its draft with ManifestSchema.safeParse; create drafts carry only form fields, edit drafts seed from the record's manifest which the server stores verbatim from what the form sent. Read at the pinned sha; NOT MEASURED at runtime.
  • devPlugins refusal is nested under invalid_union (pinned as observed); the flattening limitation the ledger's state-machine row already records.
  • stack.zod.ts:1023 and packages/cli/src/commands/compile.ts:82 still say the manifest is an open object — stale sentences for their owners (stack.zod.ts read-only for this card).
  • Cloud reachability NOT MEASURED (no checkout; code search refused by the repo-bound token).

Out of scope, filed: #14705 (os create template manifest fails the open schema — no id, no type).

Fixes#14192

Generated by Claude Code

🤖 Generated with Claude Code


Generated by Claude Code

…s strict (#14192)
Measurement first (all seven doors that reach ManifestSchema, from src and
through the built dist, identical): an unknown key inside `manifest:` parsed
green and was dropped at every one; `namesapce` left manifest.namespace
undefined with exit 0; the same held one level down for contributes.kind,
contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the
schema and no bookkeeping key reaches any door, so no envelope debt had to be
paid first. Exactly one undeclared key was found at a real door — `main`,
read by `os plugin build` off objectstack.plugin.json and written back into
the compiled manifest — and it is declared rather than refused.
ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema
are strictObject now; retired keys keep their retiredKey() tombstones; one
curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a
measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal
pins (assembled-package-body, cli protocol-version-gap); liveness row for main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ss counts for the declared main key (#14192)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx(via ManifestSchema (symbol, a top-level const object))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via PluginEnginesSchema (symbol, a top-level const object))
  • content/docs/releases/v15.mdx(via ManifestSchema (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52packageMentionDocs.

Which tree this was computed on

This run read content/docs from a7009c9c8b5fca47b53897cebd5c1d0e899342f5 — the merge of head 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e into base 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52, 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 a7009c9c8b5fca47b53897cebd5c1d0e899342f5 && git checkout a7009c9c8b5fca47b53897cebd5c1d0e899342f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e && git checkout -B drift-repro 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 && git merge --no-ff 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e
node scripts/docs-audit/affected-docs.mjs --json 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52

⚠️ 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 20:45
@os-sam
os-sam enabled auto-merge September 2, 2026 20:46
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 4d0d944Sep 2, 2026
43 checks passed
@os-sam
os-sam deleted the claude/issue-14192-manifest-schema-unknown-keys branch September 2, 2026 21:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ManifestSchema strips unknown keys silently — a namespace typo inside manifest: parses green and the namespace comes out undefined

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down) - #14714

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys
Sep 2, 2026
Merged

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down)#14714
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Contract-review tier (clause ②): the accept set of a published spec schema moves. needs:contract-review is parked on this PR and on the card; the seat runs the review. Nothing here is marked ready or armed.

1. Measurement — every door that reaches ManifestSchema (taken FIRST, before any tightening)

Taken on origin/main at bd4aa4e49 (the branch base), from packages/spec/src and once more through the built packages/spec/dist — the two readings are byte-identical (diff of the two harness outputs is empty). Harness: one script feeding each door's own entry schema a legal manifest plus one probe at a time (zzzBogusManifestKey, a transposed namesapce with namespace absent, specVersion, main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shipped manifest: block through a plain .strict() preview, then the real composeStacks(…, { manifest: 'preserve' }) output of examples/app-multi-package through the assembled entry.

doorvalue originundeclared keys observed at the doorverdict beforeverdict after
D1 ObjectStackDefinitionSchema.manifest (stack.zod.ts) — defineStack (parses with strict: true by default), os validate, os compile / os build, the metadata service's artifact doorauthored stack source (objectstack.config.ts, every os init template, the four example apps)none in any shipped block (13/13 blocks carry only declared keys); a bogus key, namesapce, specVersion, main and every bookkeeping key parsed green and were DROPPED; namespace came out undefined for the typostriprefused, unrecognized_keys at path manifest, keys named, rename suggested
D2 stack.devPlugins[]z.union([ManifestSchema, z.string()])authored stack sourcesame as D1 (dropped silently)striprefused; the union reports invalid_union at devPlugins.N with the named unrecognized_keys issue nested in its errors (the state-machine row's known flattening shape — loud, one level deeper)
D3 ArtifactPackageEntrySchema.manifest — the exported authoring-time packages[] entryauthored (no runtime caller; exported and pinned by stack-artifact-packages.test.ts)dropped silentlystriprefused at manifest
D4 ArtifactPackageSchema.manifest = AssembledPackageBodySchema = ManifestSchema.extend(collections)resolveArtifactPackageOrder (@objectstack/core since #14643; called by the ObjectQL load path and, after the merge, by the metadata door), and os compile's parse of the composed stackartifact bundle (objectstack.json); bodies are produced by assemblePackageBody in spec, { ...manifest, ...collections } — the two real bodies of examples/app-multi-package carry 0 keys outside manifest ∪ collectionsdropped silently on the body (zod 4.4.3 .extend() inherits the parent's strip posture)striprefused at manifest.extend() inherits the closed posture AND the error map (measured on zod 4.4.3 before editing), so the load gate refuses an undeclared body key without a second declaration
D5 ManifestSchema.safeParse direct — os plugin build (objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsx at the pinned sha)authored plugin manifest file; a form draft (create) or the package record's manifest (edit)main — read off the raw manifest by build.ts to pick the esbuild entry, and written back into the compiled manifest as dist/index.mjs; authored by three CLI fixtures (osplugin.test.ts, plugin-sign.test.ts, plugin-publish.test.ts). Everything else dropped silentlystriprefused for undeclared keys; main DECLARED (see §4)
D6 InstalledPackageSchema.manifestregistry.validate('package')registry item — but registerItem('package', …) has NO caller: installPackage writes the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys on InstalledPackageSchema itself are stripped by that (unchanged) row schemanone reach itstriprefused inside manifest if ever reached; the row schema is untouched
D6b registry.validate('plugin')ManifestSchema.parseregistry item via SchemaRegistry.registerPlugin — which has no caller in objectql / runtime / core / rest / metadatanone reach itstripunreachable; unchanged semantics (diagnostic-only, registers anyway)
D7 the six wire shapes embedding the manifest — InstallPackageRequestSchema, UpgradePackageRequestSchema, UpgradeSnapshotSchema.previousManifest, PackageInstallRequestSchema, PackageUpgradeRequestSchema, ResolveDependenciesRequestSchemarequest / snapshot declarations; no runtime parse site (package-routes.ts hands req.body.manifest to protocol.installPackage unparsed; the durable sys_packages row is re-registered through registerApp without a parse)nonestriprefused inside manifest if ever parsed; no behaviour change measured anywhere
stored-row check (the api/ row's peelStoredEnvelope question)sys_metadata hydration goes through applyConversionsToStoredItem / peelStoredEnvelope and never through this schema; the package / plugin registry branches above have no callerno packageId / state / published* / package key reaches any doorno envelope debt to pay first; no bookkeeping key taught to the schema
cloudno checkout in this container; the repo-bound token refuses /search/code (403)NOT MEASUREDif cloud parses a published .osplugin manifest through this schema, the compiled manifest it receives carries only declared keys (main, integrity included) by construction of os plugin build

Nested blocks, same doors: contributes.kind (for kinds), contributes.kinds[].glob (for the retired globs) and engines.protocl (for protocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence. engine.objectstak was already refused by the required objectstack sibling.

Example apps (numbers): 4/4 example-app manifest: blocks (app-crm, app-todo, app-showcase, app-multi-package core + orders = 5 blocks) parse green under the strict preview with 0 undeclared keys; so do the 3 os init templates, the create-objectstack blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails is packages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (no id, no type) — filed as #14705, not touched here. Post-close, through the rebuilt dist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declared main), both real assembled bodies parse, the composed multi-package stack parses.

2. The tightening the measurement licensed — branch (a)

ManifestSchema is strictObject({ surface: 'this package manifest', history, guidance }) — the house helper #8687 uses one door up, no second helper. Curated guidance: exactly one entry, specVersion, the retired CLI-advisory axis that os doctor / os lint used to read (the protocol-version-gap pin was the measurement) — it points at engines.protocol. No bookkeeping key was taught to the authoring schema (the trade the api/ row refused), because none reaches a door.

The retired keys (loading, capabilities, configuration, extensions, the ten retired contributes members, kinds[].globs) stay retiredKey() tombstones rather than becoming guidance lines: a tombstone types the key never for tsc and raises its prescription at parse — strictly stronger — and strictObject's acceptsNothing keeps them out of the rename candidates, so capabilitis is refused without being pointed at the dead capabilities (pinned). Every strip pin flipped to a refusal pin, none deleted: assembled-package-body.test.ts ("the body schema is not strict") and packages/cli/src/utils/protocol-version-gap.test.ts ("specVersion is accepted and dropped") now assert the refusal; the retirement pins in manifest.test.ts / plugin-loading-retirement.test.ts keep bearing weight unchanged (their prose about "not strict" updated).

3. Nested blocks — closed under the same measurement, not silently

The card's words are "inside manifest:", and contributes, contributes.kinds[], engine and engines are hand-authored in that same block and reachable ONLY through the doors above (no other user of PluginEnginesSchema in-repo beyond the type-alias pin; cloud unmeasured, stated). All four are strictObject now, each with its own surface name and one-sentence history; PluginEnginesSchema stays exported. Pinned: kind gets Did you mean kinds, protocl gets Did you mean protocol, glob is refused without a rename to the tombstone.

4. main — declared, not curated (the one measured widening)

os plugin build reads rawManifest.main to choose the module esbuild bundles and writes main: 'dist/index.mjs' into the compiled manifest it packs; ADR-0025 §3.2/§3.4 describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a naked strictObject would have refused every plugin that names its entry (the shape the api/ row records as refused). A guidance line telling authors to delete it would describe neither the source file nor the compiled one. So it is declared: main: z.string().optional() with a describe, a liveness row (live, evidence packages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and the plugin-spec.mdx callout regenerated/updated. This is additive (accept-set widening on one optional key), flagged here for the contract review.

5. The ledger

docs/audits/2026-07-unknown-key-strictness-ledger.md: the kernel/ cell is now mixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in the api/ row's form. The counts file did not move (the same six sites, re-postured strip → strict; check:strictness-ledger green — it reads the coarse table for form only). stack.zod.ts was read-only for this card (in-flight #14124): its AssembledPackageBodySchema docblock sentence "NOT strictObject: ManifestSchema is an open object" is now stale, as is the compile.ts comment "ManifestSchema is an open object" — both reported for the seat rather than edited here (one-line follow-ups; the ledger row says so).

6. Changeset level

@objectstack/spec: minor with a **BREAKING** accept-set note — the lockstep launch-window convention (check-changeset-no-major), as #8687 and #5384 did. ADR-0087 disposition: not-required (no-migration-prescription), the #5384 reasoning: no key is retired, only the unknown-key posture moves, an undeclared key was never honoured so no stored or working shape becomes invalid, and there is no single rewrite rule a ledger entry could state; the schema rejection is the upgrade channel. #8687 registered a D3 entry because it ALSO declared onEnable and had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*). check:adr-0087-registration and check:changeset-no-major are green on this body.

7. Verification (all on the final head 1efaac767, after merging origin/main at c616c2cc2)

Under scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.

  • @objectstack/spec full suite on f3946663c (pre-merge, spec src identical to the final head): Test Files 456 passed (456) · Tests 12246 passed (12246) · VERDICT command-exit 0. Re-run on 1efaac767 of src/kernel + assembled-package-body + stack-artifact-packages + stack-top-level-strict + compose-stacks + shared/strict-object + shared/alias-integrity: Test Files 54 passed (54) · Tests 1052 passed (1052) · VERDICT command-exit 0.
  • @objectstack/spec typecheck (tsc + scripts + check:test-typecheck): check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) · VERDICT command-exit 0 (re-run on the final head after the merge, since check-liveness.mts moved). The new test file is inside the tsconfig.test.json program (--listFiles count 1).
  • check:generated: ✓ All 15 generated artifacts are up to date. on the final head (spec rebuilt from the identical src after the ablation restore bumped the mtime — content hash-identical). check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:liveness (manifest 39 classified (live 23, dead 15, live-elsewhere 1)) all green.
  • Reverse verification (ablation, on committed f3946663c):manifest.zod.ts opened on disk by appending .strip() to the ManifestSchema declaration under a trap … EXIT INT TERM with absolute paths; mutation confirmed on disk by marker count 0 → 1 and blob d069b01ba… ≠ HEAD blob 8d138e453…; the pins resolve src by relative import, so no dist rebuild was needed for this leg (stated, not assumed). Result: Test Files 2 failed | 1 passed · Tests 8 failed | 60 passed — the 7 root/door refusal pins and the assembled-body pin RED, the nested-block, accept-side and tombstone pins GREEN (the predicted direction: a root .strip() does not open the nested closures). Restore by git checkout HEAD -- <absolute path>: RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.
  • Consumer readings (filter direction: downstream, by file): @objectstack/cli (plugin-commands, osplugin, plugin-sign, plugin-publish, compile-artifact-packages.e2e, init, init-scaffold-authoring-rules, init-created-files-summary.e2e, lint-protocol-range, protocol-version-gap): Test Files 10 passed (10) · Tests 106 passed (106), exit 0 (both heads). @objectstack/objectql (artifact-load-path + registry*, and artifact-packages* before its move to core): Test Files 26 passed (26) · Tests 357 passed (357), exit 0. @objectstack/core (artifact-packages* after the move + plugin-type-closed-set): 3 passed, exit 0. @objectstack/metadataplugin-artifact-packages-attribution.test.ts (the new packages[] door from fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643): 7 passed, exit 0. @objectstack/client typecheck: check:test-typecheck: OK, exit 0. Example apps: example-crm 5 files / 45 tests, example-todo 4 / 106, example-showcase 27 / 372 (after building its plugin closure; the earlier reds were unbuilt dist, not the change), all exit 0; example-multi-package has no test script — its composed config is parsed by the harness and by assembled-package-body.test.ts (NOT MEASURED as a suite).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1efaac767 derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them): check-dev-prereqs (exit 1, wants the whole-repo pnpm build), check-test-completeness (exit 3, needs a saved turbo run test log), check:dual-build-cjs-loads (exit 3, 8 packages unbuilt), check:type-check-debt (exit 3, PREREQUISITE NOT MET — the DEBT closure needs the turbo build). check:skill-examples is green after building client-react: ✅ 256 prose examples type-check across 3 surface(s). check:nul-bytes green; control-character scan of every edited file empty.

8. Reported, not changed

  • objectui (pinned 67dadd602): the Studio package form derives its JSON schema from ManifestSchema via z.toJSONSchema and validates its draft with ManifestSchema.safeParse; create drafts carry only form fields, edit drafts seed from the record's manifest which the server stores verbatim from what the form sent. Read at the pinned sha; NOT MEASURED at runtime.
  • devPlugins refusal is nested under invalid_union (pinned as observed); the flattening limitation the ledger's state-machine row already records.
  • stack.zod.ts:1023 and packages/cli/src/commands/compile.ts:82 still say the manifest is an open object — stale sentences for their owners (stack.zod.ts read-only for this card).
  • Cloud reachability NOT MEASURED (no checkout; code search refused by the repo-bound token).

Out of scope, filed: #14705 (os create template manifest fails the open schema — no id, no type).

Fixes#14192

Generated by Claude Code

🤖 Generated with Claude Code


Generated by Claude Code

…s strict (#14192)
Measurement first (all seven doors that reach ManifestSchema, from src and
through the built dist, identical): an unknown key inside `manifest:` parsed
green and was dropped at every one; `namesapce` left manifest.namespace
undefined with exit 0; the same held one level down for contributes.kind,
contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the
schema and no bookkeeping key reaches any door, so no envelope debt had to be
paid first. Exactly one undeclared key was found at a real door — `main`,
read by `os plugin build` off objectstack.plugin.json and written back into
the compiled manifest — and it is declared rather than refused.
ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema
are strictObject now; retired keys keep their retiredKey() tombstones; one
curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a
measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal
pins (assembled-package-body, cli protocol-version-gap); liveness row for main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ss counts for the declared main key (#14192)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx(via ManifestSchema (symbol, a top-level const object))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via PluginEnginesSchema (symbol, a top-level const object))
  • content/docs/releases/v15.mdx(via ManifestSchema (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52packageMentionDocs.

Which tree this was computed on

This run read content/docs from a7009c9c8b5fca47b53897cebd5c1d0e899342f5 — the merge of head 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e into base 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52, 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 a7009c9c8b5fca47b53897cebd5c1d0e899342f5 && git checkout a7009c9c8b5fca47b53897cebd5c1d0e899342f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e && git checkout -B drift-repro 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 && git merge --no-ff 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e
node scripts/docs-audit/affected-docs.mjs --json 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52

⚠️ 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 20:45
@os-sam
os-sam enabled auto-merge September 2, 2026 20:46
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 4d0d944Sep 2, 2026
43 checks passed
@os-sam
os-sam deleted the claude/issue-14192-manifest-schema-unknown-keys branch September 2, 2026 21:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ManifestSchema strips unknown keys silently — a namespace typo inside manifest: parses green and the namespace comes out undefined

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down) - #14714

Merged
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys
Sep 2, 2026
Merged

feat(spec): refuse unknown keys inside manifest:ManifestSchema goes strict (the #8687 door one level down)#14714
os-sam merged 4 commits into
mainfrom
claude/issue-14192-manifest-schema-unknown-keys

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Contract-review tier (clause ②): the accept set of a published spec schema moves. needs:contract-review is parked on this PR and on the card; the seat runs the review. Nothing here is marked ready or armed.

1. Measurement — every door that reaches ManifestSchema (taken FIRST, before any tightening)

Taken on origin/main at bd4aa4e49 (the branch base), from packages/spec/src and once more through the built packages/spec/dist — the two readings are byte-identical (diff of the two harness outputs is empty). Harness: one script feeding each door's own entry schema a legal manifest plus one probe at a time (zzzBogusManifestKey, a transposed namesapce with namespace absent, specVersion, main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shipped manifest: block through a plain .strict() preview, then the real composeStacks(…, { manifest: 'preserve' }) output of examples/app-multi-package through the assembled entry.

doorvalue originundeclared keys observed at the doorverdict beforeverdict after
D1 ObjectStackDefinitionSchema.manifest (stack.zod.ts) — defineStack (parses with strict: true by default), os validate, os compile / os build, the metadata service's artifact doorauthored stack source (objectstack.config.ts, every os init template, the four example apps)none in any shipped block (13/13 blocks carry only declared keys); a bogus key, namesapce, specVersion, main and every bookkeeping key parsed green and were DROPPED; namespace came out undefined for the typostriprefused, unrecognized_keys at path manifest, keys named, rename suggested
D2 stack.devPlugins[]z.union([ManifestSchema, z.string()])authored stack sourcesame as D1 (dropped silently)striprefused; the union reports invalid_union at devPlugins.N with the named unrecognized_keys issue nested in its errors (the state-machine row's known flattening shape — loud, one level deeper)
D3 ArtifactPackageEntrySchema.manifest — the exported authoring-time packages[] entryauthored (no runtime caller; exported and pinned by stack-artifact-packages.test.ts)dropped silentlystriprefused at manifest
D4 ArtifactPackageSchema.manifest = AssembledPackageBodySchema = ManifestSchema.extend(collections)resolveArtifactPackageOrder (@objectstack/core since #14643; called by the ObjectQL load path and, after the merge, by the metadata door), and os compile's parse of the composed stackartifact bundle (objectstack.json); bodies are produced by assemblePackageBody in spec, { ...manifest, ...collections } — the two real bodies of examples/app-multi-package carry 0 keys outside manifest ∪ collectionsdropped silently on the body (zod 4.4.3 .extend() inherits the parent's strip posture)striprefused at manifest.extend() inherits the closed posture AND the error map (measured on zod 4.4.3 before editing), so the load gate refuses an undeclared body key without a second declaration
D5 ManifestSchema.safeParse direct — os plugin build (objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsx at the pinned sha)authored plugin manifest file; a form draft (create) or the package record's manifest (edit)main — read off the raw manifest by build.ts to pick the esbuild entry, and written back into the compiled manifest as dist/index.mjs; authored by three CLI fixtures (osplugin.test.ts, plugin-sign.test.ts, plugin-publish.test.ts). Everything else dropped silentlystriprefused for undeclared keys; main DECLARED (see §4)
D6 InstalledPackageSchema.manifestregistry.validate('package')registry item — but registerItem('package', …) has NO caller: installPackage writes the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys on InstalledPackageSchema itself are stripped by that (unchanged) row schemanone reach itstriprefused inside manifest if ever reached; the row schema is untouched
D6b registry.validate('plugin')ManifestSchema.parseregistry item via SchemaRegistry.registerPlugin — which has no caller in objectql / runtime / core / rest / metadatanone reach itstripunreachable; unchanged semantics (diagnostic-only, registers anyway)
D7 the six wire shapes embedding the manifest — InstallPackageRequestSchema, UpgradePackageRequestSchema, UpgradeSnapshotSchema.previousManifest, PackageInstallRequestSchema, PackageUpgradeRequestSchema, ResolveDependenciesRequestSchemarequest / snapshot declarations; no runtime parse site (package-routes.ts hands req.body.manifest to protocol.installPackage unparsed; the durable sys_packages row is re-registered through registerApp without a parse)nonestriprefused inside manifest if ever parsed; no behaviour change measured anywhere
stored-row check (the api/ row's peelStoredEnvelope question)sys_metadata hydration goes through applyConversionsToStoredItem / peelStoredEnvelope and never through this schema; the package / plugin registry branches above have no callerno packageId / state / published* / package key reaches any doorno envelope debt to pay first; no bookkeeping key taught to the schema
cloudno checkout in this container; the repo-bound token refuses /search/code (403)NOT MEASUREDif cloud parses a published .osplugin manifest through this schema, the compiled manifest it receives carries only declared keys (main, integrity included) by construction of os plugin build

Nested blocks, same doors: contributes.kind (for kinds), contributes.kinds[].glob (for the retired globs) and engines.protocl (for protocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence. engine.objectstak was already refused by the required objectstack sibling.

Example apps (numbers): 4/4 example-app manifest: blocks (app-crm, app-todo, app-showcase, app-multi-package core + orders = 5 blocks) parse green under the strict preview with 0 undeclared keys; so do the 3 os init templates, the create-objectstack blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails is packages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (no id, no type) — filed as #14705, not touched here. Post-close, through the rebuilt dist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declared main), both real assembled bodies parse, the composed multi-package stack parses.

2. The tightening the measurement licensed — branch (a)

ManifestSchema is strictObject({ surface: 'this package manifest', history, guidance }) — the house helper #8687 uses one door up, no second helper. Curated guidance: exactly one entry, specVersion, the retired CLI-advisory axis that os doctor / os lint used to read (the protocol-version-gap pin was the measurement) — it points at engines.protocol. No bookkeeping key was taught to the authoring schema (the trade the api/ row refused), because none reaches a door.

The retired keys (loading, capabilities, configuration, extensions, the ten retired contributes members, kinds[].globs) stay retiredKey() tombstones rather than becoming guidance lines: a tombstone types the key never for tsc and raises its prescription at parse — strictly stronger — and strictObject's acceptsNothing keeps them out of the rename candidates, so capabilitis is refused without being pointed at the dead capabilities (pinned). Every strip pin flipped to a refusal pin, none deleted: assembled-package-body.test.ts ("the body schema is not strict") and packages/cli/src/utils/protocol-version-gap.test.ts ("specVersion is accepted and dropped") now assert the refusal; the retirement pins in manifest.test.ts / plugin-loading-retirement.test.ts keep bearing weight unchanged (their prose about "not strict" updated).

3. Nested blocks — closed under the same measurement, not silently

The card's words are "inside manifest:", and contributes, contributes.kinds[], engine and engines are hand-authored in that same block and reachable ONLY through the doors above (no other user of PluginEnginesSchema in-repo beyond the type-alias pin; cloud unmeasured, stated). All four are strictObject now, each with its own surface name and one-sentence history; PluginEnginesSchema stays exported. Pinned: kind gets Did you mean kinds, protocl gets Did you mean protocol, glob is refused without a rename to the tombstone.

4. main — declared, not curated (the one measured widening)

os plugin build reads rawManifest.main to choose the module esbuild bundles and writes main: 'dist/index.mjs' into the compiled manifest it packs; ADR-0025 §3.2/§3.4 describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a naked strictObject would have refused every plugin that names its entry (the shape the api/ row records as refused). A guidance line telling authors to delete it would describe neither the source file nor the compiled one. So it is declared: main: z.string().optional() with a describe, a liveness row (live, evidence packages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and the plugin-spec.mdx callout regenerated/updated. This is additive (accept-set widening on one optional key), flagged here for the contract review.

5. The ledger

docs/audits/2026-07-unknown-key-strictness-ledger.md: the kernel/ cell is now mixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in the api/ row's form. The counts file did not move (the same six sites, re-postured strip → strict; check:strictness-ledger green — it reads the coarse table for form only). stack.zod.ts was read-only for this card (in-flight #14124): its AssembledPackageBodySchema docblock sentence "NOT strictObject: ManifestSchema is an open object" is now stale, as is the compile.ts comment "ManifestSchema is an open object" — both reported for the seat rather than edited here (one-line follow-ups; the ledger row says so).

6. Changeset level

@objectstack/spec: minor with a **BREAKING** accept-set note — the lockstep launch-window convention (check-changeset-no-major), as #8687 and #5384 did. ADR-0087 disposition: not-required (no-migration-prescription), the #5384 reasoning: no key is retired, only the unknown-key posture moves, an undeclared key was never honoured so no stored or working shape becomes invalid, and there is no single rewrite rule a ledger entry could state; the schema rejection is the upgrade channel. #8687 registered a D3 entry because it ALSO declared onEnable and had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*). check:adr-0087-registration and check:changeset-no-major are green on this body.

7. Verification (all on the final head 1efaac767, after merging origin/main at c616c2cc2)

Under scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.

  • @objectstack/spec full suite on f3946663c (pre-merge, spec src identical to the final head): Test Files 456 passed (456) · Tests 12246 passed (12246) · VERDICT command-exit 0. Re-run on 1efaac767 of src/kernel + assembled-package-body + stack-artifact-packages + stack-top-level-strict + compose-stacks + shared/strict-object + shared/alias-integrity: Test Files 54 passed (54) · Tests 1052 passed (1052) · VERDICT command-exit 0.
  • @objectstack/spec typecheck (tsc + scripts + check:test-typecheck): check:test-typecheck: OK — 54 file(s) / 262 error(s) / 146 pinned signature(s) · VERDICT command-exit 0 (re-run on the final head after the merge, since check-liveness.mts moved). The new test file is inside the tsconfig.test.json program (--listFiles count 1).
  • check:generated: ✓ All 15 generated artifacts are up to date. on the final head (spec rebuilt from the identical src after the ablation restore bumped the mtime — content hash-identical). check:api-surface, check:authorable-surface, check:docs, check:strictness-ledger, check:liveness (manifest 39 classified (live 23, dead 15, live-elsewhere 1)) all green.
  • Reverse verification (ablation, on committed f3946663c):manifest.zod.ts opened on disk by appending .strip() to the ManifestSchema declaration under a trap … EXIT INT TERM with absolute paths; mutation confirmed on disk by marker count 0 → 1 and blob d069b01ba… ≠ HEAD blob 8d138e453…; the pins resolve src by relative import, so no dist rebuild was needed for this leg (stated, not assumed). Result: Test Files 2 failed | 1 passed · Tests 8 failed | 60 passed — the 7 root/door refusal pins and the assembled-body pin RED, the nested-block, accept-side and tombstone pins GREEN (the predicted direction: a root .strip() does not open the nested closures). Restore by git checkout HEAD -- <absolute path>: RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.
  • Consumer readings (filter direction: downstream, by file): @objectstack/cli (plugin-commands, osplugin, plugin-sign, plugin-publish, compile-artifact-packages.e2e, init, init-scaffold-authoring-rules, init-created-files-summary.e2e, lint-protocol-range, protocol-version-gap): Test Files 10 passed (10) · Tests 106 passed (106), exit 0 (both heads). @objectstack/objectql (artifact-load-path + registry*, and artifact-packages* before its move to core): Test Files 26 passed (26) · Tests 357 passed (357), exit 0. @objectstack/core (artifact-packages* after the move + plugin-type-closed-set): 3 passed, exit 0. @objectstack/metadataplugin-artifact-packages-attribution.test.ts (the new packages[] door from fix(metadata): register a packages[] artifact per package at the metadata door so every object has one owner across every door (#14599) #14643): 7 passed, exit 0. @objectstack/client typecheck: check:test-typecheck: OK, exit 0. Example apps: example-crm 5 files / 45 tests, example-todo 4 / 106, example-showcase 27 / 372 (after building its plugin closure; the earlier reds were unbuilt dist, not the change), all exit 0; example-multi-package has no test script — its composed config is parsed by the harness and by assembled-package-body.test.ts (NOT MEASURED as a suite).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 1efaac767 derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them): check-dev-prereqs (exit 1, wants the whole-repo pnpm build), check-test-completeness (exit 3, needs a saved turbo run test log), check:dual-build-cjs-loads (exit 3, 8 packages unbuilt), check:type-check-debt (exit 3, PREREQUISITE NOT MET — the DEBT closure needs the turbo build). check:skill-examples is green after building client-react: ✅ 256 prose examples type-check across 3 surface(s). check:nul-bytes green; control-character scan of every edited file empty.

8. Reported, not changed

  • objectui (pinned 67dadd602): the Studio package form derives its JSON schema from ManifestSchema via z.toJSONSchema and validates its draft with ManifestSchema.safeParse; create drafts carry only form fields, edit drafts seed from the record's manifest which the server stores verbatim from what the form sent. Read at the pinned sha; NOT MEASURED at runtime.
  • devPlugins refusal is nested under invalid_union (pinned as observed); the flattening limitation the ledger's state-machine row already records.
  • stack.zod.ts:1023 and packages/cli/src/commands/compile.ts:82 still say the manifest is an open object — stale sentences for their owners (stack.zod.ts read-only for this card).
  • Cloud reachability NOT MEASURED (no checkout; code search refused by the repo-bound token).

Out of scope, filed: #14705 (os create template manifest fails the open schema — no id, no type).

Fixes#14192

Generated by Claude Code

🤖 Generated with Claude Code


Generated by Claude Code

…s strict (#14192)
Measurement first (all seven doors that reach ManifestSchema, from src and
through the built dist, identical): an unknown key inside `manifest:` parsed
green and was dropped at every one; `namesapce` left manifest.namespace
undefined with exit 0; the same held one level down for contributes.kind,
contributes.kinds[].glob and engines.protocl. No stored-row caller feeds the
schema and no bookkeeping key reaches any door, so no envelope debt had to be
paid first. Exactly one undeclared key was found at a real door — `main`,
read by `os plugin build` off objectstack.plugin.json and written back into
the compiled manifest — and it is declared rather than refused.
ManifestSchema, contributes, contributes.kinds[], engine and PluginEnginesSchema
are strictObject now; retired keys keep their retiredKey() tombstones; one
curated wrong-layer pointer (specVersion). The kernel/ ledger cell becomes a
measured mixed verdict for manifest.zod.ts. Two strip pins flip to refusal
pins (assembled-package-body, cli protocol-version-gap); liveness row for main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
…ss counts for the declared main key (#14192)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 2 documentable anchor(s). ⚠️4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/plugins/development.mdx(via ManifestSchema (symbol, a top-level const object))
  • content/docs/protocol/kernel/plugin-spec.mdx(via ManifestSchema (symbol, a top-level const object))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx(via PluginEnginesSchema (symbol, a top-level const object))
  • content/docs/releases/v15.mdx(via ManifestSchema (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/spec/authorable-surface/kernel.json, packages/spec/liveness/manifest.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 128 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52packageMentionDocs.

Which tree this was computed on

This run read content/docs from a7009c9c8b5fca47b53897cebd5c1d0e899342f5 — the merge of head 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e into base 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52, 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 a7009c9c8b5fca47b53897cebd5c1d0e899342f5 && git checkout a7009c9c8b5fca47b53897cebd5c1d0e899342f5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e && git checkout -B drift-repro 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 && git merge --no-ff 1efaac7673f78ec7215c7412bdd1a2ed97be3e6e
node scripts/docs-audit/affected-docs.mjs --json 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52

⚠️ 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 3c1bbd2a87a0e80ff5fbc65ebe37d6b5f72bbb52 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Sep 2, 2026
@os-sam
os-sam marked this pull request as ready for review September 2, 2026 20:45
@os-sam
os-sam enabled auto-merge September 2, 2026 20:46
@os-sam
os-sam added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 4d0d944Sep 2, 2026
43 checks passed
@os-sam
os-sam deleted the claude/issue-14192-manifest-schema-unknown-keys branch September 2, 2026 21:14
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ManifestSchema strips unknown keys silently — a namespace typo inside manifest: parses green and the namespace comes out undefined

2 participants

@os-sam@claude