Skip to content

Commit d9ebbee

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22642-flow-cel-record-binding
2 parents e87a793 + ee3ae03 commit d9ebbee

31 files changed

Lines changed: 1564 additions & 7494 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/service-analytics': minor
3+
---
4+
5+
fix(service-analytics)!: the analytics door judges the two generic-exit declarations every other door judges — an object's `enable` block and a field's `internal: true` (#22634)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime read door: analytics stops serving objects and fields whose existing declarations already withhold them from every other generic exit, and the remedy is the declaration the author already wrote, never a rewrite of anyone's code or metadata. One optional hook, `getObjectDeclaration`, is added to the exported `AnalyticsServiceConfig` type; it is additive, and nothing on the type surface narrows. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
Every generic exit judges an object's `enable` block through the spec's one exposure decision, `apiExposureDenialReason`: the REST data routes, the runtime dispatcher, the MCP data tools, and the cross-object search for the off switch. Every generic exit also withholds a field declared `internal: true`: the data door omits it from rows, the write responses strip it, and the engine's `aggregate()` refuses it as a group key. The analytics door read neither declaration.
14+
15+
**FROM.** On both strategies (native SQL and ObjectQL), and for a member holding read and an administrator alike:
16+
17+
- The ad-hoc query, the SQL face, the configured-cube door and the dataset door served the rows of an object declaring `apiEnabled: false`, and of an object whose `apiMethods` whitelist does not grant `list`.
18+
- On the native-SQL strategy, a field declared `internal: true` was served as a group key, as an aggregate input and as a filter operand.
19+
- On the ObjectQL strategy, the engine refused the same field as a group key or an aggregate input with an undeclared `500`. It still admitted the field as a filter operand.
20+
21+
**TO.** Ahead of strategy selection, the door asks the spec's decision for the `aggregate` operation over every object a query reads: the base object, declared joins and relationship hops. It answers with the data door's codes for the same declaration:
22+
23+
- `404 OBJECT_API_DISABLED` for `apiEnabled: false`;
24+
- `405 OBJECT_API_METHOD_NOT_ALLOWED` for a whitelist that does not grant the verb.
25+
26+
On the ad-hoc doors this happens before any cube is inferred, so a refused request mints nothing. A member that reads a field declared `internal: true` is refused `400 INVALID_FIELD`, naming the object and the field. This holds in every position: dimension, measure input, filter or sort key, dataset filter, and relationship hop.
27+
28+
- **Refused, not withheld.** On the data door, the row path omits such a field because a row is a projection. Every analytics member is evaluated instead: a group key, an aggregate input, a predicate. There is nothing to omit, and the data door's own aggregate face already refuses the field as a group key.
29+
- **Every caller.** Neither declaration takes a user, so administrators are refused exactly as members are. There is no system carve-out.
30+
- **Fails closed.** A declaration lookup that throws refuses the query with `403 PERMISSION_DENIED`, logged at `error`. `AnalyticsServicePlugin` wires the lookup from the data engine and refuses when no engine can answer. A host constructing `AnalyticsService` by hand with no `getObjectDeclaration` hook gets no gate, and is told so once.
31+
- **Unchanged.** An ordinary object and an ordinary field are served as before. An object that merely has an `internal` field is still served for every other field, and so is a bare count over it.
32+
33+
**Measured producers.** On `origin/main` `f368b7e980`, no authored cube, dataset, dashboard or example in this repository names an `apiEnabled: false` object, an object whose whitelist omits `list`, or an `internal` field. The platform's System Overview datasets count `sys_user`, `sys_organization` and `sys_session`, and group `sys_audit_log` by two ordinary fields. Each of those objects grants `list`, and none of those members is `internal`. Deployed and cloud-held analytics definitions were NOT MEASURED.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the error-code ledger records `@objectstack/service-analytics` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22634)
6+
7+
The analytics door now answers the data door's two exposure codes for an object whose `enable` block refuses the aggregate operation, so `ERROR_CODE_LEDGER['@objectstack/service-analytics']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ PM 的工作是循环:选卡 → 认领 → 派发 → 收集 → 复核 → 报
266266
- 权重只排推荐不授权:读缩小特例与契约增生,⛔ 不背书投机扩张、不放行人工地板事。
267267
- 四维分析从业务角度写;四棱是四轴的卡面序列化,一一对应,也是分诊代裁置信门的输入。
268268
- 卡先于弹窗:需裁决先落 `needs-user-decision` 卡;收件箱由维护者定期消化,⛔ 不 assign 推送。
269-
- 决策卡带选项、推荐、证据与四棱块;被阻塞的执行卡同笔挂 `pm:blocked` + `Blocked-by:`。
269+
- 决策卡带选项、推荐、证据、四棱块与工作量列;受阻执行卡同笔挂 `pm:blocked`+`Blocked-by:`。
270270
- `AskUserQuestion` 只是在场加速器:仅当维护者在本会话 ~30 分钟内有过人类输入才可发。
271271
- 每问必带推荐项,被 Skip 或长挂即转卡通道,⛔ 不重弹。
272272
- 项目总监席:维护者在所在仓召唤 `/pm-dispatch director`;⛔ 无 Routine/cron;未回字 = 不在 = 停。

‎.claude/skills/pm-dispatch/references/decision-analysis.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,28 +22,28 @@
2222
带 re-check 命令的前提 / 具体问题 / 选项 / 推荐 / 相关单与 PR。正文六项:
2323

2424
- 一句话问题:开头一句话说清冲突是什么,⛔ 不带实现名词,写客户看得见的症状。
25-
- 选项 × 真实代价表:每选项一行,做什么 + 客户可感知的后果;有实测的注明实测。
25+
- 选项 × 真实代价 × 工作量表:每选项一行,做什么 + 客户可感知后果 + 工作量;实测注明。
26+
- 工作量 = 车道 · 阶段/PR 数 · 实测先例(具名落地 PR 及 +/− 行数)或「无先例」;⛔ 不猜。
2627
- 业务含义直译:每选项配一句业务上等于什么,允许行业类比。
2728
- 四轴从业务立场论证(含四棱行):论据是客户、产品或事故后果,机制名词只作括号补充。
2829
- 防 AI 犯错轴写清出错时谁看到什么:响亮拒绝,还是静默泄露。
2930
- 推荐 + 回退 + 置信缺口:荐一项、给回退项、明说看不见什么;⛔ 不沿用他席推荐。
3031
- 推荐先只按①得出:终态句写两年后该能力的平台样子与主流平台建模,点名参照。
31-
- ②③④ 只调时序分期 ⛔ 不翻字母;契约迁就实现、分期半量 ⛔ 不作推荐。
32+
- ②③④ 与工作量只调时序分期 ⛔ 不翻字母;契约迁就实现、分期半量 ⛔ 不作推荐。
33+
- 工作量恒逐选项列;①读数相近而工作量差 ≥3× 推荐须点名;翻字母只凭维护者卡上另裁。
3234
- 自检行「只看①选 X;②③④ 是否翻转:否 / 是(理由)」,答是不改字母。
3335
- 裁后执行段:写明裁定之后怎么执行,让维护者只裁方向不背执行。
3436

3537
## 四棱卡面块固定形状
3638

3739
- 落卡即带,⛔ 不留待维护者到场再补;四棱各一行,用中文写。
38-
- 语言例外见 SKILL.md 〈全体座位的不变量〉。
3940
- 首行机器可寻标记 `os-decision-facets`:纯文本单行与其 HTML 注释形等价,任一即满足。
4041
- 写后回读,提取按字面 grep;⛔ 注释形读不到永不读作无四棱块。
4142
- ① 项目长远合理性:本方案缩小还是扩大特例与契约增生。
4243
- ② 实际业务拉动:今天谁撞上;零拉动默认 defer 或 remove。
4344
- ③ 防 AI 犯错:闭合枚举优于自由结构,响亮拒绝优于静默容忍。
4445
- ④ 创业阶段不扩散:remove 优于 declare-and-maintain,每个已声明的键都是永久义务。
4546
- 四棱后:`Prior rulings read: <terms> → <n> hits; <ADR Dn|none>; thread: <k>|none`;未搜成整行写 unresolved。
46-
- 末尾推荐行 + 字母选项带自检「只看①选 X;②③④ 是否翻转:…」,再一行置信缺口。
4747

4848
## 应用仓推荐序特例
4949

‎.claude/skills/pm-dispatch/references/lanes/director.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- 同卡同笔只一场写,线程即顺序、后记者胜,裁前先读线程。
1111
- 裁决评论首行固定一条:`Ruling: batch #<n> item <k> · letter <X> · maintainer 「<verbatim>」 <UTC>`。
1212
- 录裁同笔改正文:首行加 `Ruled: <评论 id> · letter <X> · <UTC>`;⛔ 不新增标签与状态。
13+
- 裁决记录同笔写明所选字母的工作量,读数取自卡面工作量列。
1314
- 划掉原 ⛔ 不派断言,走 `post-stamped --body --ack-through`;漏此笔 ⇒ 接卡者先读正文退决策箱。
1415
- 入口:维护者说处理决裁或处理 contract-review,或 `/pm-dispatch director`。
1516
- 未回字 = 不在 = 停:维护者不在则本席不运行,⛔ 无巡检、审计、快照、聊天、卡评论。
@@ -21,6 +22,7 @@
2122
## 基本原则
2223

2324
- 三要素:每批 5 张;每卡从业务角度讲清问题、选项与代价;每卡带四维分析加一个推荐。
25+
- 呈报的代价列旁恒带工作量列(车道 · 阶段/PR 数 · 实测先例或「无先例」),与卡面同列。
2426
- 创业阶段不渐进:废弃别名与能力退役默认立即退休,无窗口、无双拼写宽限。
2527
- 一次裁清:子问题、分叉与执行参数(字母、车道、级别)同笔裁定,⛔ 不留 dev 另立决策卡。
2628

‎.gitattributes‎

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,14 @@
1717
# stay routed here as directories: the driver still owns a same-category
1818
# collision, which is the residue sharding cannot remove.
1919
#
20-
# One entry below is still a SINGLE file — spec-changes.json — and #8344 asked
21-
# what the driver-less queue does to it. MEASURED (2026-08-13, the real in-flight
22-
# case plus four synthetic pairs, each merged in a clone with no
23-
# merge.os-regen.driver, over it and the protocol upgrade guide, then a second
24-
# committed single file): the queue leaves neither stale-but-clean. Both were sorted
25-
# unions and an ADR-0087 registration is insertion-only, so the queue's text merge
26-
# either takes both sides — byte-identical to the regeneration, gates green — or
27-
# conflicts outright. It conflicts only when the two in-flight entries are ADJACENT
28-
# in registry sort order; one existing entry between them is already enough to
29-
# merge clean AND current.
30-
#
31-
# Sharding it would not buy back an ejection, which is why it is still a single
32-
# file: every conflicting case also conflicts in
33-
# packages/spec/src/migrations/registry.ts — generated, committed, unsharded,
34-
# NOT_DRIVER_MANAGED — and every registration touches it by construction. The table
35-
# and the reproduction are in packages/spec/src/migrations/entries/README.md.
20+
# packages/spec/spec-changes.json LEFT this list at #22485, because it left git: the
21+
# #22449 B′ ruling generates it at publish (`scripts/release-spec-changes.sh` writes
22+
# it into the package before the tarball is packed), the pull request generates it
23+
# in memory, and the file is gitignored. NOT_DRIVER_MANAGED records `gen:spec-changes`
24+
# as untracked output. A branch that still carries a regenerated copy meets a
25+
# modify/delete conflict when it merges main, and the deletion is the resolution:
26+
# `git rm packages/spec/spec-changes.json`, then commit — git runs no merge driver on
27+
# a modify/delete, so nothing is deferred and no marker is left.
3628
#
3729
# docs/protocol-upgrade-guide.md LEFT this list at #22483 because nothing generates
3830
# it any more. It is a hand-written pointer stub naming the guide's public address,
@@ -46,7 +38,9 @@
4638
# forked before #22483 still defers the guide on its first merge of main; the merged
4739
# tree has no row to discharge it against, so the next commit and the push are
4840
# refused instead of passed (measured on PR #22556 with the previous head as the
49-
# control, both merge directions).
41+
# control, both merge directions). Since #22485 that refusal names the path as
42+
# UNROUTED and prints the remedy that settles it — compare it with the merged-in
43+
# side, keep the right bytes, `node scripts/check-regen-pending.mjs --release PATH`.
5044
#
5145
# `merge=os-regen` hands those paths to `scripts/git-merge-regen.mjs`, which does
5246
# NOT text-merge them. See that file for why it also does not regenerate them
@@ -177,7 +171,6 @@
177171
# case where two branches' rows do not overlap and the text merge exits 0 describing
178172
# neither side.
179173

180-
packages/spec/spec-changes.json merge=os-regen
181174
packages/spec/liveness/state-counts/** merge=os-regen
182175
packages/spec/authorable-surface/** merge=os-regen
183176
packages/spec/authorable-surface.base.json merge=os-regen

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,9 @@ examples/app-crm/storage/
136136
# The release lane's scratch space: the previously published tarball it unpacks
137137
# and the artifact it packs to verify the ADR-0087 D4 per-release section.
138138
.release-spec-changes/
139+
# The two ADR-0087 D4 projections `@objectstack/spec` ships: generated INTO the
140+
# package by that lane (`scripts/release-spec-changes.sh --prepare` / `--generate`)
141+
# right before the tarball is packed, and by `gen:spec-changes` locally. Never
142+
# committed (#22449 B′, #22485); `files[]` ships whatever the lane wrote.
143+
packages/spec/spec-changes.json
144+
packages/spec/protocol-upgrade-guide.md

‎content/docs/automation/approvals.mdx‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -383,9 +383,27 @@ a queue entry resolves to nobody. Route to a `team`, `department`, or
383383
Set Manager** — see [the `manager` callout above](#3-the-approval-node). `position`, `department`,
384384
`org_membership_level` and `expression` may additionally name which
385385
organization's directory resolves them — see
386-
[Approving across organizations](#cross-org-approvers). An entry that resolves
387-
to nobody is not an error: the request opens with an empty `pending_approvers`
388-
and nothing can move it, so the run parks forever.
386+
[Approving across organizations](#cross-org-approvers).
387+
388+
What an entry that resolves to nobody leaves on the slate depends on its kind:
389+
390+
- **A person entry** (`manager`, `field`) adds no slot. Nor does an
391+
`expression` that yields no ids. A `user` entry never resolves to nobody: its
392+
value is the slot.
393+
- **A group entry** (`position`, `team`, `department`, `org_membership_level`)
394+
keeps its `<type>:<value>` literal as a slot, and so do a `queue` entry and an
395+
`expression` value that `resolveAs` re-expands into a group with nobody in it.
396+
A `position:<name>` slot is decided by whoever is staffed into that position
397+
later. No person ever holds any other literal, so only an administrator can
398+
decide it (the admin-override callout under
399+
[Acting on requests in the console](#acting-on-requests-in-the-console)).
400+
Beside entries that do resolve, a literal is a slot like any other, so a
401+
`behavior` that needs every slot (`unanimous`) waits on it too.
402+
- **A slate left empty, or holding only literals,** goes to the node's
403+
`onEmptyApprovers` policy, whose four values are listed under
404+
[Approving across organizations](#cross-org-approvers). Under the default,
405+
`admin_rescue`, the request opens on that slate and waits for an
406+
administrator, or for a holder of a `position:` literal on it.
389407

390408
### The approver finds it in their queue
391409

@@ -420,8 +438,8 @@ curl -b cookies.txt \
420438
```
421439

422440
`approverId` accepts a user id, an email, or a `<type>:<value>` approver literal
423-
(`position:finance_manager` — the form an entry falls back to when it resolves
424-
to no users) — and takes several values (comma-separated or repeated) to cover
441+
(`position:finance_manager` — the form a group entry falls back to when it
442+
resolves to no users) — and takes several values (comma-separated or repeated) to cover
425443
a person's identities in one call. A position literal matches under
426444
`position:<name>`, the one spelling the service admits a holder of that
427445
position under as their own identity. The deprecated pre-rename prefix is no

‎content/docs/ui/forms.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ formViews: {
355355
| `kind` | Renderer behavior |
356356
|---|---|
357357
| `thank-you` | Replace the form with a confirmation panel (`title`, `message`). |
358-
| `redirect` | `window.location.assign(url)` after `delayMs` (defaults to 0). |
358+
| `redirect` | Navigate in-app to `url`, its `{{record.…}}` tokens substituted and URL-escaped, after `delayMs` (defaults to 0) — see below. |
359359
| `continue` | Re-read prefill values and reset state — user can submit another response without reload. |
360360
| `next-record` | Stub for queue contexts; falls back to thank-you when no queue is wired. |
361361

@@ -382,7 +382,7 @@ submitBehavior: { kind: 'redirect', url: '/u/{{os.user.id}}' } // not
382382

383383
Widening this — an allowlist of absolute origins, say — waits for measured demand; the ruling took the narrowest shape deliberately.
384384

385-
> **Current renderer status (2026-08-11).** The console shipping at the pinned `.objectui-sha` redirects **verbatim** on this value: it does not yet substitute `{{record.…}}` tokens or escape them. The three rules above are the contract, and the consumer half is tracked in [objectui#4190](https://github.com/objectstack-ai/objectui/issues/4190). Until it lands, a token would reach the browser literally — so write a plain path unless you are following that issue.
385+
The Console applies the three rules at submit time: it resolves each `{{record.…}}` token from the record the submit just wrote, URL-escapes the value, and navigates to the resolved path inside the console shell after `delayMs`; a `url` the schema refuses is never followed — the submit is still confirmed and the refusal is shown on screen.
386386

387387
### The default is mode-aware
388388

‎docs/adr/0043-actionable-approval-links.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0043: Actionable approval links — single-use tokens with a session-less confirm page
22

3-
**Status**: Accepted — implemented (proposed 2026-06-12 · calibrated 2026-06-12)
3+
**Status**: Accepted — implemented (proposed 2026-06-12 · calibrated 2026-06-12) · **Amended** (2026-10-10, #22631 — open-time notification is the approvals service's own `approval.requested` topic, not a flow-authored `notify` node; see the Issue bullet under Mechanics. One-tap links stay `remind()`-only; the token table and every decision below are unchanged)
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0042](./0042-approval-sla-escalation.md) (reserved system actors, audit-first discipline), thread interactions (#1740), [ADR-0012/0030](./0030-notification-platform-convergence.md) (messaging + outbox)
66
**Closes**: [#1743](https://github.com/objectstack-ai/objectstack/issues/1743)
@@ -37,9 +37,11 @@ GET-executes design gets requests approved by robots.
3737
- **Issue** (`issueActionTokens`): 256-bit random raw tokens, returned
3838
once, hashes stored. Wired into `remind()` — each pending approver with
3939
a concrete identity (not `role:*` literals) gets their **own**
40-
notification carrying their own approve/reject links. (Open-time
41-
notification remains the flow author's `notify` node; templates there
42-
can adopt the same links later.)
40+
notification carrying their own approve/reject links. (Amended
41+
2026-10-10, #22631: open-time notification is the approvals service's
42+
`approval.requested` topic, published by `openNodeRequest` to each
43+
concrete approver on the slate the request opens on — PR #22625; it
44+
carries no links, and one-tap links remain `remind()`-only.)
4345
- **Confirm page** (`GET /api/v1/approvals/act?token=…`): session-less
4446
minimal HTML rendered by the plugin on the host Hono app — request
4547
summary (flow label, record title, action) + a POST form. Invalid /

0 commit comments

Comments
 (0)