fix(metadata-protocol): allow REMOVING a legacy env overlay on a rolled-back overlayable type (#6960) - #7429
Conversation
…ed-back overlayable type (#6960) #6483 / PR #6608 closed the per-org WRITE door on `permission` / `position` / `page` / `app` / `dataset` / `book` and left `supportsOverlay: true` alone, so a row authored before the rollback still merges overlay-wins at read time while the ordinary delete answered 403. Per the maintainer's ruling of 2026-08-10 the delete side moves: removing the overlay restores the code-declared state, is strictly narrowing, and cannot widen anything. Both refusal points are treated, because the second is topology-independent: * `deleteMetaItem`'s `environmentId !== undefined` two-tier block, which refused before it even probed for the row; * `SysMetadataRepository`, which now routes the DELETE verb through `assertDeleteAllowed` while `put` keeps calling `assertAllowed` unchanged — so a control-plane kernel, which skips the first gate entirely, is released by the same rule rather than left half-fixed. The carve-out is keyed on the registry's `supportsOverlay` flag, not on `allowOrgOverride`, so it stops at the tier boundary: `object` (`supportsOverlay: false`, its overlay a contributor layer per ADR-0029 D9) keeps refusing both verbs, which is D9.6's declared cost. Create and update are untouched, and both gates' doc comments now record the delete-only asymmetry so it is not later "fixed" into symmetry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
ACCEPT — PM review (step 7)File surface vs declarationFour files, exactly as declared:
CI, per job25 check runs, all What I am acceptingThe tier boundary resolved into a registry-derived predicate rather than a hardcoded list, and that is the better answer. I dispatched this with an enumeration ( Both refusal points moved, which is what makes this a whole fix rather than half of one. Create and update are verifiably untouched: Reverse verification — the variant is what earns the ACCEPTReverted against the dev's own recorded base The variant verification is decisive and is exactly the damage case I required. Relaxing the predicate for every artifact-backed type turned D9's own pin file to One missed prediction, reported rather than buried, and it found a second facet of the defect. The guard "a delete with NO overlay row answers the no-op success instead of 403" went red on the project kernel, because the refusal ran before the probe for the row — so pre-fix, an artifact-backed item with nothing customized at all was refused too. The card described that ordering ("throws … before any probe for the row") without drawing out the consequence. Kept in the file with a corrected comment. Guards labelled as guards (green in both directions, marked Suites all green with the consumption radius covered:
|
|
PM note — ACCEPT stands; the ready-flip is deferred on a GraphQL rate limit, not on anything about this PR.
Recording the pending state here rather than only in session context, so it survives a context reset or a seat handover:
Nothing is required from the dev, and the PR needs no changes. Reads (REST) are on a separate quota and still working, which is why this comment posted. Generated by Claude Code |
Fixes #6960
Base of this branch:
f16e54e1d— the commit that landed ADR-0029 D9 and its pins. Every reverse-verification direction below was measured against that SHA, never a movingorigin/main.What moved, and what did not
The maintainer's ruling of 2026-08-10 moves the delete side only. Two tiers reach the same two refusal points, and the separator is the registry's own
supportsOverlayflag — notallowOrgOverride:permission/position/page/app/dataset/book(andtool/skill, same shape)supportsOverlay: true+allowOrgOverride: falseobjectand every othersupportsOverlay: falsetypesupportsOverlay: falsesupportsOverlayis the capability of the READ path ("an overlay row under this name changes what is served");allowOrgOverrideis the permission on the WRITE path ("a tenant may author one"). #6483 / PR #6608 rolled the permission back and deliberately left the capability alone — which is exactly the state #6960 was filed about. Keying the relaxation on the capability is what makes it stop at the tier boundary: anobjectoverlay registers as its own contributor layer (ADR-0029 D9) rather than merging, and its reset refusal is D9.6's declared, maintainer-approved cost.Both refusal points are treated, because the second is topology-independent:
deleteMetaItem'senvironmentId !== undefinedtwo-tier block — which refused before it ever probed for the row;SysMetadataRepository's delete gate — reached by a control-plane kernel (environmentId === undefined), which skips (1) entirely. A fix pinned on only one path would have left the defect alive on one topology.File surface, file by file
packages/metadata-protocol/src/protocol.ts— adds the registry-derivedOVERLAY_CAPABLE_TYPESset and themergesOverlayAtRead(type)predicate beside the existingisOverlayAllowed/isRuntimeCreateAllowedfamily (derived, plural-normalized, no parallel whitelist — Prime Directive Convert to monorepo with scoped packages #8). Adds&& !legacyOverlayRemovaltodeleteMetaItem'sartifactBacked && !overlayAllowedrefusal, with the ruling, the boundary and the delete-only asymmetry documented at the call site. ExtendsdeleteMetaItem's two-tier header comment with the new bullet. Extends the save-side gate's carve-out comment — the one that already promised "removing a code-only row that predates this refusal is repair, and must stay possible" — to name the artifact-backed tier it did not cover, which is the divergence between stated intent and enforced behaviour the issue opened with.saveMetaItem's gates themselves are untouched; the only edits insidesaveMetaItemare comment lines.packages/metadata-protocol/src/sys-metadata-repository.ts— adds the registry-derivedOVERLAY_CAPABLE_TYPESset and a new privateassertDeleteAllowed(type, intent);delete()now calls it,put()still callsassertAlloweddirectly. The carve-out is scoped to theoverride-artifactintent (runtime-onlystays governed byallowRuntimeCreate).assertAlloweditself is unchanged, byte for byte.packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts— new, 101 cases..changeset/legacy-overlay-delete-rolled-back-types.md— new,patchon@objectstack/metadata-protocol.No other file is touched.
packages/specis read, never changed — the acceptance surface is untouched, which is the distinction the dispatch's STOP condition 2 draws.Reverse verification — four categories, directions declared before running
1. Predicted red, measured red
Fix taken out with
git checkout f16e54e1d -- (the two source files)(nevergit stash— shared stack), test file kept:Predicted 56, measured 57 (the extra one is category 3). Both refusal points reproduced, one per topology, with their real messages:
That is 8 types x 3 cases x 2 topologies at the protocol layer, plus 8
SysMetadataRepository.deletecases, plus the category-3 case. Every one of them is green with the fix in place.2. Green in BOTH directions — guards, not evidence
Labelled
[GUARD]in their own test names as well as here: theobject-tier delete refusals on both topologies; the same refusal for every othersupportsOverlay: falsetype with a runtime write channel;putstill refusing anoverride-artifactwrite of every rolled-back type;saveMetaItemstill refusing both the plain and the org-scoped write; theruntime-onlydelete still judged byallowRuntimeCreate. They pass before and after — what makes them load-bearing is the variant experiment below, not this run.3. Missed prediction — one, kept rather than tidied away
a delete with NO overlay row answers the no-op success instead of 403was written as a guard and measured red on the project kernel. Cause identified: the refusal ran before the probe for the row (the issue's own words), so an artifact-backed item with nothing customized was refused too — the miss is real coverage the prediction did not anticipate, not a broken assertion. On the control-plane kernel it was already green: the probe returns first andrepo.deleteis never reached. The case keeps its corrected comment in the file.4. Unmeasured
None. Every assertion in the new file was run in both directions.
Variant verification — the damage case, measured
The obvious over-broad fix is to relax the shared predicate for every artifact-backed type. Applied exactly that (
mergesOverlayAtReadreturning unconditionallytrue, andassertDeleteAllowedreturning early for any non-runtime-onlyintent), rebuilt, and ran ADR-0029 D9's own pins:Exactly D9's two delete pins, one per topology, red — and 14 of this PR's own boundary guards red with them. The variant was reverted and D9's file is back to 16/16 passed under the shipped scoping. That is what makes the scoping necessary rather than merely sufficient: D9's pins were never edited, adjusted, or worked around.
Tests
metadata-protocolhas notypecheckscript by design — it carries a measured DEBT entry (63) inscripts/check-type-check-coverage.mjs. Rantsc --noEmit -p tsconfig.jsondirectly: 63 errors, unchanged, and zero of them in the new test file, so the ratchet is not raised.Family gates that ride the ESLint job, all green locally:
check:nul-bytes(plus a targeted control-byte self-scan over all four touched files, no hits),check:engine-double-contract,check:error-code-casing,check:route-envelope,check:meta-type-normalized,check:adr-anchors.One finding filed, not fixed here
#7426 —
deleteMetaItem'scatchre-wrap carriesstatusforward but dropscode, so a repository refusal reaches the caller as a 403 with no catalogued code on the control-plane topology. It is why ADR-0029 D9's control-plane pin asserts a message substring while its project-kernel sibling assertscode+status, and why this PR's own control-plane assertions state the gap instead of asserting around it — carrying the fullcode+statusenvelope one layer down, directly againstSysMetadataRepository, where nothing re-wraps it. Out of scope: fixing it changes the error envelope of every non-conflict failure on that path, which this ruling does not reach.Generated by Claude Code