Skip to content

Commit b3269d5

Browse files
committed
Merge origin/main into claude/issue-16649-register-remaining-boot-refusal-codes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
2 parents 3acd66b + c930f85 commit b3269d5

42 files changed

Lines changed: 2618 additions & 125 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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
An action whose caller-scope record load was DENIED is now refused at every action door, not at one of the three.
6+
7+
`loadActionSubjectRecord` computes one verdict — `recordLoadDenied` — for every door, and exactly one door consumed it as a refusal: the declarative update. The flow door and the script/body door spread the same verdict into the context as a field and proceeded. So MCP `run_action` on a `type: 'flow'` action answered `ok: true` and started a persisted run for a `recordId` the caller cannot read — and, identically, for an id that names nothing at all — while `get_record` answered "not found" and `update_record` answered "no access" for that same id in the same session. Nothing in the response told the calling agent the row had not been delivered.
8+
9+
Both remaining doors now consume the verdict, on both surfaces (the REST `/actions` route and the MCP `run_action` bridge), through one shared refusal:
10+
11+
- **What is refused.** A row-scoped invocation whose caller-scope load was attempted and did not deliver the row. The refusal lands before the automation run is created and before a trusted, RLS/FLS-bypassing action body is entered — not after, which would answer an error with the run already persisted.
12+
- **The envelope is the shared not-found one**`RECORD_NOT_FOUND`, 404, the same `recordNotFoundError` the read path and the declarative door already answer. Not a 403 and not a new "denied" code: the read path collapses "filtered out by row-level security" and "this id names nothing" on purpose, so answering the two differently would make this door disclose existence where every other door declines to.
13+
- **Record-less and new-record actions are unchanged.** The verdict can only be `true` when a load was actually attempted — a `recordId` was supplied and the action key is not object-less — so an object-less ("global") action and an invocation with no `recordId` never reach the refusal, and both still receive the `recordId` stamp on `ctx.record` exactly as before. The predicate is the load's own verdict, deliberately not the `locations`-derived `requiresRecord` of an action listing, which an author may omit entirely.
14+
15+
`AutomationContext.recordLoadDenied` and the handler-side `ctx.recordLoadDenied` are untouched and still populated by the same producer; an author guard written against either keeps working. What changed is that the platform no longer depends on that guard being written.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
docs(automation): `sys_automation_run` says why `failed` has no column of its own, and `summary_json` names it (#15606)
6+
7+
`FlowRunSummary` carries five run-level totals. Four of them —
8+
`selected_count`, `acted_count`, `skipped_count`, `unmeasured_count` — have a
9+
column on `sys_automation_run`; `failed` rides inside the `summary_json` blob.
10+
That asymmetry was filed as a finding and ruled on (decision batch #76,
11+
2026-09-07) rather than closed by adding a fifth column, and this change is the
12+
ruling: the reasoning now ships in the schema instead of living only on the
13+
card.
14+
15+
The four are columns because ONE filter expression needs them in ONE row —
16+
`selected_count > 0 AND acted_count = 0`, qualified by `unmeasured_count` — and
17+
a `WHERE` clause cannot reach into a JSON blob for an operand, so every operand
18+
of that expression has to be a column or the expression cannot be written at
19+
all. `failed` is not one of its operands: it would be its own predicate
20+
(`failed_count > 0`), nobody alerts on it today, and a caller that wants it has
21+
already fetched `summary_json`.
22+
23+
What a consumer sees change:
24+
25+
- `summary_json`'s `description` now names `failed` as the field to read
26+
lost-row counts from, states that the run-level totals live in the blob
27+
alongside the per-node breakdown, and repeats the `unmeasured`/`failed`
28+
convention that an absent count means "not tracked", never zero. ⚠️ This is
29+
why the change carries a changeset and NOT `skip-changeset`, and it was
30+
MEASURED rather than assumed from "it's only prose": `SysAutomationRun` is
31+
re-exported from `src/index.ts`, `package.json` publishes `files: ["dist"]`,
32+
and after `pnpm --filter @objectstack/service-automation build` the new
33+
description text is present in BOTH published entry points — one hit each in
34+
`dist/index.js` and `dist/index.cjs`. `skip-changeset` is for a diff that
35+
publishes nothing from any released package; this one changes bytes inside a
36+
released package's shipped bundle, so it does not qualify. (`description` is
37+
also what the authorable `help` / `helpText` keys alias onto in
38+
`packages/spec/src/data/object.zod.ts` — documentation a consumer surface can
39+
render, not an internal note.)
40+
- The comment above `selected_count` — the paragraph that explains why the
41+
four are columns, and therefore the paragraph a reader is in when they
42+
notice the fifth is not — now carries the verdict for `failed` and the one
43+
condition that re-opens it: the first real need to ALERT on "which runs lost
44+
rows this week" is the card that adds `failed_count`, mirroring
45+
`unmeasured_count` (null on rows written before the column existed, never
46+
`0`) — one column on an ADR-0103 engine-owned object, a human-floor change.
47+
- `ObjectStoreSuspendedRunStore`'s terminal-row write, where a fifth
48+
`record.summary?.failed ?? null` line would go, points at that verdict so the
49+
question is not re-derived from the write site either.
50+
51+
No schema shape moves: no field is added, removed or renamed, no type or
52+
`required` flag changes, and the accepted set of every object and payload is
53+
byte-for-byte what it was. `sys-automation-run-failed-count-verdict.test.ts`
54+
pins both halves — that there is still no `failed_count` (or any other
55+
`fail`-named) column, and that `summary_json`'s description still names
56+
`failed` — so the explanation cannot rot into a claim the schema no longer
57+
supports.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
A blueprint nav entry can say WHICH view it opens: `viewName` is added to `BlueprintNavItemSchema` and, in lockstep, to the strict mirror's `StrictNavItem` (required-but-nullable, per the strict convention).
6+
7+
Without it the shape could only say which OBJECT an entry opens, so a model that had just designed a kanban and wanted it in the menu had one move left: emit a SECOND entry at the same `target` and carry the intent in `label`/`icon` alone. Both entries then opened the object's default view, and the consumer derived both ids from the target, so they collided — the user clicked 「工单看板」 and got the list, with nothing to see anywhere (the target object really exists, so a dangling-target lint has nothing to say). The runtime nav item could always express this — `ObjectNavItemSchema.viewName` is "Default list view to open" — so the gap was the blueprint's alone, and the model's duplicate entry was the reasonable move under the expressiveness it was given.
8+
9+
`viewName` is deliberately NOT `.regex(SNAKE_CASE)` on either side, unlike `target`. A view answers to two interchangeable spellings — the bare key a blueprint's `views[].name` carries and the qualified `<object>.<key>` a staged view record's `name` carries — and consumers normalize between them. Constraining the leaf would make one spelling legal to GENERATE and illegal to APPLY, the failure mode that once refused an already-approved blueprint wholesale.
10+
11+
The key-parity pin between the strict mirror and the lenient schema is widened a level further out — fields → objects → NAV ITEMS — so the next nav-level divergence fails a test rather than shipping as "the lenient side accepts a key no proposal can contain".
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
fix(driver-turso)!: `timeout` beside an UPPERCASE `WSS://` / `WS://` url in forced remote mode is refused at construction, closing the last corner of the same gap (ADR-0049 enforce-or-remove)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed at the driver constructor: no key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `TursoDriverConfig.timeout`, `url` and `mode` keep their names and types, and `TursoConfigSchema` is untouched. What moves is which CONFIGURATIONS `new TursoDriver()` accepts — one predicate now compares the url's scheme case-insensitively, exactly as `@libsql/client` itself does before routing — so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The refusal is the one the lowercase spelling already produces, naming the key, the scheme it met and both ways out; which of the two an author wants is authoring intent no ledger line can decide. -->
8+
9+
The refusal that closed `timeout` beside a `wss://` / `ws://` url matched the two schemes **literally**, so one composition still constructed with a window that reaches nothing:
10+
11+
```ts
12+
new TursoDriver({ url: 'WSS://db.example.turso.io', mode: 'remote', timeout: 30000 })
13+
```
14+
15+
Reading `@libsql/client`'s routing switch alone says that cannot happen — the switch really does match the literal lowercase (`lib-esm/node.js`: `config.scheme === "wss" || config.scheme === "ws"`). But the switch never sees the url as the author spelled it. The node entry is `_createClient(expandConfig(config, true))`, and `expandConfig` has already lowercased the scheme by then — `@libsql/core@0.17.4`, `lib-esm/config.js`: `const originalUriScheme = uri.scheme.toLowerCase();`. Executed against that version: `expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'`, and `'Ws://127.0.0.1:8080'``'ws'`. So an uppercase `WSS://` url does reach the WebSocket client, which takes no `fetch` and no timeout option of its own — the driver constructed, connected, and ran unbounded.
16+
17+
**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside an uppercase-or-mixed-case `wss://` / `ws://` `url` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and **the same message the lowercase spelling already produced**, echoing the scheme in the caller's own casing so an operator can grep their config for what they actually typed.
18+
19+
**The explicit `mode: 'remote'` is load-bearing.** Without it an uppercase url falls through `TursoDriver.detectMode` to `'local'` — behaviour that predates the refusal entirely and is **unchanged here**. Only the window predicate folds case; the mode detector is deliberately left case-sensitive, and the code says so at the predicate, because folding it there too would delete that fall-through: a mode-detection change on a published driver, which must be argued on its own rather than slipped in as a tidy-up.
20+
21+
**What stays accepted — the refusal is no wider than the gap**, pinned by controls:
22+
23+
- an uppercase url with **no** explicit `mode` still detects as `'local'`, with or without a `timeout`;
24+
- the uppercase WebSocket url with no `timeout`, or with `timeout: 0` (the documented "no bound");
25+
- `https://` / `HTTPS://` / `LIBSQL://` / `HTTP://` remote urls **with** a window — the HTTP arm is bounded, so every casing of every HTTP-side scheme keeps the key;
26+
- the existing lowercase refusals, unchanged in code, message and envelope.
27+
28+
**What an affected author does.** Unchanged from the lowercase case, and the refusal text says it: keep the window and spell the url `libsql://` or `https://` (bounded — the client resolves `libsql://` to HTTPS), or drop the window and run the WebSocket remote unbounded, as it always did.
29+
30+
Blast radius, measured on this tree: no in-repo deployment, example, test or doc pairs an uppercase remote scheme with a window; the host boot path (`OS_DATABASE_URL`) forwards only `url` and `authToken`, and the datasource seam's `buildTursoDriverConfig` normalises no casing either — so the pair is reachable in principle from both and is not observed in this repository. Whether any out-of-repo deployment spells a Turso url with an uppercase scheme is NOT measured and is not claimed to be zero.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os lint --eval --json` no longer leaks esbuild's own diagnostics to stderr while loading a `--generator` module.
6+
7+
A `--json` invocation is a machine face, and its stdout document was already well-formed — but the `--generator` load runs through `bundleRequire`, and esbuild's logger writes straight to stderr from inside that call, before anything throws. The `catch` that builds the one-key `{error}` document therefore never got a chance to suppress it, and a caller who asked for JSON got an internal bundler's diagnostic on the human channel alongside it.
8+
9+
Measured on `bin/run-dev.js` with `NO_COLOR=1`, two runs that both leaked:
10+
11+
- an unresolvable `--generator` path: exit 1, a well-formed `{error}` on stdout, and `✘ [ERROR] Could not resolve "<path>"` on stderr;
12+
- a generator that bundles and loads *successfully* but makes esbuild warn: exit 0, the full live eval report on stdout, and 340 bytes of `▲ [WARNING] …` on stderr. Nothing throws on this path at all, so no error handling was ever involved.
13+
14+
The load now passes `esbuildOptions: { logLevel: 'silent' }`, scoped to that one call site and applied only when `--json` is set.
15+
16+
- **The refusal is unchanged.** `logLevel` governs whether esbuild *prints*; it still throws its `BuildFailure` with `errors` populated, and that text already forms the tail of the `{error}` string on stdout. Both stdout documents above are byte-identical before and after.
17+
- **The human face is untouched**, by construction rather than by restating a default: without `--json` no `esbuildOptions` is passed at all. `os lint --eval --generator <bad>` still prints esbuild's line on stderr exactly as before.
18+
- **What is suppressed beyond the leak itself:** under `--json`, an esbuild *warning* on a generator that loads fine now reaches nothing. A warning is not thrown, so no handler carries it onto stdout. This is inside the defect rather than beyond it — the machine face is not a place for human-channel output — but a `--json` consumer that was reading stderr for bundler warnings will no longer see them.
19+
- The other `bundleRequire` callers in the CLI (`os serve` / `os dev`, config loading, scaffold validation) are not affected and keep their diagnostics.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
`GET /organization/list-user-invitations` now honours the declared `requireEmailVerificationOnInvitation` — the per-user invitation inbox works for the unverified sessions it was declared open to
6+
7+
`AuthManager` constructs better-auth's organization plugin with `requireEmailVerificationOnInvitation: false` on purpose: without a mailer wired in, nothing can ever verify an invitee, so requiring verification would dead-end every invite flow. The pinned better-auth 1.7.2 reads that option on `accept-invitation`, `reject-invitation` and `get-invitation`, but its `listUserInvitations` handler refuses every unverified session unconditionally. Measured on the real pipeline: the same unverified invitee got `200` from all three id-addressed routes and `403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION` from the listing, so on exactly the deployment shape the declaration exists for, an invitee could accept an invitation they were handed but never list it, and the SDK's `organizations.invitations.listMine()` inbox page was empty-by-403 for every user.
8+
9+
The endpoint is now rebuilt in place on the organization plugin's own `endpoints` record, from the vendor endpoint's own options object (same path, method, query schema and OpenAPI entry), with one predicate changed: the verification refusal is asked against the declared option instead of assumed. The listing itself is still the vendor's own `getOrgAdapter(...).listUserInvitations(sessionEmail)` — invitations addressed to the session's email, pending only — so nothing widens beyond what the same session can already accept one by one. A client-side `?email=` is still refused with the vendor's `400`, and a request with no session keeps the vendor's `400`.
10+
11+
Declared `true` keeps today's refusal byte-for-byte; an undeclared option keeps the vendor's list-route posture (refuse) rather than re-deriving the vendor-internal default the sibling routes use. No new public error code, no new export from the package entry.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
The manifest `permissions` block's unknown-key refusal now names the surface and offers the rename, like every other block on the manifest.
6+
7+
`PluginPermissionsSchema` decides which services, hooks, network hosts and filesystem paths a plugin may touch. It has refused unknown keys since it was introduced, but through zod's own bare message: an author who transposed `hooks` as `hoooks` read `Unrecognized key: "hoooks"` — the key echoed back, with no surface name and no suggested spelling — while every neighbouring block on the same manifest (`contributes`, `contributes.kinds[]`, `engines`, the legacy `engine`, and the manifest root itself) named all three. Born closed at the ADR-0025 plugin-distribution work, it never passed through the unknown-key campaign that gave the others their error maps.
8+
9+
It now uses the same `strictObject` helper as its neighbours, so the refusal reads:
10+
11+
```
12+
Unrecognized key(s) on the `permissions` block of this package manifest: `hoooks`.
13+
Did you mean `hoooks` → `hooks`? …
14+
```
15+
16+
Three spelled-out near-misses that edit distance cannot reach are curated as aliases: `filesystem` and `paths` point at `fs`, and `hosts` points at `network`.
17+
18+
**The accept set does not move.** `strictObject` is `z.object(shape, { error }).strict()` — the declared keys and the strictness are unchanged, and an error map is consulted only once an issue is already being raised. The `permissions` union keeps both arms (the legacy flat string list and the structured block), and the union itself is untouched. Only the text of a refusal that already happened is different.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
Metadata forms i18n: the `object.fields.reference` help text now carries the
6+
`tree` rule in Spanish, Japanese and Chinese, and no longer claims the field is
7+
for `lookup` / `master_detail`.
8+
9+
The English source for this row gained a normative sentence on 2026-09-05 — a
10+
`tree` field's `reference` is optional and, when present, must name the
11+
declaring object; a link to a different object is a `lookup`. That rule is
12+
enforced at parse time, so an author who writes a foreign target meets it as a
13+
refusal rather than as guidance.
14+
15+
The three translated locales still served the pre-2026-09-05 sentence. They
16+
were wrong in both directions at once: they dropped the `tree` rule entirely,
17+
and they asserted a purpose the source no longer states — "(para
18+
lookup/master_detail)" / "(lookup/master_detail 用)" / "(用于 lookup /
19+
master_detail)" — which the `tree` case contradicts. A Spanish, Japanese or
20+
Chinese console therefore told the author that `reference` was for the two
21+
relationship types that exclude `tree`, and gave no hint of the constraint they
22+
were about to hit.
23+
24+
Only the three `helpText` values move. The `label` siblings, the key set and
25+
the generated structure are unchanged.

0 commit comments

Comments
 (0)