Skip to content

Commit 8498d49

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/showcase-action-visible-matrix
2 parents a49562e + 42af12f commit 8498d49

834 files changed

Lines changed: 68404 additions & 20159 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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/runtime": major
3+
---
4+
5+
feat(runtime)!: action body `ctx.session` emits `positions` (canonical) alongside the deprecated `roles` (#5613)
6+
7+
`buildActionSession()` — the one producer of the action-body `ctx.session` — now
8+
emits the caller's position names under **both** `positions` (canonical) and
9+
`roles` (deprecated alias), with the same array under both names. This is the
10+
**runtime half** of #5613 phase 2 under the maintainer's contract-first ruling
11+
("C skeleton + A semantics"); the spec half (#5779) declared the shape this now
12+
produces, and phase 1 (#5697) declared the shape it produced before.
13+
14+
**What was wrong.** The builder copied `ExecutionContext.positions` into a key
15+
spelled `roles` — the one spelling ADR-0090 D3 bans — while its own docblock
16+
claimed it "mirrors the hook `ctx.session` shape". That sentence stopped being
17+
true at #5050, which retired `HookContext.session.roles` outright: a body author
18+
met two different answers to one key name on one platform, and the comment
19+
pointed at the wrong one. The key set reached no schema and no gate until #5697,
20+
so nothing could see it drift.
21+
22+
**Migration prescription — do this now.**
23+
24+
- Read `ctx.session.positions`. It carries exactly the array `ctx.session.roles`
25+
carried (`ExecutionContext.positions` — the rename is a rename, not a semantic
26+
change), and it is the spelling the platform now uses everywhere: the
27+
execution context, the sharing service, `ctx.user.positions`, and the hook
28+
`ctx.session.positions` (#5605).
29+
- `ctx.session.roles` still resolves for the length of the deprecation window
30+
announced by the ADR-0087 semantic migration
31+
`action-session-roles-to-positions`, and is then removed on the path
32+
`session.tenantId` already walked (#3280 deprecated → #3290 removed in v11). A
33+
body still reading it at that point sees `undefined` with nothing to catch the
34+
change — which is why the read moves **inside** the window, not at its close.
35+
- Do **not** migrate an access check by renaming it. `roles.includes('admin')`
36+
rewritten as `positions.includes('admin')` migrates the defect: neither array
37+
is an authorization input. Privilege is judged by the security service, which
38+
evaluates capability grants, placements and the derived posture (ADR-0095).
39+
- Presence semantics are unchanged: a context with no positions (or a non-array
40+
`positions`) yields **neither** key — `'positions' in ctx.session` answers
41+
`false` exactly when `'roles' in ctx.session` does — and a call with no
42+
identity envelope still yields no session at all rather than `{}` (#3712).
43+
44+
**Also breaking, for TypeScript consumers of the sandbox seam.**
45+
`ScriptContext.session` (`@objectstack/runtime`, `sandbox/script-runner.ts`) was
46+
`unknown` and is now the exported union `ScriptSession = ActionSession |
47+
HookContext['session']` — the two declared producer shapes this one seam
48+
actually carries. Code that read an arbitrary property off it must now
49+
discriminate the body kind (or read one of the keys both shapes declare:
50+
`userId`, `organizationId`, `positions`). It is deliberately **not** narrowed to
51+
`ActionSession` alone: the seam really does carry hook sessions, and declaring
52+
otherwise would re-create the "one key, two realities" defect this change
53+
closes.
54+
55+
The consistency between what the producer builds and what `ActionSessionSchema`
56+
declares stays pinned in
57+
`packages/runtime/src/action-session-shape-contract.test.ts`, and the observed
58+
shape is verified through a real dispatch in `http-dispatcher.test.ts`.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): a dataset refusal that declares an ADR-0112 envelope is never degraded to an empty result (#5717)
6+
7+
`queryDataset` wraps execution in a catch that exists for one deliberate reason
8+
(#5033): a widget whose backing object is not mounted in this kernel renders
9+
"no data" instead of failing with a 500. The criterion for "not mounted" was
10+
`isMissingSourceError` — a substring match over the error MESSAGE. So the
11+
leniency was available to any error that happened to phrase itself like a
12+
driver, and #5352 / #5367's finding on the REST face — "the wire shape of an
13+
error family must not be a property of its wording" — applied here one level
14+
worse: the outcome was not a wrong status code but a **silent empty result**.
15+
No exception, no 4xx, no 5xx; one `warn` line and a confident empty chart, which
16+
is the "populated table, Total Spend: 0" symptom #5033 was filed about.
17+
18+
One refusal already matched. `dataset-compiler.ts` refuses an `include` naming a
19+
relationship the object graph does not have with
20+
21+
> `[dataset-compiler] dataset "X" includes relationship "R" which does not exist on object "O".`
22+
23+
which carries both `relation` (inside "relationship") and `does not exist` — and
24+
that conjunction was the postgres limb. It has never gone off for one reason:
25+
`queryDataset` compiles **before** the try, so that throw has never been inside
26+
the catch's reach. A mine, wired and unarmed.
27+
28+
**Two independent defences, so the disarming does not depend on either one.**
29+
30+
- **The criterion (main change).** An error carrying an ADR-0112 envelope —
31+
numeric `status` + non-empty `code`, the same structural fact
32+
`rest-server.ts`'s `/analytics/dataset/query` catch reads — is re-thrown
33+
untouched, ahead of any message inspection. Its producer already answered the
34+
classification question. The status RANGE is deliberately not part of the
35+
test: a `DATASET_INVALID` / 400 rendered as an empty grid is the loud case,
36+
but a declared 5xx (`READ_SCOPE_COMPILE_FAILED` — an RLS lowering that failed
37+
closed) is if anything worse to swallow, since nobody is told at all.
38+
- **The sniffer.** Its postgres limb is now anchored to postgres's actual
39+
wording (`relation "x" does not exist`) instead of "any sentence containing
40+
both words" — the same pattern the sibling `missingSourceRelation` already
41+
used, so "is something missing" and "what is missing" can no longer disagree.
42+
43+
**Observable behaviour change — read this if you alert on empty widgets.** The
44+
guarantee is new, not the status of any shipped message: measured over the 13
45+
real wordings this repo carries (three driver families including sql-prefixed
46+
and schema-qualified forms, the framework's not-registered signals, and this
47+
package's own refusals), exactly one verdict moves — the compiler refusal above,
48+
which reaches callers as `400 DATASET_INVALID` either way because its throw site
49+
sits outside the try. What changes is that a caller-shaped refusal raised
50+
**during execution** can no longer become `{rows: [], fields: [], totals: []}`
51+
by phrasing alone: it now propagates and the route answers its declared code
52+
(4xx as itself, declared 5xx through `ANALYTICS_QUERY_FAILED`). A dashboard that
53+
silently rendered an empty chart for such a refusal will now surface the error.
54+
55+
**#5033's leniency is untouched, and that is asserted rather than claimed.** A
56+
bare driver error is still classified by its words and still degrades: `no such
57+
table` (sqlite/libsql), postgres's real `relation "x" does not exist`, mysql's
58+
`doesn't exist`, the framework's not-registered signals — and a bare error
59+
naming a JOINED table still fails loudly as a cross-datasource dataset. Those
60+
cases are green in all four states of the reverse verification
61+
(`dataset-degradation-envelope.test.ts`), including with both defences reverted.
62+
63+
The compile point deliberately stays outside the try. Moving it in would newly
64+
expose the compiler's own bare invariants and the host-supplied relationship
65+
resolver to this degradation path — widening leniency in the opposite direction
66+
from the fix.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
analytics: `$ne` / `$nin` / `$notContains` in a dashboard `where` keep the rows that have no value
7+
8+
Second batch of the #5298 ruling, after PR #5962 landed it on `driver-sql`,
9+
`read-scope-sql` and `formula`. An analytics filter meaning "not this" now
10+
returns the rows whose column is empty, the same answer every other backend
11+
gives — a `stage != 'won'` widget shows the deals with no stage set.
12+
13+
The Cube face was the last surface still splitting on it, and it split three
14+
ways for one filter. Measured on the package's own fixture before the change,
15+
for `{stage: {$ne: 'won'}}` with rows 3-4 carrying a NULL `stage`:
16+
17+
| compiler | was | now |
18+
|---|---|---|
19+
| `NativeSQLStrategy` raw SQL | `2` | `2,3,4` |
20+
| `ObjectQLStrategy` display-SQL echo | `2` | `2,3,4` |
21+
| `ObjectQLStrategy` engine condition | `2,3,4` | `2,3,4` |
22+
23+
The engine column was already right — because `driver-sql` guards for itself
24+
since #5962, not because the analytics layer did — so which rows a widget drew
25+
depended on which compiler downstream caught the leaf, and the `/analytics/sql`
26+
echo described a narrower query than the one that ran.
27+
28+
`filter-normalizer` now emits the guard as tree STRUCTURE (an `or` of the null
29+
predicate with the comparison) rather than as a SQL trick in one strategy, so
30+
all three compilers of that tree produce one predicate and none of them needs
31+
to know the rule. Which operators are guarded is decided by the polarity table
32+
the `$not` rewrite already consults, not by a second list of operator names:
33+
positive comparisons (`$eq`, `$in`, `$contains`, the ordering family) compile
34+
byte-identically to before, `$ne: null` stays `IS NOT NULL`, an empty `$nin`
35+
stays the TRUE constant, and `{$not: {stage: {$ne: 'won'}}}` still means
36+
"stage is won" rather than widening.
37+
38+
`FILTER_LOGIC_CASES` is unchanged: the `$ne` and `$not` null rows enrol in
39+
#5903's PR, which clears the last backend (`driver-turso` remote). The spec
40+
table's measured blocker matrix drops the Cube row it no longer describes.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/rest": patch
5+
---
6+
7+
fix(runtime,types)!: `/analytics/query` no longer echoes RLS policy field names — the declared-server-fault withhold is shared by both HTTP boundaries (#5811)
8+
9+
**Observable behaviour change — read this if you read, log, or assert on
10+
`error.message` from a dispatcher-plugin route.** An error that **declares a
11+
server fault** in the ADR-0112 envelope (`status >= 500` *and* a non-empty
12+
`code`) now leaves `dispatcher-plugin.errorResponseBase` with its message
13+
replaced by `"Internal server error"`. It previously reached the caller verbatim
14+
unless it happened to *sound* like a SQL/driver dump. This applies to every route
15+
that plugin mounts — `/analytics`, `/packages`, `/i18n`, `/automation`, `/auth`,
16+
`/notifications`, `/mcp`, … — not only the one that motivated it. Nothing a
17+
machine reads changed: the producer's `code` still arrives in the response
18+
(`error.code`, promoted there from `details` by the shared envelope builder,
19+
#3842), the status is untouched, and the full original text still goes to the
20+
server log and `errorReporter` via `__obsRecordedError`.
21+
22+
## What was wrong
23+
24+
#5367 (maintainer ruling 2026-08-06) made `read-scope-sql.ts`'s ten fail-closed
25+
RLS lowering refusals `READ_SCOPE_COMPILE_FAILED` / 500 and taught
26+
`POST /analytics/dataset/query` to withhold their message, because those messages
27+
name the field names and comparands of an **administrator's** sharing rule:
28+
29+
```
30+
[read-scope-sql] unsafe field identifier "secret_policy_field" — refusing to
31+
build read scope (fail-closed).
32+
```
33+
34+
The caller never wrote that field name and must not be able to read it out of an
35+
error body. But the **sibling** analytics face was never closed.
36+
`compileScopedFilterToSql` runs on both `NativeSQLStrategy.applyReadScope` and
37+
`ObjectQLStrategy`'s echoed SQL, both of which serve `POST /analytics/query`,
38+
which exits through `dispatcher-plugin.errorResponseBase`. That exit's only
39+
message guard was `looksLikeInternalErrorLeak` — a heuristic over SQL/driver
40+
*phrasing* — and all eleven read-scope message shapes return `false` from it.
41+
Measured at that boundary: **11 of 11 echoed verbatim**, at 500, with the policy
42+
content in `error.message`. A real reachable disclosure, not a theoretical one.
43+
44+
## What changed
45+
46+
- **`@objectstack/types` gains `declaresServerFault(err)`**, exported from
47+
`error-leak.ts` beside `looksLikeInternalErrorLeak`. The heuristic asks whether
48+
a message *sounds* internal; the declaration asks whether the producer *said
49+
so*. `error-leak.ts`'s own file header already states the principle — "do not
50+
ship driver internals to clients" is a property of the HTTP boundary, not of
51+
one router — and this is the second predicate that principle asks for.
52+
- **Both boundaries read it.** `dispatcher-plugin.errorResponseBase` gains the
53+
withhold (the fix); `rest-server.ts`'s `/analytics/dataset/query` catch drops
54+
its in-line copy of the same test in favour of the shared one. #5808 wrote that
55+
rule in-line on purpose — promoting a rule with one consumer is a speculative
56+
surface — and this is the second consumer, so it was promoted rather than
57+
duplicated (`#3843`/`#3867` paid for the two-implementations shape twice).
58+
The REST face's verdict is unchanged in every case: same `status >= 500` plus
59+
non-empty `code` test, over the same two fields.
60+
61+
## What deliberately did NOT change
62+
63+
-**This is not "withhold every 5xx".** #5667 kept **undeclared** 5xx errors
64+
legible on purpose: a bare `Error` from our own code ("no strategy can handle
65+
query …") is the operator's own bug report, names nothing tenant-sensitive, and
66+
still falls to `looksLikeInternalErrorLeak` alone. A 5xx carrying only half an
67+
envelope (a status with no code) is likewise still readable — inventing the
68+
withhold for it would be the consumer-side leniency Prime Directive #12 removes.
69+
- **4xx is untouched.** `declaresServerFault` requires `status >= 500`, so a
70+
deliberate business/validation answer can never be swallowed by it.
71+
- **`statusCode` is not accepted as a substitute for `status`.** `status` is the
72+
channel ADR-0112 declares; making a disclosure rule depend on which spelling a
73+
producer reached for would be the same leniency in a different place.
74+
- **The heuristic was not taught to recognise `[read-scope-sql]`.** That would be
75+
more prose sniffing — the mechanism #5352/#5367 exist to remove — and would only
76+
ever cover the family someone remembered to add.
77+
78+
Coverage: `analytics-query-read-scope-withhold.test.ts` (runtime) drives six RLS
79+
policy shapes end-to-end through a **real** `AnalyticsService` on the real
80+
native-SQL path and the real mounted route, asserting the 500, that the whole
81+
serialized body contains no policy detail, that `error.code` still carries
82+
`READ_SCOPE_COMPILE_FAILED`, and that the full text is still on the
83+
`__obsRecordedError` side-channel — plus a positive control and both sides of the
84+
declared-vs-undeclared tiering. `error-leak.test.ts` (types) pins the predicate
85+
directly, including that all eleven read-scope shapes stay invisible to the
86+
heuristic. The REST face's existing `analytics-read-scope-refusal-envelope.test.ts`
87+
is green before and after, unchanged, which is the pin on the refactor.

0 commit comments

Comments
 (0)