Skip to content

Commit 6bf7f10

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21329-share-link-mint-authority
# Conflicts: # packages/runtime/src/domains/share-links-enforcement-context.test.ts
2 parents b0382b1 + 3bddd4a commit 6bf7f10

31 files changed

Lines changed: 2629 additions & 207 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: a `pie` / `donut` / `funnel` / `treemap` / `sankey` dashboard widget takes ONE measure with a dimension too — two or more are refused at `values`, and the check export is renamed `checkDashboardWidgetChartMeasureArity` (#21293; extends #20958)
6+
7+
Clause-②: yes (narrowing) — the accept set NARROWS (that is the change), and the published surface swaps one export for another: `checkDashboardWidgetDimensionlessMeasureArity` is removed and `checkDashboardWidgetChartMeasureArity` is added in its place, the same check with a second arm.
8+
9+
<!-- adr-0087: registered dashboard-widget-single-series-multi-measure-refused -->
10+
11+
**BREAKING** accept-set narrowing at `dashboard.widgets[].values`, plus one renamed
12+
export, shipped as `minor` under this repo's launch-window convention for breaking
13+
changes (`check-changeset-no-major` refuses `major` while the window is open, so
14+
breaking-ness is carried by this banner and by the ADR-0087 disposition above,
15+
never by the bump level). The prescription is registered under protocol major 18
16+
as `dashboard-widget-single-series-multi-measure-refused`.
17+
18+
**What was wrong.** The previous release refused two or more measures on a
19+
dimensionless `pie` / `donut` / `funnel` / `scatter` / `radar` / `treemap` /
20+
`sankey`, and stepped aside for any widget that declared a dimension. Five of those
21+
types draw ONE series whatever the dimension: objectui's chart renderer binds the
22+
first series on its `pie` / `donut`, `funnel`, `treemap` and `sankey` arms and reads
23+
no other, so `{ type: 'pie', dimensions: ['stage'], values: ['revenue', 'cost'] }`
24+
drew one slice per stage for `revenue` and no trace of `cost`. Measured on this tree
25+
before the change: that body parsed through `DashboardWidgetSchema` on all five
26+
types (and on `scatter` / `radar` / `bar` / `table`), while `bogusProp` on the same
27+
widget was refused by name, the lit control. After it, the five are refused at
28+
`widgets[N].values`; `scatter` and `radar` with a dimension are outside the ruling
29+
and parse as before.
30+
31+
### Write instead
32+
33+
| wrote | write instead |
34+
|---|---|
35+
| `{ id: 'mix', type: 'pie', dataset: 'sales', dimensions: ['stage'], values: ['revenue', 'cost'] }` | `{ id: 'mix', type: 'table', dataset: 'sales', dimensions: ['stage'], values: ['revenue', 'cost'] }` — a column per measure |
36+
| the same, wanting a chart | `type: 'bar'` (or `column` / `horizontal-bar`) — one bar per measure in each stage |
37+
| the same, wanting the pie | `{ id: 'mix', type: 'pie', …, values: ['revenue'] }` **and** `{ id: 'mix_cost', type: 'pie', …, values: ['cost'] }` — one widget per measure, each with its own `id` (and `layout`, if you pin positions) |
38+
| `import { checkDashboardWidgetDimensionlessMeasureArity } from '@objectstack/spec/ui'` | `import { checkDashboardWidgetChartMeasureArity } from '@objectstack/spec/ui'` — same `(widget, ctx)` signature; chain it where the old name was chained |
39+
40+
No conversion does this for you: whether a two-measure pie by stage meant a table, a
41+
grouped bar chart or two pies is an authoring choice. The refusal is ONE `custom`
42+
issue at `widgets[N].values` naming the widget's `id`, the number of measures and
43+
the authored `type`, and saying that type draws one series whatever its
44+
`dimensions`.
45+
46+
**Why the export is renamed.** The dimensionless rule's check now has a second arm
47+
that judges widgets WITH a dimension, so its old name described a boundary that no
48+
longer exists. It refuses everything the old name refused, word for word on a
49+
dimensionless widget. No first-party consumer chained the old name: objectui's
50+
`DashboardWidgetSchema` mirror chains `checkDashboardWidgetStageOrder` and
51+
`checkDashboardWidgetMetricMeasureArity` only, measured at the pinned objectui
52+
commit and on objectui's `main`.
53+
54+
**Nothing else moves.** One measure parses on every type; `scatter` and `radar`
55+
keep accepting several measures with a dimension; every type in
56+
`DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` keeps accepting any number of measures with
57+
or without a dimension; a dimensionless widget of the five keeps the dimensionless
58+
refusal, word for word and still ONE issue; the metric family's refusal is
59+
unchanged; an empty `values` keeps its `too_small`; a `type` outside
60+
`ChartTypeSchema` reports the type refusal alone. Census at the branch point
61+
(`4b20c8474`), every tracked `.ts` / `.tsx` / `.js` / `.mjs` / `.cjs` / `.json` /
62+
`.md` / `.mdx` / `.yml`: 496 literals carry `values: [...]`, 33 of them on one of
63+
the seven types, and the only dimensioned multi-measure one on the five is a spec
64+
test fixture that pinned the old acceptance (moved to the refusal in this change).
65+
The same scan over objectui at its pinned commit (`89cad75d5`) finds no authored
66+
widget of that shape — its one hit is the prose example in a changeset.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
Row-level security policies and the analytics native-SQL path judge a comparand against a declared boolean field by the platform's boolean-comparand rule, the one the data engine's `where` already applies
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) a refusal or narrowing of a filter comparand against a declared boolean column at two compilers outside the engine's where door, the same comparand that door already judges: no authorable key, spelling, export or stored shape moves. RowLevelSecurityPolicySchema, every permission set, every dataset, cube and analytics query parse and save as before, the predicate's and the filter's text are untouched, @objectstack/plugin-security and @objectstack/service-analytics export the same names with the same types, and no stored row is read or rewritten. Which boolean the author meant by a refused comparand is not something a ledger entry can decide, so there is nothing for objectstack migrate meta to rewrite. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers a filter comparand's type and this diff adds none (not registered / already-registered); and the change is runtime behaviour with no published interface or type changed (not runtime-interface-only / type-surface-only). -->
11+
12+
**BREAKING**: this narrows what two compilers outside the engine's `where` door accept. The RLS compile seam now drops a row-level policy, and the analytics native-SQL face now refuses a query, when either compares a declared boolean field with a comparand outside the accepted set. It ships as `minor` under the launch-window convention for accept-set narrowings. No export, type or error code changes.
13+
14+
- **Row-level security (`@objectstack/plugin-security`).** A compiled `using` / `check` predicate on a `boolean` or `toggle` column (or a `formula` returning `boolean`) is judged by `booleanComparandDoorVerdict` from `@objectstack/spec/data`, in the same pass as the number rule. `'true'` / `'false'`, `'1'` / `'0'` and `1` / `0` are read as the boolean each names. Anything else the rule refuses (a string such as `'yes'`, `'TRUE'` or `''`, a number other than `1` / `0`) drops the policy as a refused comparand: the read is filtered by the deny sentinel, the write is refused 403, and the WARN line names the clause, the field and the position. Before, `record.flag != 'true'` kept every row on SQLite and the write check admitted every row, so the exclusion the author wrote was not applied.
15+
- **Analytics native SQL (`@objectstack/service-analytics`).** The query's `where` (and the dataset query's `runtimeFilter`, which is merged into it), each measure's own `filter` and a dataset's own `filter` are judged by the same rule before the statement compiles. An accepted spelling is read as its boolean, and anything else the rule refuses is refused `INVALID_FILTER` / 400 with the rule's own message, before any statement runs. The native strategy now answers what the engine-aggregate strategy answers. Before, `{ flag: 'true' }` counted no rows on SQLite, `{ flag: { $ne: 'true' } }` counted every row, and `{ flag: 'yes' }` answered 200.
16+
- **What you may notice.** A policy or analytics filter that compared a boolean field with a value outside the accepted set now refuses instead of answering. Write `true` / `false`. A policy `record.flag == 1` now admits writing a `true` row, which its read already showed.
17+
- **Unchanged.** A boolean literal, a column that is not boolean, a `{ $field }` reference, and an object whose declaration cannot be read (nothing is judged without one).
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
fix(plugin-audit): an activity row recording an update whose every changed field the reader is withheld is no longer served to that reader, on any listing face
6+
7+
Clause-②: no
8+
9+
A `sys_activity` row's recorded change (`metadata.old` / `metadata.new`) is narrowed key by key for each reader, through the security service's served-fields answer. An update whose every changed field the reader is withheld still reached that reader as a row with an empty change, and its summary, actor and timestamp said that the record changed, and when. An org member holding object-level `sys_activity` read was served that row for each sign-in stamp on a colleague's identity record (`last_login_at`), and for each failed-sign-in counter bump, lockout, password-change stamp and MFA-required stamp.
10+
11+
Such a row is now withheld from that reader as a row:
12+
13+
- **What counts as one.** An update row (its stored change has both an `old` and a `new` side) whose stored change had at least one key, where the reader is served none of those keys. The keys are read from the STORED change, not the redacted one.
14+
- **What is unaffected.** A create or a delete keeps its row. A row whose stored change is empty on both sides (an update that touched only `internal` fields) is unaffected. A mixed update keeps its row, with the served keys only. A reader served every field (an administrator) still reads every row with its change, within the pre-scan's bound. A system-context read is not narrowed.
15+
- **Every face agrees.** The rule is a WHERE built from a system-context pre-scan on `find`, `findOne`, `count` and `aggregate`. So a list's `total`, its pages, a by-id read (`404`) and a grouped count agree with the rows served. A pre-scan that reaches its 2,000-row bound answers a broad read from the rows it judged, for every reader, administrators included, and logs a warning. The remedy is to scope the query by `object_name` and `record_id`.
16+
17+
No migration: no key, export or config changes. A reader the security service gives no answer for (no security plugin wired) is not narrowed, as before.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the null ordering-comparand refusals name only evaluation faces that exist, and say only what was measured
6+
7+
Clause-②: no
8+
9+
`FieldOperatorsSchema` and `ComparisonOperatorSchema` refuse a `null` comparand of `$gt` / `$gte` /
10+
`$lt` / `$lte` with a pointed message. Its example of the evaluation faces disagreeing named
11+
driver-memory's reference matcher, which has been deleted, so an author or agent reading the
12+
refusal went looking for a face that no longer exists. The example now names two faces that exist
13+
and were measured to disagree: driver-memory's query path reads a stored `null` as equal to the
14+
comparand, so `{"$gte": null}` admits that row, while driver-sql compares against SQL `NULL` and
15+
admits no row.
16+
17+
That refusal and its runtime twin, the `parseFilterAST` refusal for the same comparand
18+
(`Operator "$gt" on field "…" does not accept a null comparand …`), both said "no two evaluation
19+
faces agree" on what an ordering against `null` matches. Measured, two faces do agree (driver-sql
20+
and formula both admit no row), so both now say "the evaluation faces do not agree".
21+
22+
Text only: each message's first sentence, its prescription (`{"$eq": null}` / `{"$ne": null}`), the
23+
schema door's ruling sentence and the runtime door's "NOT applied" sentence are unchanged, and both
24+
doors accept and refuse exactly the same filters. A client or log filter that matches the old
25+
wording needs the new spelling.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
`PermissionDeniedError` declares its 403 as `status` as well as `statusCode`, so a permission refusal answers 403 at every door (#21405).
6+
7+
Clause-②: no
8+
9+
The class declared `statusCode` alone, unlike every other error class in `errors.ts`, and a door that reads `status` alone derived no status from it. On a showcase boot, a plain member's `POST /api/v1/share-links` on a record they cannot read answered `500` with code `PERMISSION_DENIED` through `plugin-sharing`'s route door, while the runtime dispatcher's `/share-links` domain answered the same refusal with `403`. Both doors now answer `403 PERMISSION_DENIED`. The code, the message and `statusCode` are unchanged.

‎content/docs/permissions/rls.mdx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,23 @@ Layer 1 (business RLS) ─┘
197197

198198
## The fail-closed contract
199199

200-
Four ways a policy denies rather than leaks:
200+
Five ways a policy denies rather than leaks:
201201

202202
1. A policy exists but **every** applicable expression fails to compile → a
203203
deny-everything filter.
204204
2. A referenced context variable is missing, null, or an empty array → that
205205
policy drops out (it cannot match).
206206
3. A policy references a column the object doesn't have → deny.
207-
4. `check` is omitted → `using` stands in as the `check`. The choice is
207+
4. A policy compares a column with a literal its declared type cannot be
208+
compared with → that policy drops out, for `using` and `check` alike: on a
209+
`boolean` / `toggle` column, anything but `true` / `false` and the
210+
spellings `'true'` / `'false'`, `1` / `0` and `'1'` / `'0'`
211+
(`active == 'yes'`, `active != 'TRUE'`, `active == 2`); on a numeric
212+
column, a comparand that is not a number (a string with no numeric reading,
213+
a boolean). These are the comparisons a caller's `where` is refused for
214+
(`INVALID_FILTER`). An accepted spelling is read as the value it names, so
215+
`active != 'true'` hides the `true` rows exactly as `active != true` does.
216+
5. `check` is omitted → `using` stands in as the `check`. The choice is
208217
made per operation across all the applicable policies, not policy by
209218
policy: when any of them declares a `check`, only the declared checks
210219
decide, and a USING-only sibling's `using` is not part of the check.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ The largest single consumer — **17 of the 114 sites**.
161161
| 42 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `packages/plugins/plugin-approvals/src/lifecycle-hooks.ts#bindDelegationWriteGuard` |
162162
| 43 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `packages/plugins/plugin-approvals/src/approval-service.ts#isOverrideActor`, `#resolveActor`, `#sendBack`, `#resubmit`, `#reassign`, `#remind`, `#requestInfo`, `#comment` |
163163
| 44 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentAccessHooks`, `#installAttachmentReadVisibility` |
164-
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, on `find` / `findOne` — the audit writer and each read gate's own pre-scan — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` |
164+
| 45 | Comment access hooks return early (insert + update + delete, and the read AST), and so do the activity and audit-log read gates (their read AST), the activity field redaction and the audit-log field redaction (the rows a read serves), and the query guard over both objects' value-bearing columns (the read AST) | plugin-audit | Get: the whole activity row and the whole `sys_audit_log` row, its before/after snapshots included, on `find` / `findOne` — the audit writer, each read gate's own pre-scan and the activity redaction's — and a filter, sort or grouping by those columns. Lose: comment visibility scoping, the narrowing of `sys_activity` and of `sys_audit_log` to rows whose parent record the caller can read, the redaction of a parent field's value from an activity row's text and recorded change and from a ledger row's before/after snapshots, the withholding of an activity row recording an update whose every changed field the reader is withheld (on `count` and `aggregate` too, so a total agrees with the rows), and the refusal of a query over those columns for a reader withheld a field of the objects it can reach | `packages/plugins/plugin-audit/src/comment-access-hooks.ts#installCommentAccessHooks`, `#installCommentReadVisibility`, `packages/plugins/plugin-audit/src/activity-read-visibility.ts#installActivityReadVisibility`, `packages/plugins/plugin-audit/src/audit-log-read-visibility.ts#installAuditLogReadVisibility`, `packages/plugins/plugin-audit/src/activity-field-redaction.ts#installActivityFieldRedaction`, `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts#installAuditLogFieldRedaction`, `packages/plugins/plugin-audit/src/parent-field-query-guard.ts#installParentFieldQueryGuard` |
165165
| 46 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `packages/services/service-knowledge/src/knowledge-service.ts#applyPermissionFilter` |
166166

167167
### 5. Actions, metadata plane, provenance, the organization wall

0 commit comments

Comments
 (0)