Skip to content

Commit ef3b7f1

Browse files
committed
Merge origin/main into claude/issue-14728-i18n-coverage-duplicate-findings
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
2 parents c47c13c + c4d1354 commit ef3b7f1

20 files changed

Lines changed: 2712 additions & 133 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(plugin-security): the app default permission set resolves from the first level that NAMES one (#15298)
6+
7+
`declaredPermissionSets` carried a docblock stating a short-circuit its code did
8+
not have:
9+
10+
> The `packages[]` pass only supplies a set where the top level had none — which
11+
> is precisely the option-B artifact.
12+
13+
The code pushed the flattened top level and then **every** package body
14+
unconditionally, so on today's additive artifact (flattened level *and*
15+
`packages[]` both present) every permission set was collected twice. Nothing
16+
observable came of it — the sole caller is private and takes the first
17+
`isDefault` set, which the flattened copy still supplied — so this corrects a
18+
false written contract on a security-path reader, not a live defect. That
19+
distinction is the point: the sentence was load-bearing, because it was the
20+
stated reason the reader half was revertible on its own and safe to land before
21+
the emitter half (#14512), and the next reader would have believed the mechanism
22+
was there.
23+
24+
⚠️ Release-notes note: this supersedes one sentence of the #15226 entry in this same
25+
unreleased batch — "The resolution now reads the flattened top level FIRST and then each
26+
package body". That described #15226 accurately when it landed; after this change the
27+
`packages[]` pass runs only where the top level named no default. The earlier entry is
28+
left as written rather than retro-edited, so whoever compiles the notes collapses the two
29+
deliberately instead of reading a contradiction.
30+
31+
The reader now walks the discipline the docblock claims — start from the
32+
expression this program replaced, `appDefaultPermissionSetName(config.permissions)`,
33+
and consult `packages[]` only where it came back `undefined`.
34+
35+
- **The condition is the resolved NAME, never the `permissions` container.**
36+
Branching on the container re-creates the silent loss the reader program
37+
exists to remove, one shape further along: a flattened level that carries
38+
permission sets but marks none of them `isDefault` is legal today and
39+
hand-authorable in any `objectstack.config.ts`, and a container-shaped
40+
condition (`Array.isArray(flattened)`, with or without `&& length > 0`) shorts
41+
it past the whole `packages[]` pass and answers `undefined` — nothing thrown,
42+
nothing logged, every member of the app back down to the platform floor alone.
43+
Reading the answer also retires the `[]`-is-truthy trap rather than patching
44+
around it.
45+
- **The package order is resolved BEFORE the top level is consulted.**
46+
`resolveArtifactPackageOrder` refuses a malformed `packages` — not an array,
47+
an entry inlined instead of wrapped under `manifest:`, a duplicate package id
48+
— with an ADR-0112 envelope this reader does not catch, and that refusal must
49+
not become conditional on whether the flattened level happened to name a
50+
default first. An artifact is either loadable or refused; which level answered
51+
is not part of that question.
52+
- **No emitted artifact changes its answer.** Measured, not argued: 26 shapes —
53+
the composed additive artifact, its option-B derivative, the collection-zoo
54+
fixtures behind the #15004 acceptance pin, every config the unit suite drives,
55+
the three malformed-`packages` refusals, and the hand-authored mixed shapes —
56+
return byte-identical results before and after, with `@objectstack/plugin-security`
57+
rebuilt and the change proven present in `dist/` on each leg.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
feat(automation): a resume that consumed the pause and then failed downstream answers `status: 'stranded'` (#13937)
6+
7+
The services half of the #13937 shape-4 ruling (maintainer 2026-09-01):
8+
`resumeInternal`'s consumption order is kept — the suspension is consumed
9+
before downstream nodes run, which is what buys exactly-once across a crash —
10+
and the state that order leaves behind when a downstream node throws now
11+
carries the platform-level name #14384 put on the contract.
12+
13+
`AutomationEngine.resume()` (and every engine continuation that reaches the
14+
same catch arm) returns `{ success: false, status: 'stranded', … }` where it
15+
returned no `status` at all. Stamped on that one exit only: the pause a
16+
durable decision was waiting on is gone, the run is recorded `failed`, and it
17+
can be re-armed only by the explicit operator verb
18+
`restoreConsumedSuspension` (#13909 slice 2, already published) — never by
19+
`resume` (which answers `RUN_NOT_FOUND`) and never automatically. Distinct
20+
from `'failed'` on purpose: that one says the run ran and was rejected; this
21+
one says a recorded continuation stopped mid-flight and an operator has
22+
something to repair. The result's verdict and the restore verb are held to
23+
agree by test: a stranded result is exactly a restorable run.
24+
25+
Not changed: the run's RECORDED status (the run log, `getRun`, `listRuns`, the
26+
durable `sys_automation_run` history row) stays `failed` — that vocabulary is
27+
`ExecutionStatus` in `@objectstack/spec`, which the ruling did not widen; the
28+
durable discriminator for the condition remains the snapshot the terminal row
29+
carries. No resume semantics move for any pausing node type; shapes 2 and 3
30+
of the decision stay excluded.
31+
32+
Also in this change, under the same ruling's exactly-once guarantee, two
33+
repairs to how `restoreConsumedSuspension` finds a stranded run's snapshot:
34+
35+
- The durable run-history row of a stranded run now records the PAUSE node in
36+
`node_id`. It recorded the node that threw — the run's last step — and the
37+
object store read that column back as the snapshot's node, so a restore
38+
from the row (after a restart, or on another replica) re-armed the run at
39+
the failed node and the next resume skipped it while reporting the run
40+
completed. The throwing node stays in the row's step log and `error`.
41+
Visible on the Runs surface: `sys_automation_run`'s row title and highlight
42+
set are built from `node_id` (`titleFormat '{flow_name} · {node_id}'`), so a
43+
stranded run's row now names the PAUSED node — the one an operator can
44+
re-arm — where it named the node that threw; ordinary completed / failed
45+
rows are unchanged. The `node_id` and `variables_json` field descriptions
46+
carry this carve-out, the way `node_type`'s already did.
47+
- The verb reads the durable row and its own per-process journal as two
48+
witnesses of one strand instead of trusting either alone. The hot copy is
49+
preferred when both describe the same pause (it is the verbatim object the
50+
failure was journalled from). A row that carries no snapshot is read as
51+
"the run moved on" only when this process's own history write landed —
52+
the replica that stranded a run used to keep a hot copy that could re-arm
53+
the run after another replica had restored, resumed and finished it, and
54+
the next resume re-ran every node after the pause. A snapshot the object
55+
store could not persist (over its 256 KiB row budget) is now recorded in
56+
the row as dropped, with the pause it belonged to, so the replica holding
57+
the hot copy still restores and any other replica is refused with a reason
58+
that names the budget and the remedy.
59+
60+
In-memory and store-less deployments observe no behaviour difference. On the
61+
object store, same-replica restores re-arm the pause node on every path, and
62+
restores from the row alone do too; restores across replicas of a run that
63+
finished elsewhere are refused.

packages/plugins/plugin-security/src/app-default-permission-set.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,56 @@ describe('appSecurityPluginOptions over `packages[]` (ADR-0130 D4, #15007)', ()
259259
expect(appSecurityPluginOptions({ permissions: [permissionSet('top')] })).toEqual({ fallbackPermissionSet: 'top' });
260260
});
261261

262+
/**
263+
* [#15007 follow-up] "The top level had none" is the resolved NAME coming
264+
* back `undefined` — never the `permissions` CONTAINER being absent or empty.
265+
*
266+
* Branching on the container re-creates the silent loss this card removed,
267+
* one shape further along. A flattened level that carries permission sets but
268+
* marks none of them `isDefault` is legal today and hand-authorable in any
269+
* `objectstack.config.ts`; a container-shaped condition shorts it past the
270+
* whole `packages[]` pass and answers `undefined` — nothing thrown, nothing
271+
* logged, every member of the app back down to the platform floor alone.
272+
*/
273+
describe('the `packages[]` pass runs wherever the top level named no default', () => {
274+
const corePackage = {
275+
manifest: {
276+
id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app',
277+
permissions: [permissionSet(CORE_PROFILE)],
278+
},
279+
};
280+
281+
it('an EMPTY flattened array does not short-circuit it', () => {
282+
expect(appSecurityPluginOptions({ permissions: [], packages: [corePackage] }))
283+
.toEqual({ fallbackPermissionSet: CORE_PROFILE });
284+
});
285+
286+
it('a NON-EMPTY flattened array that marks no default does not either', () => {
287+
// A `permissions.length > 0` guard passes the case above and fails this
288+
// one — which is the whole reason the condition is the resolved name.
289+
expect(
290+
appSecurityPluginOptions({
291+
permissions: [{ name: 'core_read_only', label: 'Read only', objects: {} }],
292+
packages: [corePackage],
293+
}),
294+
).toEqual({ fallbackPermissionSet: CORE_PROFILE });
295+
});
296+
297+
it('a `permissions` key that is not an array at all does not either', () => {
298+
expect(appSecurityPluginOptions({ permissions: null, packages: [corePackage] }))
299+
.toEqual({ fallbackPermissionSet: CORE_PROFILE });
300+
});
301+
302+
it('and once the top level DOES name one, the packages pass cannot change the answer', () => {
303+
expect(
304+
appSecurityPluginOptions({
305+
permissions: [permissionSet('flattened_wins')],
306+
packages: [corePackage],
307+
}),
308+
).toEqual({ fallbackPermissionSet: 'flattened_wins' });
309+
});
310+
});
311+
262312
/**
263313
* The gate travels with the read: `resolveArtifactPackageOrder` refuses a
264314
* malformed `packages` with an ADR-0112 envelope, and this reader does not
@@ -293,5 +343,21 @@ describe('appSecurityPluginOptions over `packages[]` (ADR-0130 D4, #15007)', ()
293343
expect(err.code).toBe('DUPLICATE_ARTIFACT_PACKAGE');
294344
expect(err.status).toBe(422);
295345
});
346+
347+
it('…and refused just the same when the flattened top level already named a default', () => {
348+
// The package order is resolved BEFORE the top level is consulted, so an
349+
// artifact is either loadable or refused independently of which level
350+
// happens to answer. Move that resolution below the early return and this
351+
// pair turns into a silent accept: a permission surface resolved out of an
352+
// artifact the manifest service refuses moments later.
353+
const notAnArray = refusalOf({ permissions: [permissionSet('flattened_wins')], packages: 'nope' });
354+
expect(notAnArray.code).toBe('INVALID_ARTIFACT_PACKAGES');
355+
expect(notAnArray.status).toBe(422);
356+
357+
const entry = { manifest: { id: CORE_ID, name: 'Core', version: '1.0.0', type: 'app', permissions: [permissionSet(CORE_PROFILE)] } };
358+
const duplicate = refusalOf({ permissions: [permissionSet('flattened_wins')], packages: [entry, entry] });
359+
expect(duplicate.code).toBe('DUPLICATE_ARTIFACT_PACKAGE');
360+
expect(duplicate.status).toBe(422);
361+
});
296362
});
297363
});

packages/plugins/plugin-security/src/app-default-permission-set.ts

Lines changed: 69 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
198231
export 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

Comments
 (0)