diff --git a/.github/workflows/full-public-smokes.yml b/.github/workflows/full-public-smokes.yml index 2f990774fd..48a1971a4b 100644 --- a/.github/workflows/full-public-smokes.yml +++ b/.github/workflows/full-public-smokes.yml @@ -73,6 +73,9 @@ jobs: - name: Install smoke runtime dependencies run: python -m pip install --disable-pip-version-check "jsonschema>=4.23,<5" + - name: Install locked TypeScript parser for semantic production checks + run: npm ci --ignore-scripts + - name: Preview full-public shard run: | python3 examples/run-smokes.py \ diff --git a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md index ec9e0c0470..e26a8f9fdc 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.md @@ -205,7 +205,7 @@ the TypeScript runtime each own one spelling of the same idea. producer and must be registered as one. Enforced from M0.5. - **I12 Every kernel value is produced.** For a `kernel` vocabulary, every value not listed under `compatibility_only` has at least one production site - the fixed production forms recognise or a `variable_sourced_values` entry. A + the fixed production forms recognise or an executable witness at a registered input decoder. A variable-source note alone is not production evidence. A value that is only compared is dead or compatibility-only, never canonical. `skip` in `effective_action` is the first expected failure. Enforced from M0.5; at M0 the literal scan accepts a compared value as carried. @@ -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. @@ -360,6 +360,57 @@ when a value is added or removed after M0.5; `cross_module` only if promoted whose listed symbol is a journal or receipt writer marks the vocabulary `persisted`, which is the fact Q2 and Q10 wait on. +### Executable production evidence during M0.5/M1 + +The producer guard and the owner-carrier check have separate evidence. Defining +an enum member proves membership, not production. For each vocabulary with +producer metadata, the guard compares observed result values against `values`, +rejects undeclared **function sites**, and checks that every non-compatibility +value has an observed producer. A variable-source note is not liveness evidence. +`return_producers` lists the registered functions whose scalar return expressions +belong to this vocabulary; packet builders' unrelated return text is excluded. + +Python field assignments (including subscript/attribute and annotated writes), +dictionaries, call keywords, owner-member results and declared scalar returns +are parsed with AST. Imported enum aliases resolve only to the registered owner; +shadowed names, reassignments and unresolved calls remain unknown. Conditional +results exclude the condition's literals. TypeScript object writes, assignments +and declared returns use the repository's TypeScript parser rather than regex. +Neither parser executes inspected source. These are syntactic result witnesses, +not a proof of reachability or whole-program data flow. + +`python3.11 examples/semantic-vocabulary-drift-smoke.py --report` lists unresolved +production locations. Unknown expressions cannot supply missing value evidence. +The producer guard covers all six kernel entries using distinct evidence lanes: +`effective_action`, `turn_route`, `loop_disposition`, and +`agent_scope_frontier_action` have source witnesses; `turn_result_kind` also has +executable input witnesses at the fixed `transaction._result_kind` decoder. +For each registered value the real decoder must return the matching typed member; +invalid probes must report rejection. This proves a permitted production path, +not that a Host has emitted every member or that every host execution is valid. +`input_producer` cannot select arbitrary code: the verifier is fixed in the smoke. + +`lease_action` is explicitly legacy/compatibility-only: in-repository runtime +callers use separate acquire/renew/transfer/release command classes. Its four +members remain available to the existing typed `LeaseModeGateCommand` input +interface until M4 caller/migration review. No persisted usage is asserted. +The producer list is empty only because every value carries an explicit reason +and retirement milestone. A newly observed producer invalidates that declaration. Kernel families without producer metadata are printed as coverage pending; their +owner parity must not be reported as I12/I13 completion. M0.5 remains incomplete +until all required families meet its acceptance rows. + +The decision owner includes five existing results previously missed by the +literal scanner: `blocked_health`, `blocked_wait`, `control_plane_repair`, +`operator_gate_notify`, and `throttled_skip`. Registering them preserves the +existing quota behavior. `skip` and the synthetic legacy `operator_gate` admission +remain compatibility-only pending M1 cleanup; persisted usage is not established. +Replay and frontier separation, generated bindings and legacy retirement remain +subsequent acceptance obligations, not consequences of this check passing. + +Preparation for the TypeScript parser: `npm ci --ignore-scripts` from the +repository root, using its lockfile. The scan itself needs no network or +credentials. Python 3.11+ and the repository-supported Node runtime are required. + ### Formal model and proof boundary The registry is a finite specification of a larger program semantics. Let @@ -473,9 +524,10 @@ 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) | -| `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 | +| `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..input_producer` | Fixed executable decoder witness, currently `turn_result_kind` only | Every registered input produces the matching typed member and invalid probes reject; arbitrary callable selection is forbidden | +| `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 source site, a variable-sourced entry, or an executable input witness (I12, I13) | +| `vocabularies..compatibility_only` (M0.5) | values retained for persisted readers or a legacy typed caller interface | 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, candidate decisions, and established/bounded/unknown/unproved claims | Exact schema, role hierarchy, candidate decisions, and invariant ids are checked by the drift smoke; enforcement stages cannot be mistaken for completed proofs | | `formal_model.enforcement_policy` | blocking-now, blocking-next, advisory, and unproved lanes | Every formal invariant appears exactly once and its lane agrees with its enforcement stage | | `vocabularies..value_notes`, `deprecated_values` | per-value review notes; values slated for removal | Names must be registered values | @@ -590,11 +642,11 @@ inventory in the same PR. | Measurement covers both carrier shapes and filters local naming | `uv run --extra test python -m 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 | `uv run --extra test python -m 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 `uv run --extra test 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, candidate decision, 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 | @@ -671,7 +723,8 @@ receipts below retain the commands actually executed. | 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 | @@ -774,20 +827,19 @@ introduce a competing target state. `wait`), and `stop`, `terminal`, `contract_error` exist on one side only. The `same_concept` relations record the four shared verdicts. Recommendation: keep both, publish the projection in M2, revisit after the - managed-step consumer matures. Needed before M2. The stated reason for - keeping both is that merging would touch persisted Turn records; that - premise is unverified. Before deciding, the M0.5 production-form scan (I12, - Section 5) applied to `turn_route` should establish - whether `turn_route` is ever written to the journal or a receipt, or only - flows in-process; if the latter, the cost of a merge is far lower than this - RFC assumes and Q10 applies. -3. **Owner module for `EffectiveAction`.** The registry declares no owner - today because no symbol exists; the literal scan is the only check. - Options: `quota/should_run_packet.py` (largest producer), a new - `quota/effective_action.py`, or the TypeScript `turn_envelope.ts` with a - Python import per the migration RFC. Recommendation: TypeScript owner with - generated Python binding only if M2 lands first; otherwise - `quota/effective_action.py`. Needed before M1. + managed-step consumer matures. The persistence premise is now established: + `run_loopx_turn_once` writes `plan: dict(plan)` through the TypeScript journal + writer, including `plan.route.kind`; `load_loopx_turn_plan_from_journal` + restores that route. The executor replay regression checks an actual journal + on disk and the resume reader. Keep the three vocabularies and publish the + non-injective projection in M2; any later renaming needs a persisted-plan + migration, not just an in-process enum refactor. This evidence does not prove + compatibility of every external reader or every other persisted field. +3. **Owner module for `EffectiveAction`.** The implementation uses + `quota/effective_action.py`, matching the pre-generation option. Its runtime + callers serialize `.value` to preserve existing strings. M2 may generate this + binding from the shared contract, retaining the existing import path. No + second independent value list may be introduced into a runtime module. 4. **Companion glossary.** Whether to add `docs/reference/glossary.md` generated from the registry `meaning` fields and the inventory. Owner: docs maintainers. Recommendation: yes, in M1, generated so it cannot drift. @@ -824,17 +876,16 @@ introduce a competing target state. list, not its task list. 10. **Target state for the Turn vocabularies.** Section 11's target table keeps three sets and seven redundant spellings by default because Q2 - recommends keeping both. If the M0.5 production-form scan in Q2 shows `turn_route` is - not persisted, the maintainers should choose between (a) three sets with a - generated projection, the current plan, and (b) a two-phase merge (dual- - write, then retire) to one spelling per concept. Without this decision the - RFC has budgets but no definition of done for its headline problem. + recommends keeping both. Q2's writer/readback evidence shows that `turn_route` + is persisted. The implementation therefore retains three distinct value sets + and generates their projection; it does not merge spellings. A future proposal + to merge them must provide a dual-read/versioned migration and reader proof. 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 7512c2f1bd..970e6cd50c 100644 --- a/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md +++ b/docs/architecture/rfcs/semantic-vocabulary-convergence-v0.zh-CN.md @@ -171,8 +171,9 @@ todos、capabilities 与 TypeScript 运行时各自拥有同一想法的一种 比较、序列化或展示一个值不带来任何所有权。开始写入值的解释者或透传者已经 变成生产者,必须登记为生产者。自 M0.5 起强制。 - **I12 每个内核值都被生产。** 对 `kernel` 词表,未列入 `compatibility_only` - 的每个值至少有一个固定生产形式能识别的生产位点,或一条 - `variable_sourced_values`。只被比较的值是死值或兼容值,绝不是 canonical。 + 的每个值至少有一个固定生产形式能识别的生产位点,或已登记输入解码器的 + 可执行见证。变量来源备注本身不能作为生产证据。只被比较的值是死值或兼容值, + 绝不是 canonical。 `effective_action` 的 `skip` 是第一个预期失败。自 M0.5 起强制;M0 的字面量 扫描把被比较的值当作已携带。 - **I13 生产者只写注册值。** 写入注册集合之外值的生产位点失败即关闭,与是否 @@ -260,9 +261,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` 的备注里。 @@ -296,6 +297,47 @@ M0.5 之后新增或删除值时;`cross_module` 只在晋升后(Q8)。持 答的属性:若某个已列生产者符号是 journal 或 receipt 的写方,该词表标为 `persisted`,这正是 Q2 与 Q10 等待的事实。 +### M0.5/M1 的可执行生产证据 + +生产者守卫与 owner 载体检查使用不同证据。定义枚举成员只能证明集合成员关系, +不能证明生产。对带生产者元数据的词表,守卫比较观察到的结果值与 `values`,拒绝 +未登记的**函数位点**,并要求每个非兼容值存在观察到的生产者。变量来源备注不能 +替代存活证据。`return_producers` 列出其标量返回表达式属于该词表的已登记函数; +返回整个 packet 的构建器不会因此把无关返回文字当作词表值。 + +Python 通过 AST 解析字段赋值(含下标、属性及带注解赋值)、字典、调用关键字、 +owner 成员结果及声明函数的标量返回。导入枚举的别名只解析到已登记 owner; +被遮蔽的名字、重复赋值及未解析调用仍为 unknown。条件表达式只检查结果分支, +排除条件中的字面量。TypeScript 的对象写入、赋值及声明返回使用仓库的 TypeScript +解析器。两个解析器都不执行被检查源码。这些是句法结果证据,不是可达性或全程序 +数据流证明。 + +`python3.11 examples/semantic-vocabulary-drift-smoke.py --report` 列出未解析的生产 +位置。unknown 不能补足缺失值的生产证据。生产者守卫对六个 kernel 条目使用不同证据:`effective_action`、`turn_route`、 +`loop_disposition` 和 `agent_scope_frontier_action` 使用源码见证; +`turn_result_kind` 另有固定入口 `transaction._result_kind` 的可执行输入见证。 +真实解码器必须为每个注册输入返回相同的类型化成员,并拒绝非法探测输入。这证明 +存在允许的生产路径,不表示 Host 实际发出过全部成员或所有 Host 执行都合法。 +`input_producer` 不能从数据任意指定执行代码,验证入口固定在 smoke 中。 + +`lease_action` 明确分类为 legacy/兼容保留:仓库运行时调用者使用分开的 +acquire/renew/transfer/release command 类。四个成员为旧的类型化 +`LeaseModeGateCommand` 输入接口保留到 M4 调用者/迁移评审;不声称存在持久化 +使用。只有每个值都带保留理由及退休里程碑时,生产者列表才能为空。新发现的 +生产者必须让原兼容声明失败。 +没有生产者元数据的 kernel 词表会明确 +报告为覆盖待完成,不能把 owner 一致性宣称为 I12/I13 完成。所有要求的词表通过 +相应验收行之前,M0.5 仍未完成。 + +decision owner 补登记了旧字面量扫描漏掉的五个现存结果:`blocked_health`、 +`blocked_wait`、`control_plane_repair`、`operator_gate_notify` 和 `throttled_skip`。 +这些登记保留现有 quota 行为。`skip` 和合成夹具使用的旧 `operator_gate` 暂保留为 +兼容值,等待 M1 清理;尚未建立它们的持久化使用证据。replay/frontier 拆分、生成 +绑定及旧字段退休仍须分别验收,不能由本次检查通过推出。 + +TypeScript 解析器准备命令是在仓库根目录执行 `npm ci --ignore-scripts`,使用仓库 +锁文件。扫描本身不需网络或凭据;需要 Python 3.11+ 和仓库支持的 Node 运行时。 + ### 形式模型与证明边界 注册表是更大程序语义的有限规格。令 `V` 为已注册词表集合,`L` 为源码位点集合, @@ -387,9 +429,10 @@ external_input | compatibility_only | unknown | `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) | -| `vocabularies..producers`(M0.5) | 写入该字段的 `path::Symbol` 位点,`kernel` 必填 | 每个位点只写注册值;未列入 `compatibility_only` 的每个值至少有一个位点或一条变量来源条目(I12、I13) | -| `vocabularies..compatibility_only`(M0.5) | 为让已持久化记录的读者仍能解析而保留的值 | `values` 的子集;零生产位点;每个值带 `value_notes` 理由与退休里程碑 | +| `scope_declarations.`(M0.5a) | `bounded_context` 及上下文 ID,每个上下文含一个 `module::Symbol` owner | 每个声明名对应一个 inventory 分叉,并且一次且仅一次列出全部定义模块;只从 `multi_value_forks_semantic` 排除,未声明分叉仍可见(I14) | +| `vocabularies..input_producer` | 固定的可执行解码入口,目前仅用于 `turn_result_kind` | 每个注册输入必须产生匹配的类型化成员,非法探测输入必须拒绝;禁止任意选择执行入口 | +| `vocabularies..producers`(M0.5) | 写入该字段的 `path::Symbol` 位点,`kernel` 必填 | 每个位点只写注册值;未列入 `compatibility_only` 的每个值至少有一个源码生产位点、一条变量来源条目或可执行输入见证(I12、I13) | +| `vocabularies..compatibility_only`(M0.5) | 为持久化读者或旧类型化调用接口保留的值 | `values` 的子集;零生产位点;每个值带 `value_notes` 理由与退休里程碑 | | `formal_model` | 有限的集合、角色关系与层次、语义义务、候选决策,以及已建立/有界/unknown/未证明的声明 | 漂移 smoke 校验精确 schema、角色层次、候选决策和不变量 ID;属性实施阶段不能冒充已完成证明 | | `formal_model.enforcement_policy` | 当前阻断、下一阶段阻断、建议性和未证明层级 | 每个形式不变量恰好出现一次,且层级与其实施阶段一致 | | `vocabularies..value_notes`、`deprecated_values` | 逐值评审备注;计划删除的值 | 名字必须是已注册值 | @@ -488,9 +531,10 @@ PR 中重新生成清单。 | 两处 owner 修正不改变行为 | `uv run --extra test python -m 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` 与 `uv run --extra test loopx canary premerge --from-git-diff` | 通过 | 在干净树上可复现的 `main` 既有环境失败除外 | | 文档治理接受这对 RFC | `python3 examples/docs-governance-smoke.py` | 通过 | 检查镜像、链接、索引 | | 退休预算按子串而非标识符计数 | 分别以 `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 失败;只被比较的值不算已携带 | +| 模块局部约定过滤器是一次代码修改 | 扩宽 `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 针对形式模型结构失败 | 该模型是有限契约和证明账本,本身不等于这些性质已经被证明 | @@ -554,7 +598,8 @@ Canary 将显示为 `python3` 的命令转换为启动 LoopX 的 `sys.executable | 里程碑 | 交付行为 | 进入门 | 退出证据 | 回滚 | | --- | --- | --- | --- | --- | | 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 条目 | 从最后一个写方恢复字段 | @@ -638,16 +683,16 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 投影覆盖全部输入但非单射(`blocked` 与 `wait` 都映到 `wait`),而 `stop`、 `terminal`、`contract_error` 只在一侧存在。`same_concept` 关系记录了四个共享 裁决。建议:两者都保留,M2 发布投影,待 managed-step 消费者成熟后再议。 - M2 前需定。保留两者的理由是合并会触及已持久化的 Turn 记录;这个前提尚未 - 核实。决定之前应先用 M0.5 的生产形式扫描(I12,第 5 节)确认 `turn_route` - 是否曾写入 journal 或 - receipt,还是只在进程内流转;若是后者,合并的代价远低于本 RFC 的假设, - 适用 Q10。 -3. **`EffectiveAction` 的 owner 模块。** 注册表今天不声明 owner,因为不存在任何 - 符号;字面量扫描是唯一检查。选项:`quota/should_run_packet.py`(最大生产者)、 - 新建 `quota/effective_action.py`,或按迁移 RFC 以 TypeScript `turn_envelope.ts` - 为 owner 并生成 Python 绑定。建议:若 M2 先落地则以 TypeScript 为 owner 并 - 生成 Python 绑定;否则新建 `quota/effective_action.py`。M1 前需定。 + 持久化前提已有实现证据:`run_loopx_turn_once` 经 TypeScript journal writer + 写入完整的 `plan: dict(plan)`,其中包含 `plan.route.kind`; + `load_loopx_turn_plan_from_journal` 会恢复这个 route。执行器的回放回归用例 + 检查实际落盘的 journal 及恢复读者。因此保留三套词表,在 M2 发布非单射投影; + 后续若改名,必须迁移持久化 plan,不能只做进程内枚举重构。此证据不等于全部 + 外部读者或其他持久化字段的兼容性证明。 +3. **`EffectiveAction` 的 owner 模块。** 实现选择 `quota/effective_action.py`, + 对应生成阶段之前的选项。运行时调用者通过 `.value` 保留现有字符串。M2 可以 + 从共享契约生成该绑定,并保留现有 import 路径;不得在运行时模块另写一份 + 独立维护的值表。 4. **伴随术语表。** 是否新增 `docs/reference/glossary.md`,从注册表的 `meaning` 字段与清单生成。Owner:文档维护者。建议:是,在 M1 生成以免漂移。 5. **词族命名规则。** `gate`、`scope`、`packet`、`handoff`、`settlement` 词族中的 @@ -673,14 +718,13 @@ PR review 保留这些层级。普通改动记录检查范围和理由,无共 一次则改 (a)。Owner:仓库维护者。这是运维决策不是代码改动;应放在跟踪 issue 的决策清单里,而不是任务清单里。 10. **Turn 词表的终态。** 第 11 节的目标表默认保留三套与七个冗余拼法,因为 - Q2 建议保留两者。若 Q2 的 M0.5 生产形式扫描表明 `turn_route` 未被持久化,维护者 - 应在 (a) 三套加生成投影(现行计划)与 (b) 两阶段合并(先双写、后退休)到 - 每个概念一种拼法之间选择。没有这个决定,RFC 对其标题问题只有预算、没有 - 完成定义。Owner:Turn driver owner。M2 关闭前需定。 -11. **退休预算按标识符计数。** 六个旧字段预算用 `field in file.text` 统计; - `goal_boundary` 会匹配 `goal_boundary_repair`。M3 的零外部读者门需要词边界 - 计数,这会在一个 diff 里调低全部六个锚点。建议:在第一个 M3 PR 之前做。 - Owner:内核维护者。 + Q2 建议保留两者。Q2 的实际写入及读回证据证明 `turn_route` 已持久化,因此 + 实现保留三套不同值集并生成投影,不合并拼法。未来合并提案须提供双读或带版本 + 的迁移及读者证据。Owner:Turn driver 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 1c02c159f5..10288c49aa 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -34,19 +34,34 @@ typescript_facts, ) +from loopx.semantics.production import ( # noqa: E402 + collect_production, validate_production, probe_turn_result_input_domain, +) +from loopx.semantics.python_production import scan_python_production # noqa: E402 + 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", + "return_producers", + "input_producer", +} TIERS = {"kernel", "cross_runtime", "cross_module"} STATUSES = {"canonical", "legacy", "merge_candidate"} FORMAL_MODEL_KEYS = { @@ -87,7 +102,7 @@ # silently; that is the gap the anchor exists to close. COVERAGE_ANCHOR = { "vocabularies": 26, - "owner_symbols": 46, + "owner_symbols": 47, "literal_scan_fields": 1, "projections": 1, "relations": 9, @@ -95,6 +110,14 @@ } COVERAGE_SUFFIX_ANCHOR = (".py", ".ts") LITERAL_SCAN_ROOTS = ["loopx"] +PRODUCER_VOCABULARY_ANCHOR = { + "effective_action", "turn_route", "loop_disposition", "agent_scope_frontier_action", "turn_result_kind", "lease_action", +} +RETURN_PRODUCER_ANCHOR = { + "turn_route": {"loopx/control_plane/turn_driver/driver.py::_typed_route", "loopx/control_plane/turn_driver/loop_controller.py::_envelope_route"}, + "loop_disposition": {"loopx/control_plane/turn_driver/loop_controller.py::_route_to_disposition"}, + "effective_action": {"loopx/control_plane/quota/decision_summary.py::quota_effective_action"}, +} TWIN_ROOT_ANCHOR = "loopx/control_plane" TWIN_BUDGET_ANCHOR = 43 BUDGET_ANCHOR = { @@ -105,6 +128,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, @@ -112,12 +136,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", @@ -127,6 +151,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", @@ -192,6 +217,23 @@ 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), f"{name}: producers must be a list") + require(bool(producers) or set(vocabulary.get('compatibility_only', {})) == set(values), f"{name}: empty producers require every value to be compatibility-only") + require(all(isinstance(site, str) and OWNER_SHAPE.match(site) for site in producers), f"{name}: producers must be module::Symbol sites") + if 'input_producer' in vocabulary: + require(name == 'turn_result_kind', f"{name}: no executable input producer verifier is implemented") + require(vocabulary['input_producer'] == 'loopx/control_plane/turn_driver/transaction.py::_result_kind', f"{name}: unrecognised input producer") + returns = vocabulary.get("return_producers", []) + require(isinstance(returns, list) and all(isinstance(site, str) and OWNER_SHAPE.match(site) for site in returns), f"{name}: return_producers must be module::Symbol sites") + require(set(returns) <= set(producers or []), f"{name}: return_producers must also be registered producers") + 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") @@ -256,6 +298,15 @@ def check_formal_model(model: dict[str, Any]) -> None: def check_coverage_floor(registry: dict[str, Any]) -> str: + require( + registry['vocabularies']['turn_result_kind'].get('input_producer') == 'loopx/control_plane/turn_driver/transaction.py::_result_kind', + 'turn_result_kind: input producer coverage must retain the anchored decoder', + ) + for name in PRODUCER_VOCABULARY_ANCHOR: + require("producers" in registry["vocabularies"][name], f"{name}: producer coverage dropped below PRODUCER_VOCABULARY_ANCHOR") + for name, required in RETURN_PRODUCER_ANCHOR.items(): + actual_returns = set(registry['vocabularies'][name].get('return_producers', [])) + require(required <= actual_returns, f"{name}: return producer coverage dropped below RETURN_PRODUCER_ANCHOR") for vocabulary in registry["vocabularies"].values(): if scan := vocabulary.get("literal_scan"): require(scan["roots"] == LITERAL_SCAN_ROOTS, "literal_scan roots must cover loopx") @@ -365,10 +416,45 @@ def check_literal_vocabularies(registry: dict[str, Any], sources: list[SourceFil for value, producer in variable_sourced.items(): text = (REPO_ROOT / producer).read_text(encoding="utf-8", errors="replace") require(f'"{value}"' in text, f"{name}: variable-sourced value {value} is no longer produced by {producer}") - unused = sorted(expected - set(observed) - set(variable_sourced)) + owner_values_seen = { + value + for owner in vocabulary["owners"].values() + if owner + for value in owner_values(owner) + } + unused = sorted( + expected - set(observed) - set(variable_sourced) - owner_values_seen + ) require(not unused, f"{name}: registry lists values no module carries: {unused}") +# --- bounded producer scan ---------------------------------------------------------- + + +def _producer_literals(field: str, source: SourceFile) -> set[str]: + # Compatibility helper for direct-form mutation fixtures. No enum definitions + # are supplied, so these tests cannot accidentally count owners as producers. + if source.suffix != '.py': + return set() + rows = scan_python_production(source, field=field, enums={}) + return set().union(*(row.values for row in rows)) + + +def check_producers(registry: dict[str, Any], sources: list[SourceFile]) -> list[str]: + unknown: list[str] = [] + for name, vocabulary in registry['vocabularies'].items(): + if 'producers' not in vocabulary: + continue # Other kernel families retain an explicit M0.5 coverage gap. + try: + rows = collect_production(REPO_ROOT, vocabulary, sources) + if name == 'turn_result_kind': + rows.extend(probe_turn_result_input_domain(vocabulary)) + unknown.extend(validate_production(name, vocabulary, rows)) + except ValueError as error: + raise Drift(str(error)) from error + return sorted(set(unknown)) + + # --- relations, projections, schema versions ---------------------------------------- @@ -429,6 +515,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 ----------------------------------------------------------------------- @@ -441,7 +568,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, @@ -452,6 +579,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") @@ -468,17 +612,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; from the repository root run uv run python 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) @@ -489,6 +635,7 @@ def main() -> int: inventory, ratchets = check_inventory(registry, sources) check_owned_vocabularies(registry, inventory) check_literal_vocabularies(registry, sources) + unknown_producers = check_producers(registry, sources) check_relations(registry) check_projections(registry) check_schema_version_owners(registry, sources) @@ -499,6 +646,12 @@ def main() -> int: print(" " + ratchets) print(" " + " ".join(budgets)) print(" " + twins) + print(f" unresolved_producer_sites={len(unknown_producers)} (not proven safe)") + uncovered = [name for name, v in registry['vocabularies'].items() if v['tier'] == 'kernel' and 'producers' not in v] + print(f" kernel_producer_coverage_pending={','.join(uncovered)}") + if '--report' in sys.argv[1:]: + for site in unknown_producers: + print(f" unknown_producer: {site}") return 0 diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 1075fa5100..bf464f3c97 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..control_plane.quota.effective_action import EffectiveAction import argparse from collections.abc import Callable, Mapping @@ -226,7 +227,7 @@ def _apply_requested_quota_action_selection_preflight( pending_selection_workspace_repair_qualified = ( selection_binding == "pending_action_selection" and payload.get("workspace_repair_allowed") is True - and payload.get("effective_action") == "agent_workspace_repair" + and payload.get("effective_action") == EffectiveAction.AGENT_WORKSPACE_REPAIR.value and execution_obligation.get("kind") == "agent_workspace_repair" and execution_obligation.get("must_attempt_work") is True and agent_channel.get("must_attempt") is True @@ -562,7 +563,7 @@ def handle_quota_command( turn_instance_id=heartbeat_turn_id, ) if ( - payload.get("effective_action") == "monitor_quiet_skip" + payload.get("effective_action") == EffectiveAction.MONITOR_QUIET_SKIP.value or existing_stall is not None ): poll = record_quota_monitor_poll( diff --git a/loopx/cli_commands/quota_scheduler_followup.py b/loopx/cli_commands/quota_scheduler_followup.py index 84e518b8e8..0e9fbef4ea 100644 --- a/loopx/cli_commands/quota_scheduler_followup.py +++ b/loopx/cli_commands/quota_scheduler_followup.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..control_plane.quota.effective_action import EffectiveAction import argparse from collections.abc import Callable, Mapping @@ -181,7 +182,7 @@ def build_scheduler_followup_payload( turn_instance_id and receipt_todo_id is None and receipt_replan_id is not None - and before_decision.get("effective_action") == "heartbeat_settled_skip" + and before_decision.get("effective_action") == EffectiveAction.HEARTBEAT_SETTLED_SKIP.value ): return { "ok": True, diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 29adcdb506..e4ccb78e33 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..control_plane.quota.effective_action import EffectiveAction import argparse import json @@ -163,7 +164,7 @@ def handle_turn_command( args.turn_command == "run-once" and args.host == "codex-cli" and not resume_requested - and turn_envelope.get("effective_action") != "governed_capability_intent" + and turn_envelope.get("effective_action") != EffectiveAction.GOVERNED_CAPABILITY_INTENT.value ): session_binding = codex_cli_session_binding(runtime_root, turn_envelope) payload = build_loopx_turn_plan( diff --git a/loopx/control_plane/quota/decision_summary.py b/loopx/control_plane/quota/decision_summary.py index 4735d17654..fb1635348b 100644 --- a/loopx/control_plane/quota/decision_summary.py +++ b/loopx/control_plane/quota/decision_summary.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from dataclasses import dataclass from typing import Any, TypedDict @@ -6,7 +7,6 @@ from ...state_projection import actions_are_projection_aligned from ..goals.contract_health import project_contract_health_for_goal from ..goals.goal_frontier import ( - AUTONOMOUS_REPLAN_REQUIRED_MODE, autonomous_replan_decision_allowed, goal_frontier_is_terminal_no_followup, ) @@ -293,7 +293,7 @@ def resolve_quota_run_decision( normal_delivery_allowed = False recovery_delivery_allowed = False should_run = True - effective_action = AUTONOMOUS_REPLAN_REQUIRED_MODE + effective_action = EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED.value reason = ( "autonomous replan obligation is selected before monitor quiet " "or agent-scope wait classification" @@ -318,7 +318,7 @@ def resolve_quota_run_decision( capability_repair_allowed = False workspace_repair_allowed = False should_run = False - effective_action = "terminal_no_followup" + effective_action = EffectiveAction.TERMINAL_NO_FOLLOWUP.value reason = ( "validated closure evidence derives terminal no-follow-up from " "complete todo sources and an empty frontier; stop recurring " @@ -327,7 +327,7 @@ def resolve_quota_run_decision( if automation_prompt_upgrade_required and not terminal_no_followup: should_run = False - effective_action = "automation_prompt_upgrade_required" + effective_action = EffectiveAction.AUTOMATION_PROMPT_UPGRADE_REQUIRED.value elif inbox_reply_due: should_run = True normal_delivery_allowed = True @@ -335,7 +335,7 @@ def resolve_quota_run_decision( self_repair_allowed = False capability_repair_allowed = False workspace_repair_allowed = False - effective_action = "lark_inbox_reply_due" + effective_action = EffectiveAction.LARK_INBOX_REPLY_DUE.value reason = ( "a direct Lark question, bot mention, or verified reply to the bot " "is pending reply" @@ -347,7 +347,7 @@ def resolve_quota_run_decision( self_repair_allowed = False capability_repair_allowed = False workspace_repair_allowed = False - effective_action = "operator_inbox_material_review_due" + effective_action = EffectiveAction.OPERATOR_INBOX_MATERIAL_REVIEW_DUE.value reason = ( "captured unaddressed operator-inbox material is pending bounded review" ) @@ -386,29 +386,29 @@ def quota_effective_action( quota: dict[str, Any], ) -> str: if normal_delivery_allowed: - return "normal_run" + return EffectiveAction.NORMAL_RUN.value if recovery_delivery_allowed: - return "outcome_floor_recovery" + return EffectiveAction.OUTCOME_FLOOR_RECOVERY.value if workspace_repair_allowed: - return "agent_workspace_repair" + return EffectiveAction.AGENT_WORKSPACE_REPAIR.value if self_repair_allowed: repair_action = ( stall_self_repair.get("effective_action") if isinstance(stall_self_repair, dict) else None ) - return str(repair_action or "control_plane_repair") + return str(repair_action or EffectiveAction.CONTROL_PLANE_REPAIR.value) if capability_repair_allowed: - return "capability_bridge_repair" + return EffectiveAction.CAPABILITY_BRIDGE_REPAIR.value if state == "operator_gate": - return "operator_gate_notify" + return EffectiveAction.OPERATOR_GATE_NOTIFY.value if state == "blocked_health": - return "blocked_health" + return EffectiveAction.BLOCKED_HEALTH.value if state == "throttled": - return "throttled_skip" + return EffectiveAction.THROTTLED_SKIP.value if state in {"focus_wait", "waiting"} or quota.get("focus_wait"): - return "blocked_wait" - return "quota_skip" + return EffectiveAction.BLOCKED_WAIT.value + return EffectiveAction.QUOTA_SKIP.value def _task_orchestration_effective_action( @@ -424,18 +424,18 @@ def _task_orchestration_effective_action( and str(contract.get("execution_state") or "ready") == "ready" and should_run and normal_delivery_allowed - and effective_action == "normal_run" + and effective_action == EffectiveAction.NORMAL_RUN.value ): if contract.get("mode") == "adaptive": return ( - "coordinate_task_bundle", + EffectiveAction.COORDINATE_TASK_BUNDLE.value, ( "the task coordinator may use admitted child lanes before its " "own worker-lane delivery" ), ) return ( - "coordinate_task_bundle", + EffectiveAction.COORDINATE_TASK_BUNDLE.value, ( "the explicitly selected task coordinator must activate or resume " "eligible peer lanes before doing its own worker-lane delivery" diff --git a/loopx/control_plane/quota/effective_action.py b/loopx/control_plane/quota/effective_action.py new file mode 100644 index 0000000000..304d361496 --- /dev/null +++ b/loopx/control_plane/quota/effective_action.py @@ -0,0 +1,51 @@ +"""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" + BLOCKED_HEALTH = "blocked_health" + BLOCKED_WAIT = "blocked_wait" + CONTROL_PLANE_REPAIR = "control_plane_repair" + OPERATOR_GATE_NOTIFY = "operator_gate_notify" + THROTTLED_SKIP = "throttled_skip" diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index 26b88a7a87..f902b35fd9 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction import json from collections.abc import Mapping @@ -462,7 +463,7 @@ def fail_heartbeat_receipt( "ok": False, "decision": "skip", "should_run": False, - "effective_action": "heartbeat_receipt_write_failed", + "effective_action": EffectiveAction.HEARTBEAT_RECEIPT_WRITE_FAILED.value, "state": "blocked_health", "waiting_on": "codex", "reason": reason, diff --git a/loopx/control_plane/quota/host_poll_receipts.py b/loopx/control_plane/quota/host_poll_receipts.py index 83e9c79c69..5657699cd9 100644 --- a/loopx/control_plane/quota/host_poll_receipts.py +++ b/loopx/control_plane/quota/host_poll_receipts.py @@ -12,6 +12,7 @@ """ from __future__ import annotations +from .effective_action import EffectiveAction import json import os @@ -72,7 +73,7 @@ def record_host_poll_receipt( terminal_state = frontier.get("terminal_state") if isinstance(frontier, dict) else None terminal = bool( decision.get("should_run") is False - and decision.get("effective_action") == "terminal_no_followup" + and decision.get("effective_action") == EffectiveAction.TERMINAL_NO_FOLLOWUP.value and isinstance(terminal_state, dict) and terminal_state.get("kind") == "no_followup" ) diff --git a/loopx/control_plane/quota/live_decision.py b/loopx/control_plane/quota/live_decision.py index c9ac6427b6..62a5a6ef62 100644 --- a/loopx/control_plane/quota/live_decision.py +++ b/loopx/control_plane/quota/live_decision.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction import shlex from collections.abc import Callable, Mapping, Sequence @@ -191,7 +192,7 @@ def _apply_pending_capability_intent_precedence( "decision": "run", "should_run": True, "state": "eligible", - "effective_action": "governed_capability_intent", + "effective_action": EffectiveAction.GOVERNED_CAPABILITY_INTENT.value, "actionable_by_codex": True, "normal_delivery_allowed": False, "recovery_delivery_allowed": False, diff --git a/loopx/control_plane/quota/projection_repair.py b/loopx/control_plane/quota/projection_repair.py index df427715aa..fb201de35c 100644 --- a/loopx/control_plane/quota/projection_repair.py +++ b/loopx/control_plane/quota/projection_repair.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction import fnmatch from typing import Any @@ -108,7 +109,7 @@ def build_state_projection_gap_repair_hint( "source": "quota.should-run", "trigger": "state_projection_gap", "recommended_mode": "repair_state_projection_gap", - "effective_action": "state_projection_gap_repair", + "effective_action": EffectiveAction.STATE_PROJECTION_GAP_REPAIR.value, "allowed": True, "notify": "DONT_NOTIFY", "reason": ( @@ -204,7 +205,7 @@ def build_boundary_projection_repair_hint( "source": "quota.should-run", "trigger": "required_write_scope_missing_from_goal_boundary", "recommended_mode": "repair_boundary_projection", - "effective_action": "boundary_projection_repair", + "effective_action": EffectiveAction.BOUNDARY_PROJECTION_REPAIR.value, "blocked_action_scope": "boundary_projection", "allowed": True, "notify": "DONT_NOTIFY", diff --git a/loopx/control_plane/quota/settlement_cli.py b/loopx/control_plane/quota/settlement_cli.py index 9bc0acc544..4722bcdd74 100644 --- a/loopx/control_plane/quota/settlement_cli.py +++ b/loopx/control_plane/quota/settlement_cli.py @@ -1,6 +1,7 @@ """CLI rollout helpers for heartbeat settlement identity and receipt wiring.""" from __future__ import annotations +from .effective_action import EffectiveAction import argparse from collections.abc import Mapping @@ -193,7 +194,7 @@ def quota_rollout_settlement_binding( packet is only a diagnostic fallback when no concrete Todo is selected. """ - if payload.get("effective_action") == "unsettled_host_turn_recovery": + if payload.get("effective_action") == EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value: # This Turn only repairs the preceding Turn's closeout. A concurrently # projected Todo or autonomous replan belongs to the post-recovery # decision and must not become this receipt's settlement identity. diff --git a/loopx/control_plane/quota/settlement_precedence.py b/loopx/control_plane/quota/settlement_precedence.py index c87173c6e1..f1cf97b179 100644 --- a/loopx/control_plane/quota/settlement_precedence.py +++ b/loopx/control_plane/quota/settlement_precedence.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from typing import Any, Protocol @@ -77,7 +78,7 @@ def apply_settled_replay_route_precedence( route.capability_repair_allowed = False route.workspace_repair_allowed = False route.should_run = False - route.effective_action = "heartbeat_settled_skip" + route.effective_action = EffectiveAction.HEARTBEAT_SETTLED_SKIP.value route.reason = HEARTBEAT_SETTLED_REPLAY_REASON route.replan_decision_allowed = False route.receipt_bound_replan_decision = False @@ -127,7 +128,7 @@ def apply_settled_replay_payload_precedence( "self_repair_allowed": False, "capability_repair_allowed": False, "workspace_repair_allowed": False, - "effective_action": "heartbeat_settled_skip", + "effective_action": EffectiveAction.HEARTBEAT_SETTLED_SKIP.value, "actionable_by_codex": False, "reason": reason, "requires_user_action": False, diff --git a/loopx/control_plane/quota/should_run.py b/loopx/control_plane/quota/should_run.py index a8f4a265ab..66f052a7c7 100644 --- a/loopx/control_plane/quota/should_run.py +++ b/loopx/control_plane/quota/should_run.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from collections.abc import Callable, Mapping from pathlib import Path @@ -173,7 +174,7 @@ def build_quota_paused_should_run_payload( } execution_obligation = _execution_obligation( should_run=False, - effective_action="quota_skip", + effective_action=EffectiveAction.QUOTA_SKIP.value, heartbeat_recommendation=heartbeat_recommendation, ) payload: dict[str, Any] = { @@ -188,7 +189,7 @@ def build_quota_paused_should_run_payload( "self_repair_allowed": False, "capability_repair_allowed": False, "workspace_repair_allowed": False, - "effective_action": "quota_skip", + "effective_action": EffectiveAction.QUOTA_SKIP.value, "actionable_by_codex": False, "reason": reason, "quota": quota, diff --git a/loopx/control_plane/quota/should_run_packet.py b/loopx/control_plane/quota/should_run_packet.py index ab7eb108d0..e59517d99f 100644 --- a/loopx/control_plane/quota/should_run_packet.py +++ b/loopx/control_plane/quota/should_run_packet.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from .effective_action import EffectiveAction from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path @@ -349,7 +349,7 @@ def _apply_agent_monitor_only_precedence( "self_repair_allowed": False, "capability_repair_allowed": False, "workspace_repair_allowed": False, - "effective_action": "monitor_due" if monitor_due else "monitor_quiet_skip", + "effective_action": EffectiveAction.MONITOR_DUE.value if monitor_due else EffectiveAction.MONITOR_QUIET_SKIP.value, "actionable_by_codex": monitor_due, "reason": reason, "blocked_action_scope": "advancement_work", @@ -397,7 +397,7 @@ def _apply_agent_monitor_only_precedence( "self_repair_allowed": False, "capability_repair_allowed": False, "workspace_repair_allowed": False, - "effective_action": "agent_monitor_only", + "effective_action": EffectiveAction.AGENT_MONITOR_ONLY.value, "actionable_by_codex": False, "reason": reason, "blocked_action_scope": "advancement_work", @@ -745,7 +745,7 @@ def _planning_projections( and prepared.workspace_guard and prepared.normal_delivery_allowed ) or bool( - route.effective_action == "boundary_projection_repair" + route.effective_action == EffectiveAction.BOUNDARY_PROJECTION_REPAIR.value and prepared.boundary_projection_repair ) projection_enabled = bool( @@ -893,7 +893,7 @@ def _resolve_quota_should_run_route( "spend_policy": external_evidence_observation.get("spend_policy") or heartbeat_recommendation.get("spend_policy"), } - effective_action = "external_evidence_observe" + effective_action = EffectiveAction.EXTERNAL_EVIDENCE_OBSERVE.value reason = "external evidence monitor requires read-only observation before quiet no-op" receipt_bound_monitor_settled = ( work_lane_contract_is_receipt_bound_monitor_settled( @@ -905,7 +905,7 @@ def _resolve_quota_should_run_route( recovery_allowed = False self_repair_allowed = False should_run = False - effective_action = "heartbeat_settled_skip" + effective_action = EffectiveAction.HEARTBEAT_SETTLED_SKIP.value reason = ( "the receipt-bound monitor poll and required settlement receipts are " "complete for this heartbeat turn; defer successor selection to a new turn" @@ -934,7 +934,7 @@ def _resolve_quota_should_run_route( if monitor_quiet_skip: normal_delivery_allowed = False should_run = False - effective_action = "monitor_quiet_skip" + effective_action = EffectiveAction.MONITOR_QUIET_SKIP.value reason = str( heartbeat_recommendation.get("reason") or "monitor-only polling has no material transition; skip delivery compute" @@ -1040,7 +1040,7 @@ def _resolve_quota_should_run_route( prepared.task_orchestration_contract, effective_action=effective_action, ): - effective_action = PEER_COORDINATION_BLOCKED_ACTION + effective_action = EffectiveAction.PEER_COORDINATION_BLOCKED.value reason = ( "the explicitly selected peer task bundle is blocked and the " "coordinator has no in-scope runnable fallback; return control " diff --git a/loopx/control_plane/quota/slot_accounting.py b/loopx/control_plane/quota/slot_accounting.py index 6819f75d82..bc9088e840 100644 --- a/loopx/control_plane/quota/slot_accounting.py +++ b/loopx/control_plane/quota/slot_accounting.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction import json from collections.abc import Callable, Iterable @@ -606,13 +607,13 @@ def build_quota_slot_preview_for_decision( ( before.get("state") == "operator_gate" or before.get("recovery_delivery_allowed") is True - or before.get("effective_action") == "outcome_floor_recovery" + or before.get("effective_action") == EffectiveAction.OUTCOME_FLOOR_RECOVERY.value ) and before.get("safe_bypass_allowed") is True ) self_repair_spend = before.get("effective_action") in self_repair_spend_actions capability_repair_spend = ( - before.get("effective_action") == "capability_bridge_repair" + before.get("effective_action") == EffectiveAction.CAPABILITY_BRIDGE_REPAIR.value and before.get("capability_repair_allowed") is True ) delivery_completion_run = delivery_completion_run or ( @@ -715,7 +716,7 @@ def build_quota_slot_preview_for_decision( } delivery_workspace_validated = bool(delivery_workspace) workspace_repair_no_spend = ( - before.get("effective_action") == "agent_workspace_repair" + before.get("effective_action") == EffectiveAction.AGENT_WORKSPACE_REPAIR.value and before.get("workspace_repair_allowed") is True and not delivery_workspace_validated ) @@ -745,13 +746,13 @@ def build_quota_slot_preview_for_decision( and ( settlement_identity is not None or not before.get("should_run") - or before.get("effective_action") == "external_evidence_observe" + or before.get("effective_action") == EffectiveAction.EXTERNAL_EVIDENCE_OBSERVE.value or ( - before.get("effective_action") == "agent_workspace_repair" + before.get("effective_action") == EffectiveAction.AGENT_WORKSPACE_REPAIR.value and delivery_workspace_validated ) ) - and before.get("effective_action") != "automation_prompt_upgrade_required" + and before.get("effective_action") != EffectiveAction.AUTOMATION_PROMPT_UPGRADE_REQUIRED.value and not safe_bypass_spend and str(before.get("state") or "") in {"waiting", "focus_wait", "operator_gate", "eligible"} ) diff --git a/loopx/control_plane/quota/stall_repair.py b/loopx/control_plane/quota/stall_repair.py index 66463c8415..58f7568611 100644 --- a/loopx/control_plane/quota/stall_repair.py +++ b/loopx/control_plane/quota/stall_repair.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from typing import Any @@ -216,7 +217,7 @@ def build_runtime_capability_user_gate_repair_hint( "trigger": RUNTIME_CAPABILITY_USER_GATE_REPAIR_TRIGGER, "schema_version": "runtime_capability_user_gate_repair_v0", "recommended_mode": "repair_user_gate_projection", - "effective_action": "runtime_user_gate_projection_repair", + "effective_action": EffectiveAction.RUNTIME_USER_GATE_PROJECTION_REPAIR.value, "blocked_action_scope": "user_gate_projection", "allowed": True, "notify": "DONT_NOTIFY", @@ -297,7 +298,7 @@ def build_quota_stall_self_repair_hint( "source": "quota.should-run", "trigger": "health_blocker", "recommended_mode": "repair_control_plane_health", - "effective_action": "control_plane_health_repair", + "effective_action": EffectiveAction.CONTROL_PLANE_HEALTH_REPAIR.value, "allowed": True, "notify": "DONT_NOTIFY", "reason": ( @@ -334,7 +335,7 @@ def build_quota_stall_self_repair_hint( "source": "quota.should-run", "trigger": "waiting_without_owner_projection", "recommended_mode": "repair_waiting_projection", - "effective_action": "control_plane_projection_repair", + "effective_action": EffectiveAction.CONTROL_PLANE_PROJECTION_REPAIR.value, "allowed": True, "notify": "DONT_NOTIFY", "reason": ( diff --git a/loopx/control_plane/quota/task_orchestration.py b/loopx/control_plane/quota/task_orchestration.py index 646a8e2f1b..8e8312c13f 100644 --- a/loopx/control_plane/quota/task_orchestration.py +++ b/loopx/control_plane/quota/task_orchestration.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from .effective_action import EffectiveAction from typing import Any from ..agents.agent_scope_frontier import AgentScopeFrontierAction @@ -22,7 +22,7 @@ AgentScopeFrontierAction.REASSIGNMENT_REQUIRED.value, } PEER_AGENT_ACTIVATION_CAPABILITY = "peer_agent_activation" -PEER_COORDINATION_BLOCKED_ACTION = "peer_coordination_blocked" +PEER_COORDINATION_BLOCKED_ACTION = EffectiveAction.PEER_COORDINATION_BLOCKED.value def task_orchestration_contract_is_actionable( @@ -94,7 +94,7 @@ def payload_work_lane_contract( recovery_allowed: bool, agent_scope_frontier: dict[str, Any] | None, ) -> dict[str, Any] | None: - if recovery_allowed and effective_action == "outcome_floor_recovery": + if recovery_allowed and effective_action == EffectiveAction.OUTCOME_FLOOR_RECOVERY.value: return None if not isinstance(work_lane_contract, dict): return work_lane_contract diff --git a/loopx/control_plane/quota/unsettled_host_turn.py b/loopx/control_plane/quota/unsettled_host_turn.py index b867a36add..ef12753ae8 100644 --- a/loopx/control_plane/quota/unsettled_host_turn.py +++ b/loopx/control_plane/quota/unsettled_host_turn.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .effective_action import EffectiveAction from collections.abc import Mapping from pathlib import Path @@ -227,7 +228,7 @@ def apply_unsettled_host_turn_recovery_if_required( "decision": "unsettled_host_turn_recovery", "should_run": True, "state": "eligible", - "effective_action": "unsettled_host_turn_recovery", + "effective_action": EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value, "actionable_by_codex": True, "normal_delivery_allowed": False, "recovery_delivery_allowed": False, diff --git a/loopx/control_plane/scheduler/automation_liveness.py b/loopx/control_plane/scheduler/automation_liveness.py index e821c68592..ffd8dde899 100644 --- a/loopx/control_plane/scheduler/automation_liveness.py +++ b/loopx/control_plane/scheduler/automation_liveness.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..quota.effective_action import EffectiveAction from typing import Any @@ -64,7 +65,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: "next_trigger": "explicit quota resume with quota.compute > 0", "spend_policy": "no quota spend for paused automation shutdown", } - if effective_action == "agent_monitor_only": + if effective_action == EffectiveAction.AGENT_MONITOR_ONLY.value: return { **base, "keep_active": True, @@ -81,7 +82,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: ), "spend_policy": "no quota spend without a validated material transition", } - if effective_action == "terminal_no_followup": + if effective_action == EffectiveAction.TERMINAL_NO_FOLLOWUP.value: return { **base, "keep_active": False, @@ -99,7 +100,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: "spend_policy": "no quota spend for terminal automation shutdown", } if ( - effective_action == "monitor_quiet_skip" + effective_action == EffectiveAction.MONITOR_QUIET_SKIP.value or recommended_mode == "monitor_quiet_until_material_transition" ): return { @@ -115,7 +116,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: ), "spend_policy": "no quota spend for unchanged monitor-only polls", } - if effective_action == "heartbeat_settled_skip": + if effective_action == EffectiveAction.HEARTBEAT_SETTLED_SKIP.value: return { **base, "automation_action": "keep_active_quiet", @@ -126,7 +127,7 @@ def build_automation_liveness(payload: dict[str, Any]) -> dict[str, Any]: "next_trigger": "next heartbeat turn with a fresh turn identity", "spend_policy": "no quota spend for an already-settled heartbeat turn", } - if effective_action == "automation_prompt_upgrade_required": + if effective_action == EffectiveAction.AUTOMATION_PROMPT_UPGRADE_REQUIRED.value: return { **base, "automation_action": "repair_automation_prompt_identity", diff --git a/loopx/control_plane/todos/decision_scope.py b/loopx/control_plane/todos/decision_scope.py index 2d3b2ef3de..5c49271f5f 100644 --- a/loopx/control_plane/todos/decision_scope.py +++ b/loopx/control_plane/todos/decision_scope.py @@ -1,5 +1,6 @@ """Legacy input codec for the single typed decision-dependency rule owner.""" from __future__ import annotations +from ..quota.effective_action import EffectiveAction from typing import Any @@ -235,7 +236,7 @@ def build_required_decision_scope_repair_hint( "source": "quota.should-run", "trigger": "user_gate_scope_projection_drift", "recommended_mode": "repair_user_gate_scope_projection", - "effective_action": "todo_decision_scope_projection_repair", + "effective_action": EffectiveAction.TODO_DECISION_SCOPE_PROJECTION_REPAIR.value, "blocked_action_scope": "todo_user_gate_scope_projection", "allowed": True, "notify": "DONT_NOTIFY", @@ -257,7 +258,7 @@ def build_required_decision_scope_repair_hint( "source": "quota.should-run", "trigger": "required_decision_scope_projection_drift", "recommended_mode": "repair_required_decision_scope_projection", - "effective_action": "todo_decision_scope_projection_repair", + "effective_action": EffectiveAction.TODO_DECISION_SCOPE_PROJECTION_REPAIR.value, "blocked_action_scope": "todo_decision_scope_projection", "allowed": True, "notify": "DONT_NOTIFY", diff --git a/loopx/control_plane/todos/user_gate.py b/loopx/control_plane/todos/user_gate.py index 41bfb042df..a64f5871ad 100644 --- a/loopx/control_plane/todos/user_gate.py +++ b/loopx/control_plane/todos/user_gate.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..quota.effective_action import EffectiveAction from typing import Any @@ -159,8 +160,8 @@ def apply_scoped_user_gate_fallback_projection( projected["should_run"] = True if projected.get("decision") == "skip": projected["decision"] = "safe_bypass_user_gate_fallback" - if projected.get("effective_action") in {"skip", "monitor_quiet_skip", None}: - projected["effective_action"] = "scoped_user_gate_fallback" + if projected.get("effective_action") in {EffectiveAction.SKIP.value, EffectiveAction.MONITOR_QUIET_SKIP.value, None}: + projected["effective_action"] = EffectiveAction.SCOPED_USER_GATE_FALLBACK.value raw_execution_obligation = projected.get("execution_obligation") execution_obligation = ( diff --git a/loopx/control_plane/turn_driver/driver.py b/loopx/control_plane/turn_driver/driver.py index acd414495e..ef2180ea4d 100644 --- a/loopx/control_plane/turn_driver/driver.py +++ b/loopx/control_plane/turn_driver/driver.py @@ -1,4 +1,5 @@ from __future__ import annotations +from ..quota.effective_action import EffectiveAction import json from collections.abc import Mapping @@ -96,7 +97,7 @@ def _typed_route(envelope: Mapping[str, Any]) -> LoopXTurnRoute: if should_run: if not delivery_allowed or not must_attempt: return LoopXTurnRoute.BLOCKED - if effective_action == "governed_capability_intent": + if effective_action == EffectiveAction.GOVERNED_CAPABILITY_INTENT.value: intent = _mapping(action.get("capability_intent")) if (intent.get("schema_version") != "pending_capability_intent_projection_v0" or intent.get("goal_id") != envelope.get("goal_id") diff --git a/loopx/control_plane/turn_driver/loop_controller.py b/loopx/control_plane/turn_driver/loop_controller.py index b5d196107e..e3af43e19b 100644 --- a/loopx/control_plane/turn_driver/loop_controller.py +++ b/loopx/control_plane/turn_driver/loop_controller.py @@ -18,6 +18,7 @@ """ from __future__ import annotations +from ..quota.effective_action import EffectiveAction from collections.abc import Mapping from enum import Enum @@ -437,7 +438,7 @@ def _completion_disposition( continuation = str(completion.get("continuation") or "") if continuation == "no_followup": if ( - decision.get("effective_action") != "terminal_no_followup" + decision.get("effective_action") != EffectiveAction.TERMINAL_NO_FOLLOWUP.value or decision.get("state") != "terminal_no_followup" ): raise ValueError( @@ -529,7 +530,7 @@ def decide_loop_disposition( ) if turn_receipt is None: - if str(quota_decision.get("effective_action") or "") == "terminal_no_followup": + if str(quota_decision.get("effective_action") or "") == EffectiveAction.TERMINAL_NO_FOLLOWUP.value: if quota_decision.get("state") != "terminal_no_followup": raise ValueError( "terminal no-follow-up requires fresh Goal frontier state" diff --git a/loopx/control_plane/work_items/interaction_contract.py b/loopx/control_plane/work_items/interaction_contract.py index fccf313982..ef6818ecd6 100644 --- a/loopx/control_plane/work_items/interaction_contract.py +++ b/loopx/control_plane/work_items/interaction_contract.py @@ -1,5 +1,5 @@ from __future__ import annotations - +from ..quota.effective_action import EffectiveAction import shlex import typing from collections.abc import Mapping @@ -454,23 +454,23 @@ def _interaction_mode(payload: dict[str, Any]) -> str: kind = str(execution_obligation.get("kind") or "") effective_action = str(payload.get("effective_action") or "") state = str(payload.get("state") or "") - if effective_action == "governed_capability_intent": + if effective_action == EffectiveAction.GOVERNED_CAPABILITY_INTENT.value: return effective_action - if effective_action == "unsettled_host_turn_recovery": + if effective_action == EffectiveAction.UNSETTLED_HOST_TURN_RECOVERY.value: return effective_action - if effective_action == "agent_monitor_only": + if effective_action == EffectiveAction.AGENT_MONITOR_ONLY.value: return "agent_monitor_only" - if effective_action == "monitor_due": + if effective_action == EffectiveAction.MONITOR_DUE.value: return "monitor_due" - if effective_action == "terminal_no_followup" or state == "terminal_no_followup": + if effective_action == EffectiveAction.TERMINAL_NO_FOLLOWUP.value or state == "terminal_no_followup": return "terminal_no_followup" - if effective_action == "peer_coordination_blocked": + if effective_action == EffectiveAction.PEER_COORDINATION_BLOCKED.value: return effective_action if payload.get("scoped_user_gate_fallback"): return "scoped_user_gate_fallback" if _user_gate_notification_suppressed(payload): return "user_gate_cooldown_wait" - if effective_action == "automation_prompt_upgrade_required": + if effective_action == EffectiveAction.AUTOMATION_PROMPT_UPGRADE_REQUIRED.value: return "automation_prompt_upgrade" if user_channel_action_required(payload): if ( @@ -495,22 +495,22 @@ def _interaction_mode(payload: dict[str, Any]) -> str: return "external_evidence_observation" if kind == AUTONOMOUS_REPLAN_REQUIRED_MODE: return "autonomous_replan" - if effective_action == "coordinate_task_bundle": + if effective_action == EffectiveAction.COORDINATE_TASK_BUNDLE.value: return "task_orchestration" agent_scope_action = _agent_scope_frontier_action(effective_action) if agent_scope_action is not None: return agent_scope_action.value - if effective_action == "monitor_quiet_skip": + if effective_action == EffectiveAction.MONITOR_QUIET_SKIP.value: return "monitor_quiet_skip" - if effective_action == "heartbeat_settled_skip": + if effective_action == EffectiveAction.HEARTBEAT_SETTLED_SKIP.value: return "heartbeat_settled_skip" - if payload.get("recovery_delivery_allowed") or effective_action == "outcome_floor_recovery": + if payload.get("recovery_delivery_allowed") or effective_action == EffectiveAction.OUTCOME_FLOOR_RECOVERY.value: return "outcome_floor_recovery" - if effective_action == "capability_bridge_repair": + if effective_action == EffectiveAction.CAPABILITY_BRIDGE_REPAIR.value: return "capability_bridge_repair" - if effective_action == "agent_workspace_repair": + if effective_action == EffectiveAction.AGENT_WORKSPACE_REPAIR.value: return effective_action - if effective_action == "boundary_projection_repair": + if effective_action == EffectiveAction.BOUNDARY_PROJECTION_REPAIR.value: return "boundary_projection_repair" if payload.get("self_repair_allowed"): return "control_plane_self_repair" diff --git a/loopx/ready_score.py b/loopx/ready_score.py index bcb13fa339..586ff9b996 100644 --- a/loopx/ready_score.py +++ b/loopx/ready_score.py @@ -1,4 +1,5 @@ from __future__ import annotations +from .control_plane.quota.effective_action import EffectiveAction from typing import Any from urllib.parse import quote @@ -265,7 +266,7 @@ def build_ready_score_report( quota_points += 8 elif should_run: quota_points += 5 - if normal_allowed or effective_action == "normal_run": + if normal_allowed or effective_action == EffectiveAction.NORMAL_RUN.value: quota_points += 5 if scheduler_apply_needed is False: quota_points += 4 diff --git a/loopx/semantics/inventory_v0.json b/loopx/semantics/inventory_v0.json index 00ec902542..5546274baa 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", "blocked_health", "blocked_wait", "control_plane_repair", "operator_gate_notify", "throttled_skip"]}, {"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"]}, @@ -562,6 +563,7 @@ {"name": "ROLLOUT_EVENT_KINDS", "module": "loopx/rollout_event_log.py", "container": "set", "values": ["codex_session_observed", "capability_gap", "compact_blocker", "compact_case_result", "evidence_log_read", "failure_attribution", "pr_merge", "pr_review_ack", "quota_monitor_poll", "quota_should_run", "quota_spend", "quota_void", "refresh_state", "refresh_external_delivery", "research_evidence", "research_hypothesis", "todo_add", "todo_archive_completed", "todo_claim", "todo_complete", "todo_supersede", "todo_update", "validation"]}, {"name": "SKIP_PARTS", "module": "loopx/semantics/inventory.py", "container": "frozenset", "values": ["__pycache__", "node_modules"]}, {"name": "SOURCE_SUFFIXES", "module": "loopx/semantics/inventory.py", "container": "tuple", "values": [".py", ".ts"]}, + {"name": "PRODUCER_ROOTS", "module": "loopx/semantics/production.py", "container": "tuple", "values": ["loopx/cli_commands", "loopx/control_plane/agents", "loopx/control_plane/quota", "loopx/control_plane/todos", "loopx/control_plane/coordination", "loopx/control_plane/turn_driver", "loopx/control_plane/work_items"]}, {"name": "BLOCKED_STATUSES", "module": "loopx/session_runtime.py", "container": "set", "values": ["blocked", "error", "failed", "timed_out"]}, {"name": "COMPACT_SUFFIX_WORDS", "module": "loopx/session_runtime.py", "container": "frozenset", "values": ["id", "ids", "ref", "refs", "count", "at"]}, {"name": "EXPLICIT_COMPACT_COLLISION_KEYS", "module": "loopx/session_runtime.py", "container": "frozenset", "values": ["conversation_id", "log_count", "message_id", "prompt_token_count", "prompt_tokens", "trace_id"]}, @@ -901,12 +903,12 @@ ] }, "summary": { - "source_files": 1183, - "python_enums": 103, - "python_closed_sets": 494, + "source_files": 1186, + "python_enums": 104, + "python_closed_sets": 495, "python_literal_aliases": 8, "typescript_const_arrays": 40, - "named_string_constants": 2075, + "named_string_constants": 2074, "schema_version_names": 756, "schema_version_same_runtime_forks": 7, "cross_runtime_twins": 166, diff --git a/loopx/semantics/production.py b/loopx/semantics/production.py new file mode 100644 index 0000000000..14e340437b --- /dev/null +++ b/loopx/semantics/production.py @@ -0,0 +1,124 @@ +"""Collect and check bounded semantic production evidence for repository CI.""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +from typing import Any + +from .inventory import SourceFile +from .python_production import Production, enum_members, scan_python_production + + +# This source boundary is code owned. It is not adjustable through registry data. +PRODUCER_ROOTS = ( + 'loopx/cli_commands', 'loopx/control_plane/agents', + 'loopx/control_plane/quota', 'loopx/control_plane/todos', 'loopx/control_plane/coordination', + 'loopx/control_plane/turn_driver', 'loopx/control_plane/work_items', +) + + +def collect_production(root: Path, vocabulary: dict[str, Any], sources: list[SourceFile]) -> list[Production]: + by_path = {s.path: s for s in sources} + owner = vocabulary['owners'].get('python') + enums = {} + if owner: + module, symbol = owner.split('::') + if module not in by_path: + raise ValueError(f'producer owner must be a tracked source: {owner}') + enums[owner] = enum_members(by_path[module], symbol) + field = vocabulary.get('literal_scan', {}).get('field') + returns = vocabulary.get('return_producers', []) + selected = [s for s in sources if any(s.path.startswith(p + '/') for p in PRODUCER_ROOTS)] + rows = [] + for source in selected: + if source.suffix == '.py': + names = frozenset(site.split('::')[1] for site in returns if site.split('::')[0] == source.path) + rows.extend(scan_python_production(source, field=field, enums=enums, return_functions=names)) + ts_sources = [s for s in selected if s.suffix == '.ts'] + if ts_sources and field: + completed = subprocess.run( + ['node', str(root / 'scripts/semantic_production_scan.mjs')], + input=json.dumps({'field': field, 'sources': [{'path': s.path, 'text': s.text} for s in ts_sources], + 'return_functions': returns}), + capture_output=True, text=True, encoding='utf-8', timeout=60, check=False, + ) + if completed.returncode: + # Accept only a bounded location from the parser, never echo source + # text or arbitrary subprocess stderr into public diagnostics. + try: + failure = json.loads(completed.stdout) + except json.JSONDecodeError: + failure = None + error = failure.get('error') if isinstance(failure, dict) else None + if (isinstance(error, dict) and error.get('code') == 'typescript_syntax' + and error.get('path') in {s.path for s in ts_sources} + and isinstance(error.get('line'), int) and error['line'] > 0): + raise ValueError(f"{error['path']}:{error['line']}: invalid TypeScript source; repair syntax before semantic scanning") + raise ValueError('TypeScript production parser failed; run npm ci --ignore-scripts and check the Node runtime') + rows.extend(Production(r['site'], r['line'], r['form'], frozenset(r['values']), r['unresolved']) + for r in json.loads(completed.stdout)) + return rows + + +def validate_production(name: str, vocabulary: dict[str, Any], rows: list[Production]) -> list[str]: + """F1/F2 checks on observed results; owner members do not establish liveness. + + Unresolved sites are returned explicitly. They do not supply any missing + value evidence and this function does not claim whole-program closedness. + """ + observed = set().union(*(row.values for row in rows)) + expected = set(vocabulary['values']) + unregistered = observed - expected + if unregistered: + sites = sorted({f'{r.site}:{r.line}' for r in rows if r.values & unregistered}) + raise ValueError(f'{name}: producer writes unregistered values {sorted(unregistered)} at {sites}') + compatibility = set(vocabulary.get('compatibility_only', {})) + if compatibility - expected: + raise ValueError(f'{name}: compatibility-only values must be registered') + if compatibility & observed: + raise ValueError(f'{name}: compatibility-only values are produced: {sorted(compatibility & observed)}') + missing = expected - observed - compatibility + if missing: + raise ValueError(f'{name}: values have no observed producer: {sorted(missing)}; owner definition is not production') + producers = vocabulary.get('producers', []) + declared = set(producers) + if len(declared) != len(producers): + raise ValueError(f'{name}: producer sites repeat') + returns = set(vocabulary.get('return_producers', [])) + if not returns <= declared: + raise ValueError(f'{name}: return producers must also be registered producers') + stale = sorted(declared - {row.site for row in rows}) + if stale: + raise ValueError(f'{name}: producer sites have no observed write or return: {stale}') + undeclared = sorted({row.site for row in rows if row.values and row.site not in declared}) + if undeclared: + raise ValueError(f'{name}: undeclared producer sites: {undeclared}') + return sorted({f'{row.site}:{row.line}' for row in rows if row.unresolved}) + + +def probe_turn_result_input_domain(vocabulary: dict[str, Any]) -> list[Production]: + """Witness this real decoder's finite output domain, not the host's traces. + + A successful call is evidence that the production function can emit a value + for a legal input. Merely enumerating the owner is not such evidence. The + callable is fixed in code; registry data cannot select arbitrary imports. + """ + from loopx.control_plane.turn_driver.transaction import LoopXTurnResultKind, _result_kind + + site = 'loopx/control_plane/turn_driver/transaction.py::_result_kind' + if vocabulary.get('input_producer') != site: + raise ValueError('turn_result_kind: input_producer must name the anchored decoder') + rows = [] + for value in vocabulary['values']: + errors: list[str] = [] + actual = _result_kind(value, errors) + if errors or not isinstance(actual, LoopXTurnResultKind) or actual.value != value: + raise ValueError(f'turn_result_kind: decoder does not produce registered input {value}') + rows.append(Production(site, _result_kind.__code__.co_firstlineno, 'input_witness', frozenset({actual.value}), False)) + for invalid in (None, '', 'unknown_result_kind', 3, [], {}): + errors = [] + actual = _result_kind(invalid, errors) + if actual is not None or not errors: + raise ValueError('turn_result_kind: decoder accepted an invalid input probe') + return rows diff --git a/loopx/semantics/python_production.py b/loopx/semantics/python_production.py new file mode 100644 index 0000000000..6677414de7 --- /dev/null +++ b/loopx/semantics/python_production.py @@ -0,0 +1,246 @@ +"""Bounded Python production evidence; never execute inspected source. + +Known values are syntactic result possibilities, not proof of reachable traces. +Unresolved expressions retain their source locations. Owner definitions alone, +comparison operands, comments and quoted examples are not production evidence. +""" +from __future__ import annotations + +import ast +from collections import Counter +from dataclasses import dataclass +from typing import Mapping + +from .inventory import SourceFile + + +@dataclass(frozen=True) +class Production: + site: str + line: int + form: str + values: frozenset[str] + unresolved: bool + + +def _module(path: str) -> str: + name = path.removesuffix('.py').replace('/', '.') + return name.removesuffix('.__init__') + + +def _import_module(path: str, node: ast.ImportFrom) -> str: + if not node.level: + return node.module or '' + package = _module(path) if path.endswith('/__init__.py') else _module(path).rpartition('.')[0] + parts = package.split('.') + return '.'.join(parts[:len(parts) - node.level + 1] + ([node.module] if node.module else [])) + + +def enum_members(source: SourceFile, symbol: str) -> dict[str, str]: + """Resolve only literal members from the declared owner class.""" + tree = ast.parse(source.text, filename=source.path) + classes = [n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == symbol] + if len(classes) != 1: + raise ValueError(f'{source.path}::{symbol}: expected one owner class') + result = {} + for node in classes[0].body: + if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + result[node.targets[0].id] = node.value.value + return result + + +def scan_python_production( + source: SourceFile, + *, + field: str | None, + enums: Mapping[str, Mapping[str, str]], + return_functions: frozenset[str] = frozenset(), +) -> list[Production]: + """Observe writes and owner-member results with bounded local resolution. + + ``enums`` maps module::Class to literal member values from tracked owners. + Only imported owner classes (including aliases) or the local owner qualify. + A single local assignment can resolve a returned variable; reassignment and + parameter shadowing become unknown. Nested function returns belong to that + function and never to a registered enclosing function. + """ + tree = ast.parse(source.text, filename=source.path) + bindings: dict[str, Mapping[str, str]] = {} + for owner, members in enums.items(): + path, symbol = owner.split('::') + if path == source.path: + bindings[symbol] = members + owner_imports = {(_module(owner.split('::')[0]), owner.split('::')[1]): members + for owner, members in enums.items()} + for node in tree.body: + if isinstance(node, ast.ImportFrom): + for alias in node.names: + name = alias.asname or alias.name + members = owner_imports.get((_import_module(source.path, node), alias.name)) + if members is not None: + bindings[name] = members + else: + bindings.pop(name, None) + elif isinstance(node, ast.Import): + for alias in node.names: + bindings.pop(alias.asname or alias.name.split('.')[0], None) + # Module assignments can shadow imported owners; never silently trust them. + for node in tree.body: + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if isinstance(target, ast.Name): + bindings.pop(target.id, None) + + result: list[Production] = [] + + def matches(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return node.id == field + if isinstance(node, ast.Attribute): + return node.attr == field + return (isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant) + and node.slice.value == field) + + def scan_scope(body: list[ast.stmt], scope: str, parameters: set[str]) -> None: + nodes: list[ast.AST] = [] + nested: list[ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef] = [] + + def collect(node: ast.AST) -> None: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + nested.append(node) + return + if isinstance(node, ast.Lambda): + return + nodes.append(node) + for child in ast.iter_child_nodes(node): + collect(child) + for statement in body: + collect(statement) + assigned = Counter(n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)) + local_owner_names = {owner.split('::')[1] for owner in enums if owner.split('::')[0] == source.path} + nested_names = {n.name for n in nested} + if scope == '': + nested_names -= local_owner_names + imported = set() + if scope != '': + for node in nodes: + if isinstance(node, (ast.Import, ast.ImportFrom)): + imported.update(alias.asname or alias.name.split('.')[0] for alias in node.names) + exception_targets = {n.name for n in nodes if isinstance(n, ast.ExceptHandler) and n.name} + deleted = {n.id for n in nodes if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Del)} + shadows = set(assigned) | parameters | nested_names | imported | exception_targets | deleted + local_bindings = {k: v for k, v in bindings.items() if k not in shadows} + single_values = {} + for node in nodes: + if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + target = node.targets[0].id + if assigned[target] == 1 and target not in parameters: + single_values[target] = node.value + + def resolve(node: ast.AST | None, seen: frozenset[str] = frozenset()) -> tuple[set[str], bool]: + if isinstance(node, ast.Constant): + if isinstance(node.value, str): + return ({node.value} if node.value else set()), False + return set(), node.value is not None + if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Dict): + known: set[str] = set() + for value in node.value.values: + part, _ = resolve(value, seen) + known.update(part) + # The key domain and lookup failure are not proved here. + return known, True + if isinstance(node, ast.IfExp): + left, lu = resolve(node.body, seen) + right, ru = resolve(node.orelse, seen) + return left | right, lu or ru + if isinstance(node, ast.BoolOp): + known: set[str] = set() + unknown = False + for operand in node.values: + part, unresolved = resolve(operand, seen) + known.update(part) + unknown |= unresolved + return known, unknown + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id == 'str' and node.func.id not in shadows + and len(node.args) == 1 and not node.keywords): + return resolve(node.args[0], seen) + if isinstance(node, ast.Name) and node.id in single_values and node.id not in seen: + definition = single_values[node.id] + if (definition.lineno, definition.col_offset) < (node.lineno, node.col_offset): + return resolve(definition, seen | {node.id}) + if isinstance(node, ast.Attribute): + member = node.value if node.attr == 'value' else node + if isinstance(member, ast.Attribute) and isinstance(member.value, ast.Name): + members = local_bindings.get(member.value.id) + if members is not None: + if member.attr not in members: + raise ValueError(f'{source.path}:{node.lineno}: unknown owner member {member.attr}') + return {members[member.attr]}, False + return set(), True + + def has_owner(node: ast.AST) -> bool: + return any(isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name) + and n.value.id in local_bindings for n in ast.walk(node)) + + def enum_results(node: ast.AST | None) -> set[str]: + # Inspect result positions only. Keys, conditions and comparison + # operands describe selection; they are not produced enum values. + if isinstance(node, ast.Attribute): + return resolve(node)[0] + if isinstance(node, ast.IfExp): + return enum_results(node.body) | enum_results(node.orelse) + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + return set().union(*(enum_results(part) for part in node.elts)) + if isinstance(node, ast.Dict): + return set().union(*(enum_results(value) for value in node.values)) + if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Dict): + return enum_results(node.value) + return set() + + def record(node: ast.AST | None, form: str, location: ast.AST) -> None: + values, unknown = resolve(node) + if form == 'enum_result': + values = enum_results(node) + result.append(Production(f'{source.path}::{scope}', location.lineno, form, frozenset(values), unknown)) + + for node in nodes: + if isinstance(node, ast.Assign): + if field and any(matches(t) for t in node.targets): + record(node.value, 'assignment', node) + elif scope != '' and has_owner(node.value): + record(node.value, 'enum_result', node) + elif isinstance(node, ast.AnnAssign) and field and matches(node.target): + record(node.value, 'assignment', node) + elif isinstance(node, ast.Dict) and field: + for key, value in zip(node.keys, node.values, strict=True): + if isinstance(key, ast.Constant) and key.value == field: + record(value, 'dict', node) + elif isinstance(node, ast.Call): + for kw in node.keywords: + if field and kw.arg == field: + record(kw.value, 'keyword', node) + elif has_owner(kw.value): + record(kw.value, 'enum_result', node) + for arg in node.args: + if has_owner(arg) and not isinstance(arg, ast.Compare): + record(arg, 'enum_result', node) + elif isinstance(node, ast.Return) and node.value is not None: + if scope in return_functions: + record(node.value, 'return', node) + elif has_owner(node.value) and not isinstance(node.value, (ast.Compare, ast.Dict)): + record(node.value, 'enum_result', node) + for child in nested: + name = child.name if scope == '' else f'{scope}.{child.name}' + params: set[str] = set() + if not isinstance(child, ast.ClassDef): + args = child.args + params = {a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)} + params.update(a.arg for a in (args.vararg, args.kwarg) if a) + # A nested closure might shadow an owner in any enclosing scope. + scan_scope(child.body, name, params | shadows) + + scan_scope(tree.body, '', set()) + return sorted(set(result), key=lambda row: (row.site, row.line, row.form, sorted(row.values))) diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 7d0adb2956..1fd281cad3 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -135,7 +135,7 @@ }, "coverage_floor": { "vocabularies": 26, - "owner_symbols": 46, + "owner_symbols": 47, "literal_scan_fields": 1, "projections": 1, "relations": 9, @@ -173,7 +173,15 @@ "validation_failed": "Legacy failure class per turn-loop-controller-v0; always routes to repair.", "writeback_failed": "Legacy failure class per turn-loop-controller-v0; always routes to repair.", "quota_spend_failed": "Legacy failure class per turn-loop-controller-v0; always routes to repair." - } + }, + "input_producer": "loopx/control_plane/turn_driver/transaction.py::_result_kind", + "producers": [ + "loopx/control_plane/turn_driver/executor.py::_host_result_stage", + "loopx/control_plane/turn_driver/executor.py::_run_task_validator", + "loopx/control_plane/turn_driver/executor.py::_task_validation_receipt", + "loopx/control_plane/turn_driver/executor.py::_task_validation_stage", + "loopx/control_plane/turn_driver/transaction.py::_result_kind" + ] }, "turn_route": { "meaning": "Typed delivery route derived from a fresh should-run decision before a Host is engaged.", @@ -192,6 +200,16 @@ "wait", "blocked", "contract_error" + ], + "producers": [ + "loopx/control_plane/turn_driver/driver.py::_typed_route", + "loopx/control_plane/turn_driver/driver.py::build_loopx_turn_plan", + "loopx/control_plane/turn_driver/driver.py::reconcile_failed_turn_session_request", + "loopx/control_plane/turn_driver/loop_controller.py::_envelope_route" + ], + "return_producers": [ + "loopx/control_plane/turn_driver/driver.py::_typed_route", + "loopx/control_plane/turn_driver/loop_controller.py::_envelope_route" ] }, "loop_disposition": { @@ -211,6 +229,15 @@ "repair", "replan", "terminal" + ], + "producers": [ + "loopx/control_plane/turn_driver/loop_controller.py::_completion_disposition", + "loopx/control_plane/turn_driver/loop_controller.py::_replan_disposition", + "loopx/control_plane/turn_driver/loop_controller.py::_route_to_disposition", + "loopx/control_plane/turn_driver/loop_controller.py::decide_loop_disposition" + ], + "return_producers": [ + "loopx/control_plane/turn_driver/loop_controller.py::_route_to_disposition" ] }, "agent_scope_frontier_action": { @@ -226,12 +253,22 @@ "agent_scope_wait", "reassignment_required", "successor_replan_required" + ], + "producers": [ + "loopx/control_plane/agents/agent_scope.py::_blocked_successor_wait_frontier", + "loopx/control_plane/agents/agent_scope.py::_blocking_handoff_frontier", + "loopx/control_plane/agents/agent_scope.py::_cleared_handoff_frontier", + "loopx/control_plane/agents/agent_scope.py::_deferred_resume_frontier", + "loopx/control_plane/agents/agent_scope.py::_monitor_blocked_resume_frontier", + "loopx/control_plane/agents/agent_scope.py::_other_agent_or_exhausted_frontier", + "loopx/control_plane/agents/agent_scope.py::_route_continuation_frontier", + "loopx/control_plane/agents/agent_scope.py::_selected_candidate_priority_frontier" ] }, "lease_action": { "meaning": "Authority-core lease mutation verb.", "tier": "kernel", - "status": "canonical", + "status": "legacy", "owners": { "python": "loopx/control_plane/coordination/authority_core.py::LeaseAction", "typescript": null @@ -241,14 +278,39 @@ "renew", "transfer", "release" - ] + ], + "producers": [], + "compatibility_only": { + "acquire": { + "reason": "Retained by the legacy typed LeaseModeGateCommand input interface; current in-repository runtime callers use the separate acquire/renew/transfer/release command classes, not this vocabulary. No persisted use is asserted.", + "retirement": "M4: retire the legacy Python lease-mode input interface after caller and migration review." + }, + "renew": { + "reason": "Retained by the legacy typed LeaseModeGateCommand input interface; current in-repository runtime callers use the separate acquire/renew/transfer/release command classes, not this vocabulary. No persisted use is asserted.", + "retirement": "M4: retire the legacy Python lease-mode input interface after caller and migration review." + }, + "transfer": { + "reason": "Retained by the legacy typed LeaseModeGateCommand input interface; current in-repository runtime callers use the separate acquire/renew/transfer/release command classes, not this vocabulary. No persisted use is asserted.", + "retirement": "M4: retire the legacy Python lease-mode input interface after caller and migration review." + }, + "release": { + "reason": "Retained by the legacy typed LeaseModeGateCommand input interface; current in-repository runtime callers use the separate acquire/renew/transfer/release command classes, not this vocabulary. No persisted use is asserted.", + "retirement": "M4: retire the legacy Python lease-mode input interface after caller and migration review." + } + }, + "value_notes": { + "acquire": "Compatibility-only input member; no observed in-repository producer. Preserve the typed caller interface until its M4 retirement review.", + "renew": "Compatibility-only input member; no observed in-repository producer. Preserve the typed caller interface until its M4 retirement review.", + "transfer": "Compatibility-only input member; no observed in-repository producer. Preserve the typed caller interface until its M4 retirement review.", + "release": "Compatibility-only input member; no observed in-repository producer. Preserve the typed caller interface until its M4 retirement review." + } }, "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": { @@ -267,10 +329,13 @@ "automation_prompt_upgrade_required", "autonomous_replan_required", "block_replay", + "blocked_health", + "blocked_wait", "boundary_projection_repair", "capability_bridge_repair", "control_plane_health_repair", "control_plane_projection_repair", + "control_plane_repair", "coordinate_task_bundle", "external_evidence_observe", "governed_capability_intent", @@ -282,6 +347,7 @@ "normal_run", "observe_replay", "operator_gate", + "operator_gate_notify", "operator_inbox_material_review_due", "outcome_floor_recovery", "peer_coordination_blocked", @@ -293,6 +359,7 @@ "skip", "state_projection_gap_repair", "terminal_no_followup", + "throttled_skip", "todo_decision_scope_projection_repair", "unsettled_host_turn_recovery" ], @@ -300,13 +367,52 @@ "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::_task_orchestration_effective_action", + "loopx/control_plane/quota/decision_summary.py::quota_effective_action", + "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/settlement_precedence.py::apply_settled_replay_route_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::interpretTurnJournalEffect" + ], + "compatibility_only": { + "skip": { + "reason": "Historical comparison value; no bounded producer writes it into the decision slot.", + "retirement": "M1 slot split and consumer cleanup" + }, + "operator_gate": { + "reason": "Retained old vocabulary admission used by synthetic action-portfolio fixtures; current quota producer emits operator_gate_notify. Persisted use is not established.", + "retirement": "M1 fixture and legacy reader migration" + } + }, "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.", "block_replay": "Written only by turn_journal.ts into the replay observation slot; not a should-run verdict. Slot split is RFC Section 12 Q6.", "quota_action_selection_deferred": "Reaches the slot through the error_code variable in cli_commands/quota.py; an error code doubling as a verdict.", - "quota_action_selection_rejected": "Reaches the slot through the error_code variable in cli_commands/quota.py; an error code doubling as a verdict." - } + "quota_action_selection_rejected": "Reaches the slot through the error_code variable in cli_commands/quota.py; an error code doubling as a verdict.", + "blocked_health": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions.", + "blocked_wait": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions.", + "control_plane_repair": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions.", + "operator_gate_notify": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions.", + "throttled_skip": "Existing result of quota_effective_action; previously missed because the literal scan did not inspect declared return functions." + }, + "return_producers": [ + "loopx/control_plane/quota/decision_summary.py::quota_effective_action" + ] }, "settlement_step_kind": { "meaning": "Effect-program settlement step executed for one Turn.", @@ -743,27 +849,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 } } @@ -786,8 +892,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/scripts/semantic_production_scan.mjs b/scripts/semantic_production_scan.mjs new file mode 100644 index 0000000000..2d9ef0b8b0 --- /dev/null +++ b/scripts/semantic_production_scan.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// Parse supplied tracked source text only; never load or execute product modules. +import ts from 'typescript'; + +let input = ''; +for await (const chunk of process.stdin) input += chunk; +const request = JSON.parse(input); +const result = []; +for (const source of request.sources) { + const tree = ts.createSourceFile(source.path, source.text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + if (tree.parseDiagnostics.length) { + const line = tree.getLineAndCharacterOfPosition(tree.parseDiagnostics[0].start ?? 0).line + 1; + process.stdout.write(JSON.stringify({error: {path: source.path, line, code: "typescript_syntax"}})); + process.exit(2); + } + const field = request.field; + const returns = new Set((request.return_functions ?? []).filter(x => x.startsWith(`${source.path}::`))); + const unwrap = node => { + while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node))) node = node.expression; + return node; + }; + const values = expression => { + const node = unwrap(expression); + if (!node) return {values: [], unresolved: true}; + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return {values: node.text ? [node.text] : [], unresolved: false}; + if (node.kind === ts.SyntaxKind.NullKeyword) return {values: [], unresolved: false}; + if (ts.isConditionalExpression(node)) { + const left = values(node.whenTrue), right = values(node.whenFalse); + return {values: [...new Set([...left.values, ...right.values])].sort(), unresolved: left.unresolved || right.unresolved}; + } + return {values: [], unresolved: true}; + }; + const named = node => node && (ts.isIdentifier(node) || ts.isStringLiteral(node)) ? node.text : null; + const target = node => ts.isIdentifier(node) ? node.text === field + : ts.isPropertyAccessExpression(node) ? node.name.text === field + : ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) && node.argumentExpression.text === field; + function walk(node, scope) { + if (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) scope = scope === '' ? named(node.name) : `${scope}.${named(node.name)}`; + else if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + const parent = node.parent; + const name = ts.isVariableDeclaration(parent) || ts.isPropertyAssignment(parent) ? named(parent.name) : null; + scope = name ? (scope === '' ? name : `${scope}.${name}`) : ''; + } + let expression, form; + if (ts.isPropertyAssignment(node) && named(node.name) === field) { expression = node.initializer; form = 'object'; } + else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && target(node.left)) { expression = node.right; form = 'assignment'; } + else if (ts.isVariableDeclaration(node) && named(node.name) === field && node.initializer) { expression = node.initializer; form = 'assignment'; } + else if (ts.isReturnStatement(node) && returns.has(`${source.path}::${scope}`)) { expression = node.expression; form = 'return'; } + if (form) { + result.push({site: `${source.path}::${scope}`, line: tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1, form, ...values(expression)}); + } + ts.forEachChild(node, child => walk(child, scope)); + } + walk(tree, ''); +} +process.stdout.write(JSON.stringify(result)); diff --git a/tests/architecture/test_semantic_production.py b/tests/architecture/test_semantic_production.py new file mode 100644 index 0000000000..ea3d6a66d6 --- /dev/null +++ b/tests/architecture/test_semantic_production.py @@ -0,0 +1,157 @@ +"""Guard the producer/owner distinction using independent finite counterexamples.""" +from __future__ import annotations + +import pytest + +from loopx.semantics.production import collect_production, validate_production +from loopx.semantics.python_production import Production +from loopx.semantics.inventory import SourceFile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SITE = 'loopx/control_plane/quota/probe.py::emit' + + +def vocabulary(): + return {'values': ['run', 'wait'], 'producers': [SITE], 'owners': {'python': None}, + 'literal_scan': {'field': 'action'}} + + +def row(value, *, site=SITE, unresolved=False): + return Production(site, 1, 'return', frozenset([value]) if value else frozenset(), unresolved) + + +def test_owner_values_never_satisfy_production_liveness(): + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', vocabulary(), [row('run')]) + + +def test_undefined_producer_value_is_rejected(): + with pytest.raises(ValueError, match='unregistered values'): + validate_production('action', vocabulary(), [row('run'), row('typo')]) + + +def test_registering_an_unrelated_function_does_not_cover_a_writer(): + with pytest.raises(ValueError, match='undeclared producer sites'): + validate_production('action', vocabulary(), [row('run'), row('wait', site=SITE.replace('emit', 'hidden'))]) + + +def test_dynamic_path_remains_visible_and_cannot_supply_missing_value(): + with pytest.raises(ValueError, match='no observed producer'): + validate_production('action', vocabulary(), [row('run'), row(None, unresolved=True)]) + unknown = validate_production('action', vocabulary(), [row('run'), row('wait'), row(None, unresolved=True)]) + assert unknown == [SITE + ':1'] + + +def test_compatibility_values_must_have_no_observed_production(): + v = vocabulary() + v['compatibility_only'] = {'wait': {'reason': 'Old reader', 'retirement': 'M1'}} + assert validate_production('action', v, [row('run')]) == [] + with pytest.raises(ValueError, match='compatibility-only values are produced'): + validate_production('action', v, [row('run'), row('wait')]) + + +@pytest.mark.parametrize('source, expected', [ + ("function emit() { return {action: flag === 'condition' ? 'run' : 'wait'}; }", {'run', 'wait'}), + ("function emit() { output['action'] = 'run'; output.action = 'wait'; }", {'run', 'wait'}), + ("function read() { if (p.action === 'run') console.log('action'); }", set()), + ("// action: 'comment'\nconst example = `action: 'example'`;", set()), +]) +def test_typescript_parser_observes_results_not_context(source, expected): + rows = collect_production(ROOT, vocabulary(), [SourceFile('loopx/control_plane/quota/probe.ts', '.ts', source)]) + assert set().union(*(r.values for r in rows)) == expected + + +def test_declared_return_is_scanned_in_real_python_syntax(): + v = vocabulary() + v['return_producers'] = [SITE] + rows = collect_production(ROOT, v, [SourceFile(SITE.split('::')[0], '.py', 'def emit():\n return "unregistered"\n')]) + with pytest.raises(ValueError, match='unregistered'): + validate_production('action', v, rows) + + +@pytest.mark.parametrize('vocabulary_name, module, original', [ + ('turn_route', 'loopx/control_plane/turn_driver/driver.py', 'return LoopXTurnRoute.CONTRACT_ERROR'), + ('loop_disposition', 'loopx/control_plane/turn_driver/loop_controller.py', 'LoopXTurnRoute.READY_FOR_HOST: LoopDisposition.RUN_NOW'), +]) +def test_real_return_producer_rejects_an_unregistered_result(vocabulary_name, module, original): + import json + from loopx.semantics.inventory import load_sources + v = json.loads((ROOT / 'loopx/semantics/vocabulary_v0.json').read_text())['vocabularies'][vocabulary_name] + sources = load_sources(ROOT) + replacement = 'return "unknown_action"' if vocabulary_name == 'turn_route' else 'LoopXTurnRoute.READY_FOR_HOST: "unknown_action"' + found = False + mutated = [] + for source in sources: + if source.path == module: + assert original in source.text + source = SourceFile(source.path, source.suffix, source.text.replace(original, replacement, 1)) + found = True + mutated.append(source) + assert found + with pytest.raises(ValueError, match='producer writes unregistered values'): + validate_production(vocabulary_name, v, collect_production(ROOT, v, mutated)) + + +def test_real_turn_decoder_supplies_typed_input_witnesses(): + from loopx.semantics.production import probe_turn_result_input_domain + values = ['validated_progress', 'validated_completion', 'repair_required', + 'replan_required', 'user_action_required', 'wait', 'iteration_failed', + 'host_failure', 'validation_failed', 'writeback_failed', + 'quota_spend_failed', 'terminal_closeout_failed'] + v = {'values': values, 'input_producer': 'loopx/control_plane/turn_driver/transaction.py::_result_kind'} + rows = probe_turn_result_input_domain(v) + assert {r.form for r in rows} == {'input_witness'} + assert set().union(*(r.values for r in rows)) == set(values) + assert all(not r.unresolved for r in rows) + + +@pytest.mark.parametrize('defect', ['constant_result', 'untyped_result', 'unknown_admitted']) +def test_input_witness_probe_rejects_decoder_contract_regressions(monkeypatch, defect): + from types import SimpleNamespace + from loopx.control_plane.turn_driver import transaction + from loopx.semantics.production import probe_turn_result_input_domain + original = transaction._result_kind + + def defective(value, errors): + if defect == 'constant_result': + return transaction.LoopXTurnResultKind.WAIT + if defect == 'untyped_result': + return SimpleNamespace(value=value) + if value == 'unknown_result_kind': + return transaction.LoopXTurnResultKind.WAIT + return original(value, errors) + + monkeypatch.setattr(transaction, '_result_kind', defective) + v = {'values': ['repair_required'], 'input_producer': 'loopx/control_plane/turn_driver/transaction.py::_result_kind'} + with pytest.raises(ValueError, match='decoder'): + probe_turn_result_input_domain(v) + + +def test_legacy_lease_values_stay_visible_without_claiming_production(): + import json + from loopx.semantics.inventory import load_sources + v = json.loads((ROOT / 'loopx/semantics/vocabulary_v0.json').read_text())['vocabularies']['lease_action'] + assert v['status'] == 'legacy' + assert v['producers'] == [] + assert set(v['compatibility_only']) == {'acquire', 'renew', 'transfer', 'release'} + rows = collect_production(ROOT, v, load_sources(ROOT)) + assert not any(r.values for r in rows) + assert validate_production('lease_action', v, rows) == [] + + +def test_new_lease_producer_invalidates_compatibility_only_claim(): + import json + from loopx.semantics.inventory import load_sources + v = json.loads((ROOT / 'loopx/semantics/vocabulary_v0.json').read_text())['vocabularies']['lease_action'] + sources = load_sources(ROOT) + [SourceFile('loopx/control_plane/coordination/new_writer.py', '.py', + 'from .authority_core import LeaseAction\ndef emit():\n return LeaseAction.ACQUIRE\n')] + with pytest.raises(ValueError, match='compatibility-only values are produced'): + validate_production('lease_action', v, collect_production(ROOT, v, sources)) + + +def test_typescript_syntax_failure_reports_only_source_location(): + source = SourceFile('loopx/control_plane/quota/broken.ts', '.ts', 'const secret = "fixture-only";\nfunction invalid( {') + with pytest.raises(ValueError, match=r'broken.ts:2: invalid TypeScript source') as error: + collect_production(ROOT, vocabulary(), [source]) + assert 'fixture-only' not in str(error.value) diff --git a/tests/architecture/test_semantic_python_production.py b/tests/architecture/test_semantic_python_production.py new file mode 100644 index 0000000000..b070fa5223 --- /dev/null +++ b/tests/architecture/test_semantic_python_production.py @@ -0,0 +1,119 @@ +"""Semantic counterexamples for the bounded producer observation relation.""" +from __future__ import annotations + +import pytest + +from loopx.semantics.inventory import SourceFile +from loopx.semantics.python_production import scan_python_production + +OWNER = 'loopx/quota/owner.py::Action' +ENUMS = {OWNER: {'RUN': 'run', 'WAIT': 'wait'}} + + +def scan(text, *, returns=(), path='loopx/quota/client.py'): + return scan_python_production(SourceFile(path, '.py', text), field='action', enums=ENUMS, + return_functions=frozenset(returns)) + + +def known(rows): + return set().union(*(r.values for r in rows)) + + +def test_owner_definition_does_not_produce_values(): + assert known(scan('class Action:\n RUN = "run"\n WAIT = "wait"\n', path=OWNER.split('::')[0])) == set() + + +def test_aliased_import_enum_return_and_keyword_produce_values(): + rows = scan('from .owner import Action as A\ndef emit():\n p = Packet(action=A.RUN.value)\n return A.WAIT\n') + assert known(rows) == {'run', 'wait'} + assert {r.site for r in rows} == {'loopx/quota/client.py::emit'} + + +def test_local_owner_use_counts_but_definition_does_not(): + rows = scan('class Action:\n RUN = "run"\n WAIT = "wait"\ndef emit():\n return Action.RUN.value\n', path=OWNER.split('::')[0]) + assert known(rows) == {'run'} + + +def test_comparison_and_read_keys_are_not_production(): + rows = scan('from .owner import Action\ndef read(p):\n if p["action"] == Action.RUN.value:\n return p.get("action")\n') + assert known(rows) == set() + + +def test_registered_return_function_includes_only_its_own_returns(): + rows = scan('def emit(flag):\n def inner():\n return "inner"\n return "left" if flag == "condition" else "right"\n', returns=['emit']) + assert known(rows) == {'left', 'right'} + assert {r.site for r in rows} == {'loopx/quota/client.py::emit'} + + +def test_single_local_variable_and_reassignment_boundary(): + rows = scan('def emit(flag):\n code = "run" if flag else "wait"\n return code\n', returns=['emit']) + assert known(rows) == {'run', 'wait'} + assert not any(r.unresolved for r in rows) + rows = scan('def emit(flag):\n code = "run"\n if flag:\n code = dynamic()\n return code\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_parameter_shadowing_does_not_borrow_owner_values(): + rows = scan('from .owner import Action\ndef emit(Action):\n return Action.RUN.value\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_same_name_import_from_wrong_module_is_unknown(): + rows = scan('from .unrelated import Action\ndef emit():\n return Action.RUN.value\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_unknown_member_fails_with_location(): + with pytest.raises(ValueError, match=r'client.py:3: unknown owner member MISSING'): + scan('from .owner import Action\ndef emit():\n return Action.MISSING.value\n') + + +def test_unresolved_result_preserves_conditional_literal_evidence(): + rows = scan('def emit(flag):\n return "run" if flag else dynamic()\n', returns=['emit']) + assert known(rows) == {'run'} + assert rows[0].unresolved + + +def test_field_write_sites_are_attributed_to_distinct_functions(): + rows = scan('def first():\n return {"action": "run"}\ndef second():\n return Packet(action="wait")\n') + assert {(r.site, tuple(r.values)) for r in rows} == { + ('loopx/quota/client.py::first', ('run',)), + ('loopx/quota/client.py::second', ('wait',)), + } + + +def test_local_import_shadowing_does_not_borrow_owner_values(): + rows = scan('from .owner import Action\ndef emit():\n from .unrelated import Action\n return Action.RUN.value\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_assignment_after_return_is_not_a_variable_definition(): + rows = scan('def emit():\n return code\n code = "run"\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_module_rebind_of_builtin_str_is_unknown(): + rows = scan('str = custom\ndef emit():\n return str("run")\n', returns=['emit']) + assert known(rows) == set() + assert rows[0].unresolved + + +def test_enum_used_only_as_mapping_key_does_not_produce_that_enum(): + rows = scan('from .owner import Action\ndef explain(value):\n reasons = {Action.RUN: "text"}\n return reasons[value]\n') + assert known(rows) == set() + + +def test_enum_comparison_inside_result_packet_does_not_produce_operand(): + rows = scan('from .owner import Action\ndef explain(value):\n packet = {"ok": value == Action.RUN}\n return packet\n') + assert known(rows) == set() + + +def test_dictionary_lookup_result_includes_values_not_keys(): + rows = scan('from .owner import Action\ndef route(value):\n return {"x": Action.RUN, "y": Action.WAIT}[value]\n', returns=['route']) + assert known(rows) == {'run', 'wait'} + assert any(row.unresolved for row in rows) diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index 2d51dadff7..eb55173d14 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -65,6 +65,90 @@ def test_literal_scan_rejects_unknown_value_with_either_quote(suffix: str, quote 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) + + +@pytest.mark.parametrize("text, expected", [ + ('payload["effective_action"] = "new_action"', {"new_action"}), + ('route.effective_action: str = "new_action"', {"new_action"}), + ('Packet(effective_action="new_action")', {"new_action"}), + ('payload = {"effective_action":\n "left" if flag == "condition" else "right"}', {"left", "right"}), + ('effective_action = payload.get("effective_action", "fallback")', set()), + ('effective_action == "not_produced"', set()), + ('# effective_action = "comment"', set()), + ('example = \'effective_action = "example"\'', set()), +]) +def test_python_production_forms_separate_result_from_context(text, expected) -> None: + smoke = runpy.run_path(str(SMOKE)) + source = smoke["SourceFile"]("loopx/control_plane/quota/probe.py", ".py", text) + assert smoke["_producer_literals"]("effective_action", source) == expected + + +def test_return_producer_scope_cannot_be_removed_from_registry(): + smoke = runpy.run_path(str(SMOKE)) + registry = copy.deepcopy(smoke['load_registry']()) + registry['vocabularies']['effective_action']['return_producers'] = [] + with pytest.raises(smoke['Drift'], match='RETURN_PRODUCER_ANCHOR'): + smoke['check_coverage_floor'](registry) + + +@pytest.mark.parametrize('name', ['turn_route', 'loop_disposition', 'agent_scope_frontier_action']) +def test_registered_kernel_producer_coverage_cannot_be_removed(name): + smoke = runpy.run_path(str(SMOKE)) + registry = copy.deepcopy(smoke['load_registry']()) + registry['vocabularies'][name].pop('producers') + with pytest.raises(smoke['Drift'], match='PRODUCER_VOCABULARY_ANCHOR'): + smoke['check_coverage_floor'](registry) + def test_candidate_decisions_are_exhaustive_and_default_to_unknown() -> None: smoke = runpy.run_path(str(SMOKE)) registry = smoke["load_registry"]() diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index 7aa42779b9..7c0f5ea311 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -1180,6 +1180,20 @@ def test_run_once_commits_once_and_replays_without_duplicate_effects( assert count_path.read_text(encoding="utf-8") == "1" assert calls == {"writeback": 1, "spend": 1, "scheduler": 1} + # The route is a persisted compatibility surface, not just in-process state. + # Exercise the actual TypeScript-backed journal writer and Python resume reader. + transaction = plan["transaction"] + assert isinstance(transaction, dict) + turn_key = str(transaction["turn_key"]) + stored = json.loads(turn_journal_path( + tmp_path / "runtime", goal_id="fixture-goal", turn_key=turn_key, + ).read_text(encoding="utf-8")) + assert stored["plan"]["route"]["kind"] == "ready_for_host" + resumed = load_loopx_turn_plan_from_journal( + tmp_path / "runtime", goal_id="fixture-goal", turn_key=turn_key, + ) + assert resumed["route"] == stored["plan"]["route"] + def test_provider_can_commit_before_its_journal_checkpoint( tmp_path: Path,