Skip to content

Commit 3b29a22

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15702-half-states-h45-parent-half
2 parents 9850c05 + c2a336c commit 3b29a22

37 files changed

Lines changed: 1582 additions & 113 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/driver-memory": patch
3+
---
4+
5+
fix(driver-memory): the reference matcher's `$notContains` arm answers the predicate, not a type test, for a stored non-string value
6+
7+
`match()` used to answer `{ n: { $notContains: '5' } }` with NO for `{ n: 5 }` — the arm read `typeof value !== 'string' || value.includes(target)`, so a number failed `$contains` (correct) AND its negation (wrong: for the very reason a number cannot contain the substring, it does not contain it). This package's own live mingo path admitted the row, so one filter answered two ways depending on which face was asked; on this face the failure mode was silently dropped rows.
8+
9+
The arm now answers what `FILTER_TEXT_CASES`' new `score` rows declare on every face (maintainer ruling 2026-09-05 on the contract card): a stored value that is not a string never satisfies a positive text operator and always satisfies `$notContains`. The no-value cells keep their #13166 answer; nothing else in the matcher moved.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
---
4+
5+
A text operator over a column whose declared type stores no text (`Field.number` and its numeric siblings, `Field.boolean`) now compiles to the contract's declared answer on every dialect, instead of a dialect accident.
6+
7+
Before: `{ score: { $contains: '5' } }` over a numeric column compiled `col GLOB '*5*'` on SQLite and coerced the REAL in its storage class's spelling (`5` as `'5.0'`, so `$endsWith: '0'` matched every row), `col LIKE $1 ESCAPE $2` on Postgres and was refused at query time with SQLSTATE 42883 (`operator does not exist: real ~~ text` — a 500 for a filter the spec accepts), and `CAST(col AS BINARY) LIKE ?` on MySQL.
8+
9+
Now (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05): the positive operators (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) compile to `1 = 0` and `$notContains` to `1 = 1` — the same row set as every JS face, decided from the declared type at compile time because the stored value is not visible until run time. Postgres: a 500 becomes a result. The gate reads the `numericFields` / `booleanFields` registries `initObjects` and `registerExternalObject` already fill; a table this driver was never told about keeps the `LIKE` / `GLOB` it always compiled, every comparand refusal still runs first, and the constants compose with the NULL-safe rules (`$notContains` admits a NULL row already) and the `$not` rewrite. Temporal columns are untouched: their stored value IS text on SQLite, so the contract declares nothing for them.
10+
11+
`driver-sqlite-wasm` and `driver-turso`'s local transport inherit this compiler.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
The remote transport compiles a text operator over a declared numeric or boolean column to the contract's declared answer, in step with the local transport.
6+
7+
`RemoteTransport.buildWhereSQL` compiles filters independently of `SqlDriver` and keeps no schema, so a text operator over a `Field.number` used to compile `"col" GLOB ?` and coerce the REAL in the storage class's spelling (`5` as `'5.0'`). `TursoDriver` now hands the transport its declared-type rule (`setNonTextColumnResolver`, the same shape as the temporal `setFilterColumnSql` rule), answered from the registries `registerRemoteFieldMetadata` already fills at schema sync — so a positive text operator over such a column compiles to `1 = 0` and `$notContains` to `1 = 1` on BOTH transports (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05), instead of a dialect accident. A transport nobody handed the rule to compiles exactly as before, and every comparand refusal still runs ahead of the constant.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`FILTER_TEXT_CASES` declares what a text operator answers over a stored value that is NOT a string, and the fixture gains its first non-string column.
6+
7+
Measured before this row existed, one filter over one numeric column answered four ways across the platform: `driver-memory`'s reference matcher said NO to `$contains` and to `$notContains` for the same row; its live mingo path, `formula`, objectql's `having`, `driver-mongodb` and the analytics face type-gated (`$contains` NO, `$notContains` YES); the SQLite family coerced the number to text in its storage class's spelling (REAL renders `5` as `'5.0'`); and live Postgres refused at query time with SQLSTATE 42883 — a 500.
8+
9+
The maintainer ruled the cell on 2026-09-05 (option A, type-gate): a stored value that is not a string never satisfies a positive text operator (`$contains` / `$startsWith` / `$endsWith` / `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` — complementarity holds, on every face. Coercion was refused on the measurement; a declared-type door that refuses the filter before any backend runs is deferred to its own decision card, not rejected.
10+
11+
- `FilterTextRow` is now `{ id, name, score }``score` is a NUMBER on every row (a `0` among them), chosen so a coercing backend answers a visibly non-empty set and a truthiness guard drops a row.
12+
- Five new evaluated rows over `score`: the four positive operators the table can carry answer `[]`, `$notContains` answers all nine. (`$like` / `$ilike` follow the same rule and are pinned on the faces that answer them — the table is a driver's enrolment and `driver-mongodb` refuses those two.)
13+
- `NON_TEXT_STORED_VALUE_TYPES` (`field-value.zod.ts`) — the numeric and boolean value classes, i.e. the declared field types whose stored value is never text — is the list the SQL faces classify a column by at compile time, since they cannot read the value. Temporal types are deliberately absent: their stored form is a dialect question (ADR-0053) the row does not decide.
14+
15+
Every suite that materialises the fixture adds the column (SQL `initObjects` DDL included).
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
The three SQL compilers in this package — the RLS read-scope lowering (`compileScopedFilterToSql`), `NativeSQLStrategy`'s own `where` and the `ObjectQLStrategy` SQL echo — compile a text operator over a column whose declared type stores no text to the contract's declared answer.
6+
7+
`compileScopedFilterToSql(filter, alias, options?)` takes a new optional `nonTextColumn(field)` predicate; when it answers `true`, a positive text operator compiles to `1 = 0` and `$notContains` to `1 = 1` instead of a `LIKE` that coerces on SQLite (`5` renders `'5.0'`) and is refused at query time on Postgres (SQLSTATE 42883 — a 500 on a read scope the platform accepted). The service answers the predicate from the field metadata hook it already holds (`sourceFieldMeta`), exposed to strategies as `DatasetScopedStrategyContext.declaredFieldType`, and the two strategies pass it for the read scope and for the query's own text filters, so a query and its RLS scope answer one cell one way and the echo prints the statement that ran (`FILTER_TEXT_CASES`' `score` rows, maintainer ruling 2026-09-05). A host that wires no field metadata keeps the `LIKE` it always got, and every comparand refusal still runs ahead of the constant.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`@objectstack/spec/system` now names the ADR-0030 notification cut-over, so "has this deployment run it?" has a place to be answered.
6+
7+
`sys_migration` is the ledger a deployment writes to record that a data migration ran against its own database, and consumers read it instead of the platform version. Its well-known ids were `adr-0104-file-references` and `adr-0104-value-shapes` — the two ADR-0104 scans, both driven by an `os migrate` command that records the row. `migrateSysNotificationToEvent` (`@objectstack/metadata/migrations`) had none. It is destructive and one-way, operators are handed the call verbatim in `docs/handoff/adr-0030-notification-convergence.md`, and it recorded nothing when it ran: a deployment that performed the cut-over and one that never did are indistinguishable from the ledger. A row can only be keyed by an id, so without one the question had nowhere to be answered even in principle.
8+
9+
Added: `NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'`, exported from `@objectstack/spec/system`. Purely additive — no existing export, schema or predicate changes, and nothing reads the new id yet.
10+
11+
Deliberately NOT decided here, and the constant's docblock says so rather than leaving its silence to be read as an answer: what a `sys_migration` row under this id means. The two ADR-0104 ids get their `last_run_at` / `applied_at` / `verified_at` / `blocking` semantics from a command that scans, self-checks and only then records; this migration has no command and no self-check, and reports `migrated` / `already_done` / `not_applicable` / `error` to its caller instead. Which of those columns one of its runs may claim, whether anything may gate on the row, and whether a datastore created after the cut-over belongs in `CREATION_ATTESTED_MIGRATION_IDS`, are contract questions on this surface and are left open.

content/docs/protocol/kernel/error-handling.mdx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -346,27 +346,27 @@ deriving it from this page.
346346
- Check user has permission to see resource (row-level security)
347347
- Resource may have been deleted
348348

349-
#### `DUPLICATE_RECORD`
349+
#### `UNIQUE_VIOLATION`
350350
**HTTP Status:** 409
351-
**Meaning:** Resource with unique constraint already exists
351+
**Meaning:** The write collides with a unique constraint — a record already holds that value
352352

353353
**Example:**
354354
```json
355355
{
356-
"success": false,
357-
"error": {
358-
"code": "DUPLICATE_RECORD",
359-
"message": "Account with email 'john@acme.com' already exists",
360-
"details": {
361-
"resource": "account",
362-
"constraint": "unique",
363-
"field": "email",
364-
"value": "john@acme.com"
365-
}
366-
}
356+
"error": "A record with this email already exists",
357+
"code": "UNIQUE_VIOLATION",
358+
"field": "email",
359+
"object": "account"
367360
}
368361
```
369362

363+
The engine throws `DuplicateRecordError`, whose in-process `code` is
364+
`DUPLICATE_RECORD`; the REST door translates that envelope at the boundary, so
365+
every route answers the wire code `UNIQUE_VIOLATION` and the in-process spelling
366+
never crosses HTTP. The refusal is emitted as the flat body shown above, and
367+
`field` is best-effort — see
368+
[HTTP API](/docs/protocol/kernel/http-protocol) for the degraded shape.
369+
370370
**How to fix:**
371371
- Check for existing resource before creating
372372
- Update existing resource instead of creating new one
@@ -947,7 +947,7 @@ Even error responses can be abused:
947947
// Attacker tries to enumerate user emails
948948
for (let i = 0; i < 1000000; i++) {
949949
await register({ email: `user${i}@example.com` });
950-
// Response: "DUPLICATE_RECORD" or "VALIDATION_ERROR"
950+
// Response: "UNIQUE_VIOLATION" or "VALIDATION_ERROR"
951951
}
952952
```
953953

docs/adr/0130-release-artifact-as-co-ownership-boundary.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# ADR-0130: The release artifact is the co-ownership boundary — one artifact, N packages
22

3-
**Status**: Proposed (2026-09-01) — awaiting the maintainer's hand-merge, which is itself the
4-
acceptance act for a governed surface (Prime Directive #14). ⛔ Nothing below is settled until
5-
this record merges; the implementation cards are cut **from** the merged ADR, never ahead of it.
3+
**Status**: Accepted (2026-09-01) — accepted by the merge that landed it on `main` ([#14151](https://github.com/objectstack-ai/objectstack/pull/14151), commit `682d03ba7`), which is itself the acceptance act for a governed surface (Prime Directive #14).
64
**Scope bounded by the 2026-09-02 addendum**
75
([#14487](https://github.com/objectstack-ai/objectstack/issues/14487)): the permission matrix
86
§1.3(a) measures is **not** part of this boundary's payoff — permission sets stay whole in the

packages/drivers/driver-memory/src/memory-filter-text-conformance.test.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,11 @@ describe('[#6682] InMemoryDriver — text-operator conformance, the query path',
9494

9595
it('the fixture is all nine rows, stored verbatim', async () => {
9696
const rows = await driver.find(TABLE, { orderBy: [{ field: 'id', order: 'asc' }] });
97-
expect((rows as any[]).map((r) => [String(r.id), r.name]))
98-
.toEqual(FILTER_TEXT_ROWS.map((r) => [r.id, r.name]));
97+
expect((rows as any[]).map((r) => [String(r.id), r.name, r.score]))
98+
.toEqual(FILTER_TEXT_ROWS.map((r) => [r.id, r.name, r.score]));
99+
// [#14079] The premise of the non-string rows: `score` is stored as a
100+
// NUMBER on this driver, not stringified on the way in.
101+
for (const r of rows as any[]) expect(typeof r.score, `row ${r.id}`).toBe('number');
99102
});
100103

101104
for (const c of rowCases) {
@@ -137,7 +140,17 @@ describe('[#5374] the two general-purpose faces agree, case by case', () => {
137140
* this card are about.
138141
*/
139142
it('no case answers every row — a dropped predicate WIDENS', async () => {
143+
// [#14079] ONE case legitimately selects the whole fixture: `$notContains`
144+
// over the non-string column, whose declared answer IS every row (a number
145+
// never contains the substring, so every number "does not contain" it).
146+
// It is named here so the property stays a property, not a loophole — a
147+
// second whole-set answer is the widening this pin exists to catch, and a
148+
// dropped predicate on THAT case is caught by its positive twins (whose
149+
// declared answer is NO rows) and by the face-agreement row above.
150+
const WHOLE_SET = '$notContains is satisfied by every stored value that is not a string — complementarity holds';
151+
expect(rowCases.filter((c) => c.expected.length === ROWS.length).map((c) => c.name)).toEqual([WHOLE_SET]);
140152
for (const c of rowCases) {
153+
if (c.name === WHOLE_SET) continue;
141154
expect((await queryIds(driver, c.filter)).length, c.name).toBeLessThan(ROWS.length);
142155
expect(matcherIds(c.filter).length, c.name).toBeLessThan(ROWS.length);
143156
}
@@ -207,6 +220,9 @@ describe('[#6682] the analytics face answers the same text rules', () => {
207220
dimensions: {
208221
id: { name: 'id', label: 'Id', type: 'string', sql: 'id' },
209222
name: { name: 'name', label: 'Name', type: 'string', sql: 'name' },
223+
// [#14079] The fixture's non-string column, declared as the number it is
224+
// so the `score` rows reach this face through its own vocabulary.
225+
score: { name: 'score', label: 'Score', type: 'number', sql: 'score' },
210226
},
211227
} as unknown as Cube;
212228

@@ -247,12 +263,15 @@ describe('[#6682] the analytics face answers the same text rules', () => {
247263
.every((ops) => Object.keys(ops).every((op) => EXPRESSIBLE.includes(op))),
248264
);
249265

250-
it('covers the whole expressible subset — twelve cases, not an accidental one', () => {
266+
it('covers the whole expressible subset — fifteen cases, not an accidental one', () => {
251267
// Twelve since #8934: the infix `icontains` spelling's `%`-literal case is
252268
// computed through `parseFilterAST` and lands as `$icontains`, so it joins
253269
// this face's expressible subset automatically — exactly the mechanism the
254-
// selection note above promises.
255-
expect(analyticsCases.length).toBe(12);
270+
// selection note above promises. Fifteen since #14079: three of the five
271+
// non-string rows (`$contains` / `$icontains` / `$notContains` over
272+
// `score`) are in this face's vocabulary and join the same way; the
273+
// `$startsWith` / `$endsWith` pair stays outside it, refused loudly.
274+
expect(analyticsCases.length).toBe(15);
256275
});
257276

258277
for (const c of analyticsCases) {
@@ -275,3 +294,51 @@ describe('[#6682] the analytics face answers the same text rules', () => {
275294
expect(await analyticsIds({ name: { $icontains: 'CAFÉ' } })).toEqual(['3']);
276295
});
277296
});
297+
298+
/**
299+
* [#14079] `$like` / `$ilike` over a stored value that is not a string.
300+
*
301+
* The ruling names all six positive operators, but the shared table cannot
302+
* carry `$like` rows — a driver's enrolment is the whole table (rule 2 of its
303+
* header) and `driver-mongodb` refuses those two operators — so the pair is
304+
* pinned per face that answers it. Same shape as the table's `score` rows:
305+
* the positive pattern matches NOTHING, its `$not` matches EVERYTHING, on
306+
* both faces, and the two faces agree before either is checked against the
307+
* answer. Under coercion `'%5%'` would match seven rows and `'%0'` every row
308+
* on a REAL column (`5` renders `'5.0'`), which is the wrong answer the
309+
* assertion keeps out.
310+
*/
311+
describe('[#14079] $like / $ilike over a stored non-string value, on both faces', () => {
312+
let driver: InMemoryDriver;
313+
beforeEach(async () => { driver = await seed(); });
314+
315+
const ALL = FILTER_TEXT_ROWS.map((r) => r.id);
316+
const CASES: Array<[string, unknown, string[]]> = [
317+
['$like never matches a stored number', { score: { $like: '%5%' } }, []],
318+
['$like with a trailing wildcard never matches a stored number', { score: { $like: '%0' } }, []],
319+
['$ilike never matches a stored number', { score: { $ilike: '%5%' } }, []],
320+
['$not over $like admits every stored number — complementarity', { $not: { score: { $like: '%5%' } } }, ALL],
321+
['$not over $ilike admits every stored number', { $not: { score: { $ilike: '%5%' } } }, ALL],
322+
];
323+
324+
for (const [name, filter, expected] of CASES) {
325+
it(name, async () => {
326+
const fromQuery = await queryIds(driver, filter);
327+
const fromMatcher = matcherIds(filter);
328+
expect(fromMatcher, 'the reference matcher disagrees with the query path').toEqual(fromQuery);
329+
expect(fromQuery).toEqual(expected);
330+
});
331+
}
332+
333+
it('the reference matcher answers BOTH polarities for a valued non-string row — the #14079 cell itself', () => {
334+
// The measured defect: `{ n: 5 }` failed `$contains: '5'` AND
335+
// `$notContains: '5'`. A type test in place of the predicate says NO to an
336+
// operator and to its negation; the predicate says NO to one and YES to
337+
// the other.
338+
const row = { id: 'x', n: 5 };
339+
expect(match(row, { n: { $contains: '5' } })).toBe(false);
340+
expect(match(row, { n: { $notContains: '5' } })).toBe(true);
341+
expect(match({ id: 'y', n: 0 }, { n: { $notContains: '0' } })).toBe(true);
342+
expect(match({ id: 'z', n: true }, { n: { $notContains: 'true' } })).toBe(true);
343+
});
344+
});

packages/drivers/driver-memory/src/memory-matcher-no-value-negated-operators.test.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,21 @@ describe('[#13166] no-value rows and the negation-carrying operators', () => {
102102
expect(ids(MISSING, { name: { $contains: 'one' } })).toEqual(['1']);
103103
});
104104

105-
it('a present non-string value still fails $notContains on the type test', () => {
106-
// Out of scope for this card, and stated so a later reader does not read
107-
// the fix above as "any non-string satisfies the negation". Only the
108-
// no-value readings moved; a value that is there and is not a string
109-
// keeps the answer it had.
110-
expect(ids([{ id: '9', name: 42 }], { name: { $notContains: 'one' } })).toEqual([]);
105+
it('[#14079] a present non-string value SATISFIES $notContains — the predicate, not the type test', () => {
106+
// This pin used to assert the opposite (`toEqual([])`), stated as out of
107+
// #13166's scope: "a value that is there and is not a string keeps the
108+
// answer it had". That answer was NO to `$notContains` AND to
109+
// `$contains` for the same row — a type test standing in for the
110+
// predicate — and it disagreed with this package's own live mingo path.
111+
// The maintainer ruled the cell on 2026-09-05 (option A, type-gate):
112+
// a stored value that is not a string never satisfies a positive text
113+
// operator and always satisfies `$notContains`. Flipped here in its new
114+
// direction, and asserted with substance: the row IS in the negation,
115+
// is NOT in the positive twin, and the two still partition the rows.
116+
const rows = [{ id: '1', name: 'alpha-one' }, { id: '9', name: 42 }];
117+
expect(ids(rows, { name: { $notContains: 'one' } })).toEqual(['9']);
118+
expect(ids(rows, { name: { $contains: 'one' } })).toEqual(['1']);
119+
expect(ids([{ id: '0', name: 0 }, { id: 'f', name: false }], { name: { $notContains: 'one' } })).toEqual(['0', 'f']);
111120
});
112121
});
113122

0 commit comments

Comments
 (0)