Skip to content

Commit 07f0f49

Browse files
Merge branch 'main' into claude/issue-24284-search-empty-set-refused
2 parents fd4e073 + 2ce2e36 commit 07f0f49

23 files changed

Lines changed: 831 additions & 76 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
feat(objectql): evaluate a formula field over a record in hand the way a read serves it, one hop through a reference included
6+
7+
A formula title over lookups (`record.book.title + ' · ' + record.reader.name`) is computed on every read since the formula read path reads one hop. A record no read returned, such as the state a hook fires on, had no way to get the same value: the synchronous `evaluateFormulaField` reads no hop, so it answered `null`.
8+
9+
- **`ScopedContext.serveFormulaField(objectName, record, field)`** evaluates one declared formula field over `record` with the read path's own evaluation, under that context: the one hop it reads through a reference (one batched read per reference field, through the reader's own permissions, on the context's transaction when it carries one), the `can()` permission map, and the fault report. A related record the reader may not read, a missing one and a second hop leave the field `null` with the read's reason, as a read does. `ObjectQL.serveFormulaField(objectName, record, field, context)` is the engine method behind it.
10+
- **`resolveServedRecordTitle(schema, record, channel)`** is the async sibling of `resolveRecordTitle`: this record's title, with a formula title served on `channel` (a `ScopedContext`). `resolveRecordTitle` is unchanged and still reads no hop.
11+
- **`servedRecordTitle(schema, row)`** reads the title a read already served off the row it returned, instead of re-deriving it.
12+
13+
What to do. Nothing. Code that names a record it holds in hand and wants the title a read shows calls `resolveServedRecordTitle` with the caller's `ScopedContext`.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): `ctx.title()` in a hook body answers a title formula that reads through a lookup, as a read of the record shows it
6+
7+
A junction object's composed title, `record.book.title + ' · ' + record.reader.name`, is shown by every read of the record, but a hook body's `ctx.title()` answered `null` for it, and `ctx.title('<lookup field>')` answered `null` for a related record whose own title reads through a lookup.
8+
9+
- **`ctx.title()`** now reads the one hop the title formula reads, through the body's own `ctx.api` channel: one read per lookup the title reads through, with the caller's permissions, on the transaction the body has open. It follows the lookup's value in this write, so a `beforeInsert` names the payload's book and a `beforeUpdate` that changes `book` names the new book. A title over the record's own columns still costs no read.
10+
- **`ctx.title('<lookup field>')`** returns the title its `findOne` served, the related record's own one-hop title included, instead of re-computing it without the hop.
11+
- A related record the caller may not read leaves the title `null`, as the read of the same record shows no title to that caller. It is never a partial title and never the hidden name. A title that would need a second hop is `null`, as it is on a read.
12+
13+
What to do. Nothing. A hook that composed a junction object's title by hand can call `ctx.title()` instead.

‎.changeset/24283-console-pin-0707b4ae3712.md‎

Lines changed: 120 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Clause-②: no
6+
7+
The `forceCbpMasterDetailRequired` docblock in `src/data/object.zod.ts` no longer says that today only
8+
the security gate (`assertControlledByParentWrite`) closes the null-master-FK shape of a
9+
`controlled_by_parent` object. That was true before the builder's force landed, and the sentence is now
10+
in the past tense. It also names what has refused the declaration at parse since protocol 18:
11+
`refuseControlledByParentWithoutOneMaster` refuses a `controlled_by_parent` object none of whose
12+
`master_detail` fields declares `required: true`. No key, schema, export, accepted value or message text
13+
changes.

