Uh oh!
There was an error while loading. Please reload this page.
feat(spec): refuse unknown keys inside manifest: — ManifestSchema goes strict (the #8687 door one level down) - #14714
Conversation
…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
…nifest-schema-unknown-keys
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Uh oh!
There was an error while loading. Please reload this page.
Contract-review tier (clause ②): the accept set of a published spec schema moves.
needs:contract-reviewis 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/mainatbd4aa4e49(the branch base), frompackages/spec/srcand once more through the builtpackages/spec/dist— the two readings are byte-identical (diffof 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 transposednamesapcewithnamespaceabsent,specVersion,main, each retired key, each nested typo, each stored-envelope bookkeeping key), then every shippedmanifest:block through a plain.strict()preview, then the realcomposeStacks(…, { manifest: 'preserve' })output ofexamples/app-multi-packagethrough the assembled entry.ObjectStackDefinitionSchema.manifest(stack.zod.ts) —defineStack(parses withstrict: trueby default),os validate,os compile/os build, the metadata service's artifact doorobjectstack.config.ts, everyos inittemplate, the four example apps)namesapce,specVersion,mainand every bookkeeping key parsed green and were DROPPED;namespacecame outundefinedfor the typounrecognized_keysat pathmanifest, keys named, rename suggestedstack.devPlugins[]—z.union([ManifestSchema, z.string()])invalid_unionatdevPlugins.Nwith the namedunrecognized_keysissue nested in itserrors(the state-machine row's known flattening shape — loud, one level deeper)ArtifactPackageEntrySchema.manifest— the exported authoring-timepackages[]entrystack-artifact-packages.test.ts)manifestArtifactPackageSchema.manifest=AssembledPackageBodySchema=ManifestSchema.extend(collections)—resolveArtifactPackageOrder(@objectstack/coresince #14643; called by the ObjectQL load path and, after the merge, by the metadata door), andos compile's parse of the composed stackobjectstack.json); bodies are produced byassemblePackageBodyin spec,{ ...manifest, ...collections }— the two real bodies ofexamples/app-multi-packagecarry 0 keys outside manifest ∪ collections.extend()inherits the parent's strip posture)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 declarationManifestSchema.safeParsedirect —os plugin build(objectstack.plugin.json) and the Studio package form (objectuiPackageFormDialog.tsxat the pinned sha)main— read off the raw manifest bybuild.tsto pick the esbuild entry, and written back into the compiled manifest asdist/index.mjs; authored by three CLI fixtures (osplugin.test.ts,plugin-sign.test.ts,plugin-publish.test.ts). Everything else dropped silentlymainDECLARED (see §4)InstalledPackageSchema.manifest—registry.validate('package')registerItem('package', …)has NO caller:installPackagewrites the record straight into the collection, so this validate branch is unreachable; row-level bookkeeping keys onInstalledPackageSchemaitself are stripped by that (unchanged) row schemamanifestif ever reached; the row schema is untouchedregistry.validate('plugin')→ManifestSchema.parseSchemaRegistry.registerPlugin— which has no caller in objectql / runtime / core / rest / metadataInstallPackageRequestSchema,UpgradePackageRequestSchema,UpgradeSnapshotSchema.previousManifest,PackageInstallRequestSchema,PackageUpgradeRequestSchema,ResolveDependenciesRequestSchemapackage-routes.tshandsreq.body.manifesttoprotocol.installPackageunparsed; the durablesys_packagesrow is re-registered throughregisterAppwithout a parse)manifestif ever parsed; no behaviour change measured anywhereapi/row'speelStoredEnvelopequestion)sys_metadatahydration goes throughapplyConversionsToStoredItem/peelStoredEnvelopeand never through this schema; thepackage/pluginregistry branches above have no callerpackageId/state/published*/packagekey reaches any door/search/code(403).ospluginmanifest through this schema, the compiled manifest it receives carries only declared keys (main,integrityincluded) by construction ofos plugin buildNested blocks, same doors:
contributes.kind(forkinds),contributes.kinds[].glob(for the retiredglobs) andengines.protocl(forprotocol) all parsed green with the typo'd child dropped — the last one switches the load-time protocol handshake off in silence.engine.objectstakwas already refused by the requiredobjectstacksibling.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 3os inittemplates, thecreate-objectstackblank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest (13/13). The only shipped block that fails ispackages/cli/src/commands/create.ts's template, and it fails the OPEN schema too (noid, notype) — filed as #14705, not touched here. Post-close, through the rebuiltdist: 17/17 shipped blocks and fixtures parse (the three CLI plugin fixtures now via the declaredmain), both real assembled bodies parse, the composed multi-package stack parses.2. The tightening the measurement licensed — branch (a)
ManifestSchemaisstrictObject({ surface: 'this package manifest', history, guidance })— the house helper#8687uses one door up, no second helper. Curatedguidance: exactly one entry,specVersion, the retired CLI-advisory axis thatos doctor/os lintused to read (theprotocol-version-gappin was the measurement) — it points atengines.protocol. No bookkeeping key was taught to the authoring schema (the trade theapi/row refused), because none reaches a door.The retired keys (
loading,capabilities,configuration,extensions, the ten retiredcontributesmembers,kinds[].globs) stayretiredKey()tombstones rather than becomingguidancelines: a tombstone types the keyneverfortscand raises its prescription at parse — strictly stronger — andstrictObject'sacceptsNothingkeeps them out of the rename candidates, socapabilitisis refused without being pointed at the deadcapabilities(pinned). Every strip pin flipped to a refusal pin, none deleted:assembled-package-body.test.ts("the body schema is not strict") andpackages/cli/src/utils/protocol-version-gap.test.ts("specVersion is accepted and dropped") now assert the refusal; the retirement pins inmanifest.test.ts/plugin-loading-retirement.test.tskeep 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:", andcontributes,contributes.kinds[],engineandenginesare hand-authored in that same block and reachable ONLY through the doors above (no other user ofPluginEnginesSchemain-repo beyond the type-alias pin; cloud unmeasured, stated). All four arestrictObjectnow, each with its own surface name and one-sentence history;PluginEnginesSchemastays exported. Pinned:kindgetsDid you mean kinds,protoclgetsDid you mean protocol,globis refused without a rename to the tombstone.4.
main— declared, not curated (the one measured widening)os plugin buildreadsrawManifest.mainto choose the module esbuild bundles and writesmain: 'dist/index.mjs'into the compiled manifest it packs;ADR-0025 §3.2/§3.4describes the compiled manifest as the authored manifest plus build blocks. The key was therefore honoured while undeclared, and a nakedstrictObjectwould have refused every plugin that names its entry (the shape theapi/row records as refused). Aguidanceline 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, evidencepackages/cli/src/commands/plugin/build.ts#PluginBuild), the reference page, the authorable surface (kernel/Manifest:main) and theplugin-spec.mdxcallout 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: thekernel/cell is nowmixed · manifest.zod.ts authorable, the rest wire, with the measurement above as its rationale, in theapi/row's form. The counts file did not move (the same six sites, re-postured strip → strict;check:strictness-ledgergreen — it reads the coarse table for form only).stack.zod.tswas read-only for this card (in-flight #14124): itsAssembledPackageBodySchemadocblock sentence "NOTstrictObject:ManifestSchemais an open object" is now stale, as is thecompile.tscomment "ManifestSchemais 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: minorwith 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 declaredonEnableand had a prescription per retirement to carry; the retired manifest keys here already own their ledger entries (kernel/Manifest:*).check:adr-0087-registrationandcheck:changeset-no-majorare green on this body.7. Verification (all on the final head
1efaac767, after mergingorigin/mainatc616c2cc2)Under
scripts/pm/os-verify-lock.sh, verdict lines quoted; exits captured before any pipe.@objectstack/specfull suite onf3946663c(pre-merge, spec src identical to the final head):Test Files 456 passed (456) · Tests 12246 passed (12246)·VERDICT command-exit 0. Re-run on1efaac767ofsrc/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, sincecheck-liveness.mtsmoved). The new test file is inside thetsconfig.test.jsonprogram (--listFilescount 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.f3946663c):manifest.zod.tsopened on disk by appending.strip()to theManifestSchemadeclaration under atrap … EXIT INT TERMwith absolute paths; mutation confirmed on disk by marker count 0 → 1 and blobd069b01ba…≠ HEAD blob8d138e453…; the pins resolvesrcby relative import, so nodistrebuild 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 bygit checkout HEAD -- <absolute path>:RESTORE OK: manifest.zod.ts == HEAD blob 8d138e453ced198ffa8d69bb1d0dba43c1f91fd0; git diff HEAD empty.@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*, andartifact-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 newpackages[]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 unbuiltdist, not the change), all exit 0;example-multi-packagehas no test script — its composed config is parsed by the harness and byassembled-package-body.test.ts(NOT MEASURED as a suite).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon1efaac767derived 73 commands; 69 green with exit 0. NOT MEASURED locally (prerequisite exits, CI runs them):check-dev-prereqs(exit 1, wants the whole-repopnpm build),check-test-completeness(exit 3, needs a savedturbo run testlog),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-examplesis green after buildingclient-react:✅ 256 prose examples type-check across 3 surface(s).check:nul-bytesgreen; control-character scan of every edited file empty.8. Reported, not changed
67dadd602): the Studio package form derives its JSON schema fromManifestSchemaviaz.toJSONSchemaand validates its draft withManifestSchema.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.devPluginsrefusal is nested underinvalid_union(pinned as observed); the flattening limitation the ledger's state-machine row already records.stack.zod.ts:1023andpackages/cli/src/commands/compile.ts:82still say the manifest is an open object — stale sentences for their owners (stack.zod.tsread-only for this card).Out of scope, filed: #14705 (
os createtemplate manifest fails the open schema — noid, notype).Fixes#14192
Generated by Claude Code
🤖 Generated with Claude Code
Generated by Claude Code