Skip to content

Commit 59e01f4

Browse files
os-zhuangclaude
andcommitted
refactor(security): ADR-0057 open/paid seam — hierarchy scopes via pluggable IHierarchyScopeResolver
Hierarchy-relative visibility (scope unit/unit_and_below/own_and_reports) is an enterprise capability, not open-core. The open framework keeps the secure baseline (RLS/sharing/roles/own·org scope, BU data model + explicit business_unit recipient) and a pluggable seam; the resolver implementation ships in the private @objectstack/security-enterprise. - spec/contracts: IHierarchyScopeResolver + HierarchyScope. - plugin-sharing: SharingService delegates unit*/own_and_reports to a late-bound resolver (hierarchy-scope-resolver service); fails CLOSED to owner-only when absent (never fail-open). Removed the BU/manager rollup logic from the open SharingService. - spec/defineStack: compile-error if a grant uses a hierarchy scope without requires:['hierarchy-security'] (ADR-0049 — no silent lie). - dogfood showcase-scope-depth: registers a reference resolver (test fixture) to prove the seam (5/5), incl. a fail-closed case (no resolver → owner-only). - authz-conformance + ADR-0057: record the seam. Full build 75/75; spec 6599; dogfood 21/123; sharing 63; security 112 — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent af12531 commit 59e01f4

9 files changed

Lines changed: 194 additions & 45 deletions

File tree

docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,25 @@ Landed incrementally on branch `adr/0057-erp-authz-core`, each with a runtime pr
335335
**Deferred (evidence-gated, P4):** `sys_member.role``org_membership_level` relabel; manager-chain
336336
rollup (`subordinate_user_ids`); position hierarchy; record-owning teams; matrix cross-BU sharing;
337337
a full CEL→FilterCondition compiler (#1887) beyond the field-equality subset.
338+
339+
### Open/paid seam — hierarchy scopes are a pluggable enterprise capability (2026-06-21)
340+
341+
Hierarchy-relative visibility (scope `unit` / `unit_and_below` / `own_and_reports`
342+
"you see records by where you sit in the org") is the Salesforce/Dynamics commercial
343+
hallmark and is **not** required to build a secure app (explicit RLS + sharing rules +
344+
`own`/`org` scope suffice). It is therefore an **enterprise** capability, not open-core:
345+
346+
- **Open (framework):** the `IHierarchyScopeResolver` contract (`spec/contracts`),
347+
`own`/`org` scope, the BU data model + graph + `business_unit` *explicit* sharing
348+
recipient, roles, `#2077` seeding, the (future) predicate compiler. `SharingService`
349+
delegates hierarchy scopes to the resolver and **fails closed to owner-only** when none
350+
is registered — never fail-open. `defineStack` **errors** if a grant uses a hierarchy
351+
scope without `requires: ['hierarchy-security']` (no silent lie, ADR-0049).
352+
- **Paid (`@objectstack/security-enterprise`, private cloud repo):** the
353+
`hierarchy-scope-resolver` implementation (BU subtree + manager-chain rollup), plus the
354+
P4 heavy org-modeling (position hierarchy, matrix cross-BU, owner-teams) and governance
355+
(SSO/SCIM, audit/access-review, SoD).
356+
357+
The commercial boundary ADR lives in `cloud/docs/adr/`; this note records only the
358+
open-side technical seam. Proof: `showcase-scope-depth.dogfood` (reference resolver +
359+
fail-closed case).

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
4545
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
4646

4747
// ── ADR-0057 — ERP authorization core (enforced + e2e proven) ──────────
48-
{ id: 'scope-depth', summary: 'permission-grant access DEPTH (own/unit/unit_and_below/org)', state: 'enforced',
49-
enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing resolveOwnerScopeIds (owner IN unit-set) — ADR-0057 D1', proof: 'showcase-scope-depth.dogfood.test.ts' },
48+
{ id: 'scope-depth', summary: 'permission-grant access DEPTH (own/own_and_reports/unit/unit_and_below/org)', state: 'enforced',
49+
enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing delegates HIERARCHY scopes to a pluggable IHierarchyScopeResolver (open: fail-closed to own; enterprise @objectstack/security-enterprise; reference resolver in this proof) — ADR-0057 D1', proof: 'showcase-scope-depth.dogfood.test.ts' },
5050
{ id: 'declarative-rbac-seeding', summary: 'stack-declared roles + sharingRules seeded at boot (#2077)', state: 'enforced',
5151
enforcement: 'plugin-security bootstrapDeclaredRoles + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' },
5252
{ id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_role, decoupled from better-auth membership)', state: 'enforced',

packages/dogfood/test/showcase-scope-depth.dogfood.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
// → my BU plus all descendant BUs (BFS). Sharing still widens on top; cross-BU
77
// stays isolated. ('own' depth is already proven by showcase-private-owd.)
88
//
9+
// NOTE: hierarchy-scope resolution is an ENTERPRISE capability (lives in
10+
// @objectstack/security-enterprise). The open edition fails closed to owner-only.
11+
// This test registers a REFERENCE resolver (a test fixture) to prove the seam +
12+
// the contract end-to-end; production ships the enterprise resolver.
13+
//
914
// @proof: showcase-scope-depth
1015

1116
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -37,7 +42,7 @@ interface World { stack: VerifyStack; tokens: Record<Who, string>; }
3742

3843
// Build a BU world: bu_parent ⊃ bu_child (sibling bu_other is separate).
3944
// alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note.
40-
async function bootScopeWorld(scope: 'unit' | 'unit_and_below'): Promise<World> {
45+
async function bootScopeWorld(scope: 'unit' | 'unit_and_below', withResolver = true): Promise<World> {
4146
const stack = await bootStack(showcaseStack, {
4247
security: new SecurityPlugin({
4348
defaultPermissionSets: [...securityDefaultPermissionSets, scopeProfile(scope)],
@@ -49,6 +54,42 @@ async function bootScopeWorld(scope: 'unit' | 'unit_and_below'): Promise<World>
4954

5055
const ql: any = await stack.kernel.getServiceAsync('objectql');
5156
const sys = (o: string, d: any) => ql.insert(o, d, { context: { isSystem: true } });
57+
58+
// Reference hierarchy-scope resolver (test fixture; prod = @objectstack/security-enterprise).
59+
// Inlined (no plugin-sharing import) — proves the IHierarchyScopeResolver seam end-to-end.
60+
const refResolver = {
61+
async resolveOwnerIds(c: any, sc: string): Promise<string[]> {
62+
const meId = c.userId as string;
63+
const ids = new Set<string>([meId]);
64+
if (sc === 'own_and_reports') {
65+
let frontier: string[] = [meId]; const seen = new Set<string>([meId]);
66+
for (let d = 0; d < 20 && frontier.length; d++) {
67+
const rows = await ql.find('sys_user', { where: { manager_id: { $in: frontier } }, fields: ['id'], context: { isSystem: true } });
68+
const next: string[] = [];
69+
for (const r of rows ?? []) { const id = String(r.id ?? ''); if (id && !seen.has(id)) { seen.add(id); ids.add(id); next.push(id); } }
70+
frontier = next;
71+
}
72+
return [...ids];
73+
}
74+
const myBus = await ql.find('sys_business_unit_member', { where: { user_id: meId }, fields: ['business_unit_id'], context: { isSystem: true } });
75+
let buIds: string[] = [...new Set((myBus ?? []).map((r: any) => String(r.business_unit_id ?? '')).filter(Boolean))] as string[];
76+
if (!buIds.length) return [meId];
77+
if (sc === 'unit_and_below') {
78+
const allBu = new Set<string>(buIds); let frontier: string[] = [...buIds];
79+
for (let d = 0; d < 20 && frontier.length; d++) {
80+
const kids = await ql.find('sys_business_unit', { where: { parent_business_unit_id: { $in: frontier } }, fields: ['id'], context: { isSystem: true } });
81+
const next: string[] = [];
82+
for (const k of kids ?? []) { const id = String(k.id ?? ''); if (id && !allBu.has(id)) { allBu.add(id); next.push(id); } }
83+
frontier = next;
84+
}
85+
buIds = [...allBu];
86+
}
87+
const m = await ql.find('sys_business_unit_member', { where: { business_unit_id: { $in: buIds } }, fields: ['user_id'], context: { isSystem: true } });
88+
for (const x of m ?? []) { const u = String(x.user_id ?? ''); if (u) ids.add(u); }
89+
return [...ids];
90+
},
91+
};
92+
if (withResolver) (stack.kernel as any).registerService('hierarchy-scope-resolver', refResolver);
5293
const uid = async (who: Who) =>
5394
(await ql.findOne('sys_user', { where: { email: `scope-${who}-${scope}@verify.test` }, context: { isSystem: true } }))?.id;
5495
const id = {} as Record<Who, string>;
@@ -119,3 +160,17 @@ describe('showcase: scope-depth read — `unit_and_below` (ADR-0057 D1)', () =>
119160
expect(t.sort()).toEqual(['bob note']); // child has no descendants; no upward visibility
120161
});
121162
});
163+
164+
describe('open edition — hierarchy scope fails CLOSED without the enterprise resolver (ADR-0057)', () => {
165+
let world: World;
166+
beforeAll(async () => { world = await bootScopeWorld('unit', /* withResolver */ false); }, 120_000);
167+
afterAll(async () => { await world?.stack?.stop(); });
168+
169+
it('a `unit` grant degrades to owner-only — no widening, never fail-open', async () => {
170+
const t = await titles(world.stack, world.tokens.alice);
171+
expect(t).toContain('alice note'); // own still works
172+
expect(t).not.toContain('carol note'); // NO unit widening without @objectstack/security-enterprise
173+
expect(t).not.toContain('bob note');
174+
expect(t).not.toContain('dave note');
175+
});
176+
});

packages/plugins/plugin-security/src/permission-evaluator.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ export class PermissionEvaluator {
8787
opClass: 'read' | 'write',
8888
objectName: string,
8989
permissionSets: PermissionSet[],
90-
): 'own' | 'unit' | 'unit_and_below' | 'org' {
91-
const RANK = { own: 0, unit: 1, unit_and_below: 2, org: 3 } as const;
92-
const ORDER = ['own', 'unit', 'unit_and_below', 'org'] as const;
90+
): 'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' {
91+
const RANK = { own: 0, own_and_reports: 1, unit: 2, unit_and_below: 3, org: 4 } as const;
92+
const ORDER = ['own', 'own_and_reports', 'unit', 'unit_and_below', 'org'] as const;
9393
let widest = -1;
9494
let matched = false;
9595
for (const ps of permissionSets) {

packages/plugins/plugin-sharing/src/sharing-plugin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ export class SharingServicePlugin implements Plugin {
137137
this.service = new SharingService({
138138
engine: engine as SharingEngine,
139139
bypassObjects: this.options.bypassObjects,
140+
// [ADR-0057] Late-bound lookup of the enterprise hierarchy resolver.
141+
// Open edition: not registered → hierarchy scopes fail closed to own.
142+
hierarchyResolver: () => {
143+
try { return ctx.getService<any>('hierarchy-scope-resolver'); }
144+
catch { return null; }
145+
},
140146
});
141147
ctx.registerService('sharing', this.service);
142148

packages/plugins/plugin-sharing/src/sharing-service.ts

Lines changed: 32 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { BusinessUnitGraphService } from './business-unit-graph.js';
43
import type {
54
ISharingService,
5+
IHierarchyScopeResolver,
66
RecordShare,
77
GrantShareInput,
88
SharingExecutionContext,
@@ -72,6 +72,11 @@ export interface SharingServiceOptions {
7272
engine: SharingEngine;
7373
/** Object names that bypass sharing — typically platform internals. */
7474
bypassObjects?: string[];
75+
/**
76+
* [ADR-0057] Late-bound lookup for the enterprise hierarchy-scope resolver
77+
* (`hierarchy-scope-resolver` service). Returns null in the open edition.
78+
*/
79+
hierarchyResolver?: () => IHierarchyScopeResolver | null | undefined;
7580
}
7681

7782
/**
@@ -85,9 +90,11 @@ export interface SharingServiceOptions {
8590
export class SharingService implements ISharingService {
8691
private readonly engine: SharingEngine;
8792
private readonly bypassObjects: Set<string>;
93+
private readonly hierarchyResolver?: () => IHierarchyScopeResolver | null | undefined;
8894

8995
constructor(options: SharingServiceOptions) {
9096
this.engine = options.engine;
97+
this.hierarchyResolver = options.hierarchyResolver;
9198
this.bypassObjects = new Set([
9299
'sys_record_share',
93100
'sys_user',
@@ -125,7 +132,7 @@ export class SharingService implements ISharingService {
125132
// [ADR-0057 D1] Access DEPTH widens the owner-match for this grant:
126133
// own → [me], unit → my BU members, unit_and_below → my BU subtree, org →
127134
// no owner filter. Sharing grants are still OR-ed in on top (additive).
128-
const readScope = (context as any).__readScope as ('own' | 'unit' | 'unit_and_below' | 'org' | undefined);
135+
const readScope = (context as any).__readScope as ('own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' | undefined);
129136
if (readScope === 'org') return null;
130137
const ownerIds = await this.resolveOwnerScopeIds(context, readScope);
131138
const ownerMatch: Record<string, unknown> = ownerIds.length === 1
@@ -187,7 +194,7 @@ export class SharingService implements ISharingService {
187194
});
188195
const owner = Array.isArray(own) && own[0] ? (own[0] as any)[OWNER_FIELD] : undefined;
189196
if (owner != null) {
190-
const writeScope = (context as any).__writeScope as ('own' | 'unit' | 'unit_and_below' | 'org' | undefined);
197+
const writeScope = (context as any).__writeScope as ('own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' | undefined);
191198
if (writeScope === 'org') return true;
192199
const owners = await this.resolveOwnerScopeIds(context, writeScope);
193200
if (owners.includes(String(owner))) return true;
@@ -298,47 +305,35 @@ export class SharingService implements ISharingService {
298305
// ── helpers ──────────────────────────────────────────────────────
299306

300307
/**
301-
* [ADR-0057 D1] Resolve the owner-id set for a read/write DEPTH scope.
302-
* `own`/unset → just the caller; `unit` → members of the caller's business
303-
* unit(s); `unit_and_below` → the caller's BU subtree (BFS). Always includes
304-
* self. Elevated to SYSTEM_CTX (membership graph is platform metadata).
308+
* [ADR-0057] Resolve the owner-id set for a DEPTH scope. `own`/unset/`org`
309+
* resolve locally to the caller. HIERARCHY scopes (`unit` / `unit_and_below`
310+
* / `own_and_reports`) are an ENTERPRISE capability resolved by a pluggable
311+
* {@link IHierarchyScopeResolver} (`hierarchy-scope-resolver` service, shipped
312+
* only by `@objectstack/security-enterprise`). The open edition has none, so
313+
* this fails CLOSED to owner-only — a hierarchy scope NEVER widens without the
314+
* enterprise resolver (the spec gate also refuses to compile such a grant).
305315
*/
306316
private async resolveOwnerScopeIds(
307317
context: SharingExecutionContext,
308-
scope: 'own' | 'unit' | 'unit_and_below' | 'org' | undefined,
318+
scope: 'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org' | undefined,
309319
): Promise<string[]> {
310320
const me = String((context as any).userId);
311321
if (!scope || scope === 'own' || scope === 'org') return [me];
312-
const orgId = (context as any).organizationId ?? (context as any).tenantId ?? null;
313-
const myBus = await this.engine.find('sys_business_unit_member', {
314-
where: { user_id: me },
315-
fields: ['business_unit_id'],
316-
limit: 1000,
317-
context: SYSTEM_CTX,
318-
});
319-
const buIds = Array.from(
320-
new Set((myBus ?? []).map((r: any) => String(r.business_unit_id ?? '')).filter(Boolean)),
321-
);
322-
if (buIds.length === 0) return [me];
323-
const ids = new Set<string>([me]);
324-
if (scope === 'unit') {
325-
const members = await this.engine.find('sys_business_unit_member', {
326-
where: { business_unit_id: { $in: buIds } },
327-
fields: ['user_id'],
328-
limit: 10000,
329-
context: SYSTEM_CTX,
330-
});
331-
for (const m of members ?? []) {
332-
const u = String((m as any).user_id ?? '');
333-
if (u) ids.add(u);
334-
}
335-
} else {
336-
const bug = new BusinessUnitGraphService({ engine: this.engine, organizationId: orgId });
337-
for (const bu of buIds) {
338-
for (const u of await bug.expandUsers(bu)) ids.add(u);
339-
}
322+
const resolver = this.hierarchyResolver?.();
323+
if (!resolver) return [me];
324+
try {
325+
const ids = await resolver.resolveOwnerIds(
326+
{
327+
userId: me,
328+
organizationId: (context as any).organizationId ?? null,
329+
tenantId: (context as any).tenantId ?? null,
330+
},
331+
scope,
332+
);
333+
return Array.isArray(ids) && ids.length > 0 ? ids : [me];
334+
} catch {
335+
return [me];
340336
}
341-
return Array.from(ids);
342337
}
343338

344339
private shouldBypass(object: string, context: SharingExecutionContext): boolean {

packages/spec/src/contracts/sharing-service.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,32 @@ export interface IBusinessUnitGraphService {
249249
/** Return the manager id for a user — proxy to {@link ITeamGraphService.managerOf}. */
250250
managerOf(userId: string, organizationId?: string): Promise<string | null>;
251251
}
252+
253+
254+
/**
255+
* [ADR-0057] HIERARCHY access scopes (the Dataverse-style "see records by where
256+
* you sit in the org"). `own`/`org` are resolved by the open sharing layer
257+
* itself; these three require a pluggable resolver.
258+
*/
259+
export type HierarchyScope = 'unit' | 'unit_and_below' | 'own_and_reports';
260+
261+
export interface HierarchyScopeContext {
262+
userId: string;
263+
organizationId?: string | null;
264+
tenantId?: string | null;
265+
}
266+
267+
/**
268+
* Pluggable resolver for {@link HierarchyScope}s. The OPEN edition ships no
269+
* implementation; the ENTERPRISE package `@objectstack/security-enterprise`
270+
* registers one under the `hierarchy-scope-resolver` kernel service. When
271+
* absent, the sharing layer fails CLOSED to owner-only (a hierarchy scope never
272+
* widens without the resolver).
273+
*/
274+
export interface IHierarchyScopeResolver {
275+
/**
276+
* Owner ids whose records the caller may see under `scope` (must include the
277+
* caller). Empty/throw → caller falls back to owner-only.
278+
*/
279+
resolveOwnerIds(context: HierarchyScopeContext, scope: HierarchyScope): Promise<string[]>;
280+
}

packages/spec/src/security/permission.zod.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { lazySchema } from '../shared/lazy-schema';
1818
* [ADR-0057 D1] Object access DEPTH — the Dataverse "access level" axis,
1919
* layered on top of OWD. Widens the owner-match for owner-scoped objects.
2020
*/
21-
export const ObjectAccessScopeSchema = z.enum(['own', 'unit', 'unit_and_below', 'org']);
21+
export const ObjectAccessScopeSchema = z.enum(['own', 'own_and_reports', 'unit', 'unit_and_below', 'org']);
2222
export type ObjectAccessScope = z.infer<typeof ObjectAccessScopeSchema>;
2323

2424
export const ObjectPermissionSchema = lazySchema(() => z.object({
@@ -62,7 +62,8 @@ export const ObjectPermissionSchema = lazySchema(() => z.object({
6262
/**
6363
* [ADR-0057 D1] Read access DEPTH (Dataverse-style access level), layered on
6464
* top of OWD. For owner-scoped (`private`) objects it widens the owner-match:
65-
* `own` (owner only) | `unit` (my business unit) | `unit_and_below` (my BU +
65+
* `own` (owner only) | `own_and_reports` (me + my sys_user.manager_id
66+
* report chain) | `unit` (my business unit) | `unit_and_below` (my BU +
6667
* descendants) | `org` (whole tenant). Unset = `own` baseline. Resolved into
6768
* an `owner_id IN (…)` set at request time; sharing rules still widen on top.
6869
*/

packages/spec/src/stack.zod.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,40 @@ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefi
792792
* const stack = defineStack({ manifest: { ... }, objects: [...], views: [...] }, { strict: true });
793793
* ```
794794
*/
795+
/**
796+
* [ADR-0057] HIERARCHY access scopes (`unit` / `unit_and_below` /
797+
* `own_and_reports`) are an ENTERPRISE capability — their enforcement ships in
798+
* `@objectstack/security-enterprise`, not the open edition. A stack that uses
799+
* one MUST declare `requires: ['hierarchy-security']`; otherwise the open
800+
* runtime would silently fail closed to owner-only (the metadata would lie,
801+
* ADR-0049). This makes that an authoring-time error instead.
802+
*/
803+
function validateHierarchyScopeCapability(data: unknown): string[] {
804+
const errors: string[] = [];
805+
const d = data as { requires?: unknown; permissions?: unknown };
806+
const requires = Array.isArray(d?.requires) ? (d.requires as string[]) : [];
807+
if (requires.includes('hierarchy-security')) return errors;
808+
const HIER = new Set(['unit', 'unit_and_below', 'own_and_reports']);
809+
const perms = Array.isArray(d?.permissions) ? (d.permissions as any[]) : [];
810+
for (const ps of perms) {
811+
const objs = ps?.objects && typeof ps.objects === 'object' ? ps.objects : {};
812+
for (const [objName, grant] of Object.entries(objs)) {
813+
const g = grant as Record<string, unknown>;
814+
for (const key of ['readScope', 'writeScope']) {
815+
const v = g?.[key];
816+
if (typeof v === 'string' && HIER.has(v)) {
817+
errors.push(
818+
`permission set '${ps?.name ?? '?'}' grant on '${objName}' uses ${key}='${v}', a HIERARCHY scope. ` +
819+
`Declare \`requires: ['hierarchy-security']\` (provided by @objectstack/security-enterprise) — ` +
820+
`the open edition cannot enforce it and would fail closed to owner-only.`,
821+
);
822+
}
823+
}
824+
}
825+
}
826+
return errors;
827+
}
828+
795829
export function defineStack(
796830
config: ObjectStackDefinitionInput,
797831
options?: DefineStackOptions,
@@ -838,6 +872,13 @@ export function defineStack(
838872
throw new Error(`${header}\n\n${lines.join('\n')}`);
839873
}
840874

875+
const hierErrors = validateHierarchyScopeCapability(result.data);
876+
if (hierErrors.length > 0) {
877+
const header = `defineStack hierarchy-scope capability validation failed (${hierErrors.length} issue${hierErrors.length === 1 ? '' : 's'}):`;
878+
const lines = hierErrors.map((e) => ` ✗ ${e}`);
879+
throw new Error(`${header}\n\n${lines.join('\n')}`);
880+
}
881+
841882
return mergeActionsIntoObjects(result.data);
842883
}
843884

0 commit comments

Comments
 (0)