Skip to content

Commit 51a362c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15832-claim-probe-before-mutate
2 parents 94b8563 + 9b459b7 commit 51a362c

18 files changed

Lines changed: 1825 additions & 118 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses object pairs whose object-level collections cannot be merged (#14848)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed: every object key, every `composeStacks` option and the `ConflictStrategySchema` enum (`'error' | 'override' | 'merge'`) parse exactly as before, so `objectstack migrate meta` has nothing to rewrite. What narrows is the ACCEPT SET of one option value at composition time — two stacks whose same-name objects both declare an object-level collection with different values are now refused under `'merge'` where they used to compose with the earlier stack's entries silently dropped. The refusal text carries the whole prescription (declare the collection in one stack, make the declarations identical, or use `'override'`), and the repository measures zero non-test call sites passing `objectConflict` at all (see below), so there is no stored artifact and no authored file for a migration to act on. -->
8+
9+
**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })`
10+
— shipped as `minor` under the repo's launch-window convention for breaking
11+
changes. Maintainer ruling 2026-09-04 on #14848 (director decision batch #38
12+
item 5, verbatim 「同意」): option 4, `'merge'` **refuses** what it cannot merge
13+
instead of dropping it.
14+
15+
**What changed.** `'merge'` was implemented as
16+
`{ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }`:
17+
`fields` was the only key merged, and every other key the later object carried
18+
`actions`, `indexes`, `listViews`, `validations`, … — replaced the earlier
19+
package's value wholesale, with nothing at compose, build or boot saying so.
20+
Two packages each embedding an action on one shared object composed to the
21+
later package's array alone; the earlier package's action was gone.
22+
23+
Now, when both objects declare an object-level **collection** other than
24+
`fields` with different values, `composeStacks` throws — the refusal shape
25+
`'error'` uses — naming the object, the colliding collection and both stacks
26+
by manifest id:
27+
28+
```
29+
composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1).
30+
objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged: the later declaration would replace the earlier one wholesale, silently dropping every entry 'com.example.a' (stack #0) wrote.
31+
Fix: declare 'actions' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack.
32+
```
33+
34+
The refusal set is **derived from `ObjectSchema`'s shape** — every key whose
35+
declared type is an array or a record (through optional/default wrappers and
36+
into a union's members), except `fields` — not hand-listed, so a collection key
37+
added to the object schema joins the refusal without an edit to the composer.
38+
Today that set is `actions`, `activityMilestones`, `fieldGroups`,
39+
`highlightFields`, `indexes`, `listViews`, `requiredPermissions`,
40+
`searchableFields`, `validations`.
41+
42+
**What did not change.**
43+
44+
- `fields` keeps its documented shallow merge (later fields win, earlier
45+
fields kept).
46+
- **Identical** declarations on both sides pass through and are carried once
47+
— the same reading `composeStacks` already gives identical top-level values
48+
— so two built stacks that each bind one standalone action to the same
49+
object (identical copies) still reach the cross-stack action-key check
50+
(#14662) and are refused there, by name, as before.
51+
- A scalar or fixed-shape config object the later object declares (`label`,
52+
`sharingModel`, `enable`, `access`, …) still replaces the earlier one: the
53+
ruling narrows collections only, and the docblock now says so.
54+
- The default `'error'` and `'override'` are untouched, message for message.
55+
- An explicit `undefined` on the later object is read as no declaration — it
56+
neither counts as a differing value nor erases what the earlier stack
57+
declared (the bare spread used to let it).
58+
59+
**Who is affected.** Measured on `origin/main` @ `53cbad9f7`: **zero** non-test
60+
call sites in `packages/**`, `examples/**`, `apps/**` pass `objectConflict` at
61+
all — every real caller takes the default `'error'`. An external author who
62+
opted into `'merge'` and relied on the later package's collection winning
63+
silently now gets the refusal above; the fix is the one it names.
64+
65+
The `ConflictStrategySchema` docblock for `'merge'` states the rule.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
The REST data doors' protocol requests are compiled against the declared contract again, so a field added to a data request schema reddens the build instead of going silently unsent.
6+
7+
No runtime behaviour changes — every door assembles and forwards exactly the object it did before. What changes is what the compiler is allowed to see. `packages/rest/src/rest-server.ts` dispatched to the protocol through two erasing forms: `p.deleteData({ … } as any)` on the argument, and the stronger `(p as any).updateData({ … })` on the protocol object itself, which erases the check on *every* member — a misspelled method name would not have errored. Across the file that was 22 dispatch sites spanning `findData` / `getData` / `createData` / `updateData` / `deleteData`, their `*Many` and batch siblings, and `getUiView`.
8+
9+
The casts were load-bearing rather than lazy: these call sites pass `environmentId` and `context`, and neither is a member of any data request schema. Neither should become one. `environmentId` is the transport routing key that selects the kernel *before* the protocol call and is already ruled out of the request shape; `context` is the server-derived execution context, and a caller-supplied `context` is a privilege escalation the ingress deletes unconditionally — putting it in the published request schema would re-open that door. Both are now declared on a typed envelope alongside the request type, so they stay server-side *and* compiled, and every other member of every literal is checked against the spec.
10+
11+
One slot stays deliberately untyped and is now named rather than diffuse: `findData`'s `query` accepts both the declared AST and an undeclared wire dialect (`$top`, `$orderby`, `filters`, …) that the protocol normalizer folds. Three server-built literals speak that dialect; the erasure there is confined to the query slot alone, and the declared-versus-shipped mismatch is filed as its own question.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
`sys_email.highlightFields` names the recipient column that exists, so the platform's own email log stops rendering one column short (#15629)
6+
7+
The list read `['subject', 'to', 'status', 'sent_at']`. Three of those four resolve; `to` does not — `sys_email`'s recipient column is `to_addresses`. It now reads `['subject', 'to_addresses', 'status', 'sent_at']`, and nothing else about the object moved.
8+
9+
`highlightFields` is the object's ordered "most important fields" pointer (ADR-0085): it drives the default list columns, record cards, previews and the detail highlight strip. Every consumer **silently skips** an entry it cannot resolve — nothing throws and nothing logs — so each of those surfaces rendered one field short, and the field missing from the platform's own outbound-email log was the recipient.
10+
11+
There was a second, louder consequence that nobody could reach by accident. Since `object-field-ref-unknown` crossed onto the object write door (#15254), this body could not be republished through `PUT /api/v1/meta/object` or a package publish: the door answers `422 INVALID_METADATA`. `sys_email` reaches the runtime as a code-shipped registry object instead — `EmailServicePlugin` hands it to the manifest service, a path that runs no authoring gate — so boot was never affected and no deployment was failing. It was a trap laid for whoever next edited the object through a door rather than the file.
12+
13+
`sys-email.highlight-fields-resolve.test.ts` pins it through that real door rather than by comparing the array against `Object.keys(fields)`: it runs `runRuntimeAuthoringRules({ type: 'object' })` over the shipped declaration with the audit module's other objects as resolution context, and a control case restores the old entry and requires the same call to refuse it — so a green result means the door read this object and accepted it, never that nothing looked.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
`os migrate value-shapes` now prescribes the key rename on a legacy `{latitude, longitude}` location, instead of reporting the missing-pair type error.
6+
7+
A value-shape rejection was read positionally — `parse.error.issues[0]` — at both places the value-shape detail is produced: the write path's warn-first / strict branch, and the exported `valueShapeViolation` the scan imports. zod reports per-member issues before the object-level `unrecognized_keys` one, so on a value whose keys were **renamed** the actionable message sorts last and was discarded. A `location` stored as `{latitude, longitude}` — the exact legacy shape the scan's own header names as one it exists to find — reported `Invalid input: expected number, received undefined`, leaving an operator to derive a rename that edit distance cannot reach (`latitude` -> `lat`), while `LocationValueSchema` had built the prescription and thrown it away.
8+
9+
Both readers now prefer the undeclared-key issue when the rejection carries one, through a single shared helper — two readings of the same rejection drifting by one clause is how one path prescribes the rename and the other does not. The affected strings are the `os migrate value-shapes` finding `detail`, the warn-first `[value-shape]` log line, and the `invalid_value_shape` error's `detail` under strict enforcement.
10+
11+
⛔ No verdict moves. The same values are flagged, the same writes are rejected or admitted, and the deployment gate opens on exactly the same evidence — only the operator-facing text changes.
12+
13+
Scoped by measurement rather than by assumption: of the sixteen types these readers cover, only `location` and `address` are backed by a key-closed object schema, so only they can emit `unrecognized_keys` at all — for the other fourteen the preference cannot change a single character. Both classes it does reach curate the alias map that makes the undeclared key the more actionable half. The defect reaches `address` as well as `location`: every address member being optional rules out a *missing*-member type error, but not a *wrong-typed* declared one, which still sorts ahead of the undeclared-key issue.

.github/workflows/docker-publish.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,29 @@ jobs:
9797
# `os --version` proves the CLI resolved, installed, and runs on the
9898
# pushed image; a boot test needs an artifact + DB and belongs to the
9999
# examples/e2e suites, not here.
100+
#
101+
# The `require()` line proves the SQL drivers are actually IN the built
102+
# image. `check:docs-image-tag` cannot see that: it compares the
103+
# Dockerfile install line against README.md's published table, and both
104+
# would still agree if the drivers vanished from the image. The class
105+
# has already cost a boot once -- docker/Dockerfile records that a tree
106+
# without `pg` "died at boot on `Cannot find module 'pg'`", which is why
107+
# the install line exists. `require()` needs no artifact and no
108+
# database, so it stays on the right side of the same line drawn above.
109+
#
110+
# `-w` is load-bearing, not incidental. `npm install -g` puts the
111+
# drivers in /usr/local/lib/node_modules, which is NOT on `require()`'s
112+
# search path from this image's WORKDIR (/srv/app): node's global
113+
# folders are $PREFIX/lib/node, and the node:22-slim base sets no
114+
# NODE_PATH. Run from /srv/app the probe fails on a CORRECT image, so
115+
# it would be a constant red rather than a check. Resolving from inside
116+
# the global tree is also how the real boot path resolves them --
117+
# driver-sql lives there too. Do not "simplify" this to a bare
118+
# `docker run`; verified in both directions before it landed.
100119
env:
101120
VERSION: ${{ steps.version.outputs.version }}
102121
run: |
103122
docker pull "$IMAGE:$VERSION"
104123
docker run --rm "$IMAGE:$VERSION" os --version
124+
docker run --rm -w /usr/local/lib/node_modules "$IMAGE:$VERSION" \
125+
node -e "require('pg'); require('mysql2')"

content/docs/permissions/system-context.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ not on any flag.
6464
## How the flag is set
6565

6666
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
67-
cannot set it (`packages/rest/src/rest-server.ts:1565`, `:1594`), and neither
67+
cannot set it (`packages/rest/src/rest-server.ts:1651`, `:1680`), and neither
6868
can an action body (`packages/runtime/src/domains/actions.ts:414`). It is
6969
written by internal callers only, as an option on the engine call:
7070

@@ -103,7 +103,7 @@ that silently does not happen.
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:250` |
104104
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
105105
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
106-
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1597` |
106+
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1683` |
107107

108108
### 2. Write pipeline and data integrity
109109

@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 105 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5145`, `:6571`, `:6819`, `:7250`, `:7443` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5231`, `:6657`, `:6905`, `:7336`, `:7529` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1590` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201201
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` |
202-
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1565`, `:1594`; `domains/actions.ts:414` |
202+
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1651`, `:1680`; `domains/actions.ts:414` |
203203

204204
---
205205

0 commit comments

Comments
 (0)