Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .spec/decisions/0009-root-abi-generator-adapter-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 0009 · root-abi generator 的适配器边界与 §8.3 接口偏离:摘要链锚点必须落在 lock 上

- 日期:2026-08-29
- 状态:生效

## 背景

LCE-P0-005 要把架构源的 Root ABI 制品接进本仓。规格 §8.3 给了 `GenerateAbiRequest` /
`GeneratedAbiArtifacts` / `AbiCompatibilityReport` 的字段面,§4 定了「只消费架构源生成制品」,
§3.6 定了只读生成协议。落地时有三处规格没说、但一旦选错就会让整条摘要链变成自证的问题:

1. **compiler 身份怎么表达。** §8.3 写的是 `compiler_path: PathBuf` + `compiler_digest: Digest256`。
但上游 `compiler_hash()` 的口径是 `sha256(tools/lumio_contract.py ‖ tools/lumio_generate.py)`
——身份由**两个文件**共同决定,单个路径表达不了;而把期望摘要做成入参,等于允许调用方
自带答案来对账。
2. **摘要链的根锚在哪。** 上游 `root-abi-bundle.json` 声明了 compiler 身份、inputHash 与每份
产物的期望摘要。它在只读镜像里,是一个**可被就地改写的本地文件**。
3. **本仓自产的登记文件谁来背书。** `metadata/native-managed-abi.json` 与
`reports/layout-report.json` 不是上游产物,bundle 里没有它们的摘要。

第 2、3 两条在首版实现里都答错了,且**都不是靠推理发现的**:审查实测出两条绕过路径——
改一份无锚点产物、或改 bundle 里的三个 `outputFiles.digest`,再按同一规则重建 descriptor,
`just check-generated` 全部绿灯。本 ADR 记录修正后的边界,以及为什么必须这么定。

## 决策

### 1. 摘要链的每一环都要有**外部**锚点,descriptor 不得自证

链条自上而下:

| 环节 | 锚点 | 若无此锚点 |
| --- | --- | --- |
| 上游 bundle | `architecture.lock.json` 的 `requiredPathSha256["packages/abi/root-abi-bundle.json"]` | 改 bundle 的 outputFiles.digest 即可整体移动锚点,六份产物全部可替换 |
| compiler | bundle 声明的 `compiler.digest`(本仓复算两文件拼接) | 换 compiler 无人发现 |
| 输入集合 | bundle 声明的 `inputHash`(本仓按 `inputSet` **重算**,不照抄) | 照抄只证明「读到了这个数」,证明不了「镜像里就是那份输入」 |
| 三份上游产物 | bundle 声明的 `outputFiles[].digest` | — |
| `metadata/native-managed-abi.json` | 镜像里 `inputSet` 声明的同一份文件(逐字节相等,已被 inputHash 钉死) | 整份替换后重建 descriptor 即可全绿 |
| `reports/layout-report.json` | 由上游 `layoutProfile` **现算**的内容 | 同上 |
| `generated-contract-artifact.json` | 按同一规则**重建后逐字节比对** | 「它记的每一条都对得上」证明不了「它自己没被改」 |

**判据**:任何一份产物,如果它的唯一约束来自 descriptor,而 descriptor 又是从同一批盘上
字节重建出来的,那就是恒等式,不是校验。被背书者与背书者必须不同源。

### 2. §8.3 的两处接口偏离

- `compiler_path` + `compiler_digest` → **`compiler_directory: PathBuf`**。身份由目录下两个
固定文件共同决定;期望摘要只从上游 bundle 取,不接受调用方传入。这是**收紧**:入参形式
允许调用方自带答案,目录形式不允许。
- `build_plan: FrozenBuildPlan` → **`frozen_plan_path: Option<PathBuf>`**。计划经
`composition::verify_frozen_plan` 读取(ADR 0006 第 8 条:消费者不得自建第二套解析器)。
Root ABI 的输入集合**全部**来自上游 `inputSet`,与 BuildPlan 无交集;计划在这里的作用是
交叉核对——它记的 architecture 基线与提交必须与本仓 lock 一致,否则「按 A 计划构建、
按 B 基线生成 ABI」会一路无声走到运行时。
**`Option` 意味着这条核对可被跳过**:给了才查,不给则不查。CLI 总是给(justfile 的
`generate-abi` recipe 传 `--plan`),库调用方可以不给。这是相对规格的**弱化**,记在这里
而不是只写在 doc-comment 里。

### 3. 上游 validator 必须真跑,报告不得声称没做过的检查

上游 `emit_root_abi()` 的第一件事是 `validate_abi_document()`(schema + ADR-040 语义),
其 docstring 明写「在写出任何一个输出字节之前拒绝非法 ABI 文档」。本仓的驱动脚本重写了
`emit_root_abi` 的主体来只取三个 emitter,**必须显式补回这次调用**——首版跳过了它,同时
`AbiCompatibilityReport` 的 `schema_valid` / `semantic_rules_valid` / `symbols_valid` 三个字段
写死 `true`。那是谎报做过的检查,而该报告会被下游多张卡消费。

