Skip to content

Commit 047457c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-9232-flat-door-declared-code
2 parents eb70f7f + e374b4d commit 047457c

64 files changed

Lines changed: 4069 additions & 662 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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/runtime": minor
5+
"@objectstack/cli": patch
6+
---
7+
8+
fix(metadata-protocol): arm the three `kernel:ready` platform-table migrations on a self-hosted boot, and keep the read-only CLI commands read-only (#9380)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) One new optional
11+
declaration (`runPlatformMigrations`) added to three existing option bags and
12+
one Zod boot config. Nothing authorable is renamed, retired or tombstoned, so
13+
there is no conversion to register. The behavioural change is that three
14+
migrations which never ran on a self-hosted install now run on its serving
15+
boot. -->
16+
17+
`assembleMetadataProtocol` arms three `kernel:ready` migrations — #5839's
18+
`sys_view_definition` active-row index, #8629's `sys_setting` row-identity
19+
index, and #8686's seed/API tenancy backfill — behind one gate whose own comment
20+
states the intent: *"platform / standalone kernels own their local sys_metadata;
21+
per-project (cloud) kernels source metadata from the control plane and must NOT
22+
provision these tables locally."* So standalone was always meant to be on the
23+
INSIDE of that gate.
24+
25+
It never was. The gate **deduced** ownership from `environmentId === undefined`,
26+
and `runtime/src/standalone-stack.ts` stamps `'proj_local'` on every boot — so
27+
the block never ran on a self-hosted install at all. #8686's own header calls
28+
its `kernel:ready` half the one that "repairs an install that is ALREADY in that
29+
state, which covers every existing deployment"; on self-hosted it covered none,
30+
and those installs kept minting duplicate business identifiers.
31+
32+
**The fix is a declaration, not a wider deduction.** `environmentId` is a
33+
row-scoping key, not a topology signal — the same lesson `authoringChannel`
34+
already records one field above it in the same options bag. A new optional
35+
`runPlatformMigrations` is threaded from the host that knows the answer down to
36+
the one assembly both protocol mounts share:
37+
38+
- `AssembleMetadataProtocolOptions` / `MetadataProtocolPluginOptions` /
39+
`ObjectQLPluginOptions` gain `runPlatformMigrations?: boolean`;
40+
- `createStandaloneStack` gains the same key and **defaults it to `true`** — a
41+
standalone kernel owns its local platform tables, whatever environment id it
42+
stamps rows with;
43+
- the predicate is exported as `shouldRunPlatformMigrations(environmentId,
44+
declared)` so the default lives in exactly one place.
45+
46+
**Undeclared means unchanged.** The default is `environmentId === undefined`,
47+
the historical deduction, so every caller that does not declare — including
48+
cloud's per-project kernels (`createMetadataProtocolPlugin({ environmentId })`)
49+
and the control-plane assembly (`createMetadataProtocolPlugin()`) — keeps
50+
today's behaviour exactly.
51+
52+
**The read-only contract is preserved, and not by keying on deferral.** The
53+
CLI's one-shot boot funnel (`bootSchemaStack`) declares
54+
`runPlatformMigrations: false` for every `os migrate *` / `os meta *` command.
55+
Keying it on `deferSchemaDdl` would have covered only `os migrate plan` and
56+
`os migrate duplicates`; `os migrate summary-nulls`, `value-shapes`,
57+
`recorded-by`, `resume`, `files-to-references` and `os migrate meta` all boot
58+
**non-deferred** and are still dry-run-by-default ("a dry run writes NOTHING"),
59+
so that half would have quietly repaired rows behind a report. The serving boots
60+
`os dev`, `os serve`, `os start` — do not come through that funnel and take
61+
the default, which is where an install now gets repaired.
62+
63+
Proven on real kernels over a real SQLite file carrying the real #8686 damage,
64+
not on the predicate: the serving boot merges the split counter and adopts the
65+
movable seed row while leaving the colliding one reported-not-renumbered; the
66+
deferred and non-deferred one-shot boots both leave the data untouched; and a
67+
per-project kernel assembled cloud's way still repairs nothing.
68+
`os migrate duplicates`' own byte-identical-after-run pin
69+
(`duplicates.integration.test.ts`) still passes unchanged.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/runtime': minor
5+
'@objectstack/client': minor
6+
---
7+
8+
**BREAKING** — the automation `trigger` routes now answer **409** for a disabled
9+
flow and **422** for a flow whose definition has no start node, instead of HTTP
10+
200 wrapping an inner `{success: false}`.
11+
12+
This finishes the migration the previous release started. That changeset flipped
13+
two of the four outcomes and said of the other two:
14+
15+
> **Also unchanged, pending a ruling:** a DISABLED flow and one with no start
16+
> node still answer 200 with the inner failure. Both are exits that never
17+
> dispatched anything, and telling them apart needs a producer-side
18+
> classification the closed `AutomationResult.code` union cannot yet express.
19+
20+
That is the paragraph this change resolves. The union was widened deliberately —
21+
two new members, with measured need — rather than the transport guessing from
22+
message text or re-implementing the engine's enable-state policy.
23+
24+
`POST /api/v1/automation/:name/trigger` and the legacy
25+
`POST /api/v1/automation/trigger/:name` now answer, in full:
26+
27+
| Status | `error.code` | The run |
28+
|:---|:---|:---|
29+
| `404` || never dispatched: no such flow |
30+
| `409` | `FLOW_DISABLED` | never dispatched: the flow is switched off |
31+
| `422` | `FLOW_NO_START_NODE` | never dispatched: the definition has no `start` node |
32+
| `400` | `FLOW_FAILED` | RAN, and was rejected |
33+
| `200` || succeeded, or PAUSED at a screen node — a pause is not a failure |
34+
35+
The three refusals report no run because none exists: no node executed and
36+
nothing was written. Only `400` describes a run, and only it carries
37+
`error.details.summary` / `error.details.errorMessage`.
38+
39+
Why two statuses and not one: a disabled flow is reversible operational state —
40+
enable it and the identical request succeeds, which is what `409` means. A flow
41+
with no start node cannot be executed as stored, and no retry helps, which is
42+
what `422` means. Collapsing them would tell an operator to flip a switch that
43+
will not help.
44+
45+
**`@objectstack/spec`:** `AutomationResult.code` gains `'FLOW_DISABLED'` and
46+
`'FLOW_NO_START_NODE'`. The union stays closed; these are trigger-time refusals
47+
classified *before* dispatch, documented as a group distinct from the existing
48+
resume-refusal members. Both are registered in the ADR-0112 error-code ledger.
49+
50+
**`@objectstack/service-automation`:** `execute()` stamps the matching `code` on
51+
its disabled-flow and no-start-node exits. They continue to carry **no**
52+
`status` — that absence is what lets a transport tell a never-dispatched exit
53+
from a run that dispatched and failed (`status: 'failed'`) without inspecting
54+
`summary`, `durationMs` or the message.
55+
56+
**`@objectstack/client`:** `client.automation.trigger()`, `.execute()` and
57+
`client.project(id).automation.execute()` already rejected on a failed run;
58+
they now reject with these two additional classifications, so a caller can tell
59+
"enable the flow and retry" from "the flow definition is broken":
60+
61+
```ts
62+
try {
63+
await client.automation.execute(flow, { params });
64+
} catch (err: any) {
65+
err.httpStatus; // 409 | 422 | 400 | 404
66+
err.code; // 'FLOW_DISABLED' | 'FLOW_NO_START_NODE' | 'FLOW_FAILED'
67+
}
68+
```
69+
70+
Callers that branch only on `FLOW_FAILED` keep working for the case they
71+
handle, but will no longer see these two refusals under it — they arrive with
72+
their own codes, which is the point.
73+
74+
Not affected, and deliberately so: `POST /api/v1/actions/...` with a
75+
`type: 'flow'` action, and metadata-declared `type: 'flow'` endpoints. Both
76+
dispatch the same flow through a different door with its own response
77+
conventions, and whether they should inherit this table is tracked separately.
78+
79+
<!-- adr-0087: not-required (no-migration-prescription) retires no metadata surface: no Zod schema, no authorable key, and no stored sys_metadata row changes shape, so `objectstack migrate meta` has nothing to rewrite and no ledger entry could be written for it. What changes is an HTTP status plus two new members of a runtime result type, and the channel that reaches those consumers is this changeset plus the compiler. -->
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `STORED_TYPE_NOT_CANONICAL` ledger comment names both producers and their atomicity (#9361)
6+
7+
The ledger entry at `error-code-ledger.zod.ts:381` described `STORED_TYPE_NOT_CANONICAL`'s
8+
only producer as the publish pre-flight ("refused at the publish pre-flight, batch-atomic").
9+
PR #9360 (#9174) added a second producer — `revertCommit`'s restore limb, which refuses
10+
per-item on its existing `failed[]` channel and is explicitly NOT batch-atomic — leaving the
11+
comment naming one of two producers, with the wrong atomicity for the one it omitted.
12+
13+
The comment now names both: the publish pre-flight (batch-atomic, `#8908`) and
14+
`revertCommit`'s restore limb (per-item on `failed[]`, NOT batch-atomic, `#9174`).
15+
16+
Text-only change — accept/reject behavior, the error code, and its envelope are all
17+
unchanged.

.changeset/nine-camels-behave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`FieldSchema` docs now pin the ruled multi-value lookup empty representation (#9447, maintainer ruling 2026-08-18): an emptied multi-value lookup reads back as `[]`, never `null` — binding for every writer (cascade repair, form clears, API writes) — and `required` on a multi-value lookup means non-empty array, so an emptied required set fails validation loudly.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(metadata-protocol): compile the seed-tenancy backfill's statements for the connected dialect, so they run on MySQL (#9381)
7+
8+
`seed-tenancy-backfill.ts` quoted every identifier the ANSI way (`"x"`) on every
9+
dialect. MySQL does not run with `ANSI_QUOTES` — measured on a live MySQL 8.0.46,
10+
whose `sql_mode` is
11+
`ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`,
12+
and nothing in `driver-sql` sets one — so `"x"` is a string literal there and all
13+
seven statements failed with `ER_PARSE_ERROR`. The repair for #8686 therefore
14+
never ran on MySQL, silently: a migration must not fail a boot, so every call site
15+
turns the failure into a warning and the symptom was a skipped repair in the log
16+
rather than an error.
17+
18+
The statements are now compiled for the driver actually connected, and the seam
19+
carries the dialect with it (`resolveSeedTenancySeam` returns `{ exec, client }`;
20+
`backfillSeedTenancy` takes that pair) so a caller cannot lose it. Two further
21+
MySQL-only defects in the same statements, both measured on the same server, are
22+
fixed with it: `last_value` is a reserved word on MySQL 8.0 and is now quoted
23+
wherever it is unqualified, and the stamp's exclusion sub-SELECTs go through a
24+
derived table because MySQL refuses `UPDATE t … (SELECT … FROM t)` with
25+
`ER_UPDATE_TABLE_USED`. SQLite and PostgreSQL keep the exact ANSI spelling they
26+
had (both re-verified live).
27+
28+
`resolveSeedTenancyExec` stays exported and unchanged for callers that resolve the
29+
dialect themselves; `backfillSeedTenancy` now takes the seam object instead of a
30+
bare exec.

.claude/agents/os-dev.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,12 @@ JSON,所以终报消息就是 JSON 本身,别无其它。
123123
`check:engine-double-contract`;新错误码 ⇒ `check:error-code-casing`;
124124
`.claude/agents/**``check:agent-model-declared`;任何编辑 ⇒ `check:nul-bytes`);④ 派
125125
发词的门禁清单是**线索不是规格** —— 哪怕当天仔细取的清单也会漏族,点名的跑绿之后,对你
126-
**实际**改动的路径重新推导(`node scripts/pm/dispatch-gates.mjs <changed paths>`),补跑
127-
它新增而你的 diff 确实触及的,并在报告里点名新增项。代价是偶尔一轮 push-fix;安全的另一
126+
**实际**改动的路径重新推导 —— **`node scripts/pm/dispatch-gates.mjs` 不传路径**,脚本自
127+
己从 merge-base 取变更集(含未提交与未跟踪);⛔ 别自己 `git diff` 出一份清单喂它:两点
128+
`origin/main..HEAD`**此刻**的 origin/main 求值,分支切出后落地的姊妹 PR 文件会算到你
129+
头上(实测一次三个),而它**退出码 0**、门禁只多不少,于是全绿、无人察觉,只有报告里那
130+
份「我跑了哪些门禁」悄悄变成假的。浅检出上脚本会**响亮拒绝**而不是给错清单 —— 照它说的
131+
加深即可。补跑它新增而你的 diff 确实触及的,并在报告里点名新增项。代价是偶尔一轮 push-fix;安全的另一
128132
半归 PM,在你报告之后读真实门禁 job 结论。⛔ 这不是跳过点名族的许可 —— 它们是你仍然欠的
129133
便宜一半;你不再欠的是报告前等 CI。
130134

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ UI 创建并勾 GitHub 连接器、UI 钉模型(会话内 create_trigger 的Rout
103103
,结束会话(含限流悬挂)前补齐成对或回滚半边(report-only 巡查见机械守卫索引)。**代执行他人指令
104104
的关闭/作废,评论带出处三件(谁的指令、原话、在哪说的)** —— 无出处的关闭与误操作在证据上不可区
105105
分,会被兄弟席当误扫重开;同理适用于摘标签、回收认领等不可反推理由的动作。
106-
- **写后回读按风险定向**:标签写恒读回(label bot 整组 PUT 互吞);多席可写面恒读回(防竞态覆盖,
107-
不只防 sanitizer);正文/评论仅当含尖括号/HTML 注释才读回;「API 返回 200」≠「落地内容正确」。
106+
- **写后回读按风险定向;标签写恒为硬步骤 read-modify-write + 回读**(维护者 2026-08-18 裁定):① 先取现集 → ② 只增删目标标签 → ③ 写合并集 → ④ 写后回读核验 —— 整组 PUT 写的是本席快照,并发席落在你读与写之间的标签被静默剥掉,受害者常是你没打算碰的那枚;**承载闸门语义的标签**(如 `needs:contract-review`)挂与清两向同此四步,闸门被剥不是红灯是放行,「被剥」与「从未挂过」在证据上不可区分,回读是唯一察觉手段;
107+
多席可写面恒读回(防竞态覆盖,不只防 sanitizer);正文/评论仅当含尖括号/HTML 注释才读回;「API 返回 200」≠「落地内容正确」。
108108

109109
**一次性建标签**:`bash scripts/pm/ensure-pm-labels.sh`(幂等;退役车道刻意不在脚本里 ⛔ 不加回,理由与对象清理以脚本头为权威)。
110110

@@ -510,7 +510,7 @@ not-reachable 是设计非故障(维护者 2026-08-11 裁定)⛔ 不复测;**接
510510
**由声明行承载。档位以 dispatch-gates 常量 `CONTRACT_REVIEW_TIER` 为准(档位单源,⛔ 本文与标签不写模型名,模型升级只改一行一个文件)。派发席职责止于:卡上记一行认定、挂 `needs:contract-review`(标签命名审的对象,恒英文)、停手;⛔ 禁止自查放行。
511511
- **`needs:contract-review` 复审链**:复审资格双条件,同时满足 —— ① 跑在契约复审档位;② 非该卡派发席(犯规席在结构上无资格补救自己的犯规)。归属:常设 = 分诊席(Routine 模型由维护者在 Routines UI
512512
钉在契约复审档位),分诊轮新增子轮清该标签 —— 只审契约增量 diff、结论一行写在卡上、清标签后卡方可入队;每小时一轮即天然攒批;过渡期(分诊 Routine 未建成前)由 skills 席代行。**降档保险丝**:
513-
子轮开场自检当前模型,非契约复审档位 ⇒ 该子轮整体跳过、标签原样留置 —— 卡在队列外等待是安全态;契约复审 ⛔ 不适用额度耗尽豁免降档(豁免的对象是派发;复审的存在意义就是补偿一次低于地板的派发)。
513+
子轮开场自检当前模型,非契约复审档位 ⇒ 该子轮整体跳过、标签原样留置 —— 卡在队列外等待是安全态;契约复审 ⛔ 不适用额度耗尽豁免降档(豁免的对象是派发;复审的存在意义就是补偿一次低于地板的派发)。**载体不迁移**(维护者 2026-08-18,原话:「中期把闸门迁到 PR review 的 Request Changes 上 我觉得没必要」):闸门载体保持本标签,⛔ 不迁 PR review / Request Changes、不为迁移留门;挂与清皆按标签纪律的 read-modify-write 硬步骤写。
514514
- **碰生成物的 PR,入队前先同步 + 整体重生成** —— os-regen 驱动会零冲突标记地**静默丢掉一侧改
515515
**,只有重生成才暴露;四步序已机械化(`bash scripts/pm/os-regen-merge.sh`:**先 commit
516516
merge 再重生成**,顺序防锚点静默倒退与`gen:openapi` 假红两个陷阱);重生成后断言兄弟单条目与

0 commit comments

Comments
 (0)