Skip to content

Commit 65264f7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16645-analytics-native-sql-authz
Bring the patch round onto a fresh tree so the gate union is derived from current main rather than a 25-commit-old snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
2 parents a3fa4e2 + eabdd66 commit 65264f7

124 files changed

Lines changed: 7627 additions & 670 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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/driver-memory": minor
3+
---
4+
5+
fix(driver-memory)!: an array comparand is refused instead of compared by reference, and a `Date` comparand is compared by time value
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing and an evaluation repair inside one driver's filter gate: no metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed. `packages/spec`'s half of this diff is a docblock sentence and one test comment — `filter-comparand-type.ts`'s list of the cases its door deliberately does not rule, corrected to state what the drivers now do — and no schema member, accepted key or exported symbol moves with it, so `check:api-surface` and `check:authorable-surface` see an unchanged surface. What moves is which FILTERS `@objectstack/driver-memory` accepts, which is authored at a call site or in a stored `where`, never a key `objectstack migrate meta` could visit: there is no old spelling to rewrite into a new one, because the refused shape has no replacement spelling that means the same thing — an array comparand never had agreed semantics to preserve, which is the whole reason the cell is refused rather than converged. Measured on this tree, the authored corpus that could carry the shape holds none of it: a structural scan of every tracked JSON and YAML file with a `where` / `filters` / `filter` object (10 JSON candidates, 1 YAML candidate — the latter a GitHub Actions job named `filter`, not a filter) finds ZERO bare-array comparands, and across the repository's own suites exactly one case authored the shape (`{ code: [] }`, in this package's face-agreement table), moved in this diff to assert the refusal. Whether an out-of-repo host authors one is NOT measured and is not claimed to be zero. -->
8+
9+
`checkCondition` routed both `Date` and `Array` into `value == condition`, under a comment two lines above calling that "exact match" — and a comment seven lines below giving `==`'s reasons, all three of which are about primitives ("undefined/null mismatch", "string/number coercion", "convenient in weakly typed queries"). Between two objects `==` does no conversion at all: it compares REFERENCES. So the arm was neither exact nor a match, and the two adjacent comments described something the code could not do.
10+
11+
**BREAKING** accept-set narrowing on a published driver, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`): a purely additive widening takes at least `minor`, and a narrowing is not the additive bucket, so `patch` is refused here — the public surface really does move. The two halves take DIFFERENT dispositions, and the difference is the contract's rather than this driver's:
12+
13+
**An ARRAY comparand is refused.** `@objectstack/spec`'s comparand door names an array outside `$in`/`$nin`/`$between` as a position it deliberately does not rule, leaving it "to the layers that already answer it"; `ACCEPTED_FILTER_COMPARAND_TYPES` has no array member, and `driver-sql` refuses one with its own message. This driver now answers the same way, in the ADR-0112 envelope its every other filter refusal speaks (`code: 'INVALID_FILTER'`, `status: 400`), with a message naming the field, the received shape, the accepted set and the operators that DO take a list. The refusal is raised from `assertFilterConditionShape` — the one gate every face of this package runs before it evaluates anything — so the live query path, the reference matcher and the analytics face cannot disagree about it.
14+
15+
Why refused rather than made to work, measured on one row `{ tags: ['a','b'] }` with the filter `{ tags: ['a','b'] }` before this change: the live query path returned the row (mingo deep-equals arrays) and the reference matcher returned none (`==` compared references). One filter, one package, two answers, neither reported — the same shape as this package's `{ field: {} }`, malformed-`$between` and non-boolean-`$null` refusals. Converging UP to deep equality was available and is not what landed: the contract declines to rule the cell, and every sibling that has answered it declines the semantics.
16+
17+
**A `Date` comparand is EVALUATED, not refused** — the opposite disposition, because the contract does rule this one. `Date` is a member of `ACCEPTED_FILTER_COMPARAND_TYPES`, and `FILTER_COMPARAND_TYPE_CASES` requires that a Date comparand "must pass the door and execute everywhere". It is now compared by time value, arm for arm with `@objectstack/formula`'s `looseEq` — the sibling record-at-a-time matcher this face's conformance suites are held against — which also settles the case that actually reaches a stored row: a declared `datetime` is canonicalised to ISO TEXT on write (ADR-0053 D-B1), so a `Date` comparand meets a STRING, and `==` stringified the Date to `"Wed Jan 01 2026 …"`, which no ISO value equals. `$eq` / `$ne` take the same equality, so one predicate no longer answers two ways depending on which spelling the author used. An Invalid Date has no time value and so equals nothing, itself included.
18+
19+
**What is deliberately NOT changed**, and is now pinned so a later edit cannot take it away by accident: a SCALAR comparand against a stored ARRAY. `{ tags: 'a' }` against a row storing `['a','b']` still answers false, and `{ tags: 'a,b' }` against that row still answers true, because `==` stringifies the stored array to `"a,b"`. That is a third bad direction of the same operator, but it is on the VALUE side, and the comparand door judges comparands. It is recorded, not repaired. The list operators (`$in` / `$nin` / `$between`), for which an array is the declared comparand, are untouched; so is the text family, whose comparand disposition this package's refusal gate had already recorded as deliberately fail-closed.
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os lint --eval --generator ""` no longer prints a double space in its refusal.
6+
7+
`bundle-require` composes its own refusal as `<filepath> is not a valid JS file`, so an
8+
empty filepath contributes no characters and that fragment arrives with a leading space —
9+
which landed against the space in our own `": "` separator:
10+
11+
```
12+
Failed to load generator "": is not a valid JS file # before, both faces
13+
Failed to load generator "": is not a valid JS file # after
14+
```
15+
16+
The composed message now drops leading spaces from the detail, so the separator carries
17+
exactly one. The empty string still answers through the same door an unresolvable path
18+
answers through — same `catch`, same exit code 1, same one-key `{error}` document on the
19+
`--json` face — and every refusal whose detail does not open with a space is byte-identical,
20+
the unresolvable-path case included.

0 commit comments

Comments
 (0)