Skip to content

fix(metadata-protocol)!: refuse publishing a hierarchy-scope grant when no hierarchy resolver is mounted; explain names the absent resolver (#26188) - #26305

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-qp1ljk
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-qp1ljk

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26188

Clause-②: yes (narrowing: on a runtime with no hierarchy resolver, publishing a permission set whose grant uses own_and_reports, unit or unit_and_below is refused, where it used to answer 200; widened: @objectstack/spec/security gains the export findHierarchyScopeGrants, and @objectstack/plugin-security's ExplainEngineDeps gains the optional member hierarchyResolverMounted)
Metadata-protocol: none
Ruling-ref: #26059 ruling Q3 / Q4 (6416233738), items 9a and 9b of the item reading (6415971852); routes agreed with the server seat before the claim (claim 6424008328)

Summary

Hierarchy depths (own_and_reports, unit, unit_and_below) are enforced only by the enterprise hierarchy resolver (capability hierarchy-security, ADR-0057). Without one, the open sharing service evaluates them as own. defineStack already refuses such a grant at authoring time. A runtime save of the same permission set used to answer 200 with no word, though, and explain then blamed the caller's unit.

9a, the save and publish doors.

  • Rule location. The check is a gate-local rule in the runtime authoring gate (runtime-authoring-gate.ts), beside the [转移] 发布期护栏:多组织下「平台级 + schedule + create_record + 未显式 organization_id」拒绝——#6155 Q3=A 裁决的 lint 半边 #6285 and forms: on a walled tenancy posture a published public form bound to a tenant-scoped object is served (GET 200) but every anonymous submit answers 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED #21476 rules. Its input is a deployment fact, so AUTHORING_RULES must not judge it.

    • Resolver presence is a host fact. protocol.hierarchyResolverMounted() reads the kernel service hierarchy-scope-resolver on every write, the same lookup the sharing plugin uses, and assertRuntimeAuthoringRules passes it in. This is the shape rlsMembershipResolverKeys has. It does not detect the resolver by plugin name.
    • A host with no service registry (the CLI) states nothing, and nothing is judged there. defineStack is that host's door.
  • Publish, resolver absent. These all answer 422 INVALID_METADATA with one issues[] entry per grant, under the rule hierarchy-scope-capability-required:

    • a direct active save;
    • the per-item publish of a draft;
    • the package batch publish;
    • a restore to such a version.

    Each entry is located at permissions.NAME.objects.OBJECT.readScope (or writeScope), and its message names hierarchy-security.

  • Draft save, resolver absent. The save answers 200. advisories carries the same entry at severity: 'warning'.

    • D1 still holds: no rule gates a draft. The draft branch reads no collection, so a draft pays nothing new.
    • The UI already renders advisories as a "Saved, with warnings" toast.
  • Resolver mounted. Nothing changes on either door.

  • Shared list and sentence. @objectstack/spec/security exports findHierarchyScopeGrants, the one list of hierarchy depths and the one finding sentence. validateHierarchyScopeCapability now reads it, and defineStack's refusal text is byte-identical to before (stack-refusal-envelopes.test.ts holds).

Why the code is INVALID_METADATA, not STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED.

9b, explain.

  • The new branch. explain-engine.ts's depth-layer record cell gains a resolver-absent branch, just ahead of the arm the card names as misleading (sharingMatches === false). It reads: "Hierarchy resolver absent — requires enterprise; depth evaluated as 'own'." The outcome is excluded when the owner-match failed, and not_evaluated when a share admitted the row.
  • Placement. The branch sits after the "depth admitted this row" arm. So a row the depth really admitted is never reported as resolver-absent.
  • The dependency. ExplainEngineDeps.hierarchyResolverMounted is optional, and absent means the cell reads as before. security-plugin-explain-access.ts wires it from resolveKernelService('hierarchy-scope-resolver').

Tests

Head 1267e3cb (main merged at 617f27eb).

  • runtime-authoring-gate.hierarchy-scope.test.ts (new, pure gate, 7 tests):
    • publish refusal: envelope code + status, rule, path, rulesRun;
    • the message opens with defineStack's finding sentence;
    • a draft gets the same entry as a warning;
    • every depth on both axes, and own / org untouched;
    • resolver mounted: nothing; presence unknown: nothing; another type: nothing.
  • protocol.runtime-authoring-gate.test.ts, 3 new rows through saveMetaItem / publishMetaItem and a real service registry:
    • no resolver, publish: 422 and nothing written;
    • no resolver, draft: 200 + warning advisory, and its publish is refused;
    • stub resolver mounted: both doors clean, row written.
  • explain-engine.test.ts, 2 new cells: resolver absent names the resolver, not the depth; resolver mounted still blames the depth.
  • explain-read-verdict-inputs.test.ts, through the real SharingService:
    • The harness now registers its stub resolver as the hierarchy-scope-resolver kernel service, where the sharing plugin looks it up, and hands the sharing service that same lookup. Before, it gave the sharing service a resolver the kernel did not know.
    • A new open-edition cell: no resolver, so the in-unit row is not returned (the find and record.visible agree), and the depth cell names the absent resolver.
  • permission.test.ts, 2 rows for findHierarchyScopeGrants.
  • showcase-scope-depth-fallback.dogfood.test.ts, the open-edition world. It used to publish the unit_and_below default profile with no resolver mounted, which this PR now refuses.
    • It first pins that refusal through the real HTTP door: 422 INVALID_METADATA, rule hierarchy-scope-capability-required.
    • It then saves the profile with the reference resolver mounted and unmounts it, so the profile stands for one stored while an enterprise resolver served the database. The fail-closed read (owner-only, no widening) is still proven against it.

Red legs (scripts/ablation-replace.mjs, each leg restored to blob == HEAD):

Leg Mutation Result
A the door stops reading resolver presence (hierarchyResolverMounted() answers undefined, the behaviour on main) 2 door rows red: expected { code: undefined, status: undefined } to deeply equal { code: 'INVALID_METADATA', … }, the card's 200
B the explain branch is false (main's cell) 2 red, among them the real-stack cell: expected 'The record's owner (u_reporter) is o…' to match /^Hierarchy resolver absent …/, the card's misattribution
C a draft answers no advisories 1 red (the draft warning row)

Readings at 1267e3cb:

All runs went through the shared verify lock, so the seconds are shared-box seconds.

  • Full pnpm build (at 4cf2c1a5, source unchanged since): exit 0.
  • @objectstack/metadata-protocol: 267 files, 21549 passed, 19 skipped.
  • @objectstack/plugin-security: 227 files, 4929 passed, 45 skipped.
  • @objectstack/spec: 725 files, 21965 passed, 1 todo.
  • Typecheck of the three packages: exit 0. eslint --no-inline-config on the three packages and the dogfood file: exit 0.
  • scripts/pm/dispatch-gates.mjs: 99 gates derived, 99 run, all exit 0, 0 NOT-MEASURED (at 4cf2c1a5). The derived list is identical at 1267e3cb. The 53 of them that scan sources (all but the self-tests, the spec-package checks and the build-output checks) were re-run there, all exit 0.
  • Full dogfood (pnpm --filter @objectstack/dogfood test, at 4cf2c1a5): 296 files passed, 3 skipped, 1 failed, the fallback file above. It is fixed in 1267e3cb and now passes 4/4. The other files share that tree's source.
  • packages/runtime share-links-enforcement-context.test.ts and standalone-stack.test.ts, the other runtime suites that declare hierarchy scopes: 52 passed.

Acceptance notes

  • SaveMetaItemResponseSchema.advisories's .describe() (packages/spec/src/api/protocol.zod.ts) says the findings come from rules "applied to this body on its way to active". A draft save can now carry this one warning too. The describe is left as is, because this claim allows exactly one packages/spec change, the helper export. It is description text only, and the key's shape and meaning (non-gating findings, warning or info) hold.
  • The object-level depth layer, the one without a record, still describes the declared depth only. The ruling names the record cell, so that is the cell this PR changes.

Generated by Claude Code

…olver is refused on publish, warned on a draft, and named by explain

Claude-Session: https://claude.ai/code/session_01M4SCfSer74vqRR1SLn1hWP
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M4SCfSer74vqRR1SLn1hWP
Co-authored-by: Claude <noreply@anthropic.com>
…eading depth arm; pin it through the real sharing service

Claude-Session: https://claude.ai/code/session_01M4SCfSer74vqRR1SLn1hWP
Co-authored-by: Claude <noreply@anthropic.com>
…refusal first (#26188)

With no hierarchy resolver mounted the metadata door refuses the
unit_and_below profile (422 INVALID_METADATA,
hierarchy-scope-capability-required). The open-edition block pins that,
then saves the profile with the reference resolver mounted and unmounts
it, so the fail-closed read is still proven for a profile stored while
an enterprise resolver served the database.

Claude-Session: https://claude.ai/code/session_01M4SCfSer74vqRR1SLn1hWP
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/plugin-security, @objectstack/spec, touching 25 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/export-origins/security.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via own_and_reports (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/access-recipes.mdx (via own_and_reports (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), readScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), writeScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/administrator-guide.mdx (via unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/attachments-access.mdx (via unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/capabilities.mdx (via own_and_reports (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/index.mdx (via own_and_reports (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), readScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), writeScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/permission-sets.mdx (via own_and_reports (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), readScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), writeScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/permissions-matrix.mdx (via own_and_reports (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), readScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), writeScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/positions.mdx (via own_and_reports (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), readScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), writeScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/rls.mdx (via readScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/sharing-rules.mdx (via own_and_reports (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), readScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/permissions/system-context.mdx (via explainAccessForCaller (symbol, a top-level function))
  • content/docs/protocol/kernel/plugin-spec.mdx (via resolveWritePackageScope (literal, a string literal in assertRuntimeAuthoringRules))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via unit_and_below (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via readScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability), writeScope (literal, a string literal in findHierarchyScopeGrants; a string literal in validateHierarchyScopeCapability))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/security.json, packages/spec/export-origins/security.json) — pages documenting those are invisible to this run
  • 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 — 150 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 69aa630b9fd6ef20f76cbac9ed4d9b9efa69c804 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f7c7a36c0b6ac2173a74543c1ef614f7984c722c — the merge of head 1267e3cb2b5cec5d538fd5da7401a68bd0adde78 into base 69aa630b9fd6ef20f76cbac9ed4d9b9efa69c804, 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 f7c7a36c0b6ac2173a74543c1ef614f7984c722c && git checkout f7c7a36c0b6ac2173a74543c1ef614f7984c722c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 69aa630b9fd6ef20f76cbac9ed4d9b9efa69c804 1267e3cb2b5cec5d538fd5da7401a68bd0adde78 && git checkout -B drift-repro 69aa630b9fd6ef20f76cbac9ed4d9b9efa69c804 && git merge --no-ff 1267e3cb2b5cec5d538fd5da7401a68bd0adde78

node scripts/docs-audit/affected-docs.mjs --json 69aa630b9fd6ef20f76cbac9ed4d9b9efa69c804

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 26, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1267e3cb2b5cec5d538fd5da7401a68bd0adde78
Local-runs: none
Scope: packages/spec/src/security/permission.zod.ts, packages/spec/src/stack.zod.ts, packages/spec/api-surface/security.json, packages/spec/export-origins/security.json, packages/spec/src/security/permission.test.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/runtime-authoring-gate.hierarchy-scope.test.ts, packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, packages/plugins/plugin-security/src/explain-engine.ts, packages/plugins/plugin-security/src/security-plugin-explain-access.ts, packages/plugins/plugin-security/src/explain-engine.test.ts, packages/plugins/plugin-security/src/explain-read-verdict-inputs.test.ts, packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts + packages/metadata-protocol/src/plugin.ts, packages/core/src/kernel.ts, packages/core/src/kernel-base.ts, packages/core/src/lite-kernel.ts, packages/core/src/plugin-loader.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-sharing/src/sharing-service.ts, packages/spec/src/contracts/sharing-service.ts, packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/api/protocol.zod.ts, packages/spec/src/stack-refusal-envelopes.test.ts, packages/plugins/plugin-security/src/index.ts, packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts, packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts, packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts, packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts, examples/app-showcase/src/security/permission-sets.ts, scripts/pm/clause2-line.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, .changeset/26188-hierarchy-scope-without-resolver.md (prose face)

Read on the net diff origin/main...origin/pr-26305 (merge base 617f27eb), 16 files, 555+/36-. Inputs: card #26188 and all three comments, ruling 6416233738 and item reading 6415971852 on #26059, os-dev-report 6426008287, PR body and file list, the head's 41 check-runs.

① Derived judgments

Public-surface changes the diff implies, each judged.

  1. @objectstack/spec/security gains the export findHierarchyScopeGrants(permissionSets: unknown). Pure, tolerant of any input, no Zod schema touched; api-surface/security.json and export-origins/security.json each gain exactly the one matching row. The claim allowed one spec export of the existing helper; this is it. Right. The stack-refusal-envelopes.test.ts pin is a verbatim fragment (uses readScope='unit_and_below', a HIERARCHY scope), not the whole sentence, so byte-identity of defineStack's text is a reading, not a pin: compared line for line, the old inline loop and the new ${g.finding}. + the two unchanged tail fragments produce the same string (same array guard, same objects guard, same key order, same HIER set, String(ps?.name ?? '?') equals the template's coercion). Byte-identical; right. findHierarchyScopeGrants is a sound single source: one list, one sentence, both doors append their own remedy.
  2. @objectstack/plugin-security: ExplainEngineDeps (a public type export, index.ts:88) gains the optional member hierarchyResolverMounted, an optional function returning boolean. Absent reads as before. Right, and correctly declared as a widening.
  3. @objectstack/metadata-protocol: two new exports from runtime-authoring-gate.ts (HIERARCHY_SCOPE_CAPABILITY_REQUIRED, HIERARCHY_SCOPE_RESOLVER_SERVICE) and findHierarchyScopeCapabilityGaps; evaluateRuntimeAuthoringGate gains the optional hierarchyResolverMounted argument; AuthoringGateHost gains the same member. All additive. Right. One note: the service name is spelled a fourth time as a string literal (the sharing plugin, the explain wiring and spec/contracts/sharing-service.ts prose each spell 'hierarchy-scope-resolver' by hand); the new constant is not read by the sharing plugin. Not a defect, see ③.

Accept-set change (the narrowing), bounded exactly.

  • What is refused: a write whose singular type is permission only (args.type !== 'permission' returns early; the object draft test pins it), whose body carries readScope or writeScope equal to one of own_and_reports, unit, unit_and_below (both axes, each depth, pinned by the 3x2 loop), on a write with state: 'active', when hierarchyResolverMounted === false. own and org are untouched. One issues[] entry per grant, located at permissions.NAME.objects.OBJECT.readScope|writeScope, inside the gate's existing 422 INVALID_METADATA envelope under rule hierarchy-scope-capability-required. Right, and inside the ruling (9a: "refuse on publish").
  • Which doors: assertRuntimeAuthoringRules is the one body all four reach. Direct active save (saveMetaItem, protocol.ts:21167); per-item publish and the package batch publish both go through promoteDraftForPublish (:22918, called at :22512 and :23957); restore goes through refuseRestoredBodyTheSaveDoorRefuses with state: 'active' (protocol-authoring-gate.ts:131). Four doors, one rule, right.
  • Restore-to-version: refused too. This is not a new door the PR invented: "a restore refuses what the save door refuses" is the standing invariant of refuseRestoredBodyTheSaveDoorRefuses ([maintainer] refactor(metadata-protocol): split src/protocol.ts (33,065 lines, +10,169 in 30 days) along its ObjectStackProtocolImplementation seams, one move-only slice per PR #25467 cut 8), and re-activating a stored version that carries an unenforceable grant would make the same inert declaration live. The card names PUT and publish as the measured doors and asks that "the runtime path match" defineStack; a restore is a runtime activation. Within the ruling's shape; right. Restore carries no advisories (its receipt declares none), so a restore of a draft-state version hears nothing; drafts are not restored to active, so nothing is lost.
  • Draft save: the draft branch of both assertRuntimeAuthoringRules and evaluateRuntimeAuthoringGate returns the same finding at severity: 'warning', error: null; saveMetaItem attaches a non-empty advisories array to the 2xx regardless of mode (:21590). The branch reads no collection and no stored row (the migrate-stored pass still returns [] first). Consistent with D1: no rule gates a draft; a draft now hears one warning. The gate's own D1 comment was updated to say so. Right.
  • Host with no service registry: hierarchyResolverMounted() answers undefined only when getServicesRegistry is absent on the protocol (a direct construction) or the lookup throws; the gate judges nothing on undefined (pinned). Through MetadataProtocolPlugin (plugin.ts:249) the registry is always a function; on a kernel whose context lacks getServices it answers new Map(), which reads as false. Both in-repo kernels (ObjectKernel, and LiteKernel via kernel-base) expose getServices() as a copy of a plain Map (kernel-base.ts:130, kernel.ts:319), so every in-repo production host is judged, and the undefined reading is the exact shape rlsMembershipResolverKeys already has. Left unjudged on purpose, and the purpose holds: defineStack is that host's door. The PR body's "the CLI" phrasing is looser than the code (the CLI's os serve boots a kernel and is judged; what is unjudged is a protocol built without a registry), but the behaviour is right.
  • Can a deployment WITH an enterprise resolver be refused? Not by any in-repo path. The protocol reads ctx.getServices(), a snapshot of kernel.services; kernel.registerService writes that map and the loader (kernel.ts:383-388), which is how the reference resolvers in the dogfood worlds register. The sharing plugin reads ctx.getService('hierarchy-scope-resolver') (sharing-plugin.ts:690), a three-tier lookup (services map, then the loader's instances, then factories). The two agree for a registerService-registered resolver. They would disagree for a resolver registered through registerServiceFactory and not yet resolved once (it sits in the loader only until the first ctx.getService caches it): the protocol would read "absent" while sharing enforces it. No in-repo plugin registers any service by factory, the enterprise plugin lives in cloud and is not readable from here, and rlsMembershipResolverKeys and the SDUI-manifest read carry the identical exposure today. Judged: the PR's "same lookup the sharing plugin uses" is overstated (same name, not same resolution), the practical answer is no, and the residue is a pre-existing shape; recorded as a ③ flag, not a defect of this PR.
  • Resolver mounted: true returns [] on both states (pinned, pure and through a real registry). Nothing changes; right.
  • package-author channel: assertRuntimeAuthoringRules still returns [] before the draft branch, so a package-author write hears neither the warning nor the refusal. That is the gate's standing posture (package authoring is judged by os build / defineStack, where this grant is already refused), and the ruling's door is the environment save door. Right, unchanged, noted in ③.

INVALID_METADATA with rule: 'hierarchy-scope-capability-required' and the ADR-0112 ledger. The ledger's unit is the code, and provenance is per emitting package: INVALID_METADATA is already registered under @objectstack/metadata-protocol (ledger line 673, inside the 653-723 block), so a new rule id inside its existing issues[] envelope adds no stamp site the provenance gate (check:error-code-provenance) would sweep and no row is owed. Reusing STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED at an HTTP door would have been a new emitter of a door: 'none' spec code (line 1574, the defineStack family comment "where a throw aborts before any HTTP boundary exists"), owed a metadata-protocol row in the file this claim keeps off. The card's acceptance line ("either reused or a new row; the PR names which") is met: the PR names the reuse and says why. Correctly outside the ledger's requirement; right. A rule id is a RuntimeAuthoringIssue.rule string, the key a renderer groups by, not a wire code.

Explain (9b). The new arm sits after ownerIsMe and after depthAdmitsRow and before the sharingMatches === false arm (explain-engine.ts:2096-2110), guarded by depthScope !== 'org' (the !depthWidens arm above already caught own, so only hierarchy depths reach it) and deps.hierarchyResolverMounted?.() === false. depthAdmitsRow requires sharingMatches === true && !ownerIsMe && !anyShareAdmits && (org || sharingFilter !== null), so a row the depth really admitted is never reported as resolver-absent. Outcome excluded when the owner-match failed, not_evaluated when the row got in some other way. The wiring reads resolveKernelService('hierarchy-scope-resolver') != null per explain, kernel-side, and the real-stack harness now registers its stub under that kernel name and hands the sharing service the same lookup, which is the honest fixture. Right. One corner is worded wrong, not misattributed: a hierarchy depth on an object whose sharing filter is null (no owner column, a bypass object) on a resolver-less runtime now lands in the new arm with sharingMatches === true (the matcher admits a null filter) and anyShareAdmits === false, and the detail ends "a share admits this record (see the sharing layer)", where before the PR the sharingFilter === null arm said "the sharing service imposes no owner-match on this object". The outcome (not_evaluated) is right; the sentence is not. Reaching it needs a stored hierarchy grant on an ownerless object on an open-edition runtime, a row the door now refuses to create. Recorded in ③ as a follow-up, not blocking.

Shipped examples, apps and dogfood worlds. Read every world that declares a hierarchy depth: showcase-scope-depth (bootScopeWorld('unit', false)) and showcase-scope-depth-write (withResolver: false) deliver their profile through bootShowcase({ defaultPermissionSets }), the SecurityPlugin catalog, never the metadata door, so the gate does not run there; federated-phantom-share-grant drives sharing.checkEdit with a context __writeScope, no metadata write; admin-platform-admin-standing only mentions the scope in prose. authz-conformance.matrix.ts names the depth as a matrix id. examples/app-showcase deliberately authors no hierarchy depth (permission-sets.ts:131-137: "this open example does not author them"). The only world that PUT a hierarchy grant through /meta/permission with no resolver was showcase-scope-depth-fallback, and the dev's full battery at 4cf2c1a5 measured exactly that one failure. The fix at 1267e3cb is test-only, pins the 422 + rule through the real HTTP door first, then saves with the reference resolver mounted and unmounts it from both stores (kernel.services and pluginLoader.serviceInstances, consistent with the kernel's two-tier getService), so the fail-closed read is still proven against a stored row. No shipped example, app or world is newly refused; the one affected world is fixed honestly. The tests claimed (7 pure, 3 door rows, 2 explain cells, 1 real-stack cell, 2 spec rows, 4/4 dogfood) are all present in the diff, and the three ablation legs each name the line that would go red.

② Semver level

  • Changeset .changeset/26188-hierarchy-scope-without-resolver.md: @objectstack/metadata-protocol: patch, @objectstack/plugin-security: patch, @objectstack/spec: patch. Metadata-protocol: none is the right kind: PermissionSetSchema parses exactly what it parsed before, no authorable key, value or default moves; the refusal is a runtime-gate rule conditional on a deployment fact, the class the [转移] 发布期护栏:多组织下「平台级 + schedule + create_record + 未显式 organization_id」拒绝——#6155 Q3=A 裁决的 lint 半边 #6285 and forms: on a walled tenancy posture a published public form bound to a tenant-scoped object is served (GET 200) but every anonymous submit answers 500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED #21476 rules belong to. Under ruling A the level follows the kind alone, so none takes patch on all three. (A reading of "a value refused" as narrowed would require minor on metadata-protocol; I resolve it for the PR because the protocol an author writes against is unchanged, and the gate's own rule reads narrowed as a contradiction only beside a missing (narrowing) arm.) Right.
  • Clause-②: yes (narrowing: on a runtime with no hierarchy resolver, … ; widened: …) on both the PR body and the changeset. Through scripts/pm/clause2-line.mjs the value reads yes and readArmToken reads narrowing (the arm regex accepts the word followed by :, which is neither a word character nor |); the rest of the parenthetical is the seat's argument and is ignored. That is the documented combination "yes (narrowing): a diff that widens one surface and narrows another. Both facts are true and both are read." The spelling is what the repo's rules and the Check Changeset gate expect for a narrowing that also widens; the claim's provisional no (narrowing) was rightly revised, since a no would have denied the spec export. Right.
  • BREAKING: the (narrowing) arm is BREAKING, so a banner and an ADR-0087 disposition are owed. Present: a **BREAKING** banner with Before / Now / Fix; the title and the changeset summary carry the ! (fix(metadata-protocol)!:); exactly one adr-0087 marker, not-required (no-migration-prescription), a category in CATEGORIES. The body carries no FROM→TO label, no rewrite arrow and no framed migration section, so the prescription detector has nothing to refuse, and the exemption is honest: the author's repair is a choice between a non-hierarchy depth and an enterprise runtime, which no ledger entry can derive. check-adr-0087-registration.mjs runs inside the Check Changeset job (pr-automation.yml:999), which concluded success on this head. Owed and present; right.
  • No authorable-surface/ or authorable-defaults/ baseline moves; api-surface and export-origins rows are the only spec artifacts owed for a function export, and both are present.

③ Boundary flags

  1. Dev deviation: Clause-②: yes (narrowing…) instead of the provisional no (narrowing). Accepted, see ②. The spec export and the public ExplainEngineDeps member are real widenings.
  2. Dev deviation: two harnesses changed beyond the new cells. explain-read-verdict-inputs.test.ts moving its stub resolver into the kernel service map is a correction (the old harness gave the sharing service a resolver the kernel never knew, which is exactly the contradiction 9b is about). The dogfood world's rework is judged in ①. Accepted.
  3. Acceptance note 1: SaveMetaItemResponseSchema.advisories .describe() left stale. Read at protocol.zod.ts:1064-1076: "applied to this body on its way to active" is now partly stale, since a draft save can carry this one warning. The key's shape and the invariant that matters to a consumer ("every entry has severity warning or info", "present only when at least one advisory was raised") still hold, and the text is description only. Accepted as a one-line docs-only follow-up on packages/spec, outside this claim's one-spec-change allowance. Not blocking.
  4. Acceptance note 2: the object-level depth layer (no record) is unchanged. The ruling names explain-engine.ts:2040-2075, the record cell. The object-level cell describes the declared depth, which on a resolver-less runtime is still what the metadata says. Within scope; a future card could add the same resolver-absent sentence there. Not blocking.
  5. Out-of-scope finding on the lookup shape (mine). The protocol reads ctx.getServices() (a snapshot of kernel.services) while the sharing plugin reads ctx.getService(...) (three tiers). A resolver registered by factory and never resolved would be read as absent at the publish door while sharing enforces it. No in-repo registration takes that path; the cloud SecurityEnterprisePlugin is not readable from this repo (the card says the same of apps/objectos-ee/objectstack.config.ts). The cloud checklist card the ruling names (Q4 cloud lane, licensed posture re-run) is the place this gets measured. Escalated as a one-line note to that card's owner; not blocking here.
  6. Explain wording corner (mine). The sharingFilter === null case under a resolver-less hierarchy depth now says "a share admits this record" where no share and no owner-match exist (①). Outcome right, sentence wrong, reachable only for a stored pre-PR grant on an ownerless object. Follow-up on plugin-security; not blocking.
  7. package-author channel unchanged. A package-author write of the same grant is still silent at the runtime gate, by the gate's standing design (os build / defineStack is that channel's door and already refuses it). Within the ruling; recorded so nobody reads the four doors as five.
  8. Check-runs on the head. 20 concluded: Check Changeset, Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Dogfood Regression Gate, Spec property liveness, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Auto Label, filter, the three claim/closing-target guards and Part-of PR must not also close its card all success; Build Docs, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance, 18.x upgrade harness skipped. 20 not yet concluded at the time of this record: Build Core, Build UI Deps, Lint · CI, release & engine gates, Lint · ESLint, Lint · docs & code guards (hosts check:error-code-provenance and check:dispatcher-error-vocabulary), Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Test Core (1/8) through (8/8), Type Check · consumer gates (hosts check:api-surface), Type Check · debt ledger, Type Check · source gates, Type Check · workspace. The dev's reading is 99 derived gates at exit 0 and three packages green locally; the landing still waits for every check to conclude green (Tier S: record plus every check green).
  9. Governed surfaces. The file list touches none (GOVERNED_SURFACES paths absent; Governed Surface Queue Guard success). Head repo is objectstack-ai/objectstack, not a fork. The PR is a draft; its tier is decided by the published-schema hits (permission.zod.ts, stack.zod.ts), which is this record's face.
  10. Dev open questions: none reported. Write budget and MCP calls: reads only, three relay writes, as reported; nothing to escalate.

Implemented-by: claude/project-thread-qp1ljk
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T20:27Z

Head 1267e3cb2b, report 6426008287 (on #26188), claim 6424008328. The contract review is owed, because the diff touches packages/spec/src/** and declares a narrowing. It is on record: PASS at CONTRACT_REVIEW_TIER on this head (6426321194).

item reading
Shape Draft, base main, Fixes #26188, ! title, patch changeset for @objectstack/spec, @objectstack/metadata-protocol and @objectstack/plugin-security, with a BREAKING banner and ADR-0087 not-required (no-migration-prescription). Mergeable clean. Not governed (0 of 16 paths), 591 changed lines.
Fix (9a) The rule hierarchy-scope-capability-required is gate-local in the runtime authoring gate. Its input, resolver presence, is a host fact read from the kernel service hierarchy-scope-resolver on each write. Publishing a permission with own_and_reports, unit or unit_and_below on either axis answers 422 INVALID_METADATA through all four doors (active save, per-item publish, package batch publish, restore) when no resolver is mounted. A draft save answers 200 with the same entry in advisories (D1 holds; no collection read). A mounted resolver, or a host with no service registry, is unchanged. This is the route agreed before the claim: no ledger row, no _diagnostics. delegated-admin-gate.ts and error-code-ledger.zod.ts are untouched.
Fix (9b) The explain record cell gains a resolver-absent branch after the "depth admitted this row" arm, so a row the depth admitted is never misattributed. hierarchyResolverMounted is an optional dependency, and absent reads as before.
Spec The one allowed helper export is findHierarchyScopeGrants, with its api-surface and export-origins rows. defineStack's refusal text is byte-identical.
Evidence 40 check runs succeeded and 8 were expected skips on this head, 0 failed. There are three red legs (door, explain, draft advisory), each restored to HEAD. Package suites: metadata-protocol 21,549, plugin-security 4,929 and spec 21,965 passed. Derived gates: 99 of 99 exit 0, and --ran reconciles. The one dogfood world that published a hierarchy grant without a resolver now pins the 422 first, then saves with the reference resolver mounted.
Carried The contract review's lookup-shape flag is filed as cloud-lane card #26312: the door reads the services map while sharing's lookup is three-tier, so a factory-registered enterprise resolver would read as absent. The stale advisories .describe() and the unchanged object-level depth cell are noted follow-ups and block nothing.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay. Every check on this head is green or an expected skip, and the contract review PASS is on record.

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.

metadata save + explain: an unlicensed hierarchy scope saves silently at runtime though defineStack refuses it, and explain blames the depth

2 participants