Skip to content

Commit b029c90

Browse 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>
1 parent e638193 commit b029c90

16 files changed

Lines changed: 555 additions & 36 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/plugin-security': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
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. -->

‎packages/metadata-protocol/src/protocol-authoring-gate.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export interface AuthoringGateHost {
7272
readonly engine: ObjectStackProtocolImplementation['engine'];
7373
externalBindingSaveRefusal: ObjectStackProtocolImplementation['externalBindingSaveRefusal'];
7474
foldStoredCollection: ObjectStackProtocolImplementation['foldStoredCollection'];
75+
hierarchyResolverMounted: ObjectStackProtocolImplementation['hierarchyResolverMounted'];
7576
isArtifactBacked: ObjectStackProtocolImplementation['isArtifactBacked'];
7677
orgWallEnforced: ObjectStackProtocolImplementation['orgWallEnforced'];
7778
refuseRestoredBodyTheSaveDoorRefuses: ObjectStackProtocolImplementation['refuseRestoredBodyTheSaveDoorRefuses'];
@@ -168,7 +169,7 @@ export async function runAuthoringGate(
168169

169170
/** The body of `ObjectStackProtocolImplementation#assertRuntimeAuthoringRules`; the method's docblock there is its contract. */
170171
export async function assertRuntimeAuthoringRules(
171-
this: Pick<AuthoringGateHost, 'authoringChannel' | 'engine' | 'foldStoredCollection' | 'orgWallEnforced' | 'resolveSduiManifest' | 'resolveWritePackageScope' | 'rlsMembershipResolverKeys' | 'tenancyPostureInForce'>,
172+
this: Pick<AuthoringGateHost, 'authoringChannel' | 'engine' | 'foldStoredCollection' | 'hierarchyResolverMounted' | 'orgWallEnforced' | 'resolveSduiManifest' | 'resolveWritePackageScope' | 'rlsMembershipResolverKeys' | 'tenancyPostureInForce'>,
172173
evt: {
173174
type: string; name: string; state: 'draft' | 'active'; body: unknown; source?: string;
174175
/**
@@ -250,7 +251,21 @@ export async function assertRuntimeAuthoringRules(
250251
// proxy, which cost the ADR-0090 D11 object posture gate every
251252
// host-config deployment until #7674 finished the move.
252253
if (this.authoringChannel === 'package-author') return [];
253-
if (evt.state !== 'active') return [];
254+
if (evt.state !== 'active') {
255+
// [#26188] D1: no rule gates a draft. The one finding a draft does hear
256+
// is a hierarchy scope this runtime cannot enforce, as a warning on the
257+
// 2xx; it needs no collection read, so the draft keeps paying none.
258+
// A `migrate-stored` pass heals storage and hears nothing (see below).
259+
if (evt.source === 'migrate-stored') return [];
260+
const hierarchyResolverMounted = this.hierarchyResolverMounted();
261+
return evaluateRuntimeAuthoringGate({
262+
type: PLURAL_TO_SINGULAR[evt.type] ?? evt.type,
263+
name: evt.name,
264+
state: evt.state,
265+
body: evt.body,
266+
...(hierarchyResolverMounted !== undefined ? { hierarchyResolverMounted } : {}),
267+
}).advisories;
268+
}
254269
// `os migrate meta --stored --apply` rewrites rows that ALREADY EXIST
255270
// into the current dialect. It is not an author publishing anything —
256271
// it is the platform healing its own storage — and #4463 D4 is explicit
@@ -372,6 +387,10 @@ export async function assertRuntimeAuthoringRules(
372387
// gate stays pure.
373388
const rlsMembershipResolverKeys = this.rlsMembershipResolverKeys();
374389

390+
// [#26188] Is a hierarchy resolver mounted — a host fact of the same kind,
391+
// read here per publish and passed in so the gate stays pure.
392+
const hierarchyResolverMounted = this.hierarchyResolverMounted();
393+
375394
const verdict = evaluateRuntimeAuthoringGate({
376395
type: singular,
377396
name: evt.name,
@@ -398,6 +417,8 @@ export async function assertRuntimeAuthoringRules(
398417
// [#25234] The resolver's keys, read per publish; with them the
399418
// gate judges who owns each membership key.
400419
...(rlsMembershipResolverKeys !== undefined ? { rlsMembershipResolverKeys } : {}),
420+
// [#26188] With it the gate refuses a hierarchy scope no resolver enforces.
421+
...(hierarchyResolverMounted !== undefined ? { hierarchyResolverMounted } : {}),
401422
});
402423
if (verdict.error) throw verdict.error;
403424
return verdict.advisories;

‎packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ import { ObjectStackProtocolImplementation } from './protocol.js';
5050
import type { MetadataAuthoringChannel } from './protocol.js';
5151
import { SDUI_MANIFEST_SERVICE } from './index.js';
5252
import { RLS_MEMBERSHIP_RESOLVER_SERVICE } from '@objectstack/spec/contracts';
53-
import { PUBLIC_FORM_INTAKE_UNAVAILABLE, stampHtmlPageRequires } from './runtime-authoring-gate.js';
53+
import {
54+
HIERARCHY_SCOPE_CAPABILITY_REQUIRED,
55+
HIERARCHY_SCOPE_RESOLVER_SERVICE,
56+
PUBLIC_FORM_INTAKE_UNAVAILABLE,
57+
stampHtmlPageRequires,
58+
} from './runtime-authoring-gate.js';
5459

5560
/** The issue's body. Zod-valid: `approvers[].value` is just a string to the schema. */
5661
const brokenApprovalFlow = () => ({
@@ -4720,3 +4725,88 @@ describe('runtime authoring gate on VIEW writes — a public form declaring a fi
47204725
expect(activeViewRows(rows)).toHaveLength(1);
47214726
});
47224727
});
4728+
4729+
// ─────────────────────────────────────────────────────────────────────────────
4730+
// #26188 — a HIERARCHY scope on a runtime with no hierarchy resolver. The door
4731+
// reads resolver presence per write (`HIERARCHY_SCOPE_RESOLVER_SERVICE`): with
4732+
// none, publishing the grant is refused and a draft save answers 200 carrying
4733+
// the same finding as a `warning` advisory; with one, nothing changes. The pure
4734+
// rows are `runtime-authoring-gate.hierarchy-scope.test.ts`.
4735+
// ─────────────────────────────────────────────────────────────────────────────
4736+
4737+
describe('runtime authoring gate on PERMISSION writes — hierarchy scope without a resolver (#26188)', () => {
4738+
let warn: ReturnType<typeof vi.spyOn>;
4739+
beforeEach(() => {
4740+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
4741+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
4742+
});
4743+
afterEach(() => {
4744+
warn.mockRestore();
4745+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
4746+
});
4747+
4748+
const managerSet = () => ({
4749+
name: 'sales_manager',
4750+
label: 'Sales Manager',
4751+
objects: { leave_request: { allowRead: true, readScope: 'unit_and_below' } },
4752+
});
4753+
const NO_RESOLVER = () => new Map<string, any>();
4754+
const RESOLVER = () => new Map<string, any>([[HIERARCHY_SCOPE_RESOLVER_SERVICE, { resolveOwnerIds: async () => [] }]]);
4755+
4756+
function permissionProtocol(services: () => Map<string, any>) {
4757+
const { engine, rows } = makeStubEngine();
4758+
const protocol = new ObjectStackProtocolImplementation(engine, services as any, 'env_test') as any;
4759+
return { protocol, rows };
4760+
}
4761+
const settle = (p: Promise<unknown>) =>
4762+
p.then((result) => ({ result: result as any, err: null as any }), (err) => ({ result: null as any, err }));
4763+
const rowsOf = (rows: Map<string, Row>, state: 'draft' | 'active') =>
4764+
Array.from(rows.values()).filter((r) => r.type === 'permission' && r.state === state);
4765+
const hierarchyFindings = (list: ReadonlyArray<{ rule: string }> | undefined) =>
4766+
(list ?? []).filter((i) => i.rule === HIERARCHY_SCOPE_CAPABILITY_REQUIRED);
4767+
4768+
it('no resolver, publish: refused 422 INVALID_METADATA naming the grant, and nothing is written', async () => {
4769+
const { protocol, rows } = permissionProtocol(NO_RESOLVER);
4770+
const { err } = await settle(protocol.saveMetaItem({ type: 'permission', name: 'sales_manager', item: managerSet() }));
4771+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
4772+
const findings = hierarchyFindings(err.issues);
4773+
expect(findings).toEqual([expect.objectContaining({
4774+
severity: 'error',
4775+
path: 'permissions.sales_manager.objects.leave_request.readScope',
4776+
})]);
4777+
expect((findings[0] as any).message).toContain('`hierarchy-security`');
4778+
expect(rowsOf(rows, 'active')).toHaveLength(0);
4779+
});
4780+
4781+
it('no resolver, draft: saved (200) with the same finding as a warning advisory; publishing it is refused', async () => {
4782+
const { protocol, rows } = permissionProtocol(NO_RESOLVER);
4783+
const refused = await settle(protocol.saveMetaItem({ type: 'permission', name: 'sales_manager', item: managerSet() }));
4784+
const { result, err } = await settle(
4785+
protocol.saveMetaItem({ type: 'permission', name: 'sales_manager', item: managerSet(), mode: 'draft' }),
4786+
);
4787+
expect(err).toBeNull();
4788+
expect(result).toMatchObject({ success: true, state: 'draft' });
4789+
const published = hierarchyFindings(refused.err.issues)[0] as Record<string, unknown>;
4790+
expect(result.advisories).toEqual([{ ...published, severity: 'warning' }]);
4791+
expect(rowsOf(rows, 'draft')).toHaveLength(1);
4792+
4793+
const promoted = await settle(protocol.publishMetaItem({ type: 'permission', name: 'sales_manager' }));
4794+
expect({ code: promoted.err?.code, status: promoted.err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
4795+
expect(hierarchyFindings(promoted.err.issues)).toHaveLength(1);
4796+
expect(rowsOf(rows, 'active')).toHaveLength(0);
4797+
});
4798+
4799+
it('a resolver mounted: the same grant publishes, and neither door says anything about it', async () => {
4800+
const { protocol, rows } = permissionProtocol(RESOLVER);
4801+
const draft = await settle(
4802+
protocol.saveMetaItem({ type: 'permission', name: 'sales_manager', item: managerSet(), mode: 'draft' }),
4803+
);
4804+
expect(draft.err).toBeNull();
4805+
expect(hierarchyFindings(draft.result.advisories)).toEqual([]);
4806+
const live = await settle(protocol.saveMetaItem({ type: 'permission', name: 'sales_manager', item: managerSet() }));
4807+
expect(live.err).toBeNull();
4808+
expect(live.result).toMatchObject({ success: true, state: 'active' });
4809+
expect(hierarchyFindings(live.result.advisories)).toEqual([]);
4810+
expect(rowsOf(rows, 'active')).toHaveLength(1);
4811+
});
4812+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { stripUnauthorableProperties } from './unauthorable-nodes.js';
2727
// admits anything. See the module header for the predicate and its controls.
2828
import { markErasedAuthoringInput } from './erased-authoring-mark.js';
2929
import {
30+
HIERARCHY_SCOPE_RESOLVER_SERVICE,
3031
SDUI_MANIFEST_SERVICE,
3132
isUsableSduiManifest,
3233
stampHtmlPageRequires,
@@ -7146,6 +7147,7 @@ export class ObjectStackProtocolImplementation implements
71467147
get authoringChannel() { return readAuthoringChannel(); },
71477148
get engine() { return readEngine(); },
71487149
foldStoredCollection: (...a) => this.foldStoredCollection(...a),
7150+
hierarchyResolverMounted: (...a) => this.hierarchyResolverMounted(...a),
71497151
orgWallEnforced: (...a) => this.orgWallEnforced(...a),
71507152
resolveSduiManifest: (...a) => this.resolveSduiManifest(...a),
71517153
resolveWritePackageScope: (...a) => this.resolveWritePackageScope(...a),
@@ -7540,6 +7542,30 @@ export class ObjectStackProtocolImplementation implements
75407542
return keys as readonly string[];
75417543
}
75427544

7545+
/**
7546+
* [#26188] Is a hierarchy resolver mounted on this runtime — a service
7547+
* registered under `HIERARCHY_SCOPE_RESOLVER_SERVICE` (ADR-0057; the
7548+
* enterprise `hierarchy-security` capability registers one, the open edition
7549+
* none)? The input the runtime authoring gate refuses an unenforceable
7550+
* hierarchy scope with on publish, and warns on a draft save with.
7551+
*
7552+
* Read per write, as {@link rlsMembershipResolverKeys} reads its service, so a
7553+
* resolver registered after this protocol is assembled is seen by the next
7554+
* write; nothing is recorded. `undefined` — the CLI's reading, under which
7555+
* nothing is judged — when this host has no service registry or the lookup
7556+
* throws.
7557+
*/
7558+
private hierarchyResolverMounted(): boolean | undefined {
7559+
const registry = this.getServicesRegistry?.();
7560+
if (!registry) return undefined;
7561+
try {
7562+
const resolver = registry.get(HIERARCHY_SCOPE_RESOLVER_SERVICE);
7563+
return resolver !== undefined && resolver !== null;
7564+
} catch {
7565+
return undefined;
7566+
}
7567+
}
7568+
75437569
/**
75447570
* Run the registered projector for a just-persisted mutation (ADR-0094).
75457571
* Returns `undefined` when no projector is registered for the type;

0 commit comments

Comments
 (0)