Skip to content

Commit 42ecba3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-17321-conversion-todo-channel
2 parents bb0a830 + a9fb83e commit 42ecba3

281 files changed

Lines changed: 8417 additions & 1928 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/15293-non-array-packages-refusal.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ Clause-②: no
99
`packages` is declared as an array of package entries (`ObjectStackDefinitionSchema.packages: z.array(ArtifactPackageSchema).optional()`), and the rule is now written down once, beside `AssembledPackageBodySchema` in `@objectstack/spec`: an absent `packages` means a single-package artifact, and any other non-array value is malformed and refused. `resolveArtifactPackageOrder` in `@objectstack/core` already refused it, and so did the i18n detector in `@objectstack/plugin-dev` and the default-permission-set reader in `@objectstack/plugin-security`.
1010

1111
- **What changes**: `AppPlugin` reads its collections in `start()`, and `start()` now raises the same refusal `init()` already raised through the kernel's `manifest` service. Under `os dev`, `DevPlugin`'s child-`start()` loop logs it on its `error` line, where before the app started on its top-level collections alone. `createStandaloneStack` now refuses such an artifact while it builds the stack. Before, the refusal came later, when the app registered with the `manifest` service. `loadArtifactBundle`'s runtime-module merge reports it through its existing `warn` line and skips the merge, as it already does for a malformed `packages[]` entry. `resolveProjectDatabaseUrl` no longer reads a default datasource out of such an artifact: it declines, as it already does for any artifact it cannot read, and moves on to the next rung (the unified default database). The boot that loads the artifact then refuses it.
12-
- **What does not change**: an absent `packages`, and `packages: null`, still return the caller's own object by identity. A well-formed `packages[]` resolves exactly as before.
12+
- **What does not change**: an absent `packages` still returns the caller's own object by identity. A well-formed `packages[]` resolves exactly as before. `packages: null` is not absent: it is malformed, and it is refused the same way (#19926).
1313
- **Fix**: remove the `packages` key for a single-package artifact, or make it an array of `{ manifest: … }` entries.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/plugin-dev': minor
5+
'@objectstack/plugin-security': minor
6+
---
7+
8+
fix(core,runtime,plugin-dev,plugin-security): a release artifact whose `packages` is `null` is refused as malformed, never read as absent (#19926)
9+
10+
Clause-②: no (narrowing)
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, Zod schema, export, config field or stored metadata shape is added, removed, renamed or re-spelled. ObjectStackDefinitionSchema already refused packages null, and so did composeStacks with two or more inputs, so an authored stack that passed its schema reads exactly as before; what narrows is the runtime readers' behaviour on an artifact that bypassed that parse, and objectstack migrate meta has no document to rewrite for it. -->
13+
14+
**BREAKING** — an accept-set narrowing on a value the schema already refuses, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level).
15+
16+
`ObjectStackDefinitionSchema.packages` is `z.array(ArtifactPackageSchema).optional()`, and `.optional()` admits `undefined`, not `null`. The schema refused `packages: null` (`invalid_type`), and `composeStacks` refused it with two or more inputs (`STACK_SCHEMA_INVALID`, `status: 422`). The runtime readers below read it as absent instead: a single-package artifact whose own top level is the one package body. Those readers now follow the declaration. An absent `packages` is `undefined` and nothing else; `null` is one of the present, non-array values the rule beside `AssembledPackageBodySchema` calls malformed, like `{}`, `0` or `'x'`, and it is refused with the same envelope: `INVALID_ARTIFACT_PACKAGES`, `status: 422`. No error code is added.
17+
18+
- **`@objectstack/core`**: `resolveArtifactPackageOrder` refuses `packages: null` where it returned `[artifact]`. The refusal message names the value `null`, not `object`. The resolver's callers that hand it the whole artifact raise the refusal: the kernel `manifest` service's `register()` (`ObjectQLPlugin`) and `@objectstack/verify`'s collection reader for a collection the stack's top level does not carry.
19+
- **`@objectstack/runtime`**: `resolveArtifactCollections` drops `null` from its absent branch, so `AppPlugin`, `createStandaloneStack`, `loadArtifactBundle`'s runtime-module merge and `resolveProjectDatabaseUrl` answer a `packages: null` artifact exactly as they already answer `packages: {}`. `carriedPackageIds`, and `resolveArtifactGrantBinding` for an artifact whose `grantedPermissions` is a record, read the package list through the core resolver and raise its refusal too.
20+
- **`@objectstack/plugin-dev`**: the i18n detector's private absent guard moves in lockstep with the resolver's absent branch, so `devI18nPluginOptions` reaches the resolver and raises its refusal when the `i18n` config (on the stack or its `manifest`), a non-empty `manifest.translations` and a non-empty top-level `translations` do not answer first. `DevPlugin` keeps its posture: it reports the metadata defect on its `error` line and boots on the in-memory i18n fallback.
21+
- **`@objectstack/plugin-security`**: `appSecurityPluginOptions` has no guard of its own and raises the resolver's refusal for `packages: null`.
22+
- **What does not change**: the schema; an absent `packages` (no key, or an explicit `undefined`), which still returns the caller's own object by identity; a well-formed `packages[]`; and `composeStacks` with a single input, which still returns that input by identity.
23+
24+
No in-repo producer writes `packages: null`, and `os build` and `os validate` refuse it at the schema before any reader runs. For a single-package artifact, leave the `packages` key out.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/lint": minor
5+
---
6+
7+
`create_record` / `update_record` field values accept the CEL value envelope, declared and evaluated together.
8+
9+
A value in a `create_record` or `update_record` node's `fields` map may now be a CEL value envelope, `{ dialect: 'cel', source: '…' }`, with the same shape and dialect rules the `assignment` node's `assignments` map already has. The envelope is evaluated by the expression engine that flow conditions use, so the whole CEL stdlib is reachable from a field value, and the result is written with its type kept:
10+
11+
```ts
12+
fields: {
13+
subject: 'Quote for {account.name}', // `{token}` template — unchanged
14+
total: { dialect: 'cel', source: 'round(amount * 100.0) / 100.0' }, // CEL, evaluated to the value written
15+
}
16+
```
17+
18+
Clause-②: yes (widening) — a published authoring slot's accept set grows (a valid envelope in `fields.*` is newly evaluated), and the one newly refused shape is the edge the `assignments` map accepted when it gained the envelope: a malformed one.
19+
20+
**What newly passes.** A valid CEL value envelope as a top-level `fields` value, on both nodes. Before this release the executor wrote such an object into the record verbatim: a text or JSON column stored `{"dialect":"cel","source":"…"}` and the run reported success, and a number column was refused by the data engine.
21+
22+
**What newly refuses.** A top-level `fields` value that is a plain object with a string `dialect` key and is NOT a valid CEL value envelope. That covers a missing, empty or whitespace-only `source`, an `ast` with no `source`, a `template` or `cron` dialect, and a `source` that does not parse as CEL. Every door refuses it, located at `config.fields.<field>`: `AutomationEngine.registerFlow` refuses the flow, `objectstack validate` reports an `expression-invalid` error, the runtime publish gate answers `422 INVALID_METADATA`, and the node's execute-time contract parse refuses it. Such an object used to be written as data.
23+
24+
**The rule for nested and literal values.** Only the top-level value of each field is judged. An object nested inside a JSON value or an array is data, whatever keys it carries, and strings inside it still interpolate. A plain string is always a `{token}` template with its existing meaning, and every other literal is written as before. A JSON column whose intended literal value is itself an object with a string `dialect` key is now read as an envelope. To write such an object as data, bind it to a flow variable and write `'{thatVariable}'` (a sole token keeps its type). Measured: no flow in this repository or in HotCRM writes an envelope-shaped object into `fields`.
25+
26+
**The refusal sentence is slot-neutral.** A refused field value used to be told it was "an assignment value". The sentence every value-slot refusal leads with is now `VALUE_ENVELOPE_REFUSAL`: "A value carrying a `dialect` key is read as an expression envelope, and this one is not a valid CEL value envelope." The published `ASSIGNMENT_VALUE_ENVELOPE_REFUSAL` is kept and is the same string, so code that matches on the constant keeps matching. Code that matched the old literal text ("An assignment value carrying…") does not.
27+
28+
**New in `@objectstack/spec/automation`** (5 exports, 0 removed):
29+
30+
- `VALUE_ENVELOPE_REFUSAL`, the slot-neutral refusal sentence.
31+
- `FlowValueSlotSchema` / `FlowValueSlot` / `FlowValueSlotParsed`, the value contract every value slot shares (`AssignmentValueSchema` is the same rule under the assignment map's description).
32+
- `resolveFlowNodeValueSlots(nodeType, config)`, which returns every authored value in the ledger's value slots, strings included.
33+
- The expression ledger `FLOW_NODE_EXPRESSION_PATHS` has two new rows, `create_record.fields.*` and `update_record.fields.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries both CRUD contracts.
34+
35+
**Author-time hint (`@objectstack/lint`).** `objectstack validate` warns when a value slot holds a `{…}` template expression, meaning arithmetic or a call to `round` / `floor` / `ceil` / `abs` / `min` / `max`, and points it at the envelope. The warning never fails a build, and the template form keeps working unchanged. Plain references, the `NOW()` / `TODAY()` macros and `$User` paths are not hinted. CEL's `now()` / `today()` are timestamps rather than the strings those macros write, and the flow's CEL scope binds no user.
36+
37+
**Corrected guidance: `/ 100.0`, not `/ 100`.** The template dialect's `round()` arity refusal used to call `round(x * 100) / 100` the CEL authoring pattern. In CEL that expression truncates: `round()` returns an int, and int / int is integer division, so `x = 1234.5678` gives `1234` instead of `1234.57`. The refusal now prescribes `round(x * 100) / 100.0`, which is correct in both dialects. In the template dialect `/ 100` and `/ 100.0` give the same value.

‎.changeset/19951-rls-lint-per-request-refusals.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- FROM silent TO `rls-predicate-unenforceable`: `record.f in current_user`, `record.f.startsWith(current_user)`, `.endsWith(current_user)` and `.contains(current_user)`. Name the key that holds the value, for example `record.f in current_user.org_user_ids` or `record.f.startsWith(current_user.email)`.
1515
- FROM silent TO `rls-predicate-unenforceable`: `record.f.startsWith(current_user.org_user_ids)` (a list handed to a string method). Write `record.f in current_user.org_user_ids`, or pass a one-value key.
1616

17-
**A `null` list member or `null` ordering bound.** The platform refuses both in every filter it is sent. The RLS layer does not run that check on its own filter, so the backend answers with its own semantics: on a SQL driver `record.f in [null]`, `!(record.f in ['a', null])` and `record.f > null` read no rows. Every analytics query over the object is refused.
17+
**A `null` list member or `null` ordering bound.** The platform refuses both in every filter it is sent. The RLS compiler runs that check on its own filter too (#20212) and drops the policy on every request: reads return no rows and `check` writes are refused 403.
1818

1919
- FROM silent TO `rls-predicate-unenforceable`: `record.f in ['a', null]` and `!(record.f in ['a', null])`. Write `(record.f in ['a'] || record.f == null)`, or drop the `null` member.
2020
- FROM silent TO `rls-predicate-unenforceable`: `record.f in [null]`. Write `record.f == null`.

‎.changeset/20018-native-read-scope-faces.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ each is refused by a standing ruling the shared faces carry. Measured on SQLite:
4040
analytics face already refused all of them. No in-repo read-scope producer emits
4141
them for a policy in this repository. An RLS `using` predicate can still be
4242
written so that it lowers into the null shapes (a literal `null` inside an `in`
43-
list, or an ordering comparison against `null`), and such a policy now gets the
44-
withheld 500 on every analytics face.
43+
list, or an ordering comparison against `null`), but the RLS compiler drops such a
44+
policy (#20212), so the analytics faces receive the deny sentinel and answer zero rows.
4545

4646
**Fix.** State absence with the null predicate. "One of these values, or no
4747
value" is `{ "$or": [{ "f": { "$in": ["a"] } }, { "f": { "$null": true } }] }`,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Every example predicate in the `ConditionalValidationSchema` TSDoc docblock (all seven Use Cases, plus the ObjectStack side of the "Salesforce Pattern Comparison" block, above the schema in `packages/spec/src/data/validation.zod.ts`) is rewritten in the CEL the evaluator actually accepts, so an author or agent who copies a documented example gets a rule that evaluates instead of one that refuses every write it guards (#20026).
6+
7+
Clause-②: no
8+
9+
17 of the 19 predicates used `=` for comparison (or `AND`/`NOT`/`REGEX(...)` word-form operators) — a CEL parse error, since `@marcbachmann/cel-js` rejects a lone `=` as an unexpected character; CEL equality is `==`, and CEL has no `AND`/`OR`/`NOT` keyword form — and the other 2 (`order_total > 10000`, `approval_amount > 50000`) named a bare field with no `record.` root, an unknown-variable type error. `REGEX(tax_id, "…")` is rewritten to the evaluator's registered `matches(record.tax_id, "…")` stdlib function — the CEL form it accepts, not a literal transliteration — with the escape-free character class `[0-9]` rather than `\d`: a `\d` inside the CEL string literal parses fine as written in this TSDoc comment (comments are not JS-escape-processed), but a reader who pastes the same text into a real `.ts` string literal gets ONE level of JS unescaping the comment never applied, so `\\d` in the comment becomes `\d` at runtime and cel-js refuses it (`Invalid escape sequence: \d` — this was this round's own rework: a prior revision of this changeset claimed a passing result for that predicate without measuring it on the copied literal). The Salesforce-formula half of the comparison (`IF(ISPICKVAL(...), AND(...), FALSE)`) is deliberately untouched: it documents Salesforce's own syntax, not CEL.
10+
11+
Measured through `ExpressionEngine.evaluate` (`@objectstack/formula`), taking each predicate as the RUNTIME STRING a reader gets by pasting the docblock's literal into real `.ts` source — the literal is extracted from the source file byte for byte and handed to Node's own parser to unescape, never hand-retyped — against a record that makes each rewritten predicate true and one that makes it false; both branches evaluate as expected for all 19 (plus one extra disjunct check on the regex branch) — transcript in the PR body. The strings ship in the published `dist/object.zod-*.d.ts`, confirmed before and after this change with a lit/dark control: every rewritten string is present exactly once and every original broken string is absent.
12+
13+
| old (fails) | new (evaluates) |
14+
|:--|:--|
15+
| `account_type = "enterprise"` | `record.account_type == 'enterprise'` |
16+
| `approval_status = null` | `record.approval_status == null` |
17+
| `requires_shipping = true` | `record.requires_shipping == true` |
18+
| `shipping_address = null OR shipping_address = ""` | `record.shipping_address == null \|\| record.shipping_address == ''` |
19+
| `order_total > 10000` | `record.order_total > 10000` |
20+
| `manager_approval_id = null` | `record.manager_approval_id == null` |
21+
| `payment_method = null` | `record.payment_method == null` |
22+
| `region = "EU"` | `record.region == 'EU'` |
23+
| `gdpr_consent_given = false` | `record.gdpr_consent_given == false` |
24+
| `tos_accepted = false` | `record.tos_accepted == false` |
25+
| `country = "US"` | `record.country == 'US'` |
26+
| `state = "CA"` | `record.state == 'CA'` |
27+
| `tax_id = null OR NOT(REGEX(tax_id, "^\d{2}-\d{7}$"))` | `record.tax_id == null \|\| !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")` |
28+
| `is_taxable = true` | `record.is_taxable == true` |
29+
| `tax_code = null OR tax_code = ""` | `record.tax_code == null \|\| record.tax_code == ''` |
30+
| `user_role = "manager"` | `record.user_role == 'manager'` |
31+
| `approval_amount > 50000` | `record.approval_amount > 50000` |
32+
| `type = "enterprise"` (Salesforce comparison) | `record.type == 'enterprise'` |
33+
| `amount > 100000 AND approval = null` (Salesforce comparison) | `record.amount > 100000 && record.approval == null` |
34+
35+
`packages/spec/src/data/validation.test.ts`'s `ConditionalValidationSchema` fixtures move with the docblock (parse-only — `ValidationRuleSchema.parse(...).not.toThrow()`, no CEL evaluation, so no behaviour change): the six exact copies of Use Cases 1-3's original seven strings; `manager_approval = null` (the `order_value_validation` fixture whose message text and structure mirror Use Case 3's manager-approval example one field-name spelling apart), now `record.manager_approval == null`; and, under this round's rework, ten more fixtures whose surrounding test name and message text are verbatim copies of Use Cases 4, 5, 6 and 7's docblock text — `is_taxable = true` / `tax_code = null` (`tax_validation`, Use Case 6, the `tax_code` fixture now the full `record.tax_code == null || record.tax_code == ''`), `region = "EU"` / `gdpr_consent_given = false` / `tos_accepted = false` (`regional_validation`, Use Case 4), `user_role = "manager"` / `approval_amount > 50000` (`role_based_validation`, Use Case 7), and `country = "US"` / `state = "CA"` / `tax_id = null` (`nested_validation`, Use Case 5 — the `tax_id` fixture now the full `record.tax_id == null || !matches(record.tax_id, "^[0-9]{2}-[0-9]{7}$")`, escape-free for the same copy-paste reason as the docblock). 17 fixture strings moved in total. No schema, behaviour, or public export changes — TSDoc text only, in the same two files the strings already lived in.

0 commit comments

Comments
 (0)