Skip to content

Commit c4beecb

Browse files
committed
Merge origin/main into claude/global-actions-unreachable-rw7hpk
Integrates two main-side changes that landed mid-flight, both touching the same contract this branch changes: - ADR-0110 (#3958): action identity is `name`, undeclared executables refuse. Its new tests asserted the pre-#3962 double envelope; updated to the single wrap, and its handler-rejection case ("NOT a 404 routing miss") now asserts the 400 — the distinction it exists to pin is 400-vs-404, unchanged. - #3971: the dispatcher's `error.code` is the semantic string and `details.code` is PROMOTED into it. Assertions on `error.details.code` moved to `error.code`; `fields` stay in `details`. Conflicts resolved in ui/actions.mdx (kept main's name-vs-target paragraph + this branch's "failures speak HTTP" contract) and the type-dispatch test (kept main's ADR-0110 undeclared/valve/degraded cases, adjusted the valve run's envelope to the single wrap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F
2 parents cf83620 + c5c78bb commit c4beecb

113 files changed

Lines changed: 4733 additions & 473 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: 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: 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: 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: 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.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/client": patch
5+
---
6+
7+
fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842)
8+
9+
`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it
10+
straight into the field `ApiErrorSchema` reserves for a semantic string, so
11+
`error.code` came back as `400`/`403`/`503` — a number, duplicating the response
12+
status and occupying the one slot a caller is meant to branch on. The real code
13+
then had to go somewhere else, and did, three somewhere-elses: `details.code`
14+
(auth gate, permission denial, anonymous deny), `details.type`
15+
(project-membership gate), and `error.type` (`routeNotFound`). Four sites, three
16+
parking spots, because the declared one was full.
17+
18+
**FROM → TO on the wire.** A dispatcher error body
19+
20+
```json
21+
{ "success": false,
22+
"error": { "message": "", "code": 403, "details": { "code": "PERMISSION_DENIED" } } }
23+
```
24+
25+
is now
26+
27+
```json
28+
{ "success": false,
29+
"error": { "code": "PERMISSION_DENIED", "message": "", "httpStatus": 403 } }
30+
```
31+
32+
| Reading | Was | Now |
33+
|---|---|---|
34+
| semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` |
35+
| HTTP status | `error.code` | `error.httpStatus` (or the response status) |
36+
| context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) |
37+
38+
**One-line fix for a direct reader:** replace `body.error.details?.code ??
39+
body.error.type` with `body.error.code`, and `body.error.code` with
40+
`body.error.httpStatus`. **SDK callers need no change**`ObjectStackClient`
41+
already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still
42+
reads the old shape, so a client newer than its server is unaffected.
43+
44+
Every code already on the wire moves **verbatim**`PERMISSION_DENIED`,
45+
`ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`,
46+
`VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not
47+
rename anything. Reconciling the repo's two code vocabularies is #3841, and this
48+
leaves it exactly one map and one enum to sweep instead of four parking spots.
49+
50+
A branch with no code of its own is served one derived from the status, via the
51+
single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`
52+
in `@objectstack/spec/api` (`403``permission_denied`, `503`
53+
`service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code`
54+
is required; drawing it from `StandardErrorCode` keeps a derived code a
55+
catalogued one rather than an invented string.
56+
57+
**Spec changes:**
58+
59+
- `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is
60+
`EnhancedApiErrorSchema.httpStatus`. Additive.
61+
- `StandardErrorCode` gains `method_not_allowed` and `precondition_required`,
62+
the two statuses the runtime returns that the enum could not name. Additive.
63+
- **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string
64+
spellings of HTTP statuses, for matching against the numeric `error.code`). It
65+
is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' |
66+
'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum
67+
declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO
68+
`DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read
69+
`error.httpStatus`. TypeScript flags every call site.
70+
- **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()`
71+
(was `z.number().int()`), `error.type` is **removed** (folded into `code`), and
72+
`error.httpStatus` / `error.details` are declared. This schema is what
73+
legitimised the deviation — it declared the opposite of `ApiErrorSchema` for
74+
the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO
75+
`{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`.
76+
77+
**Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s
78+
`errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405.
79+
`errorResponseBase` previously discarded a thrown error's `.code` outright — it
80+
had nowhere to put it — so the two exits of one surface disagreed about what a
81+
caller would see; they now agree. Every body on this surface is built by one
82+
helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by
83+
`error-envelope.conformance.test.ts`: each branch driven and parsed against the
84+
schema imported from `packages/spec`, plus a source scan so a new branch cannot
85+
quietly reintroduce a numeric `code` or a `type`-as-code sibling.
86+
87+
This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be
88+
deleted rather than updated once the dispatcher was fixed.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
chore(spec): the empty-state gate now scans platform-object definitions, where #3896 actually happened
6+
7+
#3945 added a gate requiring any *"empty = permissive"* statement in the spec to
8+
be classified on purpose. It scanned `packages/spec/src/**/*.zod.ts` — and that
9+
scope had a hole big enough to miss the bug it was built for.
10+
11+
The sentence that shipped #3896, *"leave empty to share every record"*, was the
12+
`description` of `sys_sharing_rule.criteria_json`, which lives in
13+
**`plugin-sharing`**. The gate could not see its own crime scene.
14+
15+
**Now scans `**/*.object.ts` anywhere under `packages/`** — plugins,
16+
`platform-objects`, `metadata-core`, and the `create-objectstack` templates
17+
(a starter file is the highest-leverage thing a model copies from). 214 → 290
18+
files.
19+
20+
**It immediately found a real one.** `sys_user_permission_set.organization_id`
21+
declares *"NULL = applies in every org context"*: a user↔permission-set grant with
22+
no org scope applies everywhere. That is deliberate and load-bearing rather than
23+
an oversight — ADR-0095 D3 / ADR-0068 D2 derive the `platform_admin` posture from
24+
an **unscoped** `admin_full_access` grant specifically, and an org-scoped grant of
25+
the same set must not confer it. So the empty state is not merely wider, it is the
26+
distinguishing input to the highest privilege in the system. Registered `open`
27+
with that rationale and both enforcement sites cited, which is the point: the
28+
answer now lives somewhere other than a maintainer's memory.
29+
30+
Three fixes the new surface forced, each a case of the gate being wrong in a way
31+
that mattered:
32+
33+
- **Repudiated prose no longer fires.** #3929's own comment on `criteria_json`
34+
reads *Deliberately NOT "leave empty to share everything"* — the gate flagged the
35+
sentence recording why the gate exists. Negation is now handled for the
36+
imperative form as well as the token form (`deny-all`), and the escape is
37+
deliberately narrow: the negator must be attached to the phrase, not merely
38+
present in the line, because a false negative here is a missed over-share.
39+
- **The owning property is found by nesting, not by a name list.** A field's prose
40+
sits in a nested key, so the first attempt answered `description` for every
41+
platform-object hit; skipping doc slots then answered `required`, the sibling
42+
above it. What separates a field from its own config is indentation, so the
43+
resolver now takes the nearest key at a shallower indent.
44+
- **The property-search window is per-surface.** A platform-object `description:`
45+
can sit 15+ lines below its field name; a `.zod.ts` statement sits beside its
46+
property. Widening globally would let `.zod.ts` narrative be mis-attributed to a
47+
distant property — turning a correct non-failing note into a wrong failure — so
48+
`.object.ts` gets a wider window and the schema surface keeps its tight one.
49+
50+
Also makes evidence resolution honest: entries are now parsed with the liveness
51+
ledger's own `checkEvidence`, so prose around the paths works, several paths can
52+
be cited, and another repo's path (`objectui: …`) is recorded without being
53+
resolved here. The README already promised evidence resolved "like the ledger's";
54+
a single raw-path `existsSync` quietly did not.
55+
56+
6 new unit tests (32 total). No runtime behaviour changes.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): findData must not take its execution context from the request (#3960)
6+
7+
Came out of the #3946 sweep's leftover question — whether `expand`'s "advanced
8+
usage" (a caller-supplied `Record<string, QueryAST>` whose sub-ASTs each carry an
9+
`object`) is a cross-object read channel. **It is not**, and that needs saying
10+
because the answer is load-bearing: `expandRelatedRecords` takes its target from
11+
the parent schema (the expand KEY must be a real `reference` field; the sub-AST's
12+
`object` is never read), re-enters `engine.find` so the referenced object's RLS +
13+
FLS both run, `$and`-merges a nested `where` instead of spreading it over the id
14+
filter, and caps depth. No change needed there.
15+
16+
What the investigation did turn up is one layer down. `findData` built its engine
17+
options as `{ ...request.query }` and then assigned `context` from
18+
`request.context` **conditionally**:
19+
20+
- `request.query` is the caller's raw bag on every ingress — the REST
21+
`POST /data/:object/query` route passes `req.body` straight in as `query`;
22+
- `context` sits in the known-params set, so it was not swept into the
23+
implicit-filter bucket either — it survived the spread untouched;
24+
- so when no server context resolved, the caller's `context` *became* the
25+
operation's execution context.
26+
27+
Everything hangs off that value. plugin-security's middleware opens with
28+
`if (opCtx.context?.isSystem) return next()` — the entire RLS / FLS / CRUD chain
29+
skipped — and `__expandRead: true` collects the #2850 waiver on the object-level
30+
CRUD gate. Neither is ever schema-stripped on the read path:
31+
`ExecutionContextSchema.parse` runs only in `engine.createContext`, which reads
32+
do not use.
33+
34+
Route-level `enforceAuth` is what kept this unreachable: anonymous data requests
35+
are refused unless a deployment sets `requireAuth: false`. That makes it a
36+
fail-OPEN default rather than a live exploit — and not something the protocol
37+
should delegate upward. `findData` now drops any inbound `context`
38+
unconditionally before the assignment, so the execution context can only come
39+
from `request.context`.
40+
41+
Verified end-to-end at the protocol layer (a forged
42+
`{ isSystem, userId, __expandRead }` reached `engine.find` verbatim before, is
43+
dropped after). The anonymous HTTP reachability half is NOT verified — see #3960
44+
for exactly what was and was not reproduced. No caller regresses: the only
45+
in-repo builder of these args (`rest/src/import-runner.ts` `findArgsBase`) passes
46+
`context` at the top level, never inside `query`.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(cli,driver-sql): `os migrate plan` lists the datetime storage convergence (#3954)
7+
8+
The datetime canonicalisation (#3912/#3942) added two steps to `initObjects`'
9+
physical path: a row-rewriting backfill on SQLite and a `TIMESTAMP`
10+
`DATETIME(3)` column rebuild on MySQL. Both already respected the DDL deferral,
11+
so `plan` performed neither and `apply` performed both — the behaviour was never
12+
wrong. The reporting was.
13+
14+
`PendingSchemaWork` could only express `create_table` / `add_columns`, so an
15+
operator saw a plan listing two added columns, confirmed it, and `apply`
16+
additionally rewrote every row of a datetime column — or took a metadata lock to
17+
rebuild one on a large table. The plan promises to show what apply will do.
18+
19+
- `PendingSchemaWork.kind` gains `normalize_datetime_storage` and
20+
`widen_datetime_columns`, plus an optional `rows` carrying how much data the
21+
step touches: row-writes for the backfill, the table's size for the rebuild —
22+
the number that decides "now" versus "in a maintenance window".
23+
- `previewDeferredSchemaWork()` measures both without performing either, reusing
24+
the exact predicate each migration uses (the backfill's whole `WHERE`, the
25+
widening's own `information_schema` filter) so the plan and the apply cannot
26+
name different sets. A probe that cannot run is swallowed to "unlisted", never
27+
to a failed plan.
28+
- The CLI renders them under their own heading rather than folding them into the
29+
additive section, whose "created when you apply" framing carries an implicit
30+
promise that the work is never data-losing. `summarizePendingSchemaWork` — the
31+
line read just before typing `y` — never omits in-place work.

0 commit comments

Comments
 (0)