‎.claude/skills/pm-dispatch/references/dispatch-runbook.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,6 @@
199199
- 不许做 ⇒ ⛔ 不要动该文件,让门禁红着并在报告里点名该站点。
200200
- 派发那刻现取、随派发词下发的两份读数,判据同一:PM 已有的读数下发一次。
201201
- ① 门禁清单单班之内即过期;全 farm 归 CI 跑一次,⛔ 不让 dev 枚举全 farm。
202-
- 点名单当天现取仍会漏,dev 对实际改动路径重取补跑。
203202
- ② 去重或看板读数取一次(查询式加时间戳)整批共用。
204203
- 派发令写:当既有事实用,只复核其后的增量,⛔ 不重跑;dev 侧对价住 os-dev 定义。
205204
- 四轴框架块(SKILL.md 升级与决策 节)整段现取现粘、逐单随派发词下发。
@@ -209,6 +208,9 @@
209208
- 文件面两句原文照抄:预期落点是 X。
210209
- 若实测表明真正的生产者在别包,直接按生产者侧修,落点与理由写进报告和 PR 正文。
211210
- 跨包常等于跨车道,PM 事后补跨座位声明。
211+
- 卡的代码首读 spec 发布 R 首发的键、类型成员或运行时行为,即 R 的首个消费者:
212+
- 同 PR 把消费包 `@objectstack/spec` 下限抬到 R,认领文件面点名 manifest 与 lockfile specifier。
213+
- 「不动 pin」只禁 lockfile 解析版本与 `.objectui-sha`/console pin 的移动,⛔ 永不禁下限。
212214
- 模型标识只禁内容工件:PR 标题与正文、代码注释、文档、changeset、评论。
213215
- 豁免:`.claude/agents/*` frontmatter 的 `model:`,`check:agent-model-declared` 要求。
214216
- 新 changeset 文件名用 `<issue>-<slug>.md`,⛔ 不用随机词对名。
@@ -229,10 +231,8 @@
229231
- 新增或改写拒收类用例 ⇒ 信封断言本体是 os-dev 标准条款,只补边界不回填存量。
230232
- 改过滤或谓词语义 ⇒ 把 `compile-surfaces.md` 的编译面清单按复核串重验后逐面抄进派发令。
231233
- ⛔ 不凭记忆;并带一句:每一面都必须在 PR 正文有一个结论。
232-
- 结论三选一:已改、本就合规(证据)、明确不在范围(理由)。
233-
- ⛔ 静默略过:评审把没提到的面读作漏掉的面。
234+
- 结论三选一:已改、本就合规(证据)、明确不在范围(理由);评审把没提到的面读作漏掉。
234235
- 测量先行卡(修复由测量结果有条件授权)⇒ 只派只读读数 dev 或不派等裁;建设 dev 待裁。
235-
- 只读读数 dev 不建闭包、不跑测试、不开 PR;只读树与记录,读数贴决策卡或立卡。
236236
- 探针做在 dev 要留的分支上,⛔ 不用一次性分支:建得出删不掉,遗留分支被预检读成认领。
237237

238238
## security-object 无判决枚举(座位贴常设 ⑦)的方法

