Repository navigation
fix(metadata-protocol)!: the by-name /meta read answers a disabled package's non-object item as absent, as the list omits it - #24223
Conversation
…kage's non-object item as absent, as the list omits it The dispatcher's registry and metadata-service reads (/state/FIELD, the one-segment legacy exit, the item read's service fallback) and its /published fall-through answer the same way. Claude-Session: https://claude.ai/code/session_01Mr42C6J5TavuAMC2uqtFbR Co-authored-by: Claude <noreply@anthropic.com>
…he dispatcher doors Claude-Session: https://claude.ai/code/session_01Mr42C6J5TavuAMC2uqtFbR Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mr42C6J5TavuAMC2uqtFbR Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 47 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 fbe012729ce47777e47a0bd9ac816a3d4763fe3d && git checkout fbe012729ce47777e47a0bd9ac816a3d4763fe3d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b9ac35cbfe60afcea88947ed4eaf8c50e3efc214 ec7d6af8b8b7e75fc2e7d61e29a3dcf15e350ab4 && git checkout -B drift-repro b9ac35cbfe60afcea88947ed4eaf8c50e3efc214 && git merge --no-ff ec7d6af8b8b7e75fc2e7d61e29a3dcf15e350ab4
node scripts/docs-audit/affected-docs.mjs --json b9ac35cbfe60afcea88947ed4eaf8c50e3efc214
|
…ta-by-name-disabled-non-object
…d package's item as the plain read does; the catalog echo reads its binding off the kept envelope Claude-Session: https://claude.ai/code/session_01Mr42C6J5TavuAMC2uqtFbR Co-authored-by: Claude <noreply@anthropic.com>
…sets for rest and plugin-security Claude-Session: https://claude.ai/code/session_01Mr42C6J5TavuAMC2uqtFbR Co-authored-by: Claude <noreply@anthropic.com>
…chema does not list package (TS2367) Claude-Session: https://claude.ai/code/session_01Mr42C6J5TavuAMC2uqtFbR Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs: card #24210 (body; comments 6239527781, 6239967422, 6240246860, 6240299461, 6241496854, 6241893667, 6241900550, 6243012505, 6243145989), the #24097 ruling 6236971888, the #7557 pin's docblock, PR #24223's body and file list, the net diff Check-runs at read time (2026-10-17, after 23:46Z): every run ① Derived judgmentsThe by-name read,
The dispatcher (meta.ts), four doors, cross-lane to
REST (rest-server.ts), plugin-security, Blast radius, every in-repo caller of What each door answers now (disabled package's item | triage's answer | a missing name):
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Part of #24210 and carries no closing keyword, so the card stays open for the doors stopped at the fence (the "Part-of PR must not also close its card" check is green). |
| Scope | 12 files (+606 / −20), the re-declared claim's surface (6241900550): protocol.ts, the #7557 pin, runtime/src/domains/meta.ts and its pin, rest-server.ts's fetchCurrentMetaDocument only and its pin, security-catalog-echo.ts's readServedPackageBinding only and its pin, and four changesets. Nothing inside registerMetadataEndpointsInner. Cross-lane edits are declared on #6024 (no objection, notice 6243012505) and #6021. Not governed (check-governed-merges.mjs --pr 24223: 0 of 12). |
| Ruling | Seat ruling 6241893667, item by item. 1: the event and diff doors give a withheld gated item the plain read's 404 RESOURCE_NOT_FOUND; a deleted item (every layer null) keeps its answer. Read in the diff: the throw fires only when getMetaItemLayered.effective is non-null. 2: readServedPackageBinding reads the envelope's packageId, the same served document's _packageId, with no new method. 3–6: as ruled, named in the body. |
| HTTP | Before/after on pnpm dev:crm --fresh (report 6243145989). Disabled, gated author: /history, /audit, dashboard /diff went from 200 (bypass) to 404 RESOURCE_NOT_FOUND. Enabled rows and the member's 403 are unchanged; the deleted item is unchanged; re-enable restores every row. |
| Review face | Narrowing (Clause-②: no (narrowing)). The CONTRACT_REVIEW_TIER record 6243449822 on this head is PASS. The seat verified its central claims in the diff and the cited code: the fetchCurrentMetaDocument throw condition; readServedPackageBinding → served.packageId; the CLI ceremony's lock.verdict reads editable / lock / lockReason only (organization-ownership.ts:817), so the kept envelope gives it the same verdict; the four changesets' levels. |
| Prose face | Changesets, sentence by sentence against the diff and the record's per-door table. metadata-protocol, runtime and rest are each minor with BREAKING, Clause-②: no (narrowing) and the adr-0087 not-required (no-migration-prescription) marker; the runtime /published bullet names objects only, and the rest "Absent now" list names the gated types only, as the diff does. plugin-security is patch (behaviour unchanged against main). Hand-written docs: none states that a disabled package's item is served by name; the platform checklist's package-cycle item already reads disable as enforcement. |
| Pins and ablation | READERS gains four app rows and two generated rows for each of the 27 declared types but object. The new door pins cover the dispatcher (7/7), the event and diff doors, and the echo (owned while disabled and after re-enable). Seven single ablations (a)–(g) each redden only their own case; every restore is clean. |
| Gates | Dev union at ec7d6af8: 73 derived, 73 run, 0 NOT MEASURED. Typecheck scripts of all five touched packages exit 0. CI on this head at this read: 47 check-runs, 39 success, 8 skipped, 0 failed. Every skip is on the EXPECTED_SKIPS roster; Auto Label and Check PR Size are skipped on the body-edit pass only and green on the push pass. |
Disposition:
open_questions[0](flat vs nested 404 body on the event and diff doors): A, folded into this card's door-side follow-up (render throughsendMetaItemAbsentineventDoorRefusaland the/diffbranch). Status and code already match the plain read.- Remaining on metadata-protocol: a by-name /meta read of a non-object item (app, view, page, …) serves a disabled package's item while the list omits it; #24097 closes the object half only #24210, dispatched by this seat after this PR lands: non-object
?layers=true,/layersand/published(both transports); REST/meta/object/NAME/state/FIELD;GET /meta/_drafts; the event and diff body dialect. Notice 6243012505 releases the move-series sequencing. - Named, nothing owed (record ③.7–③.9): the ungated types' event and diff doors (no per-caller gate to bypass);
seedandmappingcallers reading absence; the list filter's optional call on a degenerate host. deviations: accepted (Part ofoverFixes, per os-dev.md; the round-1objectqltypecheck miss, fixed and green; the dispatcher measured throughcreateHonoApp's fetch handler; the body re-worded to the head by this seat).
Landing: ordinary queue (pr_ready + automerge_enable through the relay); every check on this head is green or a rostered skip.
Generated by Claude Code
Part of #24210 (it covers the by-name read and the dispatcher's doors; the doors listed under "Stopped at the fence" stay open and are why this does not close the card)
Clause-②: no (narrowing)
Implements triage 6239967422 on #24210 (absence for a non-object type;
OBJECT_PACKAGE_DISABLEDstays the object answer) under claim 6240246860, dispatched by thedomain:engineseat 1 (session_01JtSQQ3EWsbNPzyYMug1tXy). Rework round: seat ruling 6241893667 under the re-declared claim 6241900550 (items 4 and 5 below; Q1, Q2 and Q4 settled A).What changed
Disabling a package already took its items out of every
GET /meta/:typelist. The by-name read still served them. It now answers them the way it answers a name with nothing behind it.getMetaItem(packages/metadata-protocol/src/protocol.ts). After the body read (readMetaItem, unchanged), an item of any type exceptobjectandpackagewhose_packageIdnames a disabled package is answered as absent:previewDraftsserve noitem, and both transports answer that404 RESOURCE_NOT_FOUND, byte for byte the answer for a missing name;404 NO_DRAFT(one constructor,noPendingDraftError, for both);registrationis dropped with the item;lock,editable,deletable,versionand the rest) stays. It reports the write doors' verdicts about the address, and a disable does not change how the lock gate judges a save there. The CLI ownership ceremony gates its own raw-SQL writes on exactly these fields (lock.verdict), so this keeps a locked item locked for it.One predicate (H1).
isItemOfDisabledPackage(registry, item)is the list read's filter (readFlattenedMetaItems) and the by-name read's check. Both askregistry.isPackageDisabled(item._packageId)of the item the read resolved, so they hide the same items. There is no new public method, spec key or error code.The dispatcher (
packages/runtime/src/domains/meta.ts, cross-lane, declared todomain:cli). Four doors read past the protocol and served a disabled package's object or item:/meta/object/NAME/state/FIELDand the legacy one-segment/meta/NAMEexit readregistry.getObject. Both now askdisabledObjectAnswerfirst, which answers the protocol's own404 OBJECT_PACKAGE_DISABLED;/meta/:type/:name/publishedswallowed the layered read's refusal and fell through to the published snapshot.OBJECT_PACKAGE_DISABLEDis now the answer, as onRestServer's/published;_packageIdnames a disabled package (isServiceItemOfDisabledPackage, the same predicate).fetchCurrentMetaDocument(packages/rest/src/rest-server.ts, the class member only, nothing insideregisterMetadataEndpointsInner; ruling item 1). It is the current document the/history,/auditand/diffdoors' per-caller gate judges. With item 1 a disabled package's item read as "no current document", so those doors served events, and a view's or dashboard's diff, past the gate. When the plain read serves no item but the layered read (which keeps serving every layer) still resolves one, the member now throws the plain read's absence,404 RESOURCE_NOT_FOUND, which the doors' own catch renders. An item that is really gone (every layer null, such as a deleted item) keeps today's answer.readServedPackageBinding(packages/plugins/plugin-security/src/security-catalog-echo.ts, that function only; ruling item 2). It is a resolution read, so it reads the binding off the protection envelope's declaredpackageId(the served document's_packageId, kept when the item is withheld) instead ofitem._packageId. A disabled package's permission set or position echoes owned, and stays owned after a re-enable. No new public method, no enforcement change.packages/rest/src/meta-item-read-gate.tsis not edited.Premise: the callers of a by-name read of a non-object item
Measured before any edit. "Today" is
origin/mainb7f29ddfe; "after" is this branch. The case is an item of a disabled package. objectui is read atorigin/main54e79f9(read-only clone). No caller has to tell "disabled" from "missing", and none must be served the item, so absence holds.data-objectstackprobeMetaItemAccessandgetAppAccessVerdict(viaAppContent.tsx,useMetaItemRefusal.ts)granted, andAppContentshows the neutral "App not available" screen: its own comment names this case, "the by-name route served the app while the refreshed list still does not carry it"not_found: "This app can't be opened". Its verdict set has no disabled arm; denied stays denieddata-objectstackgetApp,getPage,getView, the batchgetItem(category, …)null(absence), the face a missing item getsMetadataProvider.getItem(type, name),useActionModal(page),FormPage.loadInternalForm(view),DocPage/DocsSlug(doc)DraftChangesPanel(/meta/TYPE/NAMEand?state=draft)404, read asnullsides. Not a reader that must be served: see Acceptance notes on_draftsPackagesPage,PackagedAutomationPage,PackagedActionsSection(Studio package screens, re-enable)/api/v1/packages,_drafts, listspackageis never hidden/layers(ResourceEditPage,EmbeddedItemEditor,PermissionMatrixEditor,LayeredDiff)/layersis a stopped door); every entry point lists firstpackages/cliownership ceremonylock.verdict(organization-ownership.ts),PRESENTATIONAL_TYPES= view, dashboard, report, translation, email_templateeditable/lockpackages/plugins/plugin-securityreadServedPackageBinding(security-catalog-echo.ts),permission/positiongetMetaItemLayered, which decides whether the echo exists, is not touchedpackages/restfetchCurrentMetaDocument(/history,/audit,/diff)404 RESOURCE_NOT_FOUND(item 4; measured below)packages/restimport mapping,service-automationconnector pull (mapping)packages/runtimeapplyPublishedSeeds,service-analyticsseed draft probe (seed)NO_DRAFT(already caught). The seed's objects are refused by the data plane anywaypackages/coreimport runner,plugin-authadmin user import,restdata-action routestype: 'object'onlyDoors, measured over HTTP
REST:
pnpm dev:crm -- --fresh(real socket), before atb7f29ddfe, after at this branch with a fullpnpm build(76/76). Dispatcher: the real store (SqliteWasmDriver) and the real protocol (ObjectQLPlugin) undercreateHonoApp, driven through its fetch handler, thepackages/qa/http-conformanceharness shape (scratch script, not committed). Both against a writable packageapp.probe-24210holding objectprobe_gadget, app, page, dashboard, flow and view.GET /meta/TYPEcontains the item (6 types)GET /meta/TYPE/NAME, app, page, dashboard, flow, viewRESOURCE_NOT_FOUND, = missing nameRESOURCE_NOT_FOUND, = missing nameGET /meta/TYPE/NAME?preview=draft, same sixRESOURCE_NOT_FOUNDRESOURCE_NOT_FOUNDGET /meta/TYPE/NAME?state=draft, same sixNO_DRAFTNO_DRAFTNO_DRAFTNO_DRAFTGET /meta/TYPE/NAME?layers=true//layers, same sixGET /meta/TYPE/NAME/published, same sixGET /meta/object/probe_gadget/publishedOBJECT_PACKAGE_DISABLEDOBJECT_PACKAGE_DISABLEDRESOURCE_NOT_FOUND(snapshot fall-through; a host with a metadata service serves the snapshot)OBJECT_PACKAGE_DISABLEDGET /meta/object/probe_gadget/state/status?from=openOBJECT_PACKAGE_DISABLEDGET /meta/probe_gadget(one segment)INVALID_REQUEST(the list route)INVALID_REQUEST, enabled and disabled alikeGET /meta/object/probe_gadget,/data/probe_gadgetOBJECT_PACKAGE_DISABLEDOBJECT_PACKAGE_DISABLED/stateByte equality: on REST the disabled item and a missing name both answer
{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}; on the dispatcher both answer{"success":false,"error":{"code":"RESOURCE_NOT_FOUND","message":"Not found","httpStatus":404}}. The enabled readings after are identical to the enabled readings before on both transports, and re-enabling restores every row. The REST server log carried 0failing openwarnings.Mechanism hypotheses (measured)
_packageIdon the resolved item: the stored row'spackage_id(grafted inreadMetaItem), the registry item (stamped byregisterItem), the artifact envelope. The list filter reads the same field off its merged items, so one helper serves both.item == null(createMetaItemAnsweron REST, thedata?.item != nullhit test on the dispatcher); the cached arm throwsmetadataItemNotFoundErroron a falsy item, and REST maps both tosendMetaItemAbsent. The dispatcher half needed the service-fallback guard above, because its miss fell through to the metadata service./published: half holds. For objects, REST already re-throws the layered refusal (typeof status === 'number'), measured 404OBJECT_PACKAGE_DISABLEDbefore any edit; the dispatcher's blanket catch is now narrowed inmeta.ts. For non-object types the producer side cannot carry it: see below./state/FIELDreads the registry directly and served the object; closed. The one-segment/meta/NAMElegacy exit is not reachable over HTTP on a real composition:refuseUnknownMetaListTypeanswers 400 first, enabled and disabled alike. It is reachable only on a host whose protocol declares no type set; closed in code and pinned there.Stopped at the fence (open on #24210 for a follow-up dispatch after this PR lands; #23050's move series is no longer in flight, notice 6243012505)
Ruling 6241893667 items 3, 4 and 6: these stay on the card, on the door side, both transports together, after slice 13e.
?layers=true,/layersand/publishedfor a non-object item, both transports. ProducergetMetaItemLayeredkeeps serving its resolution reader, the security catalog echo (echoSecurityCatalogMutation,echoStoredSecurityCatalogOverlays), which turns a storedpermission/positionoverlay into what an environment kernel enforces; absence there would be an enforcement change. The doors close on the door side (rest-server.ts's layered and/publishedhandlers insideregisterMetadataEndpointsInner, andmeta.ts's twins)./published's non-object fall-through is alsoMetadataManager.getPublished(packages/metadata), whose document carries no_packageId./meta/object/NAME/state/FIELD, the interim transport split. REST's state route readsql.registry.getObject(name)directly and serves a disabled package's object (measured 200 after this PR); the dispatcher twin answers 404OBJECT_PACKAGE_DISABLED. The one-door split holds until the REST half lands with item 1.GET /meta/_draftsstill lists a disabled package's drafts while the by-name draft read answersNO_DRAFTfor them: another door of the same rule, joining item 1's follow-up.The event and diff doors, measured over HTTP (item 4)
pnpm dev:crm -- --fresh, real socket, full build after mergingorigin/main. "Before" is this branch without item 4 (head8946a02d4, the merge on top ofb2f89e8f); "after" is232339ab8. A writable packageapp.gate-24210holds dashboardgate_dashand appgate_app, each saved twice, each withrequiredPermissions: ['gate_24210_cap']; dashboardgate_gonewas saved and deleted.studio.access/manage_metadata, so the three doors admit it, and notgate_24210_cap, so the item's own gate refuses it the item whole: the caller the bypass reaches.POST /auth/admin/create-user(no capabilities). The doors' capability gate refuses it first on every row:403 FORBIDDENon/history,/audit,/diff, enabled and disabled, before and after.GET /meta/dashboard/gate_dashand/meta/app/gate_appPERMISSION_DENIEDPERMISSION_DENIED/history,/audit,/diffof bothPERMISSION_DENIEDPERMISSION_DENIEDGET /meta/dashboard/gate_dashand/meta/app/gate_appRESOURCE_NOT_FOUNDRESOURCE_NOT_FOUND/meta/dashboard/gate_dash/historyRESOURCE_NOT_FOUND/meta/dashboard/gate_dash/auditRESOURCE_NOT_FOUND/meta/dashboard/gate_dash/difflabelv1 to v2)RESOURCE_NOT_FOUND/meta/app/gate_app/history,/auditRESOURCE_NOT_FOUND/meta/app/gate_app/diffRESOURCE_NOT_FOUNDRESOURCE_NOT_FOUNDgate_gone/history,/diffEnvelope position: the plain read answers
{"error":{"code":"RESOURCE_NOT_FOUND","message":"Metadata item not found or access denied."}}; the event and diff doors now answer status 404 and codeRESOURCE_NOT_FOUNDin the classification door's flat shape,{"error":"Metadata item not found or access denied.","code":"RESOURCE_NOT_FOUND"}, because the member throws and the doors' catch (handleRouteError) renders it. The app/diffrow, which answered the nested body before through the door's own absent check, now answers the flat one. A byte-identical nested body needs the door side (eventDoorRefusaland the/diffbranch), outside this fence.Pins
packages/objectql/src/package-disable-enforcement.test.ts, the Package disable and uninstall never reach the metadata/data layer: a disabled package's objects still serve rows, and uninstall leaves 7 orphanedsys_metadatarows #7557 pin.READERSgains four app rows (getMetaItemactive, plural,state: 'draft',previewDrafts) and two generated rows per typeMetadataTypeSchemadeclares exceptobject(27 types today; the schema does not listpackage): listing and by-name, so a new type gets its rows with nothing to write. The seed registers one item of every such type per package, as a code package registers it, plus a pending app draft. New cases: absence equals the missing name on every type (noitem, noregistration, andNO_DRAFTwith the missing draft's exact message); the control, another package's item of every type still listed and served by name; the envelope is kept byte for byte apart fromitem.packages/runtime/src/domains/meta-disabled-package-doors.test.ts(new), throughHttpDispatcher.dispatch:/state/FIELDand the one-segment exit refuseOBJECT_PACKAGE_DISABLEDand never askregistry.getObjectfor the name;/publishedanswers the refusal and never asksgetPublished; the service fallback's answer for a disabled package's page is byte-identical to a missing name's; a control for each.packages/rest/src/meta-alternate-door-read-gates.test.ts: a gated dashboard and app the plain read withholds while their layers resolve, driven with a caller the item's own gate refuses:/history,/audit,/diffanswer 404RESOURCE_NOT_FOUND(403PERMISSION_DENIEDwhile enabled), no secret in the body, andhistoryMetaItem/auditMetaItem/diffMetaItemnever called. The existing "nothing behind the name" case now says so on both reads (its layered double served the doc), so it still pins the deleted item's answer:/diffabsent,/historyits events.packages/plugins/plugin-security/src/security-catalog-echo.test.ts: a disabled package's set echoes owned (by-name item withheld, anti-vacuity), stays owned after the re-enable, and re-echoes owned; thestubProtocoldouble now answers the envelope'spackageIdas the protocol'sservedLockStatedoes.git grepofdisablePackage(,/disable,isPackageDisabledand theNO_DRAFTsentence across every test: no existing pin asserts a disabled package's non-object item is served by name, so nothing flips; the suites of every package with a hit were run (below).Verification (head
232339ab8, after mergingorigin/mainatb9ac35cbf)The head is now
ec7d6af8b, one commit later. It changes only theBY_NAME_TYPESfilter line and its docblock inpackage-disable-enforcement.test.ts(TS2367). Atec7d6af8b, the typecheck scripts (tscpluscheck:test-typecheck) ofobjectql,metadata-protocol,runtime,restandplugin-securityeach exit 0, and the pin is 15/15.@objectstack/metadata-protocol244 files / 20641 passed (3 skipped files);@objectstack/objectql438 / 8249 passed;@objectstack/runtime413 / 7095 passed;@objectstack/rest299 / 6284 passed (339 skipped);@objectstack/plugin-security188 / 3737 passed (45 skipped).metadata-protocol,runtime,rest,plugin-security. Fullpnpm build76/76 after the merge.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 73 commands at232339ab8; all 73 exited 0 (the clone is unshallow now, so the two shallow-clone refusals of the first round did not recur).--ranwith exit codes: "73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN".check-changeset-no-majorandcheck-adr-0087-registration --base origin/main: exit 0.scripts/ablation-replace.mjswith atraprestore proven byblob == HEAD:getMetaItem's check replaced by|| true,pnpm --filter @objectstack/metadata-protocol build,ablation-dist-preflight --absentpassed (the suite resolvesdist/). 31 by-nameREADERSrows went red (the 27 generated rows, one per declared type butobject, and the 4 app rows), 0 listing rows; 3 failed / 12 passed. Restored and rebuilt, marker back indist/index.js: 15 / 15.meta.ts(the suite importssrc): the/statecheck, the one-segment check, the/publishedre-throw and the service-fallback guard, each removed alone, each turned exactly its own case red (1 failed / 6 passed); restored, 7 / 7.fetchCurrentMetaDocument's throw disabled: only the new withheld-item case went red (1 failed / 426 passed); restored, 427 / 427.readServedPackageBindingback onitem._packageId: only the new disabled-package echo case went red (1 failed / 18 passed); restored, 19 / 19.Acceptance notes
origin/mainwas merged into the branch (8946a02d4, a merge commit; no rebase, no force-push), with no conflict.requiredPermissions, which is the gated caller measured above.@objectstack/objectql: its diff is a test file, outside its publishedfiles.Generated by Claude Code