Skip to content

Commit f3b987b

Browse files
committed
merge: origin/main (#4995 responsive retirement) into #4956 widget drill
Conflict was `liveness/dashboard.json`'s `_note` only; both sides kept and reconciled. #4876/#4995 recorded that `widgets[].responsive` deliberately carries NO ledger row because one would be an ORPHAN — true only while `widgets` was undrilled. The retiredKey tombstone keeps the key in the walked shape, so with the drill landed the row is REQUIRED (omitting it reports UNCLASSIFIED); it is present with the dead verdict the #3896 sweep never got to record. check:liveness green on the merged tree is the arbiter. os-regen four-step done: generated artifacts taken wholesale from origin/main, rebuilt, check:generated clean on the merged source, and every #4995 sibling entry asserted present (authorable-surface [RETIRED], spec-changes, upgrade guide, conversions registry, tombstone, changeset). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
2 parents 09aee35 + c1e67e0 commit f3b987b

51 files changed

Lines changed: 2683 additions & 206 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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): 扩 ADR-0105 D6 ② 的收件人词表至 ADR 原文范围 —— `unit_and_subordinates`
6+
也判红
7+
8+
`org-axis-cross-org-bu-grant`(D6 ②)此前只对 `sharedWith.type ===
9+
'business_unit'` 判红,而授权面**更大**的另一个业务单元收件人
10+
`unit_and_subordinates`(一个 BU **加上其全部后代单元**,ADR-0057 D5 子树扩张)
11+
直接放行。两者的缺陷完全相同:平台级对象(`tenancy.enabled: false` /
12+
`systemFields.tenant: false`)没有 organization 列可供 Layer 0 收口,BU 子树没有
13+
任何 organization 可供解析,授权因而跨到库里每一个 organization —— 正是 ADR 拒绝
14+
的"跨 org BU 巨树",从后门到达。
15+
16+
漏掉的恰恰是 ADR-0105 D6 ② 自己点名的那一个:
17+
18+
> Every BU mechanism — `unit_and_subordinates` sharing, `adminScope`
19+
> delegation, depth scopes — operates within one organization. There is no
20+
> cross-org tree.
21+
22+
判定改为收件人类型 ∈ `{ business_unit, unit_and_subordinates }`,诊断信息里点名
23+
**实际写下的**类型并说明其触及范围(子树那一个额外写明 "AND every descendant
24+
unit"),修复建议改为指向三个扁平收件人。
25+
26+
词表与 spec 枚举 `ShareRecipientType` 的差集不再是隐式的:规则里以表格逐条写明
27+
拦截二者、放行 `user` / `team` / `position` 的理由(它们的运行时展开都不经
28+
`BusinessUnitGraphService`,是 `tenancy.enabled: false` 平台级目录**被设计用来**
29+
共享的方式),并附一条测试断言两半恰好划分 `ShareRecipientType` —— 将来枚举加成员
30+
会在词表处失败,而不是无声地落进没人选过的那一桶。#4991 正是这条断言缺席的产物。
31+
32+
这是 error 级门禁的扩张,因此复核了真实元数据:`examples/app-showcase` /
33+
`app-crm` 是仓库里仅有的已声明 sharing rule(共 11 条),全仓无任何对象关掉
34+
tenancy,扩张后 org-axis 红线数为 **0** —— 不产生新红。
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): the boot banner's `Tenancy:` row now reports the resolved posture, not the superseded boolean (#4801)
6+
7+
`printServerReady` printed `Tenancy: multi-tenant | single-tenant` from a boolean
8+
`multiTenant` that `serve` filled with `resolveMultiOrgEnabled()` — i.e. from
9+
`OS_MULTI_ORG_ENABLED`. [ADR-0105 D1] replaced that knob with
10+
`OS_TENANCY_POSTURE`, keeping the boolean only as the fallback
11+
`resolveTenancyPosture()` consults when the posture is unset, and **the runtime
12+
wiring in `serve` already keys off the posture**. So the banner and the server it
13+
describes read two different sources for one fact, and they drifted exactly where
14+
it hurts: booting with `OS_TENANCY_POSTURE=isolated` and `OS_MULTI_ORG_ENABLED`
15+
unset printed
16+
17+
```
18+
Tenancy: single-tenant
19+
Plugins: 40 loaded
20+
…, Organizations, …
21+
```
22+
23+
— the banner claiming single-org one line above the plugin table that proves the
24+
organization wall is up (observed on a real boot in cloud#1020, where the lie was
25+
only caught by hand-comparing the plugin list).
26+
27+
This is not cosmetic. It is the "declared ≠ enforced" class (ADR-0049) landing on
28+
the **diagnostic** surface, which is the worst place for it: a banner that can be
29+
wrong costs every later investigation an extra lap proving whether it is.
30+
31+
**What changes for users.** The row now prints the posture verbatim — `Tenancy:
32+
single`, `Tenancy: group`, `Tenancy: isolated` — sourced from the same
33+
`resolveTenancyPosture()` call the runtime wiring uses. The old `multi-tenant` /
34+
`single-tenant` vocabulary is gone. That vocabulary was itself part of the defect:
35+
tenancy has been a three-valued spectrum since ADR-0105, and a boolean has no
36+
spelling for `group` at all, so a `group` deployment could only ever be
37+
misreported.
38+
39+
**The internal `multiTenant` option is removed, not deprecated.** With the posture
40+
authoritative, a retained boolean could only ever be a field the printer ignores —
41+
and a field that exists but cannot be believed is precisely how this bug was
42+
authored in the first place. `ServerReadyOptions.tenancyPosture` is typed as
43+
`TenancyPosture`, so re-wiring the banner to the legacy boolean now fails to
44+
compile (`resolveMultiOrgEnabled()` returns `boolean`) instead of producing a
45+
plausible-looking wrong line. The interface is package-internal — `format.ts` is
46+
not re-exported from `@objectstack/cli`'s entry point — so no consumer code needs
47+
a change.
48+
49+
Regression-pinned in `packages/cli/src/utils/format.tenancy.test.ts`, which asserts
50+
the printed token **is** `resolveTenancyPosture()`'s answer across the cases that
51+
made the old code wrong: posture set with the boolean unset, posture unset with the
52+
boolean true, both set and contradicting (either direction), the legacy `multi`
53+
spelling, and `group`.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: retire `dashboard.widgets[].responsive` — the straggler of the #3896 inert-key sweep (#4876, ADR-0049)
6+
7+
`DashboardWidgetSchema.responsive` let an author declare per-breakpoint layout
8+
overrides on a dashboard widget — `breakpoint`, `hiddenOn`, `columns`, `order`
9+
and no renderer ever read them. The value parsed, validated, and then did
10+
nothing: `DashboardRenderer`, `DashboardEditor` and `plugin-designer` name
11+
`responsive` only in comments, and the one genuine per-breakpoint consumer in
12+
objectui (`useResponsiveConfig`) is fed by `page.components[].responsive`, never
13+
by a widget. Re-measured 2026-08-03 across both repos, plus zero authored
14+
instances anywhere in this repo's examples, apps and tests.
15+
16+
Four days earlier, #3896 retired the **literally same-named** `view.responsive`
17+
on exactly this evidence. This embed survived that sweep for a reason that is
18+
worth stating plainly, because it is not "we looked and it was live": the
19+
liveness ledger declares no `children` on `dashboard.widgets`, and the walk
20+
drills only one level through an explicit `children` — so **no widget-level key
21+
has ever been classified at all** (22 of them). The instrument had a hole, not
22+
the key a mandate. That gap is filed and fixed separately as **#4956**.
23+
24+
Leaving it would have shipped v17 with one word and two fates — `view.responsive`
25+
a `tsc` error, `dashboard.widgets[].responsive` silently accepted — which no
26+
author or authoring agent could be expected to explain, on a key that today
27+
accepts *any* content on both sides (objectui types it a documented `any`). That
28+
is precisely where AI-authored metadata errors hide and multiply.
29+
30+
FROM → TO:
31+
32+
| Removed | Replacement |
33+
| :--- | :--- |
34+
| `dashboard.widgets[].responsive` (key) | **none** — delete it; the grid reflows by `columns` + `gap` on the dashboard and the `layout` box on each widget |
35+
36+
**The shape is NOT removed — only this embed.** `ResponsiveConfigSchema` /
37+
`ResponsiveConfig` stay exported and stay live on `page.components[].responsive`,
38+
whose renderer genuinely reads them. Nothing that imports the shape breaks, and
39+
an author who needs breakpoint behaviour today has a real place to put it. This
40+
narrowness is deliberate: the maintainer's ruling covers the dashboard widget
41+
surface only.
42+
43+
The retirement kit:
44+
45+
- **Tombstone.** `retiredKey()` on the widget key. `DashboardWidgetSchema` *is*
46+
`.strict()`, so a plain delete would still be loud — but only as a generic
47+
"unrecognized key". The tombstone keeps the key declared so the rejection
48+
carries the **prescription**, and types the key `never` so authoring it fails
49+
`tsc` first. A pin asserts the message is the prescription and *not*
50+
`Unrecognized key`.
51+
- **ADR-0087 D2 conversion + D3 chain step**
52+
(`dashboard-widget-responsive-removed`, `retiredFromLoadPath`):
53+
`os migrate meta --from 16` deletes the key from author sources, and stored
54+
dashboards replay clean instead of meeting the tombstone at load. A lossless
55+
delete — the key never had an effect to lose. Kept as its own entry rather than
56+
folded into `dashboard-inert-keys-removed`, whose identity is the #3896 sweep:
57+
this removal rests on its own 2026-08-03 measurement and should say so in
58+
`spec-changes.json` and the upgrade guide.
59+
- **No liveness row is added**, matching `widgets[].performance` in the #3896
60+
sweep — a widget-level row would be an ORPHAN, not a classification, until
61+
#4956 lands the drill. The ledger `_note` records the removal and why the row
62+
is absent.
63+
- Baselines moved at KEY level only, as the shape's survival implies:
64+
`authorable-surface.json` gains `ui/DashboardWidget:responsive [RETIRED]`;
65+
`json-schema.manifest.json`, `api-surface.json` and
66+
`api-surface-signatures.json` are unchanged by construction — no def stopped
67+
being emitted and no export was removed.
68+
69+
No runtime behaviour changes — that impossibility is the reason for the removal.
70+
71+
**objectui shard:** the outcome is retirement, not the fallback clause, so
72+
objectui#3235's conditional pin-bump item is permanently de-listed; the `any`
73+
declaration on that repo's side can be cleaned on its own schedule.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
---
3+
4+
ci(dx): `packages/qa/dogfood` 的 strict tsconfig 现在真的被执行 —— 加上 `typecheck` script 并从 `scripts/check-type-check-coverage.mjs` 的 DEBT 账本毕业(#4855)。Dev scripts / CI only;releases nothing。
5+
6+
dogfood 有一份认真的 tsconfig(`strict``NodeNext``include: ["src/**/*", "test/**/*"]`),但 package.json 的 scripts 里只有 `test`。根 `pnpm typecheck``turbo run typecheck`,只跑声明了该 script 的包 —— 三个 qa 包里,这是唯一没声明的一个,所以这份配置**从未被执行过**#4311 的覆盖率闸门确实看见了这个洞,但把它记成 DEBT(`errors: 12`)冻结了下来,而 DEBT 是「暂缓」不是「豁免」:门后的错误只会继续涨。在最新 main 上实测是 **14** 条,比账本冻结时多了 2 条 —— 正是这种漂移说明冻结不能长期替代执行。
7+
8+
14 条全部修掉,分四类:
9+
10+
- **NodeNext 缺扩展名(2 条,TS2307)** —— `field-zoo-roundtrip.dogfood.test.ts``field-zoo-value-shape.test.ts``from './field-zoo.matrix'`。包内另外 33 处相对 import 全都带 `.js`,这两处是仅有的例外。vitest 能解析,tsc 不能。附带消掉 1 条级联的 TS7006(未解析的 import 让符号退化成 `any`,回调参数随即报 implicit-any)。
11+
- **flow fixture 的 `type` 没有收窄(8 条,TS2322)** —— 四个 fixture 的 flow 是裸对象字面量,`type: 'autolaunched'` 推成 `string`,喂给 `defineStack` 时对不上字面量联合。修法不是 `as const`,而是按 `examples/app-todo/src/flows/task.flow.ts` 的既有写法标注 `: Flow`(`import type { Flow } from '@objectstack/spec/automation'`)—— 这样整份 fixture 都被 spec 的真实契约检查,而不只是让报错闭嘴。
12+
- **条件展开出的 headers(2 条,TS2322)** —— `attachments-permission-matrix.dogfood.test.ts``token ? { Authorization } : {}` 在匿名分支上推出 `Authorization?: undefined`,展开进 `headers``HeadersInit`(`Record< string, string >`)拒收。给该常量标注 `Record< string, string >`
13+
- **连接器 handler 少传一个参数(1 条,TS2554)** —— `showcase-mcp-self-connection.dogfood.test.ts``handler!({})`,而 `McpConnectorBundle.handlers` 声明的是 `(input, ctx)`,引擎侧 `connector-nodes.ts` 也始终按两参数派发。这条一直「能跑」,只是因为 MCP 这个 handler 恰好忽略 `ctx` —— 契约上它是错的。改为按声明传两参数,与 connector-mcp 自己的测试一致;**没有放松任何契约**(把 `ctx` 改成可选才是错误方向)。
14+
15+
修完接进执行:package.json 加 `"typecheck": "tsc --noEmit"`,并按闸门的 RECONCILED 不变式在同一个 PR 里删掉 DEBT 条目(graduated)。覆盖率从 60/77 走到 61/77,DEBT 从 17 个包降到 16 个。反向验证:在 fixture 里塞一个非法的 flow `type`,`turbo run typecheck` 判红并精确指出该行;移回后判绿(命中修复前那一次绿跑的同一个 turbo hash)。`pnpm --filter @objectstack/dogfood test` 481 passed / 3 skipped,行为未变。
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/cli": minor
4+
"@objectstack/verify": minor
5+
---
6+
7+
feat(types,cli,verify)!: 只解析 host app 声明过的包 —— `NODE_PATH` 不再算数,ADR-0093 D5 那道墙从此与启动方式无关 (#4719)
8+
9+
**问题:契约写下了,但从没被检查过。** `@objectstack/types/node`
10+
`createHostRequire` 返回一个 CJS `createRequire`,而 CJS 解析认 `NODE_PATH`
11+
(`Module.globalPaths`)。pnpm 生成的 bin shim 第一件事就是
12+
`export NODE_PATH=<workspace>/node_modules/.pnpm/node_modules`,于是任何被工作区里
13+
**任意一个包**传递依赖到的包都能"从 host app 解析成功" —— 跟这个 app 声明了什么毫无关系。
14+
15+
实测(cloud `apps/objectos-ee`,当时未声明 `@objectstack/organizations`):
16+
`pnpm start`(经 shim)boot 成功、插件表里有 `Organizations`、ADR-0093 D5 一声不吭;
17+
`node node_modules/@objectstack/cli/bin/run.js serve`(不经 shim)则
18+
`✖ FATAL: tenancy posture 'isolated' was requested…` 并 exit 1。同一个 app、同一份
19+
`package.json`、同一个 posture,**只因为进程是怎么被拉起来的**,走出两种结果。
20+
而 D5 的报错一直在教 operator "declare it in the app's package.json" —— 那正是
21+
CLI 从来没检查过的那件事。
22+
23+
**改法:声明即执行。** 解析前先读 `<hostRoot>/package.json`;只有包名出现在
24+
`dependencies` / `devDependencies` / `optionalDependencies` / `peerDependencies`
25+
****里,才去 host 的 `node_modules` 里查它。仅仅"能被解析到"不再算数 ——
26+
那正是让契约失效的那个偶然。未声明的包退回到 importing package 自身的解析
27+
(ESM,不认 `NODE_PATH`),框架自有的包加载路径不受影响。
28+
29+
**两种失败从此分开报。** 今天它们都塌成同一条 `MODULE_NOT_FOUND`,补救办法却相反:
30+
31+
- **未声明** —— 指向"在 app 的 `package.json` 里声明并安装",并说明为什么
32+
hoisting / `NODE_PATH` 不被接受;
33+
- **声明了但解析不到** —— 明确说这是**安装**问题(`pnpm install`、生产 prune
34+
砍掉了它、dist 没构建),别再让人回去重看那份已经写对的 `package.json`
35+
36+
分类经新导出的 `hostImportFailureKind(err)` 暴露给调用方;两种错误都仍带
37+
`code: 'MODULE_NOT_FOUND'`,`isModuleNotFoundError` 的既有判定不变。
38+
39+
**BREAKING — 哪类部署会从假绿变红,以及怎么修。**
40+
41+
1. **靠 hoisting 苟着的部署。** 一个 app 请求了 walled tenancy posture
42+
(`OS_TENANCY_POSTURE=group` / `isolated``OS_MULTI_ORG_ENABLED=1`)、
43+
却没在自己的 `package.json` 里声明 `@objectstack/organizations`,过去经 pnpm
44+
shim 启动能正常 boot —— 现在会命中 ADR-0093 D5 并 exit 1。
45+
**修法:在那个 app 的 `package.json` 里声明该依赖并安装。**
46+
这些部署本来就在未声明状态下运行,红的是一直存在的事实,不是新引入的故障:
47+
同一个 app 不经 shim 启动今天就已经是 exit 1。
48+
(同样适用于 `@objectstack/service-ai` / `@objectstack/service-ai-studio`,以及
49+
`bootStack({ multiTenant: true })`、dogfood 的 enterprise 门。)
50+
51+
2. **`createHostImporter` 的签名变了**,因为它现在需要 host 的**根目录**才能读到
52+
那份 manifest,而一个 `NodeRequire` 无法被问出它锚在哪里:
53+
54+
```diff
55+
- createHostImporter(createHostRequire(hostRoot))
56+
+ createHostImporter(hostRoot) // 省略参数 = process.cwd(),同旧默认
57+
```
58+
59+
`createHostRequire` 本身保持不变,仍然导出。
60+
61+
新增导出(`@objectstack/types/node`):`HOST_DECLARATION_FIELDS`
62+
`HostDeclarationField``HostDeclaration``readHostDeclaration`
63+
`isDeclaredByHost``packageNameFromSpecifier``HostImportFailureKind`
64+
`HOST_IMPORT_FAILURE_KIND``hostImportFailureKind`
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): resolve the `NOW()` defaultValue token in the engine, so it works on every datasource (#4597)
6+
7+
`Field.datetime({ defaultValue: 'NOW()' })` only ever worked on SQL. The engine's
8+
`applyFieldDefaults` special-cased exactly two `defaultValue` shapes — the
9+
Expression envelope and the `current_user` token — and passed everything else
10+
through verbatim, so the four characters `NOW()` were written into the record as
11+
a **literal string**. The SQL driver hid that: `SqlDriver.formatInput` carries an
12+
insert-time safety net that swaps any `NOW()` string for a real ISO timestamp
13+
before it hits the wire. Memory and MongoDB have no such net.
14+
15+
This is the mirror image of #4560. There, `current_user` was known to the engine
16+
and not to the DDL, so the DDL stored the token text. Here, `NOW()` was known to
17+
the SQL driver and not to the engine — same crack, opposite side. It surfaced two
18+
ways:
19+
20+
- On a **validated** field the insert was **rejected outright**, by the engine's
21+
own write validator, against a value the engine itself had just filled in:
22+
`ValidationError: … must be a valid datetime (ISO-8601)`. Every insert omitting
23+
such a field failed, with an error naming a field the caller never sent.
24+
- On a `readonly` / `system` field — which `validateRecord` skips, i.e. the ~100
25+
`created_at` / `updated_at` declarations across the platform objects — nothing
26+
was rejected at all and the string `NOW()` was **stored**.
27+
28+
`applyFieldDefaults` now resolves the token itself, from the same per-insert
29+
`now` snapshot it already passes to Expression defaults, so every field defaulted
30+
in one insert (and every row of one batch) carries the identical instant. The
31+
spelling it matches is the spec's (`isNowDefaultToken` from
32+
`@objectstack/spec/data`, case-insensitive and whitespace tolerant), the same
33+
predicate a driver's DDL consults — the engine does not re-derive its own.
34+
35+
The token resolves into the shape the field's **declared type** stores, which is
36+
what `SqlDriver.nowColumnDefault` already emits per type (ADR-0053), so no
37+
datasource disagrees about the stored form:
38+
39+
| field type | stored value |
40+
|---|---|
41+
| `date` | `YYYY-MM-DD` (UTC calendar day) |
42+
| `time` | `HH:MM:SS[.fff]` (UTC wall clock; a zero `.000` is trimmed) |
43+
| `datetime`, and any non-temporal field that opts in | `YYYY-MM-DDTHH:MM:SS.sssZ` |
44+
45+
No authoring change: `defaultValue: 'NOW()'` is the same declaration it always
46+
was, and a caller-supplied value is still never overwritten. What changes is that
47+
it now means the same thing on memory and MongoDB as it always did on SQL.
48+
Records written on a non-SQL datasource before this fix may hold the literal
49+
string `NOW()` in those columns; they are not rewritten.
50+
51+
Both driver-side mechanisms stay, unchanged, as defence in depth: `formatInput`'s
52+
safety net (now unreachable from the engine's insert path) and the native column
53+
DEFAULT, which still serves writes that bypass the engine entirely — the same
54+
division of labour `current_user` has.

0 commit comments

Comments
 (0)