Skip to content

Commit 9ab41a2

Browse files
committed
Merge origin/main into claude/issue-15711-default-locale-authored-label (regen lap: census page re-derived from the merged tree)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
2 parents b731298 + af29b66 commit 9ab41a2

84 files changed

Lines changed: 2912 additions & 998 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.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec)!: an evaluated expression slot requires a non-blank `source``EvaluatedExpressionSchema`, composed by the `assignment` value envelope (#15430)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is renamed, retired or re-typed: `source` keeps its name and meaning, and every envelope that carried a non-blank `source` parses byte-identically. The two newly refused spellings — an envelope carrying only `ast`, and a `source` that is blank after trimming — never evaluated on any release (the `ast`-only one faulted at run time with the engine's own "persist `source`" prescription, the blank one with a parse error), and a repo-wide census found no in-repo instance of either, so `objectstack migrate meta` has nothing to rewrite and the remedy is authoring a `source`, which the refusal itself prescribes. -->
8+
9+
**BREAKING** in the accept-set sense, landing in the launch window as `minor`
10+
(the lockstep convention): on the schemas that type an EVALUATED expression
11+
slot — today the `assignment` node's value envelope,
12+
`AssignmentExpressionValueSchema` — an envelope with no `source` the engine can
13+
evaluate is now **refused at authoring**, where it used to parse, register,
14+
pass `objectstack validate`, and then fault at run time.
15+
16+
Two spellings of one seam, refused by ONE rule with one message at `source`
17+
(`EVALUATED_EXPRESSION_SOURCE_REQUIRED`):
18+
19+
```yaml
20+
assignments:
21+
digest: { dialect: cel, ast: { kind: const } } # `ast` only — no engine evaluates it
22+
greeting: { dialect: cel, source: ' ' } # blank after trimming — parses to EOF
23+
```
24+
25+
> An expression in an evaluated slot needs a non-blank `source`: the expression
26+
> engine evaluates `source` (the canonical persisted form of phase M9.1) and
27+
> cannot evaluate `ast` alone, so an envelope carrying only `ast`, or a `source`
28+
> that is blank after trimming, would validate and register and then fault at
29+
> run time. Write `{ dialect: 'cel', source: '…' }`.
30+
31+
- **`ExpressionSchema` is NOT narrowed.** It is the persistence contract —
32+
`source` OR `ast` — and its docblock declares that `ast` becomes required in
33+
build output at phase M9.2. The new export `EvaluatedExpressionSchema` (and
34+
its type `EvaluatedExpression`) is a sibling: the same envelope with `source`
35+
required and non-blank, spelled once and composed by every evaluated slot, so
36+
when AST-only evaluation lands the flip is one edit there rather than a
37+
per-slot unwinding. The rule is worded as "an evaluated slot requires whatever
38+
the engine can actually evaluate"; what that is today is `source`.
39+
- **The notion of blank is the engine's own** — `.trim()`, which
40+
`cel-engine.ts`'s helpers already apply — not a third one beside the shape
41+
rule's `min(1)` and `validateExpression`'s trim.
42+
- **Three doors agree.** `registerFlow` refuses the flow, `objectstack validate`
43+
and the runtime publish gate report a located `error` at the author's own
44+
variable (`config.assignments.<name>.source`), and the executor's own shape
45+
pass refuses the same set — all through the spec schema, so none of them
46+
grew a rule of its own.
47+
48+
**What an author does with a refused envelope.** An assignment value that
49+
carried only `ast` has no evaluable form under M9.1: author its `source`. A
50+
whitespace-only `source` was never an expression: delete the entry, or write
51+
the expression. Every envelope with a non-blank `source` is unchanged, and
52+
nothing is renamed, retired or rewritten — the refusal itself carries the
53+
prescription.
54+
55+
Not touched here: the `predicate` half of the same seam — `evaluateCondition`'s
56+
silent `false` on an envelope without a `source` — is a behaviour change on a
57+
live path with its own card, and the edge-condition schema that carries that
58+
envelope is narrowed in a follow-up once the in-flight change to
59+
`automation/flow.zod.ts` lands.

.changeset/tidy-cups-smile.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/metadata-protocol': minor
4+
'@objectstack/service-automation': patch
5+
'@objectstack/lint': patch
6+
'@objectstack/spec': patch
7+
'@objectstack/service-settings': patch
8+
---
9+
10+
**BREAKING (behaviour):** a static `readonly` field is now stripped from a **non-system caller's INSERT payload inside `engine.insert`**, exactly as it already was on `engine.update`. A non-system create that used to write a read-only column now has that column dropped, reported through `onFieldsDropped` / `droppedFields`, logged at `warn`, and refused outright under `strictReadonlyWrites`. Seeding a read-only column at create time is a **system** act — use `context.isSystem`, a flow's `runAs: 'system'`, a system hook or a seed.
11+
12+
Until now the create-side strip lived only at the DataProtocol ingress (`stripReadonlyForInsert` in `@objectstack/metadata-protocol`), so `readonly` meant one thing on insert and another on update: every external REST/GraphQL/MCP create was stripped, while a caller reaching `engine.insert` directly — the automation engine's `create_record` among them — wrote the column with no refusal, no `WARN` and no dropped-field event.
13+
14+
- `stripReadonlyForInsert` and its five call sites in `@objectstack/metadata-protocol` are **deleted**, not kept as a second implementation; every create face — `createData`, `cloneData`, `createManyData`, `insertManyData`, and `batchData`'s `create` rows and both arms of `upsert` that create — now hands the caller's payload to the engine whole, and every face whose response carries `droppedFields` (`createData`, `createManyData`, `insertManyData`, every `batchData` row that created) reports the engine's own verdict there, so `droppedFields` says the same thing at each of those seams. `cloneData` forwards whole but reports nothing on the wire: its response contract (`CloneDataResponseSchema`, declared as produced) has no `droppedFields` member, so a clone that carried or overrode a read-only column is stripped and logged at `warn` but not reported in the 201 body — adding that key is a spec change, not part of this one.
15+
- `create_record` (`@objectstack/service-automation`) starts receiving readonly drops on the `onFieldsDropped` channel it has been wired for since #3407 — a flow without `runAs: 'system'` that seeds a read-only column now reports a node warning and `output.droppedFields` instead of a clean success. That package's own code changes only in prose; the traffic is new, the surface is not.
16+
- Unchanged, deliberately: `isSystem` is still the exemption; `preserveAudit` is still an UPDATE-path exemption and a create that asks for it is told so out loud; runtime-owned types (`autonumber`) keep their own pass and their own wider whitelist; platform objects (`managedBy`, the `sys_` namespace) are still left to their own field-write guards; `readonlyWhen` still has no create-side strip. A stripped key's `defaultValue` is re-derived, so a forged `approval_status` becomes `draft` rather than NULL.
17+
- `@objectstack/service-settings` is `patch`: prose only — the `upsertRow` docblock, which ships in the package's `.d.ts`, no longer states the superseded INSERT exemption; it names the platform-object carve-out that actually keeps a `sys_setting` insert outside the strip.
18+
- `@objectstack/lint` and `@objectstack/spec` are `patch`: both change prose only. All three lint rules — `validate-readonly-action-writes`, `validate-readonly-flow-writes`, `validate-readonly-hook-writes` — drop the superseded "INSERT is exempt" premise from their docblocks and from the justification of their green control cases; the two non-elevated rules now name their `insert`/`create` silence as a scan gap rather than an exemption (the action rule additionally records its now-reasoned refusal as a module-local constant that its `index` does not re-export, so no public surface widens). The spec change is prose only: one docblock sentence that named the deleted function, the `strictReadonlyWrites` contract docblock (which now states what strict refuses on insert), and the `readonly` liveness-ledger verdict, whose evidence pointer named the deleted ingress strip.
19+
20+
<!-- adr-0087: not-required (no-migration-prescription) The BREAKING here is a WRITE-PATH BEHAVIOUR change, not a retirement of an authorable or published surface, so there is nothing for the ledger to carry to `objectstack migrate meta`, `spec-changes.json` or the upgrade guide: no spec property, metadata key, accepted value or exported symbol disappears, and this body prescribes no FROM/TO migration. The remedy for an affected caller is to declare the write trusted (`context.isSystem` / `runAs: 'system'`), which is application code, not a metadata migration. The obvious retirement candidate is a non-question in the same direction: `stripReadonlyForInsert` was a bare module-private `function` in `packages/metadata-protocol/src/protocol.ts`, absent from that package's `index.ts` (the only path its `exports` map offers), so no consumer could name it. -->

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,9 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
104104

105105
| issue 上的信号 | 含义 |
106106
|---|---|
107-
| open + 队列标签 + 无 assignee | 可派发(同卡带 `pm:retriage` 的除外) |
108-
| assignee 已设 | 已认领/在飞,不是你的就永不碰 |
109-
| `pm:dispatched` | 已派发(派发评论记轮次);与摘 `pm:queue` 同一次标签写入成对落地 |
107+
| open + 队列标签 + 无 assignee | 可派发(同卡带 `pm:retriage` 的除外);`pm:queue` 卡恒无 assignee,有即半态 |
108+
| assignee 已设 | 已认领/在飞,不是你的就永不碰;离手恒走释放 —— 四因 = 改路由、前提证伪、弃飞无接管、跨车道移交,去向 = 新标签态或车道;⛔ 不静默摘 assignee |
109+
| `pm:dispatched` | 已派发(派发评论记轮次),恒带 assignee;与摘 `pm:queue` 同一次标签写入成对落地 |
110110
| `needs-user-decision` | 决定待做:永不派发、除代裁通道外永不代答;维护者的收件箱 |
111111
| `pm:on-hold` | 决定已做且答案是暂不做:不派发不催;仅当带机器可读 `Restart-when:` 行才合法 |
112112
| `pm:blocked` + 正文行 `Blocked-by: #N` | 等上游:选择期跳过,#N 关闭时由解锁扫描放回;工已完、PR 被外部门禁卡住的同用本态 |
@@ -462,8 +462,8 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
462462

463463
- 同账号多会话共享 GitHub 身份:assignee 只回答有 agent 认领了,认领评论承载身份。
464464
- assignee 字段归 PM:原子对 step 1 设,dev 席恒不写它;跨账号 assignee 不是你 ⇒ 永不碰。
465-
- 选中的单派发前按序执行一个原子对
466-
- ① Assign @me,并把 `pm:dispatched` 与摘 `pm:queue` 放进同一次标签写入。
465+
- 释放是显式动作:让卡离手者同笔清 assignee + `Release:` 行(会话/因/去向);下一任重新认领
466+
- 派发前按序执行原子对:① Assign @me,并把 `pm:dispatched` 与摘 `pm:queue` 放进同一次标签写入。
467467
- step ① 之后获得 assignee 的直接弃出本批。
468468
- ② 认领评论(英文),固定形状见 模板与表 节;首行以字面 `Claim:` 开头是机器判据。
469469
- 巡查谓词只认 `Claim:` 这一个拼写且保持严格,修法是全舰队向它收敛,⛔ 不放宽谓词。
@@ -490,7 +490,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
490490
- 过栏后,派发 worktree 的未提交改动先 WIP commit 到派发分支并 push,sha 记进回收评论。
491491
- WIP commit 标 INCOMPLETE AND UNREVIEWED;续派者 diff 它,⛔ 不无审续建。
492492
- WIP 信息只写观察到的(脏路径/行数/sha),⛔ 不写席位行为的现在时断言。
493-
- 再评论询问,静默一窗后摘 assignee(注明原因)回队;有带提交活分支的认领永不回收。
493+
- 再评论询问,静默一窗后释放回队(`Release:` 行载因);有带提交活分支的认领永不回收。
494494
- 误伤活席位 ⇒ 令其追加式更正,落 PR 正文不落分支历史。
495495

496496
### 派发

.claude/skills/pm-dispatch/references/state-machine.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@
3535
## `pm:awaiting-maintainer``pm:retriage`
3636

3737
- awaiting 无机器出口:维护者完成该操作后,由项目总监席核验摘标 + 证据评论同笔。
38-
- awaiting 卡在在飞视图里计停放库存。
39-
- retriage 挂/摘分属两方:原定级在改判前仍是权威。
38+
- retriage 原定级在改判前仍是权威;awaiting 卡在在飞视图里计停放库存。
4039
- 挂标者 = 提出异议的席位,挂标与异议评论(证据 + 所求的答案)同笔;⛔ 裸挂标不合法。
4140
- 摘标者 = 分诊 Routine,每 fire 先答异议评论所求、答后同笔摘标;老化兜底归巡查 H18。
41+
- 释放者 = 让卡离手的席位:行首 `Release:`(会话/因/去向)与清 assignee 同笔;下一任重新认领。
4242
- 标签须五仓存在,首次应用时创建。

content/docs/automation/hook-bodies.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,23 +252,23 @@ A body may write *other* objects — e.g. `await ctx.api.object('parent').update
252252

253253
### Writing a `readonly` field
254254

255-
There is an asymmetry here that costs data if you learn it the hard way, so learn it here. A field declared `readonly: true` can still be **maintained by automation** from a body — but through exactly **one** channel (the own-hook stamp, plus INSERT), and a nested `ctx.api` write is **not** one of them.
255+
There is an asymmetry here that costs data if you learn it the hard way, so learn it here. A field declared `readonly: true` can still be **maintained by automation** from a body — but only through the own-hook stamp, or under a **system** context. A nested `ctx.api` write on a non-system trigger is **not** a channel, and since the maintainer ruling of 2026-09-03 ([#14147](https://github.com/objectstack-ai/objectstack/issues/14147)) that is as true of `insert` as of `update`.
256256

257-
`readonly` governs the *caller* surface. On UPDATE the engine strips read-only keys from the payload, but only the ones the **caller supplied** and only when the value is still the caller's. So:
257+
`readonly` governs the *caller* surface. On every non-system write — UPDATE, and INSERT too since that ruling moved the create-side strip into `engine.insert` the engine strips read-only keys from the payload, but only the ones the **caller supplied** and only when the value is still the caller's. So:
258258

259259
| How the body writes it | What happens |
260260
|:---|:---|
261261
| `ctx.input.<field> = …` in `beforeInsert`/`beforeUpdate` | **Lands.** The stamp is a *server* value, not a caller-supplied one, so the strip leaves it alone. This is the recommended shape. |
262262
| `ctx.api.object('x').update({ <field> })` | **Silently dropped** — unless the hook declares `runAs: 'system'`. `ctx.api` is scoped to the *triggering* operation's context, so on any non-system trigger the payload is an ordinary caller payload and the key is stripped, and the call still returns success. Declaring [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) gives that `ctx.api` a system context, which the strip skips, so the write lands. |
263263
| `ctx.api.sudo().object('x').update({ <field> })` | **`TypeError` — not available here.** `sudo()` is a member of the *in-process* `ScopedContext`; the VM's `ctx.api` carries `object()` and `transaction()` and nothing else, so a **body** cannot reach it. Worse than unavailable: the same source *works* when the handler runs in-process, so it passes a native `hook.handler(ctx)` test and throws only once the build lowers it into a body — aborting the triggering write under the default `onError: 'abort'`. `objectstack build` now refuses to lower such a handler and keeps it bundled instead. The knob to reach for is [`runAs: 'system'`](/docs/automation/hooks#elevation--runas) on the hook itself, which is declarative and works on **both** surfaces. |
264-
| `ctx.api.object('x').insert({ <field> })` | **Lands.** INSERT is exempt — a create may legitimately seed read-only columns. |
264+
| `ctx.api.object('x').insert({ <field> })` | **Silently dropped** — unless the hook declares `runAs: 'system'`, exactly as the `update` row. `engine.insert` runs the same static strip under the same `isSystem` gate, so a non-system create no longer seeds a read-only column: the key is removed, the field falls back to its `defaultValue`, and the call still returns success. Seeding a read-only column at create time is a **system** act — `runAs: 'system'` here, or a `beforeInsert` stamp on the target object. (Before the 2026-09-03 ruling this row read "Lands — INSERT is exempt"; that row is superseded.) |
265265

266266
The dropped case is the dangerous one: nothing fails, the step reports success, and the column is simply always null. Because both halves of that judgement are declared in your own stack, it is checked at author time and **gates the build**:
267267

268268
- `hook-api-update-readonly-field`**error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` writes a field the named object declares `readonly: true`.
269269
- `hook-api-update-readonly-when-field`**warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: unlike the static strip, the conditional lock is **not** waived by a system context, so neither `runAs: 'system'` nor the `sudo()` a body cannot reach makes a caller-supplied value survive. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.
270270

271-
Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name, an object this stack does not declare, and `insert`/`create` are all skipped, so the rule has no opinion on them. The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425).
271+
Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name and an object this stack does not declare are all skipped, so the rule has no opinion on them. `insert`/`create` are skipped too — but since the 2026-09-03 ruling that is a **scan gap**, not an exemption: the write is dropped exactly as the table says, and nothing reports it at build time yet ([#15394](https://github.com/objectstack-ai/objectstack/issues/15394)). The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425), with the same gap on `create_record`.
272272

273273
The table above is about a **hook** body. An **action** body is the one surface where the answer changes, so read this before you move a body from one to the other: an action body runs **elevated** — its `ctx.api` is built over the caller's envelope with `isSystem` set, which is the same trusted posture that lets an action bypass row and field permissions — and the static strip applies only to non-system callers. So `ctx.api.object('x').update({ someReadonlyField })` **lands** in an action, and there is no finding for it. Elevation does not waive the *conditional* lock, though, so that half does carry across: `action-api-update-readonly-when-field` — a **warning** — on an action body's literal `ctx.api` update to a `readonlyWhen` field ([#13770](https://github.com/objectstack-ai/objectstack/issues/13770)). Net effect when you move a body: a `readonly` write changes behaviour, a `readonlyWhen` write does not.
274274

0 commit comments

Comments
 (0)