Skip to content

Commit 2bb64a7

Browse files
committed
Merge commit '3e270d4e296368f6600d71fcec9902f3a14c1698' into claude/issue-15964-created-at-unconditional-stamp
2 parents eff0b69 + 3e270d4 commit 2bb64a7

134 files changed

Lines changed: 5556 additions & 780 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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
MySQL: a plain unique index over existing duplicate rows no longer takes the boot down when the index has to be carried by a hash shadow.
6+
7+
`syncDeclaredIndexes` handles a declared unique that the database refuses in one `catch`, and that `catch` has two arms: the DIRECT one, and the hash-shadow one MySQL takes when a key part is wider than the 768-char utf8mb4 ceiling. #14902 taught the direct arm that a uniqueness violation over existing rows is a durability degradation rather than a fatal — log it, name the conflicting rows and the remedy, let the boot continue. The shadow arm kept the older guard, which also required a NULL-safe organization key part, so a PLAIN unique (`tenancy: { enabled: false }`, or an explicit `unique: 'global'`) matched neither branch.
8+
9+
Measured on live MySQL 8.0.46: the boot died carrying `ER_BLOB_KEY_WITHOUT_LENGTH` — a refusal about an unkeyable TEXT column, telling the operator to declare a `maxLength` the field already declared — while the real cause was two duplicate rows it never mentioned. It named no rows and no remedy.
10+
11+
The two arms now agree, and they say different things because they mean different things. The NULL-safe arm keeps its wording (existing rows violate the NULL-safe key, duplicating what the previous void constraint admitted); the plain arm gets the direct arm's reviewed sentence, because neither of those clauses is true of a plain unique — nothing admitted the rows, and there is no NULL-safe key. Widening the guard alone would have shipped a factually false durability log, which is worse than the throw it replaces.
12+
13+
`os migrate plan` already reported this operation as `destructive` with the row report and is unchanged.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Fix: a name-keyed `pages:` map no longer passes every source-page lint vacuously.
6+
7+
`pages` has two authoring carriers — a list, or a map keyed by page name that
8+
`normalizeStackInput` folds into a list before the schema sees it. Four rules
9+
(`validate-jsx-pages`, `validate-page-source-styling`,
10+
`validate-react-page-props`, `validate-react-pages`) read the collection through
11+
a private coercion that answered a map with an empty list, and they run on the
12+
raw `os lint` path where nothing has normalized it yet. On a map-shaped stack
13+
all four therefore returned no findings by never walking a single page: an
14+
empty source, a syntax error, an unparseable component and a Tailwind
15+
`className` were all reported as clean. They now read `collectionEntries`,
16+
which handles both carriers, and a finding on the map carrier is located by the
17+
author's own key (`pages.home.source`) rather than a synthetic index.
18+
19+
The same change removes the last sixteen private copies of the collection
20+
coercion in this package. Twelve rules — the `function` form, which had already
21+
grown the non-record filter locally in two different spellings — now read
22+
`recordsOf` from `object-graph.ts`. Two behaviour changes fall out, both on
23+
input that was already malformed: an array-typed member of `agents:` /
24+
`skills:` / `tools:` used to survive the looser local filter and draw one
25+
reference-integrity finding at a position nobody authored, and is now dropped;
26+
a member of a name-keyed `validations:` map whose value is not a record is now
27+
carried as `{ name }` rather than discarded, which reaches no check that reads
28+
it. No rule id, message or severity changes, and every finding path on the list
29+
carrier is unchanged.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
`os lint` no longer crashes on a localized label.
6+
7+
`convention/label-case` indexed its argument (`label[0].toUpperCase()`) on a parameter annotated `string`, while every call site reaches it through `any`-typed config walking and the spec does not require a label to be a string: `I18nLabelSchema` is `z.union([z.string(), InlineLocaleMapSchema])`. On the map form `label[0]` is `undefined`, the rule threw a `TypeError`, and the throw escaped `lintConfig` into the command's catch-all — so an author who localized an app label or a list-view label could not lint the project at all. Every face exited 1 with `Cannot read properties of undefined (reading 'toUpperCase')`, naming no rule, no path and no remedy, on input `ObjectStackDefinitionSchema` parses clean.
8+
9+
The rule now checks `typeof label === 'string'` first. Two of the four carriers it walks accept the inline locale map — `apps[].label` (`AppSchema`) and a view's `list` / `listViews.*` labels (`ListViewShapeSchema`); the other two are `z.string()` and reject the map at the schema door (`objects[].label`, `objects[].fields.*.label`).
10+
11+
**Nothing about a plain string label moves.** Same warning, same message, same `fix`, same path, on all four carriers — that is pinned per carrier rather than asserted.
12+
13+
**The rule deliberately says nothing about a localized label**, rather than resolving the map and case-checking one of its entries. Case is a property of a literal, and deciding which locale entry a case verdict is taken against is a product call, not a lint call. Widening the rule that way is a separate change.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
feat(spec): every `metadata.endpoints.*` switch gates exactly the face its name states, and the whole-store operations get their own key `maintenance` (#15542, #15854)
7+
8+
`RestServerConfig.metadata.endpoints` declared three switches, each `describe()` naming
9+
exactly one route, and each gated a different set. The mismatch ran in **both**
10+
directions at once:
11+
12+
- **`items`** — declared "GET /meta/:type - List items of type" — also gated the
13+
whole-store family: the cross-type spec-validation sweep `GET /meta/diagnostics`, the
14+
draft list `GET /meta/_drafts`, and the **`POST /meta/_migrate-stored` write door**.
15+
An operator who switched off a listing read they considered chatty silently unmounted
16+
a migration door.
17+
- **`item`** — declared "GET /meta/:type/:name - Get specific item" — gated four
18+
*reads* (`/:type/:name`, `/references`, `/layers`, `/book/:name/tree`) and left the
19+
per-item **writes** `PUT` and `DELETE /meta/:type/:name` plus the whole history family
20+
(`/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`) answering to
21+
`api.enableMetadata` alone. An operator who closed the per-item surface left its
22+
writes mounted.
23+
24+
Neither is a liveness defect — all three keys were genuinely read — which is why no
25+
ADR-0049 census could ever flag them: what drifted was each key's **radius** against its
26+
own documentation.
27+
28+
**One principle now holds across the block: a switch gates exactly the face its name
29+
states, reads and writes alike.**
30+
31+
| key | mounts it gates (default prefix `/meta`) |
32+
|---|---|
33+
| `types` | `GET /meta`, `GET /meta/types` — one handler, two paths (unchanged) |
34+
| `items` | `GET /meta/:type` — and nothing else |
35+
| `item` | `GET` / `PUT` / `DELETE /meta/:type/:name`, `/references`, `/layers`, `/history`, `/audit`, `/diff`, `/published`, `/publish`, `/rollback`, and `GET /meta/book/:name/tree` |
36+
| `maintenance` | **new**`GET /meta/diagnostics`, `GET /meta/_drafts`, `POST /meta/_migrate-stored` |
37+
38+
All four `describe()` strings are rewritten to enumerate what they gate, so the
39+
generated reference page is the radius rather than a sample of it.
40+
`api.enableMetadata` remains the master switch above all four, and
41+
`GET /meta/object/:name/state/:field` — the object FSM read, addressed by object name
42+
rather than by `:type/:name` — deliberately stays under that master switch alone.
43+
44+
**BREAKING** — for a programmatic embedder that authors `RestServerConfig.metadata.endpoints`,
45+
the mounted route table moves for two of the four keys, in opposite directions:
46+
47+
- **`items: false` now removes one route instead of four.** An embedder relying on it to
48+
close `/diagnostics`, `/_drafts` and the `POST /_migrate-stored` door **regains all
49+
three** unless it also sets `maintenance: false`. That is a write door coming back, so
50+
it is the half to read twice. One line restores the old table:
51+
`endpoints: { items: false, maintenance: false }`.
52+
- **`item: false` now removes twelve routes instead of four.** An embedder relying on it
53+
to close only the per-item *reads* while keeping `PUT`, `DELETE` and the history family
54+
mounted **loses those eight**. There is no key that restores them — the per-item face is
55+
one face by this ruling — so an embedder that wants the writes keeps `item` on and
56+
closes the surface at `api.enableMetadata` or at the object's own `enable.apiMethods`.
57+
- **The exported type `MetadataEndpointsConfigParsed` narrows: `endpoints` gains a
58+
REQUIRED member `maintenance: boolean`.** `maintenance` is `z.boolean().default(true)`,
59+
so it is optional on the way *in* and always present on the way *out* — and
60+
`MetadataEndpointsConfigParsed` is `z.infer<typeof MetadataEndpointsConfigSchema>`, the
61+
OUTPUT side. Any code that builds one of these objects by hand — a test fixture, a
62+
helper returning the parsed shape, a `satisfies MetadataEndpointsConfigParsed` literal —
63+
stops compiling with `TS2741: Property 'maintenance' is missing`. This one IS
64+
compiler-carried (the ADR-0087 D8 class), which is the good case: the break is loud, it
65+
lands at build time, and no runtime behaviour depends on the author noticing a
66+
changelog. Add `maintenance: true` to restore the previous mounts, or `false` to keep
67+
the whole-store family closed. In-repo consumers of the type: none — the narrowing was
68+
measured against a probe compiled from the rebuilt declaration, not assumed.
69+
70+
Priced and accepted rather than deferred: `RestServerConfig` is reachable from **no
71+
shipped boot path** today (`os serve` fixes the config and the dev plugin passes none,
72+
#15543), so the measured population of affected authors is **zero** and the blast radius
73+
is programmatic embedders only. That is precisely why this lands now — once a boot path
74+
starts authoring the config, the same change becomes a behaviour change on live
75+
operators.
76+
77+
**ADR-0087 disposition: a D3 semantic migration, no D2 conversion.** No
78+
authored key changes shape or spelling — `items: false` still parses to `items: false`,
79+
`maintenance` is additive with `.default(true)`, and nothing is retired (`endpoints.schema`
80+
stays the #14691 tombstone it already was). There is nothing for the conversion layer to
81+
convert: a `RestServerConfig` is plugin TS configuration, never a stack collection member
82+
and never a `sys_metadata` row (the `RestServerConfig.openApi31` precedent, #4579), so no
83+
rehydration seam sees it. What changes is a mounted route table at construction time.
84+
85+
Nor is the RADIUS change compiler-carried on the AUTHORED side — and that is the half a
86+
D3 is owed for. Every authored key is an optional boolean, so `{ items: false }` still
87+
compiles and still parses and simply mounts a different table: the author is told
88+
nothing. (The parsed-type narrowing in the third BREAKING bullet above *is*
89+
compiler-carried, but it catches only code that hand-builds the OUTPUT type — it cannot
90+
reach the embedder who authored `{ items: false }` and now silently gets three routes
91+
back.) So for the change that actually moves the route table, both channels that would
92+
otherwise reach a consumer are blind, which is precisely the residue D3 exists for — the prescription is registered as
93+
`metadata-endpoints-switch-radius-repartitioned` so `objectstack migrate meta` hands
94+
it to an upgrading embedder instead of leaving it as prose in a changelog.
95+
96+
<!-- adr-0087: registered metadata-endpoints-switch-radius-repartitioned -->
97+
98+
`@objectstack/rest` is versioned alongside rather than as a passive consumer: it is where
99+
the gates live, so the route-table change is observable there and not only in the
100+
declaration.
101+
102+
Every key's radius is pinned route by route, in both directions, in
103+
`packages/rest/src/rest-config-mount-table.pin.test.ts` — the #15544 shape, which asserts
104+
each route is **absent from the mounted table** when its switch is off rather than what
105+
the switch normalizes to. A gate that grows or loses a route reddens there.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
"@objectstack/plugin-approvals": patch
4+
"@objectstack/plugin-security": patch
5+
"@objectstack/runtime": patch
6+
---
7+
8+
Four server-side authorization sites stop deriving platform-operator authority from a NAME in `ExecutionContext.positions`, and read the ADR-0095 posture rung instead.
9+
10+
`positions[]` is the security axis, so it carries ADR-0057 D4 `sys_user_position` names alongside the built-ins. `sys_user_position` is `apiEnabled` and its `position` values are unconstrained, so a tenant could mint a row spelling `platform_admin` for one of their own users: `resolveUserAuthzGrants` pushed that name straight onto `grants.positions`, while `grants.posture` — derived from the unscoped `admin_full_access` grant and nothing else — correctly stayed `MEMBER`. Every reader of the name therefore answered `true` for a principal enforcement treats as an ordinary member. `resolve-authz-context.ts` states the rule at `hasPlatformAdminStanding` ("read the RUNG — never `positions.includes(...)`"), but a comment is not a gate and these four had not followed it.
11+
12+
Each site now tests `posture === 'PLATFORM_ADMIN'`, byte-for-byte what `hasPlatformAdminStanding` returns:
13+
14+
- **`plugin-sharing`**`hasPlatformAuthority`. The minted row satisfied `assertResolvableAdminScope`, so an org-less caller holding only the ORG-scoped `manage_sharing` capability was answered with **every tenant's** sharing rules, and could delete platform-global rules. The `manage_platform_settings` capability spelling is unchanged.
15+
- **`plugin-approvals`**`isOverrideActor`. This predicate already read the rung and then ORed the name onto it, which is no protection: an OR is only as strong as its weakest arm. Because the platform arm deliberately crosses the tenant wall, the minted row let a member of one organization approve, reject or recall a **different organization's** pending request while holding no slot in its slate. The `ADMIN_FULL_ACCESS` capability arm and both TENANT_ADMIN arms are unchanged.
16+
- **`runtime`** — the ADR-0126 §5 activation gate. Under a `group` or `isolated` posture this gate is the only thing between a tenant org admin and the **install-wide** `sys_metadata_activation` row, so the minted row reopened #10243 with a durable row behind it.
17+
- **`plugin-security`**`derivePosture` in the explain engine. Narrower than the other three, and stated precisely rather than overclaimed: the name-read sat behind an early `ctx.posture` return that `buildContextForUser` always populates, so the shipping path was already gated and a D4 row never moved it. What the read did reach was a posture-less hand-built context, where it made the panel **report** `PLATFORM_ADMIN` for a principal enforcement treats as a MEMBER — a misreport rather than an admission, but in the one tool an administrator opens to check exactly this.
18+
19+
No behaviour changes for a genuine platform operator: their resolved context carries the rung, and the built-in position is still projected onto `positions[]` for display and predicate use. What changes is that the name alone no longer answers the authorization question.
20+
21+
Graded `patch` on the surface it moves: no exported type, signature or contract changes, and no authorable metadata is added, removed or renamed. The only observable difference is that a principal who never held the capability grant stops being admitted — which is the defect, not a feature anyone could have depended on.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
`POST /api/v1/packages` now answers an install-time namespace collision with `error.code: "NAMESPACE_CONFLICT"`. `NAMESPACE_CONFLICT` is registered in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`) gains one member and a caller can branch on the refusal directly.
7+
8+
**The wire, before and after** — measured through the shipped door (`HttpDispatcher.handlePackages` over a real `SchemaRegistry`), not derived from the call graph:
9+
10+
- before: `422` with `error.code: "VALIDATION_ERROR"` and `error.declaredCode: "NAMESPACE_CONFLICT"`
11+
- after: `422` with `error.code: "NAMESPACE_CONFLICT"` and **no** `declaredCode` — with the spelling registered there is nothing left to demote
12+
13+
The status, the message and the throw are unchanged. `NamespaceConflictError` (`@objectstack/objectql`'s `SchemaRegistry.installPackage`, ADR-0048 Phase 1 / ADR-0130 D1) has carried `code` and `status: 422` since the envelope landed; what changed is that the door's #9106 narrowing no longer demotes the spelling. Until now a caller wanting to tell "your namespace is taken, rename it" from every other `422` had to read `declaredCode` — the channel ADR-0112 declares as the open, not-guaranteed one — because `error.code` carried the generic member `422` derives.
14+
15+
Scope of the widening: one new accept value on `ApiErrorSchema.code`; no export changes, no schema-shape changes, and nothing narrowed. A consumer that treats `error.code` as a closed set it enumerates locally will see a value it does not know, which is what a vocabulary widening means and why this is a `minor`.
16+
17+
The now-discharged `pending-registration` row ratchets out of `packages/runtime`'s dispatcher-error-vocabulary table in the same change — registration is what makes that row stale, and `pnpm check:dispatcher-error-vocabulary` fails on a registered code still carrying one. The door's answer is pinned in `packages/runtime/src/package-door-namespace-conflict-code.test.ts`, which drives the real route and asserts the body, so the reachability the removed row asserted is now held by a test rather than by a claim.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
`$error` now names the most recent failure in a flow run, whichever way that failure arrived.
6+
7+
The automation engine has two failure arms. When a node FAILS BY RETURNING `{ success: false }`, the engine rewrote the run-wide `$error` (and `<nodeId>.error`) and then decided whether a `fault` edge could route it. When a node FAILED BY THROWING — a `timeoutMs` firing, a dying nested container, a thrown guard — it did both **inside** the `fault`-edge branch, so a thrown failure with no `fault` edge of its own left `$error` holding an earlier, unrelated failure's value.
8+
9+
A node inside a structured region never has a `fault` edge of its own: the region's synthetic sub-flow carries only the region's own edges. So every thrown failure inside a `try_catch`, `loop` body or other region hit this. The result was not a crash but a plausible-looking wrong value: **the message and the code came from two different failures**`{ code: 'DUPLICATE_RECORD', message: "Node 'mk' timed out after 20ms" }` — and a catch region branching on `{$error.code}` swallowed a store failure as "the row is already there" while the run reported success.
10+
11+
The throw arm now publishes `$error` and `<nodeId>.error` before deciding whether the failure routes, exactly as the returned-failure arm does. What a thrown failure publishes is `{ nodeId, message }`: there is no node result on that path, so no `output` and no classified `code` exist to carry — and that absence is the right answer for a throw rather than a reason to leave a stale `code` standing.
12+
13+
Routing is unchanged. A guard refusal that throws (ADR-0049's unscoped-run refusal, for one) is still un-routable, still fatal, and still reports its own message; the thrown value itself is rethrown exactly as caught.

0 commit comments

Comments
 (0)