Skip to content

Commit 21c74a7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21142-line-items-columns-name
The one textual conflict, dropped-refinements.baseline.json's two measured counts, is resolved to 212 / 616: the reading build-schemas prints for the merged source (taken in a throwaway worktree), which the merged ledger's own 212 entries and 616 sites corroborate. Both sides' entry hunks merged as is. Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
2 parents c2b9101 + 4727fcb commit 21c74a7

143 files changed

Lines changed: 6530 additions & 3735 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/metadata-protocol': patch
3+
---
4+
5+
Provenance comments in `@objectstack/metadata-protocol` cite the commits that decided them, not tracker numbers that no longer resolve
6+
7+
Clause-②: no
8+
9+
Docblocks and comments across the package cited issue-tracker numbers that now answer 404 on GitHub.
10+
Each one now cites the commit in this repository's history that made the decision it describes
11+
(and ADR-0005's design-principle-3 correction where that record exists). Some of these docblocks sit
12+
on exported members, so the reworded text appears in the published `index.d.ts` / `index.d.cts`, and
13+
a few comments that esbuild keeps appear in the JavaScript output.
14+
15+
Comment only: no export, type, error code, status, message text or runtime behaviour changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/verify': patch
3+
'@objectstack/plugin-dev': patch
4+
'@objectstack/plugin-hono-server': patch
5+
---
6+
7+
The `verify --rls` report and messages, the dev plugin's tenancy and no-auth messages and the Hono server's no-API warning no longer cite tracker numbers; each one states the decision behind it in words
8+
9+
Clause-②: no
10+
11+
Strings these three packages show to operators, and print in verification reports, sent the reader to an issue-tracker number for the reason behind them. The number goes; where the sentence did not already say what was decided, it now does.
12+
13+
- `@objectstack/verify`, the `objectstack verify --rls` report: the header reads `=== objectstack verify (RLS / cross-owner by-id-write invariant) — <app> ===`, and the position-persona line reads `── position personas (each holds one declared position and nothing else) — N of M declared position(s) probed`.
14+
- `@objectstack/verify`, an `rls-hole` verdict's detail: it says the by-id write bypassed RLS, and that a caller that cannot read a record must not be able to write it.
15+
- `@objectstack/verify`, the refusal when the RLS probe persona cannot be provisioned (no ObjectQL engine): it says a by-id write that bypasses RLS is what becomes unreachable. The matching position-persona refusal drops its citation.
16+
- `@objectstack/verify`, the records the probe writes: the probe permission set's row-level-security policy description, the probe `sys_permission_set` row's description and the position persona's `sys_user_position` reason drop their citations. Each already said what it is for.
17+
- `@objectstack/verify`, the `bootStack` refusal for `multiTenant: true` when the app does not declare `@objectstack/organizations`: the citation beside "a package merely reachable through NODE_PATH or a hoisted workspace store is not accepted" goes.
18+
- `@objectstack/plugin-dev`, the `REST API NOT enabled` warning for a stack that mounts no auth: it says anonymous access to object data is always denied, with no setting that turns that off.
19+
- `@objectstack/plugin-dev`, the two refusals for an `OrganizationsPlugin` that refused to be constructed or failed to initialize: they drop their citations. Each already says `OS_ALLOW_DEGRADED_TENANCY` covers only an absent multi-org runtime, not a present one that declined.
20+
- `@objectstack/plugin-hono-server`, the boot warning for a server with no data or discovery API mounted: it drops its citation. It already says the plugin is a transport adapter that serves neither.
21+
22+
Text only: no status, error code, exit code, route, field, export, verdict or count moves. A log filter or script that matched the old text (for example the report header's `RLS / #NNNN` spelling) needs the new spelling.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a row-level `check` judges a `date`, `datetime` or `time` column as the row will be stored, so the write and the read the same policy scopes give one answer for one row (#21109)
6+
7+
Clause-②: no
8+
9+
The write check evaluates the compiled `check` filter in-process against the write's post-image. That image held each value as the caller or a hook wrote it, while the read compares the value the driver stored, in its column's storage form, against a comparand put into the same form. On a temporal column the two forms differ, so one row could get two answers. Measured through `ObjectQL.insert` with `SecurityPlugin` on SQLite, as a member resolving a permission set, with the same predicate as `using` and `check`:
10+
11+
| `check` | written | write, before | stored | read |
12+
|---|---|---|---|---|
13+
| `record.due_on == '2026-01-05'` | `'2026-01-05T15:00:00Z'`, or a `Date` on that day | 403 | `2026-01-05` | shown |
14+
| `record.start_time == '09:00'` | `'09:00:00'` | 403 | `09:00:00` | shown |
15+
| `record.due_at == '2026-01-05T10:00:00Z'` | `'2026-01-05T18:00:00+08:00'` | 403 | `2026-01-05T10:00:00.000Z` | shown |
16+
| `record.due_on > '2026-01-05'` | `'2026-01-05T15:00:00Z'` | admitted | `2026-01-05` | hidden |
17+
18+
Now, before the check is judged, every column the object declares `date`, `datetime` or `time` is put into `@objectstack/core`'s `temporalStorageForm`, the rule the drivers write and compare those columns by. That applies to the post-image's value and to the check's value comparands on the column (`$eq`, `$ne`, the orderings, `$in`, `$nin`, `$between`). The first three rows above are now admitted. The last is now refused, which is the read/write agreement this change buys: the read never showed that row, so a write that stores a day the predicate excludes is no longer admitted on the strength of the time of day it was sent with. Every insert, by-id update and predicate update takes the same step.
19+
20+
Unchanged: a column the object does not declare temporal is judged as written, whatever its value looks like; an object whose schema cannot be loaded is judged as before; a value the storage rule cannot read is judged as written; and refusals keep their code and status (`PERMISSION_DENIED` / 403).
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: a cube measure whose `sql` is a relationship path (`account.name`) is judged by the aggregate × field-type table, described in `fields[]` and presented on the native-SQL strategy by the declaration on the object the path's last hop reaches, as a measure over the cube's own column already was
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused) the pairs this change refuses are exactly the pairs AGGREGATE_FIELD_TYPE_COMPATIBILITY already refuses, and the table is not edited: every refused min / max pair is registered under protocol major 18 by the first id and every refused sum / avg pair by the second, each with its routes. This change makes the cube door read a relationship-path column's declaration on the object the path reaches, where it already read a base-object column's; it refuses a query shape, not a stored one, and no authorable key, export or stored row moves. -->
10+
11+
**BREAKING**: this narrows what `POST /api/v1/analytics/query` and its dry run `POST /api/v1/analytics/sql` accept on the native-SQL strategy, on every driver. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
The column a relationship path names is located by the one hop resolver both strategies join and read it through: the cube's declared join at that path, else the relationship field's declared `reference`.
14+
15+
FROM → TO, for a `measures` entry that resolves to a cube measure over a relationship path (an authored cube measure, or a compiled dataset's measure over an `include`d relationship):
16+
17+
- `min` / `max` / `sum` / `avg` over a related field of a type the table refuses for that aggregate (the string family such as `text`, `select`, `lookup`; the JSON-stored, file and `formula` types; and the rest the table lists): FROM, on the native-SQL strategy, `200` with the related column's own value (a string such as `"zeta"`) under `fields[] { type: 'number' }` on SQLite and PostgreSQL, and for `sum` a plausible `0` on SQLite and `500 DATABASE_ERROR` on PostgreSQL; the ObjectQL strategy refused it as a cross-object measure. TO `400 INVALID_FIELD` on both strategies, before either reads anything — the refusal a base-object column of the same type already got.
18+
- `min` / `max` over a related numeric field on PostgreSQL: FROM the exact-decimal string (`"250.000000000000000000000000000000"`) under `fields[] number`. TO the number `250`.
19+
- `min` / `max` over a related `date`, `datetime` or `time` field: FROM `fields[] { type: 'number' }` beside the instant. TO `fields[] { type: 'time' }`.
20+
21+
**What an author sees now.** `400 INVALID_FIELD`, naming the measure as the request wrote it, the cube, the path, the related object and the type it declares, saying the query was not run, and naming the types the aggregate accepts. The thrown error carries `member`, `param` (`measures`), `cube`, `field` (the path, `account.name`) and `object` (the related object that declares the column).
22+
23+
**What to write instead.** Aggregate a related field of a type the aggregate accepts, or `count` the rows. A first or last related record by a text value is a sort on a list, not an aggregate.
24+
25+
**Who is affected.** A dashboard, report or caller that asked `min` / `max` / `sum` / `avg` of such a related column through the native-SQL strategy and read the answer as a real one. No example app and no shipped cube or dataset authors such a pair. A dataset whose measure aggregates such a related field is now refused when its query runs (`INVALID_FIELD`), where its compile check, which reads the base object's declaration, still lets it through.
26+
27+
**Unchanged.** Every pair the table accepts; a measure over the cube's own column; `count`, and `count_distinct`, which keeps its own door; a related column the host's field metadata cannot describe; an expression `sql` or `*`; a host that wires no `sourceFieldMeta`; and the ObjectQL strategy's refusal of a related-field measure the table accepts (`max` over a related `number`), a capability limit of the engine aggregate — run that query on a native-SQL driver.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/rest': minor
4+
'@objectstack/lint': patch
5+
---
6+
7+
**BREAKING** — an anonymous public form no longer offers record search. The form field's `publicPicker` block (`view.form.sections[].fields[].publicPicker`: `displayFields`, `maxResults`, `filter`, `object`) is removed, and the anonymous lookup route `GET /api/v1/forms/:slug/lookup/:field` is deleted. A public form's `lookup`, `master_detail` and `user` fields are now always left off its anonymous rendering, whatever the form declares.
8+
9+
Clause-②: yes (narrowing)
10+
11+
Retired immediately (ADR-0087 D2), with no alias window: the maintainer's ruling reverses the earlier one that had declared the key. Mainstream web-to-lead forms do not let an anonymous visitor search records either, and no example, template, plugin or first-party UI declared or called the picker.
12+
13+
## FROM → TO
14+
15+
| you wrote (17.5 and earlier) | write instead |
16+
| --- | --- |
17+
| `{ field: 'account', publicPicker: { displayFields: ['name'], maxResults: 10 } }` on a public form | delete the `publicPicker` block — the field is left off the anonymous rendering anyway |
18+
| a public form whose visitors chose from a short, fixed list of records | a `select` field with static `options` listing the choices |
19+
| a public form whose visitors had to pick an existing record | the same form behind sign-in (an internal form), where the lookup field searches with the signed-in user's own access |
20+
| a client calling `GET /api/v1/forms/:slug/lookup/:field` | nothing to call: the path is no longer registered and answers what any unregistered path answers (`404 ENDPOINT_NOT_FOUND`) |
21+
22+
**The one-line fix:** delete the `publicPicker` block; an anonymous public form no longer offers record search. Use a `select` field with static `options`, or put the form behind sign-in.
23+
24+
**What an author who still writes it sees.** `tsc` fails at the authoring site (`FormFieldInput` types the key `never`), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `…sections[N].fields[N].publicPicker` with the prescription:
25+
26+
> `view.form.sections[].fields[].publicPicker` was removed in @objectstack/spec 17.6.0 (ADR-0087 D2) — an anonymous public form no longer offers record search: lookup, `master_detail` and `user` fields are always left off the anonymous rendering, and the anonymous record-search route (`GET /forms/:slug/lookup/:field`) no longer exists. Delete the key (the whole `publicPicker` block). To let a visitor choose from a fixed list, use a `select` field with static `options`; to let them pick an existing record, put the form behind sign-in. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.
27+
28+
**What a REST client sees.** The two error codes only that route produced, `LOOKUP_NOT_PUBLIC` and `LOOKUP_TARGET_MISSING`, leave the error-code ledger with it. `GET /api/v1/forms/:slug` and `POST /api/v1/forms/:slug/submit` are unchanged apart from the unconditional strip above.
29+
30+
## The retirement kit
31+
32+
- **A `retiredKey()` tombstone on the form field**, so the parse carries the prescription instead of a bare unknown-key verdict. The block's own schema and its two types go with it: `FormFieldPublicPickerSchema`, `FormFieldPublicPicker` and `FormFieldPublicPickerParsed` are no longer exported, and `ui/FormFieldPublicPicker` is no longer published as a JSON Schema.
33+
- **The D2 conversion `form-field-public-picker-removed`** (protocol 18, retired from the load path) deletes the key from every form field of every form payload — `sections[]`, `groups[]`, top-level `fields[]` and nested rows. Its D3 record is the semantic entry `form-field-public-picker-retired`, which asks the author how a visitor should now choose.
34+
- **`@objectstack/rest`:** the lookup route and its filter-lowering helper are deleted, and the resolve route's strip of lookup / `master_detail` / `user` fields no longer has an opt-in.
35+
- **`@objectstack/lint`:** the preset-comparand rule no longer reads a picker's `filter` (its claiming reader for that position went with the key).
36+
37+
## What an operator with a STORED form sees
38+
39+
A `sys_metadata` view row saved before this release may still carry the key. Nothing breaks at read: the conversion replays on rehydration and strips it, so the view is served canonical and parses, and the field stays off the anonymous rendering either way. `os migrate meta --stored` lists those rows, and `--apply` rewrites them.
40+
41+
<!-- adr-0087: registered form-field-public-picker-removed, form-field-public-picker-retired -->
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
'@objectstack/driver-sql': minor
3+
'@objectstack/driver-turso': minor
4+
'@objectstack/spec': patch
5+
---
6+
7+
fix(driver-sql,driver-turso)!: an upsert whose conflict lands on another organization's row is refused with `UNIQUE_VIOLATION` and writes nothing, and an upsert never changes a row's organization (#21185)
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: registered driver-upsert-cross-organization-conflict-refused -->
12+
13+
**BREAKING for `upsert` callers on the SQL drivers and on `TursoDriver`.**
14+
15+
**What changed.** `upsert` resolves its conflict against the whole table, and the
16+
primary key and a `unique: 'global'` column are installation-wide, so the row a
17+
tenant-scoped call (`options.tenantId` on an object with a tenant column) collided
18+
with could belong to another organization. The merge wrote the payload onto that
19+
row, tenant column included. Now:
20+
21+
- **A tenant-scoped upsert merges only into a row of the organization the row is
22+
written under**, for any conflict target, the primary key included. A conflict
23+
that lands on a row of another organization, or on a row with no organization,
24+
is refused with `code: 'UNIQUE_VIOLATION'`, `status: 409`, and nothing is
25+
written. That is the answer `create()` gets for the same collision: from the
26+
caller's organization the call is an insert, and that insert collides. The
27+
refusal names no organization and no value of the row it collided with.
28+
- **The tenant column is insert-only** (`insertOnlyUpsertColumns`), like `id`,
29+
`created_at` and `auto_number` columns: an upsert with no tenant context merges
30+
into the row it lands on and keeps that row's organization.
31+
32+
Mechanism, per face: on SQLite, PostgreSQL and the remote (libSQL) face, the merge
33+
statement carries the organization predicate (`DO UPDATE … WHERE`), so another
34+
organization's row is never written. On MySQL, whose `ON DUPLICATE KEY UPDATE`
35+
takes no `WHERE`, the statement and a read of the landed row run in one
36+
transaction (a savepoint inside a caller's transaction), and the read's failure
37+
rolls the write back. The remote face now also stamps the caller's organization on
38+
the row it inserts, as the local faces do.
39+
40+
## FROM → TO
41+
42+
| you relied on | now |
43+
|:--|:--|
44+
| a tenant-scoped `upsert` merging into a row of another organization | refused with `UNIQUE_VIOLATION` / 409, nothing written |
45+
| an `upsert` payload's tenant value moving the row it merges into | the row keeps its organization; to move a row between organizations, use `update()` |
46+
47+
**What is not affected.** A tenant-scoped upsert whose conflict lands on a row of
48+
its own organization merges as before, on every target. An upsert that inserts
49+
lands under the caller's organization, or under the organization the payload
50+
names explicitly, as before.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the `filter-text-operator-declared-type-refused` migration entry's control no longer counts JSON-stored fields, and it names the separate door that refuses text operators there
6+
7+
Clause-②: no
8+
9+
**`filter-text-operator-declared-type-refused` (protocol 18).** The entry's acceptance criteria
10+
named every text-valued field, `multiselect` / `checkboxes` / `tags` and lookup and `user` ids
11+
included, as the control that "must keep answering exactly as before". The declared-type door this
12+
entry registers still leaves every text-valued type alone. A second door, though, judges a field by
13+
how it is STORED: on a column stored as JSON it now refuses `$startsWith`, `$endsWith`,
14+
`$icontains`, `$like` and `$ilike` with `INVALID_FILTER` / `400`, as it already refused the scalar
15+
comparisons there. So a stored filter that uses one of those operators on a multi-valued field
16+
answers that `400` after the upgrade, and the old sentence called it a control.
17+
18+
The criteria now say four things:
19+
20+
- The control is a text-valued field that is NOT stored as a JSON column.
21+
- The JSON-stored population is `multiselect` / `checkboxes` / `tags`, any field declared
22+
`multiple: true` (a multi-valued lookup or `user` among them), and, on a SQL deployment still
23+
inside the ADR-0104 dual-encoding window, a single-value file-class field.
24+
- That door refuses every text operator except the membership pair `$contains` / `$notContains`,
25+
and its refusal names no declared type, so it is outside this entry's repair list.
26+
- The repair on a multi-valued field is membership: `$contains` for one member, an `$or` of
27+
`$contains` for any-of. A single-value file-class field answers text operators again once the
28+
deployment finishes the media-column move (the column step of
29+
`objectstack migrate files-to-references --apply`).
30+
31+
Text only: no entry id, `surface`, `replacement`, `reason`, conversion or refusal changes, and
32+
neither door moves. `objectstack migrate meta` prints the corrected `verify:` line, and the
33+
generated migration registry carries the same text.

0 commit comments

Comments
 (0)