Skip to content

fix(metadata-protocol)!: the by-name /meta read answers a disabled package's non-object item as absent, as the list omits it - #24223

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24210-meta-by-name-disabled-non-object
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24210-meta-by-name-disabled-non-object

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

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_DISABLED stays the object answer) under claim 6240246860, dispatched by the domain:engine seat 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/:type list. The by-name read still served them. It now answers them the way it answers a name with nothing behind it.

  1. getMetaItem (packages/metadata-protocol/src/protocol.ts). After the body read (readMetaItem, unchanged), an item of any type except object and package whose _packageId names a disabled package is answered as absent:

    • the active read and previewDrafts serve no item, and both transports answer that 404 RESOURCE_NOT_FOUND, byte for byte the answer for a missing name;
    • the strict draft read throws the missing draft's 404 NO_DRAFT (one constructor, noPendingDraftError, for both);
    • a flow's registration is dropped with the item;
    • the protection envelope (lock, editable, deletable, version and 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.
  2. One predicate (H1). isItemOfDisabledPackage(registry, item) is the list read's filter (readFlattenedMetaItems) and the by-name read's check. Both ask registry.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.

  3. The dispatcher (packages/runtime/src/domains/meta.ts, cross-lane, declared to domain:cli). Four doors read past the protocol and served a disabled package's object or item:

    • /meta/object/NAME/state/FIELD and the legacy one-segment /meta/NAME exit read registry.getObject. Both now ask disabledObjectAnswer first, which answers the protocol's own 404 OBJECT_PACKAGE_DISABLED;
    • /meta/:type/:name/published swallowed the layered read's refusal and fell through to the published snapshot. OBJECT_PACKAGE_DISABLED is now the answer, as on RestServer's /published;
    • the item read of a non-object type fell back to the metadata service when the protocol served no item. The service knows nothing of a disable, so the fallback now skips an item whose _packageId names a disabled package (isServiceItemOfDisabledPackage, the same predicate).
  4. fetchCurrentMetaDocument (packages/rest/src/rest-server.ts, the class member only, nothing inside registerMetadataEndpointsInner; ruling item 1). It is the current document the /history, /audit and /diff doors' 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.

  5. 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 declared packageId (the served document's _packageId, kept when the item is withheld) instead of item._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.ts is not edited.

Premise: the callers of a by-name read of a non-object item

Measured before any edit. "Today" is origin/main b7f29ddfe; "after" is this branch. The case is an item of a disabled package. objectui is read at origin/main 54e79f9 (read-only clone). No caller has to tell "disabled" from "missing", and none must be served the item, so absence holds.

Call site What it does Today After
objectui data-objectstack probeMetaItemAccess and getAppAccessVerdict (via AppContent.tsx, useMetaItemRefusal.ts) explains why an app, dashboard or view in the URL is not in the list granted, and AppContent shows 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 denied
objectui data-objectstack getApp, getPage, getView, the batch getItem(category, …) render-time reads served null (absence), the face a missing item gets
objectui MetadataProvider.getItem(type, name), useActionModal (page), FormPage.loadInternalForm (view), DocPage / DocsSlug (doc) render the item served not found. Each uses the item, and the list already omits it
objectui DraftChangesPanel (/meta/TYPE/NAME and ?state=draft) diff a pending draft against its published side both sides served both 404, read as null sides. Not a reader that must be served: see Acceptance notes on _drafts
objectui PackagesPage, PackagedAutomationPage, PackagedActionsSection (Studio package screens, re-enable) /api/v1/packages, _drafts, lists not a by-name reader unchanged. package is never hidden
objectui editors on /layers (ResourceEditPage, EmbeddedItemEditor, PermissionMatrixEditor, LayeredDiff) layered view served unchanged here (/layers is a stopped door); every entry point lists first
packages/cli ownership ceremony lock.verdict (organization-ownership.ts), PRESENTATIONAL_TYPES = view, dashboard, report, translation, email_template gates raw-SQL writes on editable / lock the item's real verdict the same verdict: the envelope is kept (pinned: "the protection envelope stays")
packages/plugins/plugin-security readServedPackageBinding (security-catalog-echo.ts), permission / position reads the package that owns a stored overlay, for an environment-kernel echo the package id the package id, read off the kept envelope (item 5; pinned owned through disable and re-enable). getMetaItemLayered, which decides whether the echo exists, is not touched
packages/rest fetchCurrentMetaDocument (/history, /audit, /diff) the current document the per-caller gate judges the item absence: the doors answer 404 RESOURCE_NOT_FOUND (item 4; measured below)
packages/rest import mapping, service-automation connector pull (mapping) use the mapping served not found. Each uses the item
packages/runtime applyPublishedSeeds, service-analytics seed draft probe (seed) apply a seed / find a pending seed draft served not found / NO_DRAFT (already caught). The seed's objects are refused by the data plane anyway
packages/core import runner, plugin-auth admin user import, rest data-action routes read type: 'object' only unchanged by this PR unchanged

Doors, measured over HTTP

REST: pnpm dev:crm -- --fresh (real socket), before at b7f29ddfe, after at this branch with a full pnpm build (76/76). Dispatcher: the real store (SqliteWasmDriver) and the real protocol (ObjectQLPlugin) under createHonoApp, driven through its fetch handler, the packages/qa/http-conformance harness shape (scratch script, not committed). Both against a writable package app.probe-24210 holding object probe_gadget, app, page, dashboard, flow and view.

Request, package disabled REST before REST after Dispatcher before Dispatcher after
GET /meta/TYPE contains the item (6 types) no no no no
GET /meta/TYPE/NAME, app, page, dashboard, flow, view 200, served 404 RESOURCE_NOT_FOUND, = missing name 200, served 404 RESOURCE_NOT_FOUND, = missing name
GET /meta/TYPE/NAME?preview=draft, same six 200, served 404 RESOURCE_NOT_FOUND 200, served 404 RESOURCE_NOT_FOUND
GET /meta/TYPE/NAME?state=draft, same six 404 NO_DRAFT 404 NO_DRAFT 404 NO_DRAFT 404 NO_DRAFT
GET /meta/TYPE/NAME?layers=true / /layers, same six 200, served 200, served (stopped) 200, served 200, served (stopped)
GET /meta/TYPE/NAME/published, same six 200, served 200, served (stopped) 200, served 200, served (stopped)
GET /meta/object/probe_gadget/published 404 OBJECT_PACKAGE_DISABLED 404 OBJECT_PACKAGE_DISABLED 404 RESOURCE_NOT_FOUND (snapshot fall-through; a host with a metadata service serves the snapshot) 404 OBJECT_PACKAGE_DISABLED
GET /meta/object/probe_gadget/state/status?from=open 200, served 200, served (stopped) 200, served 404 OBJECT_PACKAGE_DISABLED
GET /meta/probe_gadget (one segment) 400 INVALID_REQUEST (the list route) 400 400 INVALID_REQUEST, enabled and disabled alike 400
GET /meta/object/probe_gadget, /data/probe_gadget 404 OBJECT_PACKAGE_DISABLED unchanged 404 OBJECT_PACKAGE_DISABLED unchanged
control: another package's object, by name and /state 200 200 200 200

Byte 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 0 failing open warnings.

Mechanism hypotheses (measured)

  • H1, one predicate: holds. Every layer the by-name read serves carries the owning package as _packageId on the resolved item: the stored row's package_id (grafted in readMetaItem), the registry item (stamped by registerItem), the artifact envelope. The list filter reads the same field off its merged items, so one helper serves both.
  • H2, absence without a new path: holds. The missing name's exit is an item-less envelope, and both transports judge absence on item == null (createMetaItemAnswer on REST, the data?.item != null hit test on the dispatcher); the cached arm throws metadataItemNotFoundError on a falsy item, and REST maps both to sendMetaItemAbsent. The dispatcher half needed the service-fallback guard above, because its miss fell through to the metadata service.
  • H3, /published: half holds. For objects, REST already re-throws the layered refusal (typeof status === 'number'), measured 404 OBJECT_PACKAGE_DISABLED before any edit; the dispatcher's blanket catch is now narrowed in meta.ts. For non-object types the producer side cannot carry it: see below.
  • H4, the dispatcher's two doors: half holds. /state/FIELD reads the registry directly and served the object; closed. The one-segment /meta/NAME legacy exit is not reachable over HTTP on a real composition: refuseUnknownMetaListType answers 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.

  1. ?layers=true, /layers and /published for a non-object item, both transports. Producer getMetaItemLayered keeps serving its resolution reader, the security catalog echo (echoSecurityCatalogMutation, echoStoredSecurityCatalogOverlays), which turns a stored permission / position overlay 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 /published handlers inside registerMetadataEndpointsInner, and meta.ts's twins). /published's non-object fall-through is also MetadataManager.getPublished (packages/metadata), whose document carries no _packageId.
  2. REST /meta/object/NAME/state/FIELD, the interim transport split. REST's state route reads ql.registry.getObject(name) directly and serves a disabled package's object (measured 200 after this PR); the dispatcher twin answers 404 OBJECT_PACKAGE_DISABLED. The one-door split holds until the REST half lands with item 1.
  3. GET /meta/_drafts still lists a disabled package's drafts while the by-name draft read answers NO_DRAFT for 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 merging origin/main. "Before" is this branch without item 4 (head 8946a02d4, the merge on top of b2f89e8f); "after" is 232339ab8. A writable package app.gate-24210 holds dashboard gate_dash and app gate_app, each saved twice, each with requiredPermissions: ['gate_24210_cap']; dashboard gate_gone was saved and deleted.

  • Gated caller: the seeded admin. It holds studio.access / manage_metadata, so the three doors admit it, and not gate_24210_cap, so the item's own gate refuses it the item whole: the caller the bypass reaches.
  • Member caller: a user created by POST /auth/admin/create-user (no capabilities). The doors' capability gate refuses it first on every row: 403 FORBIDDEN on /history, /audit, /diff, enabled and disabled, before and after.
Gated caller, package state Request Before After
enabled GET /meta/dashboard/gate_dash and /meta/app/gate_app 403 PERMISSION_DENIED 403 PERMISSION_DENIED
enabled /history, /audit, /diff of both 403 PERMISSION_DENIED 403 PERMISSION_DENIED
disabled GET /meta/dashboard/gate_dash and /meta/app/gate_app 404 RESOURCE_NOT_FOUND 404 RESOURCE_NOT_FOUND
disabled /meta/dashboard/gate_dash/history 200, the events 404 RESOURCE_NOT_FOUND
disabled /meta/dashboard/gate_dash/audit 200, the audit trail 404 RESOURCE_NOT_FOUND
disabled /meta/dashboard/gate_dash/diff 200, the diff (label v1 to v2) 404 RESOURCE_NOT_FOUND
disabled /meta/app/gate_app/history, /audit 200, the events 404 RESOURCE_NOT_FOUND
disabled /meta/app/gate_app/diff 404 RESOURCE_NOT_FOUND 404 RESOURCE_NOT_FOUND
deleted gate_gone /history, /diff 200 (events; empty diff) 200 (unchanged)
re-enabled every row as enabled as enabled

Envelope 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 code RESOURCE_NOT_FOUND in 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 /diff row, 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 (eventDoorRefusal and the /diff branch), 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 orphaned sys_metadata rows #7557 pin. READERS gains four app rows (getMetaItem active, plural, state: 'draft', previewDrafts) and two generated rows per type MetadataTypeSchema declares except object (27 types today; the schema does not list package): 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 (no item, no registration, and NO_DRAFT with 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 from item.
  • packages/runtime/src/domains/meta-disabled-package-doors.test.ts (new), through HttpDispatcher.dispatch: /state/FIELD and the one-segment exit refuse OBJECT_PACKAGE_DISABLED and never ask registry.getObject for the name; /published answers the refusal and never asks getPublished; 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, /diff answer 404 RESOURCE_NOT_FOUND (403 PERMISSION_DENIED while enabled), no secret in the body, and historyMetaItem / auditMetaItem / diffMetaItem never 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: /diff absent, /history its 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; the stubProtocol double now answers the envelope's packageId as the protocol's servedLockState does.
  • Pin sweep: git grep of disablePackage(, /disable, isPackageDisabled and the NO_DRAFT sentence 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 merging origin/main at b9ac35cbf)

The head is now ec7d6af8b, one commit later. It changes only the BY_NAME_TYPES filter line and its docblock in package-disable-enforcement.test.ts (TS2367). At ec7d6af8b, the typecheck scripts (tsc plus check:test-typecheck) of objectql, metadata-protocol, runtime, rest and plugin-security each exit 0, and the pin is 15/15.

  • Tests, full suites at this head: @objectstack/metadata-protocol 244 files / 20641 passed (3 skipped files); @objectstack/objectql 438 / 8249 passed; @objectstack/runtime 413 / 7095 passed; @objectstack/rest 299 / 6284 passed (339 skipped); @objectstack/plugin-security 188 / 3737 passed (45 skipped).
  • Typecheck clean: metadata-protocol, runtime, rest, plugin-security. Full pnpm build 76/76 after the merge.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 73 commands at 232339ab8; all 73 exited 0 (the clone is unshallow now, so the two shallow-clone refusals of the first round did not recur). --ran with exit codes: "73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN". check-changeset-no-major and check-adr-0087-registration --base origin/main: exit 0.
  • Ablations, one per changed tested behaviour, each through scripts/ablation-replace.mjs with a trap restore proven by blob == HEAD:
    • (a) getMetaItem's check replaced by || true, pnpm --filter @objectstack/metadata-protocol build, ablation-dist-preflight --absent passed (the suite resolves dist/). 31 by-name READERS rows went red (the 27 generated rows, one per declared type but object, and the 4 app rows), 0 listing rows; 3 failed / 12 passed. Restored and rebuilt, marker back in dist/index.js: 15 / 15.
    • (b)–(e) in meta.ts (the suite imports src): the /state check, the one-segment check, the /published re-throw and the service-fallback guard, each removed alone, each turned exactly its own case red (1 failed / 6 passed); restored, 7 / 7.
    • (f) fetchCurrentMetaDocument's throw disabled: only the new withheld-item case went red (1 failed / 426 passed); restored, 427 / 427.
    • (g) readServedPackageBinding back on item._packageId: only the new disabled-package echo case went red (1 failed / 18 passed); restored, 19 / 19.

Acceptance notes

  • origin/main was merged into the branch (8946a02d4, a merge commit; no rebase, no force-push), with no conflict.
  • The member caller cannot reach the event and diff doors at all (their capability gate); the bypass reaches an author who lacks the item's own requiredPermissions, which is the gated caller measured above.
  • No changeset for @objectstack/objectql: its diff is a test file, outside its published files.

Generated by Claude Code

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/metadata-protocol, @objectstack/plugin-security, @objectstack/rest, @objectstack/runtime, touching 19 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b9ac35cbfe60afcea88947ed4eaf8c50e3efc214.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 47 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 b9ac35cbfe60afcea88947ed4eaf8c50e3efc214 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from fbe012729ce47777e47a0bd9ac816a3d4763fe3d — the merge of head ec7d6af8b8b7e75fc2e7d61e29a3dcf15e350ab4 into base b9ac35cbfe60afcea88947ed4eaf8c50e3efc214, 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 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

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

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ec7d6af8b8b7e75fc2e7d61e29a3dcf15e350ab4
Local-runs: none
Scope: packages/metadata-protocol/src/protocol.ts, packages/runtime/src/domains/meta.ts, packages/rest/src/rest-server.ts, packages/plugins/plugin-security/src/security-catalog-echo.ts, packages/objectql/src/package-disable-enforcement.test.ts, packages/runtime/src/domains/meta-disabled-package-doors.test.ts, packages/rest/src/meta-alternate-door-read-gates.test.ts, packages/plugins/plugin-security/src/security-catalog-echo.test.ts, .changeset/24210-metadata-protocol-by-name-disabled-non-object.md, .changeset/24210-runtime-meta-disabled-package-doors.md, .changeset/24210-rest-event-diff-doors-disabled-package.md, .changeset/24210-plugin-security-echo-binding-disabled-package.md + packages/objectql/src/registry.ts (isPackageDisabled, isObjectPackageDisabled, listItems), packages/spec/src/kernel/metadata-protection.zod.ts (extractProtection), packages/metadata-core/src/item-key-discriminators.ts, packages/rest/src/meta-item-read-gate.ts (createMetaItemAnswer, loadObjectItem), packages/rest/src/error-response.ts (handleRouteError), packages/rest/src/import-mapping.ts, packages/services/service-automation/src/connector-pull.ts, packages/runtime/src/domains/packages.ts (applyPublishedSeeds), packages/services/service-analytics/src/plugin.ts (seed draft probe), packages/cli/src/commands/migrate/organization-ownership.ts (lock.verdict), packages/core/src/utils/import-runner.ts, packages/rest/src/import-prepare.ts, packages/rest/src/data-action-routes.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs (CATEGORIES), objectui packages/data-objectstack/src/index.ts (probeMetaItemAccess, read at the local clone's head 2ec9e2a)

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 origin/main d3d0644 (fetched fresh; merge-base b9ac35c) to the head (12 files, +606/-20), and the 48 check-runs on the head.

Check-runs at read time (2026-10-17, after 23:46Z): every run completed; 0 failure, 0 in_progress. Two workflow passes, the push at 23:15Z and the body edit at 23:44Z. success: Build Core, Test Core and its six shards, TypeScript Type Check, Type Check workspace / source gates / consumer gates / debt ledger, Lint and Repo Gates and its seven Lint legs, Check Changeset (both passes), Governed Surface Queue Guard, Check PR Size, Check Documentation Links, Flag docs affected, Dogfood Regression Gate (aggregate), the four PR-claim checks (both passes), Auto Label, filter. skipped by path or opt-in filter: Build Docs, Console Pin Gate, Dogfood shards and Dogfood Verify CLI, Temporal Conformance, Packed-tarball smoke, and the edit pass's Auto Label and Check PR Size. The gate families the brief lists are all answered green by these.

① Derived judgments

The by-name read, getMetaItem (protocol.ts). The tail added after readMetaItem is right on every axis the triage answer fixes.

  • Type exemption. canonicalizeMetaRequestType folds the spelling before the check, so objects and packages reach request.type === 'object' || 'package' singular; the apps READERS row pins the fold for the hidden side. object keeps its pre-check refusal 404 OBJECT_PACKAGE_DISABLED (unchanged from fix(metadata-protocol)!: the by-name /meta read refuses an object of a disabled package (404 OBJECT_PACKAGE_DISABLED), as the list and the data plane do #24208); package is never hidden. Matches triage ("absence for a non-object type; OBJECT_PACKAGE_DISABLED stays the object answer").
  • One predicate (H1). isItemOfDisabledPackage asks registry.isPackageDisabled(item._packageId) of the item the read RESOLVED, after mergeArtifactProtection grafted the envelope package chosen by envelopePackageId. Every layer carries that field: the stored row (package_id grafted at step 1 and on the draft arm), the registry item (stamped by registerItem), the artifact. The list filter (readFlattenedMetaItems) now calls the same helper on its merged items, which carry _packageId from the package slot (line 10114, held._packageId === undefined ? {...held, _packageId: pkg}). A package-unbound stored row overlaying a disabled package's code item is hidden on both reads for the same reason. registry.isPackageDisabled(undefined) is false, so an unowned item is served by both. Right.
  • The absence shape (H2). Active and previewDrafts: the answer is the envelope with item: undefined and registration dropped. The strict draft read throws noPendingDraftError, the one constructor the missing-draft branch now shares (message, code and status identical; pinned). Both transports judge absence on item == null: REST's createMetaItemAnswer judges absence BEFORE the per-caller gate ([finding] GET /meta/app/NAME answers 200 with an item-less envelope for a nonexistent name — the spec declares item required and the rest pin declares a 404 #18066) and renders sendMetaItemAbsent; the REST cached arm (getMetaItemCached throws on a falsy item) reaches the same emitter; the dispatcher's data?.item != null hit test falls to the service fallback, whose new guard nulls the copy, and the branch ends at its own deps.error('Not found', 404). The missing name takes the identical exits. Byte identity is pinned on the dispatcher and measured on REST. Right.
  • The envelope kept (Q4). lock, editable, deletable, resettable, version, packageId and the rest stay. No transport serialises an item-less envelope (REST: the absent body; dispatcher: its 404), so nothing new reaches the wire. Its two in-process readers are the CLI ceremony and plugin-security, below. Right.
  • The list filter's refactor is behaviour-identical on SchemaRegistry; the one difference is that a registry WITHOUT isPackageDisabled no longer throws from the list read and hides nothing. A degenerate-host widening only, and the same optional-call shape isObjectOfDisabledPackage already has. Named, accepted.

The dispatcher (meta.ts), four doors, cross-lane to domain:cli.

  • /meta/object/NAME/state/FIELD: disabledObjectAnswer runs before registry.getObject; it asks isObjectPackageDisabled(name) (a non-object name answers false and the door proceeds), then reads the protocol's own refusal and renders it through errorFromThrown as 404 OBJECT_PACKAGE_DISABLED. A host whose protocol does not refuse gets the door's own 404 Object not found. The protocol read's body is discarded, so no schema is disclosed. Pinned: getObject is never asked the name. Right.
  • The legacy one-segment /meta/NAME exit: the same helper, placed after the anonymous deny and before registry.getObject; reachable only on a host whose protocol declares no type set (refuseUnknownMetaListType answers 400 INVALID_REQUEST first on a real composition, enabled and disabled alike, as measured). Closed in code and pinned with that host shape. Right.
  • /meta/:type/:name/published: the blanket catch now re-throws OBJECT_PACKAGE_DISABLED instead of falling through to getPublished; pinned (getPublished never called). REST's twin re-throws any status-bearing throw (typeof overlayError?.status === 'number', line 8202), so for this code the two transports agree; the dispatcher still swallows OTHER status-bearing throws into the snapshot, a pre-existing parity gap outside this card. Right for the card; the gap is named only.
  • The non-object item read's metadata-service fallback: isServiceItemOfDisabledPackage asks the engine registry's isPackageDisabled of the service copy's _packageId, package exempt. Without it the dispatcher would have served the service's copy where the protocol said absent. Pinned byte-identical to the missing name. Right.

REST (rest-server.ts), fetchCurrentMetaDocument only (ruling item 1). The member is reached from exactly two places, eventDoorRefusal (/history, /audit) and the /diff branch, and only for gatesPerCaller types: book, doc, app, view, dashboard. object never reaches it. With item 1 alone, a withheld item read as "no current document" and eventDoorRefusal returned undefined, so events and a view's or dashboard's diff were served to a caller the item's own gate refuses: a bypass this PR would have created, measured 200 before and 404 after on the gated author. The fix throws the plain read's absence when getMetaItemLayered.effective is non-null; a deleted item (every layer null) keeps undefined and its old answer. I checked for other cases where the plain read serves no item while effective resolves: the only bundled type is email_template (not gated); isAppPackageIdKey returns earlier; a rowless expanded view is served by the plain read. So for the gated types the branch fires on a disable alone. The throw is rendered by the doors' handleRouteError (resolveErrorResponse): status 404, code RESOURCE_NOT_FOUND, the flat body. Right; the body dialect is flag ③.1. For ungated types (page, flow, report, ...) the event and diff doors still serve a disabled package's item's events and diff: no gate exists to bypass, and events outliving the item is the doors' existing design for a deleted item; the rest changeset's "Absent now" list names the gated types only, honestly. Named, accepted.

plugin-security, readServedPackageBinding only (ruling item 2). The envelope's packageId is servedLockState's, which is extractProtection(decorated).packageId, which is decorated._packageId: the same field of the same served document the function read before as item._packageId. So the enabled case is unchanged byte for byte and the disabled case keeps the binding. The existence decision stays with getMetaItemLayered (untouched); no enforcement change, no new public method. Pinned on the REAL protocol with anti-vacuity (the item is withheld, the echo still records _packageId, and stays owned after the re-enable); the stub double now mirrors servedLockState. Right.

Blast radius, every in-repo caller of getMetaItem at the head. Object-only callers, unchanged: core import-runner, rest import-prepare, data-action-routes, rest-server 2652 (loadObjectItem gate input), 8429, 8472, service-automation 364, plugin-auth (sys_user field coercion), the protocol's seed adapter (25453), meta.ts's object branch. Non-object callers, each now reads absence and none must tell disabled from missing: rest import-mapping and service-automation connector-pull (mapping: 404 MAPPING_NOT_FOUND, right, a disabled package's mapping must not drive an import or a pull); runtime applyPublishedSeeds (seed: the response's seedApplied.errors[] names the unreadable seed, and the data plane refuses its objects regardless); service-analytics' pending-seed probe (NO_DRAFT already caught, no pending rows); the protocol's build probes (26607, best-effort, swallowed); getMetaItemCached (the REST cached arm, absent). The CLI ownership ceremony's lock.verdict reads editable, lock and lockReason only, never item, so the kept envelope gives it the same verdict; it enumerates through getMetaItems, which already omits these items. No resolution reader is blinded: the registry's getObject and listItems are untouched, the save's destructive-change check still reads readMetaItem, the echo's existence read is the layered read, and the lock gate's verdict rides the kept envelope. objectui's probeMetaItemAccess maps the absent 404 to not_found (isMetaItemAbsentError), getPage and friends to null, as the PR's premise table says. The REST per-caller gate's inputs are the list read and the item's own keys, and absence is judged before the gate, so no gate input changes.

What each door answers now (disabled package's item | triage's answer | a missing name):

  • getMetaItem active and previewDrafts, every type but object/package: no item, envelope kept | absence | no item, envelope kept.
  • getMetaItem state: 'draft': 404 NO_DRAFT, the missing draft's message | absence | 404 NO_DRAFT.
  • REST GET /meta/TYPE/NAME (plain, cached, ?preview=draft): 404 RESOURCE_NOT_FOUND, nested body | absence | identical bytes.
  • Dispatcher GET /meta/TYPE/NAME: 404 RESOURCE_NOT_FOUND "Not found" | absence | identical bytes (pinned).
  • Objects on every by-name face, both transports: 404 OBJECT_PACKAGE_DISABLED | the object answer | 404 RESOURCE_NOT_FOUND.
  • Dispatcher /meta/object/NAME/state/FIELD: 404 OBJECT_PACKAGE_DISABLED | the object answer | 404 Object not found.
  • Dispatcher one-segment /meta/NAME: 400 INVALID_REQUEST on a real composition; 404 OBJECT_PACKAGE_DISABLED where the exit is reachable | the object answer | 404 Not found.
  • /meta/object/NAME/published, both transports: 404 OBJECT_PACKAGE_DISABLED | the object answer | 404.
  • REST /meta/TYPE/NAME/history, /audit, /diff, gated types (app, view, dashboard, doc, book): 404 RESOURCE_NOT_FOUND, flat body | absence (status and code) | events, or /diff's absence for app/doc/book, as before.
  • Stopped, still serving, named in the PR body and the changesets: non-object ?layers=true, /layers and /published on both transports; REST /meta/object/NAME/state/FIELD (reads ql.registry.getObject at line 7947); GET /meta/_drafts; the ungated types' event and diff doors.

② Semver level

  • Three breaking changesets (@objectstack/metadata-protocol, @objectstack/runtime, @objectstack/rest), each minor, each marked BREAKING with Clause-②: no (narrowing) and the adr-0087 marker not-required (no-migration-prescription). minor is the launch-window level scripts/check-changeset-no-major.mjs enforces for a breaking change ("During the launch window we ship breaking changes as minor"); no-migration-prescription is in that gate's CATEGORIES (line 539), and its reasoning holds: no stored metadata changes shape, no key, export or code is added or removed, re-enabling restores the read. Each body states what was served and what is answered now, and the one-line remedy. The runtime changeset's /published bullet names objects only and the rest changeset's "Absent now" names the gated types only; both match the diff.
  • @objectstack/plugin-security patch, no Clause-② line, no marker: right. Relative to main the echo's observable behaviour is unchanged (an in-PR repair of what the narrowing alone would have broken; packageId is the same document field); it is a published-source change, so patch is the minimum a changeset may carry.
  • No @objectstack/objectql changeset: its diff is a test file outside the published files. Right.
  • The PR body's Clause-②: no (narrowing) is the repo spelling of triage's "yes (narrowing)" (AGENTS.md: yes means a new key on a published payload; this diff adds none; (narrowing) is the BREAKING arm). The title carries !. The face is narrowing, hence this tier.
  • CI's verdict: Check Changeset success on both passes (that job runs check-adr-0087-registration.mjs --base MERGE_BASE and check-changeset-no-major.mjs --base MERGE_BASE, pr-automation.yml lines 977 and 1127).

③ Boundary flags

  1. Flat vs nested 404 body on the event and diff doors (report 2, open question; app /diff moves from nested to flat). Answered, not a blocker: the plain read's contract is status plus code (ADR-0112 minimum, which the pin asserts), and both match; the body dialect is the one those doors already speak for every throw (handleRouteError). Escalated as a door-side follow-up item on the card, beside item 3's doors (recommendation A: render through sendMetaItemAbsent in eventDoorRefusal and the /diff branch), for the seat to fold into that dispatch.
  2. Interim REST /meta/object/NAME/state/FIELD split (Q2): accepted by ruling 6241893667 item 4, named in the PR body, claimed by no changeset. REST serves a disabled object's FSM introspection (next states from the schema, no rows) until its half lands. Notice 6243012505 says no /meta move series is pending any more, so the REST half can be dispatched at the seat's discretion rather than after slice 13e. Not a blocker.
  3. Non-object ?layers=true, /layers, /published on both transports (Q1): ruling A stands; the producer getMetaItemLayered keeps serving the echo (a resolution reader), the doors close door-side. Lead for that dispatch: MetadataManager.getPublished's document carries no _packageId, so the door needs the name's owner from the registry or the layered envelope's packageId. The body's Part of #24210 is therefore right, and the "Part-of PR must not also close its card" check is success.
  4. The envelope kept (Q4): ruling A, verified above; no transport serialises it, the CLI reads only lock fields. Answered.
  5. GET /meta/_drafts lists a disabled package's drafts while the by-name draft read answers NO_DRAFT (out-of-scope finding 1): ruling item 6 folds it into item 3's follow-up. Meanwhile Studio's DraftChangesPanel lists such a draft and reads both sides as null. Answered.
  6. The echo binding (out-of-scope finding 2): fixed in this PR under ruling item 2, verified with anti-vacuity. Answered.
  7. Ungated types' event and diff doors still serve a disabled package's item's events and diff: not a gate bypass (no per-caller gate), consistent with the deleted-item design, honestly scoped in the rest changeset. Named; nothing owed.
  8. seed and mapping callers read absence now: consistent with triage's "no caller must tell disabled from missing"; the seed-apply response names the unreadable seed. Named; nothing owed.
  9. List filter's optional call: a degenerate-host widening only (a registry without the predicate hides nothing instead of throwing); the pin runs the real SchemaRegistry. Named; nothing owed.
  10. Dev deviations: Part of over the dispatch's Fixes (os-dev.md's DoD wins; right, doors remain open); the round-1 objectql typecheck miss (TS2367) is fixed in the head commit and CI is green; the dispatcher was measured through createHonoApp's fetch handler, not a socket (no in-repo host mounts it over one; acceptable); the PR body was re-worded to the head (27 types; the schema does not list package). The dispatcher /published re-throw being narrower than REST's for other status-bearing throws is pre-existing and outside this card.
  11. CI: nothing in progress, nothing failed; the skipped runs are path and opt-in filters, and no exported symbol moves, so the Console Pin Gate's skip is not a gap.

Implemented-by: claude/issue-24210-meta-by-name-disabled-non-object
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T00:00Z · ACCEPT

Head: ec7d6af8b8.

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 through sendMetaItemAbsent in eventDoorRefusal and the /diff branch). 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, /layers and /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); seed and mapping callers reading absence; the list filter's optional call on a degenerate host.
  • deviations: accepted (Part of over Fixes, per os-dev.md; the round-1 objectql typecheck miss, fixed and green; the dispatcher measured through createHonoApp'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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants