fix: 四处静默失真 — datasource 映射、meta type 归一化、value-shape 扫描、未接线的 lint 规则 (#4462, #4432, #4455, #4449) - #4520
Merged
os-zhuang merged 4 commits intoAug 1, 2026
Conversation
…om the other side (#4449) `validateFormLayout` was implemented, unit-tested, exported and given published rule ids — and no command ever called it. A whole-repo search found the implementation, the barrel export line and its own unit test, and nothing else: the rule ran on zero stacks for as long as it existed. Two changes: * register it in `AUTHORING_RULES` as `advisory` on all three commands. It walks structured metadata only (no lazy dependency), so `os validate`, `os build` and `os lint` pay nothing measurable for it. * add the reverse closure to the wiring guard. Every invariant #4409 shipped starts FROM a registry and looks at the commands, which cannot see a rule that never entered a registry — the same blind spot as #4402's name list, one layer up. The guard now subtracts both registries from the `validate*` / `lint*` symbols on `@objectstack/lint`'s public barrel; the difference must be empty or ledgered with a reason in `UNWIRED_RULE_LEDGER`, which ships empty because today's difference was exactly this one rule. The new tests fail without the registry entry: the closure reports `validateFormLayout` as unwired, and the liveness test asserts the entry's own `run` adapter returns both findings for a stack that earns them — membership alone is not evidence a rule produces output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
…id id (#4455) `os migrate value-shapes` is the evidence half of the ADR-0104 D1 per-deployment gate, and the scan's own header names the case it exists for: "a `location` stored as `{latitude, longitude}` or a `lookup` holding an expanded record object". The second case was never detected. `ReferenceIdValueSchema` was `z.string().min(1)`, and in a SQL deployment a legacy embedded reference reaches storage as JSON TEXT — a non-empty string. So a deployment carrying exactly the values the gate exists to find ran the scan, was told it was clean, and closed the gate with `--apply`; and because the scan deliberately imports the write-path predicate, the write path was equally blind, so the value also survived future writes. `ReferenceIdValueSchema` now rejects a value whose first non-space character is `{` or `[`, in the expanded form too — `$expand` produces an object, never its serialization. Deliberately narrower than an id charset. `FileReferenceIdValueSchema` can bound its alphabet because a `sys_file` id is minted by the platform and nothing else; a reference id is whatever the target object's key holds, including an external key an ADR-0015 federated datasource supplies. So this rejects the shape that is provably not an id and leaves the alphabet to the object that owns it — `CB0-2026-0001`, `SFDC:001xx…` and `ops/eu-west/tenant-7` stay valid, and the tests pin that. Regression coverage is at the GATE, not just the schema: the scan test plants the serialized embedded record, asserts it is counted, and asserts `valueShapeScanPassed()` is false — the deployment may not record the flag — then asserts the same value is a write rejection under strict, so the scan and the validator still answer with one predicate. Reaches authors through the ADR-0104 warn-first path until a deployment opts into strict, so nothing starts rejecting writes on upgrade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
…4432) #3985 taught the per-type gates to accept both spellings of the `/meta` type segment. It did not FOLD them, so `/meta/actions/x` and `/meta/action/x` addressed two namespaces and the layers below disagreed about which one an item lived in — `SysMetadataRepository` folded to singular on its own, while the authorization tier above it (`isOverlayAllowed`, `isArtifactBacked`), the registry heal below it (`restoreArtifactRegistryView`) and the list hydration all read the caller's spelling. The damaging half was the hydration. `getMetaItems` registered overlay rows back into the SchemaRegistry under `request.type`, so one plural-spelled read minted a PLURAL registry entry; from the next read on `listItems('actions')` was no longer empty, the singular fallback that had been supplying every code-authored action stopped running, and one overlay row hid the entire code-authored listing — on a spelling no DELETE addresses, so it outlived the delete that was meant to lift it and left listing and dispatch disagreeing about a removed item. `saveMetaItem`, `getMetaItem`, `getMetaItems`, `getMetaItemLayered`, `getMetaItemCached` and `deleteMetaItem` now fold the type to its canonical singular as their first act. Reads of data AT REST keep the other-spelling fallback: rows written under a plural `type` before this fix are real and nothing rewrites them on upgrade. What changed is that nothing WRITES or REGISTERS a non-canonical key any more. Regression tests fail without the fold: a plural-spelled read mints a phantom `actions` registry entry and the second read drops the code-authored actions, and `getMetaItem` echoes back the caller's spelling so a client can round-trip it into a second namespace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
… routing, not a hint (#4462) Measured on `main` during the v17 verification: map an object to a Postgres datasource with a bad URL and the boot SUCCEEDS, `/ready` answers 200, the datasource name appears in ZERO log lines, the write returns 201 — and the row is physically in the DEFAULT store. The operator finds out by opening the database they declared and finding it empty. Two causes, one per layer, and each is what makes fixing the other correct: * `ObjectQLEngine.getDriver` step 2 read `mapped && drivers.has(mapped)`, so a MATCHED mapping rule naming a datasource with no live driver fell silently through to the default driver. It now throws — `DatasourceUnavailableError` when the connect layer recorded a verdict (#3828), otherwise an error naming the object, the datasource and the two remedies. `default` still resolves onward: the default driver keeps its natural name (#3826), so `drivers.has('default')` is false by construction and step 5 IS how routing to it works. * ADR-0062 D2's phase-1 note deliberately excluded "mapped" from the auto-connect gate, to keep `examples/app-crm` byte-for-byte unchanged. That note priced only one side. Gate (d) now fires when a mapping rule routes at least one object to a datasource, and a `declared-auto` failure is FATAL with an operator-readable reason — the same call gate (b) already makes for an explicit `object.datasource` binding, correct for (d) now that routing no longer supplies a fallback. `OS_ALLOW_DRIVER_CONNECT_FAILURE` still degrades. The mapped-object list comes from the engine's own matcher (`ObjectQLEngine.resolveMappedDatasource`, newly public) via `connectDeclared({ mappedObjects })`. The connection service never re-derives rule matching: two matchers drifting by one clause would connect a datasource routing never uses, or route to one nothing connects — the defect again. `examples/app-crm`'s mapping is DELETED, and that is what keeps the example unchanged rather than what breaks it. Its `namespace: 'crm'` rule never matched (`namespace` is deprecated; no object sets it) and its `default: true → crm_primary` rule routed everything to an unconnected `:memory:` datasource, i.e. to the default store by fall-through. Honouring it would move the whole app — platform objects included — onto a database empty on every boot. Verified against a real boot on a private port, not only in unit tests: * unchanged CRM example boots healthy; crm_primary/crm_analytics stay `unvalidated` (metadata-only) exactly as before; * with a mapping to `postgres://…@127.0.0.1:1/nonexistent_db`, boot exits 1 with "1 object(s) are routed to it by a datasourceMapping rule (crm_account) and have no fallback datasource — their reads/writes would otherwise land in a DIFFERENT database than the one they declare ⇒ fail-fast per ADR-0062 D5"; * under OS_ALLOW_DRIVER_CONNECT_FAILURE=1 the degraded-boot banner carries the same sentence and the mapped object's seeds fail instead of silently populating the default store. ADR-0062 D2 carries the amendment; the docs page and the data skill now state that a mapping rule is routing and fails the boot when it cannot be honoured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 6 package(s): 120 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 1, 2026 15:57
os-zhuang
marked this pull request as draft
August 1, 2026 16:02
os-zhuang
marked this pull request as ready for review
August 1, 2026 16:06
17 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
v17 验收缺陷四项:每一处都在"成功"的外表下做了错的事,所以没有任何门禁报警。
Fixes #4462
Fixes #4432
Fixes #4455
Fixes #4449
来自 v17 验收缺陷汇总 #4482。四项各自一个 commit,可分别 review。
1. #4462 — 映射到不可达 datasource 的对象,静默读写 DEFAULT 库
最严重的一项:数据落错库且无人知晓。
根因(两层,各自都是对方能被修好的前提)
第一层 —— 路由层会静默兜底。
ObjectQLEngine.getDriver第 2 步原本写的是mapped && this.drivers.has(mapped):一条已匹配的 mapping 规则,只要它指向的datasource 没有活 driver,就直接落到第 5 步用 default driver。于是启动成功、
/ready200、日志里该 datasource 名出现 0 次、写入返回 201,而行落在 default 库里。
第二层 —— 启动时根本没尝试连接它。 ADR-0062 D2 的 phase-1 说明明确把"被 mapping
指向"排除在自动连接闸门之外,理由是保持
examples/app-crm逐字节不变。那条说明只算了天平的一侧;另一侧就是上面这个后果。
datasourceMapping在任何作者眼里都读作"路由",不是装饰。
改动
@objectstack/objectql):已匹配的规则指向无 driver 的 datasource时抛错 —— 连接层记录过判定就抛
DatasourceUnavailableError(被 connect policy 拒绝的 datasource,查询时报的错说不出原因:getDriver只会说 "is not registered" #3828),否则抛一条点名对象、datasource 和两条补救路径的错误。
default仍然继续向下解析:默认 driver 保留自己的自然名(ADR-0062 D1 收尾:
defaultdriver 的 connect 与失败判决仍是第二份实现(阻塞点已定位) #3826),drivers.has('default')天生为 false,第 5 步就是路由到它的方式。
service-datasource+runtime):mapping 规则至少路由了一个对象的 datasource 会在启动时自动连接,
declared-auto失败致命并给出可读原因 ——这与闸门 (b)(显式
object.datasource绑定)是同一个判断,现在对 (d) 也成立,正是因为第一层去掉了兜底。
OS_ALLOW_DRIVER_CONNECT_FAILURE仍然可降级启动。resolveMappedDatasource,新公开),经
connectDeclared({ mappedObjects })传入。连接服务绝不自己再实现一遍规则匹配 ——两个匹配器差一个子句,结果就是"连了个路由不用的库"或"路由过去但没人连",也就是这个
缺陷本身。
关于
examples/app-crm它的
datasourceMapping被删除了,而这恰恰是让该示例行为保持不变的原因,不是破坏它:namespace: 'crm'规则从来没匹配上过(namespace已废弃,没有对象设它),default: true → crm_primary规则把所有东西都路由到一个没连接的:memory:datasource,也就是靠兜底落在 default 库。真去遵守这条规则,会把整个应用(含平台对象)搬到一个每次
启动都空的内存库上。
验证证据(真实启动,专属端口 38107,非仅单测)
以下三段都是实跑输出,不是构造的:
(a) 未改动的 CRM 示例照常健康启动,两个 datasource 保持
unvalidated(仅元数据)——即行为与修复前一致:
(b) 临时把
crm_account映射到postgres://nobody:secret123@127.0.0.1:1/nonexistent_db后启动,进程退出码 1,服务器不起来(验证后已还原配置并重建产物):
(c) 加
OS_ALLOW_DRIVER_CONNECT_FAILURE=1降级启动,横幅带同一句话,且被映射对象的seed 直接失败(而不是悄悄写进 default 库):
最后这行是关键旁证:
crm_account的 seed 没能写进 default 库,所以引用它的opportunity seed 才失败 —— 兜底确实消失了。
ADR-0062 D2 已加修订说明(明确推翻 phase-1 note 并写清取舍),文档页与 data skill 同步
改为"mapping 规则是路由,连不上就启动失败"。
取舍说明(需要 reviewer 认可的产品判断)
任务卡允许我在多个合理选项中选一个并说明。我选了 启动失败(fail-fast),而不是
"启动成功但
/ready转 503",理由:object.datasource绑定的既有姿态完全一致 —— 同样是"无兜底",没有理由给两种写法两种结局;
/ready转 503 意味着进程活着但不可用,运维要多绕一层才能看到原因;fail-fast 的错误文本直接点名对象、datasource 和补救办法;
OS_ALLOW_DRIVER_CONNECT_FAILURE),"我知道库连不上,先起来"的运维意图有现成出口,不需要新开关。
这是可观察行为变化,因此 changeset 定为 minor 而非 patch,正文里写明了升级后启动
失败该怎么办。
2. #4432 — meta overlay 的 type 段不归一化
根因
#3985 让
/meta的每类型闸门接受单复数两种拼法,但没有折叠它们。于是/meta/actions/x与/meta/action/x指向两个命名空间,而下面每一层对"这条目在哪个空间"的看法并不一致:
SysMetadataRepository自己折叠成单数,而它上面的授权层(
isOverlayAllowed/isArtifactBacked)、下面的注册表修复(restoreArtifactRegistryView)和列表水合都读调用方的拼法。
真正造成破坏的是水合那一处:
getMetaItems把 overlay 行按request.type写回SchemaRegistry。一次复数拼法的读取就铸出一个复数 registry 条目;从下一次读取起
listItems('actions')不再为空,那条一直在提供 11 条代码编写 action 的单数回退再也不执行 —— 一行 overlay 遮蔽整个代码编写清单,而且是在没有任何 DELETE 能寻址的拼法上,
所以它比那条本该抬走它的 DELETE 活得更久,最终 listing 与 dispatch 对一个已删除条目
各说各话。
改动
saveMetaItem/getMetaItem/getMetaItems/getMetaItemLayered/getMetaItemCached/deleteMetaItem一进门就把 type 折叠成规范单数(Prime Directive #3)。静态数据的读取仍保留另一种拼法的回退:修复前以复数
type写入的行是真实存在的,升级时没有任何东西会重写它们。变的是:不再有任何地方写入或注册非规范键。
验证证据
单测(
packages/objectql/src/protocol-meta-type-canonicalization.test.ts,5 条)。撤掉折叠后确实会红,实测失败两条:
第一条正是复现报告里的步骤 3/5:第一次复数读取铸出 phantom,第二次读取就丢掉全部代码
编写的 action。
3. #4455 —
os migrate value-shapes漏掉 lookup 里的内嵌记录对象根因
该扫描是 ADR-0104 D1 每部署闸门的证据侧,它自己的文件头点名了存在的理由:"
location存成
{latitude, longitude}或lookup里存了展开的记录对象"。第二种情形从未被检出:ReferenceIdValueSchema是z.string().min(1),而在 SQL 部署里一个遗留的内嵌引用正是以JSON 文本形式落在 TEXT 列 —— 一个非空字符串。于是恰好带着这个闸门存在意义的那类值的
部署,跑完扫描被告知"干净",然后用
--apply关闭了闸门。又因为扫描刻意复用写路径谓词,写路径同样是瞎的,这个值还能在后续写入中继续存活。
改动
ReferenceIdValueSchema现在拒绝首个非空白字符是{或[的值,expanded 形式也一样(
$expand产出的是对象,永远不是它的序列化文本)。刻意比 issue 的首个建议更窄。 它的 file 姐妹
FileReferenceIdValueSchema能限定字符集,是因为
sys_fileid 由平台铸造、且只由平台铸造;而引用 id 是目标对象主键里的任何东西,包括 ADR-0015 联邦 datasource 提供的外部键。所以这里只拒绝可证明不是 id 的形状,把
id 字母表留给拥有它的对象 ——
CB0-2026-0001、SFDC:001xx…、ops/eu-west/tenant-7全部保持有效,测试把这点钉死了。再放宽需要关于真实外部键的证据,而不是猜测。
验证证据
回归测试压在闸门上,不只压在 schema 上
(
packages/objectql/src/validation/scan-value-shapes.test.ts):种入序列化的内嵌记录 →断言被计入 → 断言
valueShapeScanPassed()为 false(该部署不得记录 flag)→ 再断言同一个值在 strict 下是写入拒绝,也就是扫描与校验器仍然用同一个谓词回答问题。这一条
是针对本批次"测试替身比真实实现宽松"教训的直接防线。
通过 ADR-0104 的 warn-first 通道触达作者(一条
[value-shape]日志),部署自己 opt-instrict 之前不会有任何写入开始被拒 —— 升级不破坏运行中的应用;但扫描现在会计数,持有这类
值的部署再也关不上闸门。
4. #4449 —
validateFormLayout写了、测了、导出了,但没有任何命令调用它根因
它有完整实现、完整单测、公开导出和已发布的规则 id,而全仓搜索除了实现、导出行和自己的
单测之外零个调用点。它存在多久,就在零个 stack 上跑了多久。
改动
advisory注册进AUTHORING_RULES,三条命令(os validate/os build/os lint)一起跑。它只走结构化 metadata、不加载任何重依赖,三条命令的成本可忽略。这个视角天生看不见一条从没进过 registry 的规则 —— 和 test(cli): 钉住三个命令都跑 reference-integrity suite(#4384) #4402 的名单只守得住名单上的名字
是同一个形状,只是高了一层。守卫现在把两个 registry 从
@objectstack/lint公开桶的validate*/lint*导出里减掉,差集必须为空或在UNWIRED_RULE_LEDGER里带理由。账本以空的形态落地:今天的差集恰好只有这一条规则。
验证证据
新测试在没有 registry 条目时会红(闭合直接报出
validateFormLayout未接线)。另外补了一条活性测试:不只断言"在 registry 里",还调用该条目自己的
run适配器,断言一个该被挑刺的stack 确实拿回两条 finding —— 成员资格不等于会产出输出,而 #4449 恰恰是一条"存在、单测全绿、
在任何真实 stack 上零输出"的规则。同时给导出扫描加了非空断言,避免正则失配导致差集因为
错误的原因为空。
门禁与验证
全部本地实跑(分片/串行执行以控内存):
@objectstack/spectest@objectstack/objectqltest@objectstack/metadata-protocoltest@objectstack/runtimetest@objectstack/service-datasourcetest@objectstack/clitest@objectstack/dogfoodshard 1/2@objectstack/dogfoodshard 2/2turbo run typecheck(六个受影响包)spec check:generatedDogfood Regression Gate 两个分片都在本地跑过并通过 —— 这是本项最需要盯的门禁,因为 #4462
改的正是启动路径。
Changeset 四份(
Check Changeset需要相对 base 的新增文件):datasource-mapping-is-routing.md、meta-canonical-type-segment.md、reference-id-embedded-record.md、form-layout-lint-wired.md。边界
没有移除或重命名任何可作者化的 spec key,没有删除公开导出(只新增了
ObjectQLEngine.resolveMappedDatasource),没有协议破坏性变更。#4462 是可观察行为变化,取舍理由见上文,changeset 里也写了升级指引。
Generated by Claude Code