Skip to content

Commit f9d5020

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21254-rls-write-json-operator-refusal
Brings in the core JSON-column refusal's field-class parameter (default unchanged), which this face imports. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
2 parents 5a56607 + 393ae87 commit f9d5020

37 files changed

Lines changed: 1937 additions & 80 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
`jsonColumnOperatorRefusalText` takes an optional fourth argument: the class of JSON column the refused operator met, `JsonColumnFieldClass` (now exported). `'multi-value-or-json'` is the default, and its words are unchanged. `'single-value-media'` words the refusal for a single-value file-class field that a SQL deployment still stores as a JSON column.
6+
7+
Clause-②: no
8+
9+
A single-value file-class field (`file`, `image`, `avatar`, `video`, `audio`) is stored as a JSON column only on a deployment inside the ADR-0104 dual-encoding window, whose media columns have not moved. There it holds one JSON string, so `$contains` with the field's exact id answers no rows. That class's refusal no longer prescribes `$contains`. It says that the field answers these operators again once the deployment finishes the media-column move (the column step of `objectstack migrate files-to-references --apply`), and it still names `$null` / `$empty` for "no value". The message stays under the REST envelope's 500-character bound. Which operators are refused, and on which fields, does not change.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
On a deployment whose media columns have not moved, the JSON-column filter refusal on a single-value file-class field (`file`, `image`, `avatar`, `video`, `audio`) now names the repair that works there: the media-column move, not `$contains`.
6+
7+
Clause-②: no
8+
9+
The filter is still refused with `INVALID_FILTER` / 400, for the same operators as before (`$eq`, `$in`, `$startsWith`, `$icontains`, the orderings and the rest of that set, and the bare `{ field: value }` spelling). Before, the refusal told the caller to use `$contains`, the membership repair for a multi-valued field. On a single-value file-class field `$contains` with the field's exact id answers no rows. The refusal now says that the field answers these operators again once the deployment finishes the media-column move (the column step of `objectstack migrate files-to-references --apply`), and it still names `$null` / `$empty`, which answer there. A multi-valued field keeps the `$contains` words, byte for byte. Once the media columns have moved, these filters are not refused, as before.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/driver-turso': minor
3+
---
4+
5+
Both transports now word the JSON-column filter refusal on a single-value file-class field (`file`, `image`, `avatar`, `video`, `audio`) the way `@objectstack/driver-sql` does: the media-column move, not `$contains`, which answers no rows on that field.
6+
7+
Clause-②: no
8+
9+
The local transport inherits the new words from `SqlDriver`. The remote transport refuses in its own filter compiler, and now reads the same class from the driver, so one filter gets one message on both transports. The refused operators and fields do not change. Remote mode never moves its media columns, so a single-value file-class field is a JSON column there on every deployment; the remote transport refuses to plan the column step of `objectstack migrate files-to-references` (`NOT_IMPLEMENTED` / 501), as before, so on that transport the prescribed move is not yet available.
10+
11+
`RemoteTransport.setJsonColumnResolver` now takes a resolver that answers the column's class (`JsonColumnFieldClass`, from `@objectstack/core`), or `undefined` for a column that is not JSON, in place of `true` / `false`. `TursoDriver` supplies it. A host that calls the method itself returns `'multi-value-or-json'` where it returned `true`, and `undefined` where it returned `false`. That replaces the setter's published parameter type, so a host resolver that returns a boolean no longer compiles: a host that injects its own resolver updates its signature, which is why this release is `minor`.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: `having` and the per-aggregation `filter` refuse a plain `{ $field }` reference between two columns of different comparison classes with `INVALID_FILTER` / 400, as `where` refuses it
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of filter STRUCTURE at the engine's query door: a plain { $field } comparand whose two columns belong to different comparison classes, at having and at a per-aggregation filter. No authorable key, spelling, export or stored shape moves: FieldReferenceSchema, every query shape and every object definition parse as before, @objectstack/objectql exports nothing new and nothing less, and no stored row is read or rewritten. What is refused is a comparison the same query's where already refuses on driver-sql, and which same-class column the caller meant is not something a ledger entry can decide. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter's comparison class (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows what a `{ $field }` reference may pair at two positions of `engine.aggregate`. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What was accepted before.** At `having` and at a per-aggregation `filter` (`aggregations[i].filter`), the comparison-class rule was applied only to a reference carrying `addDays`. A plain reference across two classes was answered: `{ closed_at: { $lte: { $field: 'due_on' } } }`, with `closed_at` a `datetime` and `due_on` a `date`, counted rows by `@objectstack/formula`'s whole-day reading of the bare day, and a `having` of `max(closed_at)` against a `day` date bucket kept groups the same way. The same comparison in a `where` is refused `INVALID_FILTER` / 400 by `driver-sql`.
14+
15+
**What is refused now.** A scalar comparison (`$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`) whose comparand is a plain `{ $field }` naming a column of a different comparison class. The classes are the spec's `CROSS_FIELD_COMPARISON_CLASSES` (`numeric`, `text`, `boolean`, `date`, `datetime`, `time`), judged by the spec's `crossFieldComparisonVerdict`, the classification `driver-sql`'s `where` compiler reads. The refusal is `INVALID_FILTER` / 400, raised before any driver is asked for a row, on an empty set as on a populated one, through `engine.aggregate` and `POST /api/v1/data/:object/query`:
16+
17+
- in a per-aggregation `filter`, the fields, the operator and the reason are withheld from the message and written to the server log, as `where` withholds them; the message now names the same-class rule beside the `addDays` one;
18+
- in `having`, the message names the two columns of the query's own projection and their classes, in the sentence `where` logs for the same pair. A `having` column's class is read off the query: a `day` date bucket is a `date`, a coarser bucket a `text` label, `count` / `count_distinct` / `sum` / `avg` are `numeric`, and `min` / `max` take the type of the field they read.
19+
20+
**The remedy.** Compare same-class columns: a `datetime` with a `datetime`, a `date` with a `date` (a `day` bucket is one), a number with a number. A comparison between a `datetime` and a calendar day has no single answer across SQL and memory, so the platform does not define one.
21+
22+
**Unchanged.** A reference between two columns of one class answers as before. A `{ $field, addDays }` pair keeps its judgement and its words. A column whose class the declaration cannot tell is not judged, as an `addDays` pair is not: a host with no registered object, a column the field map does not list (`id`), an aggregation over an undeclared field. A column the spec gives no comparison class at all (a structured-JSON, multi-valued or file field, a formula) is not judged by this rule either.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-audit': minor
4+
---
5+
6+
feat(spec,plugin-audit): the compliance ledger's audit capability, `view_all_audit_log`, exempts its holder from the ledger's parent-record read gate; platform administrators hold it by default (#21260)
7+
8+
Clause-②: yes (widening)
9+
10+
- **The capability.** `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`) gains `view_all_audit_log` ("View All Audit Log", `scope: 'org'`). It is seeded into `sys_capability` like every other curated capability, and a permission set grants it through `systemPermissions`. It is a platform capability, so an app that declares a capability of the same name cannot bind a set carrying it to the `everyone` or `guest` anchor.
11+
- **Who holds it.** `ADMIN_FULL_ACCESS_CAPABILITIES` (`@objectstack/spec`) now lists it, so platform administrators hold it by default: through the `admin_full_access` grant, and through the envelope a configured platform owner resolves to. No other shipped permission set carries it. Any other position holds it only through a permission set that grants it.
12+
- **What it does.** A read of `sys_audit_log` keeps only the rows whose parent record the caller can read. The holder skips that gate and is served every ledger row its grant on `sys_audit_log` reaches: rows about deleted records, sign-out rows, sign-in rows whose session has ended, and rows about records it cannot open. A broad read is served whole. The gate's 2,000-row pre-scan does not run for a holder, so the read is not cut off at that bound.
13+
- **What still applies to the holder.** The holder still needs object-level read on `sys_audit_log`. The field-level redaction still narrows every before/after snapshot it is served. Under a walled tenancy posture, the tenant wall still keeps the holder to its own organization's rows, which is why the capability is declared `org`.
14+
- **What it does not touch.** The activity stream (`sys_activity`) keeps its own parent-record gate for every caller, holders included. A non-holder's ledger reads are unchanged.
15+
16+
**Migration.** None: no metadata, code or configuration change is needed. Platform administrators get the deletion and sign-out trail back with no action. To give an auditor the trail, grant `view_all_audit_log` through `systemPermissions` in a permission set that also grants read on `sys_audit_log`.

