Skip to content
25 changes: 25 additions & 0 deletions .changeset/v17-verification-defects-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
---

docs+test: v17 verification defects — ReDoS assertion load-insensitivity, doc drift (#4485, #4476, #4486, #4452)

Release-nothing: touches only `.md`/`.mdx` prose and one `.test.ts` file. No
package source, no public export, no protocol change — so no package needs a
version bump.

- **#4485** `protocol-handshake.test.ts` — the ReDoS guard bounded the
pathological scan with an absolute 50ms wall clock, which measures machine
load rather than the parser: under the full-repo run (~130 parallel turbo
tasks) it exceeded 50ms on a healthy tree and reddened PRs that never touched
`@objectstack/metadata-core`. The behavioural assertions are kept; the
wall-clock proxy is replaced by a scaling check (same adversarial shapes at 1x
and 8x length), so load largely cancels out of the ratio.
- **#4476** Seventeen passages dated the v17 query-surface removals to
`@objectstack/spec` 18. They ship in 17; `spec-changes.json` gives
`toMajor: 17`.
- **#4486** The `IDataEngine` doc block dropped the trailing
`options?: BaseEngineOptions` from all four read methods — the very parameter
#4251 added, against a failure mode that raises no error.
- **#4452** `service-automation`'s README taught a flow DSL that never existed
(node type names, interpolation dialect, and nested `steps` all wrong);
rewritten from the schemas and executors. README only — no package code.
8 changes: 4 additions & 4 deletions content/docs/data-modeling/queries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ backend chooses, exactly as before.

### Keyset Pagination — a `where` predicate on the sort key

`query.cursor` was **removed in `@objectstack/spec` 18** (#4286): nothing on the server
`query.cursor` was **removed in `@objectstack/spec` 17** (#4286): nothing on the server
ever read it, so a cursor query silently returned the same first page every time. The
key is tombstoned and `QueryBuilder.cursor()` was removed with it. Express the keyset
directly — seek past the last row instead of offsetting:
Expand Down Expand Up @@ -422,7 +422,7 @@ come later behind a driver capability flag without changing these semantics.
## Joins — removed

<Callout type="warn">
`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049
`query.joins` was **removed in `@objectstack/spec` 17** (#4286, ADR-0049
enforce-or-remove): no driver's `find()` ever executed a join — the SQL, in-memory, and
MongoDB drivers all ignored the array, so it only ever declared a capability that did
not run. The key is tombstoned: authoring it is a `tsc` error, and a query carrying it
Expand Down Expand Up @@ -532,7 +532,7 @@ by `@objectstack/plugin-pinyin-search`) recomputes the column on demand.
## Window Functions — removed from the request surface

<Callout type="warn">
`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286):
`query.windowFunctions` was **removed in `@objectstack/spec` 17** (#4286):
`ObjectQL.find()` / `.aggregate()` and the `POST /api/v1/data/:object/query` route never
routed it anywhere, so sending it had no effect. The key is tombstoned — a query carrying
it fails to parse with the upgrade prescription — and the
Expand Down Expand Up @@ -565,7 +565,7 @@ report/dashboard metadata.

### Distinct Records — removed flag, three live spellings

The top-level `query.distinct` flag was **removed in `@objectstack/spec` 18** (#4286):
The top-level `query.distinct` flag was **removed in `@objectstack/spec` 17** (#4286):
no driver's `find()` ever applied it, and its only observable effect was mis-wired —
it silently suppressed the REST list count while still returning duplicate rows (the
count is truthful again). The key is tombstoned and `QueryBuilder.distinct()` was
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ console.log(field.maxLength?.toString() ?? 'no limit');
4. **Avoid deep nesting** — Limit nested `$and`/`$or` depth
5. **Use keyset pagination** — For large datasets, seeking past the last row is
faster than a deep `offset`. Express the keyset as a `where` predicate on the
sort key (the `cursor` query property was removed in `@objectstack/spec` 18,
sort key (the `cursor` query property was removed in `@objectstack/spec` 17,
#4286 — nothing ever read it)

```typescript
Expand Down
26 changes: 21 additions & 5 deletions content/docs/kernel/contracts/data-engine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The canonical `IDataEngine` interface uses **QueryAST-aligned parameter names**

```typescript
import type {
BaseEngineOptions,
EngineQueryOptions,
DataEngineInsertOptions,
EngineUpdateOptions,
Expand All @@ -31,11 +32,12 @@ import type {
} from '@objectstack/spec/data';

export interface IDataEngine {
// Query
find(objectName: string, query?: EngineQueryOptions): Promise<any[]>;
findOne(objectName: string, query?: EngineQueryOptions): Promise<any>;
count(objectName: string, query?: EngineCountOptions): Promise<number>;
aggregate(objectName: string, query: EngineAggregateOptions): Promise<any[]>;
// Query (reads take the execution context in a TRAILING options argument —
// the same position the write methods take theirs)
find(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise<any[]>;
findOne(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise<any>;
count(objectName: string, query?: EngineCountOptions, options?: BaseEngineOptions): Promise<number>;
aggregate(objectName: string, query: EngineAggregateOptions, options?: BaseEngineOptions): Promise<any[]>;

// Mutation (write ops also accept in-process WriteObservabilityOptions — see `update`)
insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise<any>;
Expand Down Expand Up @@ -65,6 +67,20 @@ export interface IDataEngine {

All query methods use canonical **QueryAST parameter names**: `where`, `fields`, `orderBy`, `limit`, `offset`, `expand`.

<Callout type="warn">
**Reads take the execution context in the trailing `options` argument**, the same
position the write methods take theirs — `find`, `findOne`, `count` and `aggregate`
all accept `options?: BaseEngineOptions`.

This matters because the mistake it prevents is silent. The same `{ context }` object
is correct as the third argument to `insert`, and passing it as the third argument to
`find` used to be **dropped without error** — so an intended `isSystem` bypass simply
vanished, and control-plane reads started coming back empty once org-scoping hooks
landed (#4251).

`query.context` remains supported. When **both** are given, `options.context` wins.
</Callout>

### find

Executes a structured query with filtering, sorting, pagination, and field selection. Returns an array of records.
Expand Down
10 changes: 5 additions & 5 deletions content/docs/protocol/objectql/query-syntax.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ on the `find()` path:
`top` is the exception that *is* honored — the engine normalises it to `limit`.

The #4286 sweep (ADR-0049 enforce-or-remove) settled every other declared-but-inert
member. **Removed** — tombstoned in `@objectstack/spec` 18, so a query carrying one
member. **Removed** — tombstoned in `@objectstack/spec` 17, so a query carrying one
fails to parse with the upgrade prescription and authoring it is a `tsc` error:
`joins` (related records are read through `expand`), `windowFunctions` (a SQL-driver
door remains: `SqlDriver.findWithWindowFunctions()`), `cursor` (express the keyset as
Expand Down Expand Up @@ -723,7 +723,7 @@ the driver's raw rows.

### Distinct

`query.distinct` was **removed in `@objectstack/spec` 18** (#4286): no driver ever
`query.distinct` was **removed in `@objectstack/spec` 17** (#4286): no driver ever
rendered `SELECT DISTINCT`, and the flag's only observable effect was mis-wired — it
silently suppressed the REST list count (`total`/`hasMore` degraded to a page-local
estimate) while still returning duplicate rows. The key is tombstoned and
Expand Down Expand Up @@ -793,7 +793,7 @@ expansion ignores them.

### Joins — removed (#4286)

`query.joins` was **removed in `@objectstack/spec` 18** (#4286, ADR-0049
`query.joins` was **removed in `@objectstack/spec` 17** (#4286, ADR-0049
enforce-or-remove): no driver ever read it, so a query carrying `joins` silently ran
as a single-table query. The key is tombstoned — authoring it is a `tsc` error, and a
query that still carries it (even as an empty array) fails to parse with the upgrade
Expand All @@ -805,7 +805,7 @@ joined in application code.

### Window Functions — removed from the request surface (#4286)

`query.windowFunctions` was **removed in `@objectstack/spec` 18** (#4286): `find()`
`query.windowFunctions` was **removed in `@objectstack/spec` 17** (#4286): `find()`
never applied it, so every OVER clause it declared was silently dropped. The key is
tombstoned, and the `WindowFunction` / `WindowSpec` / `WindowFunctionNode` exports
left with it — they declared `field` / `over` / `frame` members that no executor ever
Expand Down Expand Up @@ -871,7 +871,7 @@ every page full and every row real (objectui#3106, #4363). A query with no
### Keyset Pagination

<Callout type="warn">
`query.cursor` was **removed in `@objectstack/spec` 18** (#4286): no driver ever
`query.cursor` was **removed in `@objectstack/spec` 17** (#4286): no driver ever
implemented keyset pagination, so a cursor was accepted and ignored and every page came
back identical — a caller looping "until `hasMore` is false" never terminated. The key
is tombstoned (on `EngineQueryOptions` too) and `QueryBuilder.cursor()` was removed
Expand Down
4 changes: 2 additions & 2 deletions content/docs/releases/implementation-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ Every route carries the `/api/v1` prefix. When project scoping is enabled each r
- Client SDK supports bearer token header — but token validation requires the auth plugin
- Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered
- Fine-grained authorization (RLS, sharing) lives in `plugin-security` / `plugin-sharing`, not in the auth plugin. Territory-style access is expressed as an RLS dynamic-membership set (`ExecutionContext.rlsMembership`, e.g. `id in current_user.territory_account_ids`) rather than as a dedicated territory module
- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Tenant isolation is enforced as a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired (an OR-merged business policy could widen it). The default `member_default` set still ships per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is always denied** (ADR-0056 D2). The deployment-wide opt-out is gone: `api.requireAuth` was retired in `@objectstack/spec` 18 (#3963) and is now a tombstoned key that fails validation rather than reopening the data plane. The single decision lives in `@objectstack/core` (`security/anonymous-deny.ts`, 401 `UNAUTHENTICATED`), and every surface that legitimately serves a session-less caller derives its own narrow authorization from a declaration instead: control-plane paths via the auth-gate allowlist, public forms via `publicFormGrant` (ADR-0056 Option A), share links via a capability token validated then read as SYSTEM, `book.audience: 'public'` reads via the audience gate, and MCP via an OAuth token or API key.
- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, positions, permissions). Tenant isolation is enforced as a Layer 0 tenant wall (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired (an OR-merged business policy could widen it). The default `member_default` set still ships per-object overrides `sys_organization_self` (`id == current_user.organization_id`) and `sys_user_self` (`id == current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is the sole authority for tenant isolation. Analytics now uses the same reusable read scope via `security.getReadFilter`, so dataset-bound dashboards/reports do not bypass RLS. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_position_permission_set`. **Anonymous traffic is always denied** (ADR-0056 D2). The deployment-wide opt-out is gone: `api.requireAuth` was retired in `@objectstack/spec` 17 (#3963) and is now a tombstoned key that fails validation rather than reopening the data plane. The single decision lives in `@objectstack/core` (`security/anonymous-deny.ts`, 401 `UNAUTHENTICATED`), and every surface that legitimately serves a session-less caller derives its own narrow authorization from a declaration instead: control-plane paths via the auth-gate allowlist, public forms via `publicFormGrant` (ADR-0056 Option A), share links via a capability token validated then read as SYSTEM, `book.audience: 'public'` reads via the audience gate, and MCP via an OAuth token or API key.
- **OWD / sharing-model enforcement is live and proven end-to-end (ADR-0056)**: `private`, `public_read`, `public_read_write`, and `controlled_by_parent` are enforced through `plugin-sharing` + `plugin-security` and verified by dogfood proofs over the real HTTP stack. `object.sharingModel` accepts the canonical OWD vocabulary only (`private` / `public_read` / `public_read_write` / `controlled_by_parent`) — the legacy `read` / `read_write` / `full` aliases were removed from the enum (ADR-0090 D4), and an unset `sharingModel` on a custom object resolves to `private` (ADR-0090 D1). RLS owner policies resolve `current_user.email` in addition to `id` / `organization_id` / `positions` (#2054). Permission sets may declare `isDefault: true` as the install-time suggestion to bind the set to the built-in `everyone` position (ADR-0090 D5, superseding the ADR-0056 D7 fallback-profile mechanism). **A sharing rule must state its criteria** (#3896): all three write paths reject a match-all shape, a stored criteria-less rule matches nothing, and its materialised grants are revoked on the next reconcile.

---
Expand Down Expand Up @@ -439,7 +439,7 @@ There is no MSW package in this repo — browser API mocking is a devDependency
- [x] Organization-Wide Defaults / sharing model — `private`, `public_read`, `public_read_write`, and `controlled_by_parent` enforced via `plugin-sharing` + `plugin-security`, proven by dogfood over the real HTTP stack (ADR-0056). Canonical vocabulary only — legacy aliases removed from the enum (ADR-0090 D4); unset custom-object OWD resolves to `private` (ADR-0090 D1)
- [x] Sharing Rule evaluator — criteria rules re-evaluated on `afterInsert` / `afterUpdate` (`plugin-sharing/rule-hooks.ts`); every authorable recipient maps 1:1 onto an enforced `expandRecipient` branch (`plugin-sharing/sharing-rule-service.ts`) — `user`, `team`, `position`, `business_unit`, and `unit_and_subordinates` (business-unit-subtree widening, ADR-0057 D5 / ADR-0090 D3). Under ADR-0078 enforce-or-remove, `criteria` is now the only rule *type* (owner-type rules were removed from the authoring surface because the static materialiser cannot track live membership), the `group` recipient was renamed to `team`, `guest` was removed, and `queue` stays reserved in the runtime contract but deliberately non-authorable
- [x] Everyone-baseline suggestion — a permission set may set `isDefault: true` as the install-time suggestion to bind it to the built-in `everyone` position; resolved per-request as an additive baseline, no fallback cliff (ADR-0090 D5)
- [x] Default-deny for anonymous traffic — the global default-deny landed (ADR-0056 D2) and the `api.requireAuth` opt-out was then **removed** in `@objectstack/spec` 18 (#3963): the key is tombstoned and rejected at parse time, the deny decision is centralised in `@objectstack/core` `security/anonymous-deny.ts`, and public forms self-authorize via `publicFormGrant` (Option A)
- [x] Default-deny for anonymous traffic — the global default-deny landed (ADR-0056 D2) and the `api.requireAuth` opt-out was then **removed** in `@objectstack/spec` 17 (#3963): the key is tombstoned and rejected at parse time, the deny decision is centralised in `@objectstack/core` `security/anonymous-deny.ts`, and public forms self-authorize via `publicFormGrant` (Option A)
- [ ] Studio RLS visual editor
- [ ] Per-user×org permission cache
- [ ] Audit UI / denied-access logging
Expand Down
Loading
Loading