Skip to content

Commit 68d0003

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-12238-description-rule
2 parents 652b36d + e462186 commit 68d0003

51 files changed

Lines changed: 4108 additions & 1397 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: 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.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': patch
4+
---
5+
6+
`@objectstack/spec/data` declares which field types are masked on read — `MASKED_ON_READ_FIELD_TYPES` and `isMaskedOnReadFieldType(fieldType, managedBy)` (#20141)
7+
8+
Clause-②: yes
9+
10+
The protocol used to state this only in prose (the `FieldType` comments), so
11+
two consumers each carried their own hand-written copy: objectql's
12+
`collectMaskedReadFields` (the generic read mask and the echoed-mask write
13+
guard) and the renderer's masked-type set. The fact is now declared once:
14+
15+
- `MASKED_ON_READ_FIELD_TYPES` — a deep-frozen per-type rule table:
16+
`secret` is masked on every object; `password` is masked on every object
17+
except `managedBy: 'better-auth'` ones. Each masked type carries its own
18+
`exemptManagedBy` list, typed against `ObjectSchema.managedBy`'s enum.
19+
- `isMaskedOnReadFieldType(fieldType, managedBy)` — the one reading of that
20+
table. `managedBy` is a required argument (pass `undefined` when the object
21+
has none), and exemptions fail closed: an absent or unlisted `managedBy`
22+
never unmasks a masked type.
23+
24+
`@objectstack/objectql`: `collectMaskedReadFields` and
25+
`collectMaskedPasswordFields` now ask `isMaskedOnReadFieldType` instead of
26+
carrying their own `type === 'secret'` / `'password'` arms. No behaviour
27+
change: the masked-on-read answer is identical for every `FieldType` ×
28+
`managedBy` cell, pinned by a table test.
29+
30+
`@objectstack/spec`: `ObjectSchema.create()`'s author-time warning for a `password` field on a non-auth object now reads its `managedBy` exemption from `isMaskedOnReadFieldType` instead of hard-coding `'better-auth'`, so it follows the declaration; which objects and fields warn is unchanged.
31+
32+
A client that renders credential fields (show the mask, offer no copy) should
33+
derive its set from `isMaskedOnReadFieldType` rather than keep its own list,
34+
so the server's mask and the client's cannot drift apart.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/rest': minor
4+
---
5+
6+
fix(runtime): the dispatcher's `/meta` item reads ask the same per-caller read gate `RestServer` asks, and `@objectstack/rest` publishes it (#20193)
7+
8+
Clause-②: yes
9+
10+
`GET /meta/:type/:name` and `GET /meta/:type/:name/published` have two
11+
implementations: `RestServer`, and the runtime dispatcher's `/meta` domain. On a
12+
host that mounts only the `${prefix}/*` catch-all (`@objectstack/hono`'s
13+
`createHonoApp`, the documented embed shape, and any adapter built on the public
14+
`HttpDispatcher` API), the dispatcher is the only one that answers. Its item read
15+
and its `/published` read applied **no** per-caller read gate. An authenticated
16+
member who does not hold `crm_admin` got these answers through `dispatch()`:
17+
18+
```
19+
request before after (= RestServer)
20+
GET /meta/doc/crm_admin_runbook 200 + the gated body 403 PERMISSION_DENIED
21+
GET /meta/doc/crm_admin_runbook/published 200 + the gated body 403 PERMISSION_DENIED
22+
GET /meta/book/admin_guide (set-gated) 200 + the book 403 PERMISSION_DENIED
23+
GET /meta/app/crm (and /published) 200, every entry 200, pruned
24+
GET /meta/app/payroll (app-level perms) 200 403 PERMISSION_DENIED
25+
GET /meta/app/launchpad (unpublished) 200 404, the same body as a missing name
26+
GET /meta/dashboard/ops 200, every widget 200, minus the widget whose service is off
27+
GET /meta/object/invoice/published 200, every field 200, the ADR-0106 mask applied
28+
```
29+
30+
Holders are still served in full. Object reads through the plain item read are
31+
masked as before. An anonymous caller still gets `401 UNAUTHENTICATED`.
32+
33+
**One gate, not two.** `RestServer`'s gate moved unchanged into
34+
`packages/rest/src/meta-item-read-gate.ts`. That covers the ADR-0046 §6.7 docs
35+
audience, the app nav filter (`requiredPermissions`, the ADR-0045 §3 publish gate
36+
and the docs-audience entry arm), the ADR-0057 D10 service gates and the #7912
37+
servability gate. Both transports now call it. Each transport supplies only its
38+
own I/O (the caller, the protocol's list read, the security service and a
39+
service probe) and writes the gate's data verdict in its own envelope. There is
40+
no second audience resolver in `packages/runtime`. `RestServer` keeps its
41+
private helper names as delegates, so its own answers are byte-for-byte
42+
unchanged.
43+
44+
A gate input that cannot be read is answered as that fault, never as the
45+
document. This covers a books or doc-list read that throws, and a host whose
46+
protocol has no list read at all (fail closed, ADR-0049).
47+
48+
**`@objectstack/rest`'s published export surface widens**, and that is why this
49+
changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one value
50+
and five types:
51+
52+
- `createMetaItemReadGate(sources, metaType, name, documents, policy)`: the gate
53+
itself;
54+
- `MetaItemReadGateSources`: the I/O a caller supplies (the caller, a metadata
55+
list read, the security service, a service probe, a prune-log set);
56+
- `MetaItemReadVerdict` and `MetaItemReadRefusal`: the data verdict (`serve`, or
57+
`refuse` with `absent` / `app-permission` / `docs-audience`);
58+
- `MetaReadGateCaller`: the slice of the execution context the gate reads;
59+
- `MetaReadGatePolicy`: `arms` and `app`, how a door runs the gate.
60+
61+
They are public because the runtime dispatcher's `/meta` domain in
62+
`@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import
63+
the runtime, so the shared decision has to live here and travel as an export,
64+
the way `repeatedQueryParamMessage` does. A caller outside the platform does not
65+
need them.
66+
67+
Nothing is removed or renamed, and no authorable key moves. The dispatcher's
68+
refusals are not the widening: they pull a second transport back to the gate
69+
the contract already declares (ADR-0046 §6.7, ADR-0045 §3, `apps.mdx`), which is
70+
why `@objectstack/runtime` stays a `patch`.

‎content/docs/automation/flows.mdx‎

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -200,28 +200,35 @@ or missing-`required` violation (#4277). A node type that publishes no
200200
A value's **shape** selects its form — there is no mode key. A plain string is
201201
always `{token}` interpolation (a bare `a + b` is the literal text `a + b`, not
202202
CEL); an object that names a `dialect` is an expression envelope and must be a
203-
valid `cel` one — a missing, empty or non-string `source`, or a `template` /
204-
`cron` dialect, is refused at the variable's path. Numbers, booleans, arrays
205-
and plain objects are assigned as literals. A later `notify` node renders the
206-
variable as any other: `message: '{digest}'`, and it renders the **evaluated**
207-
value.
203+
valid `cel` one — a missing, empty, whitespace-only or non-string `source`, an
204+
`ast` with no `source`, or a `template` / `cron` dialect, is refused at the
205+
variable's path. Numbers, booleans, arrays and plain objects are assigned as
206+
literals. A later `notify` node renders the variable as any other:
207+
`message: '{digest}'`, and it renders the **evaluated** value.
208+
209+
The same rules hold for the field values of `create_record` / `update_record`
210+
(below): the `assignments` map and the `fields` map are the flow's **value
211+
slots**, and each accepts a CEL value envelope beside `{token}` templates and
212+
literals. Only a slot's top-level value is judged — an object nested inside a
213+
JSON value or an array is data, whatever keys it carries.
208214

209215
<Callout type="info" title="Where a malformed envelope is refused">
210216

211217
A malformed envelope never reaches run time silently: the same refusal runs at
212-
`objectstack validate` (a located finding naming the variable), at the runtime
213-
publish gate a Studio / REST / MCP flow write goes through, and at
214-
`registerFlow`, which refuses to register the flow. All three ask the same two
215-
questions in the same order — is it a valid `cel` envelope
216-
(`AssignmentValueSchema`), and does its source parse as CEL
218+
`objectstack validate` (a located finding naming the variable or field), at the
219+
runtime publish gate a Studio / REST / MCP flow write goes through (a `422`),
220+
and at `registerFlow`, which refuses to register the flow — in every value
221+
slot. All three ask the same two questions in the same order — is it a valid
222+
`cel` envelope (`FlowValueSlotSchema`), and does its source parse as CEL
217223
(`validateExpression`) — so a flow that registers is a flow whose envelopes
218224
those two validators accept.
219225

220-
Two shapes sit outside what either validator can judge and fault loudly at run
221-
time instead of assigning a value: an `ast`-only envelope (no `source` — the
222-
CEL engine evaluates `source`), and a whitespace-only `source`, which passes
223-
`min(1)` and reads as "not authored" to the validator while the engine parses
224-
it untrimmed. Both are tracked in [#15430].
226+
That includes the two shapes the persistence contract alone would accept but no
227+
engine can run — an `ast`-only envelope (the CEL engine evaluates `source`) and
228+
a whitespace-only `source` — refused at authoring since [#15430]. What remains
229+
for run time is an envelope that parses but cannot evaluate on the live values
230+
(an absent variable, say): it fails the run with its source attached, and
231+
nothing is assigned or written in its place.
225232

226233
[#15430]: https://github.com/objectstack-ai/objectstack/issues/15430
227234

@@ -240,6 +247,9 @@ it untrimmed. Both are tracked in [#15430].
240247
title: 'Follow up on {record.name}',
241248
assignee: '{record.owner}',
242249
due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text
250+
// CEL value envelope — evaluated to the value written, same rules as an
251+
// assignment value. `100.0`, not `100`: CEL divides two integers as integers.
252+
estimate: { dialect: 'cel', source: 'round(record.amount * 0.15 * 100.0) / 100.0' },
243253
},
244254
},
245255
}
@@ -1785,7 +1795,16 @@ means the same thing inside braces.
17851795
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
17861796
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
17871797
| Decision-node `conditions[].expression` | **CEL** (bare, no braces) | `order_amount > 10000` | flow variables by name, and `vars.*` |
1788-
| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days), and the CEL-mirrored numeric functions `round`, `floor`, `ceil`, `abs`, `min`, `max` (#11060) — `round` is **integer-only**, exactly like CEL's (there is no `round(x, 2)`); for N decimals write the CEL idiom `{round(x * 100) / 100}` (scale 2) |
1798+
| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days), and the CEL-mirrored numeric functions `round`, `floor`, `ceil`, `abs`, `min`, `max` (#11060) — `round` is **integer-only**, exactly like CEL's (there is no `round(x, 2)`); for N decimals write `{round(x * 100) / 100.0}` (scale 2). Keep the decimal point: in CEL `round()` returns an int and `int / int` is integer division, so `round(x * 100) / 100` drops the decimals there — `/ 100.0` is right in both dialects |
1799+
| Field values and assignment values, as a **CEL value envelope** | **CEL** (in an envelope) | `{ dialect: 'cel', source: 'round(price * 100.0) / 100.0' }` | flow variables by name, and `vars.*` — the whole CEL stdlib (`joinNonEmpty`, …) |
1800+
1801+
A value slot takes either form, chosen by shape: a string is interpolation, an
1802+
object naming a `dialect` is a CEL envelope. The template form keeps working
1803+
unchanged; `objectstack validate` points a template **expression** — arithmetic
1804+
or one of the six functions inside braces — at the envelope with a warning,
1805+
never an error. Plain references (`{record.name}`), the date macros and
1806+
`{$User.*}` are left alone: CEL's `now()` / `today()` are timestamps, not the
1807+
strings the macros write, and the flow's CEL scope binds no user.
17891808

17901809
<Callout type="warn">
17911810
**The failure modes to memorize:**

0 commit comments

Comments
 (0)