规则:`AbiCompatibilityReport` 的每个字段只能反映**本次真的做过**的检查。做不到的项要么
补上检查,要么改成能表达「未做此项」的形式,不得填 `true`。

`verify_generated` 不跑 validator(回读校验必须能在没有工具链的机器上进行)。它的
schema/semantic 依据是「descriptor 以 `validatorRan: true` 重建后逐字节相符」——重建时传的是
**字面量** `true`,不是从 descriptor 读回来的值。

这个区别是本 ADR 第 1 节判据的直接应用,也是第一版修复踩过的坑:把 `validatorRan` 与
`entrySymbol` 从被校验对象自己取回去参与重建,逐字节比对对这两个字段就恒真,改它们
`verify-generated` 直接 exit 0。**回读期无法外部重建的字段,不要假装它受保护**——要么以
字面量参与重建(等于「取值不符即拒收」),要么换一个有外部真值的来源。`entrySymbol` 属
后者:它的外部真值是镜像里的 ABI 文档,已被 inputHash → bundle → lock 钉死。

### 4. 上游输出集合以上游为准

驱动脚本按 `module.ABI_OUTPUT_FILES` 取输出清单并断言与本仓适配清单集合相等。上游**新增**
第 4 份输出时必须在这里响亮失败,而不是被本仓写死的三条清单静默忽略——「输出集合精确
比对」若只对本仓清单精确,对上游就是不精确。

### 5. compiler 的运行根目录用镜像内容临时拼装

`lumio_contract` 在**导入时**就按仓库根布局读 fixture,只把 `tools/` 指过去不够。本仓在临时
目录里用**只读镜像**的 `schemas/` `fixtures/` `ids/` `packages/` 加锁定 `tools/` 拼一个一次性
contract root,用完即删。镜像本身不动(受 lock 约束且已置只读),**绝不使用架构源仓工作区**
——那是不受 lock 约束的可变输入。

## 后果

- 生成与回读各多读一次 lock 与镜像输入,代价是几个文件的 I/O,换来锚点不可被就地移动。
- `check-generated` 与 `check-contracts` 的职责仍然分离(后者管镜像整体完整性),但本卡不再
依赖调用者「记得两条都跑」——bundle 对 lock 的校验在生成器内部完成。
- LCE-P0-014 / LCE-P0-008 等消费 `AbiCompatibilityReport` 的卡,可以按字段面信任它:每个字段
要么对应本次真实执行的检查(`symbols_valid`、三项 layout、两个 hash),要么对应一条
「不满足即在返回之前失败」的前置(`schema_valid` / `semantic_rules_valid`——它们为 true
的唯一路径是 descriptor 以 `validatorRan: true` 重建成功)。没有字段是无条件常量。
- 本 ADR 不改变 ADR 0001—0004、0006、0008 的任何边界,不新增依赖边(`root-abi-generator ->
composition` 在 ADR 0004 第 3 条冻结的允许边内),不定义任何公共 Schema / ID / FFI 语义。
1 change: 1 addition & 0 deletions .spec/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@
| [0006](0006-internal-build-plan-freeze.md) | BuildPlan 定为仓内确定性 JSON(plan_format_version=1),sidecar Digest 原子冻结,platform 只读 | 生效 |
| [0007](0007-composition-config-toml-parser.md) | compose 配置解析选定 `toml` crate 并精确锁版,不自研 TOML 子集解析器 | 生效 |
| [0008](0008-opened-artifact-set-construction-inversion.md) | `OpenedArtifactSet`/`MappedNativeImage` 用构造反转保持私有构造器,feature gate 不用于跨 crate 可见性 | 生效 |
| [0009](0009-root-abi-generator-adapter-boundary.md) | root-abi generator 摘要链每环须有外部锚点,descriptor 不得自证;报告不得声称没做过的检查 | 生效 |
8 changes: 8 additions & 0 deletions .spec/knowledge/lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ metadata:

## 条目

### 新写下的判据,必须在同一提交里有一条按它构造的反例测试

- 日期:2026-08-29
- 现象:写完一条规则,然后在**同一个提交里**违反它,两次同型。① ADR 0007 第 2 节明写「只用 `toml` crate 读 `*.compose.toml` 与 `tools.lock.toml`」并否决自研子集解析器,而同提交的 `toolchain.rs` 就是手写扫描——把 `supported_hosts` 写成语义相同的合法多行数组即误报「登记缺失」,错误信息还反向误导。② ADR 0009 第 1 节写下「被背书者与背书者必须不同源」,而同提交的 `verify_generated` 把 descriptor 的 `validatorRan`/`entrySymbol` 从被校验对象自己取回去参与重建,逐字节比对对这两个字段恒真——只改 descriptor 一个字段、不动任何产物即可放行。相邻的同族形态还有三次:R-00016 的「平行结构靠注释保持同步」、R-00017 的「由仓级 `cargo tree` 断言覆盖」(该断言从未创建)、R-00018 首版的「descriptor 完整性由它记的每一条间接证明」。
- 根因:写规范与执行规范之间没有机械检查,而**刚写完规范时最容易觉得自己已经遵守了**——判据在脑子里是新鲜的,于是省掉了验证。这类缺陷通读代码发现不了:五次里没有一次是读出来的,全部来自实跑构造的反例。已有的 grep 自验规则只能证明「被声称的 X 存在」,证明不了「X 覆盖的范围等于声称的范围」。
- 规避:① **判据与它的反例测试同时诞生**——新增一条 ADR 判据或「由 X 保证」的声称时,同一提交内必须有一条按该判据构造的**失败**用例(`replacing_a_self_reported_descriptor_field_is_caught` 就是 ADR 0009 第 1 节的那条,它本该和第 1 节同时写出来)。② 反例的**构造方式要覆盖多种形态**:往文件追加一个字节与替换某个字段的值,是完全不同的覆盖面——前者必然改变字节因而总被抓到,后者可能落在自证盲区里。③ 声称「由 X 覆盖」时,除 grep 验证 X 存在外,再问一句「X 挡不住的是什么」,把答案写进文档而不是省略。
- 来源:R-00016 / R-00017 / R-00018 三张卡的 reviewer 退回报告(R-00018 经两轮退回,提交 `4a37934` → `7e7447e` → `56e0be5`)。

### 跨仓 / 跨会话引用交付时,锚点用 `origin/main:<路径>`,不用裸 commit SHA

- 日期:2026-08-28
Expand Down
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,15 @@ compose p="p0-linux": (assert-profile p)
generate-abi p="p0-linux": (assert-profile p)
cargo run --locked -p lumio-core-root-abi-generator -- generate --plan build/plans/p0-linux-server-x86_64-glibc/build-plan.json --architecture-lock architecture.lock.json --out modules/root-abi/generated/LGE-V1.4-2026-08-27

# 生成物完整性(规格 §20.1「重新生成零差异」)。当前接入 lumio-core-contracts 的
# 锁定生成器校验(LCE-P0-003:descriptor Input/Output Hash 字节重算、上游 provenance
# 与镜像对账、重渲染零差异);root-abi generator 的 verify-generated(LCE-P0-005)
# 落地后按规格再并入本 recipe。
# 生成物完整性(规格 §20.1「重新生成零差异」)。两段:
# 1. lumio-core-contracts 的锁定生成器校验(LCE-P0-003:descriptor Input/Output Hash
# 字节重算、上游 provenance 与镜像对账、重渲染零差异);
# 2. root-abi 生成目录的回读校验(LCE-P0-005,本 recipe 原注释预留的接入点)——
# 逐份产物与上游 bundle 声明摘要对账、descriptor 按同一规则重建后逐字节比对、
# 文件集合与登记表完全一致。没有这一段,手改生成物不会被任何门禁发现。
check-generated:
cargo test -p lumio-core-contracts --locked --test generated_integrity
cargo run --locked -q -p lumio-core-root-abi-generator -- verify-generated --generated modules/root-abi/generated/LGE-V1.4-2026-08-27 --architecture-lock architecture.lock.json

build-platform p="p0-linux": (assert-profile p)
cargo run --locked -p lumio-core-platform-build -- build-staging --plan build/plans/p0-linux-server-x86_64-glibc/build-plan.json --plan-digest-file build/plans/p0-linux-server-x86_64-glibc/build-plan.sha256 --abi modules/root-abi/generated/LGE-V1.4-2026-08-27 --out build/platform/linux-server-x86_64-glibc/staging
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Generated Root ABI binding. Do not hand-edit.
// Publisher: LumioGameEngineArchitecture / LGE-V1.4-2026-08-27. ADR-040.
// Pure managed layout description; the consumer binds the entry symbol itself.
using System;
using System.Runtime.InteropServices;

namespace Lumio.Gen.LanguageBinding;

public static class RootAbi
{
public const uint AbiVersion = 1;
public const string EntrySymbol = "lumio_core_get_api_v1";
public const string SymbolPrefix = "lumio_";
public const string CallingConvention = "C";
public const ulong CapabilityBits = 7;
public const string TargetProfileId = "linux-x86_64-glibc";
public const int PointerBytes = 8;
public const int MaxAlignment = 8;
public const int RootHeaderBytes = 16;
public const int TableHeaderBytes = 16;
}

public enum LumioStatus : int { Ok = 0 }

[StructLayout(LayoutKind.Sequential)]
public struct LumioHandle
{
public uint Index;
public uint Generation;
public ulong Context;
}

[StructLayout(LayoutKind.Sequential)]
public struct LumioBuffer
{
public IntPtr Ptr;
public ulong Len;
public ulong Capacity;
}

[StructLayout(LayoutKind.Sequential)]
public struct LumioCoreApi
{
public uint Version;
public uint StructSize;
public ulong Reserved0;
// LumioStatus lumio_core_init(IntPtr config, LumioHandle out_context)
public IntPtr LumioCoreInit;
// LumioStatus lumio_core_shutdown(LumioHandle context)
public IntPtr LumioCoreShutdown;
// LumioStatus lumio_core_last_error_detail(LumioHandle context, LumioBuffer out_detail)
public IntPtr LumioCoreLastErrorDetail;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)]
public IntPtr[] Reserved;
}

[StructLayout(LayoutKind.Sequential)]
public struct LumioVoxelApi
{
public uint Version;
public uint StructSize;
public ulong Reserved0;
// LumioStatus lumio_voxel_world_create(LumioHandle context, IntPtr desc, LumioHandle out_world)
public IntPtr LumioVoxelWorldCreate;
// LumioStatus lumio_voxel_world_destroy(LumioHandle world)
public IntPtr LumioVoxelWorldDestroy;
}

[StructLayout(LayoutKind.Sequential)]
public struct LumioRootApi
{
public uint AbiVersion;
public uint StructSize;
public ulong CapabilityBits;
public IntPtr LumioCoreApi;
public IntPtr LumioVoxelApi;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public byte[] ReservedTail;
}

public readonly record struct SlotOffset(string Table, string Slot, int Offset);
public static class RootAbiLayout
{
public static readonly SlotOffset[] SlotOffsets =
{
new SlotOffset("lumio_core_api", "lumio_core_init", 16),
new SlotOffset("lumio_core_api", "lumio_core_shutdown", 24),
new SlotOffset("lumio_core_api", "lumio_core_last_error_detail", 32),
new SlotOffset("lumio_voxel_api", "lumio_voxel_world_create", 16),
new SlotOffset("lumio_voxel_api", "lumio_voxel_world_destroy", 24),
};

public static readonly (string Name, int Size)[] StructSizes =
{
("lumio_handle_t", 16),
("lumio_buffer_t", 24),
("lumio_core_api", 48),
("lumio_voxel_api", 32),
("lumio_root_api", 64),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"architectureCommit":"1f2ead332b3dfc3042e1495bfbe6febb8699df7e","architectureRepository":"https://github.com/LumioGames/LumioGameEngineArchitecture","baselineId":"LGE-V1.4-2026-08-27","bundleId":"root-abi-v1","compiler":{"digest":"217437fd4755e1a339e2029838cc4a2d2fb305fa05520c8cfd10ea98cc2ff290","name":"lumio-abi-compiler","version":"1.0.0"},"entrySymbol":"lumio_core_get_api_v1","fileDigests":{"csharp/Lumio.CoreEngine.Native.g.cs":"d89ff35434438773055ce4108b9f04ef6ff2b42335101249163f65c734975cd1","include/lumio_core.h":"040451bbde5a4dec3726be5f5a7be4bb934c3f68a1ca87f9c55559cae738efc7","metadata/native-managed-abi.json":"ec1bad62f4daac6c5cacd022df045ec7b47fd04c0f0a15fe39a6ce41a1ad8997","reports/layout-report.json":"fb385d696e94460444e04caf416e91db31d3cf832aeb521ef8bc5e8a99879260","rust/contracts.rs":"5e81bdfb6e879d849e2cb77a847a07167e5a459f2f23fd43f07609e726043bec"},"inputFileDigests":{"fixtures/valid/native-managed-abi.json":"ec1bad62f4daac6c5cacd022df045ec7b47fd04c0f0a15fe39a6ce41a1ad8997","schemas/native-managed-abi.schema.json":"8ef8c627eccae47841005c7bc38609b1139f6517943e21a6ca8d3002751e3a36"},"inputHash":"696a58d0525b897b549dd1e432166ae1020835902a5984221a8e60d5d8285bb3","inputSet":["schemas/native-managed-abi.schema.json","fixtures/valid/native-managed-abi.json"],"kind":"root-abi-generated-contract-artifact","outputHash":"bdbab5d398f8d98c5ac34c795712b619df70aed76d334f9961b3e53ff75a91a9","registeredFiles":["csharp/Lumio.CoreEngine.Native.g.cs","generated-contract-artifact.json","include/lumio_core.h","metadata/native-managed-abi.json","reports/layout-report.json","rust/contracts.rs"],"schemaEpoch":1,"validatorRan":true}
Loading
Loading