Skip to content

Commit e702ebd

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19961-decision-branch-expression-absent
2 parents 266cd04 + 3f86dc5 commit e702ebd

52 files changed

Lines changed: 3130 additions & 134 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.

‎.changeset/19953-rls-check-default-composition-text.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Text only. No schema shape, accepted value or runtime behaviour changes.
1616

1717
A policy is applicable when it is not `enabled: false`, its `object` is the written object or `'*'`, its `operation` is the write's own or `'all'`, and the caller holds one of its `positions` when it lists any. A `check` on a `select` or `delete` policy is never evaluated.
1818

19-
The check runs on the new row of a single-record insert and of a by-id update. An array insert and a `multi: true` update are not post-image checked; those are tracked in #19964 and #19950, and the texts now say so instead of implying every insert and update is checked.
19+
When this text change was written, the check ran on the new row of a single-record insert and of a by-id update only, and the texts said so. The same release extends it: every row of an array insert and of a `multi: true` update is judged (#19964, #19950), and a by-id update is also judged on the row its `beforeUpdate` hooks leave (#19989). The texts now state that every row an insert or an update writes is judged (#19967).
2020

2121
- **`@objectstack/spec`**: the `check` describe and TSDoc state this composition and that scope. The `rowLevelSecurity[].priority` refusal no longer gives "applicable policies OR-combine (most permissive wins)" as its reason, which is not true of the write check, and the file overview limits "OR-combine" to reads. The generated reference pages (`references/security/rls`, `references/security/permission`) are regenerated from the describe.
2222
- **`@objectstack/lint`**: the `rls-predicate-*` findings on a `using` now also say what the dropped `using` does to an insert. On an `insert` or `all` policy, when no applicable policy for the insert declares a `check`, that `using` is also the single-record insert check. If nothing else in that set compiles, every single-record insert the policy governs is refused with `PermissionDeniedError`. The findings on a `check` now say the refusal is a blanket one only when no other applicable policy declares a `check` that compiles.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): the RLS `using` / `check` texts say what the write gate enforces today — every row an insert or an update writes is checked, a check-only `update` policy is legal, and an `insert` policy's `using` is its check when none is declared (#19967)
6+
7+
Clause-②: no
8+
9+
Text only. No schema shape, accepted value or runtime behaviour changes.
10+
11+
- **`RowLevelSecurityPolicySchema.check`**: the describe and TSDoc said the check ran on the new row of a single-record insert or a by-id update, and that an array insert and a `multi: true` update were not post-image checked. Every insert and update shape is now judged: each row of an insert, an array insert included, as its `beforeInsert` hooks leave it, and each row an update changes, by id or `multi: true`, as the prior row merged with the final payload after its `beforeUpdate` hooks. One failing row refuses the whole write. A by-id update is also judged, before its hooks, on the change set as sent.
12+
- **`RowLevelSecurityPolicySchema.using`**: the describe called it a filter for SELECT/UPDATE/DELETE and "optional for INSERT-only policies", and the TSDoc said UPDATE requires it. It now says, per operation, which rows it admits, that it stands in as the check on an insert or update when no applicable policy declares `check` (on an `insert` policy that is its only effect), that a `select` or `delete` policy needs it, and that an `insert`, `update` or `all` policy may declare `check` alone.
13+
- **The "at least one of `using` or `check`" refusal**: its head is unchanged. It no longer says an UPDATE policy must provide `using` or that an INSERT policy must provide `check`. It names what each operation takes.
14+
- **OR-combination**: the schema overview, the `priority` tombstone notes and the `os migrate meta --from 16` prose for the `priority` removal no longer say applicable policies OR-combine with "most permissive wins" without qualification. That holds on reads. On a write, the check is chosen per operation across the applicable policies first, and the chosen predicates then OR-combine.
15+
- **Default deny**: the overview now limits "default deny" to the policies that apply. A caller to whom no policy applies is not restricted by the policies; the tenant wall still applies.
16+
- The generated reference pages (`references/security/rls`, `references/security/permission`) and `docs/protocol-upgrade-guide.md` are regenerated from these sources.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/cli': minor
5+
---
6+
7+
RLS policies are admitted when they are authored: the engine judges every read-scope `using`, at the save door and at `os validate` / `os build` / `os lint`
8+
9+
A row-level-security policy (`rowLevelSecurity[]` on a permission set) could carry a `using` predicate that lowers cleanly and that the engine then refuses to run: a text operator (`startsWith` / `endsWith` / `contains`) aimed at a number field, a date field compared against a value its storage cannot read, a filter on a virtual (formula) field, or a `{…}` placeholder string. Nothing refused it when it was written. The first answer was a refused analytics query, long after the author had moved on. And the metadata save door (Studio, REST `/meta`, MCP) did not run the RLS predicate rule at all, so a predicate `os validate` already refused was accepted there.
10+
11+
- **The engine's own verdict.** `validateRlsPredicateEnforceability` takes the engine's judge-only filter admission (`IObjectQLEngine.judgeFilter`) as an optional input and judges the lowered `using` of every `select` / `all` policy with it. A refusal is reported under the existing id `rls-predicate-unenforceable`, and the message quotes the engine's code, status and sentence verbatim. The rule never models the engine's checks: without the input it answers exactly as before.
12+
- **Both doors hand in a real engine.** The metadata save door probes its host engine for `judgeFilter` and passes the bound method through the publish gate. The CLI commands build an engine with no driver from the stack's own objects and pass its method.
13+
- **The save door now runs the rule for `permission` writes** (`surfaces: ['cli', 'runtime-publish']`, `runtimeTypes: ['permission']`), so every predicate `os validate` refuses is refused there too, as a `422 INVALID_METADATA` whose `issues[]` carries the same sentence.
14+
- **New optional inputs.** `AuthoringRuleContext.judgeFilter` (and so `AuthoringRuleRun.judgeFilter` for `runAuthoringRules`), the `judgeFilter` argument of `runRuntimeAuthoringRules`, and an optional second parameter of `validateRlsPredicateEnforceability`. A caller that passes nothing gets the previous verdicts.
15+
16+
**BREAKING**: a permission set whose read-scope `using` the engine cannot run now fails `os validate` / `os build` / `os lint`, and a publish of it through the metadata save door is refused with `422`. Stored rows keep being read, and a re-save of one is judged like any other publish. `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns the save-door refusal into a logged warning for a migration window. The refusal's hint names the fix for each class: write the caller's value as a `current_user` key rather than a `{…}` placeholder, point a text operator at a field that holds a string, compare a date field against a value its storage reads, or denormalise a computed value onto a stored field. Every policy authored in this repository, in its examples and in the default permission sets was measured, and none is refused.
17+
18+
Two edges are not closed here, both deliberately:
19+
20+
- The judge sees only `using` clauses in the read scope. A `check` is matched in memory against the post-image and never reaches the engine's filter admission.
21+
- At the save door, the judge reads the engine's live registry. An object that exists only in the same publish batch, or only in an organization overlay, is one that registry does not hold, so it gets the engine's unknown-object answer: no field-type verdict, while the placeholder and comparand checks still run. At the CLI door, an object the stack does not define gets the same answer.
22+
23+
Clause-②: yes (narrowing)
24+
25+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves: no `packages/spec` key, schema, export or config field is removed or renamed, and no stored metadata representation changes, so `objectstack migrate meta` has nothing to rewrite. What is refused is a predicate VALUE the engine's own filter admission already refuses whenever it is asked to run it; the refusal now arrives when the policy is authored. The new lint inputs are optional additions. -->

‎.changeset/20203-epoch-ms-date-comparand.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ The rule is shared by the drivers' write and read paths too:
2828
- `create()` / `update()` on either driver, given a number for a `date` field, stores its UTC day. Before, `driver-memory` and SQLite stored the number, and PostgreSQL refused the statement. The engine and REST write doors refuse a number on a `date` field before a driver sees it (`VALIDATION_FAILED`), as before.
2929
- A number already stored in a SQLite `date` column is read back as its UTC day by `find()`, a `groupBy` key and `distinct()`. Only a direct driver write could have put one there.
3030

31-
Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field, and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.
31+
Not changed, measured identical before and after: `NaN`, ±Infinity, a number outside the `Date` range (past ±8.64e15), a bigint, an epoch-millisecond string, every `Date` and every string on a `date` field (#20240, in the same release, then pads a `Date`'s or a number's year 0..999 to four digits and refuses one whose year falls outside 0..9999, a number past the `Date` range included), and every `datetime` and `time` reading. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/driver-sql": patch
5+
"@objectstack/driver-memory": patch
6+
---
7+
8+
fix(core,objectql)!: a number or `Date` compared against a `date` field spells its year with four digits, and one whose UTC year falls outside 0..9999 is refused `INVALID_FILTER` / 400, as its ISO string already was (#20240)
9+
10+
Clause-②: no (narrowing)
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) a query-time refusal of a comparand VALUE on a `date` field: no authorable key, spelling or stored shape moves, `packages/spec` is untouched, and a stored row keeps the form it has. What is refused is a number or `Date` whose day has no `YYYY-MM-DD` form, and which in-range day the caller meant is not something a ledger entry can decide. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a comparand value (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
13+
14+
**BREAKING**: this narrows what a filter on a `date` field accepts. A number or `Date` whose UTC calendar day falls in a year below 0 or above 9999 used to answer 200 with the wrong rows, or a 500 on PostgreSQL; it now answers `INVALID_FILTER` / 400. It ships as `minor` under the launch-window convention for accept-set narrowings.
15+
16+
`temporalStorageForm(value, 'date')` in `@objectstack/core` spelled the year of a `Date` or an epoch-millisecond number unpadded: `999-06-15`, `10000-01-01`, `-1-01-01`. The ISO string and the bare day of the same instant spelled `0999-06-15`, and as text an unpadded year sorts as no day does. Measured through `engine.find` / `engine.aggregate` and `POST /data/:object/query` (the two doors agree), on a `date` field holding six 2026 days and 0999-06-15, `$gt` / `$lt` / `$eq`:
17+
18+
| position | comparand | before: memory · SQLite · PostgreSQL | now, on all three |
19+
|:--|:--|:--|:--|
20+
| `where` | a number (or, in-process, a `Date`) for 0999-06-15 | 0/7/0 · 0/7/0 · 6/0/1 | 6/0/1 |
21+
| per-aggregation `filter` | the same | 0/7/0 on all three | 6/0/1 |
22+
| `having` on `max(date)` | the same | no group / every group / no group | the three 2026 groups / none / the 0999 group |
23+
| `where` | a number (or `Date`) for 10000-01-01 | 6/1/0 · 6/1/0 · 0/7/0 | `INVALID_FILTER` / 400 |
24+
| `where` | a number (or `Date`) for -1-01-01 | 7/0/0 · 7/0/0 · `DATABASE_ERROR` (500) | `INVALID_FILTER` / 400 |
25+
| per-aggregation `filter` | either of those two | 6/1/0 and 7/0/0 on all three | `INVALID_FILTER` / 400 |
26+
| `where`, per-aggregation `filter` | the ISO string of either | `INVALID_FILTER` / 400 | unchanged |
27+
28+
What changes:
29+
30+
- The rule pads a year from 0 to 999 to four digits, for a `Date` and a number alike, so a number, its `Date` and its ISO string spell one day. `driver-sql` (`toDateOnly`, `temporalFilterValue`), `driver-memory` (`coerceTemporalValue`) and the engine's per-aggregation `filter` and `having` all call it.
31+
- `isUninterpretableTemporalComparand('date', value)` is now also true for a finite number or a valid `Date` whose UTC year is below 0 or above 9999, a finite number past the `Date` range (±8.64e15) included. The engine's temporal-comparand door refuses such a comparand on `where` for every verb (`find`, `findOne`, `count`, `aggregate`, `update`, `delete`), in both the object and the array spelling, and in a per-aggregation `filter`, before any driver read. `IObjectQLEngine.judgeFilter` runs the same door.
32+
- The write path: `create()` / `update()` on `driver-memory` or SQLite, given a year-0..999 number or `Date` for a `date` field, now stores `0999-06-15` where it stored `999-06-15`; `engine.insert` of such a `Date` does the same. PostgreSQL and MySQL already stored a three-digit year's day, but not a shorter one: under its default `DateStyle` (`ISO, MDY`) PostgreSQL stored the unpadded `9-03-04` as 2004-09-03 and refused `99-03-04` (`22008`), and MySQL 8.0 stored `99-03-04` as 1999-03-04. All three dialects now store the day. A year outside 0..9999 keeps the spelling it had on the write and read paths; no ordered form is invented for it.
33+
34+
**Who is affected.** A caller that compares a `date` field with an epoch-millisecond number or a `Date` in a year below 0 or above 9999. No writer that stores or queries such a day has been measured; the reach is the public query door.
35+
36+
**Fix.** Compare against a `YYYY-MM-DD` day, or a number or `Date` whose UTC calendar day falls in a four-digit year.
37+
38+
**Unchanged**, measured identical before and after on memory, SQLite and PostgreSQL through the engine and REST: every `datetime` and `time` cell, the same numbers included; every string comparand on a `date` field; every number and `Date` in the years 1000 to 9999; `NaN`, ±Infinity and an Invalid Date, which name no year and are not judged; and every read-path presentation on those three. On MySQL, measured at the driver door, a stored year from 100 to 999 now reads back padded (`0999-06-15`, where it read `999-06-15`); a stored year below 100 still reads back a century late (`0009-03-04` as `1909-03-04`, mysql2's `Date.UTC` reading of a `DATE`), which this change does not touch. `having` does not reach the temporal-comparand door for any comparand, so a number or `Date` outside 0..9999 there is still compared as written. `service-analytics`' raw-SQL decline reads a time dimension by the `datetime` rule, so its answer does not move. `driver-mongodb` keeps its own copy of the `date` rule and is not changed here.

‎.changeset/rls-check-defaults-to-using.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Clause-②: no (narrowing)
1010

1111
**BREAKING**: this narrows the set of writes the write gate accepts. A write that is admitted today can be refused after this change. It ships as `minor` under the launch-window convention, the same way the insert-side `check` reorder did (#16805).
1212

13-
The published contract has always said this. `RowLevelSecurityPolicySchema.check` reads "defaults to USING clause if not specified", and PostgreSQL treats a policy without `WITH CHECK` the same way. The write gate did not do it. It compiled only the policies that declared `check`, so a policy with only a `using` never checked a write. With `using: "record.status != 'closed'"`, a caller could INSERT a closed row. The row was stored even though the same caller could not read it afterwards.
13+
The published contract has always said this. `RowLevelSecurityPolicySchema.check` read "defaults to USING clause if not specified" (it now states the default per operation across the applicable policies, #19953), and PostgreSQL treats a policy without `WITH CHECK` the same way. The write gate did not do it. It compiled only the policies that declared `check`, so a policy with only a `using` never checked a write. With `using: "record.status != 'closed'"`, a caller could INSERT a closed row. The row was stored even though the same caller could not read it afterwards.
1414

1515
**Writes that are now refused.** Each refusal is the existing row-level CHECK denial, `403 PERMISSION_DENIED`, and nothing is stored. There is no transition switch.
1616

‎.claude/skills/pm-dispatch/references/contract-review.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@
1414
- 同形 = `## Contract review` 题头、所审 head sha 独占码段、①②③ 逐项、独立性对、PASS/FAIL。
1515
- 同形含首行 `Served-tier:`:值写常量名 `CONTRACT_REVIEW_TIER`;无此行不成裁决,
1616
模板见 `node scripts/pm/record-recognisers.mjs --template`。
17+
- 同形另带 `Local-runs:` 行:`none`,或 `probe — REASON` 具名唯一本地探针;
18+
有行必校,缺行旧记录不改判。
1719
- ① derived judgments 逐项:diff 引出的接受集与公开面变化逐条点名判对错。
1820
- ② semver 定级与 changeset 声明一致;③ 边界旗:dev 挂旗与 `open_questions` 逐旗答复或升级。
1921
- 复核形状:只读 diff 与卡片,check 结论取 head 的 check-runs,⛔ 永不本地重跑派生门禁族。
2022
- 只喂卡片、既有裁决与 PR 本体,⛔ 不喂派发令与派发席自己的结论;简报写成对抗性。
23+
- 简报从 `node scripts/pm/record-recognisers.mjs --brief-template` 起稿:
24+
只读条款与输入集内建,⛔ 不删 ⛔ 行。
2125
- 隔离复核子代理暂存全写按所审 PR 命名的 `<scratchpad>/pr-<n>/`,⛔ 不读非本轮自写的暂存。
2226
- 独立性对:`Implemented-by:`/`Reviewed-by:` 取值见
2327
`node scripts/pm/record-recognisers.mjs --template`;子代理记采纳它的席位。

‎.claude/skills/pm-dispatch/references/execution-duties.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@
104104
- 豁免够不到的地板只有维护者裁决能设。
105105
- 席位档策略:skills 车道外的执行席与分诊席默认判断档会话。
106106
- 条款②自审资格按车道:spec 与 skills 席达档席内审;未达档 ⛔ 不自审,起隔离达档子代理。
107-
- 降档出口两条:额度耗尽豁免,与主动预降(余量吃紧可预先降档)。
108-
- 额度耗尽豁免仅当契约复审档实测不可用才落默认判断档,⛔ 不再往下。
107+
- 降档出口两条:额度耗尽豁免(达档实测不可用才落默认判断档,⛔ 不再往下)与主动预降。
108+
- 额度墙只向下(降档或降并发)⛔ 永不向上;临时偏离记座位贴带结束条件,逐次巡检重读。
109109
- 降档的档位与理由记入认领评论 Container & model 行;档位逐次派发显式传参,永不省略。
110110
- 解析顺序、pin 语义与允许名单回退陷阱以 os-dev 定义 frontmatter 注释为权威。
111111
- 分诊 suggestion 行是输入不是决定,不采纳给理由。

0 commit comments

Comments
 (0)