Skip to content

Commit 43d3b2a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-14637-share-link-probe-policy-gate
# Conflicts: # content/docs/permissions/system-context.mdx
2 parents 889e30d + 5784526 commit 43d3b2a

361 files changed

Lines changed: 26713 additions & 2332 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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Report the zero-account boot dead end at `kernel:ready` (#14353)
6+
7+
A deployment holding human `sys_user` rows and zero `sys_account` rows cannot
8+
be recovered from inside, and until now it booted silently. Nobody can sign in;
9+
the first-account bootstrap carve-out counts humans, and humans exist, so it
10+
does not open; the default `invite_only` audience posture refuses
11+
self-registration; and no administrator exists who could send an invitation.
12+
The only symptom was a 401 on credentials nobody holds.
13+
14+
That state is now reported at `kernel:ready` at `error` level, under the name
15+
`no_sign_in_account_at_boot`, naming both the consequence (the deployment will
16+
keep looking healthy and cannot be recovered from inside) and the remedy
17+
(provision an account out of band, or open the audience posture).
18+
19+
⛔ No admission semantics change. Whether the carve-out should count humans or
20+
logins was ruled on 2026-09-02 (option A — the door does not move); this only
21+
reports.
22+
23+
The check extends the existing `kernel:ready` walled-owner reporter rather than
24+
opening a parallel one: it shares that hook, and the bounded human-population
25+
page is read ONCE per boot and handed to `probeWalledOwnerAccountState`, so no
26+
deployment pages `sys_user` twice. At most one report is emitted per boot — a
27+
deployment matching both shapes gets this error, and the walled-owner warning
28+
is suppressed rather than stacked on top of it.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
Fix: a sharing rule with a business-unit recipient granted nothing when the unit came from seed data — and tenant-screen the member reads that widening exposes.
6+
7+
`BusinessUnitGraphService.orgScope` screened `sys_business_unit` with a strict `organization_id` equality, while the platform's own read-side chokepoint (`SqlDriver.applyTenantScope`) is null-inclusive: `(organization_id = ? OR organization_id IS NULL)`, because a NULL organization marks a platform/seeded row every tenant may see. A sharing rule always carries the caller's organization, but a business unit written by seed data carries none — a seed cannot know the id the runtime mints at boot — so the two never matched. The seed check read the unit as "does not exist", both recipient widths (`business_unit` and `unit_and_subordinates`) expanded to zero users, and the rule stayed active having materialised no `sys_record_share` row and logged nothing. `orgScope` now applies the platform's null-inclusive screen, the same predicate `plugin-approvals` already applies to these very rows and `SharingRuleService.adminOrgScope` applies to the rule table.
8+
9+
The member reads are now tenant-screened, which they were not before. Both `expandUnitMembers` and `expandUsers` queried `sys_business_unit_member` with no organization predicate at all, under a system context that carries no tenant either, so the strict unit screen was the only thing keeping an org-stamped rule away from that unscoped query. Widening the unit screen alone would have turned a silent under-grant into a silent cross-tenant over-grant, since a seeded unit id exists identically in every tenant. The member screen is strict rather than null-inclusive on purpose: seed replay and elevated system writes both leave `sys_business_unit_member.organization_id` NULL, so a NULL there means unknown tenancy rather than platform-global, and an org-scoped rule does not grant to it. The sibling recipient widths already read their membership rows this way.
10+
11+
An active business-unit rule that expands to no recipients now warns once per rule per process, naming the rule, the object, the recipient kind, the unit and the organization. That case — a rule whose unit and membership rows were both seeded — is the one combination that still grants nobody, and it is no longer silent.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
refactor(cli): spell the action-dedup object-less key as `GLOBAL_ACTION_OBJECT_KEY` (#14669)
6+
7+
`os lint` dedups action declarations on the engine's composite registration key
8+
(`<objectName>:<name>`), and the object half of that key terminated on a bare
9+
`'global'` string literal in `lintConfig`'s `PREFIXED_TYPES` table. The engine's
10+
own writers stopped spelling the literal: PR #14667 converged
11+
`ObjectQLPlugin.actionObjectKey` onto the shared `GLOBAL_ACTION_OBJECT_KEY`
12+
constant for exactly this reason — a copy that agrees by value today is the one
13+
that parts from the writer in silence the day the constant moves, with no test
14+
in the repo able to see it. This reader now imports the constant from
15+
`@objectstack/objectql`, which `@objectstack/cli` already depends on.
16+
17+
**No behaviour moves.** `GLOBAL_ACTION_OBJECT_KEY` is `'global'`, so every key
18+
this table builds is byte-identical to the one it built before; the #5510 dedup
19+
suite (`lint-namespace-prefix.test.ts`, 15 declarations over 5 objects) passes
20+
unchanged. Only `objectName` is read, exactly as before — the `object`/`entity`
21+
aliases are still rejected upstream by `ActionSchema`'s strict shape and are
22+
deliberately not admitted here.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): wire `packages/cli`'s test layer into `check:test-typecheck`, so its 115 test files are type-checked at all (#14710)
6+
7+
`packages/cli/tsconfig.json` declares `include: ["src"]` and no `exclude` at
8+
all, and the package's `typecheck` script was a bare `tsc --noEmit` against that
9+
very config — so the 115 test files in the sibling `test/` tree were read by no
10+
tsc program anywhere. This is the shape AGENTS.md already forbids, reached by
11+
the OTHER spelling: not an `exclude` naming the test globs, but an `include`
12+
that never reaches them.
13+
14+
Measured at `5a5336b399` with the workspace closure built first, rather than
15+
read off the config: `tsc --noEmit --listFiles -p tsconfig.json` puts 1009 files
16+
in the program and **0** of the 115 among them, while 119 of 119 non-test
17+
`src/**` files and all 121 `src/**` test files ARE there — so the zero is the
18+
`include` line, not a probe that sees nothing. The directional control is
19+
`packages/drivers/driver-memory`, whose tsconfig carries no test exclusion: the
20+
same probe puts 40 of its 40 test files in the program. Under the new
21+
`tsconfig.test.json` the count is **115 of 115**, plus the three package-root
22+
harness modules (`vitest.config.ts`, `vitest-tiers.ts`,
23+
`vitest-tiers.fixtures.ts`) and `test/helpers/serve-process.ts`.
24+
25+
Onboarded by *wiring* to the shared mechanism (`scripts/check-test-typecheck.mts`)
26+
the way `objectql`, `rest`, `lint`, the fourteen `packages/plugins/**` and
27+
`runtime` are wired, never by copying it: a sibling `tsconfig.test.json`
28+
matching vitest's real module semantics (`module: esnext`,
29+
`moduleResolution: bundler`), named by `typecheck` via
30+
`check:test-typecheck --project`. Strictness is untouched and inherited; not one
31+
`any` and not one `@ts-expect-error` was added to any test file to open the
32+
gate. `rootDir` IS widened to `../..`, the way `packages/client`'s test config
33+
already does it — this package's tests sit outside the build config's
34+
`rootDir: "src"`, and three of them import fixtures from
35+
`examples/app-showcase/src/**`.
36+
37+
**Seeded, not repaired, per this card's triage ruling.** The layer reports 28
38+
errors across 3 files and they are recorded EXACT and shrink-only in the new
39+
`test-typecheck-debt.json`. Every one is pre-existing: no test file is edited
40+
here. The other 112 files carry no entry, so any error they gain is red on
41+
arrival.
42+
43+
This is a CONVERSION rather than a new debt-opening decision. The same
44+
population under the build config's inherited NodeNext reports 144 — exactly the
45+
number `scripts/check-type-check-coverage.mjs` already held for this package in
46+
its per-PACKAGE `TEST_DEBT` ledger, class for class — and that entry graduates
47+
here, as the pairing forces. The 144 → 28 step is attributed in both directions
48+
with no remainder: −120 config-tier diagnostics that dissolve under vitest's
49+
module semantics (TS2835 ×56, the TS7006 ×59 cascading above them, TS2307 ×3,
50+
TS18046 ×2) and +4 that collapsing the cascade exposed (TS18048 ×4 in
51+
`test/i18n-extract-action-description.test.ts`, previously masked by an `any`
52+
from two unresolved imports). The 24 TS2339 survive unchanged, file for file.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`composeStacks` now refuses two input stacks whose action declarations resolve to one scope-qualified runtime key — **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes.
6+
7+
**The refused shape:** two (or more) input stacks that each declare an action resolving to one runtime key — `objectName:name`, or `global:name` for an object-less action. The canonical case is two packages, each legal on its own, each declaring a global action named `shared_refresh`: `composeStacks([a, b])` used to accept the pair and emit `["global:shared_refresh", "global:shared_refresh"]`, and the runtime — which registers and dispatches every action under that one exact-string key — collapsed both handlers to one registration: whichever registered second won, and the other package's action stayed a live, declared, permission-gated button whose handler was unreachable. `defineStack` refuses exactly this collision within one stack (#14686); composition was the second door in the same file that let it through.
8+
9+
The refusal carries `composeStacks`' conflict prefix and `defineStack`'s envelope shape — `composeStacks conflict: cross-stack action key collision (N issue(s)):`, one `` line per colliding key — and every line names the key, **both source stacks by manifest id** (`'com.example.a' (stack #0)`; a manifest-less input is named by position), and where each declaration sits (`stack.actions[i]`, or `objects['OBJECT'].actions[j]` for an embedded one). The fix is the one the message names: rename one of the colliding actions within its scope, bind one of them to a different object, or remove the duplicate from one of the stacks. ⛔ There is no `actionConflict` option and none is coming: `composeStacks` does not pick a winner for actions (maintainer ruling, 2026-09-03).
10+
11+
What the check judges is what composition **carries**: standalone actions from every stack (they concatenate), and each composed object's embedded actions attributed to the one stack whose object `objectConflict` handed the `actions` array to. So an embedded action that `'override'` or `'merge'` did not carry into the composed object cannot collide — that loss is the object strategy's own semantics — while a standalone action bound to an object from one stack does collide with an embedded action of the same name the composed object carries from another. Only a key declared by two or more **distinct** stacks is reported: a key an input repeats within itself is `defineStack`'s door (`strict: false` opts out there by choice), and an input built by `defineStack` legitimately carries each bound standalone action twice — as the copy the build appended to its object — which is never a collision with itself.
12+
13+
Deliberately unchanged, as in `defineStack`: one global and one object-bound action may share a name across stacks (two keys), and one name bound to two different objects is two keys. The shipped composer, `examples/app-multi-package`, declares no colliding key and composes unchanged; no `composeStacks` caller exists in objectui or hotcrm.
14+
15+
**Migration.** A composition refused by the new check must resolve the collision in one of the two packages — rename the action within its scope, bind it to a different object, or drop the duplicate. Which package keeps the name is an authoring decision the metadata cannot make for you.
16+
17+
<!-- adr-0087: not-required (no-migration-prescription) a validity narrowing over existing declarations: no key is removed, renamed or re-shaped and no export moves, so the ledger has no rewrite to carry; the refusal itself names the remedy at the composition site, and choosing which of two packages keeps the action name is an authoring decision no migration entry can perform on an upgrader's behalf. -->
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
refactor(runtime): spell the object-less action key as `GLOBAL_ACTION_OBJECT_KEY` in `action-execution.ts` (#14678)
6+
7+
`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key is
8+
written once. #14422 converged the owner-key LADDER and the ObjectQL plugin's
9+
copy of it; three bare `'global'` spellings elsewhere in
10+
`packages/runtime/src/action-execution.ts` were never in that card's path,
11+
because the runtime fence it built was a re-export plus a delegating alias.
12+
This converges those three. The constant was already imported in the file.
13+
14+
No behaviour moves — the constant is `'global'`, so every site is equal in
15+
value before and after. That equality is the entire defect: it is what made the
16+
three invisible to every test in the repo, and what would have let them part
17+
from the constant in silence the day its value changes.
18+
19+
- `seedFlowActionParams` — a live comparison (`objectName !== 'global'`) that
20+
decides whether an object-derived `<object>Id` param key is seeded. The one
21+
site where a drifted literal would change what an action body receives.
22+
- `enforceActionParams` — the warn-once dedup key, which is also interpolated
23+
into the operator-facing `[action-params] <key>: …` line. Converged rather
24+
than left: the argument for a literal here is that a log key must never fail
25+
to render, and that argument does not survive contact with the fact that
26+
`GLOBAL_ACTION_OBJECT_KEY` is a module-scope `const string` already imported
27+
into this file — it cannot fail to render either. What a drift there would
28+
actually cost is an operator grepping logs by the key the engine now uses and
29+
silently missing these lines.
30+
- `collectActionDeclarations`'s docblock, which carried a second defect
31+
independent of the literal: it called the key "the `'global'` wildcard",
32+
contradicting `action-governance.ts` ("an exact-string `Map` lookup with no
33+
wildcard semantics"). It is now the phrasing the sibling docblock 48 lines
34+
below it already used — "the object-less `GLOBAL_ACTION_OBJECT_KEY`" — so the
35+
correction is copied from the file's own converged prose rather than invented.
36+
37+
`patch`, not `skip-changeset`: `packages/runtime` publishes `dist`, which is
38+
built from this source, so the emitted bytes move even though the behaviour
39+
does not. Nothing reaches the published entry — `action-execution.ts` is not
40+
re-exported from `packages/runtime/src/index.ts` and no export, signature or
41+
type changed here — which is what keeps it below `minor`.
42+
43+
The docblock that promised the lockstep is joined by a weld that enforces it:
44+
`action-owner-key-single-source.test.ts` gains a half C that reads
45+
`action-execution.ts` and fails if any quote spelling of the key is written out
46+
by hand again. The forbidden spelling is DERIVED from the constant rather than
47+
hard-coded, so the guard is not itself a fourth copy of the literal it forbids.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(automation): `create_record` now surfaces the engine's `DUPLICATE_RECORD` code, so a `try_catch` / `fault` edge can finally tell "already there" from "the store is down" (#14419)
6+
7+
`engine.insert` (#14095) already raises `DuplicateRecordError``code: 'DUPLICATE_RECORD'` (ADR-0112) — for a unique-constraint violation, driver-independent. The `create_record` node executor threw that away: every failure, from a duplicate key to a downed connection, collapsed into one opaque string (`create_record(<object>) failed: <message>`). A flow's only two error-handling primitives, `try_catch` and a `fault` edge, saw the same shape either way — the only expressible reading of "swallow the duplicate" was "swallow everything".
8+
9+
`NodeExecutionResult` gains an optional `code?: string` field, beside the existing `errorClass`, set when the caught error carries the platform's classified `DUPLICATE_RECORD` code. `AutomationEngine` now copies it onto the `$error` run variable alongside `message` (both the direct `fault`-edge path and the `try_catch` catch-region binding, which previously reconstructed `errorVariable` from the caught exception's message alone and silently dropped it), so a flow can actually branch on `{$error.code}`:
10+
11+
```
12+
try: create_record(lead, { email })
13+
catch: { $error.code === 'DUPLICATE_RECORD' } → swallow, continue
14+
else → re-raise / route the fault edge
15+
```
16+
17+
Additive only — no existing field, message text or routing behaviour changes; an executor that never sets `code` (every one except `create_record` today) is unaffected. Deliberately scoped to `create_record` alone: `update_record` / `delete_record` collapse the same way, but `engine.update` still leaks the raw driver error (#14390, not yet fixed), so those node results have nothing structured to surface yet. `create_record` itself forwards `code` only when it equals `DUPLICATE_RECORD` — narrowly, on purpose, matching the ADR-0112 vocabulary member this repair was actually scoped to surface, not any code an as-yet-unaudited driver error might someday carry.
18+
19+
**Patch round 1 (tier contract review):** `try_catch`'s catch region reads `code` off the run-wide `$error`, but the engine only rewrites `$error` when a failing node *returns* a failure, or *throws* through a node with its own `fault` edge — and a node inside a `try_catch`'s `try` region never has one (the region's synthetic sub-flow carries only the region's own edges). A node that fails by throwing (a `timeoutMs` firing, a dying nested container) therefore used to leave `$error` exactly as an *earlier, unrelated* failure left it — its `code` included. An identity guard (`$error` must have *changed*, not merely still be present, since this attempt started) closes that; two flows now pin it: a `loop` sweeping two rows where row 1 is a genuine duplicate and row 2 times out, and a plain flow where an earlier fault-routed duplicate must not leak into a later, unrelated `try_catch`.
20+
21+
A custom `IDataEngine` implementation whose thrown error already carries `code: 'DUPLICATE_RECORD'` (without being an instance of `@objectstack/objectql`'s `DuplicateRecordError`) is treated as a duplicate too — correct under ADR-0112, since `code` is the classified envelope's public contract, not the concrete class.
22+
23+
**Known gap, filed rather than fixed here (out of this lane's scope):** `packages/spec`'s `TryCatchErrorValueSchema` — the ONE declared shape for the `errorVariable` binding shared by author, engine and run log — does not declare `code` yet, and strips it on a strict parse. `packages/spec` is single-owner (`domain:spec`); tracked as #14954.
24+
25+
<!-- adr-0087: not-required (no-migration-prescription) additive optional field; no authorable key, export removal or rename for an upgrader to migrate -->

0 commit comments

Comments
 (0)