Skip to content

Commit 03bc8c3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4391-crypto-hash-retire
# Conflicts: # docs/protocol-upgrade-guide.md # packages/spec/spec-changes.json
2 parents e6b3125 + 61cc079 commit 03bc8c3

23 files changed

Lines changed: 1189 additions & 115 deletions

.changeset/book-job-translation-app-authorwarn-keys-retired.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,14 @@ Run `os migrate meta --from 16` to rewrite existing sources automatically.
3939
migration table steered retired `errors:` authors straight into it. **That
4040
guidance entry is rewritten here**: retiring one dead key by pointing at
4141
another is the defect, not the fix.
42-
- **`app.homePageId`***its own hedge*. "If not set, usually defaults to the
43-
first navigation item" described the only behaviour there was.
42+
- **`app.homePageId`***a second source for one fact*. Not unread: objectui's
43+
console consumed it in `resolveLandingRoute()` and it was the only thing
44+
deciding where an app opened. (This entry first shipped saying otherwise;
45+
corrected in #4709, which upheld the removal.) What condemns the key is its
46+
shape — an ID cross-reference into `navigation` with no referential integrity,
47+
falling back to the first item *silently* when the id dangled. If "land
48+
somewhere other than first" is ever wanted again it belongs on the navigation
49+
item itself, not on a pointer that can miss.
4450
- **`app.areas[].order`***the sibling that works*. Nav-item `order` really is
4551
sorted; area-level order never was, and both renderers iterate the array as
4652
authored.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): `app.homePageId` 的墓碑说清真正的退役理由 —— 「no shell ever read it」是假的 (#4709)
6+
7+
**改的是「为什么删」的表述,不是删本身。** `app.homePageId` 在 17.0.0 依旧退役
8+
(`retiredKey`:编译期 `never`、解析期报错),conversion `app-dead-authoring-keys-removed`
9+
的行为、baseline、`os migrate meta --from 16` 的处方一字未动。
10+
11+
#4667 给出的理由是「no shell ever read it」。这句是**假的**,而且与本仓自己的记录直接
12+
矛盾 —— 2026-06 的 AppSchema liveness 审计
13+
(`docs/audits/2026-06-appschema-property-liveness.md`)把 `homePageId` 明确列在 LIVE
14+
一侧,因为 objectui console 的 `resolveLandingRoute()`
15+
(`packages/app-shell/src/console/AppContent.tsx`,objectui @785b8a5d)一直在读它,而且
16+
它是**唯一**决定「app 打开时落在哪」的地方。两份文档矛盾了两个月无人发现,直到有人做
17+
cloud pin 对账时先信了这句、再去核渲染器才发现不对(#4709)。
18+
19+
真正让这个键该走的是它的**形状**,不是无人使用:它把落地页编码成指向 `navigation`
20+
ID 交叉引用,没有引用完整性 —— id 悬空时**静默**回退到第一项(objectui 的实现正是如此),
21+
于是同一件事有两个来源,而错的那个不出声。将来若要「落地页 ≠ 第一项」,正确形状是导航项
22+
自身的标记(`navigation[].landing`:单一来源、不可能悬空),并按 enforce-first 设计
23+
(先有渲染器与测试,再进 schema)。
24+
25+
墓碑文案改为诚实版本后,作者看到的处方**保持不变**:删掉这个键;要改 app 从哪里打开就
26+
重排 `navigation` 让目标项排第一;根落地由 `isDefault` 决定。同步纠正:conversion 摘要
27+
(经 `gen:upgrade-guide` / `gen:spec-changes` 重生成到 `docs/protocol-upgrade-guide.md`
28+
`spec-changes.json`)、生成文档 `content/docs/references/ui/app.mdx`
29+
`content/docs/ui/apps.mdx`、liveness ledger 的 `homePageId` note、`examples/app-showcase`
30+
里那句「has no console consumer yet」,并给 6 月审计补了一条指向 #4667/#4709 的后续注记
31+
(审计结论本身是对的,原文不动)。新增一条 pin 测试,防止「无人读过」这类假前提回潮。
32+
33+
objectui 侧那段永远进不去的 `if (homePageId)` 死分支单独清理:
34+
`objectstack-ai/objectui#3264`
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
"@objectstack/objectql": major
3+
---
4+
5+
feat(objectql)!: a hook `condition` the platform cannot evaluate now ABORTS the operation (#4775)
6+
7+
**Breaking.** A declarative hook whose `condition` cannot be evaluated used to
8+
emit a `logger.warn` and `return false` — the hook simply did not fire. Existing
9+
hooks that have been getting by on that silent skip will now **fail the write**.
10+
That is the point of the change, not a side effect: those conditions were never
11+
enforcing anything, and the failure is how you find out.
12+
13+
## What changed
14+
15+
"The condition said no" and "the platform could not work out what the condition
16+
says" used to collapse into one outcome, and that one outcome carries **opposite**
17+
risks depending on the hook:
18+
19+
- a `before*` guard ("hold this write when the condition is met") swallowed into
20+
`false` **lets through** a write it was declared to stop;
21+
- an `after*` audit ("leave a trace when the condition is met") swallowed into
22+
`false` **drops** a row nobody will go looking for, because nobody knows it
23+
should exist.
24+
25+
So an unevaluable condition is `declared ≠ enforced`, and it is now resolved the
26+
way #4649 already resolved it for validation predicates one module over: reject
27+
loudly, naming the hook and the key that would not resolve. The rejection is a
28+
`HookConditionError` (exported), carrying `hook` / `object` / `event` /
29+
`condition` / `reason` / `fault` / `missingKey` machine-readably.
30+
31+
`before*` and `after*` take the **same** direction, knowingly: a typo in an
32+
`afterUpdate` audit condition fails the write it was only watching. One rule, one
33+
answer — the platform does not grow a hidden second rule that makes the failure
34+
direction depend on the event name.
35+
36+
A condition that never **compiled** aborts too. Its old treatment
37+
(`condition ignored`) was the worse half of the swallow: the gate disappeared
38+
entirely, so a declared guard let every write through and an audit fired on all
39+
of them. It is reported at invocation rather than at bind time, so one broken
40+
hook cannot wedge boot for an app nobody is writing to.
41+
42+
## What did NOT change
43+
44+
- A condition that evaluates **FALSE** is still just a skip, and the write still
45+
succeeds. Only *unevaluable* is new.
46+
- `onError` (`abort` / `log`) is untouched and is deliberately **not** in this
47+
path. It governs a handler that threw; the condition gate runs before the
48+
handler is ever reached. Routing a condition fault through it would let
49+
`onError: 'log'` resurrect the exact silent skip this change abolishes, and
50+
would mint a third set of semantics for one word. `retryPolicy` and `async`
51+
are outside it for the same reason.
52+
53+
## Predicate (`multi: true`) bulk writes (#4800)
54+
55+
A bulk write matches N rows and fires the hook **once**, so `previous` is unbound
56+
and `record` is the bare payload — there is no single prior record, and
57+
materialising declared fields to `null` would state something false about all N.
58+
Fail loud takes **no exception** here, but the message is a diagnosis rather than
59+
a riddle: it names the hook, says *this is a predicate bulk write and there is no
60+
single prior record*, and gives the route that works (rewrite without `previous`,
61+
or target the write at one record by id).
62+
63+
It deliberately does **not** offer "use a record-change flow trigger instead":
64+
that trigger subscribes to these same lifecycle hooks, so on a bulk write it
65+
fires once with `previous` undefined too — verified against
66+
`trigger-record-change` and the engine, not assumed. Pointing at it would have
67+
made this very message the next `declared ≠ delivered`.
68+
69+
An **undeclared** key on a bulk write still gets the ordinary typo message — that
70+
one really is a misspelling, and calling it a batch problem would send the author
71+
to fix a field that is spelled correctly.
72+
73+
## Migrating
74+
75+
Run your app and watch for `HookConditionError`. Each one names the hook and the
76+
key. The usual causes, in order of frequency:
77+
78+
- **a misspelled or retired field** — fix the condition, or declare the field;
79+
- **an unguarded `null` comparison** (`record.spent > record.budget`) — guard
80+
with `!= null`. Note `has(x)` does **not** do this: a declared field holding
81+
`null` is still PRESENT, so `has(x)` is `true` and the ordering comparison
82+
still faults;
83+
- **`previous` on a bulk write** — rewrite without `previous`, or write by id;
84+
- **a bare identifier** (`done == true`) — hook conditions are `record`-scoped,
85+
so write `record.done == true`. Flow/automation conditions, which flatten
86+
fields to top level, are a different surface and are unaffected.

content/docs/references/ui/app.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const result = ActionNavItemSchema.parse(data);
8383
| **navigation** | `{ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[]` | optional | Full navigation tree for the app sidebar |
8484
| **areas** | `{ id: string; label: string; icon?: string; description?: string; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
8585
| **contextSelectors** | `{ id: string; label: string; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{<id>}` template vars |
86-
| **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no shell ever read it. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
86+
| **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
8787
| **requiredPermissions** | `string[]` | optional | Permissions required to access this app |
8888
| **objects** | `any` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. |
8989
| **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Declarative endpoints belong to the stack (`defineStack({ apis })`), not the app shell. Delete the key. |

content/docs/ui/apps.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,9 @@ const projectApp = {
284284

285285
requiredPermissions: ['pm_access'],
286286
// No `homePageId`: the landing page IS the first navigation item (by `order`),
287-
// and the ROOT landing follows `isDefault`. The key was removed in 17.0.0 (#4667)
288-
// because nothing ever read it.
287+
// and the ROOT landing follows `isDefault`. The key was removed in 17.0.0
288+
// (#4667, #4709) — it did have a consumer, but it pointed at a navigation item
289+
// by id and fell back silently when that id dangled.
289290
};
290291
```
291292

docs/audits/2026-06-appschema-property-liveness.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,17 @@ These are the highest-priority fixes (authoring them per spec fails; they only w
1818
`name`, `label`, `description`, `icon`, `active`, `isDefault`, `hidden`, `navigation` (whole tree), `areas` (precedence over navigation), `contextSelectors` (all sub-fields), `homePageId`, `defaultAgent` (dual consumer: framework `agent-runtime.ts:341` + objectui chatbot), `branding.{primaryColor,favicon}`, `protection`. Nav-item union fully live: `id/label/icon/order/badge/visible(CEL)/requiredPermissions/requiresObject/requiresService` + per-type payloads (`objectName/viewName/recordId/recordMode/dashboardName/pageName/url/target/reportName/componentRef/params/children/expanded`). NavigationContribution (ADR-0029) live via `objectql/engine.ts:912`.
1919
- PARTIAL: `App.requiredPermissions` (app-entry gate not observed; only nav-item perms enforced), nav `type:'action'` `actionDef` (fires `onAction(item)`; `actionDef.{actionName,params}` shape read loosely in the action runtime, 0 direct grep in shell).
2020

21+
> **Later (2026-08-03, #4709) — `homePageId` is retired; this row's LIVE verdict was right.**
22+
> #4667 removed `app.homePageId` in spec 17.0.0 justifying it with "no shell ever read it" — which
23+
> **contradicted this audit** and was false: objectui's console read it in `resolveLandingRoute()`
24+
> (`packages/app-shell/src/console/AppContent.tsx`, @785b8a5d), exactly as recorded above. #4709
25+
> **upheld the removal on a different reason** and corrected the copy everywhere it appeared: the key
26+
> encoded the landing page as an ID cross-reference into `navigation` with no referential integrity,
27+
> silently falling back to the first item when it dangled. Post-v17 the landing page is the first
28+
> navigation item; if the capability returns it belongs on the navigation item itself
29+
> (`navigation[].landing`), enforce-first. Nothing above is amended — it is kept as written, because
30+
> the failure it exposes is procedural: **a retirement citing liveness must reconcile against the
31+
> existing audit record**, and for two months nobody noticed these two documents disagreed.
32+
2133
## Recommendation
2234
Add the 3 drift fields to the spec (`accentColor`, `badgeVariant`, `separator`) **or** stop the renderer reading them. Prune the aspirational block; `App.sharing`/`App.embed`/`apiEnabled`-style props create a false security/feature impression.

docs/protocol-upgrade-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ Finally it removes the script-body capability token 'crypto.hash' (#4391). Four
201201
| `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape |
202202
| `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only |
203203
| `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only |
204-
| `app-dead-authoring-keys-removed` | `app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order` | app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas) | retired — `migrate meta` only |
204+
| `app-dead-authoring-keys-removed` | `app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order` | app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667unread or wrongly encoded; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled — the landing page is the first nav item (premise corrected in #4709; the retirement stands), and no renderer ever sorted areas) | retired — `migrate meta` only |
205205
| `app-area-fail-open-gates-removed` | `app.areas.visible / app.areas.requiredPermissions` | navigation-area keys 'visible'/'requiredPermissions' removed (#4651, ADR-0049 — FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user, while the identically named keys on a navigation ITEM and on the APP are enforced; gate the items inside the area, or gate the app) | retired — `migrate meta` only |
206206
| `field-required-notnull-explicit` | `object.fields.*.required / object.fields.*.storage.notNull` | required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract) | retired — `migrate meta` only |
207207
| `action-inert-keys-removed` | `action.shortcut / action.bulkEnabled` | action keys 'shortcut'/'bulkEnabled' removed (#3896 close-out — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | retired — `migrate meta` only |

examples/app-showcase/src/ui/apps/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export const ShowcaseApp = App.create({
2525
branding: { primaryColor: '#7C3AED' },
2626

2727
navigation: [
28-
// First item = the app's landing surface (the delivered convention; the
29-
// spec's homePageId has no console consumer yet).
28+
// First item = the app's landing surface the only way to say it since
29+
// `homePageId` was removed in spec 17.0.0 (#4667, #4709).
3030
{ id: 'nav_capability_map', type: 'page', pageName: 'showcase_capability_map', label: 'Capability Map', icon: 'map' },
3131
{ id: 'nav_start_here', type: 'page', pageName: 'showcase_start_here', label: 'Page Authoring', icon: 'compass' },
3232
{

0 commit comments

Comments
 (0)