Skip to content

Commit 16c8ac7

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-7102-bulk-file-field-refusal
2 parents 4f6cff6 + 3566e55 commit 16c8ac7

47 files changed

Lines changed: 4317 additions & 367 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
---
2+
"@objectstack/formula": patch
3+
---
4+
5+
fix(formula): the ADR-0032 §1c retry rewrites only the operands that faulted (#7098)
6+
7+
**A CEL expression could return a silently wrong boolean.** No fault, no log
8+
line, no failing test — `{ ok: true }` with the wrong answer. If you have
9+
compound CEL that mixes a numeric comparison with a string equality over
10+
string-serialized fields, read the "which expressions change answer" list below:
11+
those expressions answer differently after this fix, and the new answer is the
12+
right one.
13+
14+
## What was wrong
15+
16+
When a comparison faults on a string-serialized numeric or date field
17+
(`record.rating >= 4` where `rating` reads back as `"5.0"`#1530 / #1534),
18+
ADR-0032 §1c hydrates and retries. The retry hydrated the **entire scope** and
19+
re-ran the **entire expression**, justified by a docblock claim that it
20+
21+
> can never change a comparison that already evaluated cleanly — it only rescues
22+
> one that already faulted.
23+
24+
That claim was false, and it was load-bearing: it was the stated reason the
25+
hydration was allowed to be unconditional and scope-wide. The retry knows only
26+
that the *whole expression* faulted, not that each sub-comparison did. So:
27+
28+
```text
29+
record.n >= 4 && record.s == "5.0" with { n: "7", s: "5.0" }
30+
before -> { ok: true, value: false } after -> { ok: true, value: true }
31+
```
32+
33+
`record.n >= 4` faults and is correctly rescued. But `record.s` was hydrated to
34+
the number `5` as well, so the author's deliberate string equality — **true**
35+
when it was evaluated the first time — became `5 == "5.0"`, which CEL answers
36+
`false` across types. The expression returned `false`, and nothing reported that
37+
a clean answer had been overruled.
38+
39+
## Which expressions change answer
40+
41+
Only expressions that **already reached the §1c retry** — i.e. some operand
42+
faulted `no such overload`. Everything that evaluates without faulting is
43+
untouched. Within that set, an expression changes answer when it also contains:
44+
45+
- **a string equality / inequality on a numeric-looking or ISO-date field**
46+
`record.n >= 4 && record.s == "5.0"`, and the `!=` and ternary forms. Now
47+
answers on the string the author wrote.
48+
- **a string membership test**`record.s in ["5.0", "x"]`.
49+
- **the same field compared as a number in one place and as a string in
50+
another**`record.n >= 4 && record.n == "7"`. Both answers are now correct
51+
at once; previously the second was collateral damage from the first.
52+
- **a numeric-looking string the expression RETURNS rather than compares**
53+
`record.n >= 4 ? record.s : "none"` returned the number `5`; it now returns
54+
the string `"5.0"`. A `Field.formula` of type text was storing a different
55+
value than the record held.
56+
57+
One class becomes a **loud fault where it used to be silently rescued**: an
58+
operand whose value the rewrite cannot read before deciding — bound by a
59+
comprehension (`record.items.exists(i, i.price > 100)`), or behind a computed
60+
index. That is the deliberate trade of this fix. Rescuing an operand we cannot
61+
prove faulted is exactly the defect being closed, so those report the original
62+
`no such overload` instead of guessing. The reported error is unchanged in shape
63+
and message.
64+
65+
## What replaces it
66+
67+
The coercion is now **per operand position** — the same discipline
68+
`rewriteTemporalEquality` already documents ("no field-wide trade-off"), one
69+
step stricter. The scope is never rewritten; the faulting operand is wrapped in
70+
`double(…)` or `date(…)` in place. An operand is rewritten only when all three
71+
hold, which makes the docblock's guarantee true by construction rather than by
72+
assertion:
73+
74+
1. the operator **raises** on a string-versus-number/Timestamp pair instead of
75+
answering one, so the comparison cannot have produced an answer;
76+
2. the counterpart is a number or a Timestamp **in this scope**, read off the
77+
values in hand rather than off a static type (every field is `dyn` under
78+
`unlistedVariablesAreDyn`);
79+
3. the operand's own value is a §1c serialization artifact — an entirely-numeric
80+
string or an ISO-8601 date. A zip like `"02134"`, or free text, still faults
81+
loudly.
82+
83+
Measured per operator on cel-js 8.0.0 and pinned in the new tests: `<` `<=` `>`
84+
`>=` `+` `-` `*` `/` `%` **fault** on a mixed pair and are eligible. `==`, `!=`
85+
and `in` **answer** across types — CEL equality is total — so they already had
86+
an answer and are never rewritten. That measurement is the root of the defect:
87+
the string equality above never faulted at all.
88+
89+
`Field.date` strings not matching a Timestamp under `==` remains owned by
90+
`rewriteTemporalEquality`, which wraps them statically on the clean path, where
91+
both sides are known from the source instead of inferred from an unrelated
92+
conjunct's fault.
93+
94+
## Reach
95+
96+
`celEngine.evaluate` — the only home of this retry — does **not** reach RLS.
97+
Row-level security compiles its `using` / `check` predicates through
98+
`compileCelToFilter` (SQL pushdown) and `matchesFilterCondition` (write-side
99+
post-image), and declared sharing rules do the same; neither calls this
100+
evaluator. No access-control decision could be inverted by this.
101+
102+
It does reach write-gating decisions, which is why the behaviour was not
103+
acceptable as documented: validation-rule predicates and `when` conditionals,
104+
`readonlyWhen`, hook `condition`s, automation/flow conditions, and formula
105+
fields and default values. A validation rule is **fail-closed** on a fault
106+
(#4649) — but a silently flipped boolean is not a fault, so a rule that should
107+
have rejected a write instead read as "not violated" and let it through.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/metadata": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
fix(metadata,objectql): stop restating the object name inside driver queries — and stop casting away the query's type to do it (#6231)
7+
8+
`DriverQuery` (`Omit<QueryAST, 'object'>`) landed in #6076 and five drivers
9+
followed in #6075, but five **call sites** stayed as they were, because they
10+
were hidden behind a cast where the compiler could not see them. This removes
11+
the redundant key at all five and, with it, the casts that existed only to
12+
carry it.
13+
14+
The redundant key was never the expensive half. `git grep 'query\.object' --
15+
'packages/drivers/*/src'` is zero: no driver reads it, so the key itself was
16+
inert. **The cast was the cost.** `as any` on a query argument does not
17+
suppress one key — it switches off checking for `where`, `orderBy` and
18+
`fields` as well, which is precisely the account #5181's changeset opened
19+
(cloud#1053 measured 20 such sites; cloud#1030's `$like` — an operator the
20+
filter dialect does not have — survived compilation and reached the runtime
21+
through exactly this hole). `packages/metadata`'s `DatabaseLoader` is the
22+
main metadata read path, so it was the worst place to be running unchecked.
23+
24+
The five sites:
25+
26+
- `metadata` `DatabaseLoader._find` / `._findOne` / `._count` — each was
27+
`driver.find(table, { object: table, ...query } as any)`. The helpers now
28+
declare `query: DriverQuery` and hand it to the driver unchanged and uncast,
29+
so all nine of their call sites' `where` / `orderBy` / `fields` are checked
30+
again.
31+
- `objectql` `ObjectQL.resolveSecret` — the `sys_secret` read was
32+
`{ object: 'sys_secret', where: { id } } as QueryAST`, where the cast existed
33+
only to satisfy the AST's then-required `object`. Both are gone.
34+
- `objectql` `LifecycleService` governance counter — `count(obj.name,
35+
{ object: obj.name })` carried no cast; it was admitted by a hand-written
36+
driver shape whose `query` was `Record<string, unknown>`, which would equally
37+
have admitted a `where` the dialect does not have. That shape is now the named
38+
`CountCapableDriver` typed with `DriverQuery`, and the call passes argument
39+
one only.
40+
41+
No behaviour changes: the key was inert on every path, and the object name has
42+
always travelled as the driver methods' first argument. What changes is that
43+
these call sites are type-checked again, and that re-adding the key is now a
44+
compile error (`TS2353`) rather than something a cast quietly absorbs.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): put `having`'s operator refusals inside the ADR-0112 envelope (#7047)
6+
7+
`having-filter.ts`'s `unknownOperator()` returned a bare `new Error(...)` from
8+
**both** of its branches — the RETIRED spellings (`$regex`, `$options`) and the
9+
unknown ones (`$nand`, `$median`, a mistyped `$icontain`) — so the thrown error
10+
carried `code: undefined` and `status: undefined`. `rest` served it through the
11+
unclassified-fault branch, and a **400-class author mistake reached the client
12+
500-shaped**.
13+
14+
This is the last of the five filter-refusal faces to join the envelope, and the
15+
only one that disagreed. Measured by EXECUTING each face rather than by grep
16+
(#6993), before and after:
17+
18+
| face | `code` before | `code` after |
19+
|:--|:--|:--|
20+
| driver-sql, driver-sqlite-wasm, driver-turso (local + remote) | `INVALID_FILTER` / 400 | unchanged |
21+
| driver-memory (`filter-refusal.ts`), driver-mongodb | `INVALID_FILTER` / 400 | unchanged |
22+
| **objectql `having`** | **`undefined` / `undefined`** | **`INVALID_FILTER` / 400** |
23+
24+
The refusal itself, and its message, are unchanged — the retired branch already
25+
printed `RETIRED_FILTER_OPERATORS[op].why` verbatim like the four driver faces.
26+
Only the envelope was missing, which is the half of #5324 that a refusal does
27+
not fix on its own and the half `FilterTextRejectionCase.code` exists to pin.
28+
The code is `INVALID_FILTER` because this joins the contract the other four
29+
already speak; a caller swapping HAVING for a driver-side `where` must not have
30+
to catch two shapes for one mistake.
31+
32+
**Client-visible change.** Code catching a `having` refusal by message
33+
substring, or branching on the absence of `err.code`, sees `INVALID_FILTER` /
34+
400 where it previously saw an uncoded `Error`. Over HTTP the status moves from
35+
500 to 400, which is the point of the change.
36+
37+
Both `unknownOperator()` returns are covered, deliberately: enveloping only the
38+
retired path would have left `{ $nand: [...] }` and every operator typo
39+
arriving 500-shaped — the same defect, one operator name away, and the more
40+
likely of the two to be typed.
41+
42+
The envelope constructor is now shared with the package's other filter-refusal
43+
site (`filter-comparand-shape.ts`'s `invalidFilterError`, exported for this)
44+
rather than copied, so objectql's two refusal sites cannot answer one mistake
45+
with two envelopes.
46+
47+
Test coverage moved with it. The rejection assertions in `having-filter.test.ts`
48+
were `toThrow(/message/)` only, which is green whether or not the error carries
49+
an envelope (#6142/#6050) — that is how the defect survived the PR that wrote
50+
those messages. They now pin `code` + `status` + the verbatim prescription, on
51+
both branches and through `applyHaving`, the entry point the engine calls. A new
52+
`having-filter-text-conformance.test.ts` drives this face against
53+
`FILTER_TEXT_CASES` — the standard the driver suites answer — so the faces
54+
cannot drift apart silently again; `having` had no conformance-table coverage at
55+
all, which is why both of the last two defects on it (#5905, this one) were
56+
found by a hand-run census rather than by CI.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
docs(plugin-security,skills): re-premise `member_default`'s removed wildcard in the published customer skill and in the plugin's own README (#7151)
6+
7+
Two shipped documents still described a permission-set shape the platform has not
8+
had for two releases. Both premises were re-measured against the real imported
9+
`defaultPermissionSets` at this branch point, and both had expired:
10+
11+
- `member_default.objects['*']` is `undefined` — the plain `'*'` object grant was
12+
removed when the platform baseline narrowed to explicit-allow.
13+
- Neither `member_default` nor `viewer_readonly` carries a `tenant_isolation`
14+
entry in `rowLevelSecurity`, and neither carries any wildcard tenant policy at
15+
all. Tenant isolation is **Layer 0** (`tenant-layer.ts`) since ADR-0095 D1.
16+
17+
**`packages/plugins/plugin-security/README.md`** described the pre-ADR-0095
18+
probe-and-strip mechanism as the plugin's own current behaviour ("Service present
19+
→ keeps the wildcard `tenant_isolation` RLS policy … shipped with the default
20+
`member_default` / `viewer_readonly` permission sets"). Rewritten to the real
21+
mechanism: the plugin resolves a tenancy **posture** at start time; the tenant
22+
wall is Layer 0, AND-composed ahead of business RLS and inert under `single`; and
23+
the strip that survives targets the platform's own tenant-scoped policies **by
24+
provenance** (`organization_admin`'s `sys_member_org` / `sys_invitation_org` /
25+
`sys_team_org`, the `sys_organization_self` carve-out), never an app-authored
26+
policy — which reaches the compiler and fails closed there (ADR-0105 D3).
27+
28+
**`skills/objectstack-data/SKILL.md`** (published customer guidance) did not
29+
merely mention the wildcard — its ⚠️ callout built a recommendation on a leak
30+
that cannot happen. The recommended recipe
31+
(`tenancy: { enabled: false }` + `requiredPermissions`) is unchanged and still
32+
correct, but every stated reason for it was rewritten to the measured one:
33+
34+
- the empty-list symptom is the Layer 0 tenant wall denying rows whose
35+
`organization_id` is null or absent, not a `member_default` RLS policy;
36+
- `viewAllRecords` short-circuits business RLS only and never crosses the wall —
37+
that takes a true platform admin (the superuser bit **and** a
38+
platform-exclusive capability) on a posture that permits it;
39+
- the ⚠️ now names the surviving hazard truthfully. `tenancy: { enabled: false }`
40+
alone switches the wall off for every caller, and the risk is any permission
41+
set with a wildcard read grant — the shipped `viewer_readonly` still has one —
42+
not `member_default`, which grants only the objects it names.
43+
44+
No runtime behaviour changes; documentation only.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): a nested plugin registers `jobs` / `emailTemplates` / `tools` / `skills` — four collections it silently dropped
6+
7+
**This changes boot behaviour for packages that already ship today.** A package
8+
whose artifacts arrive through a nested plugin (`manifest.plugins[]`) and that
9+
declares any of `jobs`, `emailTemplates`, `tools` or `skills` previously
10+
registered **nothing** for those collections: no refusal, no diagnostic, no
11+
ADR-0010 provenance stamp. After this change the same package registers them,
12+
stamped to the parent package — so `/meta/job`, `/meta/email_template`,
13+
`/meta/tool` and `/meta/skill` begin answering for it, the email plugin's
14+
`sys_email_template` materializer (#4509) begins seeing its templates, and the
15+
AI protocol begins resolving its tools and skills. Anything that has been
16+
compensating for the silence — a duplicate declaration hoisted to the top-level
17+
manifest, a hand-seeded `sys_email_template` row — will now find the collection
18+
already registered.
19+
20+
`engine.ts` reaches the provenance-stamping seam (`registerItem`
21+
`applyProtection`, the only place `_packageId` / `_provenance` are written) from
22+
two entry points, and each carried its **own copy** of the collection list. The
23+
copies had drifted by exactly those four. `capabilities` hit the same divergence
24+
and was patched into the second copy by hand (#5870) without the rest of the two
25+
lists being diffed, which is how these four survived it.
26+
27+
So the copies are gone rather than reconciled: both entry points now read one
28+
module-scope `METADATA_ARRAY_KEYS`. The two loops were measured against each
29+
other first — they differ in which object they read, which package id they stamp
30+
(both resolve to the same parent package), a per-key `debug` line, and the
31+
manifest seam's aggregated-view expansion and warn-on-nameless-item. Every one
32+
of those is a loop-body difference; none is a reason for the two seams to
33+
enumerate different collections. `check:stack-collection-maps` correspondingly
34+
pins one ObjectQL enumeration instead of two, and its waiver row recording the
35+
divergence is removed with the divergence (#6242's ratchet handshake).
36+
37+
Refs: #7049, #6242, #5870, #4509, ADR-0010.

0 commit comments

Comments
 (0)