Skip to content

Commit 0757f0f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/envelope-drift-route-modules-v2zoky
2 parents 10f15c4 + 77fadbf commit 0757f0f

188 files changed

Lines changed: 7985 additions & 1590 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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/runtime": major
3+
"@objectstack/client": minor
4+
---
5+
6+
fix(actions)!: failures speak HTTP — business rejections are 400, success is a single wrap (#3962)
7+
8+
**BREAKING (raw-HTTP callers of `POST /api/v1/actions/...` only).** The
9+
200-with-inner-envelope wire was never a designed contract: no ADR or doc ever
10+
specified it, it originated as the route's catch block reusing
11+
`deps.success()`, and `/actions` was the only route of 12 that double-wrapped.
12+
#3962 classifies it as a bug. Five defects traced back to that one extra layer
13+
(the console's green toast on failed actions, `redirectUrl` never firing, a
14+
marketplace install reported as installed when it failed, the client-envelope
15+
divergence #3927 papered over, and crashes invisible to monitoring).
16+
17+
The contract now, identical to `/data`:
18+
19+
| Outcome | HTTP | Body |
20+
|:---|:---:|:---|
21+
| Ran, returned | **200** | `{success: true, data: <handler return value>}` — single wrap |
22+
| Ran, rejected (business rule / validation) | **400** | `{success: false, error: {message, code, details: {code?, fields?}}}` |
23+
| Never dispatched (unknown / denied / wrong type / unavailable) | 404 / 403 / 400 / 503 | unchanged (#3930/#3951) |
24+
| Crashed (`TypeError`, driver class, sandbox timeout) | **500** | unchanged (#3951) |
25+
26+
A validation rejection carries `details.code: 'VALIDATION_FAILED'` and
27+
`details.fields[]` — the exact payload #3937 fought for, now on the same wire
28+
shape `/data` has always used, which `@objectstack/client` normalizes to
29+
`err.code` / `err.fields` (#3927). A rejected flow is a 400 with
30+
`details.code: 'FLOW_FAILED'`. The crash-vs-rejection discriminator (#3951,
31+
error `name`) now selects 400 vs 500.
32+
33+
`client.actions.invoke` / `invokeGlobal` still never throw: they fold every
34+
failure status into `{success: false, error}`, read the single wrap on
35+
success, and keep a NARROW legacy heuristic so a current SDK talking to a
36+
pre-#3962 server still folds the old double-wrapped 200s correctly.
37+
38+
**Migration for raw-HTTP third parties:** branch on the HTTP status — a
39+
non-2xx is the failure, `error.message` / `error.details` carry the detail; on
40+
a 200, `data` is the handler's return value directly (one level less than
41+
before). Callers using `@objectstack/client` need no change.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/runtime': major
3+
'@objectstack/objectql': minor
4+
'@objectstack/metadata': minor
5+
---
6+
7+
**ADR-0110 — an action's identity is its `name`, and anything executable over a
8+
governed surface must have a declaration.**
9+
10+
`POST /api/v1/actions/:object/:action` resolved the DECLARATION from the URL
11+
segment as a `name` but dispatched the HANDLER using that same segment as a
12+
registry key. For a target-bound action (`{ name: 'complete_task', target:
13+
'completeTask' }`) those are different strings, so the two documented callers
14+
each worked on exactly the half the other broke: the documented curl resolved
15+
the declaration then 404ed, while the Console's `target`-addressed call
16+
dispatched fine and resolved no declaration — silently skipping the ADR-0066 D4
17+
capability gate and the ADR-0104 param contract (#3935).
18+
19+
- **D1/D2** — identity is always the declarative `name`; the handler key is
20+
derived from the resolved declaration through a rotation now shared with the
21+
MCP `run_action` bridge (`resolveActionHandlerKeys`, `executeRegisteredAction`).
22+
The REST route previously rotated only the object key, never the handler key.
23+
- **D3 (breaking)** — declaration resolution is a trichotomy. A genuinely
24+
undeclared handler is **refused (404)** with the `defineAction` to add, rather
25+
than executed ungated with system privileges; an unreachable metadata plane is
26+
a **503** rather than a silent ungating (`MetadataManager.loadDiagnosed` tells
27+
a clean miss from an outage). `OS_ALLOW_UNDECLARED_ACTIONS=1` is the migration
28+
valve — it warns on every invocation and is removed in 18.
29+
- **D5**`reconcileActionRegistrations` plus `ObjectQLEngine.listRegisteredActions`
30+
power a `kernel:ready` inventory logging every registered-but-undeclared
31+
handler (refused at dispatch) and every declared script action bound to no
32+
handler — the ADR-0078 converse, mechanised.
33+
- **D6** — security-gate strictness is opt-**out** (`OS_ALLOW_*`), never opt-in.
34+
35+
Apps whose actions are all declared need no changes beyond gaining enforcement
36+
of the `requiredPermissions` they already declared.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
---
4+
5+
feat(sharing): a record's share-manager may revoke any share-link on that record (ADR-0111 D8)
6+
7+
`ShareLinkService.revokeLink` was creator-or-system only, so a record's owner or
8+
a Modify-All admin could not kill a link someone else minted on their record —
9+
their record's exposure, but not their link to revoke. Revoke authority now
10+
also admits a record **share-manager**, probed via the sharing service's
11+
late-bound `canManageShares` (owner / `modifyAllRecords`). The probe fails
12+
closed: a deployment without it (or a throwing probe) keeps the pre-D8
13+
creator-only behaviour. Mint authority is unchanged and now documented as the
14+
D8 decision it always enforced — the object's `publicSharing` opt-in AND the
15+
caller's visibility of the record.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": minor
4+
"@objectstack/plugin-security": minor
5+
"@objectstack/rest": minor
6+
---
7+
8+
fix(sharing)!: the share-management surface gains the authorization layer it never had (ADR-0111 P0, #3902)
9+
10+
Record sharing shipped as a data layer with no authorization of its own: every
11+
`/data/:object/:id/shares` and `/sharing/rules` route authenticated the caller
12+
and then ran the service under `SYSTEM_CTX` — any signed-in user could revoke
13+
anyone's share, enumerate who-can-see-what, write self-grants, and define /
14+
evaluate org-wide sharing rules. ADR-0111's P0 rulings land here:
15+
16+
- **D1/D2**`ISharingService.canManageShares(object, recordId, context)`:
17+
system, the record's owner, or a holder of Modify All Data (probed via the
18+
new fail-closed `ISecurityService.hasWriteBypass`). Enforced in the SERVICE,
19+
so every caller is covered; without plugin-security it fails closed to
20+
owner-only.
21+
- **D4**`revoke` is symmetric with grant, validates the share belongs to the
22+
URL's record (`NOT_FOUND` on mismatch), and refuses non-`manual` rows
23+
(`CONFLICT` — a rule-materialised grant would be resurrected by the next
24+
reconcile).
25+
- **D5**`listShares` is management-gated (invisible record → `NOT_FOUND`,
26+
visible-but-not-manager → `PERMISSION_DENIED`), and the open
27+
`/data/sys_record_share` read surface is self-scoped: non-admin callers see
28+
only rows naming them as recipient or grantor.
29+
- **D6** — the whole `/sharing/rules` surface (list/create/get/delete/evaluate)
30+
requires the new **`manage_sharing`** capability (D9; seeded into
31+
`admin_full_access`, `manage_platform_settings` honoured as the legacy
32+
equivalent), enforced in `SharingRuleService`.
33+
- **D7** — no inert grants: `recipientType` is narrowed to `user` (the only
34+
type any gate enforces), grants on objects the sharing gates never consult
35+
(public model, no `owner_id`, bypass, `controlled_by_parent`) fail with
36+
`SHARING_NOT_ENABLED` (422), and the manual upsert keys on
37+
`(object, record, recipient, source)` so manual and rule rows coexist.
38+
39+
**Breaking** for callers that relied on the missing gate: unauthorized share
40+
management now fails with 403/404/409/422 instead of silently succeeding, and
41+
`ISharingService.revoke` gained an optional `scope` parameter. The verb
42+
boundary (edit ≠ delete, ADR-0111 D3) is NOT in this change — it lands as the
43+
separate P1.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": minor
4+
"@objectstack/plugin-security": minor
5+
---
6+
7+
fix(sharing)!: an edit-level share no longer grants delete (ADR-0111 D3, the verb boundary)
8+
9+
`update` and `delete` shared one `canEdit` gate, and `canEdit` accepts an
10+
`edit`-level share — so one "edit" grant silently conferred delete, the
11+
opposite error from the retired `full` level. A share widens *which rows* a
12+
principal reaches, never *which verbs* they may use (Salesforce Read/Write
13+
cannot delete; Dataverse `Delete` is a distinct privilege; Odoo splits
14+
`write`/`unlink`).
15+
16+
- `ISharingService.canDelete(object, recordId, context)` — ownership (widened
17+
by write DEPTH) or the `modifyAllRecords` super-user bypass ONLY; an `edit`
18+
or legacy `full` share does not confer it. `canEdit` is unchanged (the
19+
update gate, share included).
20+
- `SharingService.buildWriteFilter` takes a `verb` parameter: a bulk
21+
`delete({multi:true})` scopes to the owner/DEPTH set alone (no share
22+
widening), while a bulk `update` keeps it.
23+
- The sharing middleware routes `delete` through `canDelete` and logs a
24+
specific fail-closed reason on denial (ADR-0111 D10).
25+
- `/security/explain` consults `canDelete` for a `delete` operation, so the
26+
record-level explanation matches enforcement.
27+
28+
**Breaking**: a caller who could delete a record *only* through an edit-level
29+
share (and holds object-level delete CRUD) can no longer delete it — delete now
30+
requires ownership, write depth, or Modify All Data. No new delete access level
31+
is introduced; a future per-record delete grant would be a capability mask
32+
AND-ed with object CRUD, not a fourth share level.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
feat(spec,core,runtime)!: ADR-0112 batch 1 — one error-code vocabulary, SCREAMING_SNAKE, schema-enforced (#3841)
8+
9+
Settles #3841 per ADR-0112: the top-level `error.code` vocabulary is
10+
SCREAMING_SNAKE, in two tiers.
11+
12+
- **`StandardErrorCode` members renamed in place** (`validation_error`
13+
`VALIDATION_ERROR`, all 53). Breaking for importers that branch on the old
14+
lowercase members; the type name and member *meanings* are unchanged.
15+
- **New `ERROR_CODE_LEDGER`** (`@objectstack/spec/api`): service-specific codes
16+
(`AUTH_REQUIRED`, `VALIDATION_FAILED`, `ATTACHMENT_DOWNLOAD_DENIED`, …) are
17+
registered per owning package. `ErrorCode` = standard ∪ registered.
18+
- **`ApiErrorSchema.code` is now `ErrorCode`**, not `z.string()` — an
19+
unregistered code fails parse, so the envelope conformance suites assert
20+
values, not just shape.
21+
- **`FieldErrorSchema.code` widened to `z.string()`** (ADR-0112 D6): field-level
22+
codes are a separate vocabulary the enum never described; #3977 owns its real
23+
catalog.
24+
- **Derived codes changed case on the wire**: `standardErrorCodeForHttpStatus`
25+
now yields SCREAMING members (`permission_denied``PERMISSION_DENIED`,
26+
`method_not_allowed``METHOD_NOT_ALLOWED`, …) — this map was #3842's
27+
designated one-file sweep point for exactly this decision.
28+
- **`ANONYMOUS_DENY_CODE` is `'UNAUTHENTICATED'`** (was `'unauthenticated'`) —
29+
the promoted code on anonymous-denied requests and the REST `enforceAuth`
30+
body change spelling with it.
31+
32+
`error-catalog.mdx` and the error-handling guides are rewritten to the single
33+
vocabulary; a spec test now locks the catalog page's headings to the enum so
34+
they cannot drift apart again. Remaining lowercase emitters (cloud-connection,
35+
plugin-auth envelope codes, metadata-protocol, …) are the batch-2 sweep.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
ADR-0112 (error-code vocabulary decision) — docs only, releases nothing.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(agents): PD #12 records the #3796 endgame — all seven flow-node config aliases graduated into the protocol-17 conversion layer and the `readAliasedConfig` shim is deleted; new aliases go straight to conversion entries, never executor shims — releases nothing.

.changeset/console-a136322f8723.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/console": minor
3+
---
4+
5+
Console (objectui) refreshed to `a136322f8723`. Frontend changes in this range:
6+
7+
- fix(app-shell)!: a modal action is client-side only — drop the server fallthrough (objectstack#3959) (#2973)
8+
- fix(app-shell)!: the server-action URL identifies an action by `name`, not `target` (ADR-0110 D1) (#2970)
9+
- fix(form): a server rejection that names fields now marks those fields (#2966)
10+
- fix(actions): one source for the /actions envelope rule, and redirectUrl finally works (#2967)
11+
- fix(actions): apply the ADR-0066 D4 capability gate on every action surface (framework#3923) (#2965)
12+
- fix(detail): multi-value lookup is selectable in inline edit (#2957)
13+
- fix(actions): a failed server action no longer reports as success (green toast) (#2963)
14+
- fix(fields): the criteria builder stops calling an empty criteria "All records" (#2962)
15+
- feat(report): carry a report's `order` into the dataset selection (framework#3916) (#2964)
16+
- feat(views): the list toolbar speaks one vocabulary — `userActions` (#2890) (#2948)
17+
18+
objectui range: `4a4829d0ef39...a136322f8723`
19+
20+
**Release-critical for v17.** The previous pin (`4a4829d0ef39`) predates the
21+
ADR-0110 D1 client fix, so the console it builds still posts `action.target`
22+
to `/api/v1/actions/:object/:action`. Against a v17 server — which resolves
23+
the declaration by `name` and refuses an unresolvable one (D3) — every
24+
target-bound script action would return 404 from the shipped console. The
25+
lockstep the ADR called for is enforced by THIS pin, not by merging the
26+
objectui PR, so v17 must not ship without this bump.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/service-datasource": minor
4+
"@objectstack/verify": patch
5+
---
6+
7+
feat(runtime,datasource): the default-datasource connect seam accepts a host driver factory — adopt pre-built instances without forking the verdict (#3826)
8+
9+
ADR-0062 D1's open-core convergence (#3869/#3886) left one structural question
10+
open: a host whose `default` needs a driver the shared factory cannot build —
11+
the cloud distribution's `turso`, or an instance pooled BEYOND one kernel (the
12+
cloud control-plane driver doubles as the proxy base of every environment
13+
kernel; per-environment drivers are cached across kernel rebuilds) — had only
14+
two options, both bad: stay on the legacy pre-built `DriverPlugin` path, whose
15+
connect verdict lives in `ObjectQLEngine.init()` (the second implementation
16+
#3826 exists to retire), or fork the connect orchestration. Either re-opens the
17+
#3741#3758 drift this whole line of work is about.
18+
19+
Two additive pieces close it:
20+
21+
- **`DefaultDatasourcePlugin` accepts an injected `IDatasourceDriverFactory`**
22+
(defaults to the shared open-core factory, byte-for-byte unchanged when
23+
omitted). The factory only changes what `create()` returns — the policy-free
24+
init connect, `bootCritical` fail-fast, `OS_ALLOW_DRIVER_CONNECT_FAILURE`
25+
escape hatch, and the start() replay into retained admin state are identical
26+
either way, and the new tests pin that (an adopted instance that cannot
27+
connect takes the exact same verdict).
28+
- **`createPrebuiltDriverFactory(driver, { driverId?, fallback? })`** in
29+
`@objectstack/service-datasource` — the "adopt an existing driver" seam the
30+
first #3826 pass found missing, landed AS a factory so it composes into the
31+
one connect path instead of becoming a second entry point. `create()` returns
32+
the SAME instance every call: construction, pooling, and reuse stay host
33+
concerns; only the verdict converges. Not for the common case — a `default`
34+
expressible as `{ driver, config }` should stay a plain definition.
35+
36+
The `@objectstack/verify` dogfood harness now boots through
37+
`DefaultDatasourcePlugin` (declared `sqlite-wasm` definition) instead of a
38+
pre-built `DriverPlugin` — so the dogfood gate exercises the same declared
39+
-default connect path `objectstack dev`/`serve` use, which is the §Risk
40+
mitigation ADR-0062 promised ("behind the dogfood gate") and did not yet have.
41+
The degraded-boot parity guard stays: `ObjectQLEngine.init()`'s verdict is
42+
still live for the boot re-verification, `DriverPlugin` escape-hatch drivers,
43+
and the cloud compositions until they converge onto this seam.

0 commit comments

Comments
 (0)