From 1e3401591eda91ed67589cdbbfd45a78582a7082 Mon Sep 17 00:00:00 2001 From: xiaoan17 Date: Thu, 29 Oct 2026 12:12:09 +0800 Subject: [PATCH] feat(control-plane): register the host-adapter settlement step vocabulary The TypeScript host owner authors an ordered provider plan and the Python transport validates the plan it receives against the same sequence, but the two sequences were private hand-kept copies: a module-local const in host_todo_completion.ts and a module-local _STEP_KINDS in host_adapter_settlement.py. Neither was exported, so the semantic inventory could not see either carrier, neither the vocabulary registry nor the drift smoke could tie them together, and a divergence would have surfaced at runtime as "TypeScript host provider step order mismatch" after the plan had already been accepted and partly executed. Both sequences are now registered owners of the new cross-runtime vocabulary host_adapter_settlement_step_kind, so the drift smoke checks each of them against the registry on every run. The vocabulary is deliberately distinct from settlement_step_kind: that one names the turn settlement phases, whose validation phase is never executed, while every kind here is a command the host adapter runs. The registry meaning records that difference. The two owners keep different names per runtime, so no new multi-value twin appears: multi_value_twins stays 8/8 and every inventory budget is unchanged. Only the coverage floor grows, 28 to 29 vocabularies and 54 to 56 owner symbols, in the registry and its anchor together. New tests pin the two runtimes against each other directly and prove the transport still refuses a plan whose step order drifts. Validation: semantic-vocabulary-drift-smoke ok; the diff-scoped semantic advisory now sees both carriers and points them at the new vocabulary; semantic inventory, production and drift test suites pass; the MCP settlement, host vision and machine guard suites pass; the TypeScript host todo completion test passes and the control plane typechecks; loopx canary premerge --from-git-diff green. Signed-off-by: xiaoan17 --- docs/reference/glossary.md | 9 ++ examples/semantic-vocabulary-drift-smoke.py | 4 +- .../control_plane/host_adapter_settlement.py | 4 +- .../turn_driver/host_todo_completion.ts | 6 +- loopx/semantics/vocabulary_v0.json | 39 ++++++-- .../test_semantic_vocabulary_drift.py | 10 +-- ...host_adapter_settlement_step_vocabulary.py | 90 +++++++++++++++++++ 7 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 tests/control_plane/test_host_adapter_settlement_step_vocabulary.py diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index c7208c2d44..68dda6aa90 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -121,6 +121,15 @@ Authority handoff mode between agents. - typescript: [`HANDOFF_MODES`](../../loopx/control_plane/coordination/handoff_mode_vocabulary.ts). - Values / 值: `legacy`, `soft_claim`, `hard_lease`. +## host_adapter_settlement_step_kind + +Ordered provider steps of one host-adapter settlement plan. The TypeScript owner authors the plan and the Python transport validates the plan it receives against the same sequence, so the two runtimes must carry the same order. Distinct from settlement_step_kind: that vocabulary names the turn settlement phases, whose validation phase is never executed, while every kind here is a command the host adapter runs. + +- Tier / 层级: `cross_runtime`; status / 状态: `canonical`. +- python: [`HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE`](../../loopx/control_plane/host_adapter_settlement.py). +- typescript: [`HOST_ADAPTER_SETTLEMENT_STEP_KINDS`](../../loopx/control_plane/turn_driver/host_todo_completion.ts). +- Values / 值: `guard`, `lifecycle_completion`, `durable_writeback`, `quota_spend`, `terminal_closeout`. + ## loop_disposition Pure controller verdict for the outer loop after combining the last Turn receipt with the fresh route. diff --git a/examples/semantic-vocabulary-drift-smoke.py b/examples/semantic-vocabulary-drift-smoke.py index 63fa173105..f8f7e2c4fc 100755 --- a/examples/semantic-vocabulary-drift-smoke.py +++ b/examples/semantic-vocabulary-drift-smoke.py @@ -191,8 +191,8 @@ # anchor. A `<=` comparison would let every tightening below the anchor be undone # silently; that is the gap the anchor exists to close. COVERAGE_ANCHOR = { - "vocabularies": 28, - "owner_symbols": 54, + "vocabularies": 29, + "owner_symbols": 56, "literal_scan_fields": 1, "projections": 1, "relations": 9, diff --git a/loopx/control_plane/host_adapter_settlement.py b/loopx/control_plane/host_adapter_settlement.py index c7d3270f09..26aad2c6a7 100644 --- a/loopx/control_plane/host_adapter_settlement.py +++ b/loopx/control_plane/host_adapter_settlement.py @@ -24,7 +24,7 @@ "loopx_host_todo_completion_reduction_v0" ) _RUNTIME_METHOD = "turn.host_todo_completion.evaluate" -_STEP_KINDS = ( +HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE = ( "guard", "lifecycle_completion", "durable_writeback", @@ -279,7 +279,7 @@ def _decode_provider_steps(value: Any) -> tuple[_ProviderStep, ...]: or len(raw_steps) not in {4, 5} ): raise RuntimeError("TypeScript host provider plan shape mismatch") - expected = _STEP_KINDS[: len(raw_steps)] + expected = HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE[: len(raw_steps)] steps: list[_ProviderStep] = [] for index, raw in enumerate(raw_steps): if not isinstance(raw, Mapping) or raw.get("step_kind") != expected[index]: diff --git a/loopx/control_plane/turn_driver/host_todo_completion.ts b/loopx/control_plane/turn_driver/host_todo_completion.ts index 709c7e637e..f4cb77fed8 100644 --- a/loopx/control_plane/turn_driver/host_todo_completion.ts +++ b/loopx/control_plane/turn_driver/host_todo_completion.ts @@ -29,7 +29,7 @@ export const HOST_ADAPTER_SETTLEMENT_SCHEMA_VERSION = "host_adapter_todo_settlement_v0"; const PHASES = ["prepare", "finalize", "classify_guard", "vision_refresh", "vision_context", "project_guard"] as const; -const STEP_KINDS = [ +export const HOST_ADAPTER_SETTLEMENT_STEP_KINDS = [ "guard", "lifecycle_completion", "durable_writeback", @@ -39,7 +39,7 @@ const STEP_KINDS = [ const TODO_ID_PATTERN = /^todo_[a-z0-9_-]{3,64}$/; type HostTodoCompletionPhase = (typeof PHASES)[number]; -type HostTodoCompletionStepKind = (typeof STEP_KINDS)[number]; +type HostTodoCompletionStepKind = (typeof HOST_ADAPTER_SETTLEMENT_STEP_KINDS)[number]; type HostGuardState = "selected" | "terminal_no_selection" | "invalid"; interface HostTodoCompletionRequest { @@ -111,7 +111,7 @@ function decodeProviderOutcomes(value: unknown): ProviderOutcome[] { const outcome = requireJsonObject(item, `provider_outcomes[${index}]`); const stepKind = requireStringLiteral( outcome.step_kind, - STEP_KINDS, + HOST_ADAPTER_SETTLEMENT_STEP_KINDS, `provider_outcomes[${index}].step_kind`, `provider_outcomes[${index}].step_kind is unsupported`, ); diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 64e18f3d94..160b95952d 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -13,7 +13,7 @@ "formal_model": { "schema_version": "loopx_semantic_formal_model_v0", "universes": { - "vocabularies": "V: registered vocabulary identifiers; Producers(V) ⊆ V is exactly the subset declaring a producers key (including compatibility-only entries with an empty list). Kernel(V) ⊆ Producers(V). Producers(V) contains 6 vocabularies: 5 kernel and 1 outside the kernel tier. The 22 cross_runtime vocabularies outside Producers(V) are unverified.", + "vocabularies": "V: registered vocabulary identifiers; Producers(V) ⊆ V is exactly the subset declaring a producers key (including compatibility-only entries with an empty list). Kernel(V) ⊆ Producers(V). Producers(V) contains 6 vocabularies: 5 kernel and 1 outside the kernel tier. The 23 cross_runtime vocabularies outside Producers(V) are unverified.", "values": "U(v): ambient runtime values; S(v): registered admitted values", "sites": "L: source locations that define, produce, consume, interpret, pass through, project, or persist values", "scopes": "Scope: global or bounded_context(context_id); ScopeDeclarations are the forked names the registry declares as bounded contexts", @@ -73,25 +73,25 @@ "invariants": [ { "id": "F1_producer_closedness", - "statement": "∀v ∈ Producers(V): Produced_scan(v) ⊆ S(v) ⊆ U(v), where Produced_scan(v) is the production the fixed forms observe inside the code-owned scan reach. Producers(V) contains 6 vocabularies: 5 kernel and 1 outside the kernel tier. The 22 cross_runtime vocabularies outside Producers(V) are unverified. Production outside the scan reach is unverified rather than proven closed.", + "statement": "∀v ∈ Producers(V): Produced_scan(v) ⊆ S(v) ⊆ U(v), where Produced_scan(v) is the production the fixed forms observe inside the code-owned scan reach. Producers(V) contains 6 vocabularies: 5 kernel and 1 outside the kernel tier. The 23 cross_runtime vocabularies outside Producers(V) are unverified. Production outside the scan reach is unverified rather than proven closed.", "enforcement": "m0_5", "evidence": "bounded production-form AST scan over the PRODUCER_ROOTS/PRODUCER_FILES reach in loopx/semantics/production.py; check_producers visits only vocabularies that declare producers, and validate_production claims no whole-program closedness; unresolved dynamic sites are reported, not treated as proven safe", "domain": { "quantifies_over": "vocabularies[producers].producers", "verified": 6, - "registered": 28, + "registered": 29, "evidence_bound": "producer_scan_reach" } }, { "id": "F2_canonical_value_liveness", - "statement": "∀v ∈ Producers(V): Canonical(v) ⊆ Produced_scan(v) ∪ CompatibilityOnly(v). Producers(V) contains 6 vocabularies: 5 kernel and 1 outside the kernel tier. The 22 cross_runtime vocabularies outside Producers(V) are unverified. Liveness outside the walked set is not proven.", + "statement": "∀v ∈ Producers(V): Canonical(v) ⊆ Produced_scan(v) ∪ CompatibilityOnly(v). Producers(V) contains 6 vocabularies: 5 kernel and 1 outside the kernel tier. The 23 cross_runtime vocabularies outside Producers(V) are unverified. Liveness outside the walked set is not proven.", "enforcement": "m0_5", "evidence": "every value of a vocabulary in Producers(V) has a producer recognised inside the scan reach, an executable input witness, or an explicit compatibility-only reason; a value whose only producer lies outside the reach would be reported as dead, not silently accepted", "domain": { "quantifies_over": "vocabularies[producers].producers", "verified": 6, - "registered": 28, + "registered": 29, "evidence_bound": "producer_scan_reach" } }, @@ -103,7 +103,7 @@ "domain": { "quantifies_over": "vocabularies[*]", "verified": 0, - "registered": 28, + "registered": 29, "evidence_bound": "inventory_only" } }, @@ -170,8 +170,8 @@ } }, "coverage_floor": { - "vocabularies": 28, - "owner_symbols": 54, + "vocabularies": 29, + "owner_symbols": 56, "literal_scan_fields": 1, "projections": 1, "relations": 9, @@ -533,6 +533,29 @@ "terminal_closeout": "The conditional fourth step, planned only when a Todo binding exists and executed as its own single-phase transaction outside the main phase list. It requires the closeout to be required and the turn result to be validated_completion, and readback accepts it only against an event declaring no followup; requesting it without those, or supplying it unrequested, fails as terminal_closeout_rejected." } }, + "host_adapter_settlement_step_kind": { + "meaning": "Ordered provider steps of one host-adapter settlement plan. The TypeScript owner authors the plan and the Python transport validates the plan it receives against the same sequence, so the two runtimes must carry the same order. Distinct from settlement_step_kind: that vocabulary names the turn settlement phases, whose validation phase is never executed, while every kind here is a command the host adapter runs.", + "tier": "cross_runtime", + "status": "canonical", + "owners": { + "python": "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE", + "typescript": "loopx/control_plane/turn_driver/host_todo_completion.ts::HOST_ADAPTER_SETTLEMENT_STEP_KINDS" + }, + "values": [ + "guard", + "lifecycle_completion", + "durable_writeback", + "quota_spend", + "terminal_closeout" + ], + "value_notes": { + "guard": "The admission step: it runs the real quota should-run command with the turn instance id, and its continue_when decides whether the later steps run at all.", + "lifecycle_completion": "The completion step: it runs the Todo completion arguments before anything is written back.", + "durable_writeback": "The writeback step: it persists the completed work, conditioned on the completion result.", + "quota_spend": "The quota step: it debits the slot, conditioned on the writeback result, and accepts an idempotent replay of an already committed spend.", + "terminal_closeout": "The optional last step, emitted only when the Todo needs a terminal closeout; it carries no continue_when because nothing follows it." + } + }, "settlement_binding_kind": { "meaning": "How a settlement step binds to its receipt.", "tier": "cross_runtime", diff --git a/tests/architecture/test_semantic_vocabulary_drift.py b/tests/architecture/test_semantic_vocabulary_drift.py index a662ce960b..e185a7ce42 100644 --- a/tests/architecture/test_semantic_vocabulary_drift.py +++ b/tests/architecture/test_semantic_vocabulary_drift.py @@ -599,7 +599,7 @@ def test_generated_domain_accepts_true_kernel_comparison() -> None: # Recovery registers one additional cross-runtime vocabulary without # claiming a qualified producer scan for its offline observations. assert "state_recovery_component_state" not in domain.walked - assert domain.outside_by_tier == (("cross_runtime", 22),) + assert domain.outside_by_tier == (("cross_runtime", 23),) def test_canonical_domain_rejects_old_kernel_only_universe() -> None: @@ -623,7 +623,7 @@ def test_domain_membership_change_requires_regenerated_prose() -> None: with pytest.raises(smoke["Drift"], match="canonical producer-domain projection"): smoke["check_formal_model"](registry["formal_model"], registry) projected = smoke["producer_domain_prose"](registry) - assert "23 cross_runtime" in projected["F1_producer_closedness.statement"] + assert "24 cross_runtime" in projected["F1_producer_closedness.statement"] assert "5 kernel and 0 outside" in projected["universes.vocabularies"] @@ -769,7 +769,7 @@ def test_adding_a_vocabulary_forces_the_declared_domain_to_move() -> None: "meaning": "probe", "tier": "cross_runtime", "status": "canonical", "owners": {"python": None, "typescript": None}, "values": ["probe_value"], } - with pytest.raises(smoke["Drift"], match="the registry holds 29"): + with pytest.raises(smoke["Drift"], match="the registry holds 30"): smoke["check_formal_model"](registry["formal_model"], registry) @@ -989,8 +989,8 @@ def test_retired_lease_registry_entry_cannot_return_as_fake_compatibility(): def test_native_lifecycle_relation_keeps_the_live_owner_and_verification_boundary(): smoke = runpy.run_path(str(SMOKE)) registry = smoke['load_registry']() - assert registry['coverage_floor']['vocabularies'] == 28 - assert registry['coverage_floor']['owner_symbols'] == 54 + assert registry['coverage_floor']['vocabularies'] == 29 + assert registry['coverage_floor']['owner_symbols'] == 56 assert registry['coverage_floor']['relations'] == 9 relation = next(row for row in registry['relations']['subsets'] if row['name'] == 'task_lease_lifecycle_decision_operation') diff --git a/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py b/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py new file mode 100644 index 0000000000..5edb7bb934 --- /dev/null +++ b/tests/control_plane/test_host_adapter_settlement_step_vocabulary.py @@ -0,0 +1,90 @@ +"""The host-adapter settlement plan step order has one registered vocabulary. + +`loopx/control_plane/turn_driver/host_todo_completion.ts` authors the ordered +provider plan and `loopx/control_plane/host_adapter_settlement.py` validates the +plan it receives against the same sequence. Until now the two sequences were +private, hand-kept copies: the TypeScript one was a module-local `const`, the +Python one a module-local `_STEP_KINDS`. Neither was exported, so the +repository's semantic inventory could not see either carrier, and neither +`loopx/semantics/vocabulary_v0.json` nor the drift smoke could tie them +together. A divergence between them would have surfaced at runtime as +`TypeScript host provider step order mismatch`, after the plan had already been +accepted and partially executed. + +Both sequences are now registered owners of `host_adapter_settlement_step_kind`, +so the drift smoke checks each of them against the registry on every run, and +the tests below check the two against each other directly. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from loopx.control_plane.host_adapter_settlement import ( + HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE, + _decode_provider_steps, +) + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +REGISTRY_PATH = REPOSITORY_ROOT / "loopx" / "semantics" / "vocabulary_v0.json" +TYPESCRIPT_OWNER = REPOSITORY_ROOT / "loopx" / "control_plane" / "turn_driver" / "host_todo_completion.ts" +VOCABULARY = "host_adapter_settlement_step_kind" + + +def _typescript_step_kinds() -> tuple[str, ...]: + """Read the TypeScript owner the way the semantic inventory reads it.""" + source = TYPESCRIPT_OWNER.read_text(encoding="utf-8") + match = re.search( + r"export const HOST_ADAPTER_SETTLEMENT_STEP_KINDS = \[([^\]]*)\] as const;", + source, + ) + assert match, "the TypeScript owner must export its step kinds as an as-const array" + return tuple(re.findall(r'"([^"]+)"', match.group(1))) + + +def test_both_runtimes_carry_the_same_step_sequence() -> None: + """The plan the TypeScript owner writes must satisfy the Python transport.""" + assert _typescript_step_kinds() == tuple(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) + + +def test_the_registry_entry_names_both_owners_and_the_same_values() -> None: + """The registered vocabulary is what the drift smoke checks both owners against.""" + registry = json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) + entry = registry["vocabularies"][VOCABULARY] + assert entry["owners"]["python"] == ( + "loopx/control_plane/host_adapter_settlement.py::HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE" + ) + assert entry["owners"]["typescript"] == ( + "loopx/control_plane/turn_driver/host_todo_completion.ts::HOST_ADAPTER_SETTLEMENT_STEP_KINDS" + ) + assert entry["values"] == list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) + + +def _plan(step_kinds: list[str]) -> dict: + """One minimal provider plan: the last step carries no continuation condition.""" + steps = [] + for index, step_kind in enumerate(step_kinds): + steps.append({ + "step_kind": step_kind, + "args": ["todo", "complete"], + "legacy_args": None, + "continue_when": {"all": []} if index < len(step_kinds) - 1 else None, + }) + return {"provider_id": "loopx_cli", "kind": "ordered_cli_sequence", "steps": steps} + + +def test_the_transport_accepts_the_registered_order() -> None: + steps = _decode_provider_steps(_plan(list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE))) + assert [step.step_kind for step in steps] == list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) + + +def test_the_transport_rejects_a_plan_whose_step_order_drifts() -> None: + """The tripwire the registry now backs: a reordered plan is refused, not run.""" + drifted = list(HOST_ADAPTER_SETTLEMENT_STEP_SEQUENCE) + drifted[0], drifted[1] = drifted[1], drifted[0] + with pytest.raises(RuntimeError, match="step order mismatch"): + _decode_provider_steps(_plan(drifted))