Skip to content

Commit 7308e03

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-13937-stranded-run-operator-verb
2 parents c5025d0 + 1226f2e commit 7308e03

82 files changed

Lines changed: 6227 additions & 1103 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `BulkDataEvent` names the one organization a predicate write's affected records belong to
6+
7+
The realtime `BulkDataEvent` payload (`@objectstack/spec/api`, the body of every
8+
`data.records.updated` / `data.records.deleted` event) gains an optional
9+
`organizationId`: the organization every record the predicate write affected
10+
belongs to — one organization for the whole batch, never per-row and never a
11+
list. It takes the same spelling, the same position beside the match term
12+
`object`, and the same refusal of the empty string as `DataEvent`'s
13+
`organizationId`, so a tenant-scoped consumer discriminates both event families
14+
on one key with one comparison — never a partition of the batch.
15+
16+
Why one organization can be honest on a batch that names no rows: a predicate
17+
write reaches the driver with the security layer's tenant wall AND-composed
18+
onto the caller's filter first (under `isolated` an equality on the caller's
19+
active organization; under `group` membership in the caller's organization
20+
set), and nothing in business RLS or sharing can widen it. When that wall names
21+
exactly one organization, every affected row belongs to it, and the producer
22+
can state so from what it already holds.
23+
24+
What a consumer may assume — and where this deliberately diverges from
25+
`DataEvent`:
26+
27+
- **Present** — every record the write affected belongs to exactly that
28+
organization. Never fabricated, and never the caller's active organization
29+
standing in for the rows'.
30+
- **Absent** — the producer did not assert one organization for the batch: every
31+
event on a `single`-posture deployment; a system, environment-wide or
32+
cross-membership predicate write; any write whose affected rows are not known
33+
to belong to one organization. A bulk event names no rows, so absence is a
34+
statement about the producer's knowledge, not about the rows. It is NOT
35+
`DataEvent`'s reading "belongs to no organization, not behind any wall". A
36+
tenant-scoped consumer (a per-organization webhook subscription, a
37+
per-organization realtime subscriber) must treat an absent key as not
38+
attributable to its organization and must not deliver the event inside an
39+
organization wall; a deployment-wide consumer may use it.
40+
41+
Declared = enforced: the key is optional and nothing else. No default
42+
fabricates a tenant; `null` and the empty string are refused with a located
43+
issue, so "not asserted" has exactly one spelling — the key is absent.
44+
45+
Additive and shape-preserving: every bulk event that parsed before parses
46+
identically, and no producer emits the key yet — the ObjectQL engine's bulk
47+
publish site is a separate change that follows this contract. `DataEvent` and
48+
`MetadataEvent` are unchanged.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os serve` / `os dev` / `os build` / `os migrate` resolve `packages[]` when a stack carries no flattened top level
6+
7+
The CLI holds four independent config-load boundaries, and every read of a
8+
package-owned collection behind them was an inline expression against the
9+
FLATTENED top level. A multi-package stack that carries each definition once
10+
under `packages[]` — the shape ADR-0130 D4's option B produces — reached those
11+
expressions with the key simply absent, and nothing threw:
12+
13+
- `os serve` / `os dev` auto-register the ObjectQL engine and the storage driver
14+
when the stack declares objects. Both gates read `config.objects`, so the app
15+
booted with **no query engine and no storage driver** and reported healthy.
16+
Nothing between the artifact and the gate could notice: the standalone stack
17+
omits the `objects` key entirely when the array is absent rather than setting
18+
`[]`, and the boot-config merge is a plain spread.
19+
- `os serve` auto-registers the i18n service plugin when the stack carries
20+
translations. `translations` is package-owned while `i18n` is an envelope key a
21+
translations-only stack never sets, so the REST i18n routes silently did not
22+
exist.
23+
- `os dev` diffs the artifact's object inventory across recompiles to name a
24+
newly added `*.object.ts`. It went permanently empty, so every recompile read
25+
as all-green.
26+
- `os build` runs the author-time rule table twice — once over the union, once
27+
per package. The per-package run already read `packages[]`; the union run,
28+
which is the only one of the two that can see a finding spanning packages,
29+
judged an empty stack and published green.
30+
31+
All of them now resolve through one seam, in the dependency-topological order
32+
`resolveArtifactPackageOrder` gives. Each answer starts from the expression it
33+
replaced, so every stack that boots or builds today takes the identical branch —
34+
including a stack declaring an empty `objects: []`, which stays a stack that gets
35+
an engine — and `packages[]` is consulted only where the old read returned
36+
nothing. A malformed `packages` list is refused with its ADR-0112 envelope on
37+
that leg instead of resolving to the silent empty.
38+
39+
The predicate `os serve` and `os migrate` each carried their own copy of — "does
40+
this config carry app metadata that needs an `AppPlugin` wrap" — is now one
41+
function. Measured, it does not lose under the new shape; it is folded in because
42+
it is the master gate for everything `AppPlugin` then reads.
43+
44+
No command emits anything different: the compiled artifact still carries both
45+
copies, and the folded stack is a rule INPUT that reaches no writer.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a published `DataEvent` now names the organization the RECORD belongs to
6+
7+
`DataEventSchema.organizationId` has been declared and published since the spec
8+
half landed, and its TSDoc states the obligation on the producer's side: *"a
9+
producer that omits the key on an organization-stamped row publishes a
10+
cross-tenant event, which is fixed at the publish site — never by a
11+
consumer-side lookup."* The engine populated it on no event at all. Every
12+
`data.record.created` / `updated` / `deleted` went out with the key absent,
13+
which a consumer is required to read as *"this record is behind no organization
14+
wall"* — so an organization-stamped row was published as an unwalled one, and a
15+
tenant-scoped fan-out had nothing to discriminate on.
16+
17+
`publishDataEvent` now resolves the organization from the row itself and spreads
18+
the key in when there is one. The row is already in hand at all three call
19+
sites — the written record on `created`, the post-state on `updated`, and the
20+
pre-image on `deleted` (the by-id branch reads it unconditionally for its
21+
existence gate) — so this buys **no** per-event read: the key exists precisely
22+
to keep a per-event lookup off the fan-out path.
23+
24+
Three properties are deliberate:
25+
26+
- **The RECORD's organization, never the caller's.** The row's own tenant column
27+
is the only source consulted. `ExecutionContext.tenantId` is the caller's
28+
*active* organization; the two coincide on an ordinary tenant write and
29+
diverge on a system or unscoped one, where substituting it would mislabel an
30+
administrator's write into another organization as belonging to the
31+
administrator's.
32+
- **Absence has exactly one spelling: the key is omitted.** An object that is
33+
not tenant-scoped, a row whose column is empty, and a value no id can be read
34+
off all publish the key absent rather than `null`, `''` or an explicit
35+
`undefined`. The schema refuses the empty string outright, so producing one
36+
would have thrown at the publish site and dropped the event entirely.
37+
- **The column is resolved the way the write path resolves it** — the
38+
`tenancy.enabled: false` opt-out, then a declared `tenancy.tenantField`, then
39+
the injected `organization_id` — so the event cannot name an organization for
40+
a column the engine does not actually scope by. Note the two spellings differ:
41+
the column is `organization_id`, the published key is `organizationId`.
42+
43+
No schema, no accepted shape and no public export moves: the key was already
44+
declared, already validated and already part of what consumers parse. Only the
45+
implementation changed, from omitting a declared key to populating it.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `ExecutionStepLog.iteration` is single-valued — the enclosing loop iteration — and the parallel branch index moves to a new optional `branch` key (#14414)
6+
7+
<!-- adr-0087: registered execution-step-iteration-single-valued -->
8+
9+
**BREAKING** for any consumer that read `iteration` as the parallel branch index
10+
on a `regionKind: 'parallel-branch'` step record: that number now means the
11+
enclosing `loop`'s iteration, and the branch index lives on `branch`. Shipped as
12+
`minor` under the repo's launch-window convention for breaking changes; the
13+
hand-migration prescription is registered under protocol major 18. Maintainer
14+
ruling 2026-09-03 on #14414 (director decision batch #15, verbatim 「同意」):
15+
option A.
16+
17+
`ExecutionStepLogSchema.iteration` was declared as "zero-based loop iteration
18+
OR parallel branch index of the enclosing region" — one field, two meanings,
19+
told apart only by reading `regionKind` first. For `loop { body: [ parallel {
20+
branches } ] }` the engine tagged each branch step with the branch index and no
21+
step of that branch with the loop iteration, so a per-row failure inside a
22+
branch was attributable to a branch, never to the row. The sibling `try_catch`
23+
rule (a try/catch region has no index of its own, so it carries the loop
24+
iteration) had deliberately left `parallel` open, because a parallel region
25+
DOES have an index of its own.
26+
27+
**What changes on the record shape** (`packages/spec/src/automation/execution.zod.ts`):
28+
29+
- `iteration` — single-valued: the zero-based iteration of the enclosing
30+
`loop`, carried through any nesting (`try` / `catch` already carried it;
31+
`parallel-branch` now does too). The try/catch sentence is unchanged.
32+
- `branch` — **new**, optional, `integer >= 0`: the zero-based index of the
33+
enclosing `parallel` branch, present only on a step inside a parallel
34+
branch. A branch step of a parallel node inside a loop body carries both.
35+
- `regionKind` — unchanged vocabulary; its describe now points the
36+
`parallel-branch` index at `branch`.
37+
38+
**What does NOT change in this PR:** the engine tagger in
39+
`@objectstack/service-automation` still writes the innermost region only —
40+
today it writes the branch index into `iteration` on `parallel-branch` steps
41+
and never writes `branch`. The tagger change is a follow-on card in the same
42+
lane, blocked by this one (three-surface rule: spec first, no engine-only patch
43+
in between). A step record written by an engine that predates that follow-on
44+
therefore carries no `branch` key, and its `iteration` under `parallel-branch`
45+
still holds the legacy branch index.
46+
47+
## FROM → TO
48+
49+
```ts
50+
// before — one key, two meanings; the loop iteration of a branch step is lost
51+
const step = { regionKind: 'parallel-branch', iteration: 1 }; // 1 = branch index
52+
const branchIndex = step.regionKind === 'parallel-branch' ? step.iteration : undefined;
53+
54+
// after — one meaning per key; a branch step inside a loop body carries both
55+
const step = { regionKind: 'parallel-branch', iteration: 3, branch: 1 };
56+
const branchIndex = step.branch; // 1 — the branch
57+
const rowIteration = step.iteration; // 3 — the enclosing loop's row
58+
```
59+
60+
Fix: where a consumer groups or labels `parallel-branch` steps by `iteration`
61+
(the objectui `FlowRunsPanel` grouping key is the one measured reader), read
62+
`branch` for the branch index and keep `iteration` for the row. Reads of
63+
`iteration` on `loop-body`, `try` and `catch` steps need no change.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
chore(spec): govern the realtime `SubscriptionSchema` surface in the liveness ledger (#14446)
6+
7+
The `liveness/` ledgers ship inside this package's npm tarball (they are named in
8+
`files`), so this is a published-data change even though no runtime behaviour moves,
9+
no schema key changes spelling, and `packages/spec/src/api/realtime.zod.ts` is not
10+
edited at all.
11+
12+
A new ledger file — `realtime_subscription.json` — classifies all six authorable
13+
properties of `SubscriptionSchema`, what a client declares to open a realtime
14+
subscription: the item type of `RealtimeConfigSchema.subscriptions` and the
15+
`Subscription` the generated API reference publishes. It is enrolled through the
16+
gate's `SPEC_ONLY_SCHEMAS` override, the route `query` / `qa` / `manifest` and the
17+
four `RestServerConfig` sub-objects already take. A transport-protocol surface is
18+
neither a metadata item nor stored metadata nor a manifest, so no registry has ever
19+
held it and no ratchet rooted in one could ask who reads it — and
20+
`RealtimeConfigSchema` is `.passthrough()`, so nothing downstream even refuses an
21+
unknown key.
22+
23+
All six are `dead`, and the container is the finding: nothing outside
24+
`packages/spec` imports `SubscriptionSchema`, `SubscriptionEventSchema` or
25+
`RealtimeConfigSchema` at all, so no key beneath them can be read. The two the
26+
census filed with this card measured are the sharp ones. `events[].type` accepts
27+
`RealtimeEventType`, whose four members (`record.created` / `record.updated` /
28+
`record.deleted` / `field.changed`) are disjoint from what the engine publishes —
29+
`DataEventType`'s `data.record.*`, with a live emitter in `service-knowledge` — so
30+
an author who writes the enum's own `record.created` gets a subscription that
31+
silently never fires, and the enum is what the API reference shows them.
32+
`events[].filters` is `z.unknown().optional()`: an authorable key with no shape and
33+
no consumer, failing in the permissive direction, since the only payload matching
34+
the platform performs compares object name and event type.
35+
36+
What this records, and what it deliberately does not. The enum's direction is
37+
settled and the row carries the 2026-09-02 triage ruling verbatim so the next reader
38+
does not re-open it: if the verdict is enforce it means repointing the enum, never
39+
changing what the runtime publishes, which would break a live event contract to
40+
satisfy a member nothing has ever used. `field.changed` is the same spelling the
41+
sibling `DataEventType` removed in 17.0.0 under ADR-0049 (#4673, PR #4685) for
42+
having no producer; it survives here only because this enum was never in a ratchet's
43+
denominator. No key is removed, enforced, deprecated or re-described here — the
44+
enforce-or-remove call per dead key is a follow-up on the human floor.
45+
46+
Rooted on `SubscriptionSchema` rather than on `RealtimeConfigSchema`, which is
47+
measurement rather than taste: the ledger walk drills exactly one level, so with the
48+
config as the root `subscriptions` would be the drilled level and `events[].type` /
49+
`events[].filters` would have no row of their own, inheriting a container verdict —
50+
the same reasoning that rooted the four `RestServerConfig` sub-objects separately.
51+
`RealtimeConfigSchema`'s own three keys are not enrolled: whether enabling realtime
52+
does anything is a different question with no census behind it yet.
53+
54+
One correction the ledger records because the next reader will hit it too. The card
55+
and its triage both name the two keys on `SubscriptionSchema`; they are declared on
56+
`SubscriptionEventSchema` (`realtime.zod.ts:46-50`), reached from this root as
57+
`events[].type` and `events[].filters`. The cited lines and quoted shapes are exact;
58+
only the owning symbol was misattributed, and this package's own authorable-surface
59+
census (`authorable-surface/api.json`) already spells the two schemas apart.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): the packages domain reaches the `protocol` service through a typed handle (#13598)
6+
7+
`deps.resolveService(context, 'protocol')` answers `any` — `protocol` is
8+
deliberately left unmapped in `ServiceSlotContracts` — so every request literal
9+
downstream of that seam compiled against nothing. Twelve sites in
10+
`domains/packages.ts` held that `any` (two of them on the variable declaration
11+
rather than the call), and an undeclared or misspelt key in the ADR-0045
12+
publish-visibility flip's `getMetaItems` / `saveMetaItem` literals compiled
13+
silently. Measured on the base tree: injecting `bogusUndeclaredKey: true` into
14+
the `saveMetaItem` literal gave `tsc --noEmit` exit 0 and zero diagnostics.
15+
16+
The slot is now narrowed once, at one helper, to a handle `Pick`ed from the
17+
DECLARED contracts — `MetadataProtocol` / `PackageProtocol` from
18+
`@objectstack/spec`, plus the producer's own exported `DeletePackageRequest` —
19+
so the same injection is now `error TS2353`. Every member is OPTIONAL and every
20+
`typeof protocol.<verb> === 'function'` capability probe is unchanged: a host
21+
may occupy the slot with a partial object, and the type answers "is this key
22+
declared?" while the probe still answers "did this host bring the verb?".
23+
24+
Compile-layer signal only — no request is newly accepted or refused, no
25+
response shape moves, and the eight verbs no contract declares keep an explicit
26+
`any` request rather than a private restatement nothing verifies.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(plugin-security): the app default permission set resolves from `packages[]`, not only the flattened top level (#15007)
6+
7+
`appSecurityPluginOptions(config)` read `config.permissions` and nothing else.
8+
For a multi-package artifact under the ADR-0130 D4 option-B shape — where
9+
`packages[]` carries each definition exactly once and the flattened top-level
10+
copy is gone — that read returns `undefined`, the reader concludes "this app
11+
declared no default profile", and the boot continues. Nothing throws and
12+
nothing logs.
13+
14+
That silence has a security posture attached. The name this resolves becomes
15+
the `SecurityPlugin`'s `fallbackPermissionSet`, i.e. the app's half of every
16+
authenticated human principal's additive baseline
17+
(`composeHumanBaselinePermissionSets`, ADR-0090 D5). Losing it does not deny
18+
anyone the boot — the deployment simply runs on the platform floor alone, and
19+
every member of a multi-package app quietly holds less access than the app
20+
declared for them. #7555 measured what that looks like from the outside: nav
21+
entries served, 403 behind them.
22+
23+
The resolution now reads the flattened top level FIRST and then each package
24+
body, in the order `resolveArtifactPackageOrder` (`@objectstack/core`,
25+
ADR-0130 D4+D5) registers them:
26+
27+
- **Every artifact the platform emits today answers bit-identically.** The
28+
flattened level still answers first, so the `packages[]` pass can only supply
29+
a set where the top level had none. This is the reader half of the ruled
30+
order (readers first, emitter last, artifact stays additive throughout), so
31+
it lands with no change to what any command emits.
32+
- **Order is the platform's one package order, not the array's.**
33+
`appDefaultPermissionSetName` resolves the FIRST `isDefault` set, so with two
34+
packages declaring one, "first" has to mean here what it means at every other
35+
artifact reader: dependency-topological, so a package that extends another is
36+
read after it whichever array slot it occupies.
37+
- **The singular `manifest` is still not consulted** (#7001 — the harness must
38+
not honour a declaration `serve.ts` ignores). That is not a special case: an
39+
artifact carrying no `packages` key makes `resolveArtifactPackageOrder`
40+
return the caller's own object as the single package body, so that branch
41+
reads `permissions` from exactly where the old code read it.
42+
- **A malformed `packages` is refused, not skipped.** A non-array `packages`,
43+
an entry inlined instead of wrapped under `manifest:`, or a duplicate package
44+
id raises the same ADR-0112 envelope (`code` + `status: 422`) the manifest
45+
service raises when it registers that artifact. Catching it would resolve a
46+
permission surface out of an artifact the loader refuses to load.
47+
48+
Every boot path that already funnelled through this one function picks the fix
49+
up unchanged: `objectstack serve`'s artifact and from-source paths, and
50+
`@objectstack/verify`'s `bootStack` / RLS harness.

0 commit comments

Comments
 (0)