Skip to content

Commit 21418c4

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20201-d3-entry-per-family
2 parents 5c60be7 + 1207baf commit 21418c4

111 files changed

Lines changed: 8330 additions & 1695 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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/plugin-sharing": minor
5+
"@objectstack/lint": minor
6+
"@objectstack/objectql": minor
7+
"@objectstack/spec": patch
8+
---
9+
10+
A row-level or sharing-rule predicate whose comparison is handed something other than one value is refused at the CEL lowering or at the write-check evaluator, instead of admitting writes and reads it was written to refuse (#19886).
11+
12+
**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, `@objectstack/lint` and `@objectstack/objectql` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `cel-predicate-one-value-comparand-refused`.
13+
14+
Clause-②: no (narrowing)
15+
16+
**Security fix for RLS write checks and reads.** Each shape below was measured through the real plugin-security on driver-sql and driver-memory:
17+
18+
- `!(record.status in [['closed', 'archived']])` (a list nested in an `in` list) admitted and stored every write the `check` was written to refuse, and a `using` read returned every row on driver-memory.
19+
- `current_user.org_user_ids != 'x'` and `current_user.org_user_ids > 'a'` (a membership set on a comparison with no field) folded to "no restriction": every write admitted, every row read, on every driver.
20+
- `record.status > ['m']` compared the list as the string `'m'` on the write check, while the analytics read scope bound the whole list as one SQL parameter. `record.reviewer_id > current_user` compared the whole caller object as a string and admitted and stored every write; in this release the RLS compiler's comparand faces (#20212) already drop that policy, and this change refuses it at the lowering for every caller of the compiler.
21+
- `record.status != record.tags`, its negation `!(record.status == record.tags)`, and the mirror `record.tags != record.status`, with `tags` a `json` field or a `multiple` lookup, admitted and stored every write.
22+
23+
What changes:
24+
25+
- `@objectstack/formula`: `compileCelToFilter` refuses, with `unsupported`, a list comparand under every comparison (the ordering operators now included, and on the constant-fold branch, whichever side), the `current_user` root or a key resolving to an object under an ordering operator, and an `in` list whose member is itself a list. The authoring shape check (`isPushdownableCel`, `isSupportedRlsExpression`) reports each literal form; a resolved value is refused per request. `matchesFilterCondition` refuses, with `INVALID_FILTER` / 400, an array under `$gt` / `$gte` / `$lt` / `$lte`, an array member of `$in` / `$nin`, and a `{ $field }` comparison (`$eq`, `$ne` or an ordering operator) whose column holds a list or an object on the record being judged, on either side. The message withholds the field, the operator and the value.
26+
- `@objectstack/plugin-security`: the RLS compiler drops a policy the compiler refuses and fails closed when no other policy applies (`RLS_DENY_FILTER`: reads return no rows, `check` writes are refused 403, and `getReadFilter` hands the analytics read scope the deny scope). A `check` comparing a field with a list-holding column is refused 400 and stores nothing.
27+
- `@objectstack/plugin-sharing`: a declared sharing rule with such a `condition` is skipped at bootstrap and never seeded.
28+
- `@objectstack/lint`: the literal forms are reported as `rls-predicate-unenforceable`, and an ordering comparison against a membership set through the reference pass.
29+
- `@objectstack/objectql`: a `having` comparison against a `{ $field }` column whose aggregated row holds a list is refused 400 where the row carries the list itself (driver-memory); driver-sql rows carry the stored JSON text and compare as before.
30+
- `@objectstack/spec`: the migration registry carries the entry.
31+
32+
The stage 2a changeset's sentence that `{ $field }` references evaluate as before no longer holds for a column holding a list or an object: that comparison is now refused.
33+
34+
**What to change.** "One of these values" is `record.status in ['open', 'pending']`, and "none of these values" is `!(record.status in ['closed', 'archived'])`, with the list flat. An ordering takes one bound (`record.status > 'm'`); a range is two comparisons joined by `&&`. Compare against one key of the caller (`record.reviewer_id > current_user.id`). A field compared with a `json` or `multiple` field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook. In a raw filter, use `$in` / `$nin` with flat lists and one bound per ordering operator.
35+
36+
Not changed: a field compared with a `json` or `multiple` field still lowers and is not reported at authoring time, because the lowering sees the predicate's text and not the object's field types; driver-memory still answers a `{ $field }` comparison on a read without evaluating the reference.
37+
38+
<!-- adr-0087: registered cel-predicate-one-value-comparand-refused -->
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` and `AssembledViewArtifactParsed` name the shapes their TSDoc promises instead of being `unknown` (#19920)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for TypeScript code that annotates with `InlineAction`, `ViewMetadataParsed`, `AssembledViewArtifact` or `AssembledViewArtifactParsed`**: a narrowing of published TYPES, landing in the launch window as `minor` (the lockstep convention: the bump level is not the carrier, this banner and the disposition below are). The runtime accept set does not move at all: no schema, no parse and no export changes, and neither does the declared type of any schema.
10+
11+
Four published type aliases were derived from a schema whose own static type erases to `unknown`, so any value type-checked against them. Each is now derived from the member schema the parse actually runs:
12+
13+
- `InlineAction`: FROM `z.input<typeof InlineActionSchema>` (`unknown`, because the schema is a `z.preprocess` whose input is the preprocess function's `unknown` parameter) TO `z.input<(typeof InlineActionSchema)['out']>`, the input type of the picked action object.
14+
- `ViewMetadataParsed`: FROM `z.infer<typeof ViewMetadataSchema>` (`unknown`, because the union's members are cast to `z.ZodTypeAny` where it is built) TO the union of the OUTPUT types of `VIEW_METADATA_MEMBERS`, the same record `ViewMetadata` reads its input types from. `diagnoseViewMetadata` keeps returning the schema's own parse output as `data`; only that value's static type changes.
15+
- `AssembledViewArtifact` / `AssembledViewArtifactParsed`: FROM `z.input` / `z.infer` of `AssembledViewArtifactSchema` (`unknown`, the same cast) TO the input / output union of the three non-container `VIEW_METADATA_MEMBERS`, the members that schema's union is mapped from. A container body is now a compile error here, as it always was at the schema.
16+
17+
**If your code stops compiling.** A value you annotated with one of these names is not the shape the name describes: correct it, or type a value that is still unvalidated as `unknown` and let the schema's `safeParse` decide. For `InlineAction`, the legacy `type: 'navigation'` and `to` spellings are refused by the type while `InlineActionSchema` still folds them onto `url` / `target`: write `type: 'url'` and `target`.
18+
19+
The types are the members' declared shapes, not the schemas' verdicts. Each schema still accepts some bodies its type refuses (the preprocess folds and strips) and still refuses some bodies its type admits (refinements are not types), so the schema remains the only judge.
20+
21+
`JoinedReportBlock` is not changed by this change, and still resolves to `unknown`.
22+
23+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author writes moves — no spec key, no export and no stored row changes and every runtime accept set is unchanged, so `objectstack migrate meta` has nothing to reach — and only TypeScript annotations narrow, whose channel is the consumer's compiler. -->
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.

0 commit comments

Comments
 (0)