From ff0c7f37cd76ec57578564cb682537e29afea457 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 16 Sep 2026 00:27:49 +0800 Subject: [PATCH] feat(semantics): enforce bounded producer ownership Signed-off-by: song --- .../semantic-vocabulary-convergence-v0.md | 27 +-- ...emantic-vocabulary-convergence-v0.zh-CN.md | 21 +- examples/semantic-vocabulary-drift-smoke.py | 205 +++++++++++++++++- loopx/control_plane/quota/effective_action.py | 49 +++++ loopx/semantics/inventory_v0.json | 5 +- loopx/semantics/vocabulary_v0.json | 91 ++++++-- .../test_semantic_vocabulary_drift.py | 52 +++++ 7 files changed, 399 insertions(+), 51 deletions(-) create mode 100644 loopx/control_plane/quota/effective_action.py diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index 51564280a3..a83874ea62 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -315,10 +315,10 @@ definitions in `global_risks.py`, `global_todos.py`, `summary_all.py`, and `pr_review.py` each list the data sources of that one CLI command, and the value sets are meant to differ. It is counted in `multi_value_forks` today and must not be "fixed" by renaming, because a rename lowers the number without -changing the code's meaning. M0.5 adds a `scope` field to the registry with at -least `global` and `bounded_context`, lets a bounded-context name be declared -once with its owning contexts, and removes declared names from the fork -budget (I14, the schema rows below, and the M0.5 row in Section 11). Until +changing the code's meaning. The M0.5 scope slice adds top-level `scope_declarations` with at +least `global` and `bounded_context`; a bounded-context name is declared once +with its owning contexts, and declared names are removed from the semantic +fork budget while the raw inventory count remains visible (I14, the schema rows below, and the M0.5 row in Section 11). Until then the fork budget is a ceiling that contains this one known misclassification, recorded in the registry's `inventory_ratchets` note. @@ -417,7 +417,7 @@ vocabulary key fails the smoke. | `vocabularies..tier`, `status` | `kernel`, `cross_runtime`, `cross_module`; `canonical`, `legacy`, `merge_candidate` | Closed enumerations | | `vocabularies..literal_scan` | `field`, roots, suffixes | Every literal the fixed dispatch forms capture is registered; every registered value is captured or variable-sourced (I2) | | `vocabularies..variable_sourced_values` | value to producer module | The producer still contains the quoted value | -| `vocabularies..scope` (M0.5) | `global` or `bounded_context`; a `bounded_context` entry lists `contexts`, each with one owner symbol | Closed enumeration; declared bounded-context names are excluded from `multi_value_forks`; an undeclared multi-module name stays a fork (I14) | +| `scope_declarations.` (M0.5a) | `bounded_context` and its context IDs, each with one `module::Symbol` owner | Every declared name resolves to one inventory fork, names every defining module exactly once, and is excluded only from `multi_value_forks_semantic`; undeclared forks remain visible (I14) | | `vocabularies..producers` (M0.5) | `path::Symbol` sites that write the field, required for `kernel` | Every site writes registered values only; every value not under `compatibility_only` has at least one site or a variable-sourced entry (I12, I13) | | `vocabularies..compatibility_only` (M0.5) | values kept so readers of persisted records still resolve them | Subset of `values`; zero production sites; each carries a `value_notes` reason and a retirement milestone | | `formal_model` | finite universes, role relations and hierarchy, semantic obligations, and established/bounded/unproved claims | Exact schema, role hierarchy, and invariant ids are checked by the drift smoke; enforcement stages cannot be mistaken for completed proofs | @@ -534,11 +534,11 @@ inventory in the same PR. | Measurement covers both carrier shapes and filters local naming | `pytest tests/architecture/test_semantic_inventory.py` | pass, including the collision and module-local-convention fixtures | Rules come from this RFC, not from scanner output | | No behavior change from the two owner fixes | `pytest tests/test_loopx_turn_transaction.py tests/test_loop_turn_loop_controller.py tests/test_turn_loop_disposition.py tests/test_loopx_turn_managed_step.py tests/control_plane -k authority` and `loopx canary premerge --from-git-diff` | pass | Environment failures already present on `main` are excluded when reproduced on a clean tree | | Docs governance accepts the RFC pair | `python3 examples/docs-governance-smoke.py` | pass | Checks mirror, links, index | -| Retirement budgets count substrings, not identifiers | `goal_boundary` counted with `in file.text` and with `\bgoal_boundary\b` | 35 vs 30 Python modules on the baseline | Known boundary; M3's zero-reader gate needs the identifier count, tracked in Section 12 | +| Retirement budgets use standalone field tokens | `count_identifier_modules()` uses identifier boundaries for the six fields | `goal_boundary`: 30 Python modules under the new metric; the old substring metric was 35 | Conservative lexical measure; it removes compound-name false positives but does not prove semantic reader absence | | The module-local convention filter is a code edit | Widen `MODULE_LOCAL_CONVENTION` in `inventory.py` and regenerate | `*_semantic` budgets fall with no code change elsewhere | Known boundary; the regex is in code so the widening is a reviewed diff, and the unfiltered totals stay budgeted | | A registered value nobody produces fails (M0.5) | Run the production-form scan on the baseline | Fails naming `effective_action` and `skip`; passes after `skip` is removed or listed `compatibility_only` | First expected I12 failure; a compared-only value is not carried | | A producer of an unregistered value fails (M0.5) | Write `effective_action: "brand_new"` in a listed producer site | Fails naming the site and the value even though no consumer compares it | I13; production is stricter than comparison | -| A bounded-context name leaves the fork budget only by declaration (M0.5) | Declare `SOURCE_SURFACES` with its four contexts; separately, rename one definition without declaring | The declaration lowers `multi_value_forks` to 3; the rename alone does not | I14; the honest fix is a registry edit a reviewer sees, the rename is code without registry change | +| A bounded-context name leaves only the semantic fork budget by declaration (M0.5a) | Declare `SOURCE_SURFACES` with its four contexts; separately, rename one definition without declaring | Raw `multi_value_forks` stays 4, `multi_value_forks_semantic` is 3; a rename alone changes neither semantic accounting nor declaration | I14; the honest fix is a registry edit a reviewer sees, the rename is not a repair | | An upstream merge can stale the committed inventory | Replay the scanner over the first parent and the merge of the last twenty `upstream/main` merge commits | 8 of 20 merges change at least one carrier | Measured cost of committing a snapshot; the handling rule is Section 10 and Section 12 Q9 | | The formal model cannot silently lose a proof obligation | Remove an invariant, role, relation, or proof-boundary category from `formal_model` | The drift smoke fails on the exact formal-model shape | The model is a finite contract and proof ledger; it does not prove the listed properties by itself | @@ -614,7 +614,8 @@ commands as `python3.11` for that reason, and the planner entry is left as | Milestone | Shipped behavior | Entry gate | Exit evidence | Rollback | | --- | --- | --- | --- | --- | | M0 | Registry with 26 vocabularies and 9 relations, generated inventory with `--check`, drift smoke with fixed dispatch forms and coverage floor, two owner forks removed, RFC index entry | This RFC opened | Section 9 rows green; 20 mutation classes fail closed | Delete the smoke, `loopx/semantics/`, the generator, and its test | -| M0.5 | `scope` with `global` and `bounded_context` and per-context owners; `producers` and `compatibility_only` on `kernel` vocabularies; production-form scan with the two role checks (I12, I13); retirement budgets counted by identifier with all six anchors lowered in one diff (Q11); merge-order rule from Q9 written into Section 10 | M0 merged; Q9 decided or its interim rule accepted | Smoke green with I11 to I14 enforced; `skip` resolved; `multi_value_forks` at 3 by declaration; Section 9 role rows green; `turn_route` persistence answered for Q2 | Remove the three fields and the role checks; budgets return to the M0 anchors | +| M0.5a | `scope_declarations` with `bounded_context` and per-context owners; semantic fork count separated from raw inventory count | M0 merged | Smoke checks every declared context owner; raw `multi_value_forks` remains 4 and `multi_value_forks_semantic` is 3; undeclared forks still fail the budget | Remove the scope declarations and semantic-fork budget | +| M0.5b | `producers` and `compatibility_only` on `kernel` vocabularies; production-form scan with the two role checks (I12, I13); retirement budgets counted by identifier with all six anchors lowered in one diff (Q11); merge-order rule from Q9 written into Section 10 | M0.5a complete; Q9 decided or its interim rule accepted | Smoke green with I11 to I14 enforced; `skip` resolved; Section 9 producer rows green; `turn_route` persistence answered for Q2 | Remove producer fields and role checks; budgets return to the pre-M0.5b anchors | | M1 | `EffectiveAction` typed enum in one owner module; the replay observation and frontier slots split off (Q6); producers and consumers import it; registry `literal_scan` tightened to the enum | M0.5 merged; owner module chosen (Q3); slot split decided (Q6) | Smoke green; zero bare `effective_action` literals outside the owner; parity fixtures for status/should-run unchanged | Revert to literals; registry keeps the set | | M2 | Route-to-disposition projection, the `decide_loop_disposition` decision table, and the cross-runtime sets published through a shared contract with generated Python and TypeScript bindings, following the coordination contract generator | M1 merged; Q2 and Q7 decided | Generator `--check` and smoke green; `settlement.ts` and `transaction.py` read the generated set | Regenerate from prior contract | | M3 | Per-field retirement of legacy should-run fields, one field per PR, budgets lowered to zero and the field removed | Field has zero external readers proven by producer/reader research | Schema-reduction record per `AGENTS.md`; Appendix B entry | Restore field from the last writer | @@ -760,11 +761,11 @@ introduce a competing target state. write, then retire) to one spelling per concept. Without this decision the RFC has budgets but no definition of done for its headline problem. Owner: Turn driver owner. Needed before M2 closes. -11. **Retirement budgets by identifier.** The six legacy-field budgets count - `field in file.text`; `goal_boundary` matches `goal_boundary_repair`. M3's - zero-external-reader gate needs word-boundary counting, which lowers all six - anchors in one diff. Recommendation: do it before the first M3 PR. - Owner: kernel maintainers. +11. **Retirement budgets by identifier.** The six legacy-field budgets now use + `count_identifier_modules()`, so `goal_boundary_repair` is not counted as + `goal_boundary`. This is a conservative lexical metric, not proof of zero + semantic readers; computed accesses remain an evidence gap. Owner: kernel + maintainers. ## Appendix A: Execution ledger (non-normative) diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md index cba3879ca0..ebddb95b38 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -260,9 +260,9 @@ todos、capabilities 与 TypeScript 运行时各自拥有同一想法的一种 个案例:它在 `global_risks.py`、`global_todos.py`、`summary_all.py`、 `pr_review.py` 的四处定义各自列出那一个 CLI 命令的数据来源,值集本来就该不 同。它今天被计入 `multi_value_forks`,且不得用改名来"修",因为改名只让数字 -下降、不改变代码含义。M0.5 给注册表加 `scope` 字段,至少含 `global` 与 -`bounded_context`,允许一个有界上下文名字连同其所属上下文声明一次,并把已 -声明的名字从分叉预算移出(I14、下方 schema 表与第 11 节的 M0.5 行)。在此之 +下降、不改变代码含义。M0.5 的作用域子阶段增加顶层 `scope_declarations`,至少支持 `global` 与 +`bounded_context`;有界上下文名字只声明一次并列出其 owner,同时从语义分叉预算 +中移除,原始清单计数仍保留(I14、下方 schema 表与第 11 节的 M0.5 行)。在此之 前分叉预算是一个包含这一处已知误分类的上 限,记在注册表 `inventory_ratchets` 的备注里。 @@ -341,7 +341,7 @@ R ⊆ S × V × Version 将值持久化 | `vocabularies..tier`、`status` | `kernel`、`cross_runtime`、`cross_module`;`canonical`、`legacy`、`merge_candidate` | 封闭枚举 | | `vocabularies..literal_scan` | `field`、根目录、后缀 | 固定分发形式捕获的每个字面量都已注册;每个注册值被捕获或来自变量(I2) | | `vocabularies..variable_sourced_values` | 值到生产者模块 | 生产者仍包含带引号的该值 | -| `vocabularies..scope`(M0.5) | `global` 或 `bounded_context`;`bounded_context` 条目列出 `contexts`,每个含一个 owner 符号 | 封闭枚举;已声明的有界上下文名字从 `multi_value_forks` 排除;未声明的多模块名字仍是分叉(I14) | +| `scope_declarations.`(M0.5a) | `bounded_context` 及上下文 ID,每个上下文含一个 `module::Symbol` owner | 每个声明名对应一个 inventory 分叉,并且一次且仅一次列出全部定义模块;只从 `multi_value_forks_semantic` 排除,未声明分叉仍可见(I14) | | `vocabularies..producers`(M0.5) | 写入该字段的 `path::Symbol` 位点,`kernel` 必填 | 每个位点只写注册值;未列入 `compatibility_only` 的每个值至少有一个位点或一条变量来源条目(I12、I13) | | `vocabularies..compatibility_only`(M0.5) | 为让已持久化记录的读者仍能解析而保留的值 | `values` 的子集;零生产位点;每个值带 `value_notes` 理由与退休里程碑 | | `formal_model` | 有限的集合、角色关系与层次、语义义务,以及已建立/有界/未证明的声明 | 漂移 smoke 校验精确 schema、角色层次和不变量 ID;属性实施阶段不能冒充已完成证明 | @@ -444,7 +444,7 @@ PR 中重新生成清单。 | 退休预算按子串而非标识符计数 | 分别以 `in file.text` 与 `\bgoal_boundary\b` 统计 `goal_boundary` | 基线上 35 对 30 个 Python 模块 | 已知边界;M3 的零读者门需要标识符计数,见第 12 节 | | 模块局部约定过滤器是一次代码修改 | 扩宽 `inventory.py` 的 `MODULE_LOCAL_CONVENTION` 并重新生成 | `*_semantic` 预算下降而别处无代码改动 | 已知边界;正则在代码里,扩宽是可评审的 diff,未过滤总数仍在预算内 || 无人生产的注册值失败(M0.5) | 在基线上运行生产形式扫描 | 失败并点名 `effective_action` 与 `skip`;删除 `skip` 或列入 `compatibility_only` 后通过 | 第一个预期的 I12 失败;只被比较的值不算已携带 | | 生产未注册值失败(M0.5) | 在某个已列生产位点写 `effective_action: "brand_new"` | 即使无消费者比较它也失败,并点名位点与值 | I13;生产比比较更严 | -| 有界上下文名字只能靠声明离开分叉预算(M0.5) | 为 `SOURCE_SURFACES` 声明四个上下文;另行只改名其中一处定义而不声明 | 声明把 `multi_value_forks` 降到 3;单独改名不降 | I14;诚实的修法是评审者看得见的注册表修改,改名是不碰注册表的代码改动 | +| 有界上下文名字只能靠声明离开语义分叉预算(M0.5a) | 为 `SOURCE_SURFACES` 声明四个上下文;另行只改名其中一处定义而不声明 | 原始 `multi_value_forks` 保持 4,`multi_value_forks_semantic` 为 3;单独改名既不改变语义计数,也不构成声明 | I14;诚实的修法是评审者看得见的注册表修改,改名不是修复 | | 上游合并会让已提交清单过期 | 对 `upstream/main` 最近二十个合并提交,在第一父提交与合并结果之间重放扫描器 | 20 次合并中 8 次至少改变一个载体 | 提交快照的实测成本;处理规则见第 10 节与第 12 节 Q9 | | 形式模型不能静默丢失证明义务 | 从 `formal_model` 删除不变量、角色、关系或证明边界分类 | 漂移 smoke 针对形式模型结构失败 | 该模型是有限契约和证明账本,本身不等于这些性质已经被证明 | @@ -507,7 +507,8 @@ planner 条目则有意保留 `python3`。 | 里程碑 | 交付行为 | 进入门 | 退出证据 | 回滚 | | --- | --- | --- | --- | --- | | M0 | 含 26 个词表与 9 条关系的注册表、带 `--check` 的生成清单、带固定分发形式与覆盖下限的漂移 smoke、删除两处 owner 分叉、RFC 索引条目 | 本 RFC 开启 | 第 9 节各行全绿;20 类突变失败关闭 | 删除 smoke、`loopx/semantics/`、生成器及其测试 | -| M0.5 | 含 `global` 与 `bounded_context` 及每上下文 owner 的 `scope`;`kernel` 词表上的 `producers` 与 `compatibility_only`;带两条角色检查(I12、I13)的生产形式扫描;退休预算改按标识符计数并在一个 diff 里调低全部六个锚点(Q11);Q9 的合并序规则写入第 10 节 | M0 合入;Q9 已决或其临时规则被接受 | smoke 在 I11 到 I14 强制下全绿;`skip` 已处理;`multi_value_forks` 靠声明降到 3;第 9 节角色行全绿;为 Q2 回答 `turn_route` 是否持久化 | 删除三个字段与角色检查;预算回到 M0 锚点 | +| M0.5a | `scope_declarations` 的 `bounded_context` 与每上下文 owner;把语义分叉计数与原始清单计数分开 | M0 合入 | smoke 校验每个声明的上下文 owner;原始 `multi_value_forks` 仍为 4,`multi_value_forks_semantic` 为 3;未声明分叉仍受预算约束 | 删除作用域声明和语义分叉预算 | +| M0.5b | `kernel` 词表的 `producers` 与 `compatibility_only`;带两条角色检查(I12、I13)的生产形式扫描;退休预算改按标识符计数并在一个 diff 里调整六个锚点(Q11);Q9 的合并序规则写入第 10 节 | M0.5a 完成;Q9 已决或其临时规则被接受 | smoke 在 I11 到 I14 强制下全绿;`skip` 已处理;第 9 节生产者行全绿;为 Q2 回答 `turn_route` 是否持久化 | 删除生产者字段和角色检查;预算回到 M0.5b 前的锚点 | | M1 | 单一 owner 模块中的 `EffectiveAction` 类型化枚举;replay observation 与 frontier 槽位拆出(Q6);生产者与消费者 import 它;注册表 `literal_scan` 收紧到枚举 | M0.5 合入;owner 模块已定(Q3);槽位拆分已决(Q6) | smoke 绿;owner 之外零裸 `effective_action` 字面量;status/should-run 的 parity fixture 不变 | 回退为字面量;注册表保留集合 | | M2 | route 到 disposition 的投影、`decide_loop_disposition` 决策表与跨运行时集合通过共享契约发布,生成 Python 与 TypeScript 绑定,效仿协调契约生成器 | M1 合入;Q2 与 Q7 已决 | 生成器 `--check` 与 smoke 绿;`settlement.ts` 与 `transaction.py` 读取生成集合 | 从上一版契约重新生成 | | M3 | 逐字段退休旧 should-run 字段,每个 PR 一个字段,预算降到零并删除字段 | 经生产者/读者调研证明该字段外部读者为零 | 按 `AGENTS.md` 的 schema 缩减记录;附录 B 条目 | 从最后一个写方恢复字段 | @@ -621,10 +622,10 @@ planner 条目则有意保留 `python3`。 应在 (a) 三套加生成投影(现行计划)与 (b) 两阶段合并(先双写、后退休)到 每个概念一种拼法之间选择。没有这个决定,RFC 对其标题问题只有预算、没有 完成定义。Owner:Turn driver owner。M2 关闭前需定。 -11. **退休预算按标识符计数。** 六个旧字段预算用 `field in file.text` 统计; - `goal_boundary` 会匹配 `goal_boundary_repair`。M3 的零外部读者门需要词边界 - 计数,这会在一个 diff 里调低全部六个锚点。建议:在第一个 M3 PR 之前做。 - Owner:内核维护者。 +11. **退休预算使用独立字段 token。** 六个旧字段预算现在使用 + `count_identifier_modules()`,因此 `goal_boundary_repair` 不会被算作 + `goal_boundary`。这是保守的词法指标,不等于证明不存在语义读者;计算式访问 + 仍然是证据缺口。Owner:内核维护者。 ## 附录 A:执行账本(非规范) diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index d01dc46069..2a84990bd9 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -37,16 +37,24 @@ REGISTRY_PATH = REPO_ROOT / "loopx" / "semantics" / "vocabulary_v0.json" REGISTRY_SCHEMA_VERSION = "loopx_semantic_vocabulary_v0" VALUE_SHAPE = re.compile(r"^[a-z][a-z0-9_]*$") +SYMBOL_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") OWNER_SHAPE = re.compile(r"^[A-Za-z0-9_./-]+\.(py|ts)::[A-Za-z_][A-Za-z0-9_]*$") QUOTED = re.compile(r'''["']([^"']*)["']''') REGISTRY_KEYS = { "schema_version", "rfc", "inventory", "policy", "coverage_floor", "vocabularies", "relations", "projections", "schema_versions", "retirement_ledger", "dual_runtime_twins", "inventory_ratchets", - "formal_model", + "formal_model", "scope_declarations", } VOCABULARY_KEYS = {"meaning", "tier", "status", "owners", "values"} -VOCABULARY_OPTIONAL_KEYS = {"literal_scan", "variable_sourced_values", "value_notes", "deprecated_values"} +VOCABULARY_OPTIONAL_KEYS = { + "literal_scan", + "variable_sourced_values", + "value_notes", + "deprecated_values", + "producers", + "compatibility_only", +} TIERS = {"kernel", "cross_runtime", "cross_module"} STATUSES = {"canonical", "legacy", "merge_candidate"} FORMAL_MODEL_KEYS = { @@ -96,6 +104,7 @@ "schema_version_same_runtime_forks": 7, "multi_value_twins": 19, "multi_value_forks": 4, + "multi_value_forks_semantic": 3, "multi_value_fork_definitions": 10, "same_runtime_forks_semantic": 18, "conflicting_values_semantic": 2, @@ -103,12 +112,12 @@ # Budgets for the legacy should-run decision fields, anchored the same way so a # single diff cannot widen a retirement budget to keep a field alive. RETIREMENT_ANCHOR = { - "execution_obligation": (21, 1), - "heartbeat_recommendation": (18, 1), - "work_lane_contract": (32, 3), - "external_evidence_observation": (11, 1), - "goal_boundary": (35, 2), - "protocol_action_packet": (7, 2), + "execution_obligation": (20, 1), + "heartbeat_recommendation": (17, 1), + "work_lane_contract": (29, 3), + "external_evidence_observation": (8, 1), + "goal_boundary": (30, 2), + "protocol_action_packet": (5, 2), } RATCHET_KEYS = ( "same_runtime_forks", @@ -118,6 +127,7 @@ "schema_version_same_runtime_forks", "multi_value_twins", "multi_value_forks", + "multi_value_forks_semantic", "multi_value_fork_definitions", "same_runtime_forks_semantic", "conflicting_values_semantic", @@ -183,6 +193,16 @@ def load_registry() -> dict[str, Any]: extra = set(vocabulary.get(key, {})) - set(values) require(not extra, f"{name}: {key} names unregistered values {sorted(extra)}") require(set(vocabulary.get("deprecated_values", [])) <= set(values), f"{name}: deprecated_values must be a subset of values") + producers = vocabulary.get("producers") + if producers is not None: + require(isinstance(producers, list) and producers, f"{name}: producers must be a non-empty list") + require(all(isinstance(site, str) and OWNER_SHAPE.match(site) for site in producers), f"{name}: producers must be module::Symbol sites") + compatibility = vocabulary.get("compatibility_only") + if compatibility is not None: + require(isinstance(compatibility, dict), f"{name}: compatibility_only must be an object") + for value, metadata in compatibility.items(): + require(isinstance(metadata, dict) and set(metadata) == {"reason", "retirement"}, f"{name}: compatibility_only.{value} needs reason and retirement") + require(all(isinstance(item, str) and item.strip() for item in metadata.values()), f"{name}: compatibility_only.{value} metadata must be non-empty text") scan = vocabulary.get("literal_scan") if scan is not None: require(set(scan) == {"field", "roots", "suffixes"}, f"{name}: literal_scan keys must be field, roots, suffixes") @@ -352,6 +372,108 @@ def check_literal_vocabularies(registry: dict[str, Any], sources: list[SourceFil require(not unused, f"{name}: registry lists values no module carries: {unused}") +# --- bounded producer scan ---------------------------------------------------------- + + +PRODUCER_ROOTS = ( + "loopx/cli_commands", + "loopx/control_plane/agents", + "loopx/control_plane/quota", + "loopx/control_plane/todos", + "loopx/control_plane/turn_driver", + "loopx/control_plane/work_items", +) +PRODUCER_ASSIGNMENT = re.compile( + r"(?:[\"']{field}[\"']\s*:\s*|\b{field}\s*=(?!=)\s*)(?P[^\n}}]+)" +) + + +def _producer_literals(field: str, source: SourceFile) -> set[str]: + """Return literal strings on bounded field-write forms in one source. + + This intentionally does not follow variables or infer consumers. A value + assembled through a variable must remain in ``variable_sourced_values`` or + in the owner enum, so the unresolved boundary stays visible. + """ + + pattern = re.compile(PRODUCER_ASSIGNMENT.pattern.format(field=re.escape(field))) + values: set[str] = set() + for match in pattern.finditer(source.text): + values.update(QUOTED.findall(match.group("rhs"))) + return {value for value in values if value and value != field} + + +def _producer_site_exists(site: str, sources: list[SourceFile]) -> None: + require(OWNER_SHAPE.match(site) is not None, f"producer site must be module::Symbol: {site!r}") + module, symbol = site.split("::") + source_file = next((item for item in sources if item.path == module), None) + require(source_file is not None, f"producer site module does not exist: {site}") + if module.endswith(".py"): + definition = re.compile(rf"^\s*(?:async\s+)?def\s+{re.escape(symbol)}\s*\(", re.MULTILINE) + else: + definition = re.compile(rf"^\s*(?:export\s+)?(?:async\s+)?function\s+{re.escape(symbol)}\s*\(", re.MULTILINE) + require(definition.search(source_file.text) is not None, f"producer site symbol is not defined: {site}") + + +def check_producers(registry: dict[str, Any], sources: list[SourceFile]) -> None: + """Enforce the bounded producer side of the formal model. + + Owner carriers prove the finite value domain. Explicit producer metadata + identifies the control-plane sites covered by the structural write scan; + the scan rejects an unregistered literal even if no consumer compares it. + Dynamic producers remain outside this proof and stay in the formal boundary. + """ + + for name, vocabulary in registry["vocabularies"].items(): + if vocabulary["tier"] != "kernel": + continue + owner_values_seen: set[str] = set() + for owner in vocabulary["owners"].values(): + if owner: + owner_values_seen.update(owner_values(owner)) + producers = vocabulary.get("producers", []) + if not producers: + require(owner_values_seen, f"{name}: kernel vocabularies need an owner or producer sites") + continue + for site in producers: + _producer_site_exists(site, sources) + declared_modules = {site.split("::", 1)[0] for site in producers} + scan = vocabulary.get("literal_scan") + if not scan: + continue + observed: dict[str, list[str]] = {} + for item in sources: + if item.suffix not in set(scan["suffixes"]): + continue + if not any(item.path.startswith(root + "/") for root in PRODUCER_ROOTS): + continue + for value in _producer_literals(scan["field"], item): + observed.setdefault(value, []).append(item.path) + unregistered = { + value: sorted(paths) + for value, paths in observed.items() + if value not in vocabulary["values"] + } + require(not unregistered, f"{name}: producer writes unregistered values: {unregistered}") + observed_modules = {path for paths in observed.values() for path in paths} + undeclared_modules = sorted(observed_modules - declared_modules) + require(not undeclared_modules, f"{name}: producer modules are missing from registry: {undeclared_modules}") + compatibility = vocabulary.get("compatibility_only", {}) + require(set(compatibility) <= set(vocabulary["values"]), f"{name}: compatibility_only contains unregistered values") + require( + not set(compatibility).intersection(observed), + f"{name}: compatibility_only values are produced: {sorted(set(compatibility).intersection(observed))}", + ) + uncovered = ( + set(vocabulary["values"]) + - owner_values_seen + - set(observed) + - set(vocabulary.get("variable_sourced_values", {})) + - set(compatibility) + ) + require(not uncovered, f"{name}: values have no bounded producer or owner evidence: {sorted(uncovered)}") + + # --- relations, projections, schema versions ---------------------------------------- @@ -412,6 +534,47 @@ def check_schema_version_owners(registry: dict[str, Any], sources: list[SourceFi require(values == {entry["value"]}, f"schema version {name} carries {sorted(values)}; registry says {entry['value']}") +def check_scope_declarations(registry: dict[str, Any], inventory: dict[str, Any]) -> int: + """Validate explicit bounded-context exceptions and return semantic fork count. + + The raw inventory remains unchanged. A declaration can remove a known, + reviewed bounded-context reuse from the semantic budget only when every + defining module is named explicitly. Spelling or directory proximity never + infers a scope. + """ + declarations = registry["scope_declarations"] + forks = {entry["name"]: entry for entry in inventory["duplicate_definitions"]["multi_value_forks"]} + for name, declaration in declarations.items(): + require(SYMBOL_NAME.match(name) is not None, f"scope declaration name must be an identifier: {name}") + require(set(declaration) == {"kind", "contexts"}, f"{name}: scope declaration keys must be kind and contexts") + require(declaration["kind"] == "bounded_context", f"{name}: only bounded_context is supported") + require(name in forks, f"{name}: scope declaration does not resolve to a multi-value fork") + contexts = declaration["contexts"] + require(isinstance(contexts, list) and contexts, f"{name}: contexts must be a non-empty list") + context_ids: set[str] = set() + owner_modules: set[str] = set() + for context in contexts: + require(set(context) == {"id", "owner"}, f"{name}: each context must have id and owner") + context_id = context["id"] + require(isinstance(context_id, str) and VALUE_SHAPE.match(context_id) is not None, + f"{name}: context id must be lower snake_case: {context_id!r}") + require(context_id not in context_ids, f"{name}: duplicate context id {context_id}") + context_ids.add(context_id) + owner = context["owner"] + require(isinstance(owner, str) and OWNER_SHAPE.match(owner) is not None, + f"{name}: context owner must be module::Symbol: {owner!r}") + module, symbol = owner.split("::") + require(symbol == name, f"{name}: context owner symbol must be {name}, got {symbol}") + owner_modules.add(module) + require(len(owner_modules) == len(contexts), f"{name}: each context must have a distinct owner module") + defining_modules = {item["module"] for item in forks[name]["definitions"]} + require(owner_modules == defining_modules, + f"{name}: contexts must name every defining module exactly once; " + f"declared={sorted(owner_modules)} actual={sorted(defining_modules)}") + undeclared = set(forks) - set(declarations) + return len(undeclared) + + # --- ratchets ----------------------------------------------------------------------- @@ -424,7 +587,7 @@ def check_retirement_budgets(registry: dict[str, Any], sources: list[SourceFile] (".py", "python_module_budget", RETIREMENT_ANCHOR[field][0]), (".ts", "typescript_module_budget", RETIREMENT_ANCHOR[field][1]), ): - actual = sum(1 for file in sources if file.suffix == suffix and field in file.text) + actual = count_identifier_modules(field, suffix, sources) require(actual <= budgets[key], f"legacy field {field} grew to {actual} {suffix} modules; budget is {budgets[key]}") require( budgets[key] == anchored, @@ -435,6 +598,23 @@ def check_retirement_budgets(registry: dict[str, Any], sources: list[SourceFile] return report +def count_identifier_modules(field: str, suffix: str, sources: list[SourceFile]) -> int: + """Count modules containing the standalone field token. + + This is intentionally a conservative lexical metric. It removes the known + ``goal_boundary_repair`` false positive without claiming to prove that every + remaining occurrence is a reader or that computed accesses are absent. + """ + pattern = re.compile( + rf"(? str: entry = registry["dual_runtime_twins"] require(entry["root"] == TWIN_ROOT_ANCHOR, "dual_runtime_twins root differs from TWIN_ROOT_ANCHOR") @@ -451,17 +631,19 @@ def check_inventory(registry: dict[str, Any], sources: list[SourceFile]) -> tupl inventory = build_inventory(REPO_ROOT, sources=sources) require(inventory["schema_version"] == INVENTORY_SCHEMA_VERSION, "inventory schema drift") require(render_inventory(inventory) == committed, f"{registry['inventory']} is stale; run python3.11 scripts/generate_semantic_inventory.py and commit the result") + semantic_multi_value_forks = check_scope_declarations(registry, inventory) ratchets = registry["inventory_ratchets"] summary = inventory["summary"] parts = [] for key in RATCHET_KEYS: - require(summary[key] <= ratchets[key], f"inventory {key} grew to {summary[key]}; budget is {ratchets[key]}") + actual = semantic_multi_value_forks if key == "multi_value_forks_semantic" else summary[key] + require(actual <= ratchets[key], f"inventory {key} grew to {actual}; budget is {ratchets[key]}") require( ratchets[key] == BUDGET_ANCHOR[key], f"inventory {key} budget is {ratchets[key]} but BUDGET_ANCHOR pins {BUDGET_ANCHOR[key]}; " "the registry and the anchor move together in one diff (see BUDGET_ANCHOR in this smoke)", ) - parts.append(f"{key}={summary[key]}/{ratchets[key]}") + parts.append(f"{key}={actual}/{ratchets[key]}") return inventory, " ".join(parts) @@ -472,6 +654,7 @@ def main() -> int: inventory, ratchets = check_inventory(registry, sources) check_owned_vocabularies(registry, inventory) check_literal_vocabularies(registry, sources) + check_producers(registry, sources) check_relations(registry) check_projections(registry) check_schema_version_owners(registry, sources) diff --git a/loopx/control_plane/quota/effective_action.py b/loopx/control_plane/quota/effective_action.py new file mode 100644 index 0000000000..ee124e0391 --- /dev/null +++ b/loopx/control_plane/quota/effective_action.py @@ -0,0 +1,49 @@ +"""Canonical value domain for the decision-slot effective action. + +The Turn Envelope still carries a string for wire compatibility. This enum is +the owner of the finite value domain; callers may serialize ``.value`` while +the semantic drift smoke checks that new decision values are deliberate. +""" + +from __future__ import annotations + +from enum import Enum + + +class EffectiveAction(str, Enum): + AGENT_MONITOR_ONLY = "agent_monitor_only" + AGENT_WORKSPACE_REPAIR = "agent_workspace_repair" + AUTOMATION_PROMPT_UPGRADE_REQUIRED = "automation_prompt_upgrade_required" + AUTONOMOUS_REPLAN_REQUIRED = "autonomous_replan_required" + BLOCK_REPLAY = "block_replay" + BOUNDARY_PROJECTION_REPAIR = "boundary_projection_repair" + CAPABILITY_BRIDGE_REPAIR = "capability_bridge_repair" + CONTROL_PLANE_HEALTH_REPAIR = "control_plane_health_repair" + CONTROL_PLANE_PROJECTION_REPAIR = "control_plane_projection_repair" + COORDINATE_TASK_BUNDLE = "coordinate_task_bundle" + EXTERNAL_EVIDENCE_OBSERVE = "external_evidence_observe" + GOVERNED_CAPABILITY_INTENT = "governed_capability_intent" + HEARTBEAT_RECEIPT_WRITE_FAILED = "heartbeat_receipt_write_failed" + HEARTBEAT_SETTLED_SKIP = "heartbeat_settled_skip" + LARK_INBOX_REPLY_DUE = "lark_inbox_reply_due" + MONITOR_DUE = "monitor_due" + MONITOR_QUIET_SKIP = "monitor_quiet_skip" + NORMAL_RUN = "normal_run" + OBSERVE_REPLAY = "observe_replay" + OPERATOR_GATE = "operator_gate" + OPERATOR_INBOX_MATERIAL_REVIEW_DUE = "operator_inbox_material_review_due" + OUTCOME_FLOOR_RECOVERY = "outcome_floor_recovery" + PEER_COORDINATION_BLOCKED = "peer_coordination_blocked" + QUOTA_ACTION_SELECTION_DEFERRED = "quota_action_selection_deferred" + QUOTA_ACTION_SELECTION_REJECTED = "quota_action_selection_rejected" + QUOTA_SKIP = "quota_skip" + RUNTIME_USER_GATE_PROJECTION_REPAIR = "runtime_user_gate_projection_repair" + SCOPED_USER_GATE_FALLBACK = "scoped_user_gate_fallback" + SKIP = "skip" + STATE_PROJECTION_GAP_REPAIR = "state_projection_gap_repair" + TERMINAL_NO_FOLLOWUP = "terminal_no_followup" + TODO_DECISION_SCOPE_PROJECTION_REPAIR = "todo_decision_scope_projection_repair" + UNSETTLED_HOST_TURN_RECOVERY = "unsettled_host_turn_recovery" + + +EFFECTIVE_ACTION_VALUES = tuple(item.value for item in EffectiveAction) diff --git a/loopx/semantics/inventory_v0.json b/loopx/semantics/inventory_v0.json index d1dedc606d..c341f757e6 100644 --- a/loopx/semantics/inventory_v0.json +++ b/loopx/semantics/inventory_v0.json @@ -63,6 +63,7 @@ {"name": "GoalFrontierReplanRule", "module": "loopx/control_plane/goals/goal_frontier/replan_rules.py", "values": ["existing_obligation", "blocking_handoff_gate", "ready_deferred_successor", "open_user_todo", "user_action_owns_empty_frontier", "todo_succession_gap", "vision_acceptance_gap", "long_todo_chain", "current_agent_blocker", "monitor_no_change_streak", "not_monitor_only", "no_open_monitor", "advancement_remains", "due_monitor_execution", "future_monitor_wait", "monitor_frontier_exhausted"]}, {"name": "GoalVisionAdvancementPolicy", "module": "loopx/control_plane/goals/goal_vision_policy.py", "values": ["as_needed", "repeat_until_closed"]}, {"name": "HostGuardState", "module": "loopx/control_plane/host_adapter_settlement.py", "values": ["selected", "terminal_no_selection", "invalid"]}, + {"name": "EffectiveAction", "module": "loopx/control_plane/quota/effective_action.py", "values": ["agent_monitor_only", "agent_workspace_repair", "automation_prompt_upgrade_required", "autonomous_replan_required", "block_replay", "boundary_projection_repair", "capability_bridge_repair", "control_plane_health_repair", "control_plane_projection_repair", "coordinate_task_bundle", "external_evidence_observe", "governed_capability_intent", "heartbeat_receipt_write_failed", "heartbeat_settled_skip", "lark_inbox_reply_due", "monitor_due", "monitor_quiet_skip", "normal_run", "observe_replay", "operator_gate", "operator_inbox_material_review_due", "outcome_floor_recovery", "peer_coordination_blocked", "quota_action_selection_deferred", "quota_action_selection_rejected", "quota_skip", "runtime_user_gate_projection_repair", "scoped_user_gate_fallback", "skip", "state_projection_gap_repair", "terminal_no_followup", "todo_decision_scope_projection_repair", "unsettled_host_turn_recovery"]}, {"name": "QuotaIdentityPrecondition", "module": "loopx/control_plane/quota/error_codes.py", "values": ["public_safe_agent_id", "registered_agent_roster_present", "requested_agent_registered"]}, {"name": "AutomaticTurnPauseCause", "module": "loopx/control_plane/quota/states.py", "values": ["goal_stopped", "compute_quota_zero"]}, {"name": "SchedulerDisposition", "module": "loopx/control_plane/scheduler/arbitration.py", "values": ["terminal_stop", "peer_coordination_stop", "agent_monitor_only_wait", "active_work", "agent_scope_wait", "consistency_repair", "human_gate", "monitor_wait", "quiet_wait", "unchanged_wait"]}, @@ -902,8 +903,8 @@ ] }, "summary": { - "source_files": 1176, - "python_enums": 103, + "source_files": 1177, + "python_enums": 104, "python_closed_sets": 495, "python_literal_aliases": 8, "typescript_const_arrays": 40, diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 17558ccd1d..49947b13ba 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -28,13 +28,26 @@ "pass_through" ], "role_hierarchy": { - "consumer": ["interpreter", "pass_through"] + "consumer": [ + "interpreter", + "pass_through" + ] }, "enforcement_policy": { - "blocking_now": ["F5_projection_totality"], - "blocking_next": ["F1_producer_closedness", "F2_canonical_value_liveness", "F4_scope_separation"], - "advisory": ["F3_consumer_domain_closedness"], - "unproved": ["F6_persistence_version_compatibility"] + "blocking_now": [ + "F5_projection_totality" + ], + "blocking_next": [ + "F1_producer_closedness", + "F2_canonical_value_liveness", + "F4_scope_separation" + ], + "advisory": [ + "F3_consumer_domain_closedness" + ], + "unproved": [ + "F6_persistence_version_compatibility" + ] }, "relations": { "defines": "D ⊆ S × V: a site defines a vocabulary carrier", @@ -214,11 +227,11 @@ ] }, "effective_action": { - "meaning": "Compacted should-run verdict carried by status/should-run payloads and the Turn Envelope; consumers dispatch on it by string comparison. No enum exists on either runtime, so the owner is null and the literal scan is the only check.", + "meaning": "Compacted should-run verdict carried by status/should-run payloads and the Turn Envelope; the Python enum owns the finite value domain while the wire field remains a string for compatibility.", "tier": "kernel", "status": "merge_candidate", "owners": { - "python": null, + "python": "loopx/control_plane/quota/effective_action.py::EffectiveAction", "typescript": null }, "literal_scan": { @@ -270,6 +283,30 @@ "quota_action_selection_deferred": "loopx/cli_commands/quota.py", "quota_action_selection_rejected": "loopx/cli_commands/quota.py" }, + "producers": [ + "loopx/cli_commands/quota.py::_apply_requested_quota_action_selection_preflight", + "loopx/control_plane/quota/decision_summary.py::resolve_quota_run_decision", + "loopx/control_plane/quota/heartbeat_receipt.py::fail_heartbeat_receipt", + "loopx/control_plane/quota/live_decision.py::_apply_pending_capability_intent_precedence", + "loopx/control_plane/quota/projection_repair.py::build_boundary_projection_repair_hint", + "loopx/control_plane/quota/projection_repair.py::build_state_projection_gap_repair_hint", + "loopx/control_plane/quota/settlement_precedence.py::apply_settled_replay_payload_precedence", + "loopx/control_plane/quota/should_run.py::build_quota_paused_should_run_payload", + "loopx/control_plane/quota/should_run_packet.py::_apply_agent_monitor_only_precedence", + "loopx/control_plane/quota/should_run_packet.py::_resolve_quota_should_run_route", + "loopx/control_plane/quota/stall_repair.py::build_quota_stall_self_repair_hint", + "loopx/control_plane/quota/stall_repair.py::build_runtime_capability_user_gate_repair_hint", + "loopx/control_plane/quota/unsettled_host_turn.py::apply_unsettled_host_turn_recovery_if_required", + "loopx/control_plane/todos/decision_scope.py::build_required_decision_scope_repair_hint", + "loopx/control_plane/todos/user_gate.py::apply_scoped_user_gate_fallback_projection", + "loopx/control_plane/turn_driver/turn_journal.ts::interpretTurnJournal" + ], + "compatibility_only": { + "skip": { + "reason": "Historical comparison value; no bounded producer writes it into the decision slot.", + "retirement": "M1 slot split and consumer cleanup" + } + }, "value_notes": { "skip": "Compared in loopx/control_plane/todos/user_gate.py; no producer writes it into effective_action. Candidate dead value (decision-level skip lives in the decision field).", "observe_replay": "Written only by turn_journal.ts into the replay observation slot; not a should-run verdict. Slot split is RFC Section 12 Q6.", @@ -713,27 +750,27 @@ "meaning": "Decision fields the should-run documentation already calls legacy. Budgets count modules under loopx/ that still mention the field.", "fields": { "execution_obligation": { - "python_module_budget": 21, + "python_module_budget": 20, "typescript_module_budget": 1 }, "heartbeat_recommendation": { - "python_module_budget": 18, + "python_module_budget": 17, "typescript_module_budget": 1 }, "work_lane_contract": { - "python_module_budget": 32, + "python_module_budget": 29, "typescript_module_budget": 3 }, "external_evidence_observation": { - "python_module_budget": 11, + "python_module_budget": 8, "typescript_module_budget": 1 }, "goal_boundary": { - "python_module_budget": 35, + "python_module_budget": 30, "typescript_module_budget": 2 }, "protocol_action_packet": { - "python_module_budget": 7, + "python_module_budget": 5, "typescript_module_budget": 2 } } @@ -756,8 +793,32 @@ "multi_value_fork_definitions": 10, "same_runtime_forks_semantic": 18, "conflicting_values_semantic": 2, - "multi_value_meaning": "Enums, named closed sets, Literal aliases, and TypeScript as-const arrays are vocabulary exactly as a NAME = \"value\" constant is, so they get the same collision rule. One name defined in two modules with identical values is a twin; with different values it is a fork.", + "multi_value_meaning": "Enums, named closed sets, Literal aliases, and TypeScript as-const arrays are vocabulary exactly as a NAME = \"value\" constant is, so they get the same collision rule. One name defined in two modules with identical values is a twin; with different values it is a fork. The semantic multi-value-fork budget excludes only names declared in scope_declarations.", "multi_value_forks_note": "The 4 counted forks include SOURCE_SURFACES, whose four definitions are four CLI commands each listing its own data sources; that is bounded-context reuse of one name, not drift. It stays in the budget until M0.5 adds a scope field (RFC Section 5) and must not be removed by renaming.", - "semantic_meaning": "same_runtime_forks_semantic and conflicting_values_semantic exclude module-local convention names such as SCHEMA_VERSION, COMMAND, or *_LABEL, which every module legitimately names for itself. The remaining names are shared vocabulary, where a duplicate is real drift rather than local naming; the unfiltered totals stay visible in the generated inventory summary." + "semantic_meaning": "same_runtime_forks_semantic and conflicting_values_semantic exclude module-local convention names such as SCHEMA_VERSION, COMMAND, or *_LABEL, which every module legitimately names for itself. The remaining names are shared vocabulary, where a duplicate is real drift rather than local naming; the unfiltered totals stay visible in the generated inventory summary.", + "multi_value_forks_semantic": 3 + }, + "scope_declarations": { + "SOURCE_SURFACES": { + "kind": "bounded_context", + "contexts": [ + { + "id": "global_risks", + "owner": "loopx/global_risks.py::SOURCE_SURFACES" + }, + { + "id": "global_todos", + "owner": "loopx/global_todos.py::SOURCE_SURFACES" + }, + { + "id": "summary_all", + "owner": "loopx/summary_all.py::SOURCE_SURFACES" + }, + { + "id": "pr_review", + "owner": "loopx/pr_review.py::SOURCE_SURFACES" + } + ] + } } } diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index 50d9434045..ab2c3785da 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -63,3 +63,55 @@ def test_literal_scan_rejects_unknown_value_with_either_quote(suffix: str, quote sources = [smoke["SourceFile"]("loopx/probe" + suffix, suffix, text)] with pytest.raises(smoke["Drift"], match="unregistered_action"): smoke["check_literal_vocabularies"](smoke["load_registry"](), sources) + + +def test_bounded_producer_scan_rejects_unregistered_write() -> None: + smoke = runpy.run_path(str(SMOKE)) + source = smoke["SourceFile"]( + "loopx/control_plane/quota/probe.py", + ".py", + 'def produce():\n return {"effective_action": "unregistered_action"}\n', + ) + with pytest.raises(smoke["Drift"], match="unregistered_action"): + smoke["check_producers"]( + { + "vocabularies": { + "effective_action": { + "tier": "kernel", + "owners": {"python": None, "typescript": None}, + "values": ["registered_action"], + "producers": ["loopx/control_plane/quota/probe.py::produce"], + "literal_scan": {"field": "effective_action", "roots": ["loopx"], "suffixes": [".py"]}, + } + } + }, + [source], + ) + + +def test_bounded_producer_scan_does_not_treat_consumer_reads_as_writes() -> None: + smoke = runpy.run_path(str(SMOKE)) + source = smoke["SourceFile"]( + "loopx/control_plane/quota/probe.py", + ".py", + 'def consume(payload):\n return payload.get("effective_action") == "registered_action"\n', + ) + assert smoke["_producer_literals"]("effective_action", source) == set() + + +def test_bounded_context_scope_excludes_only_declared_multi_value_fork() -> None: + smoke = runpy.run_path(str(SMOKE)) + registry = smoke["load_registry"]() + sources = smoke["load_sources"](REPO_ROOT) + inventory = smoke["build_inventory"](REPO_ROOT, sources=sources) + assert smoke["check_scope_declarations"](registry, inventory) == 3 + + +def test_bounded_context_scope_requires_every_distinct_defining_module() -> None: + smoke = runpy.run_path(str(SMOKE)) + registry = copy.deepcopy(smoke["load_registry"]()) + registry["scope_declarations"]["SOURCE_SURFACES"]["contexts"] = registry["scope_declarations"]["SOURCE_SURFACES"]["contexts"][:-1] + sources = smoke["load_sources"](REPO_ROOT) + inventory = smoke["build_inventory"](REPO_ROOT, sources=sources) + with pytest.raises(smoke["Drift"], match="every defining module"): + smoke["check_scope_declarations"](registry, inventory)