Skip to content

Commit 4cabb2d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4001-strictness-remeasure
2 parents 6619071 + 04b9776 commit 4cabb2d

22 files changed

Lines changed: 2278 additions & 110 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
fix(metadata): `sys_metadata` 的 DDL 失败不再被静默吞掉 —— 只有「表已存在」这一种原因可以静音 (#4728)
6+
7+
`DatabaseLoader.ensureSchema()` 过去用一个空 `catch` 吞掉 **全部** DDL 失败,并且照样把
8+
`schemaReady` 置为 `true`:
9+
10+
```ts
11+
} catch {
12+
// If syncSchema fails (e.g. table already exists), mark ready and continue
13+
this.schemaReady = true;
14+
}
15+
```
16+
17+
注释里的免责理由只覆盖了失败原因中最良性的一种,却用它为**所有**原因开脱。真实的失败
18+
(权限不足、数据源根本没连上、列类型冲突)之后,表或新列压根不存在,而进程的状态与成功
19+
路径**逐字节相同**,启动日志里一行痕迹都没有 —— 这正是 #4420 的形态:声称已持久化、实
20+
际没落盘、系统看起来完全健康。#4632 把它定成规则(AGENTS.md → "Degradation log levels"),
21+
机械检查 `pnpm check:durability-log-level` 已经能发现这一处。
22+
23+
现在按**错误类型**判别,而不是按注释里的乐观假设:
24+
25+
- **良性的「已存在」**(SQLite 的 `table … already exists` / `duplicate column name`
26+
Postgres 的 SQLSTATE `42P07`/`42701`/`42710`、MySQL 的 `ER_TABLE_EXISTS_ERROR` 等及其
27+
`errno`,并跟随 `cause` 链)—— 表确实已就绪,当作 no-op 静默通过,并照常执行后续的
28+
`project_id → environment_id` 迁移与 ADR-0005 索引。
29+
- **其余一切失败** —— 以 `console.error` 上报,文案同时说清**后果**(`sys_metadata` 的表/
30+
列未创建,后续每一次元数据写入都会报错、或在宽松驱动上悄悄丢列,而服务器仍报告健康)
31+
**修复动作**(修掉下面那条驱动/数据源错误后重启)。只说**一次**,不是每次写入都刷屏。
32+
- `schemaReady` **不再**在真实失败后置 `true`。启动依旧不被阻断(该方法不抛),但 loader
33+
不再声称一个它并不具备的就绪状态,下一次元数据操作会重试 —— 数据源只是还在连接这类瞬
34+
时故障因此可以自愈,恢复时补一条 `info`
35+
36+
`ensureHistorySchema()` 按同一规则对齐:良性「已存在」不再每次写入都打一条 `error`(过度
37+
使用 `error` 是镜像失败),真实失败则同样只响亮一次并保持重试。
38+
39+
无 API / schema 变更;新增内部工具 `isSchemaAlreadyExistsError()`(未从包入口导出)。
40+
`scripts/durability-degradation.baseline.json` 中指向本单的条目随之删除(该文件 shrink-only)。
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(protocol): `protocol/kernel/http-protocol` 的 API Discovery 一节拆成两段式 —— `@objectstack/rest` 服务的 `/api/v1`(与 `/api/v1/discovery`)与 dispatcher 服务的 `/.well-known/objectstack` 各给一份真实响应形状,不再共用一份混合示例。Docs-only;releases nothing.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
---
3+
4+
Tooling-only: `pnpm check:startup-registry-verdict` — startup registry reads may not record a verdict the boot can still contradict (#4777). Adds `scripts/check-startup-registry-verdict.mjs` + the shrink-only `scripts/startup-registry-verdict.baseline.json` (empty on landing), a `Lint & Type Check` step, and an AGENTS.md section. Releases nothing — no package changes.
5+
6+
One showcase cold start on 2026-08-03 produced three instances of one shape in three unrelated subsystems written by three people at three times: ask a registry "is X there?" while the boot is still filling it, treat the "no" as final, and **record** it — cached on the instance (#4772 plugin-auth), asserted in a `warn` (#4771 service-automation), or written to the database (#4769 objectql). The provider registers a moment later and nothing undoes the record. All three are fixed; this is what stops the class from coming back.
7+
8+
The gate matches the three-part shape, and part 3 is what makes it a rule rather than noise — a read-only probe stays completely legal, and the cures are never flagged: a probe deferred into a lazy accessor or a `kernel:ready` hook, a probe whose ordering an ADR-0116 declaration (`dependencies` / `optionalDependencies` / `requiresServices`) has already made final, and a verdict drawn at a declared seal (`sealNodeTypeVocabulary()`) all pass.
9+
10+
Its reach is stated rather than implied: it under-matches on purpose. `getService('cache')` is visible, a `resolveCacheOrFallback()` three layers down another package is not, and #4769 is invisible to it entirely — that "registry" is the `sys_migration` table in a database. This stops the bleeding; it does not cure. Whether the kernel contract should be tightened further is #4776.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
---
4+
5+
feat(plugin-audit)!: retire `sys_comment.visibility` and `sys_comment.reply_count` (#4756, ADR-0049)
6+
7+
Both fields were modelled with **zero** runtime consumers — nothing in this repo,
8+
in `objectui`, or in `cloud` ever read or maintained either one. ADR-0049
9+
enforce-or-remove; maintainer decision: remove both. Same disposition, and for
10+
the same stated reason, as `sys_attachment.share_type` / `sys_attachment.visibility`
11+
in #2755 ("attachment access is derived from the parent record").
12+
13+
**REMOVED — `sys_comment.visibility`** (`'public' | 'internal' | 'private'`,
14+
defaulted `'public'`).
15+
16+
This one is a **security-looking key with no gate behind it**, which is the
17+
primary reason it goes rather than stays. No code path consulted it: not
18+
`enforceFeedsCapability`, not the record-level gates added in #4630, not the
19+
REST layer, not objectui's discussion panel. A comment an author marked
20+
`private` was visible to exactly the same people as a `public` one — an app
21+
author (or an AI authoring metadata) reading the field list would reasonably
22+
believe otherwise, and get a silent security failure instead of an error. That
23+
is the Prime Directive #10 trap in its textbook shape.
24+
25+
There is **no replacement key**: after #4630, who can see a comment is decided
26+
by the record-level permissions of the record its `thread_id` names — one
27+
coherent rule. A per-row enum layered on top would be a second source of truth
28+
for the same question. The enum's only genuinely missing meaning ("hidden from
29+
external/portal principals") depends on external principals existing at all,
30+
which waits on ADR-0090 D11's `externalSharingModel`; today there is nobody to
31+
hide a comment from. This does not foreclose that design — when portals land,
32+
a visibility key can return **enforce-first**, with a real gate and tests.
33+
34+
**FROM → TO:** stop sending `visibility` on `sys_comment` writes; to restrict
35+
who sees a discussion, restrict who can read the record `thread_id` points at.
36+
37+
**REMOVED — `sys_comment.reply_count`** (`number`, `defaultValue: 0`,
38+
`readonly: true`).
39+
40+
Never incremented anywhere, and `readonly` meant an author could not set it by
41+
hand either, so every row read `0` forever — a UI binding an "N replies" badge
42+
to it rendered `0` for every thread. Deliberately **not** replaced by an
43+
`afterInsert`/`afterDelete` roll-up: the predicate/bulk write-hook gaps tracked
44+
by #4770 / #4778 / #4779 (a hook that returns early without a single-record id
45+
lets the whole bulk operation through) are exactly where a hook-maintained
46+
counter drifts — a bulk delete of replies would never decrement it. A counter
47+
that drifts is worse than no counter, because both the UI and an AI reading the
48+
record trust it. If a badge needs the number, aggregate `parent_id` children at
49+
read time; a designed roll-up can be revisited once #4775's family has settled
50+
bulk-hook semantics.
51+
52+
**FROM → TO:** replace reads of `reply_count` with a count of `sys_comment` rows
53+
whose `parent_id` is the comment's id.
54+
55+
**Stored data.** Existing databases keep both columns as **unmanaged leftovers**
56+
— no migration, matching #2755. What changes where:
57+
58+
- **Reads are loud everywhere.** The read-axis gates (#4134 / #4226) resolve
59+
field names from the object schema, not from the table, so a filter, sort,
60+
`select` or `expand` naming `visibility` / `reply_count` now answers
61+
`400 INVALID_FIELD` on every deployment, leftover column or not. A "0 replies"
62+
badge that silently lied becomes an error that names itself.
63+
- **Writes are loud on new databases only.** A database provisioned after this
64+
change has no such column, so the write fails at the driver and is mapped to
65+
the same `400 INVALID_FIELD` envelope. On a pre-existing database the leftover
66+
column still accepts a value nothing will ever read — record validation does
67+
not reject undeclared keys. Dropping the two columns is an optional manual
68+
cleanup, not a requirement.

.github/workflows/lint.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,24 @@ jobs:
204204
- name: Durability-degradation log-level guard
205205
run: pnpm check:durability-log-level
206206

207+
# Startup registry-verdict guard (#4777). One showcase cold start produced
208+
# three instances of one shape in three unrelated subsystems: ask a
209+
# registry "is X there?" while the boot is still filling it, treat the
210+
# "no" as final, and RECORD it — cached on the instance (#4772
211+
# plugin-auth), asserted in a warn (#4771 service-automation), or written
212+
# to the database (#4769 objectql). The provider registers a moment later
213+
# and nothing undoes the record. This gate walks the AST for that
214+
# three-part shape: a pre-ready registry read (init()/constructor, or an
215+
# ADR-0018 open capability registry before its seal) whose absent verdict
216+
# is recorded. Reading is legal and must stay legal — a probe licensed by
217+
# an ADR-0116 declaration, or one deferred into a closure/kernel:ready
218+
# hook, is exactly the fix and is never flagged. Deliberately narrow: it
219+
# cannot see a probe behind an indirection in another package, and it
220+
# cannot see #4769 at all (that "registry" is a database table). Runs its
221+
# own --self-test first.
222+
- name: Startup registry-verdict guard
223+
run: pnpm check:startup-registry-verdict
224+
207225
# Release-notes drift guard: the platform is one version-locked train, so
208226
# every released @objectstack/spec major must have a curated, navigable
209227
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

AGENTS.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,72 @@ shrink-only.
601601

602602
---
603603

604+
## Startup registry reads — never record a verdict the boot can still contradict
605+
606+
A boot fills its registries incrementally. Asking a registry "is X there?" while
607+
it is still filling is fine — the answer is simply not final yet. Turning that
608+
not-yet into a **verdict and recording the verdict** is the defect, because the
609+
provider registers a moment later and nothing goes back to undo the record.
610+
611+
Decide with **one question**, the counterpart of the degradation-log-level one:
612+
613+
> **At the moment this code concludes "X is not registered", can a provider
614+
> still register X during this same boot? And is that conclusion RECORDED
615+
> anywhere that outlives the moment?**
616+
> **Yes and yes → defect.**
617+
618+
Three parts, all three or it is not a finding:
619+
620+
1. a read of a registry that is still filling — the service registry during
621+
`init()`, or a plugin-extensible capability registry before it is sealed;
622+
2. a terminal conclusion drawn from "absent";
623+
3. that conclusion **recorded** — cached in an instance field or module binding,
624+
asserted in a `warn`, or persisted.
625+
626+
Part 3 is what makes this a rule and not noise. **A read-only probe is
627+
completely legal**: `AutomationEngine.getUnknownNodeTypeAudit()` reads the
628+
executor registry on every call, records nothing, and is correct.
629+
630+
**Why this is a rule and not a preference.** One showcase cold start on
631+
2026-08-03 produced three instances, in three unrelated subsystems, written by
632+
three people at three times: plugin-auth froze an `undefined` cache handle into
633+
its config for the life of the process, so rate-limit counters never reached the
634+
shared store and the printed warning sent operators to provision Redis for a
635+
problem they did not have (#4772); service-automation asserted that eight
636+
approval flows "will fail at execution time" 0.8s before the executor that runs
637+
them was registered, and a deployment that genuinely lacked the plugin emitted
638+
the identical eight, so the signal could not tell the two apart (#4771); objectql
639+
wrote an ADR-0104 attestation into `sys_migration` during the same boot that was
640+
still seeding rows contradicting it, so the next restart rejected its
641+
predecessor's data (#4769). Whether the kernel contract itself should be
642+
tightened further is #4776.
643+
644+
**The three cures, in preference order:**
645+
646+
1. **Resolve where it is used, not where you start.** A lazy accessor or a
647+
`kernel:ready` hook sees a provider that registered later —
648+
`createLazyCacheRateLimitStorage()` in plugin-auth is the reference.
649+
2. **Declare the ordering (ADR-0116).** `dependencies` / `optionalDependencies`
650+
/ `requiresServices` make the kernel hoist the provider ahead or assert it
651+
registered, which makes "absent" a *fact*. Tolerance belongs in the plugin's
652+
own declaration, where the kernel enforces it — not in a checker's ledger.
653+
3. **Seal the vocabulary, then judge.** For a registry that is open by contract
654+
(ADR-0018 flow node types), the host declares the moment it can no longer
655+
grow — `AutomationEngine.sealNodeTypeVocabulary()`, called at
656+
`kernel:bootstrapped` — and only then is an absence worth reporting.
657+
658+
**It has teeth**: `pnpm check:startup-registry-verdict` walks the AST for that
659+
three-part shape and fails on it; accepted exceptions live in the shrink-only,
660+
hand-edited `scripts/startup-registry-verdict.baseline.json`. Like
661+
`check:durability-log-level` it is deliberately narrow — it cannot *discover* a
662+
new seam, only stop known ones from regressing, and it under-matches on purpose
663+
rather than risk a false positive: `getService('cache')` is visible, a
664+
`resolveCacheOrFallback()` three layers down another package is not, and #4769's
665+
"registry" is a database table it can never see. Found a new open registry? Add
666+
it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
667+
668+
---
669+
604670
## Post-Task Checklist
605671

606672
1. `pnpm test` — verify nothing broke. Touched a type-check-covered package? `pnpm typecheck` too.

0 commit comments

Comments
 (0)