You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit b029c90
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metadata-protocol)!: refuse publishing a hierarchy-scope grant when no hierarchy resolver is mounted; explain names the absent resolver (#26188) (#26305)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X35pgvocjuh1o5vbpJ4ZCyZ)_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 #6285 and
#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`.**
- The runtime authoring gate refuses with one envelope: 422
`INVALID_METADATA`, with `issues[].rule`. Every gate-local refusal
(#6285, #20312) rides that envelope rather than adding a second envelope
or a second throw site. A rule inside it reaches all four write doors
above at once.
- Stamping the `STACK_*` code at an HTTP door would need a provenance
row under `@objectstack/metadata-protocol` in
`error-code-ledger.zod.ts`, a file this claim keeps off (sibling
#26189). That ledger row also reads `door: 'none'`, `defineStack` only.
- `INVALID_METADATA` is already registered for this package, so no
ledger row is added. The server seat agreed this route before the claim.
**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](https://claude.ai/code/session_01M4SCfSer74vqRR1SLn1hWP)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(metadata-protocol)!: a permission set that grants a hierarchy scope is refused on publish, and warned on a draft save, when the runtime mounts no hierarchy resolver; explain names the absent resolver (#26188)
8
+
9
+
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`)
10
+
Metadata-protocol: none
11
+
Ruling-ref: #26059 ruling Q3 / Q4 (`6416233738`), items 9a and 9b of the item reading (`6415971852`)
12
+
13
+
`own_and_reports`, `unit` and `unit_and_below` are enforced only by the enterprise hierarchy resolver (capability `hierarchy-security`, ADR-0057). Without it the open sharing service evaluates them as `own`. `defineStack` already refuses such a grant unless the stack declares `requires: ['hierarchy-security']`, but a runtime save of the same permission set answered 200 with no word, and explain then told a manager that a subordinate's record was outside their unit.
14
+
15
+
What changes:
16
+
17
+
- The runtime authoring gate reads whether a hierarchy resolver is mounted (the kernel service `hierarchy-scope-resolver`) on every metadata write. When none is, a permission set whose `readScope` or `writeScope` is a hierarchy depth is refused on publish: a direct active save, the per-item publish of a draft, the package batch publish and a restore to such a version all answer 422 `INVALID_METADATA`, with one `issues[]` entry per grant under the rule `hierarchy-scope-capability-required`. The entry is located at the grant and names the `hierarchy-security` requirement.
18
+
- A draft save of the same body still succeeds. Its answer carries the same finding, at `severity: 'warning'`, in `advisories`.
19
+
- With a resolver mounted, nothing changes. A host with no service registry (the CLI) judges nothing here; `defineStack` is that host's door.
20
+
-`explainAccess`: on a deployment with no resolver, the depth layer's record cell for a hierarchy read depth now reads "Hierarchy resolver absent — requires enterprise; depth evaluated as 'own'." instead of saying the owner is outside the caller's read depth.
21
+
-`@objectstack/spec/security` exports `findHierarchyScopeGrants`, the one list of hierarchy depths and the one finding sentence. `defineStack`'s refusal reads it too, and its message is unchanged.
22
+
23
+
**BREAKING** for a runtime with no hierarchy resolver that publishes hierarchy-scope grants at runtime.
24
+
25
+
-**Before.** The publish answered 200 and the grant was enforced as owner-only.
26
+
-**Now.** The publish answers 422 `INVALID_METADATA` with the rule `hierarchy-scope-capability-required`. A draft save warns.
27
+
-**Fix.** Set the grant's `readScope` / `writeScope` to `own` or `org`, or run the permission set on a runtime that mounts the enterprise hierarchy resolver (`hierarchy-security`). Rows already stored are read as before.
28
+
29
+
<!-- adr-0087: not-required (no-migration-prescription) a run-time narrowing at the metadata publish door, conditional on a deployment fact (no hierarchy resolver mounted). No authorable key, spelling or stored shape moves: the permission-set schema parses exactly as before, `defineStack` already refused the same grant, and no stored row is read, rewritten or converted. The repair is the author's choice between a non-hierarchy depth and an enterprise runtime, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this verdict (not already-registered); the one spec export is added, not moved, renamed or removed; and nothing a stored artifact carries changes type, so no conversion has a source to rewrite. -->
0 commit comments