Repository navigation
fix(metadata-protocol): a packaged item's save answers the package door before the checks that judge its body, on every kernel topology - #22338
Conversation
…e the gates that judge the body saveMetaItem asked refusePackagedBaseOverride only on an environment kernel. A host-config kernel met the same predicate at the repository write, which is the method's last act, so a publish of a packaged object whose body the authoring gate or the spec parse refuses answered 422 INVALID_METADATA there and 403 NOT_OVERRIDABLE on an environment kernel. The door now runs on every topology at its existing position. The predicate is the repository's own, so no acceptance set moves; the _lock gate's hand-written deferral to the door is now always true and is removed. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…e package-door pins Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…ugh the operator hatch The suite reached the check by leaving environmentId unset, which skipped the package door. The door is now asked on every topology, so a packaged object's in-place write answers 403 NOT_OVERRIDABLE before the diff; the suite opens OS_METADATA_WRITABLE=object, the one route by which such a write reaches the check. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…s ahead of the permission-set lock on every topology The hatch-closed case pinned the lock's error class on a host-config kernel, where the protocol's package door used to be skipped. The door is now asked on every topology, as on an environment kernel, so that save is refused by the door with the same NOT_OVERRIDABLE / 403 envelope. The hatch-open cases, where the door yields, still pin the lock's class. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…tor hatch The lint-before-R1 order and the two R1 refusals reached the posture gate on a host-config kernel because the package door was skipped there. The door is now asked on every topology, so with OS_METADATA_WRITABLE shut a packaged object's overlay answers 403 NOT_OVERRIDABLE before either gate; the cases open the hatch, the path R1 judges, and keep their expectations. Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
…ckage-door-before-authoring-gate
…the engine-double ledger Claude-Session: https://claude.ai/code/session_01EUBvqtauTDmHi2ZgY759p2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 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 — 11 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 51bcfc4e3ad7dd71a269695ffc1ac1323d6db922 && git checkout 51bcfc4e3ad7dd71a269695ffc1ac1323d6db922
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 28bff18d0c4013db86d61eba87c739c3145e17fd 81a42b120377e6108fe1dd0eb606ca75c930baf1 && git checkout -B drift-repro 28bff18d0c4013db86d61eba87c739c3145e17fd && git merge --no-ff 81a42b120377e6108fe1dd0eb606ca75c930baf1
node scripts/docs-audit/affected-docs.mjs --json 28bff18d0c4013db86d61eba87c739c3145e17fd
|
Fixes #22220
Clause-②: no
What changes
saveMetaItem's package door (refusePackagedBaseOverride) is now asked on every kernel topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and before every check that reads the body or the store. It used to sit behindenvironmentId !== undefined. A host-config kernel (the CLI's assembler, the showcase's boot shape,OS_MODE=off) met the same predicate only atSysMetadataRepository.assertAllowed, the first statement ofrepo.put, which is the method's last act. So on that kernel every refusal in between answered first.packages/metadata-protocol/src/protocol.ts: theenvironmentIdwrapper around the door is removed. The_lockgate's guardpackagedBaseRefusal(...) === null(its hand-written deferral to the door on host-config) always answered "no refusal" once the door runs first on every kernel, so it is removed. The invariant comment on that gate is extended, and the door's call site records why no acceptance set moves. Two TSDoc paragraphs that said the door is environment-only are corrected.packages/metadata-protocol/src/protocol.package-door-before-gates.test.ts(one table over both kernel topologies,code+statusper row).scripts/engine-double-contract.pinned.json: the new pin file'sfindOnedouble, recorded bycheck-engine-double-contract.mjs --write..changeset/22220-package-door-before-gates.md:@objectstack/metadata-protocolpatch.Door table, live (base
4e4111ca05vs head9e763ec0bc, both built and booted before themainmerge)Seeded admin, default composition,
OS_METADATA_WRITABLEunset. Bodies: served = theGETitem; gate-refused = served plus one autonumber field whose format names a missing field (autonumber-references-unknown-field); spec-refused = served plus an undeclared top-level key (unrecognized_keys). Every cell isstatus code.examples/app-crm,PUT /api/v1/meta/object/crm_account(packaged undercom.example.crm;objectisallowOrgOverride: false). Environment kernel =pnpm dev:crm -- --fresh(env_local). Host-config kernel = the same stack underOS_MODE=off(the lightweight assembler,environmentIdundefined; confirmed by the repository's sentence on the base row).permission/crm_sales_user, spec-refused, publishposition/sales_rep, spec-refused, publishzz_local_obj, gate-refused, publishzz_local_obj, spec-refused, publishAt head the host-config 403 carries the environment kernel's sentence: the two
crm_accountgate-refused publish bodies compare byte-equal. At base the host-config 403s carried the repository's sentence ('object' is not allowOrgOverride in the registry ...), and a packagedpermission's plain publish carried plugin-security's lock sentence. The environment kernel's code path is unchanged by this diff, which is why its two unmeasured base cells are left unmeasured rather than inferred.The card's own composition,
examples/app-showcase(pnpm dev -- --fresh, host-config),PUT /api/v1/meta/object/showcase_task: base answered 422 for gate-refused publish and for spec-refused publish and draft, 403 for the rest; head answers 403NOT_OVERRIDABLEfor all six rows, and the env-local controls answer 422INVALID_METADATA.Door table, unit pins (both kernels; base =
protocol.tsat the merge base, head = this branch)Measured through the real
saveMetaItemover an engine double (the new pin file's harness). "gate reached" =assertRuntimeAuthoringRuleswas called.?package=naming its read-only package, gate-refused, publishallowOrgOverride: true), spec-refusedRegistry-wide (a packaged
pkg_TYPEwith a spec-refused body, publish, every type inDEFAULT_METADATA_TYPE_REGISTRY): at base the host-config kernel answered differently from the environment kernel for 15 types (object409DESTRUCTIVE_CHANGE;hook,seed,mapping,page,app,action,dataset,datasource,doc,book,permission,position,tool,skill422INVALID_METADATA). At head both kernels give the same envelope for every type, and every type the door governs (allowOrgOverride: false,allowRuntimeCreate: true) answers 403NOT_OVERRIDABLE. The other 13 types answered alike on both kernels at base and at head.The window the door now precedes (M2)
Refusals
saveMetaItemcould answer on a host-config kernel between the door's position andrepo.put, for a packagedallowOrgOverride: falsesave:_lockgate, 403ITEM_LOCKED(already deferred to the door by hand; that guard is removed);OBJECT_OVERLAY_PACKAGE_MISMATCH;DESTRUCTIVE_CHANGE(measured above);INVALID_METADATA; the save-name refusal, 400VALIDATION_ERROR;FLOW_CONVERSION_CONFLICT;INVALID_METADATA, draft and publish (measured);VALIDATION_ERROR;INVALID_METADATA, publish only (measured);NOT_OVERRIDABLE, its own error class and sentence; measured live) and its object posture gate R1 (403, wirecodePERMISSION_DENIED,declaredCodeowd_widening_forbidden).The view-container collision refusal also sits there but judges only
view, which allows overlays, so the door never precedes it. ADR-0070 D1 (WRITABLE_PACKAGE_REQUIRED) judges onlyruntime-onlywrites, which the door never refuses.Mechanism assumptions, as measured
mainrefuses (autonumber-references-unknown-field); no pass-4 lift was needed.refusePackagedBaseOverride's own, already topology-independent (packagedBaseRefusalasks it on every topology for the/automationdoors). It andassertAllowedread the same registry-derivedallowOrgOverrideset, the sameOS_METADATA_WRITABLEhatch and the sameisWritablePackage(package-writability.ts), and both throw the samereadOnlyBaseOverrideErrorfor a named read-only base. They differ only in the fallback sentence for a type with no ADR-0126 regime row.0b997ea447. The door's input isisArtifactBacked, the same input the repository's intent uses, so stack-declared positions are refused by the door on both kernels (measured live forposition/sales_rep). Thesecurity-domain types arepermission,positionandcapability;capabilityis code-only and answers the code-only refusal ahead of the door, unchanged.viewand a packaged object with the hatch open are still judged by the gates.What does not move (Clause-② measurement, on this head)
artifactBacked && !isOverlayAllowed(type).repo.putrunsassertAllowedwith intentoverride-artifactwheneverartifactBacked, and that refuses on the same predicate, on every topology.saveMetaItemhas no success return beforerepo.put(the onereturnin that window is inside a closure). So a request the door refuses was refused before, and a request it admits meets the same checks as before. The unit tables above show every measured row refused at base and at head.dist/index.d.tsanddist/index.d.ctsof@objectstack/metadata-protocol, built from this head and from the merge base'sprotocol.ts(fe98cc63a4): the only differences are the two TSDoc paragraphs corrected above. No declaration line changes.allowOrgOverride: falsesave on a host-config kernel that one of the checks above refused, the answer is now 403NOT_OVERRIDABLE(or 403ITEM_LOCKEDfor a named read-only base) with the door's sentence, which is what an environment kernel already answered.Downstream pins that encoded the old host-config order
Each relied on the host-config kernel skipping the door. Each now reaches the check it tests the way an environment kernel always had to.
packages/objectql/src/protocol-destructive.test.ts: saved a destructive edit of a packaged object with noenvironmentId"to bypass the overlay opt-in gate". It now opensOS_METADATA_WRITABLE=object, the one route by which a packaged object's write reaches the destructive diff. Expectations unchanged.packages/rest/src/meta-object-owd-gate.test.ts: the lint-before-R1 order case and the two R1 refusals drove a packaged-object overlay with the hatch shut. They now open the hatch (the path R1's docblock names). Expectations unchanged. Two comments that said the door was environment-scoped are corrected.packages/plugins/plugin-security/src/packaged-permission-set-lock-gate.test.ts: the hatch-closed case pinned the lock's error class on host-config. With the hatch closed the door now answers first there, as on an environment kernel, so the case asserts the sameNOT_OVERRIDABLE/ 403 envelope and that the class is not the lock's. The hatch-open cases still pin the lock's class.Verification
protocol.ts(swapped on disk, blob-verified, restored by trap to the HEAD blob): 23 failed / 26 passed of 49, every failure a host-config row or a registry row; at head 49/49.pnpm --filter @objectstack/metadata-protocol exec vitest run: 222 files passed, 3 skipped; 28329 tests passed (at9e763ec0bc, before themainmerge, which touched only the seed loader in this package).81a42b1203, aftergit merge origin/mainand a rebuild: the new pin file 49/49;objectqlprotocol-destructive.test.ts7/7;plugin-securitypackaged-permission-set-lock-gate.test.ts7/7;restmeta-object-owd-gate.test.ts14/14; the fiveqa/dogfoodfiles that drivesaveMetaItem27/27.typecheck(withcheck:test-typecheckwhere the package has it) green formetadata-protocol,objectql,plugin-securityandrest.objectqlfull--project local(383 files passed; the 3 failures wereprotocol-destructive.test.ts, updated above),plugin-securityfull (181 files passed; the 1 failure was the lock-gate case updated above), the 35runtimefiles and 24restfiles that callsaveMetaItem(all green except the 3meta-object-owd-gatecases updated above).node scripts/pm/dispatch-gates.mjs --commandsderives 78 commands on81a42b1203. 70 ran on32d94c2d00(the merge commit; the one later commit only adds the ledger row); the 8 that row adds, the changeset gates and the ratchet families re-ran on81a42b1203. 77 exited 0;--ranreconciles 78 derived, 77 run, 1 unrun.check:engine-double-contractfirst exited 1 for the missing ledger row and exits 0 with it recorded.check:type-check-debt(a repository-wide re-measure) hit a 400 s local timeout: NOT MEASURED, left to CI.--format jsonreports 5 files, 0 errors, 0 warnings. This config enables no type-aware linting, so the diff cannot move a verdict on an untouched file.Serial notes
saveMetaItem's flow canonicalization and spec-parse region; this diff stays out of those lines.protocol.tsaround the drafts listing andsys-metadata-repository.ts; no overlap with this diff.Acceptance notes
packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts'shostConfigDoordocblock still sayssaveMetaItemskips its package door on host-config. The test measuresrepo.putdirectly and stays correct; only that sentence is now stale. Not edited here (outside the claimed file surface).packaged-base-regime.tsandsys-metadata-repository.tsdescribe the protocol door as environment-scoped (incomplete now, not false). Not edited here, for the same reason.assertAllowedhas refused these writes on those kernels since before this change; this diff moves no acceptance set, so it reverses no ADR decision. The ADR text is stale relative to shipped behaviour, not to this change.deleteMetaItemkeeps its ownenvironmentId-scoped removal door; this card is about the save door only.Generated by Claude Code