Skip to content

Commit cb4abd7

Browse files
committed
Merge origin/main into claude/issue-14685-ratchet-cross-file-move
Two conflicts, both against main commits that landed after this branch's merge base 5ff5f95: - .claude/skills/pm-dispatch/references/lanes/services.md: main's 365141f (#14856) edited the very bullet this branch deletes (the bullet moved to references/platform-readings.md). Settled to OUR side: the bullet stays deleted and the move stands. The FACT main corrected in that bullet (the i18n gates answer PREREQUISITE NOT MET with exit 3, not 1) is carried into the moved text by the follow-up commit, so this merge commit's non-conflict content stays a pure merge of the two parents. - scripts/check-ratchet-remedy-authority.mjs: main's a610775 (#14896) added the self-test battery roster and floor at the spot where this branch's section (21) sits. Settled by keeping BOTH: section (21) opens with battery('(21) ...'), is declared in SELF_TEST_BATTERIES at its case count (2), and sits before the floor-evaluation block; the roster's own size pin SELF_TEST_BATTERY_FLOOR moves 20 -> 21 by the header's own convention (the roster size is pinned at the declared count). The exception phrase stays assembled, never spelled, in author-facing text, and the gate's run line is byte-identical to main's. scripts/pm/check-skill-line-ratchet.mjs auto-merged with main's c47a5ac (#14853) verdict handshake; the two run-path verdict lines this branch adds are outside that handshake, which covers selfTest()'s sentinel return only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1
2 parents 62ddd75 + 09cc6be commit cb4abd7

420 files changed

Lines changed: 30369 additions & 2809 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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): an `autonumber` field is `unique: 'organization'` by default; explicit `unique: false` opts out (#13894)
6+
7+
**BREAKING** emitted-shape change on `FieldSchema` (the accept set is unchanged),
8+
shipped as `minor` under the repo's launch-window convention for breaking changes.
9+
10+
An auto-number is a business identifier — a contract number, a quote number, a
11+
case number — and an identifier that may repeat is not one. Yet the platform
12+
only ever materialized a unique index where the author had written `unique`
13+
by hand: of hotcrm's nine auto-numbered identifiers, exactly one
14+
(`crm_case.case_number`, `unique: true`) carried the tenant-composite unique
15+
index, and the other eight could mint the same number twice (measured:
16+
objectstack#12394 re-issued `ACC-000009`). Maintainer ruling 2026-08-31
17+
(hotcrm#1301): the default flips.
18+
19+
- An `autonumber` field that **omits** `unique` now parses to
20+
`unique: 'organization'` — one holder per organization, materialized by the
21+
drivers exactly as `case_number`'s hand-written declaration was: the NULL-safe
22+
tenant-composite index `(COALESCE(organization_id, '__global__'), <field>)` on an
23+
organization-scoped object, a plain unique index on an object with no
24+
organization key.
25+
- Every **other** field type keeps `unique: false` as its default, at the same
26+
key position — parse output for non-autonumber fields is byte-identical.
27+
- Every **authored** spelling (`true`, `'organization'`, `'global'`, `false`)
28+
parses exactly as before, on every type.
29+
- The default is materialized at parse time (the `.overwrite()` tail of
30+
`FieldSchema`, the type-conditional precedent `deleteBehavior` set), because
31+
the drivers read the parsed `unique` value-only; the published JSON Schema
32+
therefore no longer carries `default: false` on `Field.unique` — the
33+
description states the rule, and the authorable-defaults ratchet records the
34+
move as `data/Field:unique = false → (none)`.
35+
36+
**Opting out.** Write `unique: false` explicitly on the autonumber field. That
37+
is the whole opt-out surface — no second key. It is legitimate only for a
38+
display-only sequence that nothing uses to identify the record; note that the
39+
platform's duplicate scan (`os migrate duplicates`) keeps treating every
40+
autonumber field as an identifier regardless.
41+
42+
**Migration — what an operator with existing duplicates sees.** A table that
43+
already holds duplicate auto-numbers cannot take the index. On SQLite/Postgres/
44+
MySQL the SQL driver does not fail the boot and does not skip silently: it logs
45+
on the `error` channel —
46+
47+
```
48+
[sql-driver] cannot create NULL-safe unique index 'uniq_crm_quote_organization_id_quote_number' on "crm_quote" — existing rows violate it (duplicates the previous NULL-distinct index admitted, #5030). The constraint 'organization_id, quote_number' is NOT enforced until the data is deduplicated: run "os migrate plan" for the conflicting rows (ADR-0120 D4).
49+
```
50+
51+
— and the same boot's drift pass names the conflicting key groups with their
52+
row counts:
53+
54+
```
55+
[schema-drift] crm_quote: cannot create 'uniq_crm_quote_organization_id_quote_number' as UNIQUE (COALESCE(organization_id, '__global__'), quote_number) — existing rows already violate the NULL-safe unique constraint (duplicates the old index wrongly admitted, #5030): (organization_id="__global__", quote_number="QUO-00009") × 2 rows; (organization_id="org_x", quote_number="QUO-00010") × 2 rows. The op is BLOCKED: apply re-probes and refuses, and the existing index stays in place (ADR-0120 D4). Deduplicate the listed rows, then re-run "os migrate plan".
56+
```
57+
58+
`os migrate plan` reports the same blocked `create_index` with the same groups
59+
until the rows are deduplicated; `os migrate duplicates` lists the holder row
60+
ids of any value minted across organization partitions (the seed/API split).
61+
Deduplicate — which duplicate keeps its number is a business decision — then
62+
re-run `os migrate plan` / restart, and the index materializes. An object with
63+
`tenancy.enabled: false` takes a plain unique index instead, and there the
64+
driver raises the database's own unique-violation error at boot (it names the
65+
index, not the rows) — run `os migrate duplicates` / a `GROUP BY <field> HAVING
66+
COUNT(*) > 1` to find them.
67+
68+
Two landed defects change shape on purpose under the default: a counter that
69+
re-issues a number after a burned reservation (#12394) and two counters minting
70+
for one object (#8686) used to produce a *silent* duplicate; they now produce a
71+
loud unique-violation refusal at the write.
72+
73+
<!-- adr-0087: registered autonumber-default-unique-organization -->
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.

0 commit comments

Comments
 (0)