@@ -86,8 +86,9 @@ export function appDefaultPermissionSetName(permissions: unknown): string | unde
8686}
8787
8888/**
89- * [ADR-0130 D4, #15007] Every permission set a stack config DECLARES — from the
90- * flattened top level, and from `packages[]`.
89+ * [ADR-0130 D4, #15007] The app-declared default permission-set NAME, resolved
90+ * from wherever the artifact carries the declaration — the flattened top
91+ * level, or `packages[]`.
9192 *
9293 * ## What this exists to stop
9394 *
@@ -114,52 +115,83 @@ export function appDefaultPermissionSetName(permissions: unknown): string | unde
114115 * this function has to be a superset of the old read rather than a replacement
115116 * for it: for every artifact the platform emits today the flattened level
116117 * answers first and this returns exactly what it returned before. The
117- * `packages[]` pass only supplies a set where the top level had none — which is
118- * precisely the option-B artifact. That is what makes this card revertible on
119- * its own and safe to land before the emitter half (#14512).
118+ * `packages[]` pass is consulted ONLY where the top level named no default —
119+ * which is precisely the option-B artifact. That is what makes this card
120+ * revertible on its own and safe to land before the emitter half (#14512).
121+ *
122+ * ## The condition is the ANSWER, never the container
123+ *
124+ * "The top level had none" is spelled as `appDefaultPermissionSetName` coming
125+ * back `undefined`, and deliberately NOT as the `permissions` array being
126+ * absent or empty. Branching on the container re-creates the silent loss this
127+ * card exists to remove, one shape further along: a config whose flattened
128+ * level carries permission sets but marks none of them `isDefault` — legal
129+ * today, and expressible by hand in any `objectstack.config.ts` — would
130+ * short-circuit the whole `packages[]` pass and resolve `undefined`, with
131+ * nothing thrown and nothing logged. Reading the container also hands back the
132+ * `[]`-is-truthy trap for free. The answer is the only condition that cannot
133+ * be wrong in either direction, so the answer is what this branches on.
134+ *
135+ * ⚠️ That is deliberately NOT the shape of the sibling reader's condition, and
136+ * the difference is a property of the readers, not an inconsistency to
137+ * converge away. `resolveStackCollection` (`packages/cli/src/utils/
138+ * stack-collections.ts`, #15006) branches on the CONTAINER — `if
139+ * (Array.isArray(top)) return top;` — and is right to: it returns a whole
140+ * collection, so a top level that carries the key has, by construction,
141+ * already answered, and `composeStacks` flattened that array into the union.
142+ * This reader extracts a DISTINGUISHED ELEMENT out of the collection instead,
143+ * so "the key is present" and "the key answers" are two different facts here
144+ * and one of them is the wrong one to branch on. Same discipline — start from
145+ * the expression this program replaced, consult `packages[]` only where it came
146+ * back empty — read against what each reader's expression actually returns.
120147 *
121148 * ## The order is `resolveArtifactPackageOrder`'s, not the array's
122149 *
123- * `appDefaultPermissionSetName` resolves the FIRST `isDefault` set , so with more
124- * than one package declaring one, "first" has to mean the same thing here as it
125- * does everywhere else the artifact is read. `resolveArtifactPackageOrder`
150+ * The first package body that names a default wins , so with more than one
151+ * package declaring one, "first" has to mean the same thing here as it does
152+ * everywhere else the artifact is read. `resolveArtifactPackageOrder`
126153 * (`@objectstack/core`, ADR-0130 D4+D5, #14643) is the ONE place that turns an
127154 * artifact into its ordered package list — dependency-topological, so a package
128155 * that extends another is read after it regardless of which array slot it
129156 * occupies. ⛔ Do not iterate `config.packages` directly here; a second
130157 * traversal is a second ordering, and the depended-upon package would win or
131158 * lose by authoring accident.
132159 *
133- * ## Two things it deliberately does NOT do
134- *
135- * • It does not look inside the SINGULAR `manifest`. That constraint is
136- * #7001's and it still holds — the harness must not honour a declaration
137- * `serve.ts` ignores. Note this is not a special case bolted on: an
138- * artifact carrying no `packages` key makes `resolveArtifactPackageOrder`
139- * return the caller's own object as the single package body (D4's second
140- * branch, D7's compatibility term), so that branch reads `permissions` from
141- * exactly where the old code read it and nowhere else.
142- * • It does not catch `resolveArtifactPackageOrder`'s refusals. A malformed
143- * `packages` (not an array, an unwrapped entry, a duplicate package id)
144- * raises an ADR-0112 envelope here, the same one the manifest service
145- * raises when it registers that artifact moments later. Swallowing it would
146- * resolve a permission surface out of an artifact the loader refuses to
147- * load — the gate travels with the read.
160+ * ## The package order is resolved BEFORE the top level is consulted
161+ *
162+ * Reading that line as a misplaced statement is the expected mistake, so: it is
163+ * placed there on purpose, and moving it below the early return is a behaviour
164+ * change. `resolveArtifactPackageOrder` REFUSES a malformed `packages` (not an
165+ * array, an unwrapped entry, a duplicate package id) with an ADR-0112 envelope,
166+ * and this reader does not catch it — swallowing it would resolve a permission
167+ * surface out of an artifact the loader refuses to load. Resolving the order
168+ * first is what keeps that refusal unconditional: an artifact is either
169+ * loadable or refused, and which answer this reader gives about it must not
170+ * depend on whether its flattened level happened to name a default first.
171+ *
172+ * ## One thing it deliberately does NOT do
173+ *
174+ * It does not look inside the SINGULAR `manifest`. That constraint is #7001's
175+ * and it still holds — the harness must not honour a declaration `serve.ts`
176+ * ignores. Note this is not a special case bolted on: an artifact carrying no
177+ * `packages` key never reaches the package pass at all, so that branch reads
178+ * `permissions` from exactly where the old code read it and nowhere else.
148179 */
149- function declaredPermissionSets ( config : unknown ) : unknown [ ] {
150- const sets : unknown [ ] = [ ] ;
180+ function declaredDefaultPermissionSetName ( config : unknown ) : string | undefined {
181+ const packages = ( config as { packages ?: unknown } | null | undefined ) ?. packages ;
182+ const bodies =
183+ packages === undefined || packages === null ? [ ] : resolveArtifactPackageOrder ( config ) ;
151184
152185 const flattened = ( config as { permissions ?: unknown } | null | undefined ) ?. permissions ;
153- if ( Array . isArray ( flattened ) ) sets . push ( ...flattened ) ;
154-
155- const packages = ( config as { packages ?: unknown } | null | undefined ) ?. packages ;
156- if ( packages === undefined || packages === null ) return sets ;
186+ const fromFlattened = appDefaultPermissionSetName ( flattened ) ;
187+ if ( fromFlattened !== undefined ) return fromFlattened ;
157188
158- for ( const body of resolveArtifactPackageOrder ( config ) ) {
189+ for ( const body of bodies ) {
159190 const declared = ( body as { permissions ?: unknown } | null | undefined ) ?. permissions ;
160- if ( Array . isArray ( declared ) ) sets . push ( ...declared ) ;
191+ const fromPackage = appDefaultPermissionSetName ( declared ) ;
192+ if ( fromPackage !== undefined ) return fromPackage ;
161193 }
162- return sets ;
194+ return undefined ;
163195}
164196
165197/**
@@ -191,13 +223,14 @@ function declaredPermissionSets(config: unknown): unknown[] {
191223 * the result straight through — `new SecurityPlugin(appSecurityPluginOptions(config))`
192224 * — and a caller cannot get the undefined case subtly wrong.
193225 *
194- * Reads the sets through {@link declaredPermissionSets} — the flattened top
195- * level `serve.ts` has always read, and, for a multi-package artifact, the
196- * `packages[]` bodies that carry the same declaration under ADR-0130 D4.
226+ * Resolves the name through {@link declaredDefaultPermissionSetName} — the
227+ * flattened top level `serve.ts` has always read, and, for a multi-package
228+ * artifact, the `packages[]` bodies that carry the same declaration under
229+ * ADR-0130 D4.
197230 */
198231export function appSecurityPluginOptions (
199232 config : unknown ,
200233) : { fallbackPermissionSet : string } | undefined {
201- const name = appDefaultPermissionSetName ( declaredPermissionSets ( config ) ) ;
234+ const name = declaredDefaultPermissionSetName ( config ) ;
202235 return name ? { fallbackPermissionSet : name } : undefined ;
203236}
0 commit comments