Skip to content

Commit 3878a58

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14439-multi-package-build
# Conflicts: # scripts/check-stack-collection-maps.mjs
2 parents e167d43 + 53d3689 commit 3878a58

280 files changed

Lines changed: 11509 additions & 1760 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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
fix(runtime): one registrar for the artifact boot's security collections — the artifact door, not `AppPlugin` (#12892 step 2)
6+
7+
On an artifact boot (`createStandaloneStack`: `os serve` / `os dev` / `os start` /
8+
`os migrate` booting from `dist/objectstack.json`) the stack-declared
9+
`positions` / `permissions` / `capabilities` / `sharingRules` used to reach the
10+
metadata service through TWO registrars over the same bytes: the artifact door
11+
(`MetadataPlugin`, which forward-converts, strict-parses and stamps ADR-0010
12+
provenance) and `AppPlugin`'s ADR-0057 block (which does none of that). Because
13+
`AppPlugin` started last its copy won, so a consumer of
14+
`metadata.list('sharing_rule')` read a sharing rule whose `condition` was a bare
15+
STRING where the door's copy carries `{ dialect, source }` (reading
16+
`.condition.source` gave `undefined`), and a capability with no `scope` default
17+
and no `_packageVersion` / `_provenance`. Which shape a reader saw depended on
18+
plugin start order.
19+
20+
Maintainer ruling on #12892 (2026-08-29): the door owns the route. Now:
21+
22+
- `AppPlugin` takes `securityMetadataRegistrar: 'app-plugin' | 'artifact-door'`
23+
(constructor `opts`, default `'app-plugin'`). Under `'artifact-door'` the
24+
ADR-0057 block registers nothing and logs a `debug` line naming the
25+
collections left to the door; an unknown value is refused loudly.
26+
- `createStandaloneStack` — the one composition that runs the door over the
27+
same artifact — declares `'artifact-door'` on the `AppPlugin` it composes. On
28+
that boot the metadata service holds exactly ONE copy of every security item:
29+
the door's, parsed, defaulted and provenance-stamped, in either start order.
30+
- Every door-less composition (`new AppPlugin(config)` over a `defineStack()`
31+
module in `os serve` / `os migrate`, `DevPlugin`, `@objectstack/verify`'s
32+
`bootStack`, embedders) is unchanged: the default keeps registering all four
33+
collections exactly as before.
34+
35+
`minor` rather than `patch`: a public constructor option is added, and what the
36+
metadata service serves for these four kinds on an artifact boot changes shape
37+
(door copy instead of raw copy) — a fix, but one a reader of `GET /meta/<kind>`
38+
or `metadata.list(<kind>)` on an artifact boot can observe.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): close the declaration-boot write guard's two named boundaries — engine-held drivers and immediate DDL (#14126)
6+
7+
`os migrate plan` / `os migrate apply` boot host plugins for their declarations behind a guard that refuses the contract's row writes and, since #13332 / #14053, prints "a plan writes nothing" only when that held. Two boundaries were left open and named in the guard's own census; both are now covered, under ONE outcome-line rule:
8+
9+
- **Engine-held drivers.** Only the default datasource is published as `driver.*`; every other driver reaches the engine through `engine.registerDriver` alone (`DatasourceConnectionService.connect()`, `AppPlugin`'s `drivers.register`, `ObjectQL.create`), so a hook writing to an object bound to a second datasource landed during a plan. The guard now shadows `registerDriver` on the engine instance the kernel publishes (`objectql` / `data`) for the length of the boot, arms each driver instance in place as it is registered — forwarding the SAME instance, never a wrapper, never a second registration under a held name — reaches drivers the engine already held through its public accessors, and restores the engine on `disarm()`. Such a write is now refused and reported as `via engine.<datasource>`.
10+
- **Immediate DDL.** `dropTable()` / `rotateShards()` are not held back by the schema deferral and execute immediately. They are still not refused (refusing DDL an operator's own hook asked for is out of this guard's scope) — but they now get `execute()`'s treatment: forwarded, counted per driver/method/object, warned once per driver on stderr, named in the notes, and the run no longer claims "a plan writes nothing".
11+
12+
The rule, decided once: the claim prints only when it held across everything the guard can see — every write refused, nothing forwarded (raw `execute()`, immediate DDL), and no instance that refused the override (a frozen driver, an engine that could not be shadowed). Each of those is named in the notes and withholds the line. An embedder with no data plane, and read/log-only hooks, are untouched: a quiet boot still renders byte-identically.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/driver-memory': minor
3+
---
4+
5+
feat(driver-memory): `update()` and `upsert()` publish their honest types (#13878)
6+
7+
**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the shape ADR-0087's 2026-08-30 addendum names (a published SDK method whose declared return moves off `any` onto the contract it always answered) — shipped as `minor` under the launch-window convention. The emitted `.d.ts` read `Promise<any>` for both doors: the return types were inferred through the backing store's `any[]` rows, so the union collapsed and no caller was asked to narrow. They are now declared as the contract declares them — `update()` returns `Promise<Record<string, unknown> | null>` (the `null` arm is the non-`strictMode` miss the driver has always answered with), `upsert()` returns `Promise<Record<string, unknown>>`. A caller that read fields off `update()`'s result through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members of either result now types them.
8+
9+
`upsert()` now asserts — throws on — the `null` arm of `update()` on a path it cannot reach (the row it updates was found in the same table a moment earlier, with nothing yielding in between), instead of widening its own declared return to carry an arm it can never produce. No reachable runtime behaviour changes.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped and nothing exists for `objectstack migrate meta` to rewrite; the obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. `type-surface-only` is not claimable here because the same diff touches `packages/spec/**` (the contract declaration itself). -->
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/plugin-email": patch
3+
---
4+
5+
fix(plugin-email): judge the default sender where it is configured, and record a send rejected before delivery (#14318)
6+
7+
Measured on a local rig with `OS_EMAIL_FROM="ObjectOS Local <noreply@localhost>"`,
8+
`OS_EMAIL_PROVIDER=log` and `OS_AUTH_REQUIRE_EMAIL_VERIFICATION=true`: the server
9+
booted clean, the first sign-up answered `200`, the UI said a verification email
10+
had been sent, and nothing had been. `EMAIL_REGEX` requires a dotted domain and
11+
correctly refuses `noreply@localhost` — but it refused it inside
12+
`normalizeMessage`, on the **first send**, which for a fresh deployment is the
13+
first user's sign-up. better-auth runs `sendVerificationEmail` through
14+
`runInBackgroundOrAwait`, which logs `Failed to run background task` and returns
15+
normally, so the account was created and the user was parked on a verify screen
16+
whose Resend repeated the whole sequence. `sys_email` held no row for any of it:
17+
the throw happened *before* the row insert, and every other failure shape in the
18+
service is a row at `status:'failed'`.
19+
20+
Two changes, one per half.
21+
22+
**The address is judged where it is configured.** `EmailServicePlugin.init()`
23+
now refuses a declared `defaultFrom` that no message could ever be sent from, so
24+
a deployment that names an unsendable sender fails its boot instead of failing
25+
every send — the same trade `resolveTransport` already makes for an SMTP
26+
provider with no host, and the error names the consequence and the fix
27+
(`OS_EMAIL_FROM` / `config.email.defaultFrom`). An **absent** sender is still
28+
accepted: callers that always pass `input.from` are a complete configuration,
29+
and `normalizeMessage` already refuses a send that has neither.
30+
31+
The `mail` settings channel takes that method's opposite, stated trade — a save
32+
must not kill a running server — so an unsendable saved From address is
33+
**refused, the previous sender kept**, and the consequence stated at `error`.
34+
`error` and not `warn` because nothing looks broken afterwards: the save
35+
succeeds and the settings page shows the address the operator typed.
36+
37+
**A send rejected before delivery now leaves a `sys_email` row.** The
38+
`normalizeMessage` window was the one path on which a send produced no record at
39+
all. It now writes `status:'failed'` with the reason, prefixed
40+
`rejected before delivery:` so the column distinguishes a message that never
41+
reached a transport from one an SMTP host refused. The envelope columns carry
42+
what the caller actually passed (never re-canonicalised — canonicalisation is
43+
what threw), `(none)` where the input named nothing, since `from_address` /
44+
`to_addresses` / `subject` are required. The row is safe by construction: both
45+
re-delivery paths — the `afterInsert` outbox drain hook and the boot outbox
46+
sweep — gate on `status === 'queued'`, so a rejection record can never be
47+
mistaken for an outbox entry. Persisting it is best-effort and never replaces
48+
the caller's error.
49+
50+
Unchanged: `formatAddress`, `EMAIL_REGEX` and `normalizeMessage` keep their
51+
exact verdicts (the new `isSendableAddress` predicate shares the one regex, so a
52+
boot cannot pass a check the send path then fails), `send()` still throws on
53+
validation failure rather than answering `failed`, and the auth layer's
54+
propagation is as it was — `sendVerificationEmail` already rejects on both a
55+
throw and a returned `status:'failed'`, which is now pinned by a test.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/metadata': patch
3+
---
4+
5+
`FilesystemLoader` now implements `loadManyKeyed()`, so a metadata file whose
6+
body has no top-level `name` is no longer invisible to `MetadataManager.list()`.
7+
`loadMany()` globbed files and pushed bodies, discarding the path it had just
8+
read; the manager then fell back to keying by `body.name`, which drops every
9+
nameless body — an aggregated `defineView` container has none by design. That is
10+
the #14205 defect, unrepaired for this loader until now.
11+
12+
The key is this loader's own name-to-path derivation — the basename minus
13+
extension, the same one `list()` reports — but only where that derivation is a
14+
bijection for the file: it sits directly under `ROOT/TYPE/` and carries an
15+
extension `findFile()` tries, so `findFile(type, key)` resolves back to that same
16+
file. Every other shape (a nested path, an extension-less file) keeps the
17+
previous behaviour verbatim: keyed by `body.name` when it has one, dropped when
18+
it has none. `list()` and `findFile()` disagree outside the flat shape — `list()`
19+
reports the bare basename for a nested file and `findFile()` cannot resolve it —
20+
so keying those by the basename would mint names `get()` and `exists()` cannot
21+
open, and two directories holding one basename would collide silently. Repairing
22+
the derivation itself is tracked separately.
23+
24+
One deliberate consequence: a flat file whose `body.name` disagrees with its
25+
basename is now keyed by the basename. That is #14205's rule (identity is the
26+
key the store holds an item under, not `body.name`) applied to this loader, and
27+
it aligns `list()` with `listNames()` for that shape. `loadMany()`'s own
28+
signature and answer are unchanged; both methods now share one file walk so
29+
their bodies cannot drift.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): a contained per-iteration failure is visible in the flow run contract — run-level `failed`, loop iteration through `try` / `catch`, row identity on `$error` (#13681, spec half)
6+
7+
The ruled containment spelling for a per-iteration failure that must not end
8+
the sweep is `loop { body: [ try_catch { try, catch } ] }` — ⛔ no
9+
`loop.config.onIterationError` key (maintainer 2026-08-31, verbatim
10+
「其他同意」; branch B selected by measurement: 5/5 iterations processed, the
11+
run completes). The ruling's rider is that such a caught failure is invisible
12+
at run level, and this change declares the three contract surfaces that close
13+
that gap. Additive only: nothing narrows, no existing key moves, no
14+
`BREAKING`.
15+
16+
- **`FlowRunSummary.failed`** — total node executions that failed, a fold of
17+
the existing per-node counter (`failed = Σ nodes[].failures`; that counter
18+
already exists and already counts a caught try-region attempt, so there is
19+
no second per-node key). On a run that completed, every one of them was
20+
contained — caught by a `try_catch` or routed down a `fault` edge — and the
21+
run went on. **Absent is not zero**, exactly as `unmeasured`: a run
22+
recorded before the field existed did not track it, and a parse leaves the
23+
key absent rather than defaulting it to `0`. It reads BESIDE the
24+
broken-sweep filter (`selected > 0 AND acted = 0 AND unmeasured = 0`), not
25+
inside it: `acted = 0` with `failed > 0` is a sweep whose writes were
26+
attempted and failed, not one that silently stopped.
27+
- **`ExecutionStepLog.iteration` through `try` / `catch`** — a step executed
28+
inside a try/catch region that is itself inside a loop body carries the
29+
ENCLOSING LOOP's `iteration` (a try/catch region has no index of its own)
30+
while `regionKind` stays `try` / `catch`. The schema already declared both
31+
keys; the contract now states how they combine, and pins it.
32+
- **`TryCatchErrorValueSchema`** (`@objectstack/spec/automation`, beside
33+
`TryCatchConfigSchema`, with `TryCatchErrorValue` /
34+
`TryCatchErrorValueParsed`) — the value a `try_catch` binds to
35+
`errorVariable` (default `$error`) was assembled by the engine and typed
36+
nowhere. It is now declared: `nodeId`, `message`, and — only when the
37+
failure happened inside a loop body — `iteration` (the enclosing loop's)
38+
and `item` (the loop item being processed, `unknown`). A plain object
39+
closed by convention: a value the engine assembles, not an authoring
40+
surface.
41+
42+
The engine half — populating `failed` in `summarizeRun`, propagating the loop
43+
iteration through `try_catch` → `runRegion`, binding `$error.iteration` /
44+
`$error.item`, and printing `failed=` on the run summary line when present —
45+
is the `domain:services` card that follows this contract.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): decide the insert-side runtime-owned strip by hook-write PROVENANCE, not `Object.is` (#14259)
6+
7+
#14088 replaced `Object.is(payload[k], supplied[k])` inside `stripReadonlyFields`
8+
with a recording of the keys the before-phase hook chain actually assigned
9+
(`recordHookPayloadWrites`). Its argument was never about `null`: value equality
10+
cannot separate *the hook deliberately wrote the value the caller also sent* from
11+
*the hook never touched the key*, and those two demand opposite verdicts.
12+
13+
`stripRuntimeOwnedFields` — the INSERT-side twin — was left on the comparison
14+
that argument retired, and #6339's own prose is the finding: it argued a key SET
15+
made the contract true "only BY ACCIDENT" and moved to values, which is
16+
accidental in the identical way. A `beforeInsert` hook that re-issues or
17+
normalises a record number therefore still lost its write to any caller that
18+
submitted the same value — the caller who omitted the key kept the hook's number,
19+
the caller who echoed it got the sequence value, and the two differed in nothing
20+
else.
21+
22+
`engine.insert` now arms one recording **per row** at hook-context construction
23+
and seals each immediately after that row's `beforeInsert` chain, and
24+
`stripRuntimeOwnedFields` consults the sealed record before the value test. Per
25+
row, never per call, so a hook stamping one row of a batch confers nothing on the
26+
next.
27+
28+
⛔ **Not a relaxation of #5503, and the accept set for callers does not move.** A
29+
caller-seeded record number that no hook assigned is still stripped, still warns
30+
with the same text, and still reports through `onFieldsDropped` /
31+
`strictReadonlyWrites`; `isSystem` and `preserveAudit` are untouched. What
32+
changed is only the EVIDENCE for the hook-write exemption that already existed —
33+
a record of which keys were assigned, instead of an inference from the values
34+
afterwards.
35+
36+
The forgery boundary is inherited verbatim: a caller-supplied value must never
37+
become hook-owned. The new insert-side recording is armed after the caller's
38+
payload has arrived and been snapshotted, sealed before any engine-owned pass
39+
touches the row, and records that an assignment ran rather than anything about
40+
the payload's contents — a caller cannot execute an assignment, so no key it
41+
sends can enter the record. A hook that REPLACES the payload object leaves no
42+
attributable record and falls back to the pre-existing value test, which
43+
over-strips: keeping the old bug is the only safe direction, because reading a
44+
replacement's keys as hook-owned would launder a caller's forgery.
45+
46+
The `readonlyWhen` sibling seam #14259 also names (`isCallerSuppliedValue`,
47+
behind `stripReadonlyWhenFields` / `stripReadonlyWhenFieldsMulti`) is **not**
48+
included: threading the record there was measured to let a caller's value survive
49+
a TRUE `readonlyWhen` predicate, which is a maintainer decision rather than a
50+
mechanical follow-through. Nothing about that seam's behaviour changes here.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `IDataDriver.update()` declares its not-found arm (#13878)
6+
7+
**BREAKING** for TypeScript consumers, shipped as `minor` under the repo's launch-window convention for breaking changes (maintainer ruling 2026-09-01, option A). `IDataDriver.update()`'s return type is now `Promise<Record<string, unknown> | null>` — the shape `findOne()` already carries and the not-found vocabulary `delete()` already uses (`false`) — and its docblock says when `null` is returned: no record with that id exists, on a driver not configured to throw on missing records. The Zod mirror `DriverInterfaceSchema.update` carries the same `.nullable()` output and the same sentence. This declares the behaviour four of the six shipped drivers have always had, which the previous declaration forbade.
8+
9+
What breaks: a caller that read fields straight off an `update()` result compiled before only because the arm was undeclared; it now narrows the `null` arm first — a compile-time obligation at the call site in place of a silent runtime hazard. No metadata key, no runtime behaviour and no wire shape moves.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) A declared return type gains the not-found arm the implementations already answer: no metadata key is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, and the compiler is the channel that reaches every affected consumer. -->

0 commit comments

Comments
 (0)