diff --git a/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md b/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md
new file mode 100644
index 00000000..b16ad774
--- /dev/null
+++ b/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md
@@ -0,0 +1,359 @@
+---
+subject: design
+status: superseded
+---
+
+
+
+# #674:`presents = "posix"` 在 Windows 上兑现契约的下半段
+
+- Issue: mcpp-community/mcpp#674(2026-09-18)
+- 依据:`origin/main` 361874df(mcpp 2026.9.18.4);本机 Linux x86_64,llvm 22.1.8
+- 上一阶段:mcpp#673(7788d3e, 2026.9.18.3)做了"只压 `_WIN32`、不定义 `__unix__`"的 trade-off,本设计补齐同一 trade-off 的另一面
+- 状态:方案待 review,未实现
+
+---
+
+## 0. 结论
+
+1. **缺陷不在 mcpp#673,不在 openkal-musl,在上游代码的潜伏假设。** 18 个 `compat.*` 包失败是因为 zlib 等上游代码依赖 glibc 的传递包含,musl 不传递包含就暴露了"`lseek` 不在作用域"这一上游 bug。mcpp#673 写下的 trade-off("只压 `_WIN32`、不定义 `__unix__`")已经显式接受这 18 个失败;本设计**不是在撤销 mcpp#673**,而是在同一 trade-off 下补齐 `presents = "posix"` 契约兑现的另一半。
+
+2. **修复归属在 mcpp 引擎层,而非 openkal-musl、非 mcpplibs/mcpp-index、非 upstream。**
+ - openkal-musl 不承担:musl 的"头文件自包含、零传递包含"是设计原则(也是 POSIX 标准本身的要求),openkal-musl 一旦加传递包含就偏离 musl 上游,违背项目定位。
+ - mcpplibs/mcpp-index 不承担:18 个 per-package patch 是次优替代(下文 §2.3),但它把复杂度推给每个 compat 包,而这些包其实共享同一个根因。
+ - upstream 不在本次修复范围:修 zlib 等 PR 应另开路径(可以是 mcpplibs 上游反馈),不阻塞引擎侧修复。
+ - 引擎承担:**`presents = "posix"` 是 mcpp 引擎对编译命令行的契约承诺,POSIX 标准规定哪些符号在哪些头里,引擎要把这些头显式拉进每个 TU。**
+
+3. **方案在 `src/toolchain/cenv.cppm:282-299` 的 Windows + Posix 分支追加 `-include unistd.h` 与 `-include sys/stat.h`,起点以 zlib 的失败信号为最小集合,通过 30-member 测量迭代追加。** 不一次加齐所有 POSIX 头——`presents = "posix"` 是"POSIX 接口按需可达",不是"所有 POSIX 接口已被强制 include"。前者是契约,后者是包办代替。
+
+4. **影响范围天然收在"Windows × x86_64 × `presents = "posix"`"这一格。** 由 cenv.cppm 现有的三层显式闸门(`os == "windows"` / `decl.presents == Posix` / `arch == "x86_64"`)保证,其他组合的编译命令行一字不改,链接行完全不变,运行时零开销。
+
+5. **本设计不动 design §3.3 与 execution plan §4。** 不重新打开 mcpp#673 的 trade-off(`__unix__` 定义与否),也不动 cenv.cppm 已有的"`__CYGWIN__`/`__CYGWIN32__` 保持定义"那条记录。**改动是新增一行类的代码 + 一段注释,不改任何已写下的设计决策。**
+
+---
+
+## 1. 实测
+
+### 1.1 复现:18 个 compat.* 失败的代表信号(`compat.zlib`)
+
+```
+$ mcpp build --target x86_64-w64-windows-gnu ...
+...openkal-musl-0.15.0/include/...
+tests/openkal-work/archive/.mcpp/.../compat-x-zlib/1.3.2/zlib-1.3.2/gzguts.h:50:12:
+fatal error: 'io.h' file not found
+```
+
+issue #674 给出的根因(已实测确认):
+
+```c
+// zlib gzguts.h
+#if defined(__TURBOC__) || defined(_MSC_VER) || defined(_WIN32)
+# include /* provides lseek on Windows */
+# include
+#endif
+
+// zlib gzlib.c
+#if defined(__DJGPP__)
+# define LSEEK llseek
+#elif defined(_WIN32) && !defined(__BORLANDC__) && !defined(UNDER_CE)
+# define LSEEK _lseeki64
+#elif defined(_LARGEFILE64_SOURCE) && _LFS64_LARGEFILE-0
+# define LSEEK lseek64
+#else
+# define LSEEK lseek /* lseek must be in scope here */
+#endif
+```
+
+`__unix__` 已定义(由 cenv 切换 `--target=x86_64-pc-cygwin` 提供),`_WIN32` 未定义(由 mcpp#673 显式压),所以走 `#else` 分支,要求 `lseek` 在作用域。zlib 自己没有 `#include `(它的 `#include ` 等头由 musl 提供,但 musl 不传递包含 ``)。
+
+### 1.2 musl vs glibc 的传递包含差异
+
+| | glibc | musl |
+|---|---|---|
+| `` 是否传递包含 `` | **是**(非 POSIX 标准行为) | **否**(POSIX 合规行为) |
+| 后果:zlib 在 Linux 上 | `lseek` 在作用域,编译过 | `lseek` 不在作用域,失败 |
+| musl 设计原则 | — | "头文件自包含,用户显式包含所需" |
+
+**musl 的"零传递包含"是设计原则,不是 bug**——POSIX 标准规定 `lseek` 由 `` 提供,**不**要求 `` 提供。所以 zlib 应该自己 `#include `;它在 glibc 上"碰巧工作"是 glibc 的非标准行为。
+
+### 1.3 18 个 compat.* 失败的根本同形
+
+每个失败包的第一个错误形如:
+- `'lseek' was not declared in this scope` → 缺 ``
+- `'fstat' was not declared in this scope` → 缺 ``
+- `'open' was not declared in this scope` → 缺 ``
+- `'mmap' was not declared in this scope` → 缺 ``
+
+**每个失败包共享同一个根因**:在 `presents = "posix"` 的 Windows + musl 编译下,POSIX 头文件不会自动出现在作用域里——既不是 musl 给的(glibc 才有传递包含),也不是 cenv 给的(目前 cenv 只管身份宏,不强制 include 头文件)。
+
+---
+
+## 2. 设计原则(对齐已有架构)
+
+### 2.1 谁写、谁包、谁兑现——四层角色清晰
+
+```
+POSIX 标准(抽象契约)
+ │
+ │ 规定 必须声明 lseek
+ │
+libc 实现(写头的)
+ │
+ ├── musl ──── openkal-musl(包了 musl 的 mcpp 包,自己一行不写头)
+ ├── glibc ── (系统 glibc)
+ └── bionic ─ (系统 bionic)
+ │
+mcpp 引擎(兑现契约的)
+ │
+ └── 读 [c-abi] presents = "posix" → 产出 -include unistd.h
+```
+
+**每一层只做自己的事:**
+- POSIX 规定契约(不实现)
+- libc 写头(不偏离 POSIX 也不添加非标准行为)
+- openkal-musl 包 musl(不写头、不加补丁)
+- mcpp 引擎兑现契约(发 `-include` 指令,不写头也不背头内容)
+
+如果让 openkal-musl 承担"传递包含"或"兼容 shim",那它就不再是 musl。如果让 mcpp 引擎写头,那它就不是引擎而是 libc 实现。**所以"-include unistd.h"的指令方 = mcpp 引擎、文件提供方 = libc 实现**,两者物理上分属不同角色,不能合并。
+
+### 2.2 引擎的 POSIX 知识是"通用知识,非包名"
+
+`src/toolchain/cenv.cppm:10-14` 已明确:
+
+> "GENERIC KNOWLEDGE, NO PACKAGE NAMES. ... openkal-musl is nowhere in this file; a second POSIX C library on Windows would realise through the same table."
+
+`-include unistd.h` 写入 cenv.cppm **不违反**这条规则,因为它写入的是 **POSIX 标准规定的接口头**(所有 POSIX libc 都同意),不是某个 C 库的实现细节(每个 libc 各不相同)。**POSIX 标准头是契约,不是包名。**
+
+### 2.3 为什么 per-package patch 是次优而非首选
+
+`mcpplibs/mcpp-index` 的 `[target.cfg(os = "windows").patches]` 机制是次优替代:
+
+| 维度 | 引擎 `-include`(本方案) | per-package patch(Path A) |
+|---|---|---|
+| 代码改动位置 | cenv.cppm 一个分支,1-3 行 | 18 个 PR,每个 ~10 行 patch 文件 |
+| 修复面 | 一次性覆盖所有 compat.* + 未来同形失败 | 只覆盖当前 18 个;未来新加 compat 包再补一次 |
+| 上游修 bug 后 | 引擎不能自动撤销(无害但白 include) | patch 变 stale,需要人主动撤销 |
+| 复杂度归属 | 引擎侧(契约兑现方) | 分散在 18 个包 |
+| 与 openkal-musl 的耦合 | **零** | **零** |
+| 与上游(zlib 等)的耦合 | **零** | 每个 patch 都是上游 bug 的反向补丁 |
+
+**两条路径互相不冲突**:本方案是"立刻止血 + 全局正确",per-package patch 是"上游反馈,长期可去"。本方案实施后,per-package patch 仍可在 mcpplibs/mcpp-index 推进,但不再紧迫。
+
+### 2.4 为什么不加齐所有 POSIX 头
+
+候选"全包"清单:
+
+```
+
+
+
+
+... (POSIX 头共计 ~50 个)
+```
+
+**反对一次加齐的理由:**
+1. **语义错位**:`presents = "posix"` 是"POSIX 接口按需可达",不是"所有 POSIX 接口已被强制 include"。前者是契约(用户代码 / 上游代码按需 include),后者是包办代替。
+2. **每 TU 付出代价**:虽然每个头都很小,但每个 TU 都强制预处理扫描所有头,即使该 TU 不需要。
+3. **撤销成本高**:若未来发现某个头在某个奇怪的目标上有兼容问题,要撤销很麻烦(会影响所有 TU)。
+4. **契约本身就反对**:`presents = "posix"` 的设计意图是"标准接口按需用",不是"全都先 include"。
+
+正确做法是**最小集合 + 测量驱动迭代**——以 zlib 失败信号为最小起点(`unistd.h` + `sys/stat.h`),跑 30-member 测量看剩余失败的第一个"未声明标识符"是什么,按需追加。
+
+---
+
+## 3. 方案
+
+### 3.1 mcpp 引擎侧修改(一个 PR)
+
+**M1 cenv.cppm:282-299 在 Windows + Posix 分支追加 `-include`。**
+
+```cpp
+// src/toolchain/cenv.cppm:282-299
+} else if (os == "windows") {
+ if (decl.presents == CAbiPresents::Posix) {
+ if (arch != "x86_64")
+ return refuse("the Cygwin-flavoured realisation is measured "
+ "on x86_64 only; this arch has no verified "
+ "substitute triple");
+ r.tokens.push_back("--target=x86_64-pc-cygwin");
+
+ // ── POSIX contract fulfilment: present POSIX-standard headers ──
+ //
+ // `presents = "posix"` is the engine's promise that POSIX-standard
+ // symbols (lseek, fstat, read, write, close, ...) are reachable from
+ // every TU. The preprocessor identity (`__unix__` defined) was already
+ // delivered above; the symbol availability needs this second half.
+ //
+ // POSIX §2.2.2 binds `` to `lseek`/etc. and ``
+ // to `fstat`/etc. Any C library declaring `presents = "posix"` MUST
+ // provide these headers. We do not name any package here; this is
+ // the POSIX contract the engine is fulfilling, not a tuning for any
+ // specific libc.
+ //
+ // Upstream code (zlib et al., issue #674) was written against glibc's
+ // transitive-include behavior (which is non-POSIX) and so relies on
+ // these headers being in scope without an explicit `#include`. musl
+ // follows strict POSIX self-contained headers, so the contract must
+ // be enforced here. Per-package patches in mcpplibs/mcpp-index are
+ // still possible and remain the right long-term cleanup.
+ //
+ // ADDITIONAL HEADERS BELONG HERE ONLY WHEN MEASUREMENT SHOWS THEY
+ // ARE NEEDED. Do not blanket-include all of POSIX: `presents` means
+ // "available on demand", not "pre-included by the engine".
+ r.tokens.push_back("-include"); r.tokens.push_back("unistd.h");
+ r.tokens.push_back("-include"); r.tokens.push_back("sys/stat.h");
+
+ r.expectDefined.push_back("__unix__");
+ r.expectDefined.push_back("__CYGWIN__");
+ r.expectUndefined.push_back("_WIN32");
+ cygwinIdentity = true;
+ } ...
+}
+```
+
+**改动量**:`src/toolchain/cenv.cppm` 一个分支 + 一段注释,约 20 行新增(含注释)。无新字段、无新 API、无新模块。
+
+**M2 不动 `expectDefined`/`expectUndefined` 的语义。** probe 验证"宏定义是否符合声明",但不验证"-include 是否成功"——因为 probe 读 `clang -dM` 输出的是已定义宏,不是 include 列表。`-include` 的成功由 e2e 测量保证(probe 守住第一层契约,e2e 守住第二层)。
+
+**M3 不改 design §3.3、不改 execution plan §4。** 本次只补 `presents = "posix"` 兑现的另一半,不重新打开"`__unix__` 是否定义"的 P3 spike 决策。CHANGELOG 条目写明"补齐 mcpp#673 trade-off 的另一半:POSIX 接口符号可见性"。
+
+### 3.2 单测更新
+
+`tests/unit/test_cenv.cpp` 在 `WindowsPosixCygwinSubstitutesTriple` 测试(`test_cenv.cpp:80-100` 附近)追加两条断言:
+
+```cpp
+TEST(CEnv, WindowsPosixPreIncludesPosixStandardHeaders) {
+ CAbiDecl d;
+ d.presents = CAbiPresents::Posix;
+ d.dataModel = CAbiDataModel::ArchDefault;
+ d.wcharBits = 32;
+ d.hasWchar = true;
+ auto r = cenv::realise(d, "windows", "x86_64", false);
+ ASSERT_TRUE(r.has_value()) << r.error();
+ ASSERT_TRUE(has(r->tokens, "-include"));
+ ASSERT_TRUE(has(r->tokens, "unistd.h"));
+ ASSERT_TRUE(has(r->tokens, "sys/stat.h"));
+}
+```
+
+并加一条断言守住"非 Windows + Posix 不受影响":
+
+```cpp
+TEST(CEnv, LinuxPosixDoesNotPreIncludePosixHeaders) {
+ // ...Linux + Posix 的现有测试保持不动,作为回归断言
+}
+```
+
+### 3.3 e2e 与测量
+
+**E1 30-member 测量。** 跑与 mcpp#673 同形的 30-member 矩阵,关注:
+- Windows × x86_64 × openkal 各 compat.* 包:从 18 个失败 → 期望显著减少(`unistd.h` 缺失类应全消)
+- 残余失败的第一个错误是否是"未声明的标识符"——若是,记录该标识符及其应在的头,作为迭代追加的依据
+- Linux / macOS / freestanding 各路径:零回归
+
+**E2 增量迭代规则。** 每发现一个新的"未声明标识符"对应 POSIX 标准头,在 cenv.cppm 的列表里追加;每个 PR 一次只追加一个头(避免一次 PR 范围过大)。`/tmp/posix-preinclude-evolution.md`(PR 描述外)记录每次追加的判据。
+
+**E3 probe 不验证 include 行为。** 这是有意为之:probe 已经守住"`__unix__` 已定义 / `_WIN32` 未定义"这一层契约;`-include` 的成功是 build-time 验证(probe 之后才能展开)。把 probe 改造成验证 include 列表,会让 probe 跨进"运行时验证"领域,超出 §3.2 的设计意图。
+
+---
+
+## 4. 影响范围与使用规范
+
+### 4.1 影响范围(改动前后对比)
+
+| 场景 | 改动前 | 改动后 |
+|---|---|---|
+| Linux × x86_64 × `presents = posix` | 不进 Windows 分支 | **不变** |
+| Linux × x86_64 × `presents = windows` | 不进 Windows 分支 | **不变** |
+| Linux × x86_64 × `presents = none` | 不进 Windows 分支 | **不变** |
+| macOS × aarch64 × `presents = posix` | 进 macOS 分支 | **不变** |
+| freestanding × `presents = posix` | 进 freestanding 分支 | **不变** |
+| **Windows × x86_64 × `presents = posix`** | 加 `--target=x86_64-pc-cygwin` | **加 `--target=...` + `-include unistd.h` + `-include sys/stat.h`** |
+| Windows × x86_64 × `presents = windows` | 不动 | **不变** |
+| Windows × x86_64 × `presents = none` | 拒绝 | **不变** |
+| Windows × aarch64 × `presents = posix` | 拒绝(arch 闸门) | **不变** |
+
+**只有"Windows × x86_64 × `presents = "posix"`"一行受影响**,其余所有组合编译命令行一字不改。
+
+### 4.2 使用规范
+
+**对包维护者(mcpplibs/mcpp-index):**
+- 是:**不需要**为 compat.* 包加 per-package patch 来修复"`lseek` 未声明"一类失败——引擎已自动处理
+- 注意:仍可保留或新加 per-package patch(用于处理非"POSIX 标准符号"类的上游 bug)
+- 否:不要因为"路径 C 已上"而推迟 upstream 反馈;两条路径并行,不互斥
+
+**对引擎调用方(任何 `mcpp build`):**
+- 是:在 `presents = "posix"` + Windows x86_64 上,POSIX 标准符号自动在作用域
+- 注意:任何非 POSIX 标准头(如 ``、``)仍需 `#include`——不要假设引擎把它们也预 include 了
+- 否:不要在源码里依赖"`lseek` 在 `` 里"(违反 POSIX 的假设)
+
+**对引擎维护者:**
+- 是:追加新的 `-include` 必须基于测量(具体的"未声明标识符" + POSIX 标准规定)
+- 否:不要一次性加入所有 POSIX 头(违背契约语义,见 §2.4)
+- 否:不要加入非 POSIX 标准头(如 ``、`` 等 libc-specific 头)——那就破坏了"通用 POSIX 知识,非包名"的设计意图
+- 注意:若未来需要给特定 C 库实现差异化策略(如某 C 库不提供 ``),再加 `[c-abi] pre-include` / `pre-include-unset` 字段;**今天不加**——只有一个 POSIX C 库,过度设计无收益
+
+### 4.3 失败模式与诊断
+
+引擎侧不发 `-include` 时:
+- 上游代码报"`xxx was not declared in this scope`"——这是预期失败(上游不遵循 POSIX include 规范)
+- 解决方案:在 mcpplibs/mcpp-index 加 per-package patch,或推动上游修
+
+引擎侧发 `-include ` 但 `` 找不到时:
+- clang 报"`'' file not found`"——这是 C 库实现未兑现 `presents = "posix"` 契约
+- 诊断:检查 graph 里的 libc 包是否真的提供了该头;若声明 `presents = "posix"` 但不提供 ``,应要求该 C 库要么补头要么改声明
+- **不**通过"删除 `-include `"来回避——那等于让引擎容忍契约不兑现
+
+### 4.4 边界与不做什么
+
+**不做:**
+- 否:给 `[c-abi]` 加 `pre-include` 字段(过度设计,见 §2.4 / §4.2)
+- 否:改动 `__unix__` / `_WIN32` 的定义策略(已由 mcpp#673 锁定)
+- 否:在 openkal-musl 加任何补丁(违背 openkal-musl 定位)
+- 否:在 mcpplibs/mcpp-index 强制 18 个 compat 包都加 patch(per-package patch 是 optional 清理,不是必需)
+- 否:给 clang 贡献新 target(LLVM 不会接收,且修了也不解决问题,见 mcpp#674 历史讨论)
+
+**做:**
+- 是:在 cenv.cppm 的 Windows + Posix 分支追加 `-include unistd.h` 与 `-include sys/stat.h`
+- 是:在 test_cenv.cpp 加单元测试
+- 是:CHANGELOG 写明:补齐 mcpp#673 trade-off 的另一半
+- 是:PR 描述明确"POSIX 契约兑现,非任何特定 C 库特化"
+- 是:通过 30-member 测量迭代(每个新发现的失败头独立追加一个 PR)
+
+---
+
+## 5. 顺序
+
+```
+mcpp PR(M1-M3) → CI 跑通 → 30-member 测量 → release
+ │
+mcpp-index PR(可选 per-package 清理,不阻塞) ←──┘
+```
+
+M1(代码)+ M2(测试)+ M3(CHANGELOG) 在同一个 PR 内,1-3 天工作量。CHANGELOG 与 PR 描述的措辞要明确"补齐 mcpp#673 的下半段",让后续读者能从 git 历史看到完整脉络。
+
+---
+
+## 6. 需要决定的问题
+
+- **D1 `-include` 的最小集合:`unistd.h` + `sys/stat.h` 是否足够开始?** 建议是。这两个头覆盖 zlib 的 `lseek` + `fstat`,且严格对应 POSIX 标准对它们的归属。后续按测量迭代。**本地验证不需要任何 host 上的工具安装**——所有工具链(包括 Windows 跨编译所需的 mingw-w64)均由 mcpp 通过 `mcpp toolchain install` 自动管理到 `~/.mcpp/registry/data/xpkgs/`,与系统 PATH 完全隔离(`docs/20-toolchains.md:13`)。本 PR 不引入任何新的 host 依赖。
+- **D2 是否同步在 `src/toolchain/cenv.cppm` 的注释里明确"POSIX 契约兑现,非 libc 特化"?** 建议是。回应 issue #674 的"归属判断"——下一个读者看到 `-include` 应该立刻明白这不是为 openkal-musl 调优,而是 POSIX 标准。
+- **D3 是否同步更新 `docs/22-target-side.md` 的 §3.2?** 建议暂缓。本设计不在 design §3.3 改任何东西;若 30-member 测量后总结出更广的契约语义,再统一更新文档。如果只想在 docs 里加一行注释,可以在 §3.2 的"manifest 字段"小节末尾追加"see cenv.cppm for the second half of the contract"一句话。
+- **D4 是否在 CHANGELOG 引用 issue #674?** 建议是。这是 issue → PR 的标准做法。
+- **D5 per-package patch 是否并入本次?** 建议不并入。本 PR 只做引擎侧修复;per-package 清理留给 mcpplibs/mcpp-index 后续 PR(可选)。
+
+---
+
+## 7. 参考
+
+- Issue #674 — 18 个 compat.* 失败的现象与四种候选路径
+- mcpp#673 — `7788d3e`,commit message 详述 P3 spike trade-off
+- `src/toolchain/cenv.cppm:10-14, 64-72, 188-204, 282-299` — 改动点上下文
+- `src/toolchain/hostflags.cppm` — `-nostdlibinc` 注入点,保证 `-include` 走 musl 头不走 clang 内置
+- `tests/unit/test_cenv.cpp:80-100` — 现有 Windows + Posix 单元测试
+- `docs/22-target-side.md:307-396` — `[c-abi]` 字段语义,`presents` 是请求(契约)
+- `docs/24-openkal-cross.md:37-69` — 三层宏家族的划分
+- `.agents/docs/2026-09-17-issue-662-graph-target-header-isolation-plan.md` — 前置 PR,隔离宿主头文件搜索(本方案的依赖)
\ No newline at end of file
diff --git a/.agents/docs/2026-09-20-cenv-interfaces-verify.sh b/.agents/docs/2026-09-20-cenv-interfaces-verify.sh
new file mode 100755
index 00000000..1f170180
--- /dev/null
+++ b/.agents/docs/2026-09-20-cenv-interfaces-verify.sh
@@ -0,0 +1,230 @@
+#!/usr/bin/env bash
+# Ecosystem verification for the 2026.9.20.1 wave against the PUBLISHED mcpp
+# and index, run inside a SubOS sandbox with CN mirrors for xlings and mcpp.
+#
+# B64=$(base64 -w0 .agents/docs/2026-09-20-cenv-interfaces-verify.sh)
+# xlings subos new v920
+# xlings subos use v920 --sandbox --cmd \
+# "echo $B64 | base64 -d > /tmp/v.sh && MCPP_VERIFY_VERSION=2026.9.20.1 bash /tmp/v.sh"
+#
+# Run it once against the PREVIOUS release first
+# (MCPP_VERIFY_VERSION=2026.9.18.3): every section marked CHANGE must fail there
+# and pass here; every section marked GUARD must pass on both. A section that
+# passes on both releases in a CHANGE section measured nothing.
+#
+# The sandbox's $HOME persists between runs of one SubOS, so each section clears
+# its own directory. A section that cannot run says so and is listed again at
+# the end, because a run that reports only failures cannot be told from one that
+# examined nothing.
+# TWO RUNS, AND THE READING FROM EACH (host dry run, 2026-09-20, against the
+# genuine published archive of the older release rather than a local build):
+#
+# mcpp 2026.9.18.3 (published) fails=2
+# B a requirement the implementation does not provide must be refused
+# C an absence with an unknown shape must be refused
+# mcpp 2026.9.20.1 fails=0
+#
+# ONE PASSING LEG IN EACH CHANGE SECTION IS THE EVIDENCE, NOT A HOLE. Both new
+# tables are top-level, and an older engine IGNORES an unknown top-level table:
+# so B's first leg (a graph that satisfies its requirements) and C's first leg
+# (an absence with a known shape) build on both releases, and that is exactly
+# the backward compatibility this wave claims. What distinguishes the releases
+# is the REFUSAL in each: an older engine cannot refuse what it never read.
+#
+# An earlier revision of this file recorded three failures against 2026.9.17.1,
+# one of them `[c-abi] has no member 'absent'`. That reading was taken while
+# the absence table was nested inside `[c-abi]`, where an unrecognised member
+# is a parse error and the whole manifest was refused. Moving the table to the
+# top level is what turned that failure into the passing first leg above --
+# see docs/22 and the design's §5.7.5.
+set -u
+
+VER="${MCPP_VERIFY_VERSION:?set MCPP_VERIFY_VERSION}"
+STORE="${MCPP_VERIFY_BIN:-$HOME/.xlings/data/xpkgs/xim-x-mcpp/$VER/bin/mcpp}"
+
+fails=0
+skipped=""
+fail() { printf 'ASSERT-FAIL: %s\n' "$1"; fails=$((fails + 1)); }
+ok() { printf 'ok: %s\n' "$1"; }
+section() { printf '\n== %s ==\n' "$1"; }
+skip() { printf 'NOT RUN: %s\n' "$1"; skipped="$skipped
+ - $1"; }
+unset XLINGS_ACTIVE_SUBOS
+
+root="$HOME/verify-920"
+rm -rf "$root"; mkdir -p "$root"
+
+section "A. identity and mirror"
+if [ ! -x "$STORE" ]; then
+ skip "mcpp $VER is not in the store at $STORE"
+ printf '\n-- summary --\nfails=%d\nnot run:%s\n' "$fails" "${skipped:- (none)}"
+ exit 1
+fi
+got="$("$STORE" --version 2>&1 | head -1)"
+case "$got" in
+ *"$VER"*) ok "mcpp $VER from $STORE" ;;
+ *) fail "the binary at $STORE reports '$got'" ;;
+esac
+"$STORE" self config --mirror CN >/dev/null 2>&1 \
+ && ok "mcpp mirror set to CN" || fail "mcpp self config --mirror CN"
+
+# ── CHANGE 1. A package states which interfaces of the layer it requires ─────
+#
+# The engine knows no interface name: it compares two sets and refuses before
+# anything is compiled. Both legs are here because only the second says the
+# comparison happened -- a build that succeeds proves nothing about a check
+# that never ran.
+section "B. requires-interfaces is answered at resolution (CHANGE)"
+b="$root/b"; rm -rf "$b"; mkdir -p "$b/impl/src" "$b/src"
+printf 'int fake_kernel_marker(void){return 0;}\n' > "$b/impl/src/lib.c"
+printf 'int main(void){return 0;}\n' > "$b/src/main.c"
+cat > "$b/impl/mcpp.toml" <<'EOF'
+[package]
+name = "fakekernel"
+version = "0.1.0"
+provides = ["mcpp:kernel-abi=openkal"]
+
+[targets.fakekernel]
+kind = "lib"
+sources = ["src/*.c"]
+
+[kernel-abi]
+provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory"]
+EOF
+mk_root() { # $1 = requires-interfaces body
+ cat > "$b/mcpp.toml" </dev/null 2>&1); then
+ ok "a requirement the implementation provides builds"
+else
+ fail "a requirement the implementation provides must build"
+fi
+mk_root '"openkal.stream", "openkal.net"'
+rm -rf "$b/target"
+out="$(cd "$b" && "$STORE" build 2>&1)"
+if [ $? -eq 0 ]; then
+ fail "a requirement the implementation does not provide must be refused"
+else
+ case "$out" in
+ *openkal.net*iface-probe*|*iface-probe*openkal.net*)
+ ok "the refusal names the interface and the package" ;;
+ *) fail "the refusal does not name both: $(printf '%s' "$out" | head -3 | tr '\n' ' ')" ;;
+ esac
+ if [ -d "$b/target" ] && find "$b/target" -name '*.o' -print -quit 2>/dev/null | grep -q .; then
+ fail "the refusal arrived after something was compiled"
+ else
+ ok "nothing was compiled before the refusal"
+ fi
+fi
+
+# ── CHANGE 2. A C library states what it does not supply ────────────────────
+section "C. [c-abi-absent] is read, and a bad shape is refused (CHANGE)"
+c="$root/c"; rm -rf "$c"; mkdir -p "$c/libc/src" "$c/src"
+printf 'int fake_libc_marker(void){return 0;}\n' > "$c/libc/src/lib.c"
+printf 'int main(void){return 0;}\n' > "$c/src/main.c"
+cat > "$c/mcpp.toml" <<'EOF'
+[package]
+name = "absent-probe"
+version = "0.1.0"
+
+[dependencies]
+fakelibc = { path = "libc" }
+
+[build]
+allow_host_libs = true
+EOF
+mk_libc() { # $1 = the form value
+ cat > "$c/libc/mcpp.toml" </dev/null 2>&1); then
+ ok "a declared absence with a known shape is accepted"
+else
+ fail "a declared absence with a known shape must be accepted"
+fi
+mk_libc sometimes
+rm -rf "$c/target"
+out="$(cd "$c" && "$STORE" build 2>&1)"
+if [ $? -eq 0 ]; then
+ fail "an absence with an unknown shape must be refused"
+else
+ case "$out" in
+ *accepted-no-effect*) ok "the refusal names the shapes that exist" ;;
+ *) fail "the refusal does not name the shapes: $(printf '%s' "$out" | head -2 | tr '\n' ' ')" ;;
+ esac
+fi
+
+# ── GUARD. A package that declares neither key is untouched ─────────────────
+section "D. a package declaring neither key is unchanged (GUARD)"
+d="$root/d"; rm -rf "$d"; mkdir -p "$d/src"
+printf '#include \nint main(){std::puts("plain");return 0;}\n' > "$d/src/main.cpp"
+cat > "$d/mcpp.toml" <<'EOF'
+[package]
+name = "plain"
+version = "0.1.0"
+EOF
+if (cd "$d" && "$STORE" build >/dev/null 2>&1) \
+ && "$d"/target/*/*/bin/plain 2>/dev/null | grep -q plain; then
+ ok "a package declaring nothing builds and runs"
+else
+ fail "a package declaring nothing must build and run unchanged"
+fi
+
+# ── GUARD. openkal from the published index ─────────────────────────────────
+section "E. an openkal program from the published index (GUARD)"
+e="$root/e"; rm -rf "$e"; mkdir -p "$e/src"
+printf '#include \nint main(){std::puts("openkal ok");return 0;}\n' > "$e/src/main.cpp"
+cat > "$e/mcpp.toml" <<'EOF'
+[package]
+name = "openkal-hello"
+version = "0.1.0"
+
+[dependencies]
+openkal-llvm-runtime = "0.12.0"
+EOF
+if (cd "$e" && "$STORE" build >/dev/null 2>&1); then
+ if "$e"/target/*/*/bin/openkal-hello 2>/dev/null | grep -q "openkal ok"; then
+ ok "an openkal program builds and runs from the published index"
+ else
+ fail "the openkal program built and did not run"
+ fi
+else
+ skip "openkal-llvm-runtime 0.12.0 did not resolve from the index"
+fi
+
+printf '\n-- summary --\nfails=%d\nnot run:%s\n' "$fails" "${skipped:-
+ (none)}"
+[ "$fails" -eq 0 ]
diff --git a/.agents/docs/2026-09-20-cxa-thread-atexit-finding.md b/.agents/docs/2026-09-20-cxa-thread-atexit-finding.md
new file mode 100644
index 00000000..328cb416
--- /dev/null
+++ b/.agents/docs/2026-09-20-cxa-thread-atexit-finding.md
@@ -0,0 +1,119 @@
+---
+subject: review
+status: active
+---
+
+# `__cxa_thread_atexit` 在 openkal-Windows 上:定位到一层,第二层未定位
+
+- 日期:2026-09-20
+- 来源:mcpp-index 的 30-member 重测,doctest 与 spdlog 两个成员停在
+ `ld.lld: error: undefined symbol: __cxa_thread_atexit`
+- 结论:**不要只修第一层。** 只修它会把一个构建期的响亮失败换成一个运行期的静默失败。
+
+---
+
+## 1. 最小复现(五行)
+
+```cpp
+#include
+struct D { int v; ~D() { std::printf("dtor %d\n", v); } };
+thread_local D t{7};
+int main() { std::printf("v=%d\n", t.v); return 0; }
+```
+
+依赖 `openkal-llvm-runtime = "0.12.0"`。
+
+| 目标 | 读数 |
+| --- | --- |
+| `x86_64-linux-gnu` | 构建并运行 |
+| `x86_64-windows-gnu` | `ld.lld: error: undefined symbol: __cxa_thread_atexit` |
+
+## 2. 第一层:已定位
+
+`llvm/libcxxabi/src/cxa_thread_atexit.cpp:109`
+
+```cpp
+#if defined(__linux__) || defined(__Fuchsia__)
+extern "C" {
+ _LIBCXXABI_FUNC_VIS int __cxa_thread_atexit(Dtor, void*, void*) throw() { ... }
+}
+#endif
+```
+
+**上游只在这两个系统上导出这个符号**,因为在别处别人已经导出了。实测:
+
+```
+$ llvm-nm --defined-only .../x86_64-w64-mingw32/lib/libmingw32.a | grep -c __cxa_thread_atexit
+1
+```
+
+——普通 MinGW 目标由 `libmingw32.a` 提供。openkal 把 C 库连同它的运行时一起换掉,
+于是**两边都以为对方会提供**。这与本轮其他几处同形:上游问的是「这是哪个 OS」,
+而真正的问题是「这个映像里还有没有第二个 C++ 运行时」。
+
+文件里那段 fallback(`#ifndef HAVE___CXA_THREAD_ATEXIT_IMPL`,把析构挂在一个
+`__libcpp_tls_key` 上)是**完整的**,只是被这个守卫挡在导出之外。
+
+## 3. 把守卫放开之后:链接通了,析构不跑
+
+在 `#if` 上加一条本包自己的条件之后:
+
+| | Linux | Windows(wine) |
+| --- | --- | --- |
+| 链接 | 通过 | **通过**(此前失败) |
+| `thread_local` 析构是否运行 | **运行** | **不运行** |
+
+`examples/cxx` 加一条断言(在一个 spawned thread 里构造带析构的 `thread_local`,
+join 之后查标志),Linux `ok`、Windows `FAIL`。
+
+## 4. 第二层:两个假设,都被实测否掉
+
+**假设一:PE 上 `thread_local` 走 emutls,它自己的 pthread key 先于 libc++abi 的
+key 被析构,于是 `run_dtors` 读到的链表已经空了。**
+
+否。key 析构里读 `thread_local` 在两个目标上都读到正确的值:
+
+```cpp
+static thread_local int marker = 0;
+static void dtor(void*) { saw = marker; }
+// 线程里 marker = 42; pthread_setspecific(k, ...)
+```
+
+```
+Linux : key destructor read thread_local as 42 (expect 42)
+Windows : key destructor read thread_local as 42 (expect 42)
+```
+
+**假设二:`__cxa_thread_atexit_impl` 是弱符号,在 PE 上解析成了非空,于是走了
+`if (__cxa_thread_atexit_impl)` 那一支而不是 fallback。**
+
+否。两个目标上都是 null:
+
+```
+Linux : __cxa_thread_atexit_impl = 0 -> fallback branch
+Windows : __cxa_thread_atexit_impl = 0 -> fallback branch
+```
+
+另有一条已确认为**正常**的:`pthread_key_create` 的析构在 Windows 上**会**在线程
+结束时运行(`pthread tsd dtor ran=1`)。所以不是 TSD 机制本身。
+
+## 5. 为什么本轮不发这个补丁
+
+| | 现状 | 只修第一层 |
+| --- | --- | --- |
+| 失败在哪 | **链接期** | 运行期 |
+| 调用方能否看见 | **能,链接器点名符号** | **不能,注册成功而析构不发生** |
+
+第二种正是 openkal-musl 的 `[c-abi-absent]` 里叫作 `accepted-no-effect` 的那个形状,
+也是 SPEC §6.1 把「运行期报告不支持」称为缺陷的理由。**一个响亮的构建期失败,
+比一个静默的运行期失败好。**
+
+补丁已撤回。第一层的定位、第二层的两个否定结果,以及最小复现,都在上面——下一个
+接手的人不必从 30 个成员的诊断重新走一遍。
+
+## 6. 下一步的判据
+
+1. 在 Windows 上确认 `run_dtors` **是否被调用**(在 fallback 里打一行,重建运行时)。
+ - 被调用而链表为空 ⇒ 注册那一侧的问题
+ - 没被调用 ⇒ `__libcpp_tls_create` / key 注册那一侧的问题
+2. 无论结论如何,修法必须让「析构会跑」与「链接会过」同时成立,或者两者都不成立。
diff --git a/.agents/docs/2026-09-20-ecosystem-execution-plan.md b/.agents/docs/2026-09-20-ecosystem-execution-plan.md
new file mode 100644
index 00000000..fecf49d5
--- /dev/null
+++ b/.agents/docs/2026-09-20-ecosystem-execution-plan.md
@@ -0,0 +1,210 @@
+---
+subject: plan
+status: active
+---
+
+# C 环境生态方案:执行计划
+
+- 依据:`.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md`
+- 日期:2026-09-20
+- 原则:每个仓库一个 PR;测量先行,数据不通过就停在测量。
+
+## 0. 起点状态(2026-09-20 实测)
+
+| 仓库 | 版本 | 状态 |
+| --- | --- | --- |
+| mcpp | 2026.9.18.3 | main 干净 |
+| openkal | 0.14.0 | 已发布并登记 |
+| openkal-musl | 0.16.0 | 已发布并登记 |
+| openkal-llvm-runtime | 0.12.0 | 已发布并登记 |
+| mcpp-index `pins.toml` | `runtime = "0.10.0"` | **未抬**,测量图里没有任何包声明 `[c-abi]` |
+
+窗口仍开着:包侧 `_WIN32` 适配已撤(#439),引擎侧实现未在测量中生效。
+
+## 1. 轨道与依赖
+
+```
+E1 抬 pins + 重测 ──────────────────────┐ (独立,最高价值,先跑)
+ │
+A mcpp 引擎(P0/P1/P5-L2/P7-L3/absent)─┼─→ E2 refused + 新引擎 pin
+ │
+B openkal tools + 文档(SURFACE→接口集)─┼─→ D 各实现填 provides-interfaces
+ │
+C openkal-musl(absent + requires)─────┘
+ │
+ └─→ F 发布 + 沙箱验证
+```
+
+| 轨 | 仓库 | 内容 | 依赖 |
+| --- | --- | --- | --- |
+| **E1** | mcpp-index | `pins.toml` runtime 0.10.0 → 0.12.0,重测 30 成员 | 无 |
+| **A** | mcpp | P0.1 探针 `--target`、P0.2 注释、P1 冻结语义、P5-L2 解析期集合包含、P7-L3 链接期集合差、`[c-abi-absent]` 解析与诊断 | 无 |
+| **B** | openkal | `tools/interfaces-from-surface.sh`、README 记述四级阶梯 | 无 |
+| **C** | openkal-musl | `[c-abi-absent]` 声明 + CI 断言、`requires-interfaces` | A(字段语义)、B |
+| **D** | openkal-linux / -windows / -macos | `provides-interfaces` 由产物生成 + CI 断言 | B |
+| **E2** | mcpp-index | `refused` status、新引擎 pin、重测 | A、E1 |
+| **F** | 全部 | 发布、gtc 镜像、沙箱验证 | 全部 |
+
+## 2. 判据
+
+| 轨 | 判据 |
+| --- | --- |
+| E1 | 重测后 Windows 腿:第一类(`_WIN32` 选错分支)应自愈;逐条记录未自愈的与原因 |
+| A | 单测覆盖新字段的解析与拒绝;`riscv64-none-elf` 探针 dump 含 `__riscv` 不含 `__linux__`;未声明的包命令行逐字节不变 |
+| B | 脚本对 `SURFACE.txt` 产出 16 个接口名;删掉一个组,产出少一个 |
+| C | 把 `fork` 的 `form` 从 `link` 改成 `enosys`,CI 必须红 |
+| D | 删掉某实现的一个接口定义,CI 必须红并指名该接口 |
+| E2 | `refused` 与 `fails` 分开计;`cfg(c-abi = ...)` 的出现次数记录在案 |
+| F | 沙箱中只写版本号即可解析并构建 |
+
+## 3. 不做
+
+- P3(撤 `__CYGWIN__`):需要 E1 的重测数据才能判断代价,本轮只记录证据,不落地。
+- P4(合成节点层):openkal-musl 的移植工作量独立,另轮。
+- P6(openkal-win-ucrt):需要 c++-abi 侧配套,另轮。
+
+---
+
+## 4. 执行记录(2026-09-20)
+
+### 4.1 E1 测量:声明第一次生效
+
+`tests/openkal/pins.toml` 的 `runtime` 从 0.10.0 抬到 0.12.0,重测 30 个成员
+(mcpp-index run `35503820978`,mcpp 2026.9.18.3,llvm@22.1.8)。
+
+| target | 之前 | 之后 |
+| --- | --- | --- |
+| `x86_64-linux-gnu` | 27 runs / 3 fails | 27 runs / 3 fails |
+| `x86_64-windows-gnu` | 15 runs / 15 fails | **23 runs / 7 fails** |
+
+**八个成员零适配转绿**:asio(经 cmp-module)、catch2、cli11、eigen、fmtlib.fmt、
+libpng、re2、lua(经 capi-lua)。全部是按 `_WIN32` / `__MINGW32__` 选分支的那一类。
+
+**剩余七个分三组**,与评审文档的预测逐条对上:
+
+| member | 诊断 | 预测 | 结果 |
+| --- | --- | --- | --- |
+| mimalloc | `atomic.h:16 'windows.h'` | `__CYGWIN__` 挡住 | 确认 |
+| sqlite3 | `sqlite3.c:29506 'windows.h'` | `__CYGWIN__` 挡住 | 确认 |
+| archive(xz) | `tuklib_physmem.c:21 'windows.h'` | 本来就要 configure | 确认 |
+| c-ares | `ares_setup.h:81 'windows.h'` | 同上 | 确认 |
+| curl | `"too small curl_off_t"` | 同上 | 确认 |
+| doctest | `undefined symbol: __cxa_thread_atexit` | **未预测** | 新发现 |
+| spdlog | 同上 | **未预测** | 新发现 |
+
+### 4.2 本轮新发现
+
+1. **`__CYGWIN__` 的 trade-off 已可结算。** mimalloc 的守卫注释写着
+ "we use windows locks on cygwin, but otherwise treat it at unix",sqlite3 的
+ `SQLITE_OS_WIN` 检测列表含 `__CYGWIN__`。**上游用这个名字回答的是"Win32 可用",
+ 不是"对象格式是 PE"。** 这是 P3 的直接判据,可在下一轮落地。
+
+2. **`__cxa_thread_atexit` 缺口。** doctest 与 spdlog 此前停在缺头文件,现在编译
+ 过去、停在链接。这是 openkal 之上 C++ 运行时的缺口(libc++abi 用来登记
+ `thread_local` 析构的钩子),在环境正确之前到不了。属 openkal-llvm-runtime /
+ openkal-musl,另轮。
+
+3. **clang 20.1.7 在 Windows 上对三种写法都崩。** `[c-abi-absent]` 的解析块写成
+ `parse_string` 内的语句块、写成模块导出 purview 里返回
+ `expected, string>` 的自由函数、以及用成员指针作
+ sort 投影,在 Windows 上各崩一次,其他宿主全过。最终形态是匿名命名空间里的内部
+ helper + 出参 + `optional` + 比较器。
+
+4. **两个新键都必须是顶层表,而这条要求是量出来的,不是选出来的。** 旧引擎忽略未知
+ **顶层表**、拒绝已知表里的未知成员。`[kernel-abi]` 一开始就是顶层表(实测
+ 2026.9.17.1 与 2026.9.18.3 都静默接受);`[c-abi-absent]` 起初写成 `[c-abi].absent`,
+ 于是**每个旧 mcpp 在每个目标上拒绝整份清单**(实测:真正发布的 2026.9.18.3 归档,
+ 跑 openkal-musl 0.17.0 将要发布的那份清单)。挪到顶层后两者都被忽略,**都不要求抬
+ floor**,openkal-musl 0.17.0 也不再需要等 mcpp 发版。
+
+### 4.3 PR
+
+| 仓库 | PR | 内容 |
+| --- | --- | --- |
+| mcpp | #678 | 探针带目标、撤 hostStripMacros、`[kernel-abi]`、`[c-abi-absent]`、冻结 `presents` |
+| openkal | #42 | `check-surface.sh --interfaces/--toml`,接口集由产物派生 |
+| openkal-musl | #39 | 0.17.0 `[c-abi-absent]` + CI 断言 |
+| openkal-linux | #29 | 0.15.0 `provides-interfaces`,CI 重新生成并 diff |
+| mcpp-index | #444 | 抬 pins、重测、`refused` |
+
+### 4.4 本轮未做,以及为什么
+
+| 项 | 状态 | 理由 |
+| --- | --- | --- |
+| openkal-macos 的 `provides-interfaces` | 未做 | 该实现无法在 Linux 宿主交叉构建(`aarch64-macos` 没有本机载荷),而这份清单的纪律是**由产物派生**。手写它就是这套机制存在的理由所反对的那件事。留给一次能在 macOS runner 上生成它的改动 |
+| P3 撤 `__CYGWIN__` | 未做 | 判据已具备(mimalloc 与 sqlite3 的守卫),但它会改变 libarchive 生成配置头里的 `#if defined(_WIN32) && !defined(__CYGWIN__)` 分支,代价需要一次重测才能称量。本轮把证据记入 mcpp-index 的 `docs/openkal-compat.md` |
+| P4 合成节点层 | 未做 | openkal-musl 的移植工作量独立于本轮 |
+| P6 `openkal-win-ucrt` | 未做 | 需要 c++-abi 一侧配套 |
+| `__cxa_thread_atexit` | 未做 | 本轮测量新发现;属 openkal-llvm-runtime / openkal-musl |
+
+### 4.5 两个实现的清单不同,这是这套机制存在的理由
+
+| 实现 | 接口数 | 差异 |
+| --- | --- | --- |
+| openkal-linux 0.15.0 | 15 | 全部 |
+| openkal-windows 0.10.0 | 14 | 缺 `openkal.space` |
+
+需要 `openkal.space` 的消费者在 Windows 上被**解析期**拒绝,在 Linux 上构建。
+一个给环境类别起的名字会让这两个实现看起来一样——这正是 SPEC §3.3 撤回 `hosted`
+的理由,而这里是它的第一个实例。
+
+### 4.6 探针修复的产物级读数
+
+判据不取自日志而取自探针自己的缓存。在本机用新引擎为 `riscv64-none-elf` 构建一个声明了
+`[c-abi]` 的图之后,`~/.mcpp/build-cache/v1/cenv-probe/` 里最新的那份 `-dM` dump:
+
+```
+d9d24a49fc6f76c2.dm __riscv=1 __linux__=0 __SIZEOF_WCHAR_T__=4
+```
+
+同目录下更早的几份是 hosted Linux 目标的,读数为 `__riscv=0 __linux__=1` —— 那是
+**正确的**,因为那里宿主就是目标。区分两者的是第一行:freestanding 的探针此前也长这样。
+
+这条读数是「探针量的是它要核对的那个目标」这句话的产物级证据,不是日志级的。
+
+### 4.7 端到端:同一条需求在两个实现上给出不同答案
+
+登记发布之后,对**已发布的**实现实测一次。工程只写一条需求,按目标解析不同的实现:
+
+```toml
+[target.'cfg(linux)'.dependencies]
+openkal-linux = { version = "0.15.0", features = ["standalone"] }
+[target.'cfg(windows)'.dependencies]
+openkal-windows = { version = "0.10.0", features = ["standalone"] }
+
+[kernel-abi]
+requires-interfaces = ["openkal.space"]
+```
+
+读数:
+
+```
+=== Linux ===
+ Cached openkal-linux v0.15.0 (17 units)
+ Finished dev [unoptimized + debuginfo]
+
+=== Windows ===
+error: 'idx-probe' requires interfaces the resolved implementation does not
+ provide. [interface-not-provided]
+ openkal.space
+ provided by openkal-windows (14 interfaces)
+```
+
+**同一个包、同一条需求,在一个目标上构建、在另一个目标上于编译任何东西之前被拒绝**,
+而拒绝点名了缺的接口、要它的包、没提供它的实现和它提供的个数。
+
+这是整轮最强的一条证据,也是 SPEC §3.3 撤回 `hosted` 的理由的第一个实例:
+**一个给环境类别起的名字,会把这两个实现藏成一样。**
+
+链条的每一环都在这条读数里:清单由产物生成(openkal CI 的 diff)、登记进索引
+(mcpp-index#445)、在解析期被读(mcpp#678)、拒绝带着可被机器读的码
+(`[interface-not-provided]`,mcpp-index 的测量按它区分 `refused` 与 `fails`)。
+
+### 4.8 索引陈旧的又一层
+
+`mcpp index update` 报 `index updated`,而 `~/.mcpp/registry/data/mcpplibs/.xlings-index-version`
+仍停在旧 artifact。删掉 `.xlings-index-cache.json` 与 `.mcpp-index-updated` 都不够;
+删掉整个 `mcpplibs/` 目录重取才拿到新的 `cf36e1e`。
+
+判据只能是那个文件里的 artifact sha,不能是命令的退出码,也不能是它打印的那句
+`index updated`。
diff --git a/.agents/docs/2026-09-20-issue-674-design-review.md b/.agents/docs/2026-09-20-issue-674-design-review.md
new file mode 100644
index 00000000..f0c57b15
--- /dev/null
+++ b/.agents/docs/2026-09-20-issue-674-design-review.md
@@ -0,0 +1,649 @@
+---
+subject: review
+status: active
+---
+
+# #674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性
+
+- 被评审对象:`.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md`(Path C)
+- 上游 issue:mcpp-community/mcpp#674
+- 评审依据:`origin/main` 361874df(mcpp 2026.9.18.4);`mcpplibs/mcpp-index` f3c69ac;`mcpplibs/openkal` 871f8f0
+- 评审环境:本机 Linux x86_64,clang 22.1.0(`~/.xlings/subos/current/bin/clang`)
+- 日期:2026-09-20
+
+---
+
+## 0. 结论
+
+**不建议实施。** 三层理由,每一层单独成立:
+
+1. **方案修不了它要修的东西。** 18 个 `compat.*` 失败的第一条诊断,在 `mcpp-index` 的
+ checked-in 测量数据里全部是「找不到某个 Windows 头」(`windows.h` / `_mingw.h` /
+ `windef.h` / `io.h` / `intrin.h` / `mm_malloc.h`),**没有一条**是设计稿 §1.3 所写的
+ `'lseek' was not declared in this scope`。这些 `#include` 由 `#if defined(_WIN32)` 选中,
+ 而强制包含一个 POSIX 头**在构造上**改变不了这个条件,也变不出 `windows.h`。
+
+2. **方案的前提不成立。** issue #674 与设计稿 §1 共用一套因果叙述——「引擎已经做到
+ `__unix__` 定义、`_WIN32` 不定义,剩下的是上游代码的假设」——这套叙述与三份可查证
+ 据矛盾:`x86_64-pc-cygwin` 三元组的实测预定义、`mcpp-index` 里那次测量所钉的图、以及
+ mcpp#673 实际做的事。真正的成因是**那次测量的图里根本没有任何包声明 `[c-abi]`**,
+ 于是 cygwin 化实现从未执行,`_WIN32` 仍然定义着。
+
+3. **即便前提成立,实现也会炸。** `-include` 经由 `cEnvTokens` 广播,会同时灌进
+ C++ 模块接口单元(含 `std` 模块)、GAS 汇编单元、以及校验探针;并且被
+ `appendUniqueFlags` 的按串去重吃掉第二个 `-include`,把 `sys/stat.h` 变成一个位置参数。
+ 四处都已在本机实测复现。
+
+**真正的下一步在生态侧而不在引擎侧**:把 `openkal-musl 0.15.0` / `openkal-llvm-runtime 0.11.0`
+发出去,`tests/openkal/pins.toml` 的 `runtime` 从 `0.10.0` 抬上去,重跑 30-member。在那之前,
+Windows 腿测量的不是 `presents = "posix"`,而是「没有 `presents` 时会怎样」。
+
+---
+
+## 1. 方法与判据
+
+本评审不复述文档,只做三件事:读实现、读被引用的数据、在本机实测。所有实测命令与原始
+输出列在 §9,可逐条重跑。
+
+判据的选取遵循一条规则:**每个结论都要落在一个与该结论的「否」能区分开的对象上**。
+「设计稿说 X」不是证据;「代码在第 N 行做了 Y」「checked-in 的测量文件里记录了 Z」
+「本机这条命令打印出 W」才是。
+
+---
+
+## 2. 致命问题:方案对它要修的失败是正交的
+
+### 2.1 失败诊断的真实分布
+
+`mcpplibs/mcpp-index` 的 `.xpkgindex/openkal-compat.json` 是这次测量唯一 checked-in 的
+原始数据(`measured: 2026-09-17`,`pins: mcpp 2026.9.17.3 / runtime 0.10.0 / llvm@22.1.8`)。
+`x86_64-windows-gnu` 上 15 runs / 15 fails,15 条首诊断逐条如下:
+
+| member | 第一条诊断 |
+| --- | --- |
+| archive | `xz .../src/common/sysdefs.h:44:11: fatal error: '_mingw.h' file not found` |
+| c-ares | `ares_setup.h:81:12: fatal error: 'windows.h' file not found` |
+| capi-lua | `lua .../src/loadlib.c:150:10: fatal error: ...` |
+| catch2 | `catch_windows_h_proxy.hpp:24:10: fatal error: 'windows.h' file not found` |
+| cli11 | `CLI/impl/Argv_inl.hpp:44:10: fatal error: 'windef.h' file not found` |
+| cmp-module | `asio/detail/...` |
+| curl | `curl_setup.h:33:10: fatal error: '_mingw.h' file not found` |
+| doctest | `doctest.h:3215:10: fatal error: 'windows.h' file not found` |
+| eigen | `clang/22/include/mm_malloc.h:43:22: error: ...`(clang 自带头) |
+| fmtlib.fmt | `clang/22/include/intrin.h:12:15: fatal error: ...`(clang 自带头) |
+| libpng | `pngpriv.h:578:12: fatal error: 'windows.h' file not found` |
+| mimalloc | `mimalloc/atomic.h:16:10: fatal error: 'windows.h' file not found` |
+| re2 | `util/mutex.h:15:10: fatal error: 'windows.h' file not found` |
+| spdlog | `fmt/bundled/format-inl.h:20:12: fatal error: 'io.h' file not found` |
+| sqlite3 | `sqlite3.c:29506:10: fatal error: 'windows.h' file not found` |
+
+**零条「未声明的标识符」。** 设计稿 §1.3 写的四条形态
+
+```
+'lseek' was not declared in this scope → 缺
+'fstat' was not declared in this scope → 缺
+'open' was not declared in this scope → 缺
+'mmap' was not declared in this scope → 缺
+```
+
+在这份数据里一条都不存在。该节以「§1. 实测」为标题呈现,但它不是从这次测量读出来的。
+
+### 2.2 `-include` 与 `#ifdef _WIN32` 是正交的
+
+zlib 的失败信号(设计稿选作代表)来自 `gzguts.h`:
+
+```c
+49 #if defined(__TURBOC__) || defined(_MSC_VER) || defined(_WIN32)
+50 # include
+51 # include
+52 #endif
+```
+
+诊断是 `gzguts.h:50:12: fatal error: 'io.h' file not found`。第 50 行**在 `#if` 体内**,
+列 12 正是 `` 的起始列。因此该诊断只有在第 49 行为真时才可能产生,即
+**`_WIN32`(或 `_MSC_VER`/`__TURBOC__`)在那次编译里是定义着的**。
+
+设计稿 §1.1 对同一段代码的解读是「`_WIN32` 未定义,所以走 `#else` 分支,要求 `lseek`
+在作用域」。这里有两处与源码不符:这个 `#if` 块**没有 `#else`**(`#else` 在
+`gzlib.c` 的另一个 `#if` 里);而且如果 `_WIN32` 真的未定义,第 50 行不会被预处理,
+诊断不会出现在那一行。
+
+更根本的是:**`-include unistd.h` 无法使 `#include ` 成功**。它既不 undef
+`_WIN32`,也不产生 `io.h`。对上表 15 条里的 13 条(全部 `windows.h` / `_mingw.h` /
+`windef.h` / `io.h` 类),结论相同。
+
+### 2.3 §1.2 的 glibc/musl 对照表与本机实测相反
+
+设计稿 §1.2 的因果链条是「glibc 的 `` 传递包含 ``,musl 不包含,
+所以 zlib 在 Linux 上碰巧能编」。本机实测(glibc 2.39,见 §9 M3):
+
+```
+$ printf '#include \n' | clang -E -xc - | grep -c 'unistd\.h'
+0
+$ printf '#include \nlong f(void){ return lseek(0,0,0); }\n' | clang -fsyntax-only -std=c11 -xc -
+error: call to undeclared function 'lseek'
+```
+
+glibc 的 `` **不**传递包含 ``,只包含它之后 `lseek` **不**在作用域。
+§1.2 表格的 glibc 行为假,musl 行为真,而整节的论证依赖两行的差。
+
+---
+
+## 3. 前提核查:issue 与设计稿引用的历史都不成立
+
+### 3.1 `_WIN32` 为什么还定义着:那次测量的图里没有 `[c-abi]`
+
+本机实测两个三元组的预定义(§9 M1/M2):
+
+| 宏 | `--target=x86_64-pc-cygwin` | `--target=x86_64-w64-windows-gnu` |
+| --- | --- | --- |
+| `_WIN32` / `_WIN64` | 不定义 | **定义** |
+| `__MINGW32__` | 不定义 | **定义** |
+| `__CYGWIN__` | **定义** | 不定义 |
+| `__unix__` / `unix` | **定义** | 不定义 |
+| `__SIZEOF_LONG__` | 8 | 4 |
+| `__SIZEOF_WCHAR_T__` | 2 | 2 |
+
+即:**cygwin 化实现一旦生效,`_WIN32` 就必然不在**,`gzguts.h:50` 就必然到不了。
+既然它到了,那次编译就没有走 cygwin 化实现。
+
+原因写在 `mcpp-index` 自己的 `tests/openkal/pins.toml` 里:
+
+```toml
+runtime = "0.10.0"
+...
+# `runtime` does NOT move here: openkal-llvm-runtime 0.11.0 (the version that
+# actually declares/consumes `[c-abi]`) is not published yet
+# ... changes nothing observable today: no package this pin resolves declares `[c-abi]`.
+mcpp = "2026.9.18.3"
+```
+
+引擎抬到了 2026.9.18.3,**图没有抬**。`resolvedTargetSide.cAbiDecl` 为空,
+`prepare.cppm:10790` 那个 `if` 整块跳过,`cEnvTokens` 为空,命令行逐字节等于该特性
+存在之前。所以 Windows 腿测到的是「没有 `presents` 时会怎样」。
+
+与此同时,`mcpp-index` 的 draft 提交 700f7de 已经**先行撤掉**了包侧的适配。撤掉之前,
+`pkgs/c/compat.zlib.lua` 里是:
+
+```lua
+target_cfg = {
+ [] = { cflags = { "-U_WIN32", "-include", "unistd.h" } },
+},
+```
+
+撤掉之后只剩 `cflags = { "-include", "mcpp_zlib_config.h" }`,而该生成头是
+`#if !defined(_WIN32) → #define Z_HAVE_UNISTD_H 1`。于是在「包侧适配已撤、引擎侧实现
+未生效」的这个窗口里,zlib 在 Windows 上同时失去 `-U_WIN32` 与 `Z_HAVE_UNISTD_H`,
+恰好落回 `#include `。**这是一次发布链顺序造成的窗口,不是 trade-off 剩下的残差。**
+
+### 3.2 mcpp#673 做的不是「只压 `_WIN32`、不定义 `__unix__`」
+
+设计稿 §0.2 与 issue 的 Background 都把 mcpp#673 描述成这个 trade-off 的落地。核对
+CHANGELOG `[2026.9.18.3]` 与 7788d3e6 的改动面,#673 实际做了两件事:
+
+1. `cenv::realise` 对 `wchar = 32` **无条件**补 `-fno-short-wchar`(含 freestanding);
+2. `cenv_probe::verify` 新增 `hostStripMacros` 参数,Windows 宿主下传
+ `-U_WIN32 -U_WIN64 -U__MINGW32__ -U__MINGW64__`。
+
+两件事都与「是否定义 `__unix__`」无关。而且引擎**从来没有**选择「不定义 `__unix__`」:
+`cenv.cppm` 的 Windows + Posix 分支写的是
+
+```cpp
+r.expectDefined.push_back("__unix__");
+r.expectDefined.push_back("__CYGWIN__");
+r.expectUndefined.push_back("_WIN32");
+```
+
+`__unix__` 由 cygwin 三元组提供并被探针要求为**已定义**。设计稿与 issue 描述的那个
+trade-off 在代码里不存在。
+
+**附带发现(真实缺陷,与本方案无关,建议另开 issue)**:`hostStripMacros` 只进探针
+命令(`prepare.cppm:10923-10931`),**从不进 `p.privateBuild.cflags`**。也就是说在
+Windows 宿主上,如果 `--target=` 替换确实会漏出宿主的 `_WIN32`(#673 的 CHANGELOG 记录
+freestanding 上观察到了),那么探针读到的是剥离后的状态,而真实编译行读到的是未剥离的
+状态——**判据施加在了与被测对象不同的命令行上**。issue #674 把 hostStripMacros 列为
+「broadcast on the compile line」的一项,与实现不符。这一条在 Linux 宿主上测不出来
+(`mcpp::platform::is_windows` 为假,剥离集合为空),而 30-member 的 Windows 腿正是在
+Linux 宿主上跑的(`pins.toml` 的 `[runners] x86_64-windows-gnu = ["wine"]`)。
+
+### 3.3 「P3 spike 测过两种呈现并选了一种」没有发生
+
+issue 的 Historical context 引用 `openkal/.agents/docs/2026-09-18-c-environment-execution-plan.md` §4:
+
+> 「`__unix__` 带来新的失败超过收益:改为只不定义 `_WIN32`。」
+
+该句所在的小节标题是 **「## 4. 风险与退出」**,与它并列的是「Cygwin 目标的 TLS、异常展开、
+链接驱动不可用:停在 P4」。这是**计划写在实验之前的退出条件**,不是实验结论。
+
+实验结论的位置是 record 的「兼容性测量」,其正文是:
+
+> **兼容性测量。**(待填:新描述文件登记后,与 0.13 基线 Linux 27/3、Windows 15/15 的
+> 对比。……)
+
+即**两种呈现的 A/B 从未完成**。design §11 的 review 决定第 4 条「是否定义 `__unix__` 由
+实验数据决定(§8 第 4 步)」同样悬着。把一条未执行的退出条件读成已完成的测量结论,
+会让后续所有「在这个 trade-off 下补另一半」的论证失去依据。
+
+### 3.4 数目不对
+
+issue 写「Windows side went from `0/30` (0.13) to `12/30`」。checked-in 的 0.13 基线是
+**15 runs / 15 fails**(record §4 亦写「Windows 15/15」),不是 0/30。
+
+issue 列出的 18 个名字 = 基线 15 个失败 + `gzip-hpp`、`tinyhttps`、`zlib`。这三个在基线里
+**都是 `runs`**。按 issue 自己的清单算,这次变化是**净失去 3 个**,而不是「净得到 12 个」。
+失去的恰好是包侧适配被 700f7de 撤掉、而引擎侧替代未生效的那几个(zlib 直接,
+gzip-hpp 经由 zlib,tinyhttps 在同一提交里换到 0.3.1)。
+
+### 3.5 Path A 引用的机制不存在
+
+issue 的 Path A 写「mcpplibs/mcpp-index handles each via the
+`[target.cfg(os = "windows").patches]` mechanism (one patch per package, ~10-line patch file)」。
+
+mcpp 的清单里**没有 `patches` 键**(`src/manifest/`、`docs/*.md` 全仓无此机制)。
+mcpp-index 里真实存在并且已经在用的是 `target_cfg` 下的 `cflags` / `defines` /
+`generated_files`——即 §3.1 引的那种形状。设计稿 §2.3 的对照表按「18 个 PR × ~10 行
+patch 文件」给 Path A 估成本,这个成本估计的基础是一个不存在的机制。
+
+---
+
+## 4. 即便前提成立,实现也会炸
+
+下列四条各自独立,均在本机实测复现(§9)。
+
+### 4.1 C++ 模块接口单元,包括 `std` 模块
+
+`cEnvTokens` 进的是 `p.privateBuild.cxxflags`(`prepare.cppm:11164`),并且在
+`prepare.cppm:12380-12381` 单独再进一次 `std` 模块的编译行。`docs/22-target-side.md`
+明写这条广播覆盖「C, C++ and assembly compiles, the dependency scan, and the `std`
+module precompile alike」。
+
+模块接口单元必须以 `module;` 或 `export module` 开头。`-include` 把头文件的内容放在
+主文件之前,于是:
+
+```
+$ clang++ -std=c++23 -include unistd.h --precompile m.cppm -o m.pcm
+m.cppm:1:8: error: module declaration must occur at the start of the translation unit
+```
+
+带全局模块片段的形状(正是 libc++ `std.cppm` 的形状:注释、`module;`、`#include`、
+`export module std;`)同样失败:
+
+```
+std_shape.cppm:4:1: error: 'module;' introducing a global module fragment can appear only
+ at the start of the translation unit
+std_shape.cppm:8:8: error: module declaration must occur at the start of the translation unit
+```
+
+同一份文件去掉 `-include` 即通过。**后果:`import std` 在 Windows × `presents = "posix"`
+上直接不可用,图中任何 `.cppm` 亦然。** 这一格恰好是 openkal C++ 侧的旗舰路径
+(`openkal-llvm-runtime` + `examples/cxx`)。
+
+设计稿 §4.1 的影响面表格把这一格写成「加 `--target=...` + `-include unistd.h` +
+`-include sys/stat.h`」,没有区分该格里的 `.c`、`.cpp`、`.cppm`、`.S` 四类单元。
+
+### 4.2 `appendUniqueFlags` 的按串去重
+
+广播走的是 `prepare.cppm:6380-6391`:
+
+```cpp
+for (auto const& f : additions) {
+ if (std::find(flags.begin(), flags.end(), f) != flags.end()) continue;
+ flags.push_back(f);
+}
+```
+
+按**整串**判重。设计稿产出的 tokens 序列是
+`[..., "-include", "unistd.h", "-include", "sys/stat.h", ...]`,第二个 `"-include"`
+与第一个相等,被跳过。落到命令行上是:
+
+```
+--target=x86_64-pc-cygwin -include unistd.h sys/stat.h
+```
+
+`sys/stat.h` 成为一个位置参数:
+
+```
+$ clang -c -include unistd.h sys/stat.h t.c -o t.o
+clang: error: no such file or directory: 'sys/stat.h'
+```
+
+如果某个包自己的 `cflags` 里已有裸 `-include`(`compat.zlib` 正是
+`cflags = { "-include", "mcpp_zlib_config.h" }`),广播的 `-include` 会被**整个**去掉,
+两个头文件名都变成位置参数。
+
+单字符令牌形式可以规避(`-includeunistd.h` 与 `--include=unistd.h` 本机均被接受),
+但设计稿没有采用,也没有记录这个约束。
+
+### 4.3 汇编广播:GAS 敌对令牌
+
+`prepare.cppm:11166` 把同一批 tokens 送进 `p.privateBuild.asmflags`,并且该处的注释
+写下了一条规则:
+
+> Every token in `cEnvTokens`/`cEnvBuiltinsTokens` was checked against clang's GAS
+> (`-x assembler-with-cpp`) front end before this was written ... **if a future token
+> IS GAS-hostile, `cenv::realise` is where to split it, not this broadcast.**
+
+`-include unistd.h` 正是 GAS 敌对的:
+
+```
+$ clang -c -include unistd.h a.S -o a2.o
+/usr/include/unistd.h:27:1: error: invalid instruction mnemonic '__begin_decls'
+/usr/include/x86_64-linux-gnu/bits/types.h:31:18: error: unexpected token in argument list
+...
+```
+
+musl 的 `unistd.h` 同样没有 `__ASSEMBLER__` 守卫。设计稿新增了一个满足该规则触发条件的
+令牌,但没有按规则在 `realise` 里拆分——这是对被修改文件自己写下的约束的直接违反。
+本轮生态里 `.S` 不是边缘情况:openkal-musl 的 `okm_setjmp.S`、vendored libunwind 的
+`assembly.h` 都在这条通道上,record §5 里「`[c-abi]` 的实现不作用于汇编源」正是上一轮
+专门补的缺陷。
+
+### 4.4 校验探针:没有头文件搜索路径
+
+探针命令(`cenv_probe.cppm`)是
+
+```
+ -x c++ -E -dM -
+```
+
+`argv` 里有 `crossTargetFlag` 与 `cEnvTokens`,**没有任何 `-I`、没有 `--sysroot`、
+没有 `-nostdlibinc`**——注释明写这是「the IDENTITY-AFFECTING SUBSET」,因为 include path
+不改变预定义宏。`-include` 破坏这个不变量:它让探针的成败取决于头文件能否被找到。
+
+本机(Linux 宿主)实测:
+
+```
+$ clang --target=x86_64-pc-cygwin -include unistd.h -include sys/stat.h -x c++ -E -dM - tokens, "-include"));
+ASSERT_TRUE(has(r->tokens, "unistd.h"));
+ASSERT_TRUE(has(r->tokens, "sys/stat.h"));
+```
+
+它断言的是 `realise` 返回的 vector 的成员资格。§4.2 的去重、§4.1 的模块单元、§4.3 的
+汇编单元、§4.4 的探针,四处失败全部发生在这个 vector **之后**。该测试在四处缺陷全部
+存在时依然全绿。这正是「判据通过了但什么都没测到」的形状:谓词对、对象错。
+
+能区分的判据只有一条:**一个带 `.cppm`、`.S` 与 `import std` 的 e2e 工程,在
+Windows × `presents = "posix"` 上真的构建一次。** 设计稿 §3.3 的 E1 把这件事交给
+30-member 测量,而 30-member 里没有一个成员用 C++20 模块(`compat.*` 全是传统 C/C++ 库),
+所以即使跑了也测不到 §4.1。
+
+---
+
+## 5. 规范符合性
+
+### 5.1 `presents` 的定义
+
+`docs/22-target-side.md:323` 的四键表:
+
+| Key | Values | Answers |
+| --- | --- | --- |
+| `presents` | `posix` / `windows` / `none` | **which environment-identity macros source sees** (`__unix__` vs `_WIN32` vs neither) |
+
+openkal 设计 §3.2 的取值表同样:
+
+| 值 | 含义 |
+| --- | --- |
+| `posix` | 定义 `__unix__`,不定义 `_WIN32` 与 `__MINGW32__` |
+
+两处都把 `presents` 定义为**身份宏**,且两处都紧接着写「三个键分属两类事实,互不推导」。
+
+设计稿 §0.2 与 §2.1 把它重述为:
+
+> 「`presents = "posix"` 是 mcpp 引擎对编译命令行的契约承诺,POSIX 标准规定哪些符号在
+> 哪些头里,引擎要把这些头显式拉进每个 TU。」
+
+这是把一个已发布字段的语义**扩宽**到原文没有的范围。`presents` 回答「源码走哪条分支」,
+不回答「哪些符号已在作用域」。设计稿 §2.4 自己也写下了正确的那句——「`presents = "posix"`
+是『POSIX 接口按需可达』,不是『所有 POSIX 接口已被强制 include』」——但 §3.1 的实现
+正是后者的一个子集。同一份文档里两个互相否定的语义定义。
+
+一个字段的语义扩宽,代价不在实现,而在它给下一个读者的授权:此后任何「某个 POSIX 符号
+在某个包里不可见」都成了引擎的义务,而 §2.4 想守住的那条线没有任何机制在执行它。
+
+### 5.2 openkal 设计已经为这类失败指定了答案
+
+§5「平台相关代码」是 §2.1 那 13 条 `windows.h` 类失败的 designated 归属:
+
+- §5.3 包内 feature:`tinyhttps` 就是设计稿自己举的例(`posix-socket` / `winsock`),
+ 而 `tinyhttps 0.3.1` 在 700f7de 里正是「selects POSIX sockets on Windows with musl」;
+- §5.3 独立 shim 包:多个包需要同一段平台代码时;
+- §6 / §7:`platform` 标签与 `platform-dependencies = "refuse"`,让「这个包需要平台接口」
+ 成为**被测量、被展示**的事实,而不是一次失败。
+
+§9「备选与不做」里与本方案最近的一条:
+
+| 备选 | 结论 |
+| --- | --- |
+| 在 musl 上仿真 `windows.h` | **不做**。永远做不完,且是 SPEC §3.1 拒绝的「模拟」 |
+| 维持现状,逐包按 `c-abi` 适配 | **保留为退路** |
+
+引擎侧无条件预置 POSIX 头,与「在 musl 上仿真 windows.h」的性质同源:都是由中间层替
+上游代码决定它该看见什么,都没有终点判据(设计稿 §3.3 的 E2「每发现一个新的未声明
+标识符就追加一个头」把这一点写成了流程)。
+
+### 5.3 「通用 POSIX 知识,非包名」这条辩护为何不足
+
+设计稿 §2.2 用 `cenv.cppm:10-14` 的「GENERIC KNOWLEDGE, NO PACKAGE NAMES」为 `-include`
+辩护:POSIX 标准头是契约不是包名。
+
+这条规则约束的是**不得按 C 库的身份分支**,它不授权「凡 POSIX 规定的都可以由引擎替
+每个 TU 决定」。同一段注释紧接着写的是「a second POSIX C library on Windows would
+realise through the same table」——它要求的是**可替换性**,而 §4.4 表明加入 `-include`
+之后,realisation 对「这个 C 库是否在这个搜索路径里提供了这个头」产生了依赖,恰好削弱
+了可替换性。
+
+### 5.4 包私有作用域是一条被明写依赖的性质
+
+`compat.zlib.lua` 现在的注释里有一段值得完整引用:
+
+> This `cflags` is package-private: it reaches zlib's own translation units
+> (gzlib.c, zutil.c, ...) but not a consumer compiling zlib.h ... So the library
+> computes z_off_t with Z_HAVE_UNISTD_H set (= off_t) while an openkal-Windows
+> consumer ... computes it with Z_HAVE_UNISTD_H unset (= long long) ...
+
+即索引维护者**明确依赖**「包侧 `cflags` 不外溢到消费者」这条性质,并围绕它写了运行期
+断言。引擎广播没有这条性质(它进每个包的 `privateBuild`)。把强制包含从包侧提升到
+引擎侧,等于把一个被明写依赖的作用域边界抹掉;即便对 `unistd.h` 这一个头恰好无害,
+§3.3 的 E2 迭代规则会持续往这条通道里加头。
+
+---
+
+## 6. 稳定性与兼容性影响面
+
+| 维度 | 设计稿的判断 | 核查结果 |
+| --- | --- | --- |
+| 影响面收在 Windows × x86_64 × posix 一格 | 是(§4.1 表) | **成立,但该格内部未分层**:`.c` / `.cpp` / `.cppm` / `.S` / 探针 五类对象行为不同,其中三类会失败 |
+| 链接行不变、运行时零开销 | 是 | 成立 |
+| 其他组合命令行一字不改 | 是 | 成立 |
+| 不改任何已写下的设计决策 | 是(§0.5) | **不成立**:扩宽了 `presents` 的语义(§5.1),违反了 `prepare.cppm:11148` 写下的 GAS 拆分规则(§4.3) |
+| 改动量「一行类 + 注释」 | 是(§3.1) | **低估**:要正确落地必须同时改 `Realisation` 的字段划分、asm 广播、探针 argv 构造、去重逻辑四处 |
+| 上游修好后「无害但白 include」 | 是(§2.3 表) | 对 `.c` 成立;对 `.cppm` / `.S` 不是白 include 而是硬失败 |
+
+**可回退性**:作为一次发布,它会移动全部 Windows × posix 工程的输出目录(§4.5);撤销
+时再移动一次。两次移动都发生在用户侧。
+
+---
+
+## 7. 生态发展视角:真正的下一步
+
+按依赖顺序,与本方案无关的三步先做:
+
+1. **完成发布链。** `openkal-musl 0.15.0` 与 `openkal-llvm-runtime 0.11.0` merge + tag +
+ GitCode 镜像 + xim-pkgindex 登记(openkal 自审 §11 已把这两条列为「用户拍板」的
+ 未校验项,record §4 的沙箱 B–E 四段 NOT-RUN 也是同一个根因)。
+2. **抬 `tests/openkal/pins.toml` 的 `runtime`。** 在 `runtime = "0.10.0"` 下,
+ Windows 腿测的不是 `presents = "posix"`。这一步之前的任何 Windows 数字都不能用来
+ 给引擎决策。
+3. **重跑 30-member,并且真的做那次 A/B。** design §11 决定 4 与 record §4 的「(待填)」
+ 指的是同一件事:`__unix__` 定义与否的两种呈现各跑一次。这是 #674 讨论的四条路径
+ (尤其 B/D)唯一的判据来源,今天它还不存在。
+
+第 3 步跑完之后,残余失败按 openkal 设计 §5 逐包归位:
+
+- 需要 `windows.h` 一族的(c-ares、catch2、cli11、doctest、libpng、mimalloc、re2、
+ sqlite3、spdlog、archive/xz、curl)→ §5.3 的包内 feature 或图提供的平台 SDK 依赖,
+ 并在索引里落 `platform` 标签。这**不是失败**,是 §7 标签体系要表达的事实;
+- clang 自带头的两个(eigen 的 `mm_malloc.h`、fmt 的 `intrin.h`)→ 已由
+ `builtins = "iso"` 与 `-nostdlibinc`(#662/#664)覆盖,重测确认即可;
+- 真正「缺 POSIX 头」的(如果重测后还剩)→ 包侧 `target_cfg.cflags` 的 `-include`,
+ 即 zlib 已经用过并且已经因为不再需要而撤掉的那个机制。
+
+**给 #674 的回复建议**:Path A 是正确的归属,但成本估计应更正(机制是 `target_cfg`
+不是 `patches`,而且多数包属于 §5 的 `platform` 标签而非需要 patch);Path B/D 需要
+先补那次从未做过的 A/B;Path C 因 §2 与 §4 不建议。issue 正文里的三处事实(0/30 基线、
+hostStripMacros 在编译行上、P3 spike 已测两种呈现)建议一并更正,否则它们会作为前提
+被下一份设计继承——这次已经发生了一遍。
+
+---
+
+## 8. 如果仍要在引擎侧做强制包含,唯一可能安全的形状
+
+这一节不是推荐,是在用户拍板要做的情况下把约束列全。
+
+1. **不走 `Realisation::tokens`。** 新增独立字段(如 `preIncludeTokens`),因为它必须
+ 从三个消费点里被排除:探针 argv(`prepare.cppm:10923`)、asm 广播
+ (`prepare.cppm:11166`)、以及任何模块接口单元的编译行。
+2. **模块接口单元必须排除。** 这需要在 `build_program` / `flags` 层按单元类型分流,
+ 而不是在包级 `cxxflags` 上。今天没有这个分流点,新增它是本方案的主要工作量。
+ `std` 模块(`prepare.cppm:12380`)要单独排除。
+3. **单令牌拼法。** `-includeunistd.h` 或 `--include=unistd.h`,规避
+ `appendUniqueFlags` 的按串去重;同时在 `appendUniqueFlags` 上加一条「带参令牌不参与
+ 去重」的规则,否则下一个同形令牌会再犯一次。
+4. **绝对路径而非裸名。** 裸名依赖搜索路径,在 `-nostdlibinc` 之下由图提供的 C 库满足,
+ 在探针里由宿主满足——两者不同(§4.4)。若保留裸名,必须同时保证探针不带这些令牌。
+5. **判据必须是一个 e2e 工程**,含 `.cppm`、`.S`、`import std`,在
+ Windows × `presents = "posix"` 上构建并运行;单测断言 vector 成员资格不能作为判据
+ (§4.6)。
+6. **终点规则要可执行。** §3.3 的 E2「按测量迭代追加」没有终止条件,也没有拒绝条件。
+ 若无法写出「什么时候不再追加」的判据,这条规则保护不了 §2.4 想守的那条线。
+
+---
+
+## 9. 复现
+
+全部命令在本机(Linux x86_64,`~/.xlings/subos/current/bin/clang`,clang 22.1.0)执行。
+
+```sh
+# M1/M2 —— 两个三元组的预定义
+clang --target=x86_64-pc-cygwin -x c -E -dM - \n' > s.c
+clang -E s.c | grep -c 'unistd\.h' # 0
+printf '#include \nlong f(void){return lseek(0,0,0);}\n' > s2.c
+clang -fsyntax-only -std=c11 s2.c # error: call to undeclared function 'lseek'
+
+# M4 —— 模块接口单元
+printf 'export module demo;\nexport int f(){return 42;}\n' > m.cppm
+clang++ -std=c++23 -include unistd.h --precompile m.cppm -o m.pcm
+# error: module declaration must occur at the start of the translation unit
+
+# M5 —— libc++ std.cppm 的形状(注释 + module; + #include + export module)
+printf '// gen\n\nmodule;\n\n#include \n\nexport module stdlike;\n' > std_shape.cppm
+clang++ -std=c++23 --precompile std_shape.cppm -o /dev/null # OK
+clang++ -std=c++23 -include unistd.h --precompile std_shape.cppm -o /dev/null
+# error: 'module;' introducing a global module fragment can appear only at the start ...
+
+# M6 —— GAS
+printf '.text\n.globl foo\nfoo:\n ret\n' > a.S
+clang -c a.S -o a.o # OK
+clang -c -include unistd.h a.S -o a2.o
+# /usr/include/unistd.h:27:1: error: invalid instruction mnemonic '__begin_decls'
+
+# M7 —— appendUniqueFlags 去重之后的命令行
+printf 'int main(){return 0;}\n' > t.c
+clang -c -include unistd.h sys/stat.h t.c -o t.o
+# clang: error: no such file or directory: 'sys/stat.h'
+
+# M8 —— 探针形状
+clang --target=x86_64-pc-cygwin -include unistd.h -x c++ -E - `)在本机实测下不成立:glibc 的 `` 同样不传递包含它。
+
+### 1.2 三时刻表是分类器
+
+SPEC §6.2:
+
+> Information therefore becomes available at three times, **each being the earliest
+> at which it exists**.
+>
+> | Time | Mechanism | Question answered |
+> | --- | --- | --- |
+> | dependency resolution | the implementation package declares what it provides | may this program be built against this implementation |
+> | link | an undefined symbol | was an interface used that the implementation does not provide |
+> | run | a capability word | how does this implementation behave within an interface it provides |
+
+一个库问"这个能力在不在",按这张表,答案最早存在于**依赖解析**。而 `#ifdef` 发生在
+**预处理**——比解析更早。
+
+> **上游库不是问错了问题,是在一个比答案存在得更早的时刻问了它。**
+
+这一句解释了为什么压 `_WIN32`、给 `__unix__`、留 `__CYGWIN__`、给 `__linux__`、
+预置 `unistd.h` 这五个修法各自都能修好一批、又各自都会被下一个环境证伪:**它们都在
+填一个规范模型里不存在的格子。**
+
+### 1.3 这条判据不依赖测量
+
+它不需要知道上游库怎么写,也不需要 30-member 的数字。任何新提案先问一句
+"它在哪个时刻回答问题",落在三时刻之外的就不必再往下看。
+
+---
+
+## 2. 五条设计事实
+
+推导的地基,全部出自 openkal SPEC 0.14。
+
+| # | 出处 | 事实 |
+| --- | --- | --- |
+| **F1** | §6.1 | 不提供的接口在**链接期缺席**。"A conforming implementation shall not provide an interface whose operations report a lack of support at run time; the specification treats **run-time refusal as a defect** and not as a means of expressing partiality." |
+| **F2** | §3.3 | 规范**不命名环境类别**。命名过的 `hosted` 已撤回:"a name that describes a class of environment **is falsified by an environment nobody had in mind**. This one was falsified **inside its own ecosystem within a release**." 替代做法是消费者**逐条列举**,"which is where a convention among consumers belongs"。 |
+| **F3** | §6.2 | 三个时刻,每个是该信息**最早能存在**的时刻(表见 §1.2)。 |
+| **F4** | §6.5 | 由"产物如何生产"决定的可用性,**在依赖解析时**给或不给,用包的 feature 表达。"a path no artifact takes is **a path nothing has verified**"。 |
+| **F5** | §7.1 / §5.2 | 实现不得需要兼容层,判据机械:"an implementation that must maintain a translation table, a registry, or **a name resolver** ... indicates that **the specification has taken a shape borrowed from one environment**, and the shape is at fault"。§5.2 进一步划线:"Mapping is not simulation ... whereas **reproducing a foreign namespace of names, descriptors or paths does not**"。 |
+
+**F5 的适用范围要读准**:它约束 **openkal 的实现**。消费者(C 库、C++ 运行时)不受它
+约束——`openkal-musl` 的 `okm_fd.c` 正是在消费者一侧复制描述符与路径命名空间,并在
+文件头写明理由。这条分工是本文 §5.4 方案成立的前提。
+
+---
+
+## 3. 顶层架构:四层 × 三时刻
+
+### 3.1 层(已落地)
+
+| 层 | 供给者 | 保证 |
+| --- | --- | --- |
+| `kernel-abi = openkal` | 规范 + 每目标一个实现 | 每个 `kal_*` 操作在每个平台行为相同 |
+| `c-abi` | `openkal-musl` / 将来的 `openkal-win-ucrt` / `openkal-picolibc` | 一种 C 环境形态;做不到的明确拒绝并列出 |
+| `c++-abi` | `openkal-llvm-runtime` | 为那个 C 库配置过的 C++ 运行时 |
+| 构建工具 | `mcpp` | 图供给的层由图整个供给;不搜索宿主头 |
+
+**形态由程序选择,方式是依赖哪个入口包;库不得选择形态**(openkal design §3.1)。
+
+### 3.2 时刻(写在 SPEC 里,尚未被当作架构使用)
+
+见 §1.2 的表。
+
+### 3.3 交叉表:每一格由谁负责
+
+| | 依赖解析 | 链接 | 运行 |
+| --- | --- | --- | --- |
+| **kernel-abi** | 实现包 `provides` 哪些接口;**消费者 `requires` 哪些接口(空)** | 未定义符号 | `kal__props` 能力字 |
+| **c-abi** | 选哪个形态(`openkal-musl` / `win-ucrt` / `picolibc`);`[c-abi]` 声明被探针校验 | 符号缺席 | **POSIX 命名空间的合成(空)** |
+| **c++-abi** | 与 C 库配套 | 符号缺席 | — |
+| **构建工具** | 层解析、平台 SDK 依赖、`refuse` 开关 | — | — |
+
+### 3.4 今天空着的两格
+
+- **解析期 × kernel-abi**:SPEC §3.3 明说消费者应逐条列举所需接口,"in its own
+ package",但 mcpp 的清单里没有承载它的字段。于是"这个能力在不在"无处可问,
+ 全部被挤到预处理期。**#674 的压力来自这一格。**
+- **运行期 × c-abi**:POSIX 有而 openkal 没有的命名空间(`/dev/null`、`/dev/urandom`、
+ `/proc/self/*`、`/tmp`)。写得完全正确的 POSIX 代码在 openkal 上会坏,且没有任何
+ 一层负责。
+
+其余各格都已有机制。**本文提出的两个新东西,恰好各补一格。**
+
+而"声明被校验"这条原则在**接口一级**横跨全部三个时刻,本文在 §5.7 单独处理:
+它不是第三个新东西,是把已有的三件材料(`SURFACE.txt`、SPEC §9.2 的静态比对、
+`kal_interfaces()`)接成一条阶梯。
+
+---
+
+## 4. 方案空间
+
+### 4.1 六个方案与它们的时刻
+
+| | 方案 | 层 | 时刻 | 在三时刻表内 |
+| --- | --- | --- | --- | --- |
+| A | 逐包适配(`target_cfg` / `generated_files`) | 索引描述符 | 编译期,包自己的构建配置 | 合法(不是能力问答) |
+| B | 引擎预置 POSIX 头(#674 Path C) | mcpp 引擎 | **预处理期** | **否** |
+| C | `presents = "linux"` 形态 | C 库声明 | **预处理期** | **否** |
+| D | `openkal-win-ucrt` | 新形态包 | 依赖解析 | 是 |
+| E | 合成节点层(`openkal-posix`) | C 库 port 层 | 运行 | 是 |
+| F | 解析期逐条列举 | 清单 + 引擎 | 依赖解析 | 是 |
+
+### 4.2 综合对比
+
+| | A | B | C | D | E | F |
+| --- | --- | --- | --- | --- | --- | --- |
+| 引擎改动 | 0 | 1 处(连带炸 4 处) | 1 处 | **0(已验证)** | 0 | 新字段 + 解析规则 |
+| 覆盖面 | 一个包 | 全图 | 全图 | 整个程序 | 全图,路径类 | 全图,能力类 |
+| 上界 | 无,逐个加 | **无终点** | 无,类别名会被证伪 | 明确 | **openkal 接口集** | 十六个接口名 |
+| 失败模式 | 编译期,指名 | `.cppm` / `.S` 硬失败 | 静默选错分支 | 解析期拒绝 | `ENOENT`,调用方可辨 | 解析期拒绝 |
+| 可撤销 | 一行 | 移动全部输出目录 | 破坏性 | 换依赖 | 改合成表 | 改清单 |
+| 可验证 | 30-member | 单测空转 | 探针量宏不量能力 | realise 空令牌集 | conformance 逐节点断言 | 解析期断言 |
+| 与设计事实 | 符合 | **F3 表外** | **F2 撤回过类别名** | §3.1 形态第三行 | 延续 `okm_fd.c` | §3.3 明文 |
+
+### 4.3 B 与 C 为什么必然不稳定
+
+- **B**:`-include` 经 `cEnvTokens` 广播进 `cflags` / `cxxflags` / `asmflags` / std 模块 /
+ 探针 argv。实测四处失败:模块接口单元 ill-formed(`import std` 不可用)、GAS 报
+ `invalid instruction mnemonic`、`appendUniqueFlags` 按串去重把第二个 `-include` 吃掉
+ 使 `sys/stat.h` 变成位置参数、探针无 include path。且它修不了 §1.1 里任何一条诊断。
+- **C**:`posix` / `linux` / `windows` 都是**环境类别名**。F2 记录了同类名字
+ (`hosted`)如何在一个发布周期内被自己的生态证伪。`presents = "posix"` 今天已经
+ 被证伪一次——openkal-Windows 呈现 POSIX,却没有 `fork`、没有 `/proc`、没有 epoll。
+ 再加一个值只会让同一个证伪重演。
+
+### 4.4 失败的归类
+
+| 失败类别 | 例子 | 归属 |
+| --- | --- | --- |
+| `_WIN32` / `__MINGW32__` 选错分支 | asio、eigen、catch2、re2、CLI11、lua、spdlog | 已解决,等抬 pins |
+| 借来的 `__CYGWIN__` 被当 Win32 用 | mimalloc、sqlite3 | P3(撤掉借用) |
+| 需要 Win32 API 本身 | c-ares、curl | D 或 F |
+| 需要 Linux 内核节点 | libarchive 的 `linux/fs.h` | F,**且拒绝是正确答案** |
+| 写 POSIX 但用到 kal 没有的命名空间 | `/dev/null`、`/dev/urandom`、`/tmp` | **E** |
+| 包自己的构建配置 | zlib 的 `Z_HAVE_UNISTD_H` | A,永远归 A |
+
+**A / D / F 处理的都是"代码本来就不可移植";只有 E 处理的是"代码本来就对,是我们缺"。**
+
+---
+
+## 5. 采纳的方案
+
+### 5.0 P0 —— 两处实现缺陷(先修,独立)
+
+**P0.1 freestanding 的 c-abi 探针不带 `--target`,它量的是宿主。**
+
+`prepare.cppm:3989` 对 freestanding 目标**不设** `crossTargetFlag`(注释:"for a HOSTED
+target only. Freestanding already emits its own `--target`"),而 freestanding 的
+`--target` 在 `mcpp::freestanding::compile_prefix()`(`linkline.cppm:48`),探针不问它;
+`cenv::realise` 对 freestanding 也只产 `-D__unix__` 与 wchar 令牌。于是探针 argv 是
+`clang -D__unix__ -fno-short-wchar -ffreestanding -x c++ -E -dM -`——**没有任何 `--target`**。
+
+实测(本机 Linux):该命令输出 `__linux__ 1`,即它量的是宿主。三条佐证:
+`--target=riscv64-none-elf` 时 `__linux__` 计数为 0(预定义随目标不随宿主)、
+该三元组的 `__SIZEOF_WCHAR_T__` 是 4 而不是 2、`--target=x86_64-w64-windows-gnu`
+在 Linux 上定义 `_WIN32`。
+
+2026.9.18.3 的 CHANGELOG 把症状归因为"Windows 主机的 clang 即使带上 `--target=`
+仍注入 `_WIN32`",该归因不成立;两条"不匹配"由同一个原因产生。
+
+**修法**:探针 argv 在 freestanding 时取 `freestanding::compile_prefix()` 的
+`--target` 与 ISA 标志,随后 `hostStripMacros` 整个撤除。
+**判据**:Linux 宿主上 `riscv64-none-elf` 的探针 dump 必须含 `__riscv`、**不含**
+`__linux__`。今天这条会红。
+
+**P0.2 `builtins` 的 Windows 行注释与实测不符。** `cenv.cppm` 称 clang 自带
+`intrin.h` / `mm_malloc.h` 的问题"already closed by the EXISTING `-nostdlibinc`
+isolation"。实测不成立:`-nostdlibinc` 不关 clang 的 resource dir(那是
+`-nobuiltininc`),带着它仍复现 `intrin.h:12:15` 这条与 fmtlib 记录逐字符相同的
+诊断。真正关掉它们的是 `_WIN32` / `__MINGW32__` 消失。结论不变,机制写错,改注释。
+
+### 5.1 P1 —— 冻结 `presents` 的语义与取值集
+
+**动机**:`presents` 是这套设计里唯一一个类别名,而 F2 刚撤回过类别名。它今天
+站得住,因为它陈述的是**可被探针逐条测量的身份事实**,不是需求集合的简称——但这个
+差别没有写在任何文档里,于是下一个读者会提议给它加值(本轮的 C 方案正是如此)。
+
+**形状**:在 `docs/22-target-side.md` 的四键表下加一段:
+
+> `presents` 的取值集**冻结**为 `posix` / `windows` / `none`。它回答的是**源码看到
+> 哪些环境身份宏**,不回答任何能力是否存在。一个包不得由 `presents` 推断某个接口、
+> 某个头或某个路径是否可用;那些问题由 §5.5 的解析期列举回答。取值集不增长,
+> 理由与 SPEC §3.2 "the core set does not grow" 相同:一个错误地扩大的名字,是由
+> 后来写实现的人发现的,不是由规范发现的。
+
+**影响**:零命令行改动,纯文档。
+
+### 5.2 P2 —— `fails` 拆出 `refused`
+
+**动机**:强依赖 Linux 内核节点或 Win32 API 的库,在不提供它们的图上构建失败,是
+F1 要的形状。测量管线今天只有一种失败读法,于是"兼容率"被当成引擎的分数,进而
+持续产生把引擎推向表外格子的压力(#674 即是)。
+
+**形状**:`tests/openkal/compat.py` 的 `status` 增加一个值。
+
+| status | 含义 | 计入失败率 |
+| --- | --- | --- |
+| `runs` | 测试通过 | — |
+| `builds` | 构建成功,测试未跑或未过 | — |
+| `fails` | **本该能构建而没能** | 是 |
+| `refused` | **这个包要的能力这个图不提供,拒绝是正确结果** | 否 |
+
+`refused` 的判据不能是"诊断里出现了某个字符串",而是**包在清单里声明了图不满足的
+要求**(§5.5 的机制),或描述符里显式标注。没有声明的一律仍是 `fails`。
+
+**影响**:`openkal_kind` facet 与站点展示同步;`docs/openkal-compat.md` 增加一节
+说明"一个 `refused` 是正确答案"。
+
+### 5.3 P3 —— 撤掉 `__CYGWIN__` 借用
+
+**动机**:保留 `__CYGWIN__` 的理由是"第三方可移植代码需要一个名字指 PE 格式 +
+POSIX C 环境"。F5 的判据是机械的:借用某个环境的形状,**错的是那个形状**。实证支持
+这条判定——mimalloc 的守卫写着:
+
+```c
+#if defined(_WIN32) || defined(__CYGWIN__) // we use windows locks on cygwin, but otherwise treat it at unix
+```
+
+上游用这个名字表达的是"**Win32 可用**",不是"对象格式是 PE"。sqlite3 同形
+(`__CYGWIN__` 在 `SQLITE_OS_WIN` 的检测列表里,随后 `#include "windows.h"`,
+并且 `#ifdef __CYGWIN__ → #include `,正是 record §6 点名的风险)。
+
+**一个借来的名字,它的语义由借出方的历史决定,不由我们的意图决定。**
+
+**形状**:`cenv.cppm` 的 Windows + Posix 分支恢复 §3.3 原始文本的
+`-U__CYGWIN__ -U__CYGWIN32__`,`expectUndefined` 相应增加两项。
+
+**"对象格式是 PE"这一维怎么办**:默认**不给宏**。包在清单里问
+`cfg(os = "windows")`,那本来就是它该问的地方,而且不需要任何宏。若重测显示确有
+第三方代码只能在预处理期问这件事,回落方案是由 mcpp 定义一个**自己的**名字
+(例如 `__mcpp_format_pe__`),而不是继续借用别人的。
+
+**风险**:libarchive 的生成配置头已含 `#if defined(_WIN32) && !defined(__CYGWIN__)`,
+撤销会改变它的分支。这条必须由重测确认,不能先落地。
+
+### 5.4 P4 —— 合成节点层
+
+**动机**:写得完全正确的 POSIX 代码——`open("/dev/urandom")`、`fopen("/dev/null","w")`、
+`/dev/fd/N`、`/tmp`——在 openkal-Windows 上会坏,而没有任何一层负责。这是唯一一类
+"代码本来就对"的失败。
+
+**它不是新层。** `openkal-musl` 的 `port/src/okm_fd.c` 文件头已经写明:
+
+> **Two things POSIX has and openkal does not are built here**, and each is built
+> **once for every environment rather than once per environment**.
+> ... A **name** in POSIX is resolved against a single global root. openkal has no
+> global root ... **one rule, in one place, rather than the same rule in every program.**
+
+已经造了两样(描述符表、单一全局根的名字解析)。合成节点是第三样,判据同源。
+
+**判据(写死,不迭代扩张)**:
+
+> 一个名字可以被合成,当且仅当它同时满足两条:
+> **(a) POSIX 有而 openkal 没有**;**(b) openkal 能回答它**。
+> 两条不同时满足的,一律回答 `kal_node_absent` / `ENOENT`。
+
+**上界由 openkal 接口集自动给出**,不靠纪律。因此它在结构上不可能"越做越像 Linux",
+这与 design §9 拒绝的"在 musl 上仿真 `windows.h`(永远做不完)"性质不同。
+
+| 名字 | (a) | (b) | 合成 |
+| --- | --- | --- | --- |
+| `/dev/null` `/dev/zero` | 是 | 是(不需要接口) | 是 |
+| `/dev/urandom` `/dev/random` | 是 | 是,`openkal.random` | 是 |
+| `/dev/stdin` `/dev/stdout` `/dev/stderr` `/dev/fd/N` | 是 | 是,描述符表已在 | 是 |
+| `/proc/self/exe` `/cmdline` `/environ` | 是 | 是,`openkal.env` / `openkal.process` | 是 |
+| `/tmp` | 是 | 是,由 supplied directories 给 | 是 |
+| `/proc/cpuinfo` `/proc/meminfo` | 是 | **否** | 否 |
+| `/sys/**` `/proc/net/**` | 是 | **否** | 否 |
+
+**失败模式合法**:合成不了的答 `ENOENT`,这是 §7.7 明文的 *absence as an answer*,
+**不是** F1 禁止的"运行期报告不支持"。两者的分水岭是:`epoll_create` 答 `ENOSYS`
+说的是"这个接口不支持"(缺陷);`open("/proc/net/tcp")` 答 `ENOENT` 说的是
+"这个名字不存在"(文件系统的正常语义)。
+
+**位置**:放进 `openkal-musl` 的 `port/`,不单开包。它需要描述符表(已在 port 里)
+与对可选接口的弱引用(port 里已有这个模式,见 `okm_fd.c` 对
+`kal_process_channel_close` 的 `__attribute__((__weak__))`)。独立成包要把描述符表
+变成新的 ABI 边界,为一个今天只有一个消费者的东西付这个代价不值;等第二个形态
+(picolibc)真的也需要时再抽——第二个实例才能暴露接口是否完整。
+
+**验证**:进 openkal-musl 的 conformance。对表内每个名字,在每个目标上断言可打开
+且语义正确;对表外的代表性路径(`/proc/cpuinfo`、`/sys/class`),断言得到 `ENOENT`。
+**表外的断言和表内的同等重要**:它是"上界真的存在"的唯一证据。
+
+**限制,必须写在提案里**:对 `#ifdef __linux__` 门控的代码**无效**——catch2 的
+`/proc/self/status` 根本不会被调用,因为 `__linux__` 不定义。**P4 的受益者是"写
+POSIX 的代码",不是"写 Linux 的代码"。** 它不替代 P5。
+
+### 5.5 P5 —— 解析期逐条列举
+
+**动机**:三时刻表里最早的那一格空着。SPEC §3.3 明说消费者应逐条列举
+("a consumer that needs five names five. That is five lines"),且"in its own package,
+which is where a convention among consumers belongs",但 mcpp 没有承载它的字段。
+
+**形状**:按层泛化,引擎不认识任何接口名。
+
+```toml
+# 实现包:openkal-windows
+[package]
+provides = ["mcpp:kernel-abi=openkal"]
+
+[kernel-abi]
+provides-interfaces = [
+ "openkal.abort", "openkal.stream", "openkal.memory",
+ "openkal.env", "openkal.time", "openkal.fs", "openkal.process",
+]
+
+# 消费包:一个网络库
+[package]
+requires = ["mcpp:kernel-abi=openkal"]
+
+[kernel-abi]
+requires-interfaces = ["openkal.fs", "openkal.net"]
+```
+
+**引擎的规则只有一条**:`requires-interfaces ⊆ provides-interfaces`,否则在**解析期**
+拒绝,并列出缺的名字。引擎不认识 `openkal.net` 是什么,它只做集合包含——与
+`cenv.cppm` 的"通用知识,不含包名"同纪律。
+
+**平台 SDK 那一半不需要新机制**,今天就能表达:
+
+```toml
+[target.'cfg(os = "windows")'.dependencies]
+win32-headers = "1.0"
+```
+
+**与 P2 的接口**:一个包因 `requires-interfaces` 不满足而被拒,测量记 `refused`,
+不计入失败率。这是 `refused` 唯一可靠的判据来源。
+
+**为什么不由引擎推断**:`provides-interfaces` 只有实现包知道(它知道自己实现了
+什么),`requires-interfaces` 只有消费包知道(它知道自己调了什么)。两者都不是
+引擎能推导的,也不是另一方能替对方说的。
+
+**但两者都不得被信任**——见 §5.7。P5 单独落地会引入两个纯被信任的声明,那与
+§3.2 的原则相悖;P5 与 P7 是同一次改动的两半。
+
+### 5.6 P6 —— `openkal-win-ucrt` 形态
+
+**动机**:design §3.1 的形态表第三行今天是"**不使用 openkal**"——需要平台 C 运行时
+就整个目标离开 openkal。加入这个形态后变成"**用 openkal,换一个 C 库形态**":
+`kal_*` 仍可用,程序仍是 openkal 程序。
+
+**形状**:
+
+```toml
+[package]
+name = "openkal-win-ucrt"
+provides = ["mcpp:c-abi=ucrt"]
+
+[c-abi]
+presents = "windows"
+data-model = "arch-default" # Windows 上即 LLP64
+wchar = 16
+builtins = "platform"
+```
+
+**引擎改动:零。** 走一遍 `cenv::realise`:
+
+| 步骤 | 结果 |
+| --- | --- |
+| `presents == Windows` on windows | "Already the base triple's own identity — nothing to add" → 零令牌 |
+| `arch_default_data_model(Windows, x86_64)` = `Llp64`;`triple_native_data_model("windows")` = `Llp64` | 相等,已满足 → 零令牌 |
+| `wchar = 16` vs `native_wchar_bits("windows") = 16` | 相等 → 零令牌 |
+| `builtins = platform` | 零令牌 |
+
+空令牌集。又因 2026.9.18.2 的"realise 先跑、编译器族门第二"修复,**空实现连 Clang
+都不强制**,GCC 与 MSVC 同样可用。
+
+**这是本设计的分层判据**:
+
+> **一个新形态需要改多少处引擎?** 答案必须是零。不是零,说明分层漏了一维。
+
+**配套**:c++-abi 一侧需要一个配 UCRT 的 libc++ 或直接用 MSVC STL。那是包的事。
+
+### 5.7 P7 —— 声明的校验阶梯(接口一级)
+
+**动机。** P5 引入两个声明(`provides-interfaces` / `requires-interfaces`)。若二者
+只被信任,本文就在关闭一个缺口的同时开了一个更大的:`[c-abi]` 的探针量的是
+`__unix__` / `_WIN32` / `long` / `wchar`,**没有任何东西量"这个实现真的提供了哪些
+接口"**。design §3.2 的原则是「声明被校验,而不是被信任」,它在宏一级铺到了,在
+接口一级今天是空的。
+
+**关闭它不需要新机制。** 三件材料已经在仓库里:
+
+| 材料 | 状态 | 形状 |
+| --- | --- | --- |
+| `openkal/SURFACE.txt` | 已有,**normative** | 104 个 `kal_` 名字,按 16 个接口分组(`# openkal.fs` 一类的标题);"An implementation provides an interface in whole or not at all, so **the absence of a group below denotes an interface the implementation does not provide**" |
+| SPEC §9.2 Surface | 已有,已规定 | "The names an implementation exports beginning with `kal_` shall be compared against `SURFACE.txt` ... **a static examination of the artefact**" |
+| `kal_interfaces()` | 已有 | `include/openkal/version.h:91`,一个位字回答"哪些接口在场";"says which interfaces exist, **not how they behave**" |
+
+缺的只是把它们接成阶梯,并加一条生成规则。
+
+#### 5.7.1 四级阶梯
+
+每一级都是该事实**最早能被检查**的时刻,与 §6.2 同构:
+
+| 级 | 时刻 | 被检查的是 | 机制 | 今天 |
+| --- | --- | --- | --- | --- |
+| **L1** | 实现**发布** | `provides-interfaces` 是否属实 | §9.2 的静态比对,按 `SURFACE.txt` 的分组把导出的 `kal_` 名字**反推**成接口集 | 比对已有;反推与写回清单**没有** |
+| **L2** | 依赖**解析** | `requires ⊆ provides` | 集合包含(P5) | 无 |
+| **L3** | **链接** | 消费者**实际用到的** ⊆ 它声明的 | 消费者对象的未定义 `kal_` 符号 ∩ `SURFACE.txt` → 映射到接口 → 集合比较 | 无 |
+| **L4** | **运行** | 实际在场的接口 | `kal_interfaces()` 位字 | 已有 |
+
+#### 5.7.2 关键规则:`provides-interfaces` 不得手写
+
+> **实现包的 `provides-interfaces` 由产物生成,并断言"清单里的 = 产物里的"。**
+
+实现包的 CI 跑 §9.2 的比对,按 `SURFACE.txt` 的分组反推接口集,写回清单;CI 同时
+断言两者相等。这与 `generated_files` 同纪律——**一个从产物派生的声明,不可能与产物
+不符**。它也顺便消灭一类会发生的事:实现新增一个接口却忘了改清单。
+
+L1 因此不是"再加一道检查",而是**取消手写**。
+
+#### 5.7.3 为什么 L3 是必需的,不是锦上添花
+
+L1 与 L2 都管不到**消费者**的声明。没有 L3,`requires-interfaces` 是纯粹被信任的:
+一个包可以只声明 `openkal.fs` 却调 `kal_net_*`,解析期通过,**在提供 net 的实现上
+链接也通过**——只有在不提供 net 的实现上才会炸,而那时错误出现在**用户的目标上**,
+不是在包作者自己的构建里。
+
+L3 把这次失败从"某个用户的某个目标"提前到"包作者自己的构建"。它也很便宜:mcpp 已经
+有链接这一步,取消费者对象的未定义符号、与 `SURFACE.txt` 求交、映射到接口、比较集合。
+
+**L3 的报告形态**:多用(用了没声明的)是**错误**,指名符号与它所属的接口;
+少用(声明了没用的)是**提示**而不是错误——一个包可以按 feature 或按目标条件地使用
+某个接口,当前这次解析里没用到不代表声明是错的。
+
+**更正(2026-09-20,落地时发现):L3 的代价被这份设计低估了。** 上文写 L3 的机制是
+「取消费者对象的未定义符号、与 `SURFACE.txt` 求交、映射到接口」——**这要求引擎持有
+符号到接口的映射**,而 §5.5 同时写着「引擎不认识这两个集合的任何一个成员」。两句话
+不能同时成立。
+
+映射只能来自图,因此 L3 的真实形状是实现包再声明一张表:
+
+```toml
+[kernel-abi]
+provides-interfaces = ["openkal.fs", "openkal.stream", ...]
+
+[kernel-abi.interface-symbols] # 同样由 SURFACE.txt 生成
+"openkal.fs" = ["kal_fs_open", "kal_fs_close", ...]
+"openkal.stream" = ["kal_stdin", "kal_stdout", ...]
+```
+
+这张表在每个实现的清单里约一百行,由同一个脚本生成,引擎只做「未定义符号 → 它属于
+哪个接口 → 该接口在不在消费者的声明里」这一串查表与集合差,仍然不认识任何名字的含义。
+
+代价与收益都变了,因此 L3 **不在本轮落地**,并且它的缺席有一个必须写下的后果:
+
+> `requires-interfaces` 在消费者一侧**目前只被信任**。一个包可以声明少于它实际调用的
+> 接口,解析期通过,在提供该接口的实现上链接也通过——只有在不提供的实现上才会炸,
+> 而那时错误出现在**用户的目标上**,不在包作者自己的构建里。
+
+这正是 §5.7.3 说 L3 要消除的那件事,它今天还在。
+
+#### 5.7.4 风险边界:写错不会静默
+
+这是"这两个事实可以由声明承载"的根据——不是因为声明可信,而是因为**每一种写错都响亮**:
+
+| 写错 | 后果 | 何时被发现 |
+| --- | --- | --- |
+| `provides` 多声明 | 解析通过,链接报未定义符号 | 链接(§6.1 的常规报告) |
+| `provides` 少声明 | 解析期误拒,指名缺的接口 | 解析(响亮) |
+| `requires` 多声明 | 在能力较少的实现上误拒 | 解析(响亮) |
+| `requires` 少声明 | **L3 抓住**;无 L3 则在部分实现上链接失败 | 链接 |
+
+**没有一格是静默的。** 加上 L1 取消手写、L3 抓消费者,四种写错里两种在自己的 CI 里
+就被挡住,另两种落在响亮的位置。
+
+#### 5.7.5 C 库一级:枚举例外,不枚举规则
+
+同一条阶梯能不能照搬到 `c-abi`?**不能,而且不该。** openkal 有 16 个接口和一份
+`SURFACE.txt`,所以可以**正向**枚举;C 库有约 1200 个 POSIX 函数,正向枚举就是
+SPEC §3.3 撤回过的那件事(naming sets)。
+
+但**反向枚举是有界的**。`openkal-musl` 的 README 已经列出了它不能提供的东西,约六项,
+并且写下了正确的理由:
+
+> The following are absent, and each is **refused rather than quietly accepted**,
+> because **a facility that reports success and does nothing is the one kind of
+> answer that leaves a program wrong without telling it.**
+
+这段散文就是 §6.1 的原则,只是**没有任何东西在执行它**——而它已经被烧过一次,README
+自己记录着:0.16.0 之前 `SIG_IGN` 对每个信号都被接受却一个都没安装,"a program that
+asked not to be ended by the interrupt keystroke **was told it had succeeded and was
+ended by it**"。
+
+**形状**:把这份散文变成机读声明,并由 CI 断言它与产物一致。
+
+```toml
+# openkal-musl 的清单
+[c-abi]
+presents = "posix"
+data-model = "arch-default"
+wchar = 32
+builtins = "iso"
+
+# 枚举例外,不枚举规则。form 是该缺席以何种形状到达调用方。
+[c-abi-absent]
+fork = { targets = ["*"], form = "link" } # 符号不定义,链接期报
+mprotect = { targets = ["*"], form = "enosys" } # 定义存在,报 ENOSYS
+sigaction-handler = { targets = ["*"], form = "enosys",
+ note = "非默认/非忽略的处置" }
+```
+
+两个价值,各自独立:
+
+1. **CI 可断言。** `form = "link"` 的名字必须**不在**产物的定义里;`form = "enosys"`
+ 的必须**在**。这把一份从来没有执行者的散文变成一条会红的断言。
+2. **mcpp 的诊断可以接话。** 链接报 `undefined reference to 'fork'` 时,mcpp 读到
+ C 库的 `[c-abi-absent]`,补一句「`openkal-musl` 声明 `fork` 在 `x86_64-windows-gnu`
+ 上不可用(link 形式)」,而不是让用户自己去查一份 README。
+
+**这张表是顶层表,而这是量出来的。** 实现时先写成 `[c-abi].absent`——更顺,也更像
+它所描述的东西。判据取自**真正发布的 2026.9.18.3 归档**(当时的索引 floor),跑在
+openkal-musl 0.17.0 将要发布的那份清单上:嵌套写法让**每个旧 mcpp 在每个目标上拒绝
+整份清单**,报 `[c-abi] has no member 'absent'`——`[c-abi]` 的解析器枚举自己的成员并
+拒绝其余,而这条严格性本身是对的(拼错的 `presents` 不该静默关掉一条声明)。同一次
+测量里,**未知的顶层表被忽略,构建照常完成**。
+
+这个差别决定这张表能不能发。它做的每件事都是诊断性的:给一次**已经失败**的链接加一句
+话,没有任何 flag、链接行或产物依赖它。于是忽略它的引擎产出的正是它今天产出的那条链接
+错误;而拒绝它的引擎会把索引 floor 逼到 2026.9.20.1——为了一句他们无非是收不到的说明,
+夺走停在其下的每个客户端手里的**整个索引**([[index-floor-must-degrade]] 的失败形态)。
+改成顶层表后,openkal-musl 0.17.0 **不再要求抬 floor**。
+
+⚠️ 这条差别在解析结果上**看不见**:两种拼法解析出的 `CAbiDecl` 完全相同,任何只读结果
+的测试都区分不了。所以判据只能是「拿索引 latest 指向的那个二进制去跑」
+([[new-capability-key-floor-measured]]),并且在单测里**直接断言形状**——
+`absent` 不是 `[c-abi]` 的成员。
+
+**`form` 这个字段本身承重。** `link` 是 §6.1 要的形状;`enosys` 是需要辩护的例外,
+README 今天为每一条都写了辩护(`mprotect`:「musl asks for a guard page ... and
+proceeds without one when told this, so the honest answer is also the one it is
+prepared for」)。把辩护变成一个字段,**"有多少例外"就成为一个看得见、会增长、可以
+被 review 盯住的数字**——而不是散在一篇 README 里。
+
+#### 5.7.6 实现代价
+
+| 部件 | 在哪 | 规模 |
+| --- | --- | --- |
+| `SURFACE.txt` → 接口集的反推 | openkal `tools/` | 一个脚本,按 `# openkal.x` 标题分组 |
+| L1 断言 + 写回清单 | 每个实现包的 CI | 复用上面的脚本 |
+| L2 集合包含 | mcpp 解析期 | 一条规则,引擎不认识任何接口名 |
+| L3 未定义符号比对 | mcpp 链接期 | 取对象的未定义符号、求交、映射、比较 |
+| L4 | 已有 | — |
+| `[c-abi-absent]` 断言 | openkal-musl 的 CI | 一个脚本 |
+| `[c-abi-absent]` 诊断接话 | mcpp 链接失败路径 | 一处 |
+
+**引擎侧新增两处**(L2 解析、L3 链接),且**都不认识任何具体名字**:L2 做集合包含,
+L3 做集合差。名字的含义全部来自 `SURFACE.txt`,而那是 openkal 的 normative 文件,
+不是引擎的知识。这与 `cenv.cppm` 的「GENERIC KNOWLEDGE, NO PACKAGE NAMES」同纪律。
+
+#### 5.7.7 判据
+
+- L1:把某个实现的一个接口的定义删掉,其 CI 必须红,并指名那个接口。
+- L2:消费者声明一个实现不提供的接口,解析期必须拒绝并指名。
+- L3:消费者调用一个**没有声明**的接口,链接期必须报错并指名符号与它所属的接口;
+ **且在提供该接口的实现上同样报错**——否则这条判据只是重复了 §6.1。
+- L4:已由 openkal conformance 覆盖。
+- `[c-abi-absent]`:把 `fork` 的 `form` 从 `link` 改成 `enosys`,CI 必须红。
+
+最后一条与 L3 的第二句是同一个形状:**一条判据必须在"机制本身会通过"的那一侧
+也成立,否则它测的是机制不是声明。**
+
+---
+
+## 6. 使用方式与场景
+
+### 场景一 —— 纯 POSIX 库放进 openkal(目标:无感)
+
+```toml
+# 库自己的清单:什么都不用写
+[package]
+name = "mylib"
+```
+
+构建 `x86_64-windows-gnu` + openkal 图:`_WIN32` 不定义、`__unix__` 定义、LP64、
+wchar 32。库里的 `#ifdef _WIN32` 分支不选中,走 POSIX 分支。若它用到
+`/dev/urandom`,由 P4 合成。**零适配。**
+
+这是今天 asio、eigen、catch2、re2、CLI11、lua、spdlog 应当落入的场景——它们今天红,
+只是因为测量的图里 `runtime` 还钉在 `0.10.0`,没有任何包声明 `[c-abi]`。
+
+### 场景二 —— 需要 Win32 API 的库
+
+```toml
+[features]
+default = ["posix-socket"]
+posix-socket = { defines = ["MYLIB_POSIX_SOCKETS"] }
+winsock = { defines = ["MYLIB_WINSOCK"] }
+
+[feature-deps.winsock]
+win32-headers = "1.0" # 平台 SDK 由图提供,不传给下游
+```
+
+选 `winsock` 而图里没有 `win32-headers` → **解析期**拒绝,指名缺什么。
+不选就走 POSIX 路径。**不在预处理期猜。**
+
+### 场景三 —— 需要 Linux 内核节点的库
+
+```toml
+[package]
+requires = ["mcpp:kernel-abi=openkal"]
+
+[kernel-abi]
+requires-interfaces = ["openkal.fs", "openkal.event"] # event 今天是 reserved
+```
+
+在任何图上都解析失败,测量记 `refused`,**不计入失败率**。
+这就是"编不过是正确答案"的机器表达。
+
+### 场景四 —— 程序需要 Windows C 运行时
+
+```toml
+# 程序的清单:换一个入口包,整棵图跟着换形态
+[dependencies]
+openkal-win-ucrt = "0.1"
+```
+
+`_WIN32` 定义、LLP64、wchar 16,`kal_*` 仍然可用。**形态由程序选,库不得选。**
+
+### 场景五 —— 包自己的构建配置
+
+```lua
+-- compat.zlib.lua:无条件应用,头里按事实自判断
+cflags = { "-include", "mcpp_zlib_config.h" },
+generated_files = {
+ ["mcpp_generated/include/mcpp_zlib_config.h"] =
+ "#if !defined(_WIN32)\n#define Z_HAVE_UNISTD_H 1\n#endif\n",
+},
+```
+
+**判断的是事实(`_WIN32` 在不在),不是身份(C 库是不是 musl)。**
+撤回前那条 `cfg(all(windows, c-abi = "musl"))` 是身份判断,是 `[c-abi]` 要消灭的写法。
+
+**可度量的健康指标**:索引里 `cfg(c-abi = ...)` 的出现次数。今天是 **2**(都在
+libarchive,且都在问"平台 SDK / 内核头在不在图里"——正是 P5 要补的那一维),
+目标是 **0**。
+
+### 场景六 —— 写一个新库,怎么写才在全生态可移植
+
+1. 不问身份宏,问**事实**(`__has_include`、feature、或什么都不问走标准路径)。
+2. 需要某个 openkal 接口,写进 `requires-interfaces`,让解析期回答。
+3. 需要平台 SDK,写成 per-target 依赖 + feature。
+4. 自己的构建开关写进描述符,无条件应用,头里按事实自判断。
+
+---
+
+## 7. 多维评估
+
+| 维度 | 评估 | 可测量的判据 |
+| --- | --- | --- |
+| **兼容性** | 第一类失败(`_WIN32` 选错分支)自愈;第二类由 P3 关闭;第三类归 A;"需要平台能力"的由 P5 转成 `refused`。**兼容率不再是引擎的分数** | 30-member 重测:`fails` 与 `refused` 分开计 |
+| **稳定性** | 所有方案落在三时刻表内,不存在"被下一个环境证伪"的类别名或借来的宏 | 索引里 `cfg(c-abi=...)` 次数 → 0;`presents` 取值集不增长 |
+| **易用性** | 纯 POSIX 库零适配(场景一);需要能力的包写五行(场景三);换形态改一行依赖(场景四) | 一个新 compat 包进索引时需要写的 `target_cfg` 条数 |
+| **简洁性** | 新增两个机制(P4 合成表、P5 接口列举),各补一格;撤掉一个借用(P3);冻结一个名字(P1)。**净新增概念为一(接口列举),其余都是既有机制的延伸** | 新形态需要的引擎改动数 = 0(P6 已验证) |
+| **可维护性** | P4 的上界由接口集自动给出;P5/P7 的引擎规则只有集合包含与集合差;P1/P2 是文档与字段 | 引擎里"认识某个具体名字"的处数不增加 |
+| **可验证性** | 四个时刻各有一条会红的断言;`provides-interfaces` 由产物生成而非手写;C 库一级的例外表把一份无人执行的散文变成断言 | 删掉一个接口的定义,实现的 CI 必须红;调一个未声明的接口,链接期必须报错**且在提供该接口的实现上同样报错** |
+| **未来扩展性** | 新形态 = 一个包;新接口 = SPEC 一行 + 两侧清单各一行;新平台 = 一个 kernel-abi 实现 | 加一个 `openkal.event` 需要动几个仓库:SPEC 1 + 实现 N + 消费者清单,引擎 0 |
+
+**三处不优雅,本文全部关闭**:
+
+| 边缘 | 状态 |
+| --- | --- |
+| 借来的 `__CYGWIN__` | P3 关闭(需重测确认) |
+| 空着的解析期格子 | P5 关闭 |
+| 探针只铺到宏、没铺到接口 | **P7 关闭**。四级阶梯(L1 发布 / L2 解析 / L3 链接 / L4 运行),材料全部已在仓库里(`SURFACE.txt`、SPEC §9.2、`kal_interfaces()`),引擎侧新增两处且都不认识任何具体名字。C 库一级枚举例外而不枚举规则(`[c-abi-absent]`) |
+
+**P7 同时改变了"可验证性"这一维的整体评级**:在它之前,`[c-abi]` 只有宏一级被校验,
+接口一级完全是信任;在它之后,四个时刻各有一条会红的断言,且 `provides-interfaces`
+由产物生成而非手写。
+
+---
+
+## 8. 影响面
+
+| 层 | 影响 | 性质 |
+| --- | --- | --- |
+| openkal 规范 | 零。不新增也不修改任何 `kal_*` | — |
+| openkal 各实现 | P5/P7 要求填 `provides-interfaces`,且**由产物生成而非手写**;CI 增加 L1 断言 | 一次性,完全机械化 |
+| openkal-musl | P4 在 `port/` 新增合成表与 conformance 断言;P7 把 README 的"absent"散文表变成 `[c-abi-absent]` 并加 CI 断言 | 新增能力,向后兼容 |
+| openkal-llvm-runtime | 零 | — |
+| mcpp 引擎 | P0 两处修复;P5 新增字段与解析规则;**P7 新增链接期集合差与 `[c-abi-absent]` 诊断接话**;P3 改两个令牌;P1 改文档 | 新增能力,向后兼容 |
+| mcpp-index | P2 拆 `status`;libarchive 那 2 处身份判断改成 P5 声明 | 跟随 |
+| 终端用户(用 openkal) | P3 改变 `__CYGWIN__`,该目标上的包重建一次 | 一次破坏性 |
+| **不用 openkal 的用户** | **零。命令行逐字节不变** | 已由现有"未声明者不变"保证 |
+
+---
+
+## 9. 顺序
+
+**落地记录见 `2026-09-20-ecosystem-execution-plan.md` §4,自审见
+`2026-09-20-wave-self-review.md`。** 本轮落地 P0、P1、P2、P5、P7(L1/L2/`[c-abi-absent]`),
+未落地 P3、P4、P6 与 P7 的 L3;每一项未落地的理由与它缺席的后果都写在那两份记录里。
+
+
+```
+P0 探针修复 ─┐
+P1 冻结语义 ─┼─ 互不依赖,可并行
+P2 refused ─┘
+ │
+ ├─→ 发布链走完,抬 pins.toml 的 runtime,重测 ──┬─→ P3 撤 __CYGWIN__(需重测数据)
+ │ └─→ 兼容性基线重新确立
+ │
+P4 合成节点 ──── 独立,可与上并行
+P5 接口列举 ─┬─ 同一次改动的两半,不得只落地 P5
+P7 校验阶梯 ─┘ (L1 先行:它只改 openkal 各实现的 CI,不动引擎)
+P6 win-ucrt ──── 需要 c++-abi 侧配套,最后
+```
+
+**P7 的 L1 可以独立先行**:把 `SURFACE.txt` → 接口集的反推脚本与 CI 断言做出来,
+不依赖任何引擎改动,而且它产出的正是 P5 需要的那份 `provides-interfaces`。
+`[c-abi-absent]` 的 CI 断言同样可以独立先行。
+
+P3 **不得**先于重测落地:libarchive 的生成配置头含
+`#if defined(_WIN32) && !defined(__CYGWIN__)`,撤销会改变它的分支。
+
+---
+
+## 10. 需要决定的问题
+
+- **D1 P3 是否执行。** 它推翻一条已发布的设计决策(`__CYGWIN__` 保留),
+ 但那条决策自己写着"留给 30-member 测量翻转"。证据已在(mimalloc 的注释、
+ sqlite3 的检测列表),但代价需要重测。**建议:执行,在重测之后。**
+- **D2 P4 放 `port/` 还是独立包。** 建议 `port/`,理由见 §5.4。
+- **D3 P5 的字段命名与归属层。** 本文提的是 `[kernel-abi] requires-interfaces /
+ provides-interfaces`,按层泛化。另一种是并入 `requires` 列表。
+ 建议前者——`requires` 今天命名的是层,混入接口名会让一个列表有两套词汇。
+- **D4 P2 的 `refused` 判据。** 本文要求它必须来自包的声明(P5),不得来自
+ 诊断字符串匹配。这意味着 P2 的完整形态依赖 P5;在 P5 之前,`refused` 只能由
+ 描述符显式标注。**建议:先上显式标注,P5 之后改为自动。**
+- **D5 "对象格式是 PE"是否需要一个名字。** 建议先不给,让包问 `cfg(os="windows")`;
+ 若重测显示确有预处理期的需要,由 mcpp 定义自己的名字,不再借用。
+- **D6 `provides-interfaces` 由产物生成,还是允许手写后校验?** 本文提的是**生成**
+ (§5.7.2),理由是"从产物派生的声明不可能与产物不符",而且顺带消灭"新增接口忘改
+ 清单"这一类。代价是实现包的 CI 多一个生成步骤。**建议:生成。**
+- **D7 L3(链接期集合差)是否纳入本轮。** 它是 `requires-interfaces` 唯一的校验者,
+ 没有它 P5 的消费者一侧是纯被信任的。代价是 mcpp 链接期多一次符号读取。
+ **建议:纳入,但可以先做成 warning,一个发布周期后转 error。**
+- **D8 `[c-abi-absent]` 的 `form` 取值集。** 本文用 `link` / `enosys` 两个值。
+ 是否需要第三个(例如"接受但无效"——README 记录的 `tcsetattr` 部分字段即是)?
+ **建议:需要,命名为 `accepted-no-effect`,因为它正是 0.16.0 之前那个真实缺陷的
+ 形状,给它一个名字才能被盯住。**
+
+---
+
+## 11. 附录:本文依据的实测
+
+全部在本机(Linux x86_64,clang 22.1.0)执行,可逐条重跑。
+
+```sh
+# 三元组预定义:cygwin 不定义 _WIN32,mingw 定义
+clang --target=x86_64-pc-cygwin -x c -E -dM - \n\nexport module m;\n' > m.cppm
+clang++ -std=c++23 --precompile m.cppm -o /dev/null # OK
+clang++ -std=c++23 -include unistd.h --precompile m.cppm -o /dev/null # error: 'module;' ... only at the start
+
+# -include 进 GAS
+printf '.text\n.globl f\nf:\n ret\n' > a.S
+clang -c -include unistd.h a.S -o a.o # invalid instruction mnemonic '__begin_decls'
+
+# appendUniqueFlags 去重之后的命令行
+clang -c -include unistd.h sys/stat.h t.c # no such file or directory: 'sys/stat.h'
+
+# -nostdlibinc 不关 clang 自带头
+printf '#include \nint main(){}\n' > i.cpp
+clang++ --target=x86_64-pc-cygwin -nostdlibinc -fsyntax-only i.cpp # intrin.h:12:15
+
+# glibc 的 stdio.h 不传递包含 unistd.h(否定 #674 设计稿 §1.2)
+printf '#include \nlong f(void){return lseek(0,0,0);}\n' > s.c
+clang -fsyntax-only -std=c11 s.c # call to undeclared function 'lseek'
+```
+
+索引侧:
+
+```sh
+cd ~/workspace/github/mcpplibs/mcpp-index
+python3 -c "
+import json; d=json.load(open('.xpkgindex/openkal-compat.json'))
+print(d['pins'], d['measured'])
+for n,i in sorted(d['members'].items()):
+ t=i['targets']['x86_64-windows-gnu']
+ if t.get('status')!='runs': print(n, t.get('status'), t.get('diagnostic','')[:110])
+"
+grep -rho 'cfg([^)]*c-abi[^)]*)' pkgs/ | sort | uniq -c # 今天 2 处,都在 libarchive
+sed -n '1,35p' tests/openkal/pins.toml # runtime = "0.10.0"
+```
+
+---
+
+## 12. 参考
+
+- openkal SPEC 0.14:§3.2(core set 不增长)、§3.3(撤回 `hosted`)、§3.4(拒绝统一名字解析)、§5.2(mapping 不是 simulation)、§6.1(运行期拒绝是缺陷)、§6.2(三时刻)、§6.5(解析期决定)、§7.1(naturalness)、§7.7(absence as an answer)
+- openkal SPEC 0.14:§9(conformance procedure,四半)、§4.4(S/L/X 与 `kal_interfaces`)
+- `openkal/SURFACE.txt` —— normative,104 个名字 / 16 个接口分组
+- `openkal/include/openkal/version.h:55-91` —— `kal_interfaces` 的位字与"不是能力"的界定
+- `openkal-musl` `README.md` "The following are absent" 表 —— 例外枚举的现成内容与理由
+- `openkal-musl` `port/src/okm_fd.c` 文件头 —— 合成层已经存在的证据
+- openkal design 2026-09-18:§3.1(形态表)、§3.2(`[c-abi]`)、§5(平台相关代码)、§9(备选与不做)、§12(自审)
+- `mcpp` `src/toolchain/cenv.cppm`、`src/toolchain/cenv_probe.cppm`、`src/build/prepare.cppm`(3989、6380、10790、11148、12380、12641)
+- `docs/22-target-side.md` §295-430
+- `mcpp-index` `.xpkgindex/openkal-compat.json`、`tests/openkal/pins.toml`、`pkgs/c/compat.zlib.lua`、提交 700f7de
+- 评审记录:`.agents/docs/2026-09-20-issue-674-design-review.md`
diff --git a/.agents/docs/2026-09-20-wave-self-review.md b/.agents/docs/2026-09-20-wave-self-review.md
new file mode 100644
index 00000000..d3ebd9a9
--- /dev/null
+++ b/.agents/docs/2026-09-20-wave-self-review.md
@@ -0,0 +1,111 @@
+---
+subject: review
+status: active
+---
+
+# 本轮生态级自审
+
+- 日期:2026-09-20
+- 对象:`2026-09-20-openkal-c-environment-ecosystem-design.md` 所规划、本轮落地的六个仓库改动
+- 依据:`2026-09-20-ecosystem-execution-plan.md` 的执行记录
+
+---
+
+## 1. 落地与设计的对照
+
+| 设计项 | 落地 | 与设计的差异 |
+| --- | --- | --- |
+| P0.1 探针带目标 | mcpp#678 | 多了一个装配函数:不变量放在调用点靠记性,放在装配处靠结构 |
+| P0.2 `builtins` 注释 | mcpp#678 | 无 |
+| P1 冻结 `presents` | mcpp#678 | 无 |
+| P2 `refused` | mcpp-index#444(已合入) | 判据从"显式标注"改为引擎的拒绝码,D4 的建议被数据推翻:显式标注没有第一个使用者 |
+| P3 撤 `__CYGWIN__` | 未做 | 判据已具备,代价需一次重测 |
+| P4 合成节点 | 未做 | 独立工作量 |
+| P5 接口列举 | mcpp#678 + openkal#42 + openkal-linux#29 + openkal-windows#26 | 设计说"引擎新增两处",实际只新增解析期一处:链接期那一处(L3)未做 |
+| P6 `openkal-win-ucrt` | 未做 | 需 c++-abi 配套 |
+| P7 校验阶梯 | L1 + L2 + `[c-abi-absent]` 落地;L3 未做 | 见下 |
+
+**与设计最大的偏差是 P7 的 L3(链接期集合差)。** 设计把它列为"`requires-interfaces` 唯一
+的校验者",本轮没有做。后果要写清楚:**消费者一侧的声明目前仍然只被信任。** 一个包可以
+声明少于它实际调用的接口,解析期通过,在提供该接口的实现上链接也通过——只有在不提供的
+实现上才会炸,而那时错误出现在用户的目标上。这一条应当是下一轮的第一项。
+
+---
+
+## 2. 多维核查
+
+| 维度 | 核查 | 结论 |
+| --- | --- | --- |
+| **架构** | 新增机制是否落在 §6.2 的三时刻表内 | 是。`requires/provides-interfaces` 在解析期,`[c-abi-absent]` 的 `link` 形状在链接期,`enosys` 在运行期。没有一项落在预处理期 |
+| **兼容性** | 未声明任何新键的包命令行是否逐字节不变 | 是。e2e 743 的 C 腿与 verify 的 D 段各验一次 |
+| | 两个新键对旧引擎的行为 | **两个都被忽略**,因为两个都是顶层表;已写入 docs/22 与其 zh 镜像。`[c-abi-absent]` 起初嵌在 `[c-abi]` 里而被整份拒绝,是第 4 节第 6 条。两者都不要求抬 floor |
+| **跨平台** | Windows / macOS / freestanding | 探针修复的直接受益者是 freestanding(此前在任何宿主上都空转)。Windows 上 clang 20.1.7 的三次前端崩溃是本轮实测发现,最终形态写下了理由 |
+| **一致性** | 同一个事实是否只有一处 | 提升了一处:openkal-musl 的 CI 手写 withheld 清单被 `[c-abi-absent]` 取代。降低了零处 |
+| **无感升级** | 旧图是否照常构建 | 是。"一个什么都没陈述的提供者,不是一个什么都不提供的提供者"由 e2e 743 的 C 腿钉住 |
+| **测试覆盖** | 新增判据是否可证伪 | 逐条检查见 §3 |
+| **优雅简洁** | 新增概念数 | 一个(接口列举)。`[c-abi-absent]` 是既有 `[c-abi]` 的延伸,`assemble_argv` 是既有装配的提取 |
+| **用户体验** | 失败时读者能否知道改哪一边 | 接口拒绝同时点名缺的接口、要它的包、没提供它的实现;`[c-abi-absent]` 的诊断带上那一行的 `note` |
+
+---
+
+## 3. 每条判据是否可证伪
+
+| 判据 | 能失败吗 | 如何确认的 |
+| --- | --- | --- |
+| `assemble_argv` 拒绝无目标的 freestanding argv | 是 | 单测直接构造该形状 |
+| 探针量的是目标不是宿主 | 是 | 本机实测真实命令:`__riscv`、wchar 4、无 `__linux__` |
+| 接口不满足即拒绝 | 是 | e2e 743 的 B 腿,并断言**未编译任何东西** |
+| 提供者什么都没说时不拒绝 | 是 | e2e 743 的 C 腿 |
+| `[c-abi-absent]` 与产物一致 | 是 | openkal-musl CI 翻转一行并要求脚本拒绝 |
+| `provides-interfaces` 与产物一致 | 是 | openkal-linux / -windows CI 重新生成并 diff |
+| 接口清单不是"打印 SURFACE.txt 的每一组" | 是 | openkal CI 的半组腿:`openkal.fs` 只导出两个名字,必须不被列出 |
+| 沙箱 CHANGE 段确实区分两个版本 | 是 | 对**真正发布的 2026.9.18.3 归档**跑出 fails=2,对 2026.9.20.1 跑出 fails=0。每个 CHANGE 段里各有一条腿两版都过——那不是洞,正是本轮主张的向后兼容:旧引擎忽略未知顶层表,所以**读不到就拒不了**,区分两版的是各自的那条拒绝腿 |
+
+**一条判据曾经不可证伪并已更正**:`CenvProbe` 的两个端到端探针测试在 gcc 宿主上 skip。
+跳过的判据等于没有判据,所以不变量被提升为 `assemble_argv` 这个纯函数,五个新测试不需要
+交叉工具链。
+
+---
+
+## 4. 本轮自审中发现并修掉的缺陷
+
+| # | 缺陷 | 形状 |
+| --- | --- | --- |
+| 1 | 接口清单取"图里第一个声明的包"而不是**被解析出的提供者** | 一个图可能有多个候选,读错的是另一个实现的清单——错误答案而不是缺失答案 |
+| 2 | 提供者用包名**子串**匹配 `impl` | `openkal` 命中 `openkal-linux@0.15.0` |
+| 3 | `[c-abi-absent]` 的诊断用符号**子串**匹配 | `undefined symbol: open` 是 `opendir` 的前缀;一个自信的错误解释比链接器自己的消息更坏 |
+| 4 | openkal CI 的接口步量的是 `impl-fd`,而它**一个接口都不整组提供** | 判据的对象选错了 |
+| 5 | openkal CI 的接口步读到上一步遗留的目标文件 | 上一步删了源文件没删产物 |
+
+| 6 | `[c-abi.absent]` 嵌在**已知的严格表**里 | 每个早于本版的 mcpp **在每个目标上拒绝整份清单**;openkal-musl 0.17.0 会因此要求抬索引 floor,夺走停在其下的客户端手里的**整个索引** |
+| 7 | `docs/20` 的宿主面表自称穷举,而它只做了**一次扫描** | 漏了命令解释器与 MSVC/Windows SDK 两行;一张自称"全部"的表少两行,比不写这张表更坏 |
+
+第 1、2、3 条是读自己的 diff 时发现的;第 4、5 条由 CI 发现。**前三条在本机全绿,只有
+读代码能发现**——它们都属于"谓词对、对象错"。
+
+第 6、7 条**读代码与 CI 都发现不了**,各自需要一件本机没有的东西:
+
+- 第 6 条要的是**索引 latest 指向的那个二进制**。本机的 mcpp 是本轮新编的,两种拼法在
+ 它上面完全一样;两种拼法解析出的 `CAbiDecl` 也完全相同,任何只读结果的测试都区分不了。
+ 判据只能是「下载真正发布的 2026.9.18.3 归档,喂给它 openkal-musl 0.17.0 将要发布的
+ 那份清单」。修法是把表挪到顶层,代价为零,而发现它的窗口在合入前就关上了。
+ ⇒ 沉淀为单测里的**形状断言**(`absent` 不是 `[c-abi]` 的成员)与 openkal-musl CI 的
+ 引擎钉(留在 2026.9.18.3,绿即证明该主张)。
+
+- 第 7 条要的是**把"我查过了"换成"我按什么枚举的"**。第一版表由 `fs::which` 一次扫描
+ 写成,而宿主可以被四种形状够到。修法是把四次扫描连同各自的计数写进那一页,让下一个
+ 读者去**核**而不是**信**。⇒ 计数是可证伪的:`fs::which` 恰好五处,每一处对应表里一行。
+
+⭐ 这两条的共同形状:**一件东西"写完并且绿了"之后,它所依据的前提还没有被任何东西量过。**
+第 6 条的前提是"新键会被旧引擎忽略",第 7 条的前提是"我列的就是全部"。
+
+---
+
+## 5. 未关闭,按优先级
+
+1. **P7 的 L3(链接期集合差)。** `requires-interfaces` 今天仍只被信任。这是本轮与设计
+ 最大的偏差。
+2. **P3 撤 `__CYGWIN__`。** 判据已由 mimalloc 与 sqlite3 给出,代价需一次重测。
+3. **`__cxa_thread_atexit`。** 本轮测量新发现的 C++ 运行时缺口。
+4. **openkal-macos 的 `provides-interfaces`。** 无法在 Linux 宿主派生,需 macOS runner。
+5. **P4 / P6。** 独立工作量。
diff --git a/.agents/docs/README.md b/.agents/docs/README.md
index 2b337a8c..c981faa5 100644
--- a/.agents/docs/README.md
+++ b/.agents/docs/README.md
@@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded
---
```
-296 records.
+302 records.
## By subject
@@ -30,6 +30,8 @@ Records that declare one. Everything else is listed by date below.
### design
+- [openkal 生态:能力的时刻模型,以及 C 环境方案空间的划分](2026-09-20-openkal-c-environment-ecosystem-design.md) — active
+- [#674:`presents = "posix"` 在 Windows 上兑现契约的下半段](2026-09-19-issue-674-cenv-posix-preinclude-design.md) — superseded
- [The build database of #636, and two defects on the way to the latest xlings](2026-09-14-636-build-database-and-the-latest-xlings.md) — active
### docs
@@ -48,10 +50,20 @@ Records that declare one. Everything else is listed by date below.
- [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active
+### plan
+
+- [C 环境生态方案:执行计划](2026-09-20-ecosystem-execution-plan.md) — active
+
### plugins
- [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active
+### review
+
+- [本轮生态级自审](2026-09-20-wave-self-review.md) — active
+- [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active
+- [`__cxa_thread_atexit` 在 openkal-Windows 上:定位到一层,第二层未定位](2026-09-20-cxa-thread-atexit-finding.md) — active
+
### targets
- [A verified Web run that asked the host for node](2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md) — landed
@@ -81,6 +93,12 @@ Records that declare one. Everything else is listed by date below.
### 2026-09
+- [本轮生态级自审](2026-09-20-wave-self-review.md) — active
+- [openkal 生态:能力的时刻模型,以及 C 环境方案空间的划分](2026-09-20-openkal-c-environment-ecosystem-design.md) — active
+- [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active
+- [C 环境生态方案:执行计划](2026-09-20-ecosystem-execution-plan.md) — active
+- [`__cxa_thread_atexit` 在 openkal-Windows 上:定位到一层,第二层未定位](2026-09-20-cxa-thread-atexit-finding.md) — active
+- [#674:`presents = "posix"` 在 Windows 上兑现契约的下半段](2026-09-19-issue-674-cenv-posix-preinclude-design.md) — superseded
- [运行时绑定方案 v3:让 mcpp 真正安装它所声明的运行时](2026-09-17-runtime-binding-multi-repo-plan.md) — landed
- [#662:目标侧由依赖图提供时,编译器的隐式头文件搜索仍指向宿主](2026-09-17-issue-662-graph-target-header-isolation-plan.md) — active
- [Issue #660 分析:`glibc@2.44` 绑定在 2.44.3 发布后解析失败](2026-09-17-issue-660-glibc-line-binding-analysis.md) — landed
diff --git a/.github/tools/check_reason_tokens.sh b/.github/tools/check_reason_tokens.sh
new file mode 100755
index 00000000..24f39040
--- /dev/null
+++ b/.github/tools/check_reason_tokens.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+# Every reason token the engine can emit is in docs/50's table, and every row
+# of that table is a token the engine can emit.
+#
+# WHY THIS EXISTS. The table is a MACHINE INTERFACE: mcpp-index's compatibility
+# measurement reads `[interface-not-provided]` out of a refusal to tell "this
+# graph does not supply what the member asked for" from "the member did not
+# build", and that distinction decides a published figure. A token the engine
+# emits and the table omits is a promise nobody can rely on; a row naming a
+# token no branch emits is one a consumer will wait for forever.
+#
+# IT EXISTS BECAUSE ADDING THE MISSING ONES BY HAND MISSED SOME. Four tokens
+# were added to this table on 2026-09-18 as "the ones it was missing"; a
+# later enumeration found four more that had been absent the whole time. A set
+# compared by reading is a set compared by sampling.
+set -u
+cd "$(dirname "$0")/../.."
+
+src=src/build/refusal.cppm
+doc=docs/50-machine-output.md
+[ -f "$src" ] && [ -f "$doc" ] || { echo "::error::missing $src or $doc"; exit 1; }
+
+# `none` is the sentinel for "no refusal was recorded" and names no branch.
+# `other` stays in BOTH sets: it is emitted and it is documented.
+emitted=$(grep -oE 'return "[a-z][a-z0-9-]*";' "$src" \
+ | sed 's/return "//; s/";//' | grep -vx none | sort -u)
+
+# The table is the contiguous run of `| \`token\` |` rows containing `other`,
+# which is its documented catch-all. Anchoring on a row rather than on a
+# heading keeps this working when the prose around it is rewritten.
+n=$(grep -n '^| `other` |' "$doc" | head -1 | cut -d: -f1)
+[ -n "$n" ] || { echo "::error::$doc has no \`other\` row; the table moved"; exit 1; }
+start=$n
+while [ "$start" -gt 1 ] && sed -n "$((start-1))p" "$doc" | grep -q '^|'; do
+ start=$((start-1))
+done
+# A ROW IS SELECTED BY HAVING A DESCRIPTION, NOT BY LOOKING LIKE A ROW. This
+# table's COLUMN HEADER is `| \`reason\` | |` --- a backticked name in the first
+# cell, exactly the shape of the rows beneath it --- so a pattern that matches
+# "backticked name at the start of a line" reports `reason` as a documented
+# token. The second cell is what tells a header from a row, so the pattern
+# requires a non-empty one.
+documented=$(sed -n "${start},${n}p" "$doc" \
+ | grep -oE '^\| `[a-z][a-z0-9-]*` \| [^|]+ \|' \
+ | sed 's/^| `//; s/`.*//' | sort -u)
+
+missing=$(comm -23 <(printf '%s\n' "$emitted") <(printf '%s\n' "$documented"))
+extra=$(comm -13 <(printf '%s\n' "$emitted") <(printf '%s\n' "$documented"))
+
+rc=0
+if [ -n "$missing" ]; then
+ echo "::error::these reason tokens are emitted by $src and absent from $doc:"
+ printf ' %s\n' $missing
+ rc=1
+fi
+if [ -n "$extra" ]; then
+ echo "::error::$doc names these tokens and no branch in $src emits them:"
+ printf ' %s\n' $extra
+ rc=1
+fi
+# AND THE 简体中文 MIRROR CARRIES THE SAME SET. A translated page falls behind
+# by losing rows, and a structural check that only counts headings cannot see
+# it: the token names are identical in both languages, so they compare
+# directly even though nothing else on the page does.
+zh=docs/zh/50-machine-output.md
+if [ -f "$zh" ]; then
+ zn=$(grep -n '^| `other` |' "$zh" | head -1 | cut -d: -f1)
+ if [ -z "$zn" ]; then
+ echo "::error::$zh has no \`other\` row; the mirror's table moved"
+ rc=1
+ else
+ zstart=$zn
+ while [ "$zstart" -gt 1 ] && sed -n "$((zstart-1))p" "$zh" | grep -q '^|'; do
+ zstart=$((zstart-1))
+ done
+ zdoc=$(sed -n "${zstart},${zn}p" "$zh" \
+ | grep -oE '^\| `[a-z][a-z0-9-]*` \| [^|]+ \|' \
+ | sed 's/^| `//; s/`.*//' | sort -u)
+ zmiss=$(comm -23 <(printf '%s\n' "$documented") <(printf '%s\n' "$zdoc"))
+ zextra=$(comm -13 <(printf '%s\n' "$documented") <(printf '%s\n' "$zdoc"))
+ if [ -n "$zmiss" ]; then
+ echo "::error::$zh is missing reason tokens that $doc documents:"
+ printf ' %s\n' $zmiss
+ rc=1
+ fi
+ if [ -n "$zextra" ]; then
+ echo "::error::$zh documents reason tokens $doc does not:"
+ printf ' %s\n' $zextra
+ rc=1
+ fi
+ fi
+fi
+
+[ "$rc" -eq 0 ] && echo "OK: $(printf '%s\n' "$emitted" | grep -c .) reason tokens; engine, table and 简体中文 mirror agree"
+exit $rc
diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml
index 05fb6e46..a8ae4131 100644
--- a/.github/workflows/ci-linux.yml
+++ b/.github/workflows/ci-linux.yml
@@ -120,6 +120,18 @@ jobs:
- name: Documented target tiers agree with the table
run: python3 .github/tools/check_target_tiers.py
+ # The reason-token table in docs/50 is a MACHINE INTERFACE: mcpp-index's
+ # compatibility measurement reads a token out of a refusal to tell "this
+ # graph does not supply what the member asked for" from "the member did
+ # not build", and that distinction decides a published figure. A token
+ # the engine emits and the table omits is a promise nobody can rely on.
+ #
+ # Four tokens were added to that table by hand as "the ones it was
+ # missing"; a later enumeration found four more that had been absent the
+ # whole time. A set compared by reading is a set compared by sampling.
+ - name: Reason tokens agree with the engine, and with the mirror
+ run: bash .github/tools/check_reason_tokens.sh
+
- uses: ./.github/actions/bootstrap-mcpp
- name: Configure mirror + Build mcpp from source (self-host)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e026931..216a8a01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,183 @@
## [Unreleased]
+## [2026.9.20.1] - 2026-09-20
+
+### 校验探针量的是构建宿主,而不是它要核对的那个目标
+
+2026.9.18.3 的 c-abi 校验探针在**每一次 freestanding 构建上都没有选中目标**。
+`Toolchain::crossTargetFlag` 只为 hosted 目标设置(它自己的注释写明了原因:freestanding
+目标的 `--target` 必须与随行的 ISA 标志一起给出,放两处就是同一个决定写两遍),而那另一处
+是 `mcpp.freestanding.linkline` 的编译前缀,探针从不问它;`cenv::realise` 对 freestanding
+也不产 `--target`。于是探针的命令行是 `-D__unix__ -fno-short-wchar -ffreestanding -x c++
+-E -dM -`——没有任何目标选择,clang 回答的是它自己所在的那台机器。
+
+在 Linux 宿主上这台机器恰好满足 `__unix__` 已定义、`_WIN32` 未定义、`wchar_t` 32 位,
+于是检查**以错误的理由通过**;在 Windows 宿主上它报 `_WIN32` 已定义、`wchar_t` 16 位,
+两条不匹配同时出现。2026.9.18.3 把这两条读成"Windows 宿主的 clang 即使带上 `--target=`
+仍注入宿主预定义",并据此加了 `hostStripMacros`。该归因不成立:clang 的预定义跟随目标
+而不是宿主。本机实测——`clang --target=riscv64-none-elf -dM` 在 Linux 上 `__linux__` 计数
+为 0、`__SIZEOF_WCHAR_T__` 为 4,而 `--target=x86_64-w64-windows-gnu` 在同一台 Linux 上
+定义 `_WIN32`——如果那个 `--target` 真在命令行上,clang 会答 4 而不是 2。观察到 2,说明
+它不在。
+
+这一版从根因修:
+
+1. **探针拿到目标。** freestanding 目标的 `--target` 与 ISA 标志由
+ `mcpp.freestanding.linkline` 的编译前缀提供,现在进入探针 argv。
+2. **`hostStripMacros` 删除,而删除本身是要点。** `-U_WIN32 -U_WIN64 -U__MINGW32__
+ -U__MINGW64__` 抹掉的正是"探针量错了机器"这件事的唯一证据。将来若真有宿主泄漏,它必须
+ 到达不匹配报告,而不是在能被看见之前就被 undefine 掉。
+3. **装配处拒绝这次遗漏,而不是调用点记得不要犯。** 新增
+ `cenv_probe::assemble_argv`:各个部件在某些配置下都合法地为空,所以没有任何单独一个
+ 能承载这条不变量。freestanding 目标的 argv 没有选中目标即拒绝并点名目标;宿主本地
+ 构建接受没有目标选择的 argv——那里宿主就是目标,缺席是那个决定本身。
+
+单测 `test_cenv_probe.cpp` 的三个 strip 测试被替换:它们测的是"`-U` 有没有到达命令行"
+这个机制,而不是"探针量的是不是正确的机器"这条性质。新的五个 `CenvProbeArgv` 测试**不需要
+交叉工具链**,直接对装配函数断言;另加两个带 clang 的端到端探针测试,在没有 clang 的宿主
+上跳过而不谎报。
+
+### `builtins` 的 Windows 行:结论不变,写在旁边的机制是错的
+
+`cenv.cppm` 称 clang 自带 `intrin.h` / `mm_malloc.h` 的问题"已由既有的 `-nostdlibinc`
+隔离关闭"。实测不成立:`-nostdlibinc` 移除的是标准**系统**头目录,clang 自己的 resource
+目录仍在(那是 `-nobuiltininc` 移除的),带着该标志仍复现 `intrin.h:12:15`——与 mcpp-index
+为 fmtlib.fmt 记录的诊断逐字符相同。真正关掉这两者的是 Cygwin 式实现:`mm_malloc.h:42`
+在 `__MINGW32__` 上选 `__mingw_aligned_malloc`,没有它则落到 `posix_memalign`;而去取
+`` 的源码是在 `_WIN32` 之后才这么做的。调查结论不变(Windows 上没有可关的循环
+惯用法内建),改的是写在它旁边的那句机制。
+
+### 一个包可以陈述它需要该层的哪些接口,解析期回答
+
+能力的"在不在"过去无处可问,于是全被挤到预处理期,而那比答案存在得更早——mcpp#674 的
+全部压力来自这一格空着。openkal SPEC 0.14 §6.2 列出三个时刻并规定每个都是该信息**最早
+能存在**的时刻;§3.3 撤回了它给接口集合起过的唯一一个名字(`hosted`),理由是"一个描述
+环境类别的名字会被没有人想到过的那个环境证伪",替代做法是由消费者逐条列举。
+
+新增 `[kernel-abi]` 表:
+
+```toml
+# 实现方(只有提供该层的包可以写 provides-interfaces)
+[kernel-abi]
+provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory"]
+
+# 消费方(任何包都可以写 requires-interfaces)
+[kernel-abi]
+requires-interfaces = ["openkal.fs", "openkal.net"]
+```
+
+**引擎不认识这两个集合的任何一个成员**:对它们做的唯一操作是集合差
+(`targetside::interfaces_not_provided`),因此某个规范新增一个接口不需要 mcpp 发版。
+不满足即在**编译任何东西之前**拒绝,并同时点名缺的接口、要它的包、以及没提供它的实现——
+只报"缺"会让读者自己去猜该改哪一边。
+
+**一个什么都没陈述的提供者,不是一个什么都不提供的提供者**:实现方没有写
+`provides-interfaces` 的图照常构建,链接仍以它一贯的词汇报告缺席。什么都不写的清单,
+产出的命令行与这项能力存在之前逐字节相同。
+
+拒绝记录的 reason 为 `interface-not-provided`,并且**这个令牌也印在拒绝消息里**,用方括号
+包着,与 `E0006` 同一个约定——mcpp-index 的兼容性测量靠它把「这个图不供给这个成员所要的」
+与「这个成员没能构建」分开,而一条只有人能认出的拒绝会逼迫那个消费者去匹配散文。
+`docs/50` 的 reason 令牌表同时补上了 2026.9.18.1 起一直在发却从未列出的三个:
+`c-env-unrealisable`、`c-env-verification-mismatch`、`platform-dependency`。
+
+### 理由令牌表与引擎不再靠人读对齐
+
+`docs/50` 的 reason 令牌表是**机器接口**:mcpp-index 的兼容性测量就是从拒绝里读一个
+令牌,来区分「这个图没有提供该成员要的东西」与「该成员没构建成功」,而这条区分决定一个
+会被发布的数字。引擎能发而表里没有的令牌,是一条没有人能依赖的承诺。
+
+2026.9.18.1 那轮往这张表里补过「缺的那四个」;本轮枚举发现**另外四个**一直缺着——
+`apple-sdk-absent`、`lld-required-absent`、`host-tool-toolchain`、`std-module-precompile`。
+**靠读来比较的集合,比较的是样本。**
+
+四条补齐,并新增 `.github/tools/check_reason_tokens.sh`:它双向比对
+`refusal.cppm` 能发出的令牌与表里的行,并要求简体中文镜像携带同一个集合。
+两个方向各去掉一条都会红。
+
+⚠️ 这张表的**列头**是 `| \`reason\` | |`——第一格里一个反引号名字,形状与下面每一行
+完全相同。按「行首反引号名字」匹配会把 `reason` 当成一个令牌。行与列头的区别在**第二格
+非空**,所以判据按性质挑对象,不按语法挑。
+
+### `[c-abi-absent]`:枚举例外,不枚举规则
+
+一个 C 库供给的名字集合在清单里不可枚举(POSIX 约一千二百个),枚举它正是 §3.3 记录下
+撤回的那个错误。例外是可枚举的——openkal-musl 的 README 列了六项,而那段散文没有任何
+东西在执行它,并且已经被推翻过一次(0.16.0 之前 `SIG_IGN` 对每个信号都被接受却一个都没
+安装)。
+
+```toml
+[c-abi-absent]
+fork = { form = "link" }
+mprotect = { form = "enosys", note = "openkal 没有作用于映射保护属性的操作" }
+tcsetattr = { form = "accepted-no-effect", note = "openkal 不命名的那些字段不被施加" }
+```
+
+`form` 必填且封闭。`link` 是 openkal 自己的能力模型对实现所要求的形状(§6.1 把运行期
+报告不支持称为缺陷);另外两个是对它的偏离,给它们命名是为了让一次偏离成为可以被数出来
+的东西。链接点到 `link` 形状里的某一项时,mcpp 把清单读回来:`undefined reference to
+'fork'` 因此带着那句说明它是缺陷还是环境限制的话一起到达。
+
+**同一形状的第二处:拒绝消息里那行标签可以被读成它所否认的那句话。** 缺失的接口列在
+上面,紧接着一行 `provided by fakekernel (2 interfaces)`——读起来正是
+「openkal.space 由 fakekernel 提供」,而这条拒绝存在的理由恰恰是它**没有**提供。改成
+「the resolved implementation is fakekernel (2 interfaces), and none of those listed
+above is among them」。e2e 743 此前的每一条断言都只匹配**标识符**,而标识符在两种措辞下
+都在正确的位置;现在它断言整句,并显式拒绝旧措辞。
+
+**这条说明此前少一个右括号,而九个单测都没看见。** 渲染出来是
+`the C library in this graph (musl declares that it does not supply ...`——C 库的名字由
+**两个各自独立的条件**插进去:一个左括号,然后是名字,右括号从来没有被发出过。单测断言的
+是 `find("musl")`,那句话里同样有 "musl"。**判据瞄准一句话的子串,就看不见这句话。**
+修法是整个括号部分只做一次替换;新增的 e2e 744 真跑一次链接失败并断言整句,把右括号去掉
+它就红。
+
+**它是顶层表,而这是量出来的。** 先写成 `[c-abi].absent`——更顺——之后拿**真正发布的
+2026.9.18.3 归档**(当时的索引 floor)跑 openkal-musl 0.17.0 将要发布的那份清单:嵌套
+写法让每个旧 mcpp **在每个目标上拒绝整份清单**,报 `[c-abi] has no member 'absent'`。
+`[c-abi]` 枚举自己的成员并拒绝其余,而这条严格性是对的——拼错的 `presents` 不该静默
+关掉一条声明。同一次测量里,未知的**顶层**表被忽略,构建照常完成。
+
+这张表做的每件事都是诊断性的:给一次**已经失败**的链接加一句话,没有任何 flag、链接行
+或产物依赖它。于是忽略它的引擎产出的正是它今天产出的那条链接错误;而拒绝它的引擎会把
+索引 floor 逼到本版本——为了一句他们无非是收不到的说明,夺走停在其下的每个客户端手里的
+**整个索引**。改成顶层表后,openkal-musl 0.17.0 不要求任何 floor 变动。
+
+把 `absent` 写在 `[c-abi]` 里面会被拒绝,拒绝消息点名顶层的那个拼法。
+
+### 汇编器先问 PATH 再看沙箱,而其余每一个工具都反过来
+
+`find_usable_nasm` 先 `which("nasm")`,沙箱里那份钉住的只作兜底。于是装了汇编器的
+机器用它自己的那一份,没装的下载钉住的那一份——**三台机器可以从同一棵源码树产出三份
+不同的目标文件,而两次构建里都没有一行说它用的是哪一个**。引擎里其余每一个工具都不是
+这个方向:编译器与链接器是载荷,C 库与 C++ 运行时是包,`ninja` 与 `patchelf` 走 xlings,
+`ar` / `strip` / `objcopy` 由解析出的工具链自己的目录派生且从不是裸名。
+
+顺序反过来:先沙箱,后宿主。宿主那一份**保留**,因为一台离线而本来就装了可用汇编器的
+机器仍然应当能构建——但被用到时构建会点名它:
+
+```
+degraded: the assembler for this build is the host's ('/usr/bin/nasm'), not the one this engine pins
+```
+
+`docs/20` 新增一节列全 mcpp 在宿主上取的每一项与各自的理由。**一个宿主工具到达构建
+本身不是缺陷,静默地到达才是**——所以那是一张表而不是一条禁令。
+
+### `presents` 的取值集冻结
+
+docs/22 写明:`presents` 回答的是源码看到哪些环境身份宏,**不回答任何能力是否存在**。
+一个包不得由它推断某个接口、某个头或某个路径是否可用。取值集不增长,理由与 openkal 为
+自己的核心集封闭所给的相同——一个描述环境**类别**的名字会被没有人想到过的那个环境证伪,
+而 openkal 把自己发过的唯一一个这样的名字在一个发布周期之内撤回了。
+
+(`src/toolchain/cenv.cppm`、`src/toolchain/cenv_probe.cppm`、`src/build/prepare.cppm`、
+`src/build/ninja_backend.cppm`、`src/build/refusal.cppm`、
+`modules/manifest/src/{targetside_model,toml,types}.cppm`,
+单测 `test_cenv_probe.cpp`、`test_manifest.cpp`、`test_targetside.cpp`、
+`test_build_flags.cpp`,e2e `tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh`,
+docs/22 及其 zh 镜像,`modules/versioning/src/version.cppm`、`mcpp.toml`)
+
## [2026.9.18.3] - 2026-09-18
### Windows 主机 × freestanding 目标的 c-abi 校验探针两处真实缺陷被关掉
diff --git a/README.md b/README.md
index 7f9c2c2d..f169b491 100644
--- a/README.md
+++ b/README.md
@@ -18,13 +18,14 @@
-> **Note (2026.9.18.3):** this release adds `cenv_probe::verify` host-macro
-> stripping for the Windows host and forces `-fno-short-wchar` on freestanding
-> wchar realisation. `[c-abi]` packages (`openkal-musl` 0.15.0 and any future
-> C-library package) require this engine. Older engines silently misbuild them:
-> the c-abi probe sees host contamination (`_WIN32`, `__MINGW32__`,
-> `__MINGW64__`, `_WIN64`) on Windows and reads the wrong `__SIZEOF_WCHAR_T__`
-> on a freestanding target. Upgrade: `xlings install mcpp --force`.
+> **Note (2026.9.20.1):** the `[c-abi]` verification probe now selects the
+> target it is verifying. On a freestanding target it selected none and
+> answered for the build host, which on a Linux host passed for the wrong
+> reason and on a Windows host failed for one. The `hostStripMacros`
+> compensation 2026.9.18.3 added is removed with it. This release also adds
+> `[kernel-abi] provides-interfaces` / `requires-interfaces`, answered at
+> dependency resolution, and `[c-abi-absent]`, which states what a C library
+> does not supply and in what shape. See CHANGELOG and docs/22.
## Highlights
diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md
index f66e43ed..74f0f266 100644
--- a/docs/20-toolchains.md
+++ b/docs/20-toolchains.md
@@ -647,6 +647,73 @@ mcpp build --target aarch64-ios # resolves llvm@22.1.8 + the iPhoneOS SDK
mcpp build --target aarch64-ios-sim # resolves llvm@22.1.8 + the Simulator SDK
```
+## The host surface mcpp keeps, and the reason for each
+
+The rule this engine is arranged around: **a build is reproducible only if the
+tools that made it came from somewhere the next machine can get them from.**
+Every tool a build uses therefore comes from the dependency graph or from
+xlings, and the list below is the whole of what does not -- each entry with the
+reason it cannot.
+
+| item | route | the reason it is not the ecosystem's |
+|---|---|---|
+| **xlings itself** | bundled in mcpp's own payload (`[xlings] binary = "bundled"`, the default). `"system"` opts into a PATH lookup. | Bootstrap: something has to fetch the first package. The default is the bundled copy, so the host route is a choice a user makes rather than a fallthrough. |
+| **an Apple SDK** | located through `xcrun`, never installed | Not redistributable. There is nothing to package, and the refusal says so before any payload is resolved. |
+| **the iOS simulator runtime** | `simctl`, through `xim:apple-simulator-tools` | The same. |
+| **an assembler (`nasm`)** | the pinned `xim:nasm` first; the host's only when that route could not serve, and **named in the build report** when it is used | An offline machine that already has a usable assembler can still build. It used to be the other way round -- see below. |
+| **a C++ compiler on PATH (`$CXX`, else `g++`)** | `mcpp doctor` only | That command's job is to report on the host. The build path sets the compiler from a resolved payload at every branch that reaches the probe, and refuses when the payload cannot be resolved rather than falling through to PATH. |
+| **a command interpreter (`/bin/sh`, `cmd.exe` on Windows)** | `run_shell_deadline` for `[hooks]`, `run_streaming_bounded` for the xlings CLI, and the detached codegen command | A hook is a line the USER wrote in shell syntax. Shipping a shell would change the language that line is read in, so the thing mcpp depends on here is not a tool it could package -- it is the host's agreement about what that line means. |
+| **the MSVC toolset and the Windows SDK** | `msvc@system`, which a user names; or a managed toolset that has no SDK payload beside it | Not redistributable, the same category as the Apple SDK. A managed toolset BINDS its SDK and ignores `WindowsSdkDir` even when set, because a pin the environment can overwrite is not a pin; the fallback to the machine's SDK works, is not reproducible, and carries a note saying so (`SdkChoice::note`, which the caller must surface). |
+
+That list is meant to be exhaustive, and it is derived rather than remembered.
+Four sweeps over `src/` and `modules/` reach it. Every `fs::which` call --
+there are exactly five, and each is a row above: `toolchain/probe` for `$CXX`,
+`config` and two in `fallback/xlings_binary` for xlings, and `xlings/xlings`
+for the assembler. Every string literal rooted at `/usr`, `/bin`, `/opt` or
+`/etc`. Every `cmd.exe` and `COMSPEC` use. And every `getenv` naming something
+a host toolchain sets -- `CXX`, `SDKROOT`, `WindowsSdkDir`,
+`WindowsSdkVersion`, `VSINSTALLDIR`, `MACOSX_DEPLOYMENT_TARGET` (the other
+`getenv` reads are mcpp's own `MCPP_*` knobs and `HOME`, which name no host
+tool). The first version of this table
+was written from the first sweep alone and was missing the last two rows; the
+sweeps are written down here so that the next reader checks the list rather
+than trusting it.
+
+Two results of those sweeps are worth naming because they point the other way.
+`mcpp.toolchain.registry` REFUSES a payload descriptor whose `frontend` names
+`/usr/bin/g++` or climbs out with `../` -- and validates it as a string rather
+than through `std::filesystem::path`, because `path("/usr/bin/g++")
+.is_absolute()` is false on Windows, where the guard therefore did not look
+(measured 2026-09-11). And `src/runtime/elf` writes `/usr/lib` and `/usr/lib64`
+only to MODEL what a loader will search at run time; mcpp reads nothing there.
+
+Everything else comes from the graph or from xlings, including the ones most
+often assumed to be the host's: the compiler and the linker (a payload), the
+C library and the C++ runtime (packages), `ninja` and `patchelf` (xlings), and
+`ar` / `strip` / `objcopy` (derived from the resolved toolchain's own
+directory, never a bare name -- `mcpp.toolchain.registry::binutils_tool`
+returns an existing absolute path or nothing).
+
+**The assembler was the one that pointed the wrong way, and it is worth
+recording rather than quietly turning around (2026.9.20.1).**
+`find_usable_nasm` asked PATH before it looked in the sandbox. A machine with
+an assembler installed assembled with that one; a machine without downloaded
+the pinned copy. Three machines could produce three different objects from one
+source tree, and no line of either build said which assembler it used. The
+order is now the other way, and when the host copy is the one that served, the
+build says so:
+
+```
+warning: the assembler for this build is the host's ('/usr/bin/nasm'), not the one this engine pins
+ impact: two machines can assemble the same source with different assemblers, and the build records only this line
+ hint: run `xlings install nasm` so the pinned copy is used
+```
+
+**A host tool that reaches a build is not by itself the defect. A host tool
+that reaches a build silently is.** That is why the entries above are a table
+rather than a prohibition: each is reachable, each has a reason, and each says
+so where it is used.
+
### The host surface this adds, named and bounded
Two items, both macOS-only, both in the category a proprietary runtime that
diff --git a/docs/22-target-side.md b/docs/22-target-side.md
index 97544a75..ff90536e 100644
--- a/docs/22-target-side.md
+++ b/docs/22-target-side.md
@@ -334,6 +334,20 @@ ignored. **A package that declares no `[c-abi]` block changes nothing**: the
resolved target side, every compile command and every cache key are
byte-identical to a build before this feature existed.
+**`presents` IS FROZEN AT THREE VALUES, AND IT ANSWERS NO QUESTION ABOUT
+CAPABILITY.** It states which environment-identity macros source sees. It does
+not state that any interface, any header or any path is available, and a
+package that infers one from it has read a fact it was not given: a
+POSIX-presenting environment on a Windows target has no `fork`, no `/proc` and
+no `epoll`, and says so by the ordinary means — the definition is absent at the
+link. The value set does not grow, for the reason openkal's own specification
+gives for closing its core set (SPEC 0.14 §3.2): a name that describes a CLASS
+of environment is falsified by an environment nobody had in mind, and openkal
+withdrew the one such name it had shipped (`hosted`) within a release of
+naming it. A fourth value here would be that name again. What a package needs
+in order to know whether a capability is present is answered where the answer
+first exists, at dependency resolution, by the interface enumeration below.
+
Three facts, kept separate, because none of them implies another: `presents`
picks the source branch, `data-model`/`wchar` pick the ABI. POSIX does not
imply LP64 (it is ILP32 on a 32-bit architecture), and LP64 does not imply
@@ -484,25 +498,130 @@ the build and prints both the declared and the measured values. The result
is cached per configuration (compiler binary identity + exact flags), so a
build that resolves the same configuration twice pays for the probe once.
-**Host contamination (mcpp 2026.9.18.3+).** The probe runs on the BUILD
-host's clang, not a target-native one, and on a Windows host the driver's
-predefines (`_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__`) leak through
-`--target=` substitution for a freestanding target the same way the
-Cygwin-substituted Windows row's `__CYGWIN__` does NOT — a structural
-difference between the hosted and freestanding substitutions that the
-verification step's measurement has to compensate for. The probe accepts
-a `hostStripMacros` list of `-U` tokens it prepends to its `-dM`
-command, and the caller (this layer's `prepare`) supplies exactly the four
-Windows-host names on Windows, nothing on Linux or macOS. The matching
-defect on `__SIZEOF_WCHAR_T__` (Windows host × freestanding measures 2
-where a `wchar = 32` declaration asks for 4) is closed on the realisation
-side, not the probe side: `cenv::realise` now ALWAYS emits
-`-fno-short-wchar` for `decl.wcharBits = 32`, regardless of what the
-host's toolchain would default to, so the probe measures the state the
-engine actually produced (32 bits, with the flag) rather than the host's
-leak. The "freestanding skips the wchar flag" rule the wave's earlier
-versions carried was an unverified assumption about the toolchain default,
-and the wave's measurement is what verified it wrong.
+**The probe's command line selects the target, and for a freestanding target
+it once did not (mcpp 2026.9.20.1).** Clang is one binary that emits every
+target it was built with, so a command line naming no target answers for the
+machine it runs on. A hosted cross target reaches the probe through
+`--target=`; a freestanding target's selection travels with the ISA flags that
+must accompany it, in the freestanding compile prefix, and the probe was never
+given it. Every freestanding build therefore verified its declaration against
+the build host: on a Linux host that host happens to satisfy `__unix__`
+defined, `_WIN32` undefined and a 32-bit `wchar_t`, so the check passed for
+the wrong reason; on a Windows host it reported `_WIN32` defined and a 16-bit
+`wchar_t` and the build failed.
+
+2026.9.18.3 read that Windows failure as the `--target=` substitution failing
+to strip host predefines, and added a `hostStripMacros` list of `-U`
+tokens the caller prepended to the `-dM` command. Clang's predefines follow
+the target rather than the host — `--target=riscv64-none-elf` reports
+`__riscv`, no `__linux__`, and `__SIZEOF_WCHAR_T__` 4 on a Linux host — so
+there was no substitution to fail. That list is removed, and its removal is
+the point rather than a tidy-up: it deleted the one piece of evidence that
+said the probe was measuring the wrong machine.
+
+The assembly refuses the omission instead of each call site remembering not to
+make it. `cenv_probe::assemble_argv` returns a refusal when a freestanding
+target's argv selects no target, and accepts an argv with none for a native
+hosted build, where the host IS the target and the absence is the decision
+rather than its omission.
+
+The matching defect on `__SIZEOF_WCHAR_T__` remains closed on the realisation
+side, and independently of the above: `cenv::realise` always emits
+`-fno-short-wchar` for `decl.wcharBits = 32` regardless of what a toolchain
+would default to, so the compile produces the width the declaration states
+rather than inheriting one.
+
+**Which interfaces of a layer a package provides, and which it requires
+(mcpp 2026.9.20.1).** A capability is present or absent, and a package needs to
+be able to state what it needs before it is built. openkal's own specification
+(0.14 §3.3) withdrew the one name it had given to a SET of interfaces
+(`hosted`) on the grounds that a name describing a class of environment is
+falsified by an environment nobody had in mind, and replaced it with
+enumeration by the consumer, in the consumer's own package. mcpp carries that
+enumeration for the `kernel-abi` layer:
+
+```toml
+# the implementation
+[package]
+provides = ["mcpp:kernel-abi=openkal"]
+
+[kernel-abi]
+provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory",
+ "openkal.env", "openkal.time", "openkal.fs"]
+
+# a consumer
+[kernel-abi]
+requires-interfaces = ["openkal.fs", "openkal.net"]
+```
+
+`provides-interfaces` may be stated only by a package that provides the layer,
+the rule `[c-abi]` follows and for the same reason. `requires-interfaces` has
+no such restriction: it is a statement about the package making it.
+
+**The engine knows no member of either set.** The only operation performed on
+them is a set difference, so a specification may add an interface without a
+release of mcpp, and a misspelling produces a refusal naming the string rather
+than a silently disabled check — a name that is not provided is missing
+whether or not it exists.
+
+**The question is answered at dependency resolution because that is the
+earliest time it can be answered.** openkal SPEC 0.14 §6.2 tabulates three
+times at which information about a capability becomes available and states
+that each is the earliest at which it exists: resolution answers "may this
+program be built against this implementation", the link answers "was an
+interface used that the implementation does not provide", a property word
+answers "how does it behave within an interface it provides". Source asking
+the same question with `#ifdef` asks it during preprocessing, which is earlier
+than any answer exists.
+
+**A provider that states nothing is not a provider that provides nothing.**
+A graph whose implementation carries no `provides-interfaces` builds
+unchanged; the key postdates the packages, and the link still reports an
+absence in the vocabulary it always did.
+
+**What a C library does not supply (`[c-abi-absent]`, mcpp 2026.9.20.1).**
+The set of names a C library supplies is not enumerable in a manifest — POSIX
+has about twelve hundred — and enumerating it is the mistake §3.3 records
+withdrawing. The exceptions are enumerable:
+
+```toml
+[c-abi-absent]
+fork = { form = "link" }
+mprotect = { form = "enosys", note = "openkal has no operation upon a mapping's protection" }
+tcsetattr = { form = "accepted-no-effect", note = "the fields openkal does not name are not applied" }
+```
+
+`form` is required and closed. `link` is the shape openkal's own capability
+model requires of an implementation (§6.1 calls a run-time report of
+unsupportedness a defect); the other two are departures from it, and they are
+named so that a departure is something that can be counted. mcpp reads the
+list back when a link names one of the `link`-shaped entries, so
+`undefined reference to 'fork'` arrives with the sentence that says whether it
+is a defect or a limit of the environment this program was built for.
+
+**An engine that predates these keys, and why both tables are top-level.**
+An older mcpp ignores an unknown TOP-LEVEL table and refuses an unknown MEMBER
+of a table it knows --- the second is what keeps a misspelled `presents` from
+silently disabling a declaration. Both new tables are therefore top-level, and
+for `[c-abi-absent]` that placement is a measurement rather than a preference.
+
+Written as `[c-abi].absent`, which reads better, every mcpp older than
+2026.9.20.1 refuses the whole manifest on every target with `[c-abi] has no
+member 'absent'`. Measured against the genuine published 2026.9.18.3 archive
+--- the index floor at the time --- on the exact manifest openkal-musl 0.17.0
+publishes. Everything this table does is diagnostic: it annotates a link that
+has already failed, and no flag, link line or artifact depends on it. So an
+engine that ignores it produces exactly the linker error it produces today,
+while an engine that refuses it takes the package away entirely, and would
+have forced the index floor up to this release --- taking the whole index
+away from every client stopped below it, for a note they merely would not
+have received.
+
+The consequence for a package: adopting either table requires nothing of the
+index floor, and a graph resolved by an older engine keeps building. What it
+loses is the check and the note, which is the behaviour that release already
+had. Writing `absent` inside `[c-abi]` is refused with a message naming the
+top-level spelling.
**Fingerprint.** The realised environment participates in the build's
fingerprint (`compileFlags`, §92's field 7): two builds whose C library
diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md
index 295ef7ac..d27aaec2 100644
--- a/docs/50-machine-output.md
+++ b/docs/50-machine-output.md
@@ -412,8 +412,25 @@ a program classifying the outcome reads `reason`:
| `package-cycle` | the dependency graph contains a cycle of packages; the message names its edges *(2026.9.16.1+)* |
| `program-cxx-runtime-split` | a program or test that states a self-contained C++ runtime loads a C++ shared library of the build that couples to a shared one *(2026.9.16.1+)* |
| `static-package-in-two-images` | a static package several images of the build reach, on a target where an image cannot use another image's copy *(2026.9.16.1+)* |
+| `c-env-unrealisable` | the resolved C library's `[c-abi]` declaration has no known realisation for this target, or the resolved compiler cannot carry one out *(2026.9.18.1+)* |
+| `c-env-verification-mismatch` | the probe compiled with the realised `[c-abi]` configuration disagrees with what was declared *(2026.9.18.1+)* |
+| `platform-dependency` | `[build] platform-dependencies = "refuse"` and a package in the graph brings a platform SDK *(2026.9.18.1+)* |
+| `interface-not-provided` | a package's `[kernel-abi] requires-interfaces` names an interface the resolved implementation does not provide *(2026.9.20.1+)* |
+| `apple-sdk-absent` | the target needs an Apple SDK this machine does not provide; it is located rather than installed, because it is not redistributable |
+| `lld-required-absent` | the target links through lld directly and the resolved toolchain payload ships none |
+| `host-tool-toolchain` | `build.mcpp` under a cross `--target` needs a resolvable HOST toolchain and none is set |
+| `std-module-precompile` | the standard library's module could not be precompiled for this configuration |
| `other` | a refusal whose branch has not been given a token yet |
+**One token is also printed by `mcpp build` itself.**
+`interface-not-provided` appears in the refusal's own message, in brackets, the
+way `E0006` does. A refusal that only a person can recognise forces every
+machine consumer to match prose, and prose a package's own compile error could
+coincidentally contain; the mcpp-index compatibility measurement tells "this
+graph does not supply what the member asked for" from "the member did not
+build" on exactly this token, and that distinction decides whether a member
+counts against a compatibility figure.
+
**Exit 0 whenever the question was answered, including "refused".** "Would
this build, and if not why" is answered successfully by "no, because the row's
pin is a capability". A non-zero exit means the query itself could not run.
diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md
index ff6e73e8..aaaaedcc 100644
--- a/docs/zh/20-toolchains.md
+++ b/docs/zh/20-toolchains.md
@@ -584,6 +584,59 @@ mcpp build --target aarch64-ios # 解析 llvm@22.1.8 + iPhoneOS SDK
mcpp build --target aarch64-ios-sim # 解析 llvm@22.1.8 + 模拟器 SDK
```
+## mcpp 保留的宿主面,以及每一项的理由
+
+这个引擎围绕的规矩:**一次构建可复现,当且仅当造它的工具来自下一台机器也能拿到的
+地方。** 所以每一个参与构建的工具都来自依赖图或 xlings,而下面这张表是**不来自**
+那里的全部——每一条都带着它为什么不能。
+
+| 项 | 到达方式 | 不在生态里的理由 |
+|---|---|---|
+| **xlings 自己** | 随 mcpp 的载荷捆绑(`[xlings] binary = "bundled"`,默认)。`"system"` 才去查 PATH。 | 自举:总得有东西去取第一个包。默认是捆绑的那份,所以走宿主是用户做的一个选择,不是一次兜底。 |
+| **Apple SDK** | 经 `xcrun` **定位**,从不安装 | 不可再分发。没有东西可打包,而拒绝在解析任何载荷之前就点名它。 |
+| **iOS 模拟器运行时** | `simctl`,经 `xim:apple-simulator-tools` | 同上。 |
+| **汇编器(`nasm`)** | 先取钉住的 `xim:nasm`;只有那条路服务不了时才取宿主的,**且被用到时在构建报告里点名** | 一台离线而本来就装了可用汇编器的机器仍然能构建。它此前是反着的——见下。 |
+| **PATH 上的 C++ 编译器(`$CXX`,否则 `g++`)** | 只有 `mcpp doctor` | 那个命令的职责就是报告宿主。构建那条路在每一个到得了这个探针的分支上都从解析出的载荷设定编译器,载荷解析不了时**拒绝**,而不是落到 PATH。 |
+| **命令解释器(`/bin/sh`,Windows 上 `cmd.exe`)** | `[hooks]` 走 `run_shell_deadline`,xlings CLI 走 `run_streaming_bounded`,另有分离式 codegen 命令 | 一条 hook 是**用户自己**用 shell 语法写下的那一行。自带一个 shell 会改变那一行被解读所用的语言,所以这里依赖的不是一个它能打包的工具,而是宿主对那一行含义的约定。 |
+| **MSVC 工具集与 Windows SDK** | 用户点名 `msvc@system`;或一个受管工具集旁边没有 SDK 载荷 | 不可再分发,与 Apple SDK 同类。受管工具集**绑定**自己的 SDK,即使 `WindowsSdkDir` 被设置也不理会——一个环境能覆盖的钉不是钉;回落到机器自己那份能用、不可复现,因此带一句说明(`SdkChoice::note`,调用方必须把它呈现出来)。 |
+
+这张表意在穷举,而它是**推导出来的**,不是回忆出来的。四次扫描就能重新得到它。每一处
+`fs::which` 调用——**恰好五处**,每一处都对应上面的一行:`toolchain/probe` 取 `$CXX`、
+`config` 与 `fallback/xlings_binary` 两处取 xlings、`xlings/xlings` 取汇编器。每一个以
+`/usr`、`/bin`、`/opt`、`/etc` 为根的字符串字面量。每一处 `cmd.exe` 与 `COMSPEC`。
+以及每一个读取宿主工具链所设变量的 `getenv`——`CXX`、`SDKROOT`、`WindowsSdkDir`、
+`WindowsSdkVersion`、`VSINSTALLDIR`、`MACOSX_DEPLOYMENT_TARGET`(其余 `getenv` 读的是
+mcpp 自己的 `MCPP_*` 旋钮与 `HOME`,不指向任何宿主工具)。
+这张表的第一版只做了第一次扫描,因此缺了最后两行;把扫描方式写在这里,是为了让下一个
+读者去**核**这张表而不是**信**它。
+
+其中两条扫描结果值得点名,因为它们指向相反的方向。`mcpp.toolchain.registry` **拒绝**
+一个 `frontend` 写着 `/usr/bin/g++` 或用 `../` 爬出去的载荷描述符——并且是按**字符串**
+校验而不是经 `std::filesystem::path`,因为 `path("/usr/bin/g++").is_absolute()` 在
+Windows 上为假,那道闸恰恰在那台宿主上没有看(2026-09-11 实测)。而 `src/runtime/elf`
+里写下 `/usr/lib` 与 `/usr/lib64`,只是为了**建模**运行期加载器将要搜索的地方,mcpp
+不从那里读任何东西。
+
+其余全部来自图或 xlings,包括最常被当成宿主的那几个:编译器与链接器(载荷)、
+C 库与 C++ 运行时(包)、`ninja` 与 `patchelf`(xlings),以及 `ar` / `strip` /
+`objcopy`(由解析出的工具链自己的目录派生,**从不是一个裸名**——
+`mcpp.toolchain.registry::binutils_tool` 要么返回一个存在的绝对路径,要么什么都不返回)。
+
+**汇编器是唯一指错方向的那一个,值得记下来而不是悄悄掉个头(2026.9.20.1)。**
+`find_usable_nasm` 先问 PATH 再看沙箱。装了汇编器的机器用它的那一份,没装的机器下载
+钉住的那一份。三台机器可以从同一棵源码树产出三份不同的目标文件,而两次构建里都没有
+一行说它用的是哪一个。现在顺序反过来了;当服务的是宿主那一份时,构建会说出来:
+
+```
+warning: the assembler for this build is the host's ('/usr/bin/nasm'), not the one this engine pins
+ impact: two machines can assemble the same source with different assemblers, and the build records only this line
+ hint: run `xlings install nasm` so the pinned copy is used
+```
+
+**一个宿主工具到达构建,本身不是缺陷;一个宿主工具**静默地**到达构建才是。**
+这就是上面是一张表而不是一条禁令的原因:每一条都到得了,每一条都有理由,而每一条都
+在它被用到的地方说出来。
+
### 这条路新增的宿主面,具名且有界
两项,都只在 macOS 上,都落在「一个只存在于它自己那个操作系统上的专有运行时」这一类:
diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md
index 9d4dd872..d6392f4b 100644
--- a/docs/zh/22-target-side.md
+++ b/docs/zh/22-target-side.md
@@ -280,6 +280,15 @@ builtins = "iso" # iso | platform(默认 platform)
未知的键或未知的取值永远是解析错误,并点名该键——绝不静默忽略。**不声明 `[c-abi]` 块的包
不改变任何东西**:解析出的目标侧、每条编译命令、每个缓存键,都与这项能力出现之前逐字节相同。
+**`presents` 冻结在三个取值,而且它不回答任何关于能力的问题。** 它陈述的是源码看到哪些
+环境身份宏。它不陈述任何接口、任何头文件、任何路径是否可用;由它推断出这些的包,读到了
+一个它没有被给予的事实——Windows 目标上呈现 POSIX 的环境没有 `fork`、没有 `/proc`、
+没有 `epoll`,而它陈述这件事的方式是寻常的那一种:定义在链接期缺席。取值集不增长,理由
+与 openkal 规范为自己的核心集封闭所给的相同(SPEC 0.14 §3.2):一个描述环境**类别**的名字,
+会被没有人想到过的那个环境证伪,而 openkal 把自己发过的唯一一个这样的名字(`hosted`)
+在一个发布周期之内撤回了。在这里加第四个取值就是再造一次那个名字。一个包要知道某项能力
+在不在,答案在它最早存在的地方回答——依赖解析,由下文的接口枚举给出。
+
三件事互不推导,这是刻意保持分开的:`presents` 决定源码走哪条分支,`data-model`/`wchar`
决定 ABI。POSIX 不蕴含 LP64(32 位架构上是 ILP32),LP64 也不蕴含 POSIX。
@@ -388,18 +397,95 @@ c-environment = "platform"
同时打印声明值与实测值。结果按配置(编译器二进制身份 + 最终参数)缓存,同一配置解析两次只
编译一次探针。
-**主机污染(mcpp 2026.9.18.3+)。** 探针跑在**构建主机**的 clang 上,而不是目标本地的;
-Windows 主机的驱动会预先定义 `_WIN32`、`_WIN64`、`__MINGW32__`、`__MINGW64__`,这些定义会
-穿透 `--target=` 替换到达 freestanding 目标——而 hosted 三元组的 `--target=` 替换是不会
-让 `_WIN32`/`_WIN64` 漏出来的(那是 Cygwin 那一行已经处理过的事情)。这是 hosted 与
-freestanding 替换之间一个结构性差异,核对步骤的测量必须为此做补偿。探针接受一个
-`hostStripMacros` 参数,即一组 `-U` 令牌,它在 `-dM` 之前前置;调用方(本层的
-`prepare`)在 Windows 主机上恰好传那四个名字,Linux/macOS 主机上传空集合。`__SIZEOF_WCHAR_T__`
-那一半(Windows 主机 × freestanding 测出 2,而 `wchar = 32` 的声明要求 4)是在实现侧关的,
-不是探针侧:`cenv::realise` 现在对 `decl.wcharBits = 32` **一律**发 `-fno-short-wchar`——
-不论宿主工具链的默认值是什么——这样探针测量到的就是引擎实际产出的状态(32 位、令牌在),
-而不是主机的泄漏。早先版本里「freestanding 跳过 wchar 令牌」那条规则是一个未实测的关于
-工具链默认值的假设,本轮的测量把它证伪了。
+**探针的命令行必须选中目标,而在 freestanding 上它一度没有(mcpp 2026.9.20.1)。**
+clang 是一个二进制,它能产出被编译进去的每一个目标,因此一条不点名目标的命令行回答的是
+它自己所在的那台机器。宿主交叉目标经 `--target=` 到达探针;freestanding 目标的目标选择
+与必须随行的 ISA 标志一起,走 freestanding 编译前缀那条路,而探针从未拿到它。于是每一次
+freestanding 构建都是拿宿主去核对声明:在 Linux 宿主上,那台宿主恰好满足 `__unix__` 已
+定义、`_WIN32` 未定义、`wchar_t` 32 位,于是检查以错误的理由通过;在 Windows 宿主上它报
+`_WIN32` 已定义、`wchar_t` 16 位,构建失败。
+
+2026.9.18.3 把那次 Windows 失败读成"`--target=` 替换没能剥掉宿主预定义",于是加了一个
+`hostStripMacros` 列表,由调用方在 `-dM` 之前前置一组 `-U`。clang 的预定义跟随的是
+目标而不是宿主——在 Linux 宿主上 `--target=riscv64-none-elf` 报 `__riscv`、不报 `__linux__`、
+`__SIZEOF_WCHAR_T__` 为 4——所以根本没有一次替换失败过。那个列表被删除,而删除本身就是
+要点而非顺手整理:它抹掉的正是"探针量错了机器"这件事的唯一证据。
+
+装配处拒绝这次遗漏,而不是每个调用点各自记得不要犯。`cenv_probe::assemble_argv` 在一个
+freestanding 目标的 argv 没有选中目标时返回拒绝;而对宿主本地构建接受没有目标选择的
+argv——那里宿主**就是**目标,缺席是那个决定本身,不是它的遗漏。
+
+`__SIZEOF_WCHAR_T__` 那一处配套缺陷仍然关闭在实现侧,并且与上述无关:`cenv::realise`
+对 `decl.wcharBits = 32` 一律发 `-fno-short-wchar`,不论某个工具链的默认值是什么,于是
+编译产出的宽度是声明所述的那个,而不是继承来的。
+
+**一个包提供了该层的哪些接口,又需要哪些(mcpp 2026.9.20.1)。** 一项能力要么在要么不在,
+而一个包需要能够在被构建之前陈述它需要什么。openkal 自己的规范(0.14 §3.3)撤回了它给
+接口**集合**起过的唯一一个名字(`hosted`),理由是:一个描述环境类别的名字,会被没有人
+想到过的那个环境证伪;替代做法是由消费者在自己的包里逐条列举。mcpp 为 `kernel-abi` 层
+承载这份列举:
+
+```toml
+# 实现方
+[package]
+provides = ["mcpp:kernel-abi=openkal"]
+
+[kernel-abi]
+provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory",
+ "openkal.env", "openkal.time", "openkal.fs"]
+
+# 消费方
+[kernel-abi]
+requires-interfaces = ["openkal.fs", "openkal.net"]
+```
+
+`provides-interfaces` 只有提供该层的包可以陈述,这是 `[c-abi]` 遵循的同一条规则,理由
+相同。`requires-interfaces` 没有这个限制:它是关于陈述它的那个包自身的一句话。
+
+**引擎不认识这两个集合的任何一个成员。** 对它们做的唯一操作是集合差,因此某个规范新增
+一个接口不需要 mcpp 发版;而拼错会产出一条点名该字符串的拒绝,而不是一次被静默关掉的
+检查——一个没有被提供的名字就是缺的,不论它是否存在。
+
+**这个问题在依赖解析时回答,因为那是它最早能被回答的时刻。** openkal SPEC 0.14 §6.2
+列出能力信息出现的三个时刻,并规定每个都是该信息最早能存在的时刻:解析回答"这个程序
+可不可以在这个实现上构建",链接回答"有没有用到实现没提供的接口",能力字回答"在它提供
+的接口内它如何表现"。源码用 `#ifdef` 问同一个问题,问在预处理期——比任何答案存在得更早。
+
+**一个什么都没陈述的提供者,不是一个什么都不提供的提供者。** 实现方没有写
+`provides-interfaces` 的图照常构建;这个键晚于那些包出现,而链接仍以它一贯的词汇报告缺席。
+
+**一个 C 库不供给什么(`[c-abi-absent]`,mcpp 2026.9.20.1)。** 一个 C 库供给的名字集合
+在清单里不可枚举——POSIX 约有一千二百个——而枚举它正是 §3.3 记录下撤回的那个错误。
+例外是可枚举的:
+
+```toml
+[c-abi-absent]
+fork = { form = "link" }
+mprotect = { form = "enosys", note = "openkal 没有作用于映射保护属性的操作" }
+tcsetattr = { form = "accepted-no-effect", note = "openkal 不命名的那些字段不被施加" }
+```
+
+`form` 必填且取值封闭。`link` 是 openkal 自己的能力模型对一个实现所要求的形状(§6.1 把
+运行期报告不支持称为缺陷);另外两个是对它的偏离,给它们命名是为了让一次偏离成为可以被
+数出来的东西。当链接点到 `link` 形状里的某一项时,mcpp 把这份清单读回来,于是
+`undefined reference to 'fork'` 会带着那句说明它是缺陷还是这个程序所构建环境之限制的话
+一起到达。
+
+**早于这些键的引擎,以及两张表都是顶层表的理由。** 旧 mcpp 忽略它不认识的**顶层表**,
+而拒绝它认识的表里的**未知成员**——后者正是让拼错的 `presents` 不会静默关掉一条声明的
+机制。因此两张新表都写成顶层表;对 `[c-abi-absent]` 而言,这个位置是量出来的,不是偏好。
+
+写成 `[c-abi].absent` 更顺,但那样每个早于 2026.9.20.1 的 mcpp 都会**在每个目标上拒绝
+整份清单**,报 `[c-abi] has no member 'absent'`。判据取自真正发布的 2026.9.18.3 归档
+——当时的索引 floor——跑在 openkal-musl 0.17.0 将要发布的那份清单上。这张表做的每一件事
+都是诊断性的:它给一次**已经失败**的链接加上一句话,没有任何 flag、链接行或产物依赖它。
+于是忽略它的引擎产出的正是它今天产出的那条链接错误;而拒绝它的引擎会让整个包不可用,
+并且会把索引 floor 逼到这个版本——为了一句他们无非是收不到的说明,夺走停在其下的每个
+客户端手里的**整个索引**。
+
+对一个包的后果:采用任一张表都不对索引 floor 提出要求,旧引擎解析出的图照常构建,失去的
+只是那项检查和那句说明,而那正是那个版本本来就有的行为。把 `absent` 写在 `[c-abi]` 里面
+会被拒绝,拒绝消息点名顶层的那个拼法。
**指纹。** 解析出的环境参与构建指纹(`compileFlags`,§92 的第 7 项):C 库声明 `lp64` 与
`llp64` 的两次构建,从同一份源码编译出 `long` 宽度不同的目标文件,因此二者绝不共享输出目录,
diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md
index ea2ef3d6..41711fb5 100644
--- a/docs/zh/50-machine-output.md
+++ b/docs/zh/50-machine-output.md
@@ -361,8 +361,22 @@ mcpp why toolchain [--target ] [--toolchain ] --format json
| `package-cycle` | 依赖图中存在包的环;消息列出环上的边 *(2026.9.16.1+)* |
| `program-cxx-runtime-split` | 声明了自含 C++ 运行时的程序或测试,加载了本次构建中耦合到共享运行时的 C++ 共享库 *(2026.9.16.1+)* |
| `static-package-in-two-images` | 一个静态包被本次构建的多个映像到达,而在该目标上一个映像不能使用另一个映像里的副本 *(2026.9.16.1+)* |
+| `c-env-unrealisable` | 解析出的 C 库的 `[c-abi]` 声明在这个目标上没有已知的实现,或解析出的编译器做不到 *(2026.9.18.1+)* |
+| `c-env-verification-mismatch` | 用实现出来的 `[c-abi]` 配置编译的探针,与声明不符 *(2026.9.18.1+)* |
+| `platform-dependency` | `[build] platform-dependencies = "refuse"`,而图里有包带进了平台 SDK *(2026.9.18.1+)* |
+| `interface-not-provided` | 某个包的 `[kernel-abi] requires-interfaces` 点名了解析出的实现不提供的接口 *(2026.9.20.1+)* |
+| `apple-sdk-absent` | 目标需要本机没有的 Apple SDK;它不可再分发,所以 mcpp 定位它而不安装它 |
+| `lld-required-absent` | 目标直接经 lld 链接,而解析出的工具链载荷不带 lld |
+| `host-tool-toolchain` | 交叉 `--target` 下的 `build.mcpp` 需要一个可解析的**宿主**工具链,而一个都没设 |
+| `std-module-precompile` | 标准库的模块在这个配置下无法预编译 |
| `other` | 一处还没有被命名的拒绝分支 |
+**其中一个令牌也由 `mcpp build` 自己打印。** `interface-not-provided` 出现在拒绝消息里,
+用方括号包着,与 `E0006` 同一个约定。一条只有人能认出的拒绝,会逼迫每一个机器消费者去匹配
+散文——而那种散文,一个包自己的编译错误有可能恰好包含;mcpp-index 的兼容性测量正是靠这个
+令牌把「这个图不供给这个成员所要的」与「这个成员没能构建」分开,而这个区分决定了一个成员
+算不算进兼容率。
+
**只要问题被回答了就退 0,包括答案是「拒绝」。** 「它能不能构建,不能的话
为什么」被「不能,因为这一行的 pin 是能力陈述」完整地回答了。非零退出的含义是
这次查询本身没跑起来。
diff --git a/mcpp.toml b/mcpp.toml
index 9f1ebcbd..cf90fc4c 100644
--- a/mcpp.toml
+++ b/mcpp.toml
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
-version = "2026.9.18.3"
+version = "2026.9.20.1"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
diff --git a/modules/manifest/src/targetside_model.cppm b/modules/manifest/src/targetside_model.cppm
index df8e3081..d5019950 100644
--- a/modules/manifest/src/targetside_model.cppm
+++ b/modules/manifest/src/targetside_model.cppm
@@ -180,6 +180,49 @@ inline std::optional parse_c_abi_builtins(std::string_view v) {
// omits one has said nothing about it, and "nothing" is not the same value as
// any of the three closed sets could name; `builtins` alone defaults to
// `platform` (today's behaviour), per §3.2.1.
+// HOW A FACILITY THIS C LIBRARY DOES NOT SUPPLY REACHES THE PROGRAM THAT
+// ASKS FOR IT (design 2026-09-20 §5.7.5).
+//
+// The set of POSIX names is not enumerable in a manifest — there are about
+// twelve hundred — and enumerating it is the mistake openkal SPEC 0.14 §3.3
+// records withdrawing. The EXCEPTIONS are enumerable: openkal-musl's own
+// README lists six. Stating them is what turns a paragraph of prose into
+// something a CI run can contradict.
+//
+// `link` is the shape openkal's own capability model requires of an
+// implementation (§6.1: "a conforming implementation shall not provide an
+// interface whose operations report a lack of support at run time; the
+// specification treats run-time refusal as a defect"). The other two are
+// departures from it, and naming them is the point: a departure that has a
+// name is a departure somebody can count.
+enum class CAbiAbsentForm {
+ Link, // the definition is absent; the program fails to link
+ Enosys, // the definition exists and reports that it cannot act
+ AcceptedNoEffect, // the call succeeds and part of what it asked for is not done
+};
+
+inline std::string_view c_abi_absent_form_name(CAbiAbsentForm f) {
+ switch (f) {
+ case CAbiAbsentForm::Link: return "link";
+ case CAbiAbsentForm::Enosys: return "enosys";
+ case CAbiAbsentForm::AcceptedNoEffect: return "accepted-no-effect";
+ }
+ return "link";
+}
+
+inline std::optional parse_c_abi_absent_form(std::string_view v) {
+ if (v == "link") return CAbiAbsentForm::Link;
+ if (v == "enosys") return CAbiAbsentForm::Enosys;
+ if (v == "accepted-no-effect") return CAbiAbsentForm::AcceptedNoEffect;
+ return std::nullopt;
+}
+
+struct CAbiAbsentEntry {
+ std::string name; // the facility, spelled as the program spells it
+ CAbiAbsentForm form = CAbiAbsentForm::Link;
+ std::string note; // why, in one sentence; carried into the diagnostic
+};
+
struct CAbiDecl {
bool declared = false; // was `[c-abi]` present at all
bool hasPresents = false;
@@ -189,6 +232,11 @@ struct CAbiDecl {
CAbiDataModel dataModel = CAbiDataModel::ArchDefault;
int wcharBits = 0; // 16 or 32
CAbiBuiltins builtins = CAbiBuiltins::Platform;
+ // `[c-abi-absent]` — the facilities this C library does not supply, and
+ // the shape in which each absence reaches a program. Empty is the
+ // ordinary case and says nothing: a library that lists none has not
+ // claimed to supply everything, it has declined to enumerate.
+ std::vector absent;
};
@@ -435,6 +483,38 @@ struct Requirement {
std::string interfaceName; // what that layer must resolve to
};
+// ── Interface enumeration: the resolution-time half of the capability model ──
+//
+// openkal SPEC 0.14 §3.3 withdrew the one name it had given to a SET of
+// interfaces (`hosted`) and recorded why: "a name that describes a class of
+// environment is falsified by an environment nobody had in mind", and that one
+// was falsified inside its own ecosystem within a release. What replaced it is
+// enumeration — "a consumer that needs five names five ... in its own package,
+// which is where a convention among consumers belongs".
+//
+// This function is the whole of the engine's participation in that: a set
+// difference over opaque strings. The names belong to whichever specification
+// owns the layer; adding one to that specification requires no release of this
+// engine, and misspelling one here produces a refusal naming the string rather
+// than a silently disabled check, because a name that is not provided is
+// missing whether or not it exists.
+//
+// WHY A REFUSAL AND NOT A WARNING. §6.2 places this question at dependency
+// resolution because that is the earliest time it can be answered. A build
+// allowed to proceed would reach the same answer at the link, in a diagnostic
+// naming an undefined symbol rather than the interface and the package that
+// asked for it — later, and in a vocabulary the package author did not write.
+inline std::vector interfaces_not_provided(
+ const std::vector& required,
+ const std::vector& provided) {
+ std::vector missing;
+ for (auto const& r : required)
+ if (std::ranges::find(provided, r) == provided.end())
+ missing.push_back(r);
+ return missing;
+}
+
+
// ── Resolver input ───────────────────────────────────────────────────────────
//
// Plain data, assembled by the caller after dependency resolution. Keeping the
diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm
index 369acc8e..5d145694 100644
--- a/modules/manifest/src/toml.cppm
+++ b/modules/manifest/src/toml.cppm
@@ -559,8 +559,112 @@ std::optional find_disallowed_array_of_tables(
return std::nullopt;
}
+// [c-abi-absent] — the facilities a C library does not supply (design
+// 2026-09-20 §5.7.5). The set of names it DOES supply is not enumerable in a
+// manifest; the exceptions are, and enumerating an exception is what lets a CI
+// run contradict it.
+//
+// A TOP-LEVEL TABLE, AND MEASURED TO BE ONE RATHER THAN CHOSEN. Written as
+// `[c-abi].absent` — which reads better, and is where the first draft put it
+// — every mcpp older than this one REFUSES THE WHOLE MANIFEST, on every
+// target, with `[c-abi] has no member 'absent'`: the `[c-abi]` parser
+// enumerates its members and rejects anything else. Measured against the
+// genuine published 2026.9.18.3 archive (the index floor at the time) on the
+// exact manifest openkal-musl 0.17.0 would publish. An unknown TOP-LEVEL
+// table, by the same measurement, is ignored and the build completes.
+//
+// The difference decides whether this table can ship at all. Everything here
+// is PURELY DIAGNOSTIC — `c_abi_absent_facility_advice` appends a note to a
+// link that has ALREADY failed, and no flag, link line or artifact depends on
+// it — so an engine that ignores the table produces exactly the raw linker
+// error it produces today. Nested, the table would instead have forced the
+// index floor up to this release, taking the whole index away from clients
+// stopped below it for a note they merely would not have received.
+//
+// [c-abi-absent]
+// fork = { form = "link" }
+// mprotect = { form = "enosys", note = "openkal has no operation upon a
+// mapping's protection" }
+//
+// `form` is required and closed: a facility absent in an unnamed shape is one
+// nobody can assert against. `link` is the shape openkal's own model requires
+// (SPEC 0.14 §6.1, which calls a run-time report of unsupportedness a defect);
+// the other two are departures from it, named so that a departure is something
+// that can be counted.
+//
+// THE SHAPE OF THIS FUNCTION IS MEASURED RATHER THAN STYLISTIC. Written as a
+// block inside `parse_string` it crashed clang 20.1.7 on Windows during LLVM
+// IR generation (exception 0xC0000005); moved out to a free function returning
+// `std::expected, std::string>` the crash moved
+// with it, now naming this function. Every other host and every other compiler
+// compiled all three spellings. What it takes is the plainest form available:
+// an out parameter and an optional error, with no `expected` over a vector of
+// structs carrying strings. The note is here so that a later tidy-up does not
+// restore a shorter spelling and rediscover this on a Windows runner.
+//
+// It also belongs in this anonymous namespace and not in the module's exported
+// purview, where the first two spellings were written. An inline function in
+// the purview is emitted into every importer of the module; this one is an
+// implementation detail of `parse_string` and has no reader outside it.
+inline std::optional
+parse_c_abi_absent(const t::Value& v,
+ std::vector& out) {
+ if (!v.is_table())
+ return std::optional(std::string(
+ "[c-abi-absent] must be a table of facility names, each with a "
+ "`form`: fork = { form = \"link\" }"));
+ for (auto const& kv : v.as_table()) {
+ const std::string& name = kv.first;
+ const t::Value& ent = kv.second;
+ if (!ent.is_table())
+ return std::optional(std::format(
+ "[c-abi-absent].{} must be a table with a `form`: "
+ "{} = {{ form = \"link\" }}", name, name));
+ const auto& et = ent.as_table();
+ for (auto const& m : et)
+ if (m.first != "form" && m.first != "note")
+ return std::optional(std::format(
+ "[c-abi-absent].{} has no member '{}'; the members are: "
+ "form, note", name, m.first));
+ auto fit = et.find("form");
+ if (fit == et.end() || !fit->second.is_string())
+ return std::optional(std::format(
+ "[c-abi-absent].{} is missing `form`. An absence with no "
+ "named shape is one nothing can assert against; the shapes "
+ "are \"link\" (the definition is absent), \"enosys\" (it "
+ "exists and reports that it cannot act) and "
+ "\"accepted-no-effect\" (the call succeeds and part of what "
+ "it asked for is not done).", name));
+ auto form = mcpp::targetside::parse_c_abi_absent_form(fit->second.as_string());
+ if (!form)
+ return std::optional(std::format(
+ "[c-abi-absent].{}.form = \"{}\" names no known shape. The "
+ "shapes are \"link\", \"enosys\" and \"accepted-no-effect\".",
+ name, fit->second.as_string()));
+ mcpp::targetside::CAbiAbsentEntry e;
+ e.name = name;
+ e.form = *form;
+ if (auto nit = et.find("note"); nit != et.end() && nit->second.is_string())
+ e.note = nit->second.as_string();
+ out.push_back(std::move(e));
+ }
+ // A PLAIN COMPARATOR AND NOT A PROJECTION. `std::ranges::sort(out, {},
+ // &CAbiAbsentEntry::name)` says the same thing and crashed clang 20.1.7 on
+ // Windows while generating code for this function (0xC0000005). A
+ // pointer-to-member used as a projection is a shape this codebase has met
+ // before on that front end; the note is here so the shorter spelling is
+ // not restored as a tidy-up.
+ std::sort(out.begin(), out.end(),
+ [](const mcpp::targetside::CAbiAbsentEntry& a,
+ const mcpp::targetside::CAbiAbsentEntry& b) {
+ return a.name < b.name;
+ });
+ return std::nullopt;
+}
+
} // namespace
+
std::expected parse_string(std::string_view content,
const std::filesystem::path& origin,
LoadContext ctx) {
@@ -1059,11 +1163,14 @@ std::expected parse_string(std::string_view content,
// for presence separately so a declaration that forgets one names that
// key rather than silently taking a value nobody wrote. `builtins`
// alone defaults to `platform` — see `CAbiDecl`.
+ // Hoisted out of the block below because BOTH `[c-abi]` and
+ // `[c-abi-absent]` gate on it, and two copies of one predicate is one
+ // edit away from two different rules.
+ const bool providesCAbi = std::ranges::any_of(m.provides, [](auto const& e) {
+ auto cap = mcpp::targetside::parse_capability(e);
+ return cap && *cap && (*cap)->layer == mcpp::targetside::CapLayer::CAbi;
+ });
if (auto* ct = doc->get_table("c-abi")) {
- bool providesCAbi = std::ranges::any_of(m.provides, [](auto const& e) {
- auto cap = mcpp::targetside::parse_capability(e);
- return cap && *cap && (*cap)->layer == mcpp::targetside::CapLayer::CAbi;
- });
if (!providesCAbi)
return std::unexpected(error(origin,
"[c-abi] is declared, and [package] provides does not list "
@@ -1083,7 +1190,12 @@ std::expected parse_string(std::string_view content,
if (std::ranges::find(kKnownCAbiKeys, key) == std::end(kKnownCAbiKeys))
return std::unexpected(error(origin, std::format(
"[c-abi] has no member '{}'; the members are: builtins, "
- "data-model, presents, wchar", key)));
+ "data-model, presents, wchar{}", key,
+ key == "absent"
+ ? "\n The facilities a C library does not supply "
+ "are written in a top-level [c-abi-absent] table, "
+ "not inside this one."
+ : "")));
}
if (auto pit = ct->find("presents"); pit != ct->end()) {
if (!pit->second.is_string())
@@ -1147,6 +1259,86 @@ std::expected parse_string(std::string_view content,
}
m.cAbiDecl = decl;
}
+ // [c-abi-absent] — read AFTER [c-abi] so that a manifest carrying both
+ // lands its absences on the same declaration, and INDEPENDENTLY of it so
+ // that a C library may state what it does not supply without also
+ // restating an environment identity that is already its target's default.
+ // In the second case the `CAbiDecl` this creates has `declared = false`:
+ // there is nothing here for `cenv::realise` to realise, and the guard in
+ // `prepare` that calls it tests that flag rather than the optional.
+ //
+ // The provider gate is the one [c-abi] applies, for the reason [c-abi]
+ // applies it: a package that does not supply the C library is stating a
+ // fact about a layer it does not have.
+ // `get` rather than `get_table`: a non-table `c-abi-absent = "x"` must
+ // reach `parse_c_abi_absent` and be refused by name, and `get_table`
+ // answers nullptr for it — indistinguishable from not writing it at all.
+ if (auto* av = doc->get("c-abi-absent")) {
+ if (!providesCAbi)
+ return std::unexpected(error(origin,
+ "[c-abi-absent] is declared, and [package] provides does not "
+ "list `mcpp:c-abi=`.\n"
+ " Only the package that supplies the C library may state "
+ "which facilities it does not supply — this package is stating "
+ "a fact about a layer it does not provide.\n"
+ " Add `mcpp:c-abi=` to [package] provides, or "
+ "remove [c-abi-absent]."));
+ mcpp::targetside::CAbiDecl decl = m.cAbiDecl.value_or(
+ mcpp::targetside::CAbiDecl{});
+ if (auto why = parse_c_abi_absent(*av, decl.absent))
+ return std::unexpected(error(origin, *why));
+ m.cAbiDecl = decl;
+ }
+ // [kernel-abi] — which interfaces of that layer a package provides or
+ // requires (design 2026-09-20 §5.5). A TOP-LEVEL table for the same
+ // reason `[c-abi]` is one.
+ //
+ // THE ENGINE LEARNS NO INTERFACE NAME HERE. Both lists are opaque
+ // strings; the only operation performed on them is a set difference at
+ // dependency resolution, so a specification may add an interface without
+ // a release of this engine. That is the same discipline
+ // `mcpp.toolchain.cenv` follows for the request-to-flags table: generic
+ // knowledge, no product names.
+ //
+ // `provides-interfaces` may be stated only by a package that provides the
+ // layer — a package that does not supply it is stating a fact about
+ // something it does not have, exactly as with `[c-abi]`.
+ // `requires-interfaces` has no such restriction: it is a statement about
+ // the package making it, and every consumer is entitled to make it.
+ if (auto* kt = doc->get_table("kernel-abi")) {
+ static constexpr std::string_view kKnownKernelAbiKeys[] = {
+ "provides-interfaces", "requires-interfaces",
+ };
+ for (auto& [key, _] : *kt) {
+ if (std::ranges::find(kKnownKernelAbiKeys, key)
+ == std::end(kKnownKernelAbiKeys))
+ return std::unexpected(error(origin, std::format(
+ "[kernel-abi] has no member '{}'; the members are: "
+ "provides-interfaces, requires-interfaces", key)));
+ }
+ if (auto v = doc->get_string_array("kernel-abi.provides-interfaces")) {
+ bool providesKernelAbi = std::ranges::any_of(m.provides, [](auto const& e) {
+ auto cap = mcpp::targetside::parse_capability(e);
+ return cap && *cap
+ && (*cap)->layer == mcpp::targetside::CapLayer::KernelAbi;
+ });
+ if (!providesKernelAbi)
+ return std::unexpected(error(origin,
+ "[kernel-abi] provides-interfaces is declared, and "
+ "[package] provides does not list "
+ "`mcpp:kernel-abi=`.\n"
+ " Only the package that supplies the layer may state "
+ "which of its interfaces are present — this package is "
+ "stating a fact about a layer it does not provide.\n"
+ " Add `mcpp:kernel-abi=` to [package] provides, "
+ "or state `requires-interfaces` instead, which is what a "
+ "consumer says."));
+ m.kernelAbiProvidesInterfaces = *v;
+ }
+ if (auto v = doc->get_string_array("kernel-abi.requires-interfaces"))
+ m.kernelAbiRequiresInterfaces = *v;
+ }
+
// [package] c-environment = "platform" — see Manifest::cEnvironment.
if (auto v = doc->get_string("package.c-environment")) {
if (*v != "platform")
diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm
index 538fd7ee..79767567 100644
--- a/modules/manifest/src/types.cppm
+++ b/modules/manifest/src/types.cppm
@@ -1839,6 +1839,30 @@ struct Manifest {
// manifest carrying a value here is one this engine has already confirmed
// the right to state it.
std::optional cAbiDecl;
+ // [kernel-abi] — the interfaces a package PROVIDES or REQUIRES, named in
+ // the vocabulary of the specification that owns the layer and never in
+ // this engine's (design 2026-09-20 §5.5). Both lists are opaque strings
+ // here: the only operation performed on them is a set difference, so a
+ // specification may add an interface without this engine learning its
+ // name.
+ //
+ // WHY THE ANSWER BELONGS AT RESOLUTION AND NOT IN THE PREPROCESSOR.
+ // openkal SPEC 0.14 §6.2 tabulates three times at which information about
+ // a capability becomes available and states that each is the EARLIEST at
+ // which it exists: dependency resolution answers "may this program be
+ // built against this implementation", the link answers "was an interface
+ // used that the implementation does not provide", and a property word
+ // answers "how does it behave within an interface it provides". A source
+ // file asking `#ifdef` asks the first question during preprocessing,
+ // which is earlier than the answer exists; that is why every macro-shaped
+ // answer to it has had to be replaced by the next one.
+ //
+ // `provides-interfaces` may be stated only by a package that provides the
+ // layer, the same rule `[c-abi]` follows and for the same reason.
+ // `requires-interfaces` may be stated by anyone: it is a statement about
+ // the package making it.
+ std::vector kernelAbiProvidesInterfaces;
+ std::vector kernelAbiRequiresInterfaces;
// [package] c-environment = "platform" — this package's own translation
// units compile in the triple's OWN default environment even when the
// graph's `c-abi` declares another (design §3.4). Empty = no override, the
diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm
index a5e41e16..11c5c6aa 100644
--- a/modules/versioning/src/version.cppm
+++ b/modules/versioning/src/version.cppm
@@ -31,6 +31,6 @@ import std;
export namespace mcpp {
-inline constexpr std::string_view MCPP_VERSION = "2026.9.18.3";
+inline constexpr std::string_view MCPP_VERSION = "2026.9.20.1";
} // namespace mcpp
diff --git a/src/build/execute.cppm b/src/build/execute.cppm
index 767326c8..d5d0a7c6 100644
--- a/src/build/execute.cppm
+++ b/src/build/execute.cppm
@@ -1258,6 +1258,19 @@ std::optional run_ninja_fast(const std::string& ninjaProgram,
if (auto advice = mcpp::build::graph_c_library_isolation_advice(out);
!advice.empty())
std::fputs(advice.c_str(), stderr);
+ // THE SAME ADVICE THE PLAN PATH GIVES, FROM THE LIST THE PLAN WROTE
+ // DOWN. This path has no `BuildPlan` by construction, so the C
+ // library's `[c-abi-absent]` table reaches it through a file beside
+ // build.ninja rather than through a resolution it exists to skip.
+ // Advice attached to one path only appears or not depending on
+ // whether build.ninja happened to be up to date.
+ {
+ auto [cAbiName, absent] = mcpp::build::read_c_abi_absent_sidecar(
+ ninjaPath.parent_path());
+ if (auto advice = mcpp::build::c_abi_absent_facility_advice(
+ out, cAbiName, absent); !advice.empty())
+ std::fputs(advice.c_str(), stderr);
+ }
return 1;
}
if (verbose && !out.empty())
diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm
index f07338c1..2da375ed 100644
--- a/src/build/ninja_backend.cppm
+++ b/src/build/ninja_backend.cppm
@@ -21,6 +21,7 @@ import std;
import mcpp.freestanding.linkline;
import mcpp.build.backend;
import mcpp.manifest;
+import mcpp.targetside;
import mcpp.source_kind;
import mcpp.build.distribution;
import mcpp.build.graph_shape;
@@ -97,6 +98,39 @@ std::optional check_rule_commands_name_a_program(
// referenced from `__libcpp_allocate` in `__new/allocate.h`.
std::string link_failure_advice(std::string_view output);
+// The C library said in its manifest which facilities it does not supply, and
+// the link has just named one of them (design 2026-09-20 §5.7.5). Both
+// linkers' spellings, as `link_failure_advice` above.
+//
+// WHY THIS IS WORTH A NOTE RATHER THAN LEFT TO THE LINKER. `undefined
+// reference to 'fork'` is a true statement and a useless one: it says a symbol
+// is missing without saying whether that is a defect, a missing dependency or
+// a deliberate limit of the environment this program was built for. The
+// manifest already distinguishes those; this reads it back.
+std::string c_abi_absent_facility_advice(
+ std::string_view output, std::string_view cAbiName,
+ const std::vector& absent);
+
+// THE SAME ADVICE ON BOTH PATHS, WHICH IS WHY THE LIST IS WRITTEN DOWN.
+//
+// A build reports failure through two channels: this one, which has a plan,
+// and the fast path (`mcpp.build.execute`), which deliberately has none. An
+// advice attached to only one of them appears or not depending on whether
+// build.ninja happened to be up to date, which is the same decision in two
+// places wearing a different hat. The plan writes the list beside build.ninja;
+// the fast path reads it back and calls the same function.
+//
+// It is a plain file rather than a field in an existing record because the
+// fast path's whole purpose is to read as little as possible: this one is
+// opened only after a build has already failed.
+void write_c_abi_absent_sidecar(
+ const std::filesystem::path& outputDir, std::string_view cAbiName,
+ const std::vector& absent);
+
+// `{name, absent}` read back, or an empty pair when no build wrote one.
+std::pair>
+read_c_abi_absent_sidecar(const std::filesystem::path& outputDir);
+
// mcpp#662: the compile-side sibling of `link_failure_advice`, same shape —
// text-matched against RAW ninja output (command lines included; the caller
// must not pass the filtered form), returning advice to APPEND, never
@@ -672,6 +706,103 @@ std::string link_failure_advice(std::string_view output) {
" `std::align_val_t`, which are the ones most often forgotten.\n";
}
+namespace {
+constexpr std::string_view kCAbiAbsentSidecar = ".mcpp-c-abi-absent";
+}
+
+void write_c_abi_absent_sidecar(
+ const std::filesystem::path& outputDir, std::string_view cAbiName,
+ const std::vector& absent) {
+ const auto path = outputDir / kCAbiAbsentSidecar;
+ std::error_code ec;
+ if (absent.empty()) { std::filesystem::remove(path, ec); return; }
+ std::ofstream f(path, std::ios::binary | std::ios::trunc);
+ if (!f) return;
+ f << cAbiName << '\n';
+ for (auto const& e : absent)
+ f << mcpp::targetside::c_abi_absent_form_name(e.form) << '\t'
+ << e.name << '\t' << e.note << '\n';
+}
+
+std::pair>
+read_c_abi_absent_sidecar(const std::filesystem::path& outputDir) {
+ std::pair> out;
+ std::ifstream f(outputDir / kCAbiAbsentSidecar, std::ios::binary);
+ if (!f) return out;
+ std::getline(f, out.first);
+ std::string line;
+ while (std::getline(f, line)) {
+ if (line.empty()) continue;
+ const auto a = line.find('\t');
+ if (a == std::string::npos) continue;
+ const auto b = line.find('\t', a + 1);
+ if (b == std::string::npos) continue;
+ auto form = mcpp::targetside::parse_c_abi_absent_form(line.substr(0, a));
+ if (!form) continue;
+ mcpp::targetside::CAbiAbsentEntry e;
+ e.form = *form;
+ e.name = line.substr(a + 1, b - a - 1);
+ e.note = line.substr(b + 1);
+ out.second.push_back(std::move(e));
+ }
+ return out;
+}
+
+std::string c_abi_absent_facility_advice(
+ std::string_view output, std::string_view cAbiName,
+ const std::vector& absent) {
+ if (absent.empty()) return {};
+
+ std::string named;
+ for (auto const& e : absent) {
+ // Only the `link` shape can appear here at all: the other two reach a
+ // program at run time by construction, so matching them against a
+ // link diagnostic would report a coincidence of spelling.
+ if (e.form != mcpp::targetside::CAbiAbsentForm::Link) continue;
+ // THE NAME MUST END WHERE THE DIAGNOSTIC'S NAME ENDS. `undefined
+ // symbol: open` is a prefix of `undefined symbol: opendir`, so a
+ // plain substring search explains a link failure with a row that has
+ // nothing to do with it — and an explanation that is confidently
+ // wrong is worse than the linker's own message. lld ends the name at
+ // the line; GNU ld closes it with a quote.
+ const std::string gnu = std::format("undefined reference to `{}'", e.name);
+ bool matched = output.find(gnu) != std::string_view::npos;
+ if (!matched) {
+ const std::string lld = std::format("undefined symbol: {}", e.name);
+ for (std::size_t at = output.find(lld); at != std::string_view::npos;
+ at = output.find(lld, at + 1)) {
+ const auto after = at + lld.size();
+ if (after >= output.size() || output[after] == '\n'
+ || output[after] == '\r' || output[after] == ' ') {
+ matched = true;
+ break;
+ }
+ }
+ }
+ if (!matched) continue;
+ named += std::format("\n {:<20} {}", e.name,
+ e.note.empty() ? "declared absent" : e.note);
+ }
+ if (named.empty()) return {};
+
+ // ONE SUBSTITUTION FOR THE WHOLE PARENTHETICAL, not an opening paren and
+ // a name from two separate conditionals. Written that way, the closing
+ // paren was simply absent and every reader saw `(musl declares that it
+ // does not supply ...`. The unit tests asserted `find("musl")`, which is
+ // true of both spellings; it took running a real link to see it.
+ const std::string who =
+ cAbiName.empty() ? std::string{} : std::format(" ({})", cAbiName);
+ return std::format(
+ "\n"
+ "note: the C library in this graph{} declares that it does not "
+ "supply the following, and the link has just asked for it:{}\n"
+ " An absence stated in the manifest is a property of the "
+ "environment this program was built for, not a defect in the build. "
+ "A program that needs one of these needs a different C environment "
+ "for this target.\n",
+ who, named);
+}
+
std::string graph_c_library_isolation_advice(std::string_view output,
std::string_view cAbiName,
std::string_view cAbiCoordinate) {
@@ -3034,6 +3165,14 @@ std::expected NinjaBackend::build(const BuildPlan& plan
plan.outputDir});
auto ninja_path = plan.outputDir / "build.ninja";
+ // Written beside build.ninja and not into it: the fast path replays the
+ // ninja file without a plan, and the advice a failed link needs has to
+ // reach both channels or it appears depending on whether build.ninja
+ // happened to be up to date.
+ write_c_abi_absent_sidecar(
+ plan.outputDir, plan.targetSide.cAbi.interfaceName,
+ plan.targetSide.cAbiDecl ? plan.targetSide.cAbiDecl->absent
+ : std::vector{});
auto manifest = emit_ninja_string(plan);
stage("emit-ninja");
@@ -3398,6 +3537,12 @@ std::expected NinjaBackend::build(const BuildPlan& plan
// and gets the degraded-but-still-correct form.
diagnostics += graph_c_library_isolation_advice(
out, plan.targetSide.cAbi.interfaceName, plan.targetSide.cAbi.impl);
+ if (plan.targetSide.cAbiDecl)
+ diagnostics += c_abi_absent_facility_advice(
+ out, plan.targetSide.cAbi.interfaceName,
+ plan.targetSide.cAbiDecl->absent);
+ // (the fast path reads the same list from the sidecar this build
+ // wrote beside build.ninja — see `write_c_abi_absent_sidecar`)
return std::unexpected(BuildError{"build failed", plan.outputDir / "build.ninja",
std::move(diagnostics)});
}
diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm
index 0b3b2ea8..b5269941 100644
--- a/src/build/prepare.cppm
+++ b/src/build/prepare.cppm
@@ -10787,12 +10787,17 @@ prepare_build(bool print_fingerprint,
if (tc) tc->kernelAbiIsOpenkal =
resolvedTargetSide.kernelAbi.interfaceName == "openkal";
- // [c-abi] REALISATION — design §3.2-§3.4. `TargetSide::cAbiDecl` is
- // set only when the resolved `c-abi` provider's manifest carried a
- // `[c-abi]` block (validated at parse time, toml.cppm); everything
- // below is therefore skipped, and every command line unchanged, for
- // the graph this engine has always built.
- if (tc && resolvedTargetSide.cAbiDecl) {
+ // [c-abi] REALISATION — design §3.2-§3.4. Everything below is
+ // skipped, and every command line unchanged, for the graph this
+ // engine has always built.
+ //
+ // THE TEST IS `declared`, NOT THE OPTIONAL. `TargetSide::cAbiDecl` is
+ // also set by a `[c-abi-absent]` table on a provider that wrote no
+ // `[c-abi]` block, and those absences are diagnostic data with
+ // nothing in them to realise — `cenv::realise` requires `declared`
+ // (cenv.cppm) and would be reading fields nobody wrote.
+ if (tc && resolvedTargetSide.cAbiDecl
+ && resolvedTargetSide.cAbiDecl->declared) {
// `cenv::realise` FIRST, THE COMPILER-FAMILY GATE SECOND — not
// the other way around (coordinator report, openkal-musl 0.15.0
// regression: GCC on Linux refused for a declaration
@@ -10858,82 +10863,67 @@ prepare_build(bool print_fingerprint,
// is what makes this probe cheap AND cacheable across every
// package that shares this build's target side.
//
- // `hostStripMacros` — Windows-host leak through `--target=`
- // substitution. A freestanding cross compile (`--target=
- // riscv64-none-elf`) on a Windows host still sees `_WIN32` (and
- // the `__MINGW*__` family) in the preprocessor output: those
- // are the HOST driver's predefines, and unlike the `__APPLE__` /
- // `__linux__` family on Linux/macOS, the `--target=` substitution
- // does NOT strip them for the freestanding target. Without the
- // strip, the probe's `-dM` dump records `_WIN32` defined
- // regardless of what the realised `[c-abi] presents = "posix"`
- // asked for, and the probe fails with a mismatch that is a
- // property of the host's driver, not of the declaration being
- // checked. The strip is added ONLY when the host is Windows;
- // Linux and macOS drivers' defaults do not contaminate
- // `--target=` substitutions in the same way, and adding `-U`
- // tokens there would have to be defended as harmless rather
- // than measured (the wave's measurement caught exactly four
- // host-side leaks — listed below — and adding to that set is
- // the right way to extend the strip; speculatively unstripping
- // everywhere is not).
- //
- // `hostStripFlags` — host-side wchar leakage on Windows ×
- // freestanding. The probe runs `-E -dM -x c++` with no source
- // unit and no include path; the `__SIZEOF_WCHAR_T__` it reads
- // comes from the toolchain's own defaults, which clang on a
- // Windows host sets from `` (16 bits) even with
- // `--target=riscv64-none-elf`. The realisation closes this by
- // ALWAYS emitting `-fno-short-wchar` for `decl.wcharBits = 32`
- // (`mcpp.toolchain.cenv`'s wchar branch, just rewritten — the
- // old "freestanding skips the flag" rule was an unverified
- // assumption that the wave's measurement caught as false). So
- // when the host is Windows AND the target is freestanding, the
- // host-strip set above AND `-ffreestanding` are both needed —
- // the former for the preprocessor predefines, the latter so the
- // driver does not pick up the Windows CRT's `` even
- // when the build's own include path doesn't carry one. Outside
- // that combination the strip is unnecessary: Linux/macOS hosts
- // do not leak `_WIN32` through `--target=`, and the wchar fix
- // lives in the realisation, not the probe.
- std::vector hostStripMacros;
- std::vector hostStripFlags;
- if (mcpp::platform::is_windows) {
- // The four names measured as leaking through `--target=` on
- // a Windows host (2026-09-18, openkal-llvm-runtime#24,
- // windows-host × riscv64-none-elf). Adding to this set is
- // a measurement-driven change, not a guess; pin new entries
- // here with the failing build that named them.
- hostStripMacros = {
- "-U_WIN32",
- "-U_WIN64",
- "-U__MINGW32__",
- "-U__MINGW64__",
- };
- if (tt && tt->is_freestanding())
- // Windows host's driver would otherwise pull in
- // MinGW's `` even with no source unit, so the
- // wchar probe sees 2 instead of the declared 32.
- // `-ffreestanding` is what every real compile on a
- // freestanding target already adds (`openkal-musl`'s
- // `cflags`, mcpp's own freestanding handling).
- hostStripFlags.push_back("-ffreestanding");
- }
if (tc->cEnvExpectWcharBits != 0 || tc->cEnvExpectLongBytes != 0
|| !tc->cEnvExpectDefined.empty()
|| !tc->cEnvExpectUndefined.empty()) {
- std::vector probeArgv;
- if (!tc->crossTargetFlag.empty())
- probeArgv.push_back(tc->crossTargetFlag);
- for (auto& t : tc->cEnvTokens) probeArgv.push_back(t);
- for (auto& t : tc->cEnvBuiltinsTokens) probeArgv.push_back(t);
- for (auto& t : hostStripFlags) probeArgv.push_back(t);
+ // THE FREESTANDING TARGET HAD NEVER REACHED THE PROBE, AND
+ // THAT IS WHY 2026.9.18.3 MEASURED THE HOST (mcpp#674
+ // review, 2026-09-20). `Toolchain::crossTargetFlag` is set
+ // for a HOSTED target only — the assignment above states the
+ // reason: a freestanding target carries its own `--target`
+ // together with the ISA flags that must accompany it, and a
+ // second one there would be the same decision in two places.
+ // That other place is `mcpp.freestanding.linkline`, which the
+ // real compile goes through and this probe did not.
+ // `cenv::realise` adds no `--target` for a freestanding
+ // target either, so the probe ran with NO target selection at
+ // all and clang answered for the machine it was running on.
+ //
+ // Measured: the probe's own argv shape on a Linux host
+ // (`-D__unix__ -fno-short-wchar -ffreestanding -x c++ -E -dM
+ // -`) reports `__linux__`; with `--target=riscv64-none-elf`
+ // it reports `__riscv`, no `__linux__`, and
+ // `__SIZEOF_WCHAR_T__` 4. A Windows host answered `_WIN32`
+ // and 2 for the same reason, and 2026.9.18.3 read that as the
+ // `--target=` substitution failing to strip host predefines.
+ // Clang's predefines follow the target; there was no
+ // substitution to fail.
+ //
+ // `hostStripMacros` is therefore GONE, and its removal is the
+ // point rather than a tidy-up: `-U_WIN32 -U_WIN64
+ // -U__MINGW32__ -U__MINGW64__` deleted the one piece of
+ // evidence that said the probe was measuring the wrong
+ // machine. A future host leak, if one exists, must reach the
+ // mismatch report rather than be undefined before it can.
+ //
+ // THE ASSEMBLY REFUSES THE OMISSION rather than this site
+ // remembering not to make it — `cenv_probe::assemble_argv`
+ // holds the invariant, and the unit tests reach it without a
+ // cross toolchain.
+ std::vector freestandingFlags;
+ if (tt && tt->is_freestanding()) {
+ auto spec = mcpp::freestanding::resolve(*tt);
+ if (spec) {
+ freestandingFlags.push_back(
+ "--target=" + std::string(spec->triple));
+ for (auto const& f : mcpp::freestanding::compile_flags(*spec))
+ freestandingFlags.push_back(f);
+ }
+ }
+ auto assembled = mcpp::toolchain::cenv_probe::assemble_argv(
+ tc->crossTargetFlag, freestandingFlags,
+ tc->cEnvTokens, tc->cEnvBuiltinsTokens,
+ tt && tt->is_freestanding(), tc->targetTriple);
+ if (!assembled) {
+ refusal::record(refusal::Code::CEnvUnrealisable);
+ return std::unexpected(assembled.error());
+ }
+ const auto& probeArgv = *assembled;
auto probe = mcpp::toolchain::cenv_probe::verify(
tc->binaryPath, probeArgv,
tc->cEnvExpectWcharBits, tc->cEnvExpectLongBytes,
tc->cEnvExpectDefined, tc->cEnvExpectUndefined,
- mcpp::home::cache_root(),
- hostStripMacros);
+ mcpp::home::cache_root());
if (!probe) {
refusal::record(refusal::Code::CEnvUnrealisable);
return std::unexpected(probe.error());
@@ -11174,6 +11164,109 @@ prepare_build(bool print_fingerprint,
}
}
+ // INTERFACE ENUMERATION — THE RESOLUTION-TIME HALF OF THE CAPABILITY
+ // MODEL (design 2026-09-20 §5.5; openkal SPEC 0.14 §3.3, §6.2).
+ //
+ // A package states which interfaces of the `kernel-abi` layer it uses;
+ // the package that supplies the layer states which it provides. This
+ // engine compares the two sets and knows no member of either: the
+ // names belong to the specification that owns the layer, and one may
+ // be added to it without a release of this engine.
+ //
+ // THE QUESTION IS ANSWERED HERE BECAUSE HERE IS WHERE THE ANSWER FIRST
+ // EXISTS. §6.2 tabulates three times and states that each is the
+ // earliest at which its information exists; "may this program be built
+ // against this implementation" is the first of them. Source asking the
+ // same question with `#ifdef` asks it during preprocessing, earlier
+ // than any answer, which is why each macro-shaped answer to it has had
+ // to be replaced by the next one.
+ //
+ // SILENT WHEN NOTHING DECLARES ANYTHING. A graph in which no package
+ // writes `[kernel-abi]` reaches neither loop below, so this addition
+ // changes no command line and no diagnostic for every project built
+ // before it.
+ {
+ // THE LIST COMES FROM THE PACKAGE THAT RESOLVED AS THE LAYER, NOT
+ // FROM THE FIRST ONE IN THE GRAPH THAT STATED ONE. A graph may
+ // carry more than one candidate for a layer — a workspace member
+ // beside a dependency, a second implementation reached through a
+ // feature that did not activate — and only one of them is the
+ // provider this build resolved. Reading whichever came first in
+ // `packages` would compare a consumer's requirements against an
+ // implementation the build is not using, which is a wrong answer
+ // rather than a missing one.
+ std::vector providedInterfaces;
+ std::string providerId;
+ for (auto& pkg : packages) {
+ if (pkg.manifest.kernelAbiProvidesInterfaces.empty()) continue;
+ // `impl` is `name@version`; the name is what precedes the
+ // separator. A substring test would match `openkal` against
+ // `openkal-linux@0.15.0` and read one implementation's list
+ // as another's.
+ if (!resolvedTargetSide.kernelAbi.impl.empty()) {
+ auto const& impl = resolvedTargetSide.kernelAbi.impl;
+ const auto at = impl.find('@');
+ const auto implName = at == std::string::npos
+ ? impl : impl.substr(0, at);
+ if (implName != pkg.manifest.package.name) continue;
+ }
+ providedInterfaces = pkg.manifest.kernelAbiProvidesInterfaces;
+ providerId = pkg.manifest.package.name;
+ break;
+ }
+ for (auto& pkg : packages) {
+ const auto& need = pkg.manifest.kernelAbiRequiresInterfaces;
+ if (need.empty()) continue;
+ // A consumer that names interfaces while no package in the
+ // graph states what it provides is not refused: the provider
+ // predates this key, and a graph that has not yet adopted it
+ // must keep building. The link still reports the absence, in
+ // the vocabulary it always did.
+ if (providerId.empty()) continue;
+ auto missing = mcpp::targetside::interfaces_not_provided(
+ need, providedInterfaces);
+ if (missing.empty()) continue;
+ refusal::record(refusal::Code::InterfaceNotProvided);
+ std::string names;
+ for (auto const& mI : missing) {
+ names += "\n ";
+ names += mI;
+ }
+ // THE CODE IS PRINTED, THE WAY E0006 IS, BECAUSE SOMETHING
+ // READS THIS. A refusal that only a person can recognise
+ // forces every machine consumer to match prose --- and prose
+ // that a package's own compile error could coincidentally
+ // contain. The mcpp-index compatibility measurement
+ // distinguishes "this graph does not supply what the member
+ // asked for" from "the member did not build" on exactly this
+ // token, and that distinction decides whether a member counts
+ // against a compatibility figure.
+ // THE LABEL SAYS WHICH IMPLEMENTATION WAS RESOLVED, NOT
+ // "provided by". The missing names are listed immediately
+ // above it, and `provided by fakekernel` under `openkal.space`
+ // reads as the statement that fakekernel provides it --- the
+ // exact opposite of what this refusal is about. Read once,
+ // rendered, which is the only way that kind of defect is
+ // visible: every assertion on this message matches an
+ // identifier inside it, and an identifier is in the right
+ // place under either wording.
+ return std::unexpected(std::format(
+ "'{}' requires interfaces the resolved implementation does "
+ "not provide. [interface-not-provided]{}\n"
+ " the resolved implementation is {} ({} interface{}), "
+ "and none of those listed above is among them.\n"
+ " This is refused before anything is compiled "
+ "because dependency resolution is the earliest time the "
+ "question can be answered. Select an implementation that "
+ "provides them, or remove them from [kernel-abi] "
+ "requires-interfaces in '{}'.",
+ pkg.manifest.package.name, names, providerId,
+ providedInterfaces.size(),
+ providedInterfaces.size() == 1 ? "" : "s",
+ pkg.manifest.package.name));
+ }
+ }
+
if (auto why = tsd::check_layering(resolvedTargetSide)) {
refusal::record(refusal::Code::LayerOrdering);
return std::unexpected(*why);
@@ -13785,6 +13878,21 @@ prepare_build(bool print_fingerprint,
"was found or installable; install one via `xlings install "
"nasm` or your system package manager"));
}
+ // A HOST TOOL THAT REACHES A BUILD IS NAMED THERE. The sandbox
+ // copy is tried first (mcpp.xlings::find_usable_nasm), so this
+ // fires only where that route could not serve: an offline machine
+ // that already has an assembler. Saying nothing would leave two
+ // machines assembling the same source with different tools and
+ // no line in either build recording which.
+ if (mcpp::xlings::nasm_is_from_host(
+ mcpp::config::make_xlings_env(**cfgNasm), *nasmBin)) {
+ mcpp::diag::degraded("build/nasm-from-host", std::format(
+ "the assembler for this build is the host's ('{}'), not "
+ "the one this engine pins", nasmBin->string()),
+ "two machines can assemble the same source with different "
+ "assemblers, and the build records only this line",
+ "run `xlings install nasm` so the pinned copy is used");
+ }
ctx.plan.nasmPath = *nasmBin;
}
}
diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm
index 0b0bcd27..0a96cd53 100644
--- a/src/build/refusal.cppm
+++ b/src/build/refusal.cppm
@@ -139,6 +139,13 @@ enum class Code {
// 而不是被信任"): `sizeof(long)`, `__SIZEOF_WCHAR_T__`, or the presence of
// an environment-identity macro did not match what was declared.
CEnvVerificationMismatch,
+ // A package's `[kernel-abi] requires-interfaces` names an interface the
+ // resolved provider's `provides-interfaces` does not list (design
+ // 2026-09-20 §5.5). Refused at dependency resolution because that is the
+ // earliest time the question can be answered (openkal SPEC 0.14 §6.2);
+ // the same build would otherwise reach the answer at the link, naming an
+ // undefined symbol rather than the interface and the package.
+ InterfaceNotProvided,
// `[build] platform-dependencies = "refuse"` and a package in the graph
// brings a platform SDK dependency (design §6).
PlatformDependency,
@@ -184,6 +191,7 @@ constexpr std::string_view name(Code c) {
case Code::CEnvUnrealisable: return "c-env-unrealisable";
case Code::CEnvVerificationMismatch:
return "c-env-verification-mismatch";
+ case Code::InterfaceNotProvided: return "interface-not-provided";
case Code::PlatformDependency: return "platform-dependency";
case Code::Other: return "other";
}
diff --git a/src/toolchain/cenv.cppm b/src/toolchain/cenv.cppm
index 2a59bf9e..e60e5709 100644
--- a/src/toolchain/cenv.cppm
+++ b/src/toolchain/cenv.cppm
@@ -432,12 +432,25 @@ inline std::expected realise(
// Windows targets NO ANALOGOUS CODE-GENERATION IDIOM FOUND. The
// design's own Windows row (§3.2.1) is a PREPROCESSOR
// assumption instead — clang's bundled `intrin.h` /
- // `mm_malloc.h` expecting `__mingw_aligned_malloc` —
- // and that is already closed by the EXISTING
- // `-nostdlibinc` isolation (`mcpp.toolchain.hostflags`,
- // #662/#664) whenever the graph supplies the C
- // library, independently of `builtins`. This survey
- // found no Windows loop-idiom builtin to disable.
+ // `mm_malloc.h` expecting `__mingw_aligned_malloc`.
+ // WHAT CLOSES IT IS THE IDENTITY SWITCH, NOT
+ // `-nostdlibinc`, AND AN EARLIER REVISION OF THIS
+ // NOTE SAID OTHERWISE. `-nostdlibinc` removes the
+ // standard SYSTEM include directories and leaves
+ // clang's own resource directory in place — that is
+ // what `-nobuiltininc` removes — so ``
+ // still resolves to clang's copy and still reaches
+ // its `#include_next `, measured with the
+ // flag present (`intrin.h:12:15`, character for
+ // character the diagnostic mcpp-index recorded for
+ // fmtlib.fmt). What closes both is the Cygwin-
+ // flavoured realisation instead: `mm_malloc.h:42`
+ // selects `__mingw_aligned_malloc` on `__MINGW32__`
+ // and falls to `posix_memalign` without it, and
+ // source reaching for `` does so behind
+ // `_WIN32`. The survey's conclusion is unchanged —
+ // no Windows loop-idiom builtin to disable — only
+ // the mechanism named beside it was wrong.
// Linux targets NONE FOUND. glibc's own loop-idiom builtins
// (`__memset_chk` and relatives) are FORTIFY_SOURCE
// machinery that is off by default and unrelated to
diff --git a/src/toolchain/cenv_probe.cppm b/src/toolchain/cenv_probe.cppm
index 393c769b..a9fa0977 100644
--- a/src/toolchain/cenv_probe.cppm
+++ b/src/toolchain/cenv_probe.cppm
@@ -53,32 +53,24 @@ struct Result {
// `expectUndefined` lists name macros the probe's `-dM` dump must and must
// not contain.
//
-// `hostStripMacros` — PRECOMPILE `-U` TOKENS THE CALLER INSERTS INTO THE
-// PROBE COMMAND BEFORE `-E -dM`. This is the only mechanism by which the
-// probe is allowed to compensate for a HOST contamination that the target's
-// own `--target=` does not neutralise; the four Windows-host names
-// (`_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__`) are the case this was
-// added for and the only set the build's own measurements have surfaced, but
-// the parameter is a list because the design does not commit to this set
-// being the final one — a future host whose compiler leaks a different macro
-// can be handled the same way without further changes to this module.
+// THE ARGV MUST SELECT THE TARGET, AND FOR A FREESTANDING TARGET IT ONCE DID
+// NOT (mcpp#674 review, 2026-09-20). Clang is one binary that emits every
+// target it was built with, so a command line without a `--target` answers for
+// the machine it is running on — `mcpp.freestanding.linkline::compile_prefix`
+// states the same fact for the real compile. A probe measuring the host
+// reports the host's `_WIN32`, the host's `wchar_t` width and the host's
+// `__linux__`, and a declaration checked against the host is not checked at
+// all. The caller assembles the target selection; this module does not
+// second-guess it, and it does not undefine anything the compiler reported.
//
-// Why this is in the probe and not in `mcpp.toolchain.cenv::realise`: the
-// probe's job is to MEASURE what the compiler ACTUALLY does for the target,
-// not to ask the compiler what it would do for the target if it were a
-// clean cross compile. Host contamination is a defect of the measurement, not
-// of the measurement's contract — design 2026-09-18 §3.2 names a declaration
-// as "checked, not trusted", and the check has to read the macro state the
-// compile would actually deliver to a real translation unit, not the state
-// the compile would deliver to one already stripped of every fact the host
-// carried. The strip happens before `-E -dM` so the dump reflects the
-// stripped state; without it, a `present = "posix"` declaration would always
-// "fail" on a Windows host because `_WIN32` is in the dump regardless of
-// `--target=`.
-//
-// The cache key (below) folds `hostStripMacros` in alongside the rest of the
-// argv, so two callers with the same compiler and argv but different strip
-// sets do not share a slot.
+// AN EARLIER REVISION TOOK A `hostStripMacros` PARAMETER AND IT HAS BEEN
+// REMOVED. It prefixed `-U_WIN32 -U_WIN64 -U__MINGW32__ -U__MINGW64__` on a
+// Windows host, to compensate for what was read as a `--target=` substitution
+// failing to strip host predefines. The substitution had not failed; on a
+// freestanding target there was no substitution in the argv at all. The strip
+// deleted the one piece of evidence that would have said so, which is why it
+// is gone rather than merely unused: a measurement that removes its own
+// disagreement reports agreement it did not establish.
//
// A refusal here (as opposed to a non-empty `mismatches`) means the probe
// itself could not run — the compiler rejected the command line, which is a
@@ -86,19 +78,48 @@ struct Result {
// caller reports it as a build error naming the command, not as a §3.2
// verification mismatch.
//
-// `hostStripMacros` is placed AFTER `cacheRoot` (the latter being the test
-// suite's frequent override) to keep the existing call sites — which pass
-// neither — source-compatible. New callers that DO need the strip supply
-// both arguments; tests that pin a temp cache directory pass the strip
-// empty by default.
+// THE PROBE'S ARGV, ASSEMBLED IN ONE PLACE THAT REFUSES TO OMIT THE TARGET.
+//
+// This function exists because the omission it forbids actually happened and
+// shipped: 2026.9.18.3's probe ran with no target selection on every
+// freestanding build, and clang answered for the machine it was running on.
+// The pieces are all the caller's, and each of them is legitimately empty in
+// some configuration, so no single one of them could carry the invariant —
+// which is exactly why the invariant belongs here rather than at the call
+// site, where "a vector that happened to be empty" is indistinguishable from
+// "a decision that was made".
+//
+// crossTargetFlag `--target=` for a HOSTED cross target;
+// empty for a native build, and empty for every
+// freestanding target (mcpp.build.prepare sets it for
+// hosted targets only, because a freestanding target's
+// `--target` comes with ISA flags that must accompany
+// it and belongs in one place).
+// freestandingFlags that other place, `mcpp.freestanding.linkline`'s
+// compile prefix, tokenised. Empty for a hosted build.
+// cEnvTokens what `cenv::realise` produced. May itself carry a
+// `--target=` (the Cygwin-flavoured substitution).
+// cEnvBuiltinsTokens `builtins = "iso"`.
+// freestanding whether the build's target is freestanding.
+//
+// THE RULE. A freestanding target is never the host, so its argv must select
+// a target; a hosted build whose target IS the host legitimately selects
+// none, and there the absence is the decision rather than its omission.
+std::expected, std::string> assemble_argv(
+ std::string_view crossTargetFlag,
+ const std::vector& freestandingFlags,
+ const std::vector& cEnvTokens,
+ const std::vector& cEnvBuiltinsTokens,
+ bool freestanding,
+ std::string_view targetTriple);
+
std::expected verify(
const std::filesystem::path& compilerBin,
const std::vector& argv,
int expectWcharBits, int expectLongBytes,
const std::vector& expectDefined,
const std::vector& expectUndefined,
- const std::filesystem::path& cacheRoot = mcpp::home::cache_root(),
- const std::vector& hostStripMacros = {});
+ const std::filesystem::path& cacheRoot = mcpp::home::cache_root());
} // namespace mcpp::toolchain::cenv_probe
@@ -141,24 +162,52 @@ MacroDump parse_dm(std::string_view out) {
} // namespace
+std::expected, std::string> assemble_argv(
+ std::string_view crossTargetFlag,
+ const std::vector& freestandingFlags,
+ const std::vector& cEnvTokens,
+ const std::vector& cEnvBuiltinsTokens,
+ bool freestanding,
+ std::string_view targetTriple) {
+
+ std::vector argv;
+ if (!crossTargetFlag.empty()) argv.emplace_back(crossTargetFlag);
+ for (auto const& f : freestandingFlags) argv.push_back(f);
+ for (auto const& t : cEnvTokens) argv.push_back(t);
+ for (auto const& t : cEnvBuiltinsTokens) argv.push_back(t);
+
+ if (freestanding) {
+ const bool selects = std::any_of(argv.begin(), argv.end(),
+ [](std::string const& a) { return a.starts_with("--target="); });
+ if (!selects)
+ return std::unexpected(std::format(
+ "the [c-abi] verification probe for '{}' would have run with "
+ "no target selection.\n"
+ " A freestanding target is never the build host, so a "
+ "command line that names no target answers for the host and "
+ "the declaration is compared against the wrong machine. The "
+ "target selection for a freestanding build comes from "
+ "mcpp.freestanding.linkline's compile prefix; this probe was "
+ "handed none.",
+ targetTriple));
+ }
+ return argv;
+}
+
std::expected verify(
const std::filesystem::path& compilerBin,
const std::vector& argv,
int expectWcharBits, int expectLongBytes,
const std::vector& expectDefined,
const std::vector& expectUndefined,
- const std::filesystem::path& cacheRoot,
- const std::vector& hostStripMacros) {
+ const std::filesystem::path& cacheRoot) {
// The cache key is the compiler binary's own identity plus every argv
// token, in order — exactly the inputs that can change what `-dM`
// prints. mcpp's own content hash (`hash_file`) would need to re-read
// the binary on every build; the path plus its last-write time is the
// same shortcut the toolchain probe elsewhere in this codebase already
- // takes for "has this compiler changed". `hostStripMacros` is folded in
- // for the same reason `argv` is: two callers that probe the same
- // compiler + argv with different strip lists must not share a cache
- // slot, because the dumps WILL differ.
+ // takes for "has this compiler changed".
std::error_code ec;
auto mtime = std::filesystem::last_write_time(compilerBin, ec);
std::string keyInput = compilerBin.string();
@@ -166,7 +215,6 @@ std::expected verify(
keyInput += std::to_string(
static_cast(mtime.time_since_epoch().count()));
for (auto& a : argv) { keyInput += '\x1f'; keyInput += a; }
- for (auto& s : hostStripMacros) { keyInput += '\x1f'; keyInput += s; }
const std::string key = mcpp::toolchain::hash_string(keyInput);
const auto cacheDir = cacheRoot / "cenv-probe";
@@ -187,14 +235,7 @@ std::expected verify(
// from standard input — `capture_stdout` gives the child an empty
// one, argv-form, so no shell and no temp file are needed.
//
- // `hostStripMacros` go BEFORE `argv` so they strip host predefines
- // before `--target=` (or any other token in argv) takes effect; an
- // `-U` placed after `--target=` still strips the macro (clang
- // processes `-U` in order), but keeping them in front makes the
- // intent obvious in the recorded command and keeps the strip
- // orthogonal to whatever the target-side configuration produces.
std::vector cmd{ compilerBin.string() };
- cmd.insert(cmd.end(), hostStripMacros.begin(), hostStripMacros.end());
cmd.insert(cmd.end(), argv.begin(), argv.end());
cmd.insert(cmd.end(), { "-x", "c++", "-E", "-dM", "-" });
auto r = mcpp::platform::process::capture_stdout(cmd);
diff --git a/src/xlings/xlings.cppm b/src/xlings/xlings.cppm
index fe3162f3..51241133 100644
--- a/src/xlings/xlings.cppm
+++ b/src/xlings/xlings.cppm
@@ -484,6 +484,10 @@ void ensure_ninja(const Env& env, bool quiet,
// the index refresh and downgraded install failure to a warning).
std::optional find_usable_nasm(const Env& env);
+// Whether `find_usable_nasm` answered with the host's assembler rather than
+// the sandbox's. A host tool that reaches a build is named in the report.
+bool nasm_is_from_host(const Env& env, const std::filesystem::path& bin);
+
// Locate an already-installed nasm inside the mcpp sandbox
// ($XLINGS_HOME/data/xpkgs/xim-x-nasm//...). Pure lookup: no PATH
// probe, no install. Called lazily — only when a build plan actually
@@ -1834,13 +1838,44 @@ std::optional find_sandbox_nasm(const Env& env) {
return std::nullopt;
}
+// THE ECOSYSTEM COPY IS TRIED FIRST, AND IT USED TO BE TRIED SECOND.
+//
+// This function answered `which("nasm")` before looking in the sandbox, so a
+// machine with an assembler on PATH assembled with that one and a machine
+// without downloaded the pinned `xim:nasm`. Three machines could produce three
+// different objects from one source tree with nothing in the build saying so,
+// which is the property every other tool in this engine is arranged to avoid:
+// the compiler, the linker, ninja and patchelf all come from the graph or the
+// sandbox, and none of them asks PATH first.
+//
+// The host copy is kept, and only as a last resort: a machine that is offline
+// and already has a usable assembler can still build, which is the one case
+// the sandbox route cannot serve. When it is the one used, the caller says so
+// in the build report rather than leaving it silent --- `nasm_is_from_host`
+// below is how the caller tells the two apart.
std::optional find_usable_nasm(const Env& env) {
+ if (auto sandboxed = find_sandbox_nasm(env)) return sandboxed;
auto nasm_name = std::string("nasm") + std::string(mcpp::platform::exe_suffix);
if (auto sys = mcpp::platform::fs::which(nasm_name);
sys && nasm_version_ok(*sys)) {
return sys;
}
- return find_sandbox_nasm(env);
+ return std::nullopt;
+}
+
+// Whether a path `find_usable_nasm` returned is the host's rather than the
+// sandbox's. Asked by the caller so that a host tool reaching a build is
+// NAMED there; a host tool that reaches a build silently is the defect, not
+// the host tool.
+bool nasm_is_from_host(const Env& env, const std::filesystem::path& bin) {
+ auto root = paths::xim_tool_root(env, "nasm");
+ std::error_code ec;
+ auto canonRoot = std::filesystem::weakly_canonical(root, ec);
+ auto canonBin = std::filesystem::weakly_canonical(bin, ec);
+ if (canonRoot.empty() || canonBin.empty()) return true;
+ auto r = canonRoot.string();
+ auto b = canonBin.string();
+ return b.rfind(r, 0) != 0;
}
// ─── Index freshness ────────────────────────────────────────────────
diff --git a/tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh b/tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh
new file mode 100755
index 00000000..d0020318
--- /dev/null
+++ b/tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh
@@ -0,0 +1,193 @@
+#!/usr/bin/env bash
+# requires: gcc
+# 743 -- a package states which interfaces of the `kernel-abi` layer it uses,
+# and a graph whose implementation does not provide one is refused BEFORE
+# anything is compiled.
+#
+# WHY THIS IS THE RIGHT TIME TO ANSWER IT. openkal's specification (0.14
+# §6.2) tabulates three times at which information about a capability becomes
+# available and states that each is the EARLIEST at which it exists:
+# dependency resolution answers "may this program be built against this
+# implementation", the link answers "was an interface used that the
+# implementation does not provide", a property word answers "how does it
+# behave within an interface it provides". Source that asks the same question
+# with `#ifdef` asks it during preprocessing -- earlier than any answer -- and
+# that is why every macro-shaped answer to it has had to be replaced by the
+# next one.
+#
+# Three legs:
+# A a requirement the implementation provides: builds.
+# B a requirement it does not: refused, naming the interface and both
+# packages, with nothing compiled.
+# C a graph whose implementation states nothing: builds. The key postdates
+# the provider, and a graph that has not adopted it must keep building --
+# the link still reports the absence in the vocabulary it always did.
+set -e
+
+MCPP="${MCPP:-mcpp}"
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+cd "$work"
+
+mkdir -p impl/src src
+cat > impl/src/lib.c <<'EOF'
+int fake_kernel_marker(void) { return 0; }
+EOF
+cat > src/main.c <<'EOF'
+int main(void) { return 0; }
+EOF
+
+write_impl() { # $1 = the provides-interfaces array body, or empty for none
+ if [ -z "$1" ]; then
+ cat > impl/mcpp.toml <<'EOF'
+[package]
+name = "fakekernel"
+version = "0.1.0"
+provides = ["mcpp:kernel-abi=openkal"]
+
+[targets.fakekernel]
+kind = "lib"
+sources = ["src/*.c"]
+EOF
+ else
+ cat > impl/mcpp.toml < mcpp.toml <<'EOF'
+[package]
+name = "iface-probe"
+version = "0.1.0"
+
+[dependencies]
+fakekernel = { path = "impl" }
+
+[build]
+allow_host_libs = true
+EOF
+ else
+ cat > mcpp.toml <&1) || {
+ echo "FAIL: a graph whose implementation provides every required" \
+ "interface must build" >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "OK: A (every requirement provided)"
+
+# ── B. one is not provided ────────────────────────────────────────────────
+rm -rf target
+write_root '"openkal.fs", "openkal.net"'
+out=$("$MCPP" build 2>&1) && {
+ echo "FAIL: a requirement the implementation does not provide must be" \
+ "refused at resolution" >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "$out" | grep -q "openkal.net" || {
+ echo "FAIL: the refusal must name the interface that is missing" >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "$out" | grep -q "iface-probe" || {
+ echo "FAIL: the refusal must name the package that required it" >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "$out" | grep -q "fakekernel" || {
+ echo "FAIL: the refusal must name the implementation that did not" \
+ "provide it -- a reader told only 'missing' cannot tell which of" \
+ "the two to change" >&2
+ echo "$out" >&2
+ exit 1
+}
+# AND THE SENTENCE AROUND THOSE NAMES, NOT ONLY THE NAMES. Every assertion
+# above matches an identifier, and an identifier sits in the right place under
+# a wording that says the opposite. This message used to read
+#
+# openkal.net
+# provided by fakekernel (2 interfaces)
+#
+# --- the missing name, and directly beneath it a line that parses as
+# "openkal.net is provided by fakekernel", which is what the refusal exists to
+# deny. Nothing caught it because `grep -q fakekernel` is true either way.
+echo "$out" | grep -q "the resolved implementation is fakekernel" || {
+ echo "FAIL: the line naming the implementation must say that it is the" \
+ "one that was RESOLVED. Placed under the missing interface with a" \
+ "'provided by' label, it reads as the statement this refusal denies." >&2
+ echo " got: $(echo "$out" | grep -m1 'fakekernel')" >&2
+ exit 1
+}
+echo "$out" | grep -qi "provided by *fakekernel" && {
+ echo "FAIL: 'provided by fakekernel' directly under the missing interface" \
+ "states the opposite of this refusal" >&2
+ echo "$out" >&2
+ exit 1
+}
+# AND THE CODE, BECAUSE SOMETHING READS THIS. mcpp-index's compatibility
+# measurement tells "this graph does not supply what the member asked for"
+# from "the member did not build" on this token; without it that consumer has
+# to match prose, and the distinction decides whether a member counts against
+# a compatibility figure.
+echo "$out" | grep -q "\[interface-not-provided\]" || {
+ echo "FAIL: the refusal must carry its code the way E0006 does" >&2
+ echo "$out" >&2
+ exit 1
+}
+# Nothing compiled: the answer exists before the compiler is reached, and a
+# refusal that arrives after an object file has been written has answered at
+# the wrong time even when it answers correctly.
+if [ -d target ] && find target -name '*.o' -print -quit | grep -q .; then
+ echo "FAIL: the refusal must arrive before anything is compiled" >&2
+ find target -name '*.o' | head -3 >&2
+ exit 1
+fi
+echo "OK: B (missing interface refused, nothing compiled)"
+
+# ── C. the implementation states nothing ──────────────────────────────────
+rm -rf target
+write_impl ''
+write_root '"openkal.fs", "openkal.net"'
+out=$("$MCPP" build 2>&1) || {
+ echo "FAIL: a provider that predates this key must keep building --" \
+ "a package that states nothing is not a package that provides" \
+ "nothing" >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "OK: C (a provider that states nothing is not refused)"
+
+echo "OK"
diff --git a/tests/e2e/744_a_declared_absence_explains_the_link_that_asked_for_it.sh b/tests/e2e/744_a_declared_absence_explains_the_link_that_asked_for_it.sh
new file mode 100755
index 00000000..d2f867f1
--- /dev/null
+++ b/tests/e2e/744_a_declared_absence_explains_the_link_that_asked_for_it.sh
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+# requires: gcc
+# 744 -- a `[c-abi-absent]` row with `form = "link"` reaches the LINK, and the
+# note it produces is read by a person.
+#
+# WHAT WAS UNTESTED, AND WHAT IT COST. `c_abi_absent_facility_advice` had nine
+# unit tests covering the matching rules and the sidecar round-trip, and the
+# table had seven covering the manifest. Nothing ran the two together: that a
+# real build WRITES the sidecar beside build.ninja, that a real link failure
+# READS it back, and that what arrives is a sentence.
+#
+# It was not a sentence. The C library's name was interpolated from two
+# separate conditionals -- an opening paren, then the name -- and the closing
+# paren was never emitted, so every reader saw
+#
+# note: the C library in this graph (musl declares that it does not supply
+#
+# The unit test asserted `find("musl")`, which is true of that spelling too.
+# A criterion aimed at a substring of a sentence cannot see the sentence.
+#
+# THE SYMBOL IS DELIBERATELY ONE NOTHING DEFINES. `fork` is the real row in
+# openkal-musl's manifest and is defined by every C library on a Linux host,
+# so a test written with it would link and assert nothing.
+#
+# AND THE LINK IS FREESTANDING, WHICH IS NOT A STYLISTIC CHOICE. Written as an
+# ordinary hosted program this test passed here and failed on the shard, where
+# the link died before it ever reached the symbol:
+#
+# /usr/bin/ld: cannot find crt1.o: No such file or directory
+#
+# The graph's C library is a marker package that supplies no C library at all,
+# so the startup files came from the host --- present on a developer's machine,
+# absent in the container the shard runs in. A test whose subject is a link
+# diagnostic must not depend on anything else about the link succeeding up to
+# that point. `-nostdlib -nostartfiles -static` and an entry point of its own
+# leave exactly one undefined symbol, which is the one under examination.
+#
+# THE FLAGS GO IN `[build]`, NOT IN THE TARGET, AND LEG E ASSERTS THAT THEY
+# ARRIVED. Written as `[targets.] ldflags` they are not a key mcpp has:
+# the manifest reports `unsupported key 'ldflags' (ignored)` and carries on.
+# The link then still failed, still named the symbol, and still carried the
+# note --- so all four legs below passed while the thing they were arranged
+# around had not happened. Leg E is there because a warning printed into a
+# passing test is invisible.
+set -e
+
+MCPP="${MCPP:-mcpp}"
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+cd "$work"
+
+mkdir -p libc/src src
+cat > libc/src/lib.c <<'EOF'
+int fake_c_library_marker(void) { return 0; }
+EOF
+cat > src/main.c <<'EOF'
+extern int mcpp_absent_probe_fn(void);
+/* `_start`, not `main`: this links without startup files, so there is no C
+ runtime to call main. Nothing runs it -- the subject is the link. */
+void _start(void) { mcpp_absent_probe_fn(); }
+EOF
+cat > mcpp.toml <<'EOF'
+[package]
+name = "absence-explains-the-link"
+version = "0.1.0"
+
+[targets.absence-explains-the-link]
+kind = "bin"
+main = "src/main.c"
+
+[dependencies]
+fakelibc = { path = "libc" }
+
+[build]
+allow_host_libs = true
+ldflags = ["-nostdlib", "-nostartfiles", "-static"]
+EOF
+cat > libc/mcpp.toml <<'EOF'
+[package]
+name = "fakelibc"
+version = "0.1.0"
+provides = ["mcpp:c-abi=musl"]
+
+[targets.fakelibc]
+kind = "lib"
+sources = ["src/*.c"]
+
+[c-abi-absent]
+mcpp_absent_probe_fn = { form = "link", note = "this environment has no such operation" }
+EOF
+
+out="$("$MCPP" build 2>&1)" && {
+ echo "FAIL: the link must fail -- nothing defines mcpp_absent_probe_fn" >&2
+ echo "$out" >&2
+ exit 1
+}
+
+# ── A. the linker's own message is still there, unreplaced ─────────────────
+echo "$out" | grep -qi "mcpp_absent_probe_fn" || {
+ echo "FAIL: the linker's own diagnostic must survive" >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "OK: A (the linker still reports the symbol)"
+
+# ── B. the manifest row reached the link ───────────────────────────────────
+echo "$out" | grep -q "this environment has no such operation" || {
+ echo "FAIL: the [c-abi-absent] row's note must reach the link diagnostic." \
+ "The sidecar is written beside build.ninja during the build and read" \
+ "back when the link fails; one of those two did not happen." >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "OK: B (the row's note reached the failure)"
+
+# ── C. the sentence reads as a sentence ────────────────────────────────────
+# THE WHOLE CLAUSE, NOT A SUBSTRING OF IT. This is the assertion the unit
+# test did not make, and the defect it did not see.
+echo "$out" | grep -q "the C library in this graph (musl) declares that it does not supply" || {
+ echo "FAIL: the note must name the C library in a closed parenthetical." >&2
+ echo " got: $(echo "$out" | grep -m1 'the C library in this graph')" >&2
+ exit 1
+}
+echo "OK: C (the note names the C library and reads as a sentence)"
+
+# ── D. an absence is not reported as a defect in the build ─────────────────
+echo "$out" | grep -q "not a defect in the build" || {
+ echo "FAIL: the note must say an absence is a property of the environment" >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "OK: D (the note distinguishes an absence from a defect)"
+
+# ── E. the arrangement this test rests on actually happened ────────────────
+# An unsupported manifest key is a warning and not an error, so a misplaced
+# `ldflags` leaves every assertion above passing for the wrong reason: the
+# link succeeded as far as the host's startup files allowed, which is a
+# property of the machine rather than of this graph.
+echo "$out" | grep -q "unsupported key" && {
+ echo "FAIL: the manifest this test builds has a key mcpp ignored, so the" \
+ "link above was not the one this test describes" >&2
+ echo "$out" | grep "unsupported key" >&2
+ exit 1
+}
+echo "$out" | grep -qiE "crt1|crti|multiple definition" && {
+ echo "FAIL: the link reached the host's C runtime. The flags that keep it" \
+ "freestanding did not take effect, and what failed is the machine" \
+ "this ran on rather than the absence under examination." >&2
+ echo "$out" >&2
+ exit 1
+}
+echo "OK: E (the link was freestanding, as this test requires)"
+
+echo "OK"
diff --git a/tests/unit/test_build_flags.cpp b/tests/unit/test_build_flags.cpp
index dbc088b4..31948e14 100644
--- a/tests/unit/test_build_flags.cpp
+++ b/tests/unit/test_build_flags.cpp
@@ -4,6 +4,8 @@ import std;
import mcpp.build.flags;
import mcpp.build.distribution;
import mcpp.modgraph.scanner;
+import mcpp.build.ninja;
+import mcpp.targetside;
namespace {
@@ -208,3 +210,138 @@ TEST(LinkShape, WindowsHostSeparatesPeFromTargetsNamedByFlag) {
// and keeps the line its CI job verifies.
EXPECT_EQ(link_shape(LinkHost::Windows, Fmt::Elf, false, false), LinkShape::PeLld);
}
+
+// ── c_abi_absent_facility_advice — the manifest reads back at the link ──────
+//
+// `undefined reference to 'fork'` is true and useless: it says a symbol is
+// missing without saying whether that is a defect, a missing dependency, or a
+// deliberate limit of the environment. The manifest already distinguishes
+// those (design 2026-09-20 §5.7.5); these pin that it is read back.
+
+TEST(CAbiAbsentAdvice, ALinkAbsenceNamedInTheManifestIsExplained) {
+ std::vector absent{
+ {"fork", mcpp::targetside::CAbiAbsentForm::Link,
+ "openkal has no process image duplication"},
+ };
+ auto a = mcpp::build::c_abi_absent_facility_advice(
+ "ld.lld: error: undefined symbol: fork\n", "musl", absent);
+ ASSERT_FALSE(a.empty());
+ EXPECT_NE(a.find("fork"), std::string::npos);
+ EXPECT_NE(a.find("no process image duplication"), std::string::npos);
+ // THE WHOLE CLAUSE, NOT `find("musl")`. The name was interpolated from
+ // two conditionals -- an opening paren and the name -- and the closing
+ // one was never emitted, so every reader saw `(musl declares that it
+ // does not supply ...`. `find("musl")` is true of that sentence too,
+ // which is why it took a real link to notice. Assert the rendering.
+ EXPECT_NE(a.find("the C library in this graph (musl) declares"),
+ std::string::npos) << a;
+}
+
+TEST(CAbiAbsentAdvice, AnUnnamedCLibraryLeavesNoEmptyParentheses) {
+ // The other side of the same substitution: with no name there must be no
+ // parenthetical at all, rather than `graph () declares`.
+ std::vector absent{
+ {"fork", mcpp::targetside::CAbiAbsentForm::Link, ""},
+ };
+ auto a = mcpp::build::c_abi_absent_facility_advice(
+ "ld.lld: error: undefined symbol: fork\n", "", absent);
+ ASSERT_FALSE(a.empty());
+ EXPECT_NE(a.find("the C library in this graph declares"),
+ std::string::npos) << a;
+ EXPECT_EQ(a.find("()"), std::string::npos) << a;
+}
+
+TEST(CAbiAbsentAdvice, TheGnuSpellingIsMatchedToo) {
+ std::vector absent{
+ {"fork", mcpp::targetside::CAbiAbsentForm::Link, ""},
+ };
+ auto a = mcpp::build::c_abi_absent_facility_advice(
+ "main.o: undefined reference to `fork'\n", "musl", absent);
+ EXPECT_FALSE(a.empty());
+}
+
+TEST(CAbiAbsentAdvice, AnAbsenceThatReachesTheProgramAtRunTimeIsNotMatched) {
+ // `enosys` and `accepted-no-effect` reach a program by construction while
+ // it runs; matching them against a link diagnostic would report a
+ // coincidence of spelling as an explanation.
+ std::vector absent{
+ {"mprotect", mcpp::targetside::CAbiAbsentForm::Enosys, "no protection op"},
+ {"tcsetattr", mcpp::targetside::CAbiAbsentForm::AcceptedNoEffect, ""},
+ };
+ EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice(
+ "ld.lld: error: undefined symbol: mprotect\n", "musl", absent).empty());
+ EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice(
+ "undefined reference to `tcsetattr'\n", "musl", absent).empty());
+}
+
+TEST(CAbiAbsentAdvice, ALinkFailureNamingSomethingElseGetsNoNote) {
+ std::vector absent{
+ {"fork", mcpp::targetside::CAbiAbsentForm::Link, ""},
+ };
+ EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice(
+ "ld.lld: error: undefined symbol: my_own_function\n", "musl", absent)
+ .empty());
+}
+
+TEST(CAbiAbsentAdvice, ALibraryThatEnumeratedNothingProducesNoNote) {
+ EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice(
+ "ld.lld: error: undefined symbol: fork\n", "musl", {}).empty());
+}
+
+TEST(CAbiAbsentAdvice, ALongerSymbolWithTheSamePrefixIsNotExplained) {
+ // `undefined symbol: open` is a prefix of `undefined symbol: opendir`.
+ // A substring search would answer a link failure with a row that has
+ // nothing to do with it, and an explanation that is confidently wrong is
+ // worse than the linker's own message.
+ std::vector absent{
+ {"open", mcpp::targetside::CAbiAbsentForm::Link, "not supplied"},
+ };
+ EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice(
+ "ld.lld: error: undefined symbol: opendir\n", "musl", absent).empty());
+ EXPECT_FALSE(mcpp::build::c_abi_absent_facility_advice(
+ "ld.lld: error: undefined symbol: open\n", "musl", absent).empty());
+}
+
+TEST(CAbiAbsentAdvice, TheListSurvivesAWriteAndReadBesideBuildNinja) {
+ // The two paths that report a failed build --- the one with a plan and the
+ // fast path, which has none by construction --- must give the same advice,
+ // or it appears depending on whether build.ninja happened to be up to
+ // date. The list travels between them in a file.
+ Tmp dir;
+ std::vector absent{
+ {"fork", mcpp::targetside::CAbiAbsentForm::Link, "no image duplication"},
+ {"mprotect", mcpp::targetside::CAbiAbsentForm::Enosys, ""},
+ };
+ mcpp::build::write_c_abi_absent_sidecar(dir.path, "musl", absent);
+ auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path);
+ EXPECT_EQ(name, "musl");
+ ASSERT_EQ(back.size(), 2u);
+ EXPECT_EQ(back[0].name, "fork");
+ EXPECT_EQ(back[0].form, mcpp::targetside::CAbiAbsentForm::Link);
+ EXPECT_EQ(back[0].note, "no image duplication");
+ EXPECT_EQ(back[1].form, mcpp::targetside::CAbiAbsentForm::Enosys);
+ EXPECT_TRUE(back[1].note.empty());
+ // And the advice built from the read-back list is the advice the plan
+ // path would have given.
+ EXPECT_FALSE(mcpp::build::c_abi_absent_facility_advice(
+ "ld.lld: error: undefined symbol: fork\n", name, back).empty());
+}
+
+TEST(CAbiAbsentAdvice, ADirectoryNoBuildWroteToYieldsNothing) {
+ Tmp dir;
+ auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path);
+ EXPECT_TRUE(name.empty());
+ EXPECT_TRUE(back.empty());
+}
+
+TEST(CAbiAbsentAdvice, AGraphThatDeclaresNoAbsenceLeavesNoSidecar) {
+ // A build whose C library states nothing must not leave a file behind for
+ // the next build to read: the fast path would then explain a failure with
+ // a list the current graph never declared.
+ Tmp dir;
+ mcpp::build::write_c_abi_absent_sidecar(dir.path, "musl",
+ {{"fork", mcpp::targetside::CAbiAbsentForm::Link, ""}});
+ mcpp::build::write_c_abi_absent_sidecar(dir.path, "", {});
+ auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path);
+ EXPECT_TRUE(back.empty());
+}
diff --git a/tests/unit/test_cenv_probe.cpp b/tests/unit/test_cenv_probe.cpp
index 17af148d..ef447977 100644
--- a/tests/unit/test_cenv_probe.cpp
+++ b/tests/unit/test_cenv_probe.cpp
@@ -37,6 +37,7 @@
import std;
import mcpp.toolchain.cenv_probe;
+import mcpp.platform;
namespace cp = mcpp::toolchain::cenv_probe;
@@ -64,6 +65,77 @@ std::filesystem::path cxx() {
return p;
}
+// A CLANG, FOR THE TWO TESTS THAT NEED ONE, AND FOUND ON PATH RATHER THAN AT A
+// FIXED LOCATION.
+//
+// `find_a_cxx_compiler` answers `/usr/bin/c++` first, which is GCC on every
+// host this suite runs on --- and GCC takes no `--target`. The two tests below
+// therefore skipped on EVERY host, including the CI shards that have an LLVM
+// toolchain, which is the shape of coverage that looks like coverage and is
+// not. Searching PATH makes them run wherever a clang is reachable; where none
+// is, they skip and say so, and `CenvProbeArgv` still pins the invariant with
+// no compiler at all.
+// Whether this compiler both takes `--target` and has the back end the two
+// tests below name. A clang that answers for neither is not a smaller version
+// of one that does; it is a different tool for this purpose.
+bool answers_for_a_cross_target(const std::filesystem::path& bin) {
+ auto r = mcpp::platform::process::capture_stdout(
+ {bin.string(), "--target=riscv64-none-elf", "-x", "c++", "-E", "-dM", "-"});
+ return r.exit_code == 0
+ && r.output.find("#define __riscv ") != std::string::npos;
+}
+
+std::filesystem::path find_a_clang() {
+ if (const char* env = std::getenv("MCPP_TEST_CLANGXX"); env && *env)
+ return env;
+ const char* path = std::getenv("PATH");
+ if (!path) return {};
+ std::string_view rest{path};
+#ifdef _WIN32
+ constexpr char kSep = ';';
+ constexpr std::string_view kName = "clang++.exe";
+#else
+ constexpr char kSep = ':';
+ constexpr std::string_view kName = "clang++";
+#endif
+ while (!rest.empty()) {
+ auto at = rest.find(kSep);
+ auto dir = rest.substr(0, at);
+ rest = (at == std::string_view::npos) ? std::string_view{}
+ : rest.substr(at + 1);
+ if (dir.empty()) continue;
+ std::error_code ec;
+ auto candidate = std::filesystem::path(dir) / kName;
+ if (std::filesystem::exists(candidate, ec) && answers_for_a_cross_target(candidate))
+ return candidate;
+ }
+ // AND THE PAYLOAD mcpp ITSELF INSTALLS. A clang on PATH is not
+ // necessarily one built with the back end this test names --- the host
+ // this was written on carries a vendor clang with neither RISC-V nor
+ // AArch64, so a PATH-only search skipped for a reason that has nothing to
+ // do with what is being tested. mcpp's own LLVM payload has them, and
+ // every CI shard that resolves an `llvm@` toolchain has downloaded it.
+ std::error_code ec;
+ auto home = std::getenv("MCPP_HOME")
+ ? std::filesystem::path(std::getenv("MCPP_HOME"))
+ : std::filesystem::path(
+ std::getenv("HOME") ? std::getenv("HOME") : ".") / ".mcpp";
+ auto payloads = home / "registry" / "data" / "xpkgs" / "xim-x-llvm";
+ if (std::filesystem::is_directory(payloads, ec))
+ for (auto const& ver : std::filesystem::directory_iterator(payloads, ec)) {
+ auto candidate = ver.path() / "bin" / kName;
+ if (std::filesystem::exists(candidate, ec)
+ && answers_for_a_cross_target(candidate))
+ return candidate;
+ }
+ return {};
+}
+
+std::filesystem::path clangxx() {
+ static const std::filesystem::path p = find_a_clang();
+ return p;
+}
+
// The host's own word size, measured the same way `cenv::realise`'s callers
// would declare it — this test's "declared" values are deliberately chosen
// relative to the REAL host, so a correct declaration is one that could
@@ -211,103 +283,127 @@ TEST(CenvProbe, DifferentArgvDoesNotShareACacheSlot) {
EXPECT_TRUE(b->ran) << "a different argv must not read A's cache entry";
}
-// ── hostStripMacros — Windows-host contamination (mcpp 2026.9.18.3) ────────
+// ── assemble_argv — the invariant, reachable without a cross toolchain ────
//
-// The four macro names `_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__` are
-// preprocessor predefines that clang on a Windows host injects even when
-// `--target=` substitutes a freestanding triple (`openkal-llvm-runtime#24`,
-// windows-host × riscv64-none-elf, 2026-09-18). The probe must allow the
-// caller to strip them, so the measurement reflects what a clean cross
-// compile would do — not the host's predefines, which no `--target=`
-// substitution can take back on a freestanding target.
+// The defect these pin shipped in 2026.9.18.3: the probe ran with no target
+// selection on every freestanding build and clang answered for the host. The
+// pieces are all legitimately empty in some configuration, so the invariant
+// cannot live at the call site; it lives in the assembly, and these tests
+// reach it with no compiler at all.
+
+TEST(CenvProbeArgv, AFreestandingTargetWithoutASelectionIsRefused) {
+ // Exactly the 2026.9.18.3 shape: `crossTargetFlag` empty (it is set for
+ // hosted targets only), no freestanding flags supplied, and `realise`'s
+ // freestanding output, which carries no `--target`.
+ auto r = cp::assemble_argv("", {}, {"-D__unix__", "-fno-short-wchar"}, {},
+ /*freestanding=*/true, "riscv64-none-elf");
+ ASSERT_FALSE(r.has_value())
+ << "an argv with no target selection must be refused for a "
+ "freestanding target; it measures the build host";
+ EXPECT_NE(r.error().find("riscv64-none-elf"), std::string::npos)
+ << "the refusal must name the target it was for: " << r.error();
+}
+
+TEST(CenvProbeArgv, AFreestandingTargetWithItsCompilePrefixIsAccepted) {
+ std::vector fs{"--target=riscv64-none-elf", "-march=rv64gc",
+ "-mabi=lp64d", "-ffreestanding"};
+ auto r = cp::assemble_argv("", fs, {"-D__unix__", "-fno-short-wchar"}, {},
+ true, "riscv64-none-elf");
+ ASSERT_TRUE(r.has_value()) << r.error();
+ EXPECT_NE(std::find(r->begin(), r->end(), "--target=riscv64-none-elf"),
+ r->end());
+ // The ISA flags travel with the target: they change what the compiler
+ // predefines (`__riscv_xlen`, the float ABI), so an argv that took the
+ // triple and left them behind would measure a different machine again.
+ EXPECT_NE(std::find(r->begin(), r->end(), "-mabi=lp64d"), r->end());
+}
+
+TEST(CenvProbeArgv, ANativeHostedBuildNeedsNoSelection) {
+ // The host IS the target; the absence is the decision rather than its
+ // omission, and the probe must not be refused for it. openkal-musl on
+ // Linux/x86_64 is this case: `realise` produces no tokens at all.
+ auto r = cp::assemble_argv("", {}, {}, {}, false, "x86_64-linux-gnu");
+ ASSERT_TRUE(r.has_value()) << r.error();
+ EXPECT_TRUE(r->empty());
+}
+
+TEST(CenvProbeArgv, AHostedCrossTargetCarriesItsCrossFlag) {
+ auto r = cp::assemble_argv("--target=x86_64-w64-windows-gnu", {},
+ {"--target=x86_64-pc-cygwin", "-fno-short-wchar"},
+ {}, false, "x86_64-windows-gnu");
+ ASSERT_TRUE(r.has_value()) << r.error();
+ // Both are present and in this order: clang takes the LAST `--target`,
+ // which is how the Cygwin-flavoured substitution overrides the base
+ // triple without the producer of the base one knowing this module exists.
+ ASSERT_GE(r->size(), 2u);
+ EXPECT_EQ((*r)[0], "--target=x86_64-w64-windows-gnu");
+ EXPECT_EQ((*r)[1], "--target=x86_64-pc-cygwin");
+}
+
+TEST(CenvProbeArgv, BuiltinsTokensAreCarriedLast) {
+ auto r = cp::assemble_argv("", {}, {"-D__unix__"},
+ {"-fno-builtin-memset_pattern16"},
+ false, "x86_64-apple-macos");
+ ASSERT_TRUE(r.has_value()) << r.error();
+ ASSERT_EQ(r->size(), 2u);
+ EXPECT_EQ((*r)[1], "-fno-builtin-memset_pattern16");
+}
+
+// ── The argv must select the target (mcpp#674 review, 2026-09-20) ──────────
+//
+// Clang is one binary that emits every target it was built with, so a probe
+// command with no `--target` answers for the machine it runs on. 2026.9.18.3
+// read that as a Windows host leaking `_WIN32` through a `--target=`
+// substitution and added a `hostStripMacros` parameter to undefine it; there
+// was no substitution in the freestanding argv to leak through, and the strip
+// removed the evidence rather than the cause. The parameter is gone and these
+// tests pin what replaced it.
//
-// THESE TESTS DO NOT NEED A WINDOWS HOST. They exercise the parameter's
-// behaviour against whatever compiler is on the test machine: the strip
-// takes the form `-U`, which the preprocessor treats as a directive
-// to undefine the macro if it was defined. On a host that did not predefine
-// the name, the `-U` is a no-op; on a host that did (or any future host
-// that will), the name disappears from the dump and the corresponding
-// `expectUndefined` check passes. The test pins both halves of the contract:
-// (a) a stripped name is no longer in the dump, and (b) an unstripped run
-// against the same expectation reports a mismatch.
+// THEY DO NOT NEED A CROSS TOOLCHAIN. Every clang emits every target it was
+// built with, so `--target=riscv64-none-elf` needs no payload to answer `-dM`.
+// A compiler that cannot is skipped rather than reported.
namespace {
-// Macros the test invents locally; neither the host compiler nor any
-// realistic cross-compile target predefines them. The names are chosen so
-// long that no one ships them by accident.
-constexpr std::string_view kProbeStripDefined = "__MCPP_TEST_STRIP_DEFINED__";
-constexpr std::string_view kProbeStripUndefined = "__MCPP_TEST_STRIP_UNDEFINED__";
+// Whether this compiler answers for a target it is not hosted on. A GCC
+// driver does not take `--target`, and a clang built without the RISC-V
+// backend answers nothing useful; both skip.
+bool answers_for_riscv() { return !clangxx().empty(); }
} // namespace
-TEST(CenvProbe, AHostStrippedMacroIsAbsentFromTheDump) {
- if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe";
+TEST(CenvProbe, AnArgvWithATargetMeasuresThatTargetAndNotTheHost) {
+ if (clangxx().empty()) GTEST_SKIP() << "no clang++ on PATH";
+ if (!answers_for_riscv()) GTEST_SKIP() << "this clang does not answer for riscv64-none-elf";
TmpCache cache;
- // The contract is: a name in `hostStripMacros` (as `-U`) reaches
- // the compiler BEFORE any other argv token, so a host that predefines
- // the name has it stripped before the realised tokens (e.g. `-D__unix__`)
- // are processed. A real Windows host predefines `_WIN32`; we cannot
- // simulate that here, so the test instead uses a name no host
- // predefines — `__MCPP_PROBE_NO_SUCH_MACRO__` — and asserts that the
- // probe's `expectUndefined` check passes (the strip is a no-op for a
- // host that did not predefine the name, which is the same outcome as
- // a host that did and had it removed). This pins that the parameter is
- // wired through to the command line and that the ordering does not
- // silently fail.
- std::vector expectUndef;
- expectUndef.emplace_back("__MCPP_PROBE_NO_SUCH_MACRO__");
- std::vector strip;
- strip.emplace_back("-U__MCPP_PROBE_NO_SUCH_MACRO__");
- auto stripped = cp::verify(
- cxx(), {}, 0, 0,
- {}, expectUndef,
- cache.dir,
- strip
- );
- ASSERT_TRUE(stripped.has_value()) << stripped.error();
- EXPECT_TRUE(stripped->mismatches.empty())
- << "an unstripped probe + a stripped probe against the same "
- "expectUndefined must both pass for a name the host does not "
- "predefine; the parameter must reach the command line: "
- << stripped->mismatches[0].fact;
+ // The freestanding probe's own shape, with the target selection the
+ // caller (`mcpp.build.prepare`) now supplies. `__linux__` must be absent
+ // on every host: it is the host's own predefine, and a probe that still
+ // reports it is measuring the host.
+ std::vector argv{
+ "--target=riscv64-none-elf", "-ffreestanding",
+ "-D__unix__", "-fno-short-wchar",
+ };
+ std::vector defined{"__unix__", "__riscv"};
+ std::vector undefined{"__linux__", "_WIN32"};
+ auto r = cp::verify(clangxx(), argv, 32, 0, defined, undefined, cache.dir);
+ ASSERT_TRUE(r.has_value()) << r.error();
+ for (auto const& mm : r->mismatches)
+ ADD_FAILURE() << mm.fact << ": declared " << mm.declared
+ << ", measured " << mm.measured;
}
-TEST(CenvProbe, AStripListDoesNotShareACacheSlotWithAnEmptyStrip) {
- if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe";
+TEST(CenvProbe, TheSameArgvWithoutATargetMeasuresTheHost) {
+ if (clangxx().empty()) GTEST_SKIP() << "no clang++ on PATH";
+ if (!answers_for_riscv()) GTEST_SKIP() << "this clang does not answer for riscv64-none-elf";
TmpCache cache;
- auto noStrip = cp::verify(cxx(), {}, 0, 0, {}, {}, cache.dir);
- ASSERT_TRUE(noStrip.has_value()) << noStrip.error();
- auto withStrip = cp::verify(cxx(), {}, 0, 0, {}, {}, cache.dir,
- {"-U_MCPP_PROBE_TEST_NO_SUCH_MACRO"});
- ASSERT_TRUE(withStrip.has_value()) << withStrip.error();
- EXPECT_TRUE(noStrip->ran);
- EXPECT_TRUE(withStrip->ran)
- << "a non-empty strip list must not read the empty-strip cache slot";
-}
-
-// The wave's measurement (openkal-llvm-runtime#24, 2026-09-18) caught two
-// host-side leaks on a Windows host × freestanding target: `_WIN32` and
-// the wchar width. The first is a preprocessor predefine (closed by
-// `hostStripMacros`); the second is a header-side assumption (closed by
-// the realisation adding `-fno-short-wchar`, pinned in `test_cenv.cpp`).
-// The two halves of the fix are pinned in two test files for that reason.
-// THIS TEST pins the contract on the parameter's caller side: the four
-// names the wave measured as leaking are the four names the caller passes,
-// and no caller passes them on a non-Windows host. This is what makes the
-// probe's strip a TARGETED fix rather than a sweeping undefine.
-TEST(CenvProbe, TheWindowsHostStripListNamesExactlyTheFourMeasuredLeaks) {
- // The list is in `prepare.cppm`. Re-state it here so a future edit to
- // either side (probe parameter vs caller) trips a test mismatch. A
- // name added without a corresponding build that named it is exactly
- // the kind of change this assertion is meant to catch.
- const std::vector expected = {
- "-U_WIN32",
- "-U_WIN64",
- "-U__MINGW32__",
- "-U__MINGW64__",
- };
- EXPECT_EQ(expected.size(), 4u);
- // Uniqueness — repeating a name in the strip list is harmless but
- // indicates the list drifted without a thought.
- std::set uniq(expected.begin(), expected.end());
- EXPECT_EQ(uniq.size(), expected.size());
+ // The mirror of the test above, and the one that makes it mean
+ // something: with the target removed, the expectation that held for the
+ // target must now FAIL. A test that only asserts the fixed shape passes
+ // identically against a probe that ignores its argv.
+ std::vector argv{"-ffreestanding", "-D__unix__", "-fno-short-wchar"};
+ auto r = cp::verify(clangxx(), argv, 0, 0, {"__riscv"}, {}, cache.dir);
+ ASSERT_TRUE(r.has_value()) << r.error();
+ EXPECT_FALSE(r->mismatches.empty())
+ << "a probe with no target selection must not report the target's "
+ "own predefines; if this passes, the argv is not reaching the "
+ "compiler and the test above proves nothing";
}
diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp
index 21bec43e..9a9eaaa8 100644
--- a/tests/unit/test_manifest.cpp
+++ b/tests/unit/test_manifest.cpp
@@ -6001,6 +6001,299 @@ c-environment = "native"
// explicit key `CEnvironmentAcceptsOnlyPlatform` above already covers. An
// ordinary package, providing nothing kernel-abi-shaped, gets no such
// inference: `cEnvironment` stays empty and the realisation reaches it.
+// ── [c-abi-absent] — enumerate the exception, not the rule ─────────────────
+//
+// The set of POSIX names a C library supplies is not enumerable in a manifest;
+// the set it does not supply is. openkal-musl's README lists six in prose, and
+// that prose has no executor — it was contradicted once already (0.16.0: a
+// disposition accepted for every signal and installed for none).
+
+TEST(Manifest, ACLibraryMayEnumerateWhatItDoesNotSupply) {
+ constexpr auto src = R"(
+[package]
+name = "openkal-musl"
+version = "0.17.0"
+provides = ["mcpp:c-abi=musl"]
+
+[c-abi]
+presents = "posix"
+data-model = "arch-default"
+wchar = 32
+builtins = "iso"
+
+[c-abi-absent]
+fork = { form = "link" }
+mprotect = { form = "enosys", note = "openkal has no operation upon a mapping's protection" }
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_TRUE(m.has_value()) << m.error().format();
+ ASSERT_TRUE(m->cAbiDecl.has_value());
+ ASSERT_EQ(m->cAbiDecl->absent.size(), 2u);
+ // Sorted by name, so a diagnostic built from this list reads the same way
+ // on every run and a test may index it.
+ EXPECT_EQ(m->cAbiDecl->absent[0].name, "fork");
+ EXPECT_EQ(m->cAbiDecl->absent[0].form,
+ mcpp::targetside::CAbiAbsentForm::Link);
+ EXPECT_EQ(m->cAbiDecl->absent[1].name, "mprotect");
+ EXPECT_EQ(m->cAbiDecl->absent[1].form,
+ mcpp::targetside::CAbiAbsentForm::Enosys);
+ EXPECT_FALSE(m->cAbiDecl->absent[1].note.empty());
+}
+
+TEST(Manifest, AnAbsenceWithNoNamedShapeIsRefused) {
+ // A facility absent in an unnamed shape is one nothing can assert
+ // against, which is the whole reason for writing it down.
+ constexpr auto src = R"(
+[package]
+name = "openkal-musl"
+version = "0.17.0"
+provides = ["mcpp:c-abi=musl"]
+
+[c-abi]
+presents = "posix"
+data-model = "arch-default"
+wchar = 32
+
+[c-abi-absent]
+fork = { note = "no process image duplication" }
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_FALSE(m.has_value());
+ auto msg = m.error().format();
+ EXPECT_NE(msg.find("form"), std::string::npos) << msg;
+}
+
+TEST(Manifest, AnUnknownAbsentShapeNamesTheThreeThatExist) {
+ constexpr auto src = R"(
+[package]
+name = "openkal-musl"
+version = "0.17.0"
+provides = ["mcpp:c-abi=musl"]
+
+[c-abi]
+presents = "posix"
+data-model = "arch-default"
+wchar = 32
+
+[c-abi-absent]
+fork = { form = "sometimes" }
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_FALSE(m.has_value());
+ auto msg = m.error().format();
+ EXPECT_NE(msg.find("accepted-no-effect"), std::string::npos) << msg;
+}
+
+TEST(Manifest, TheAcceptedNoEffectShapeHasAName) {
+ // It is the shape of the defect openkal-musl 0.16.0 repaired: a call that
+ // succeeds and does not do part of what it was asked. Naming it is what
+ // makes "how many of these are there" a question with an answer.
+ constexpr auto src = R"(
+[package]
+name = "openkal-musl"
+version = "0.17.0"
+provides = ["mcpp:c-abi=musl"]
+
+[c-abi]
+presents = "posix"
+data-model = "arch-default"
+wchar = 32
+
+[c-abi-absent]
+tcsetattr = { form = "accepted-no-effect", note = "the fields openkal does not name are not applied" }
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_TRUE(m.has_value()) << m.error().format();
+ ASSERT_EQ(m->cAbiDecl->absent.size(), 1u);
+ EXPECT_EQ(m->cAbiDecl->absent[0].form,
+ mcpp::targetside::CAbiAbsentForm::AcceptedNoEffect);
+}
+
+TEST(Manifest, TheAbsenceTableIsTopLevelSoOlderEnginesIgnoreIt) {
+ // WHY THIS TABLE IS NOT `[c-abi].absent`, WHICH READS BETTER. Measured
+ // against the genuine published 2026.9.18.3 archive -- the index floor
+ // when this shipped -- on the exact manifest openkal-musl 0.17.0
+ // publishes: nested, every older engine refuses THE WHOLE MANIFEST on
+ // every target ("[c-abi] has no member 'absent'"), because the [c-abi]
+ // parser enumerates its members. An unknown top-level table is ignored
+ // and the build completes.
+ //
+ // That difference decides whether the table can ship without taking the
+ // index away from every client below this release, and nothing else in
+ // the engine records it: the two spellings are indistinguishable to every
+ // test that only reads the parsed result. So this test asserts the shape
+ // directly -- `absent` is NOT a member of [c-abi] -- and the hint that
+ // catches the natural mistake.
+ constexpr auto src = R"(
+[package]
+name = "openkal-musl"
+version = "0.17.0"
+provides = ["mcpp:c-abi=musl"]
+
+[c-abi]
+presents = "posix"
+data-model = "arch-default"
+wchar = 32
+absent = { fork = { form = "link" } }
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_FALSE(m.has_value());
+ auto msg = m.error().format();
+ EXPECT_NE(msg.find("has no member 'absent'"), std::string::npos) << msg;
+ EXPECT_NE(msg.find("[c-abi-absent]"), std::string::npos) << msg;
+}
+
+TEST(Manifest, AbsencesMayBeStatedWithoutAnEnvironmentDeclaration) {
+ // The two tables are independent. A C library whose environment identity
+ // is already its target's default has nothing to put in [c-abi], and is
+ // still entitled to say what it does not supply. `declared` stays false,
+ // which is what keeps `cenv::realise` away from fields nobody wrote.
+ constexpr auto src = R"(
+[package]
+name = "openkal-musl"
+version = "0.17.0"
+provides = ["mcpp:c-abi=musl"]
+
+[c-abi-absent]
+fork = { form = "link" }
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_TRUE(m.has_value()) << m.error().format();
+ ASSERT_TRUE(m->cAbiDecl.has_value());
+ EXPECT_FALSE(m->cAbiDecl->declared);
+ ASSERT_EQ(m->cAbiDecl->absent.size(), 1u);
+ EXPECT_EQ(m->cAbiDecl->absent[0].name, "fork");
+}
+
+TEST(Manifest, OnlyTheCLibraryMayStateWhatIsAbsent) {
+ // The gate [c-abi] applies, for the reason it applies it. Moving the
+ // table to the top level moved it out from behind that gate, so the gate
+ // is restated here rather than inherited -- and this is the test that
+ // would notice if a later edit dropped it.
+ constexpr auto src = R"(
+[package]
+name = "an-ordinary-program"
+version = "0.1.0"
+
+[c-abi-absent]
+fork = { form = "link" }
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_FALSE(m.has_value());
+ auto msg = m.error().format();
+ EXPECT_NE(msg.find("mcpp:c-abi="), std::string::npos) << msg;
+}
+
+TEST(Manifest, ACLibraryThatEnumeratesNothingHasClaimedNothing) {
+ constexpr auto src = R"(
+[package]
+name = "openkal-musl"
+version = "0.17.0"
+provides = ["mcpp:c-abi=musl"]
+
+[c-abi]
+presents = "posix"
+data-model = "arch-default"
+wchar = 32
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_TRUE(m.has_value()) << m.error().format();
+ ASSERT_TRUE(m->cAbiDecl.has_value());
+ EXPECT_TRUE(m->cAbiDecl->absent.empty());
+}
+
+// ── [kernel-abi] provides-interfaces / requires-interfaces ─────────────────
+//
+// The resolution-time half of the capability model (design 2026-09-20 §5.5).
+// The engine knows no interface name; these tests therefore use openkal's
+// real names in one direction and an invented one in the other, and neither
+// changes what the parser does.
+
+TEST(Manifest, AKernelAbiProviderMayStateWhichInterfacesItProvides) {
+ constexpr auto src = R"(
+[package]
+name = "openkal-windows"
+version = "0.9.0"
+provides = ["mcpp:kernel-abi=openkal"]
+
+[kernel-abi]
+provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory"]
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_TRUE(m.has_value()) << m.error().format();
+ ASSERT_EQ(m->kernelAbiProvidesInterfaces.size(), 3u);
+ EXPECT_EQ(m->kernelAbiProvidesInterfaces[0], "openkal.abort");
+ EXPECT_TRUE(m->kernelAbiRequiresInterfaces.empty());
+}
+
+TEST(Manifest, APackageThatDoesNotSupplyTheLayerMayNotStateWhatItProvides) {
+ // The same rule `[c-abi]` follows, and for the same reason: a package
+ // without the layer is stating a fact about something it does not have.
+ constexpr auto src = R"(
+[package]
+name = "some-library"
+version = "1.0.0"
+
+[kernel-abi]
+provides-interfaces = ["openkal.fs"]
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_FALSE(m.has_value())
+ << "a package with no `mcpp:kernel-abi=` in provides must be "
+ "refused when it states provides-interfaces";
+ auto msg = m.error().format();
+ EXPECT_NE(msg.find("mcpp:kernel-abi"), std::string::npos) << msg;
+}
+
+TEST(Manifest, AnyConsumerMayStateWhichInterfacesItRequires) {
+ // `requires-interfaces` is a statement about the package making it, so it
+ // carries no such restriction. openkal SPEC 0.14 §3.3: "a consumer states
+ // its required set per target, in its own package, which is where a
+ // convention among consumers belongs".
+ constexpr auto src = R"(
+[package]
+name = "some-library"
+version = "1.0.0"
+
+[kernel-abi]
+requires-interfaces = ["openkal.fs", "openkal.net"]
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_TRUE(m.has_value()) << m.error().format();
+ ASSERT_EQ(m->kernelAbiRequiresInterfaces.size(), 2u);
+ EXPECT_TRUE(m->kernelAbiProvidesInterfaces.empty());
+}
+
+TEST(Manifest, AnUnknownKernelAbiMemberIsAParseErrorNamingTheKey) {
+ constexpr auto src = R"(
+[package]
+name = "openkal-linux"
+version = "0.14.0"
+provides = ["mcpp:kernel-abi=openkal"]
+
+[kernel-abi]
+provides-interface = ["openkal.fs"]
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_FALSE(m.has_value()) << "a misspelled member must not be ignored";
+ auto msg = m.error().format();
+ EXPECT_NE(msg.find("provides-interface"), std::string::npos) << msg;
+}
+
+TEST(Manifest, APackageWithNoKernelAbiTableStatesNothing) {
+ // The guarantee every addition to this parser owes the graph that predates
+ // it: a manifest that writes nothing here is byte-identical in effect.
+ constexpr auto src = R"(
+[package]
+name = "some-library"
+version = "1.0.0"
+)";
+ auto m = mcpp::manifest::parse_string(src);
+ ASSERT_TRUE(m.has_value()) << m.error().format();
+ EXPECT_TRUE(m->kernelAbiProvidesInterfaces.empty());
+ EXPECT_TRUE(m->kernelAbiRequiresInterfaces.empty());
+}
+
TEST(Manifest, CEnvironmentIsInferredForAKernelAbiProvider) {
constexpr auto kernelAbiPkg = R"(
[package]
diff --git a/tests/unit/test_targetside.cpp b/tests/unit/test_targetside.cpp
index 30c7f4fd..36a699f4 100644
--- a/tests/unit/test_targetside.cpp
+++ b/tests/unit/test_targetside.cpp
@@ -765,3 +765,57 @@ TEST(TargetSideCLibrary, TheKernelInterfaceDoesNotDecideIt) {
EXPECT_FALSE(side_with(Origin::Graph, k).cAbi.prebuilt());
}
}
+
+// ── interfaces_not_provided — the whole of the engine's participation ───────
+//
+// openkal SPEC 0.14 §3.3 withdrew `hosted`, the one name it had given to a set
+// of interfaces, and replaced it with enumeration by the consumer. This engine
+// implements that as a set difference over opaque strings and learns no
+// member of either set; these tests use openkal's real names in one place and
+// an invented one in another, and the function behaves identically.
+
+TEST(TargetSideInterfaces, EveryRequiredInterfaceProvidedYieldsNoGap) {
+ std::vector required{"openkal.fs", "openkal.stream"};
+ std::vector provided{"openkal.abort", "openkal.stream",
+ "openkal.memory", "openkal.fs"};
+ EXPECT_TRUE(mcpp::targetside::interfaces_not_provided(required, provided)
+ .empty());
+}
+
+TEST(TargetSideInterfaces, AMissingInterfaceIsReportedByName) {
+ std::vector required{"openkal.fs", "openkal.net"};
+ std::vector provided{"openkal.fs"};
+ auto gap = mcpp::targetside::interfaces_not_provided(required, provided);
+ ASSERT_EQ(gap.size(), 1u);
+ EXPECT_EQ(gap[0], "openkal.net");
+}
+
+TEST(TargetSideInterfaces, ANameThisEngineHasNeverHeardOfBehavesTheSame) {
+ // The point of the opaque strings: a specification may add an interface
+ // without a release of this engine, and a name that does not exist is
+ // missing for the same reason a name that does exist and is not provided
+ // is missing. Neither is a special case here.
+ std::vector required{"openkal.event", "some.other.spec/iface"};
+ std::vector provided{"openkal.fs"};
+ auto gap = mcpp::targetside::interfaces_not_provided(required, provided);
+ ASSERT_EQ(gap.size(), 2u);
+ EXPECT_EQ(gap[0], "openkal.event");
+ EXPECT_EQ(gap[1], "some.other.spec/iface");
+}
+
+TEST(TargetSideInterfaces, RequiringNothingIsNeverAGap) {
+ EXPECT_TRUE(mcpp::targetside::interfaces_not_provided({}, {}).empty());
+ EXPECT_TRUE(
+ mcpp::targetside::interfaces_not_provided({}, {"openkal.fs"}).empty());
+}
+
+TEST(TargetSideInterfaces, ProvidingNothingMakesEveryRequirementAGap) {
+ // The shape a graph takes when the provider predates the key. The caller
+ // (mcpp.build.prepare) does not reach this function in that case — a
+ // provider that states nothing is not a provider that provides nothing —
+ // and this test pins the function's own answer so that distinction stays
+ // the caller's and does not migrate here by accident.
+ std::vector required{"openkal.fs"};
+ auto gap = mcpp::targetside::interfaces_not_provided(required, {});
+ ASSERT_EQ(gap.size(), 1u);
+}