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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/reference/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions examples/semantic-vocabulary-drift-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions loopx/control_plane/host_adapter_settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]:
Expand Down
6 changes: 3 additions & 3 deletions loopx/control_plane/turn_driver/host_todo_completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 {
Expand Down Expand Up @@ -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`,
);
Expand Down
39 changes: 31 additions & 8 deletions loopx/semantics/vocabulary_v0.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
},
Expand All @@ -103,7 +103,7 @@
"domain": {
"quantifies_over": "vocabularies[*]",
"verified": 0,
"registered": 28,
"registered": 29,
"evidence_bound": "inventory_only"
}
},
Expand Down Expand Up @@ -170,8 +170,8 @@
}
},
"coverage_floor": {
"vocabularies": 28,
"owner_symbols": 54,
"vocabularies": 29,
"owner_symbols": 56,
"literal_scan_fields": 1,
"projections": 1,
"relations": 9,
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions tests/architecture/test_semantic_vocabulary_drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"]


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +11 to +12

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] 请把这里和 PR 说明中的失败时序改成“在副作用前拒绝”。settle_host_todo_completion 先调用 _decode_provider_steps 解码整份计划,成功后才调用 _run_provider_plan;即使只把真实 TypeScript 五步计划的最后一项改错,实际入口也会报 step order mismatch,provider 调用为 0,且不会 finalize。基线与当前 head 的对照观察一致。登记两份已有 carrier 仍然有用,但不是在修复“部分步骤已经执行后才拒绝”的风险。


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))
Loading