‎.claude/skills/pm-dispatch/references/triage-duties.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
- objectui pin 落后且其队列已空 ⇒ 在本仓立/刷新 console bump 单。
2828
- 见新 tag 族发布 ⇒ 在 cloud 立 `.objectstack-sha` bump 单。
2929
- bump 单单张封顶:先查同题 open 单,已有就追评刷新;工具链新形态只提请不扩面。
30+
- bump 单的等待卡清单读自 hold 索引,查询式与命中数落正文;⛔ 凭记忆写的不是该清单。
31+
- hold 索引 = `Restart-when:` 或 `Blocked-by:` 行点名该发布、版本或上游 commit 的全部 open 卡。
3032
- 分类动作每张三选一,外加一个修复通道。
3133
- 裁定写方向、给六态之一。
3234
- 方向在 ADR/本卡裁决/不可重裁规则:贴 `check-prior-rulings.mjs` 的 `Prior rulings read:` 行,或不点。
@@ -36,8 +38,7 @@
3638
- `pm:queue` 也收恢复不变量的 finding、test-only pin,与实现未被裁错的说明书脱节(修文档)。
3739
- `needs-user-decision` = 设计卡、feature/契约形状提案、需要 appetite 的多周程序。
3840
- 碰迁移形状或删已发布能力也进决策箱;说明书脱节不进:有裁定出处才是 (b),否则改文档。
39-
- 决策卡落卡必带四棱卡面块与其上的「维护者速读」,⛔ 不留待有人接手再补。
40-
- 决策卡的速读同题,用业务语言先讲事情与选项,末句只问一字:A/B/C 或是否。
41+
- 决策卡速读居四棱块之上、同题,用业务语言先讲事情与选项,末句只问一字:A/B/C 或是否。
4142
- 首行写立卡门类别 ①–④;四类定义、⛔ 清单、三答与配额见 `references/filing-gate.md`。
4243
- `finding` = 三类内待定级:(a) 可复现缺陷(复现或失败探针);(b) 违背已声明契约(引契约文);
4344
- (c) AI 写元数据会被运行时拒收或静默丢弃的陷阱;其余进 PR `## Acceptance notes`,⛔ 不立卡。
@@ -52,9 +53,8 @@
5253
- 决策箱勤务:入箱校验/补全四棱块、速读与 `Prior rulings on this card:` 行;存量卡低频回填。
5354
- 入决策箱那一笔连「前提今天还成立吗」一起现读;改状态只需现读卡。
5455
- 线程有 `Ruling:`/RULED 的卡再入箱,该行逐 id 各一句变化;缺行 ⇒ 拒入,`pm:retriage` 回立卡者。
55-
- `type`(Bug/Feature/Task)是分诊固定产出,分诊席唯一权威生产;立单者可预填,分诊校正。
56+
- `type`(Bug/Feature/Task)立单者可预填、分诊校正;新卡即时打,存量卡 ⛔ 不回填、下次碰到补。
5657
- 判据:违背已声明契约 ⇒ `Bug`,扩大接受集/公开面越出已声明 ⇒ `Feature`,其余 ⇒ `Task`。
57-
- ⛔ 不回填存量 backlog 的 type;新卡即时打、存量卡下次碰到补。
5858
- 定级按北极星「优先级」1–3 条;首行 `Path: <定义项> | <清单项 id/缺项> | P<路步> | none`。
5959
- 定义项取北极星「做出来的是什么」或仪器卡的车队决定;无则关;`none` 非分诊缺口。
6060
- 清单项三态定级(会 fail / 步骤够不到 / 无项断言)与定级判据见 `references/filing-gate.md`。

‎.objectui-sha‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
5e15928b4631a9dfd0d9bdd7282d1b746cdbaa68
1+
0707b4ae37120d45d6401170666a0b76cb7a3546

‎content/docs/automation/hook-bodies.mdx‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,8 @@ The script sees only what the surrounding `ctx` object exposes:
100100
| `ctx.user` / `ctx.session` | Identity context. | none |
101101
| `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` |
102102
| `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` |
103-
| `ctx.title()` | This record's title — the object's `nameField`, **including when it is a `formula`** (evaluated server-side against the record already in hand, no extra read). | none |
104-
| `ctx.title('<lookup field>')` | The related record's title, through a lookup / master_detail / user / tree column. Costs one `findOne`. | `api.read` |
103+
| `ctx.title()` | This record's title — the object's `nameField`, **including when it is a `formula`** (evaluated server-side against the record already in hand: no extra read over the record's own columns, one read per lookup a title such as `record.book.title + ' · ' + record.reader.name` reads through). | none |
104+
| `ctx.title('<lookup field>')` | The related record's title, through a lookup / master_detail / user / tree column. Costs one `findOne` (plus the lookups that record's own title reads through). | `api.read` |
105105
| `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` |
106106
| `ctx.log.{info,warn,error}` | Structured logging. | `log` |
107107
| `ctx.connector(name).<method>(...)` _(planned)_ | Outbound HTTP / SaaS calls. **Not yet wired into the sandbox** — ships with the separate Connector spec. | (separate Connector spec) |
@@ -127,14 +127,21 @@ const account = await ctx.title('account_id');
127127

128128
Three properties worth knowing:
129129