‎content/docs/permissions/record-view-auditing.mdx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,14 @@ every field is `readonly`, so the ledger is never written through a form.
209209
Programmatic queries go through `services.data` against `sys_audit_log`. Outside
210210
system context a read returns a view row only when the caller can read the
211211
record it names, so neither the list view nor a query serves a view of a record
212-
the reader cannot open, or of a record that has since been deleted. Those rows
213-
stay stored, and a system-context read still returns them. Rows carry the
212+
the reader cannot open, or of a record that has since been deleted, unless the
213+
reader holds the `view_all_audit_log` capability. Its holder is served every
214+
ledger row its grant on `sys_audit_log` reaches (under a walled tenancy
215+
posture, its own organization's rows), with each snapshot still narrowed by
216+
field-level security. Platform administrators hold it by default;
217+
anyone else holds it only through a permission set whose `systemPermissions`
218+
grant it. Those rows stay stored, and a system-context read still returns them.
219+
Rows carry the
214220
ADR-0057 `audit` lifecycle class: retained hot for
215221
90 days, then archived for seven years where an `archive` datasource is
216222
registered.

‎content/docs/protocol/objectui/actions.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ ai:
491491

492492
- `category` — overrides the derived tool category (`data`, `action`, `flow`, `integration`, `vector_search`, `analytics`, `utility`).
493493
- `paramHints` — per-parameter hints (keyed by param name or `recordId`) that tighten the JSON Schema the model sees without changing UI metadata.
494-
- `outputSchema` — JSON Schema for the return value, enabling structured tool chaining.
494+
- `outputSchema` — JSON Schema for the return value. The cloud AI runtime validates the action's result against it and withholds a result that does not conform; a schema it cannot enforce is refused before the action runs.
495495
- `requiresConfirmation` — override the human-in-the-loop gate for AI invocations.
496496

497497
## Real-World Examples

‎examples/app-showcase/src/ui/pages/command-center.page.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,10 @@ export const CommandCenterPage = definePage({
144144
panel({
145145
id: 'cc_kpi', title: '核心指标 · Key Metrics', accent: A.c3, minHeight: '0px', pad: '14px 18px 16px',
146146
child: band('cc_kpi_grid', 6, [
147-
kpi('cc_k1', 'showcase_project', '活跃项目 Active', 'blue', { field: 'id', function: 'count' }, { status: 'active' }),
148-
kpi('cc_k2', 'showcase_task', '待办任务 Open', 'teal', { field: 'id', function: 'count' }, { status: { $ne: 'done' } }),
149-
kpi('cc_k3', 'showcase_task', '待复审 Review', 'purple', { field: 'id', function: 'count' }, { status: 'in_review' }),
150-
kpi('cc_k4', 'showcase_project', '风险项目 At-Risk', 'danger', { field: 'id', function: 'count' }, { health: 'red' }),
147+
kpi('cc_k1', 'showcase_project', '活跃项目 Active', 'blue', { field: 'id', function: 'count' }, [{ field: 'status', operator: 'equals', value: 'active' }]),
148+
kpi('cc_k2', 'showcase_task', '待办任务 Open', 'teal', { field: 'id', function: 'count' }, [{ field: 'status', operator: 'not_equals', value: 'done' }]),
149+
kpi('cc_k3', 'showcase_task', '待复审 Review', 'purple', { field: 'id', function: 'count' }, [{ field: 'status', operator: 'equals', value: 'in_review' }]),
150+
kpi('cc_k4', 'showcase_project', '风险项目 At-Risk', 'danger', { field: 'id', function: 'count' }, [{ field: 'health', operator: 'equals', value: 'red' }]),
151151
kpi('cc_k5', 'showcase_account', '客户 Accounts', 'orange', { field: 'id', function: 'count' }),
152152
kpi('cc_k6', 'showcase_project', '总预算 Budget', 'success', { field: 'budget', function: 'sum' }, undefined, '0.0a'),
153153
], '10px'),
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { ComponentPropsMap } from '@objectstack/spec/ui';
5+
6+
import { CommandCenterPage } from '../src/ui/pages/index.js';
7+
8+
/**
9+
* The Command Center's KPI tiles write `filter` in the form their own contract
10+
* declares.
11+
*
12+
* The page is built through local helpers (`kpi()`, `band()`, `panel()`), and
13+
* `kpi()` spreads its `filter` argument straight into the `object-metric`
14+
* node's `properties`. `PageComponent.properties` is an open bag, so nothing in
15+
* `definePage()` judges what lands there: four tiles wrote the MongoDB-style
16+
* record form (`{ status: 'active' }`) that `ComponentPropsMap['object-metric']`
17+
* refuses by name, and the page still loaded. `os validate` reports it only as
18+
* an advisory warning, so a green run did not mean a clean page.
19+
*
20+
* This pin evaluates the page as authored (helpers included), finds every
21+
* `object-metric` node wherever it sits in the tree, and parses its WHOLE
22+
* `properties` bag against the row: the verdict on a `filter` value is a value
23+
* verdict, so the full parse has to be green, not just free of unknown keys.
24+
*/
25+
26+
type AnyRec = Record<string, unknown>;
27+
28+
const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v);
29+
30+
/** Every `object-metric` node in the page, at any depth. */
31+
function metricNodes(root: unknown): AnyRec[] {
32+
const out: AnyRec[] = [];
33+
const visit = (v: unknown): void => {
34+
if (Array.isArray(v)) {
35+
for (const item of v) visit(item);
36+
return;
37+
}
38+
if (!isRec(v)) return;
39+
if (v.type === 'object-metric') out.push(v);
40+
for (const child of Object.values(v)) visit(child);
41+
};
42+
visit(root);
43+
return out;
44+
}
45+
46+
/** The tiles that scope their count by a filter; dropping one changes what the tile counts. */
47+
const FILTERED_TILES = ['cc_k1', 'cc_k2', 'cc_k3', 'cc_k4'];
48+
49+
const metricRow = ComponentPropsMap['object-metric'];
50+
51+
describe('Command Center — object-metric properties parse against their ComponentPropsMap row', () => {
52+
const nodes = metricNodes(CommandCenterPage.regions);
53+
54+
it('finds the KPI tiles through the helpers, filtered ones included', () => {
55+
expect(nodes.length).toBeGreaterThan(0);
56+
const filtered = nodes.filter((n) => isRec(n.properties) && n.properties.filter !== undefined);
57+
expect(filtered.map((n) => n.id).sort()).toEqual(expect.arrayContaining(FILTERED_TILES));
58+
});
59+
60+
it('every object-metric properties bag parses clean', () => {
61+
const failures: string[] = [];
62+
for (const node of nodes) {
63+
const result = metricRow.safeParse(node.properties);
64+
if (result.success) continue;
65+
for (const issue of result.error.issues) {
66+
failures.push(`${String(node.id)} › properties.${issue.path.join('.')}: ${issue.code}`);
67+
}
68+
}
69+
expect(failures).toEqual([]);
70+
});
71+
72+
it('the filtered tiles carry a non-empty ViewFilterRule array', () => {
73+
for (const id of FILTERED_TILES) {
74+
const node = nodes.find((n) => n.id === id);
75+
const filter = isRec(node?.properties) ? node.properties.filter : undefined;
76+
expect(Array.isArray(filter) && filter.length > 0, `${id}: filter is a non-empty array`).toBe(true);
77+
}
78+
});
79+
});

0 commit comments

Comments
 (0)