Skip to content

Commit 5e4e828

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6394-date-bucket-options-cast
2 parents 9629e6a + 64d764e commit 5e4e828

38 files changed

Lines changed: 3843 additions & 155 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/client": patch
6+
---
7+
8+
feat(spec,metadata-protocol,rest,client): the direct-mount surfaces (`packages`, `datasources/:name/external/*`) become discoverable, and the SDK follows the advertised base (#6633)
9+
10+
The rest surface's `/discovery` never advertised `routes.packages` — routes
11+
mounted but not advertised, the unstated half of ADR-0076 D12 — so the SDK's
12+
`packages.*` always fell back to the hard-coded `/api/v1/packages`; and the
13+
SDK's `datasources.external.*` had no discovery mechanism at all, hard-coding
14+
`/api/v1/datasources/...` in each of its five methods. On any deployment with a
15+
non-default API base, both families built wrong URLs (measured in #6633).
16+
Maintainer ruling 2026-08-08 (route B, prerequisite for #6306):
17+
18+
- **spec** (minor, additive): `ApiRoutesSchema` declares a `datasources` key —
19+
the base of the federation-admin family. Optional like `mcp`: absent = not
20+
mounted.
21+
- **metadata-protocol** (minor, additive): `getDiscovery()` advertises
22+
`routes.packages: '/api/v1/packages'` iff the `package` service is
23+
registered (`serviceToRouteKey` gains the mapping; the route flows through a
24+
non-slot table because `package` is not a `CoreServiceName`). `datasources`
25+
is deliberately NOT advertised by this builder — the mount belongs to the
26+
REST host it cannot see (same disposition as `mcp`).
27+
- **rest** (minor): `/discovery` advertises `routes.packages` and
28+
`routes.datasources` as projections of the RECORDED direct mounts (#5822) —
29+
advertisement and mounting derive from one fact, so #6306's later mount-base
30+
move carries the advertisement along by construction. Not mounted ⇒ not
31+
advertised. An end-to-end parity pin (`discovery-advertised-direct-mounts.
32+
parity.test.ts`) drives the composed surface and goes red on any change that
33+
moves only one side.
34+
- **client** (patch, behavior fix): the five `datasources.external.*` methods
35+
derive their base via `getRoute('datasources')` — connected clients follow
36+
the advertised base; unconnected clients (or servers that advertise no
37+
`datasources` key) keep building byte-identical `/api/v1/...` URLs.
38+
39+
No key is removed and no wire shape changes for existing deployments: servers
40+
gain two advertised keys, and the SDK changes URLs only when a server
41+
advertises the new keys with a non-default base.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the field-level user-root rejection covers all three ADR-0068 spellings, not just `current_user` (#6585)
6+
7+
#6290 gave field-level `visibleWhen` / `readonlyWhen` / `requiredWhen` a
8+
surface-level rejection when the predicate reaches for the signed-in user, with
9+
a prescription that names surfaces which actually bind one. That check matched a
10+
single spelling — `current_user` — while ADR-0068 D1 makes `user` and `ctx.user`
11+
**the same object under different names**: `buildScope` hangs one `EvalUser`
12+
reference on `current_user` / `user` / `ctx.user` / `os.user`. So the identical
13+
semantic error produced an error under one spelling and **total silence** under
14+
the other two (both have always been in `SCOPE_ROOTS`, so the bare-reference
15+
check never fired on them either). Which of three ADR-equivalent spellings the
16+
author happened to pick decided whether they got a build-time diagnostic at all.
17+
18+
The failure direction is the one #6146 named: an unbound root faults, the fault
19+
falls back, and visibility's fallback is `true` — so a predicate written to HIDE
20+
a field by role left it visible to everyone, silently.
21+
22+
All three roots now share one verdict, one prescription and one message; only
23+
the root named in the message varies. Nothing about the option level changes:
24+
per-option `visibleWhen` resolves against the host's predicate scope, which
25+
binds the user under every spelling, so the showcase's role-gated option
26+
(`'admin' in current_user.positions`) stays legal — under the aliases too.
27+
28+
**`ctx` is judged as a whole root, not only in `ctx.user` form.** At this
29+
surface that is simply what is true: `buildScope` creates the `ctx` root *only*
30+
when the evaluation carries a user, and no field-level site passes one — the
31+
server binds `record` + `previous` (+ `parent`) and the client's
32+
`evalFieldPredicate` binds `record` + `previous` + a caller scope that is only
33+
ever `{ parent }`. `ctx.locale` therefore faults exactly like `ctx.user.id`
34+
here. The narrower reading was rejected because it needs a source-level spelling
35+
match, which would re-open this very fork one level down (`ctx["user"].id`
36+
silent, `ctx.user.id` rejected) while leaving a real fail-open fault
37+
unreported. `ctx` remains ActionEngine's predicate root elsewhere and is
38+
untouched there — the platform's own `ctx.user` predicates all sit on action
39+
`visible` (`sys-user.object.ts`, `sys-invitation.object.ts`), a surface this
40+
rule never reads, and that acceptance is pinned.
41+
42+
Sweep: field-level `*When` predicates reading any user root measure **zero**
43+
across `examples/`, `packages/` and the downstream `objectui` repo, by both a
44+
slot-keyed scan and an alias-keyed one — so no shipping metadata is refused by
45+
the widening. `objectstack validate` stays clean on all three example apps.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `ImportRequest.dryRun` states the boundary an import preview has — no automations run (#6537)
6+
7+
An import dry run routes through the engine's own write-path validation
8+
(`DataProtocol.validateData`, #6037 / #4633 ruling D) and applies no
9+
`runAutomations` gate, because gating it would leave the common dry run with no
10+
validation at all — the false all-clear that work set out to close. The engine's
11+
validate-only path deliberately runs **no hooks**: a preview that fired
12+
user-authored side effects (mail, outbound calls, writes to other objects) would
13+
be the retired `BatchOptions.validateOnly` defect (#4052) in a new spelling.
14+
15+
The consequence an author can meet is narrow and, until now, written down only in
16+
the engine's and the import runner's source comments: on an object whose
17+
`beforeInsert` hook derives a **required business field**, a dry run with
18+
`runAutomations: true` can report `required` for a row the real import would have
19+
created. Audit and ownership stamps (`created_by`, `owner_id`,
20+
`organization_id`, …) are `system`/`readonly` and skipped by validation anyway, so
21+
they cannot produce this.
22+
23+
`ImportRequest.dryRun`'s description now says so, which puts it on the reference
24+
page an author reads before sending the request — the same schema backs
25+
`CreateImportJobRequest`, so the synchronous route and the async import job both
26+
carry it. **Zero behaviour change**: one description string, and the
27+
`content/docs/references/api/export.mdx` cells regenerated from it by
28+
`gen:schema && gen:docs` (no hand edits).
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a single-record update binds the row the CALLER named, not the row the body names (#6479)
6+
7+
`PATCH /data/:object/:id` decided which row to write **twice, differently**. The
8+
protocol's `updateData` probed existence and validated `If-Match` /
9+
`expectedVersion` against the path `:id`, built `{ where: { id: request.id } }`,
10+
and then handed the request body to the engine verbatim — where the dispatch
11+
reads the payload first, so a truthy scalar `data.id` outranks `where.id`.
12+
13+
So `PATCH /data/task/rec_1` with a body of `{"id":"rec_2","title":"x"}`:
14+
15+
- probed **rec_1** for existence (404 gate, #4435);
16+
- version-checked **rec_1** against the caller's `If-Match`;
17+
- **wrote rec_2**; and
18+
- answered `{ id: "rec_1", record: <rec_2's readback> }` — a receipt whose two
19+
halves name different rows.
20+
21+
rec_2 was never probed and never version-checked, so the most common client
22+
shape there is — GET a record, edit a field, PUT the whole body back — performed
23+
a **silent cross-row write straight past its own optimistic-concurrency check**
24+
whenever the body carried another row's id (a mis-clicked list row, a stale
25+
refresh, a generated client that copied the wrong field).
26+
27+
`updateData` now merges the path id over the payload before dispatch
28+
(`{ ...request.data, id: request.id }`) — the same shape the **bulk** ingress has
29+
always used for this question (`ql.update(op.object, { ...data, id }, …)`), so the
30+
two ingresses give one answer instead of two. The probed row, the OCC-checked
31+
row, the written row and the receipt's `id`/`record` are now the same row: the
32+
one in the URL.
33+
34+
Nothing else moves:
35+
36+
- **The engine is untouched.** ObjectQL's payload-first dispatch (#5748) and its
37+
by-id payload strip (#6435) are unchanged and still correct for a caller who
38+
hands ObjectQL a payload and nothing else; this was a gap at the REST/protocol
39+
ingress, which had already named the row.
40+
- **No new rejection, no request-shape change.** A body `id` equal to the path
41+
id behaves exactly as before, and a differing one is now simply overridden
42+
rather than refused — `UpdateDataRequestSchema` still accepts the same bodies.
43+
- **Non-record payloads pass through untouched** (`undefined`, `null`, an array),
44+
so the engine's own diagnostics for a malformed call still surface unchanged.
45+
46+
Callers that deliberately relied on the body's `id` redirecting a
47+
single-record PATCH must address the intended row in the URL instead — the bulk
48+
endpoint has never honoured a body id either.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
feat(metadata-protocol): publishing a platform-level scheduled `create_record` flow is refused on a multi-organization deployment unless it declares `organization_id` (#6285)
6+
7+
A scheduled flow that creates records now has to say which organization those
8+
records belong to — but only where the answer matters, and only where nothing
9+
else can supply it.
10+
11+
## What was open
12+
13+
`ScheduleTrigger` builds its context as
14+
`{ event: 'schedule', params: { jobId, flowName, schedule } }` — no `tenantId`.
15+
PR #6153 closed the engine half of #5494 on the rule "stamp what the engine
16+
KNOWS": a run whose trigger resolved an organization carries it through, and the
17+
driver's tenant machinery fills `organization_id` on rows that omit it. A
18+
schedule resolves none, so nothing fills anything — and the dominant production
19+
shape of the whole issue is a nightly sweep, which fires on a schedule and not
20+
by hand. Every row it created was born `organization_id` NULL.
21+
22+
That is not a cosmetic NULL. A `(organization_id, …)` unique index does not
23+
constrain across NULL and an org-scoped query does not see the row, so the
24+
damage is duplicate and invisible records — hotcrm#698's duplicate numbering —
25+
in a stored shape no later fix can retroactively repartition.
26+
27+
## What now happens
28+
29+
At the runtime publish gate, this exact combination is refused with the existing
30+
422 `INVALID_METADATA` envelope (`code` + `status` + `issues[]`, ADR-0112):
31+
32+
- the deployment enforces an organization wall
33+
(`postureEnforcesWall(resolveTenancyPosture())``group` or `isolated`,
34+
ADR-0105 D1), **and**
35+
- the flow is platform-level (the write carries no organization), **and**
36+
- it binds to the **schedule** trigger, **and**
37+
- it contains a `create_record` node, **and**
38+
- that node declares no `fields.organization_id`.
39+
40+
Every limb's negation still publishes: a single-organization deployment, an
41+
org-scoped write, any other trigger, a flow that creates nothing, and — the
42+
fix an author actually applies — a node that declares
43+
`config.fields.organization_id`. That key is not new: `CreateRecordConfigSchema`
44+
has always carried `fields`, and #6153's fill-only stamping already guarantees
45+
an author-supplied value wins over any engine fill. One issue is reported per
46+
offending node, including nodes nested inside `loop` / `try_catch` / `parallel`
47+
regions, each addressed at the key the author must write.
48+
49+
Drafts are never gated (#4463 D1) and the draft to active promotion is, so the
50+
draft door is not a bypass. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` degrades the
51+
refusal to a loud log exactly as it does for the 26 shared rules, and
52+
`os migrate meta --stored` stays carved out.
53+
54+
## Where the judgement lives, and why
55+
56+
Runtime publish gate only; `os validate` / `os build` / `os lint` do **not**
57+
judge this. Both inputs the rule needs are facts about the **deployment**, and
58+
the CLI runs on a build machine — a shared rule would sentence every
59+
single-organization repository on whatever `OS_TENANCY_POSTURE` happened to be
60+
exported in CI. The gate's caller performs the two readings and passes them as
61+
arguments, so the judgement itself stays a pure function of its inputs.
62+
63+
Migration note for a multi-organization deployment: an existing scheduled flow
64+
keeps running untouched — the gate blocks new writes only, never stored rows —
65+
but the next time one is republished it will be refused until the
66+
`organization_id` is declared, which is the same edit that stops it writing
67+
outside the organization partition.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the `retryDelayMs` tombstone no longer points an upgrading author at the retired ETL surface (#6630)
6+
7+
#6414 retired the whole L2 ETL layer (`automation/etl.zod.ts`: `ETLPipeline`,
8+
`ETLPipelineRun`, their source/destination/transformation vocabulary, four enums
9+
and the `ETL` factory — 9 defs, 27 exported names). `shared/retry-policy.zod.ts`
10+
was updated in exactly one stanza and left every other mention of
11+
`ETLPipeline.retry` in the present tense — including the one string that is not
12+
documentation.
13+
14+
**The author-visible half.** `retryDelayMs`'s `retiredKey()` guidance is the
15+
upgrade channel `shared/retired-key.ts` describes in its own words ("an agent
16+
bumping `@objectstack/spec` sees THIS string, not our docs site"). It enumerated
17+
four surfaces on which the converged spelling applies, and the fourth — "an ETL
18+
pipeline's `retry`" — had been deleted in the same major. An author or agent
19+
migrating `retryDelayMs``backoffMs` was therefore told, by the platform's own
20+
upgrade prescription, that a surface exists where `tsc` now reports TS2724/TS2305.
21+
The message now names exactly the three surfaces that still carry the policy:
22+
23+
```
24+
… the retry policy now has ONE spelling for its base delay across every surface
25+
that carries it: `job.retryPolicy`, a `try_catch` node's `retry` and
26+
`flow.errorHandling`. Rename the key to `backoffMs`; …
27+
```
28+
29+
The prescription itself is unchanged — same rename, same value semantics, same
30+
`os migrate meta --from 16` pointer — and a new pin in
31+
`shared/retry-policy.test.ts` now asserts the enumeration in both directions
32+
(every live surface named, no retired one named) so the string cannot drift wide
33+
again unobserved.
34+
35+
**No acceptance change.** `retryPolicyShape()` and `RetryPolicySchema` keep their
36+
exact key sets, bounds and defaults; `retryDelayMs` is still rejected, still with
37+
a prescription. Everything else in this change is comment text: the module TSDoc
38+
in `shared/retry-policy.zod.ts` (the four-surfaces arithmetic, the strict-surface
39+
counts, and the paragraph that claimed a `maxAttempts` tombstone carries the ETL
40+
migration — it does not; the tombstone went with the shape that carried it, and
41+
`RETIRED_DEFS_BY_MAJOR` plus the D3 `etl-pipeline-layer-retired` entry are the
42+
declaration) and the matching stanza in `conversions/registry.ts`.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
fix(runtime): a `/share-links` permission denial answers 403, not 500 (#6649)
6+
7+
The dispatcher's `/share-links` domain ended in a hand-written catch that read
8+
one status channel:
9+
10+
```
11+
return sendErr(err?.status ?? 500, err?.code ?? 'INTERNAL', err?.message ?? '…');
12+
```
13+
14+
Every refusal `ShareLinkService` raises itself carries `status` (its `makeError`
15+
sets `status` + `code`), which is why the 403 `FORBIDDEN` and 422
16+
`SHARING_NOT_ENABLED` answers were always correct. But the refusals that come
17+
out of the **security middleware** do not come from that service. Creating a
18+
link performs a visibility read — `svc.createLink` calls
19+
`engine.find(object, { context })` — and when the caller's permission sets grant
20+
no `allowRead` on the object, the CRUD gate throws
21+
`PermissionDeniedError { code = 'PERMISSION_DENIED'; statusCode = 403 }`, a class
22+
with **no `status` field at all** (`plugin-security/src/errors.ts`; runtime's own
23+
mirror in `security/resolve-execution-context.ts` has the same shape).
24+
`ShareLinkService` does not catch it, so it reached the domain catch, `err?.status`
25+
was `undefined`, and a 403-class refusal left as **HTTP 500** while `error.code`
26+
faithfully read `PERMISSION_DENIED`.
27+
28+
That envelope contradicted itself, and the contradiction is load-bearing on the
29+
client: 5xx is retryable to many SDKs and browser clients, so a permanent
30+
authorization answer was being retried, and a caller branching on the status saw
31+
"the server is broken" where the truth was "you may not read this record". It is
32+
reproducible on either tenancy posture, and — because `registerShareLinkRoutes:
33+
false` makes this domain the ONLY share-link surface on cloud's per-environment
34+
kernels — it is the primary surface there, not a fallback one.
35+
36+
The catch now exits through `deps.errorFromThrown`, the dispatcher's shared
37+
thrown-error mapper that `/meta`, `/actions` and `/mcp` already use. It reads
38+
`status` **or** `statusCode`, and it carries a thrown error's structured
39+
`issues` / `fields` details through instead of collapsing them to a message.
40+
Reaching for the shared mapper — rather than widening the hand-written chain to
41+
`err?.status ?? err?.statusCode ?? 500` — is the part that stops this exit
42+
re-diverging: a second hand-written copy is how the two drifted apart in the
43+
first place.
44+
45+
Two wire-visible consequences, both corrections:
46+
47+
- A permission denial on `POST` / `GET` / `DELETE /share-links` answers **403
48+
`PERMISSION_DENIED`** where it answered 500 `PERMISSION_DENIED`. Clients
49+
treating 5xx as retryable stop retrying a permanent refusal.
50+
- A throw carrying neither status channel nor a code answers **500
51+
`INTERNAL_ERROR`** where it answered 500 `INTERNAL`. `'INTERNAL'` was never
52+
registered for `@objectstack/runtime` in `ERROR_CODE_LEDGER` (only `rest`,
53+
`service-storage`, `service-i18n` and `plugin-sharing` register it, and the
54+
ledger's per-package rows are provenance) — so this domain was emitting a code
55+
it had not registered, and the required field is now filled by the catalogued
56+
derivation every other dispatcher exit uses (ADR-0112).
57+
58+
Refusals that already carried `status` are untouched: the mapper reads that
59+
channel on the same first branch the old chain did.

0 commit comments

Comments
 (0)