130-
- **A formula `nameField` costs nothing extra.** It is evaluated server-side
131-
against the record the hook is already firing on — the same expression, the
132-
same evaluator and the same rounding a `GET` of that record would use, so the
133-
title a hook writes and the title the UI shows cannot drift.
134-
- **The related form costs exactly one `findOne`**, through your body's own read
130+
- **A formula `nameField` is evaluated the way a `GET` evaluates it.** Against
131+
the record the hook is already firing on — the same expression, the same
132+
evaluator and the same rounding a `GET` of that record would use, so the title
133+
a hook writes and the title the UI shows cannot drift. A title over the
134+
record's own columns costs nothing extra. A title that reads one hop through a
135+
lookup (`record.book.title + ' · ' + record.reader.name`) costs one read per
136+
lookup it reads through, follows the lookup's value in this write (a
137+
`beforeUpdate` that changes `book` names the new book), and is read through
138+
your body's own read channel: a related record you may not read leaves the
139+
title `null`, exactly as the `GET` shows no title.
140+
- **The related form costs one `findOne`**, through your body's own read
135141
channel — so it obeys the caller's scope and joins an open
136142
`ctx.api.transaction`. That is why it requires `api.read` while the bare form
137-
requires nothing: the token gates the read, and there is no read to gate.
143+
requires nothing: the token gates your body's reach for another record; the
144+
bare form names this record by its own declaration.
138145
- **It never falls back to the id.** No title resolvable ⇒ `null`. An id-shaped
139146
string is a plausible-looking title to whatever renders it, so the platform
140147
will not manufacture one; write your own fallback if you want one.
@@ -410,7 +417,7 @@ The extractor scans each body for known patterns and adds the matching capabilit
410417
| `*.object(…).insert / update / upsert / delete / patch / remove / create` | `api.write` |
411418
| `ctx.crypto.randomUUID` | `crypto.uuid` |
412419
| `ctx.log.info / warn / error / debug` | `log` |
413-
| `*.title(<argument>)` — the related-record form only; bare `ctx.title()` performs no read | `api.read` |
420+
| `*.title(<argument>)` — the related-record form only; bare `ctx.title()` names this record and needs no token | `api.read` |
414421

415422
The matcher is deliberately over-inclusive — it names spellings the VM does not install (`patch`, `remove`, `get`, `list`, `create`) because an over-inferred token costs nothing the sandbox ever checks, while an under-inferred one surfaces as a sandbox refusal far from its cause. `create` is listed only for that reason: a body spelling it is refused at lowering (see [What the sandbox forbids](#what-the-sandbox-forbids)) and never reaches inference at all.
416423

‎content/docs/ui/react-pages.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -359,9 +359,9 @@ by tag and through `<Block type="record:…">` alike:
359359

360360
```
361361
✗ Author-time rules failed (1 issue)
362-
• page "showcase_renewals_pipeline" › <RecordHighlights>: <RecordHighlights> renders "record:highlights", which reads its record from the record context a record page mounts — a kind:'react' page never mounts one, so the block renders empty no matter how it is bound (its objectName/recordId are not read by the renderer).
363-
fix: On a react page bind the record yourself: <ObjectForm objectName="…" mode="view" recordId={…} fields={[…]} />, or read the record with useAdapter().findOne and lay the strip out in JSX.
364-
rule: react-block-needs-record-context at pages[27].source
362+
• page "showcase_renewals_pipeline" › <RecordHighlights>: <RecordHighlights> renders "record:highlights", which reads its record from a record page's context; a kind:'react' page mounts none, so the block renders empty
363+
fix: Instead: <ObjectForm objectName="…" mode="view" recordId={…} fields={[…]} />, or read the record with useAdapter().findOne and lay the strip out in JSX.
364+
rule: react-block-needs-record-context at pages[28].source — `os explain react-block-needs-record-context` for why a record block renders empty on a react page
365365
```
366366

367367
The error names the replacement for the block you reached for, so read the hint rather

‎packages/cli/src/utils/extract-hook-body.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,8 @@ const CAPABILITY_PATTERNS: Array<{ rx: RegExp; cap: 'api.read' | 'api.write' | '
529529
{ rx: /ctx\.log\.(?:info|warn|error|debug)\b/, cap: 'log' },
530530
// [#11293] `ctx.title(field)` — the RELATED-record form, and only that form.
531531
// `ctx.title()` resolves this record's title (formula included) from the
532-
// state the hook is already firing on and performs no read at all, so it
532+
// state the hook is already firing on — no read over its own columns, and
533+
// only its declaration's one hop through a reference otherwise — so it
533534
// needs no capability and inferring one for it would tax the majority case
534535
// with a grant it never exercises. The argument form costs exactly one
535536
// `findOne` through the body's own read channel, which is the same read

0 commit comments

Comments
 (0)