From 7b3ee0d35f6dd28086d7184a5a1273e21968c265 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 12:51:04 +0800 Subject: [PATCH 1/6] fix(leases): unify typed owner eligibility and canonical inspection Signed-off-by: huangruiteng --- .../coordination/authority_core.py | 50 +++------ .../coordination/local_authority_runtime.ts | 1 + .../control_plane/coordination/todo_claim.ts | 8 +- .../control_plane/effect_runtime_handlers.ts | 2 + loopx/control_plane/work_items/task_lease.py | 43 ++++++-- .../work_items/task_lease_acquire.ts | 31 ++---- .../work_items/task_lease_eligibility.ts | 54 ++++++++++ .../work_items/task_lease_lifecycle.ts | 15 +-- .../task_lease_lifecycle_decision.ts | 20 +--- .../test_canonical_lease_inspection.py | 100 ++++++++++++++++++ .../task_lease_eligibility.test.ts | 92 ++++++++++++++++ 11 files changed, 308 insertions(+), 108 deletions(-) create mode 100644 loopx/control_plane/work_items/task_lease_eligibility.ts create mode 100644 tests/control_plane/test_canonical_lease_inspection.py create mode 100644 tests/control_plane_ts/task_lease_eligibility.test.ts diff --git a/loopx/control_plane/coordination/authority_core.py b/loopx/control_plane/coordination/authority_core.py index aa9dd1c7c3..9a1e586e4c 100644 --- a/loopx/control_plane/coordination/authority_core.py +++ b/loopx/control_plane/coordination/authority_core.py @@ -235,38 +235,6 @@ def _invalid_lease_snapshot(lease: LeaseSnapshot | None) -> bool: ) -def _lease_owner_rejection( - snapshot: CoordinationSnapshot, - owner: str | None, -) -> str | None: - todo = snapshot.todo - if todo is None: - return "todo_not_found" - if todo.status != "open": - return "todo_not_open" - if not owner: - return "invalid_owner" - if owner not in snapshot.registered_agents: - return "owner_not_registered" - if owner in todo.excluded_agents: - return "owner_excluded_from_todo" - if todo.claimed_by and todo.claimed_by != owner: - return "owner_conflicts_with_claim" - return None - - -def _lease_is_effective( - snapshot: CoordinationSnapshot, - lease: LeaseSnapshot | None, -) -> bool: - return bool( - lease is not None - and lease.present - and lease.active - and _lease_owner_rejection(snapshot, lease.owner) is None - ) - - def write_scopes_overlap( left: tuple[str, ...] | list[str], right: tuple[str, ...] | list[str], @@ -502,9 +470,20 @@ def _decide_lease_owner_eligibility( snapshot: CoordinationSnapshot, command: LeaseOwnerEligibilityCommand, ) -> TransitionPlan: - rejection = _lease_owner_rejection(snapshot, command.owner) - if rejection is not None: - return _result(DecisionOutcome.REJECTED, rejection) + payload = effect_runtime_result( + "task_lease.owner_eligibility", + { + "todo": _todo_fact_payload(snapshot.todo) if snapshot.todo else None, + "owner": command.owner, + "registered_agents": list(snapshot.registered_agents), + }, + ) + if not isinstance(payload, dict) or payload.get("schema_version") != "task_lease_owner_eligibility_v0": + raise RuntimeError("TypeScript lease owner eligibility result shape mismatch") + if payload.get("outcome") == "rejected": + return _result(DecisionOutcome.REJECTED, str(payload["code"])) + if payload.get("outcome") != "apply" or payload.get("code") != "lease_owner_allowed": + raise RuntimeError("TypeScript lease owner eligibility verdict mismatch") return _result( DecisionOutcome.APPLY, "lease_owner_allowed", @@ -537,7 +516,6 @@ def _decide_acquire( { "present": lease.present, "active": lease.active, - "effective": _lease_is_effective(snapshot, lease), "status": lease.status, "owner": lease.owner, "idempotency_key": lease.idempotency_key, diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 02dd8503bf..912789e6be 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1162,6 +1162,7 @@ export async function listLocalCoordinationTodos( todo_read_model: todoReadModel, ...(leaseIndex === null ? {} : { leases: leaseIndex.lease_todo_ids.map((id) => leaseIndex.leases.get(id)!), + handoff_mode: head.head.handoff_mode ?? "legacy", }), provider_revision: head.provider_revision, cursor: head.cursor, diff --git a/loopx/control_plane/coordination/todo_claim.ts b/loopx/control_plane/coordination/todo_claim.ts index 25517b72e4..ed348cf03e 100644 --- a/loopx/control_plane/coordination/todo_claim.ts +++ b/loopx/control_plane/coordination/todo_claim.ts @@ -1,3 +1,4 @@ +import {leaseOwnerRejection as ownerRejection} from "../work_items/task_lease_eligibility.ts"; import type { JsonObject } from "../effect_program.ts"; import type { AuthorityStore, AuthorityStoreCommit, AuthorityStoreReceiptResult } from "./authority_store.ts"; import { @@ -23,7 +24,6 @@ import { normalizeIdempotencyKey, normalizeTtl, normalizeWriteScopes, - ownerRejection, TASK_LEASE_SCHEMA_VERSION, utcIsoformat, type LeaseRecord, @@ -536,11 +536,6 @@ export async function executeCoordinationTodoClaim( if (handoffMode === "hard_lease" && leaseRequest !== null) { const todoFact = todoLeaseFact(todo); const currentActive = currentLease !== undefined && leaseIsActive(currentLease, input.now); - const currentEffective = currentLease !== undefined && currentActive && ownerRejection( - todoFact, - normalizeAgent(currentLease.owner), - input.registered_agents, - ) === null; const otherLeases = projection.lease_todo_ids.flatMap((todoId) => { if (todoId === input.todo_id) return []; const candidate = projection.leases.get(todoId)!; @@ -565,7 +560,6 @@ export async function executeCoordinationTodoClaim( lease: currentLease === undefined ? null : { present: true, active: currentActive, - effective: currentEffective, status: typeof currentLease.status === "string" ? currentLease.status : null, owner: normalizeAgent(currentLease.owner), idempotency_key: typeof currentLease.idempotency_key === "string" diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 8e9d5c5f79..f4a1da6702 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -1,3 +1,4 @@ +import {evaluateTaskLeaseOwnerEligibility} from "./work_items/task_lease_eligibility.ts"; import { evaluateSubagentContext, describeSubagentContext } from "./subagent_context.ts"; import { effectIdsMatch, @@ -450,6 +451,7 @@ export function createEffectRuntimeHandlers( ["quota.void.commit", evaluateQuotaVoidCommit], ["quota.settlement.read", readQuotaSettlement], ["quota.turn_envelope.evaluate", evaluateTurnEnvelope], + ["task_lease.owner_eligibility", evaluateTaskLeaseOwnerEligibility], ["task_lease.acquire.decide", evaluateTaskLeaseAcquireDecision], ["task_lease.acquire.native", executeTaskLeaseAcquire], ["task_lease.lifecycle.decide", evaluateTaskLeaseLifecycleDecision], diff --git a/loopx/control_plane/work_items/task_lease.py b/loopx/control_plane/work_items/task_lease.py index dd977bc23d..99d340093f 100644 --- a/loopx/control_plane/work_items/task_lease.py +++ b/loopx/control_plane/work_items/task_lease.py @@ -915,17 +915,42 @@ def inspect_task_lease( ) -> dict[str, Any]: goal_id = normalize_goal_id(goal_id) todo_id = normalize_lease_todo_id(todo_id) - lease_path = task_lease_path(runtime_root=runtime_root, goal_id=goal_id, todo_id=todo_id) - lease = read_lease(lease_path) + from ..coordination.local_authority import read_canonical_todos_if_promoted + from ..todos.handoff_mode import normalize_handoff_mode + + # A promoted read cannot combine canonical Todo facts with stale local + # lease files or display frontmatter. Absence is an authoritative result. + canonical = read_canonical_todos_if_promoted( + runtime_root=runtime_root, goal_id=goal_id, include_leases=True, + ) + source_fields: dict[str, Any] = {} + if canonical is not None: + if "handoff_mode" not in canonical: + raise TaskLeaseError("canonical lease snapshot omitted handoff mode; update the runtime", + code="local_authority_snapshot_incomplete") + lease_path = None + lease = next((row for row in canonical["leases"] if row.get("todo_id") == todo_id), None) + todo = next((row for row in canonical["todos"] if row.get("todo_id") == todo_id), None) + handoff_mode = normalize_handoff_mode(canonical.get("handoff_mode")) + source_fields = { + "source_authority": canonical["source_authority"], + "provider_revision": canonical["provider_revision"], + "legacy_fallback_used": False, + } + else: + lease_path = task_lease_path(runtime_root=runtime_root, goal_id=goal_id, todo_id=todo_id) + lease = read_lease(lease_path) + handoff_mode = _optional_handoff_mode(registry_path, goal_id) active = lease_is_active(lease) executor_constraint: dict[str, Any] | None = None if active and lease: try: - todo = task_lease_todo_projection( - registry_path=registry_path, - goal_id=goal_id, - todo_id=todo_id, - ) + if canonical is None: + todo = task_lease_todo_projection( + registry_path=registry_path, + goal_id=goal_id, + todo_id=todo_id, + ) except TaskLeaseError as exc: active = False executor_constraint = { @@ -942,7 +967,6 @@ def inspect_task_lease( active = False else: executor_constraint = None - handoff_mode = _optional_handoff_mode(registry_path, goal_id) return { "ok": True, "schema_version": TASK_LEASE_SCHEMA_VERSION, @@ -951,7 +975,8 @@ def inspect_task_lease( "todo_id": todo_id, "active": active, "lease": lease, - "lease_path": str(lease_path), + "lease_path": str(lease_path) if lease_path is not None else None, + **source_fields, **({"handoff_mode": handoff_mode} if handoff_mode else {}), **({"executor_constraint": executor_constraint} if executor_constraint else {}), } diff --git a/loopx/control_plane/work_items/task_lease_acquire.ts b/loopx/control_plane/work_items/task_lease_acquire.ts index c0217f2d92..9157f2af37 100644 --- a/loopx/control_plane/work_items/task_lease_acquire.ts +++ b/loopx/control_plane/work_items/task_lease_acquire.ts @@ -1,3 +1,4 @@ +import {leaseOwnerRejection as ownerRejection} from "./task_lease_eligibility.ts"; import { ShadowManagementError, requireShadowPrimaryWriteAllowed } from "../coordination/shadow_management.ts"; import { parseIsoTimestamp } from "../runtime_timestamp.ts"; import { LegacyCoordinationWriteError, requireLegacyCoordinationPrimaryWriteAllowed } from "../coordination/legacy_writer_fence.ts"; @@ -104,7 +105,6 @@ export interface LeaseRecord extends JsonObject { interface AcquireDecisionLease { present: boolean; active: boolean; - effective: boolean; status: string | null; owner: string | null; idempotency_key: string | null; @@ -602,22 +602,6 @@ export function utcIsoformat(value: Date): string { return value.toISOString().replace(/\.\d{3}Z$/u, "Z"); } -export function ownerRejection( - todo: TodoFact | undefined, - owner: string | null, - registeredAgents: readonly string[], -): string | null { - if (todo === undefined) return "todo_not_found"; - if (todo.status !== "open") return "todo_not_open"; - if (owner === null) return "invalid_owner"; - if (!registeredAgents.includes(owner)) return "owner_not_registered"; - if (todo.excluded_agents.includes(owner)) return "owner_excluded_from_todo"; - if (todo.claimed_by && todo.claimed_by !== owner) { - return "owner_conflicts_with_claim"; - } - return null; -} - function ownerFailure( code: string, request: AcquireRequest, @@ -797,10 +781,11 @@ function decodeDecisionTodo(value: unknown): TodoFact | null { function decodeDecisionLease(value: unknown): AcquireDecisionLease | null { if (value === null || value === undefined) return null; const lease = requireJsonObject(value, "task lease acquire decision lease"); + // Accept old callers without trusting their derived eligibility hint. + if (lease.effective !== undefined) decisionBoolean(lease.effective, "lease.effective"); return { present: decisionBoolean(lease.present, "lease.present"), active: decisionBoolean(lease.active, "lease.active"), - effective: decisionBoolean(lease.effective, "lease.effective"), status: decisionNullableString(lease.status, "lease.status"), owner: decisionNullableString(lease.owner, "lease.owner"), idempotency_key: decisionNullableString( @@ -910,7 +895,9 @@ export function evaluateTaskLeaseAcquireDecision(value: unknown): AcquireDecisio ) { return acquireDecisionResult("conflict", "version_mismatch"); } - if (lease !== null && lease.present && lease.active && lease.effective) { + // The old wire effective hint is not authority over the supplied owner facts. + if (lease !== null && lease.present && lease.active && + ownerRejection(input.todo, lease.owner, input.registered_agents) === null) { if ( lease.owner === command.owner && lease.idempotency_key === command.idempotency_key @@ -1260,11 +1247,6 @@ async function commitAcquire( const version = leaseVersion(existing); const epoch = leaseEpoch(existing); const active = leaseIsActive(existing, at); - const existingEffective = existing !== null && active && ownerRejection( - todo ?? undefined, - normalizeAgent(existing.owner), - request.authority.registered_agents, - ) === null; const otherLeases = await otherLeaseFacts(request, at); const decision = evaluateTaskLeaseAcquireDecision({ handoff_mode: request.authority.handoff_mode, @@ -1275,7 +1257,6 @@ async function commitAcquire( : { present: true, active, - effective: existingEffective, status: typeof existing.status === "string" ? existing.status : null, owner: normalizeAgent(existing.owner), idempotency_key: typeof existing.idempotency_key === "string" diff --git a/loopx/control_plane/work_items/task_lease_eligibility.ts b/loopx/control_plane/work_items/task_lease_eligibility.ts new file mode 100644 index 0000000000..93abde6b5f --- /dev/null +++ b/loopx/control_plane/work_items/task_lease_eligibility.ts @@ -0,0 +1,54 @@ +/** Owner eligibility is shared by acquisition, renewal, transfer and observation. + * It is not a lease grant: time, mode, proof, CAS and release remain with callers. */ +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; +import {requireJsonObject} from "../runtime_decode.ts"; + +export interface LeaseEligibilityTodo { + readonly status: string; + readonly claimed_by: string | null; + readonly excluded_agents: readonly string[]; +} + +export type LeaseOwnerRejection = "todo_not_found" | "todo_not_open" | "invalid_owner" | + "owner_not_registered" | "owner_excluded_from_todo" | "owner_conflicts_with_claim"; + +export function leaseOwnerRejection(todo: LeaseEligibilityTodo | null | undefined, + owner: string | null, registered: readonly string[]): LeaseOwnerRejection | null { + if (todo == null) return "todo_not_found"; + if (todo.status !== "open") return "todo_not_open"; + if (!owner) return "invalid_owner"; + if (!registered.includes(owner)) return "owner_not_registered"; + if (todo.excluded_agents.includes(owner)) return "owner_excluded_from_todo"; + if (todo.claimed_by && todo.claimed_by !== owner) return "owner_conflicts_with_claim"; + return null; +} + +function strings(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some(item => typeof item !== "string")) { + throw new EffectRuntimeRequestError(`${label} must be an array of strings`); + } + return value as string[]; +} + +function nullableString(value: unknown, label: string): string | null { + if (value == null) return null; + if (typeof value !== "string") throw new EffectRuntimeRequestError(`${label} must be a string or null`); + return value; +} + +/** A narrow adapter for the still-shipped Python coordination/inspection API. + * Inputs are normalized facts, not Markdown or a second authority snapshot. */ +export function evaluateTaskLeaseOwnerEligibility(value: unknown) { + const input = requireJsonObject(value, "task lease owner eligibility"); + const raw = input.todo == null ? null : requireJsonObject(input.todo, "todo"); + let todo: LeaseEligibilityTodo | null = null; + if (raw !== null) { + if (typeof raw.status !== "string") throw new EffectRuntimeRequestError("todo.status must be a string"); + todo = {status: raw.status, claimed_by: nullableString(raw.claimed_by, "todo.claimed_by"), + excluded_agents: strings(raw.excluded_agents, "todo.excluded_agents")}; + } + const code = leaseOwnerRejection(todo, nullableString(input.owner, "owner"), + strings(input.registered_agents, "registered_agents")); + return {schema_version: "task_lease_owner_eligibility_v0", + outcome: code === null ? "apply" : "rejected", code: code ?? "lease_owner_allowed"}; +} diff --git a/loopx/control_plane/work_items/task_lease_lifecycle.ts b/loopx/control_plane/work_items/task_lease_lifecycle.ts index 927c7444a8..81752d187e 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle.ts @@ -1,3 +1,4 @@ +import {leaseOwnerRejection as ownerRejection} from "./task_lease_eligibility.ts"; import { ShadowManagementError, requireShadowPrimaryWriteAllowed } from "../coordination/shadow_management.ts"; import { LegacyCoordinationWriteError, requireLegacyCoordinationPrimaryWriteAllowed } from "../coordination/legacy_writer_fence.ts"; import { createHash, randomUUID } from "node:crypto"; @@ -1396,20 +1397,6 @@ function todoFactsMatch(left: TodoFact, right: TodoFact): boolean { matchesOptional("blocks_agent", left.blocks_agent === right.blocks_agent); } -function ownerRejection( - todo: TodoFact | null, - owner: string | null, - registered: readonly string[], -): string | null { - if (!todo) return "todo_not_found"; - if (todo.status !== "open") return "todo_not_open"; - if (!owner) return "invalid_owner"; - if (!registered.includes(owner)) return "owner_not_registered"; - if (todo.excluded_agents.includes(owner)) return "owner_excluded_from_todo"; - if (todo.claimed_by && todo.claimed_by !== owner) return "owner_conflicts_with_claim"; - return null; -} - function ownerError( request: LifecycleRequest, code: string, diff --git a/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts b/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts index 64d1f1d59e..44eb9afebb 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle_decision.ts @@ -1,3 +1,4 @@ +import {leaseOwnerRejection as ownerRejection} from "./task_lease_eligibility.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { type JsonObject } from "../effect_program.ts"; import { requireJsonObject } from "../runtime_decode.ts"; @@ -200,22 +201,6 @@ function result( }; } -function ownerRejection( - input: TaskLeaseLifecycleDecisionInput, - owner: string | null, -): string | null { - const todo = input.todo; - if (todo === null) return "todo_not_found"; - if (todo.status !== "open") return "todo_not_open"; - if (!owner) return "invalid_owner"; - if (!input.registered_agents.includes(owner)) return "owner_not_registered"; - if (todo.excluded_agents.includes(owner)) return "owner_excluded_from_todo"; - if (todo.claimed_by && todo.claimed_by !== owner) { - return "owner_conflicts_with_claim"; - } - return null; -} - /** * Pure lifecycle decision shared by the local file transaction and every * provider-neutral coordination executor. Persistence, clocks, provider CAS, @@ -242,8 +227,9 @@ export function decideTaskLeaseLifecycle( return result("rejected", "owner_not_registered"); } const rejection = ownerRejection( - input, + input.todo, command.operation === "transfer" ? command.new_owner : command.owner, + input.registered_agents, ); if (rejection !== null) return result("rejected", rejection); } diff --git a/tests/control_plane/test_canonical_lease_inspection.py b/tests/control_plane/test_canonical_lease_inspection.py new file mode 100644 index 0000000000..9cb3ab4bdd --- /dev/null +++ b/tests/control_plane/test_canonical_lease_inspection.py @@ -0,0 +1,100 @@ +"""A public lease read must not mix promoted authority with obsolete files.""" +import json +from pathlib import Path +import subprocess +import sys + +import pytest +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.local_authority import ( + LocalCoordinationAuthorityUnavailable, + read_canonical_todos_if_promoted, +) +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.work_items.task_lease import inspect_task_lease + +GOAL = "goal-lease-reader" +TODO = "todo_current" + + +def _fixture(root: Path, provider: str, *, retained: bool = True, excluded: bool = False): + runtime = root / "runtime" + state = root / "ACTIVE_GOAL_STATE.md" + state.write_text("---\nhandoff_mode: soft_claim\n---\n# Obsolete display\n") + registry = root / "registry.json" + registry.write_text(json.dumps({"schema_version": 1, "common_runtime_root": str(runtime), "goals": [{ + "id": GOAL, "repo": str(root), "state_file": str(state), + "coordination": {"registered_agents": ["agent-a", "agent-b"]}}]})) + todo = {"schema_version": "todo_item_v0", "source_section": "Agent Todo", + "todo_id": TODO, "role": "agent", "text": "Verify canonical ownership", + "status": "open", "done": False, "archive_state": "active", "task_class": "advancement_task", + "excluded_agents": ["agent-a"] if excluded else []} + lease = {"schema_version": "task_lease_v0", "goal_id": GOAL, "todo_id": TODO, + "status": "active", "owner": "agent-a", "idempotency_key": "lease-first", + "expires_at": "2099-01-01T00:00:00Z", "lease_epoch": 3, "version": 2} + projection = build_todo_runtime_shadow_projection(goal_id=GOAL, todos=[todo], + leases=[lease] if retained else [], handoff_mode="hard_lease") + initialize_canonical_authority(runtime, GOAL, projection, state_path=state, provider=provider) + obsolete = runtime / "goals" / GOAL / "task-leases" / f"{TODO}.json" + obsolete.parent.mkdir(parents=True, exist_ok=True) + obsolete.write_text(json.dumps({**lease, "owner": "agent-b", "version": 999})) + return registry, runtime, state, obsolete + + +def _inspect(registry, runtime): + return inspect_task_lease(registry_path=registry, runtime_root=runtime, goal_id=GOAL, todo_id=TODO) + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("display", ["stale", "missing", "malformed"]) +@pytest.mark.parametrize("retained", [False, True]) +def test_public_inspect_uses_one_revision_and_never_revives_obsolete_files(tmp_path, monkeypatch, provider, display, retained): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, state, obsolete = _fixture(tmp_path, provider, retained=retained) + if display == "missing": + state.unlink() + elif display == "malformed": + state.write_text("invalid display") + obsolete.write_text("invalid obsolete lease") + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL, include_leases=True) + obsolete_before = obsolete.read_bytes() + result = _inspect(registry, runtime) + assert result["ok"] is True + assert result["active"] is retained + assert result["handoff_mode"] == "hard_lease" + assert result["lease_path"] is None + assert result["legacy_fallback_used"] is False + assert result["provider_revision"] == before["provider_revision"] + if retained: + assert result["lease"]["owner"] == "agent-a" + assert result["lease"]["version"] == 2 + else: + assert result["lease"] is None + process = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--format", "json", "task-lease", "inspect", "--goal-id", GOAL, "--todo-id", TODO], + capture_output=True, text=True, timeout=30) + assert process.returncode == 0, process.stderr + process.stdout + assert json.loads(process.stdout) == result + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=GOAL, include_leases=True) == before + assert obsolete.read_bytes() == obsolete_before + assert state.exists() is (display != "missing") + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_active_but_excluded_owner_is_not_effective(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + registry, runtime, _, _ = _fixture(tmp_path, provider, excluded=True) + result = _inspect(registry, runtime) + assert result["active"] is False + assert result["lease"]["status"] == "active" + assert result["executor_constraint"]["reason"] == "owner_excluded_from_todo" + + +def test_unavailable_provider_fails_instead_of_reading_legacy_lease(tmp_path): + registry, runtime, _, obsolete = _fixture(tmp_path, "file") + authority = runtime / "authority" / "file-v0" + authority.rename(authority.with_name("offline-fixture")) + with pytest.raises(LocalCoordinationAuthorityUnavailable) as error: + _inspect(registry, runtime) + assert getattr(error.value, "code", "").startswith("local_authority_") + assert json.loads(obsolete.read_text())["owner"] == "agent-b" diff --git a/tests/control_plane_ts/task_lease_eligibility.test.ts b/tests/control_plane_ts/task_lease_eligibility.test.ts new file mode 100644 index 0000000000..ddf06216cc --- /dev/null +++ b/tests/control_plane_ts/task_lease_eligibility.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {evaluateTaskLeaseAcquireDecision} from "../../loopx/control_plane/work_items/task_lease_acquire.ts"; +import {decideTaskLeaseLifecycle} from "../../loopx/control_plane/work_items/task_lease_lifecycle_decision.ts"; +import {evaluateTaskLeaseOwnerEligibility, leaseOwnerRejection, type LeaseEligibilityTodo} from + "../../loopx/control_plane/work_items/task_lease_eligibility.ts"; +import {productionScaleCoordinationFixture} from "./production_scale_coordination_fixture.ts"; + +const todo = {todo_id: "todo_target", status: "open", claimed_by: null, excluded_agents: []}; +const lease = {present: true, active: true, status: "active", owner: "agent-a", + idempotency_key: "first", version: 1, lease_epoch: 1, write_scopes: [], acquire_ttl_seconds: 120}; +function acquire(effective: boolean) { + return {handoff_mode: "hard_lease", registered_agents: ["agent-a", "agent-b"], todo, + lease: {...lease, effective}, other_leases: [], command: {owner: "agent-b", + idempotency_key: "second", ttl_seconds: 120, write_scopes: [], expected_version: 1}}; +} + +test("current lease eligibility comes from owner facts, not a stale effective flag", () => { + for (const flag of [true, false]) { + const result = evaluateTaskLeaseAcquireDecision(acquire(flag)); + assert.equal(result.outcome, "conflict"); + assert.equal(result.code, "todo_lease_conflict"); + } +}); + +test("ineligible retained owner cannot stay effective through a stale true flag", () => { + for (const flag of [true, false]) { + const request = acquire(flag); + request.registered_agents = ["agent-b"]; + const result = evaluateTaskLeaseAcquireDecision(request); + assert.equal(result.outcome, "apply"); + assert.equal(result.next_lease?.owner, "agent-b"); + } +}); + +test("release remains a fenced cleanup operation after owner eligibility is lost", () => { + const result = decideTaskLeaseLifecycle({handoff_mode: "soft_claim", registered_agents: [], + todo: {...todo, status: "done", excluded_agents: ["agent-a"]}, lease, + command: {operation: "release", owner: "agent-a", idempotency_key: "first", + expected_version: 1, ttl_seconds: null, new_owner: null, new_idempotency_key: null}}); + assert.equal(result.outcome, "apply"); + assert.equal(result.next_lease?.status, "released"); +}); + +test("owner eligibility has one precedence across adapters and renewal", () => { + const cases: {todo: LeaseEligibilityTodo | null; owner: string | null; agents: string[]; reason: string | null}[] = [ + {todo: null, owner: null, agents: [], reason: "todo_not_found"}, + {todo: {...todo, status: "done"}, owner: null, agents: [], reason: "todo_not_open"}, + {todo, owner: null, agents: [], reason: "invalid_owner"}, + {todo: {...todo, excluded_agents: ["agent-a"]}, owner: "agent-a", agents: [], reason: "owner_not_registered"}, + {todo: {...todo, excluded_agents: ["agent-a"], claimed_by: "agent-b"}, owner: "agent-a", agents: ["agent-a"], reason: "owner_excluded_from_todo"}, + {todo: {...todo, claimed_by: "agent-b"}, owner: "agent-a", agents: ["agent-a"], reason: "owner_conflicts_with_claim"}, + {todo, owner: "agent-a", agents: ["agent-a"], reason: null}, + ]; + for (const row of cases) { + assert.equal(leaseOwnerRejection(row.todo, row.owner, row.agents), row.reason); + const adapted = evaluateTaskLeaseOwnerEligibility({todo: row.todo, owner: row.owner, registered_agents: row.agents}); + assert.equal(adapted.code, row.reason ?? "lease_owner_allowed"); + if (!row.owner) continue; + const renewed = decideTaskLeaseLifecycle({handoff_mode: "hard_lease", registered_agents: row.agents, + todo: row.todo && {...row.todo, todo_id: "todo_target"}, lease, + command: {operation: "renew", owner: row.owner, idempotency_key: "first", expected_version: 1, + ttl_seconds: 120, new_owner: null, new_idempotency_key: null}}); + if (row.reason) assert.equal(renewed.code, row.reason); + else assert.equal(renewed.outcome, "apply"); + } +}); + +test("production-scale history preserves independent claim, exclusion and terminal constraints", () => { + const fixture = productionScaleCoordinationFixture("goal-scale-eligibility"); + const records = fixture.projection.todos as {status: string; claimed_by?: string; excluded_agents?: string[]}[]; + assert.ok(records.length > 400); + for (const record of records) { + const normalized = {...record, claimed_by: null, excluded_agents: []}; + const terminal = record.status !== "open"; + assert.equal(leaseOwnerRejection(normalized, "agent-a", ["agent-a"]), terminal ? "todo_not_open" : null); + assert.equal(leaseOwnerRejection({...normalized, claimed_by: "agent-b"}, "agent-a", ["agent-a"]), + terminal ? "todo_not_open" : "owner_conflicts_with_claim"); + assert.equal(leaseOwnerRejection({...normalized, excluded_agents: ["agent-a"]}, "agent-a", ["agent-a"]), + terminal ? "todo_not_open" : "owner_excluded_from_todo"); + } +}); + +test("eligibility decoder rejects malformed facts and does not infer authorisation from prose", () => { + for (const patch of [{registered_agents: "agent-a"}, {owner: 1}, + {todo: {...todo, status: true}}, {todo: {...todo, excluded_agents: "agent-a"}}]) { + assert.throws(() => evaluateTaskLeaseOwnerEligibility({todo, owner: "agent-a", registered_agents: ["agent-a"], ...patch})); + } + const described = {...todo, text: "approved owner agent-a; safe to proceed", claimed_by: "agent-b"}; + assert.equal(evaluateTaskLeaseOwnerEligibility({todo: described, owner: "agent-a", registered_agents: ["agent-a"]}).code, + "owner_conflicts_with_claim"); +}); From 2f06255c66f5b12ab47d7d195094aa1c3d530c70 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 12:51:05 +0800 Subject: [PATCH 2/6] docs(rfcs): record lease reader closure and remaining boundaries Signed-off-by: huangruiteng --- ...shared-goal-authority-state-provider-v0.md | 8 +++++++ ...-goal-authority-state-provider-v0.zh-CN.md | 6 +++++ .../typescript-control-plane-migration-v0.md | 13 +++++++++++ ...script-control-plane-migration-v0.zh-CN.md | 10 ++++++++ .../active-state-structured-projection-v0.md | 23 +++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index d01346ef5a..775ab7c81c 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2679,6 +2679,14 @@ File/SQLite reader replay with a missing Markdown display must remain read-only; the graph never repairs display or changes authority. This retires duplicate Python relationship/traversal knowledge without changing the D1–D3 gates below. +The T3 lease-inspection reader now binds Todo, lease and handoff mode to one +provider revision and never reads obsolete local lease files after promotion. +Its eligibility policy is shared with current acquire/lifecycle rules, including +claim divergence and exclusion; a read result is not a lease grant or a commit +receipt. An empty canonical lease set stays empty. This read closure and removal +of duplicate eligibility rules do not qualify a provider, alter CAS/replay or +relax D1–D3; permanent Markdown display and the remaining roadmap stay intact. + Capability-gap consumers now share the TS requirement/resolution owner across legacy and canonical inputs, including quota's Monitor capability partition. The old Python missing-set and owner/repair decision builders are removed; diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 64ed57da3f..72fa9275d1 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -2124,6 +2124,12 @@ Task graph 的 T3 topology consumer 现共用 inventory/horizon 关系目录, 必须只读:图不修复展示,也不改变 authority。本批删除 Python 重复关系与 遍历知识,不改变以下 D1–D3 门禁。 +T3 lease inspect 已将 Todo、lease 与 handoff mode 绑定到同一 provider revision, +promotion 后不再读取本地旧 lease 文件;canonical 空租约集合保持为空。资格策略与 +当前 acquire/lifecycle 共用 TS owner,包含 claim 分歧和 exclusion;读取结果不是 +租约授权,也不是 commit receipt。该 reader 闭合和重复规则删除不代表 provider +资格化,不改变 CAS/replay 或 D1–D3;永久 Markdown 展示与后续规划继续保留。 + 命令清单、update/monitor 事务和 consumer 删除统一按 [TS 执行卡](typescript-control-plane-migration-v0.zh-CN.md#当前-stack-合入后的执行卡) 推进,不在这里复制第二套实现路线,也不把 read-policy PR 合并视为存储就绪。 diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 9556e1593f..350469dc22 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -520,6 +520,19 @@ It does not change lifecycle admission, claim/lease semantics or default provide The status source can still be incomplete: this closes one T3 interpretation boundary, not all graph source delivery or the remaining T1–T4 work. +Lease inspection now consumes one canonical Todo/lease/handoff-mode revision +after promotion; an absent canonical lease does not revive a local lease file, +and provider failure cannot fall back to Markdown. The read reports its provider +revision without repairing display or changing the lease. Unpromoted inspection +retains its legacy source contract. The shared `task_lease_eligibility.ts` owner +also replaces the Python authority-core and three TS owner-eligibility copies +used by acquire, lifecycle and terminal fencing. Current-lease effectiveness is +derived inside acquire from the supplied owner/claim/exclusion/registration facts, +not from the old caller-provided `effective` hint. Other-Todo overlap facts still +come from the existing complete execution snapshot; release retains its separate +key/version cleanup fence. This closes one T3 reader and shared rule boundary, +not the remaining Goal-channel lease display, T1/T2 transactions or promotion. + Capability resolution now shares `agents/capability_gate.ts`: missing prerequisites, repair outputs, owner/agent resolution and blocked-Todo bindings have one typed owner. Quota planning v1 passes normalized requirements, not Python-computed diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index daa491f24e..08bf982cec 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -399,6 +399,16 @@ evidence/handoff 的脱敏展示。明确的语义修正:successor 谱系不 不改变生命周期准入、claim/lease 或默认 provider。来源仍可能不完整:本批 闭合一个 T3 解释边界,不宣称所有图来源交付或 T1–T4 已完成。 +Lease inspect 在 promotion 后从同一 canonical revision 读取 Todo、lease 与 +handoff mode;canonical 无租约不复活本地旧文件,provider 失败不回退 Markdown。 +结果携带 provider revision,读取不修复展示、不修改租约;未 promotion 的来源契约保留。 +`task_lease_eligibility.ts` 同时替代 Python authority core 和三处 TS owner 资格判断, +供 acquire、lifecycle 与终态 fence 复用。当前租约是否有效由 acquire 内部根据同一输入 +的 owner/claim/exclusion/注册事实推导,不再由旧 `effective` 派生提示覆盖。 +其他 Todo 的 scope 冲突仍消费现有完整执行快照;release 保留独立的 key/version +清理门禁。这是一个 T3 reader 与共享规则边界的闭合,不代表 Goal-channel lease +展示、T1/T2 全部事务或 promotion 已完成。 + Quota 的 scope/claim 消费者现通过每个 source 一次 `todo.quota_planning.project`, 组合选择、有限展示与既有 resume planner。`quota_selection.ts` 替代 Python claim-visibility 模块及 Agent-scope 中独立的 User gate/action 过滤器。Python diff --git a/docs/reference/protocols/active-state-structured-projection-v0.md b/docs/reference/protocols/active-state-structured-projection-v0.md index 9daaa7a7e0..7ce9a7a08d 100644 --- a/docs/reference/protocols/active-state-structured-projection-v0.md +++ b/docs/reference/protocols/active-state-structured-projection-v0.md @@ -190,6 +190,29 @@ canonical. After promotion, a provider outage or revision mismatch fails closed; operators may restore a reviewed provider snapshot and regenerate the Todo sections, but must not promote stale Markdown back to canonical truth. +## Lease inspection / 租约检查 + +`loopx task-lease inspect --goal-id --todo-id ` follows the same +promotion boundary as Todo reads. Before promotion it reads the existing local +lease store. After promotion it reads Todo, lease and handoff mode from one +canonical revision, reports `source_authority`, `provider_revision` and +`legacy_fallback_used=false`, and returns `lease_path=null` because no local +lease JSON is authoritative. Canonical absence returns `lease=null, active=false`; +provider errors fail the read, never revive stale local files or repair display. + +`active` retains its existing meaning of an effective lease, not just an +unexpired timestamp. The retained `lease.status` can remain `active` while +`executor_constraint` explains a removed/excluded owner or divergent claim. +The shared typed owner predicate does not grant execution, mutate claims or +settle work. Release still requires its own key/version fence and remains usable +for cleanup after eligibility is lost. Acquire derives current effectiveness +from facts; old wire `effective` hints are accepted but cannot override them. + +中文:promotion 后检查租约必须读取同一 revision 的 Todo/lease/handoff mode, +不能拼接本地旧文件。canonical 缺失表示无租约;来源故障明确失败。`active` 仍表示 +有效租约,未过期但持有人失去资格时返回原因,不自动续租、转移或清理。 +读取不提供写授权;release 的 key/version 门禁与幂等、CAS 规则保持不变。 + ## Migration Path The projector accepts complete legacy records and native `TodoDomainRecord` From 87e716cfbc2aacec3dc466d7ae749d1e2e7a24b2 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 17:15:06 +0800 Subject: [PATCH 3/6] test(windows): expose quota payload on failure Signed-off-by: huangruiteng --- tests/test_windows_install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_windows_install.py b/tests/test_windows_install.py index 5f1ba8f4aa..65eb676779 100644 --- a/tests/test_windows_install.py +++ b/tests/test_windows_install.py @@ -185,7 +185,7 @@ def test_windows_installer_promotes_release_and_runs_doctor(tmp_path: Path) -> N args=[*common, "quota", "should-run", "--goal-id", "windows-probe"], env=launch_env, ) - assert quota.returncode == 0, quota.stderr + assert quota.returncode == 0, quota.stderr or quota.stdout quota_payload = json.loads(quota.stdout) assert quota_payload["should_run"] is True From dcf2962c723a8fd64a37c4a32d00fc30eb5e4976 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 17:23:36 +0800 Subject: [PATCH 4/6] test(windows): include quota diagnostics Signed-off-by: huangruiteng --- tests/test_windows_install.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_windows_install.py b/tests/test_windows_install.py index 65eb676779..05546d7696 100644 --- a/tests/test_windows_install.py +++ b/tests/test_windows_install.py @@ -182,7 +182,14 @@ def test_windows_installer_promotes_release_and_runs_doctor(tmp_path: Path) -> N quota = _run_loopx( pwsh=pwsh, launcher=launcher, - args=[*common, "quota", "should-run", "--goal-id", "windows-probe"], + args=[ + *common, + "quota", + "should-run", + "--goal-id", + "windows-probe", + "--verbose", + ], env=launch_env, ) assert quota.returncode == 0, quota.stderr or quota.stdout From 3ecca7ec99f3e4aeb6081b029b92c1e2fc14db71 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 17:31:11 +0800 Subject: [PATCH 5/6] fix(windows): ship timezone data for quota validation Signed-off-by: huangruiteng --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a7a51d87d4..917e991964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,12 @@ requires-python = ">=3.11" license = "Apache-2.0" license-files = ["LICENSE", "NOTICE", "LICENSE-MIT"] authors = [{ name = "LoopX contributors" }] -dependencies = [] +dependencies = [ + # Windows does not ship the IANA database used by zoneinfo; keep the + # cross-platform periodic-report default (UTC) and explicit IANA zones + # valid in installed releases as well as source checkouts. + "tzdata>=2024.1", +] [project.urls] Homepage = "https://huangruiteng.github.io/loopx/" From a12fe472454b7ff3adbb6284b8c6108fb6433dbd Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Sat, 12 Sep 2026 17:36:00 +0800 Subject: [PATCH 6/6] fix(periodic-report): keep UTC portable on Windows Signed-off-by: huangruiteng --- .../periodic_report/goal_configuration.py | 9 ++++---- .../periodic_report/machine_defaults.py | 21 +++++++++++-------- pyproject.toml | 7 +------ .../test_periodic_report_machine_defaults.py | 16 ++++++++++++++ 4 files changed, 34 insertions(+), 19 deletions(-) diff --git a/loopx/capabilities/periodic_report/goal_configuration.py b/loopx/capabilities/periodic_report/goal_configuration.py index 9e55bc8f3d..f00214b3ca 100644 --- a/loopx/capabilities/periodic_report/goal_configuration.py +++ b/loopx/capabilities/periodic_report/goal_configuration.py @@ -30,10 +30,11 @@ def normalize_configuration(value: Mapping[str, Any]) -> dict[str, Any]: if not isinstance(enabled, bool): raise TypeError("periodic_report.enabled must be a boolean") timezone = str(value.get("timezone") or "UTC").strip() - try: - ZoneInfo(timezone) - except ZoneInfoNotFoundError as exc: - raise ValueError("periodic_report.timezone is unknown") from exc + if timezone != "UTC": + try: + ZoneInfo(timezone) + except ZoneInfoNotFoundError as exc: + raise ValueError("periodic_report.timezone is unknown") from exc profile_preset = str(value.get("profile_preset") or "").strip() route_ref = str(value.get("route_ref") or "").strip() if enabled and (not profile_preset or not route_ref): diff --git a/loopx/capabilities/periodic_report/machine_defaults.py b/loopx/capabilities/periodic_report/machine_defaults.py index 4c0a047375..4fa242b3fc 100644 --- a/loopx/capabilities/periodic_report/machine_defaults.py +++ b/loopx/capabilities/periodic_report/machine_defaults.py @@ -128,10 +128,11 @@ def normalize_periodic_report_machine_defaults( periodic.get("timezone", "UTC"), "periodic_report.timezone", ) - try: - ZoneInfo(timezone) - except ZoneInfoNotFoundError as exc: - raise ValueError("periodic_report.timezone is unknown") from exc + if timezone != "UTC": + try: + ZoneInfo(timezone) + except ZoneInfoNotFoundError as exc: + raise ValueError("periodic_report.timezone is unknown") from exc normalized_periodic: dict[str, Any] = { "schema_version": PERIODIC_REPORT_MACHINE_DEFAULTS_SCHEMA, "enabled": enabled, @@ -258,10 +259,11 @@ def _normalized_goal_subscription( timezone_name = _text( config.get("timezone", "UTC"), "goal periodic_report.timezone" ) - try: - ZoneInfo(timezone_name) - except ZoneInfoNotFoundError as exc: - raise ValueError("goal periodic_report.timezone is unknown") from exc + if timezone_name != "UTC": + try: + ZoneInfo(timezone_name) + except ZoneInfoNotFoundError as exc: + raise ValueError("goal periodic_report.timezone is unknown") from exc profile_preset = str(config.get("profile_preset") or "").strip() or None route_ref = str(config.get("route_ref") or "").strip() or None if enabled: @@ -292,7 +294,8 @@ def _invalid_goal_subscription_fields(config: Mapping[str, Any]) -> tuple[str, . timezone_name = _text( config.get("timezone", "UTC"), "goal periodic_report.timezone" ) - ZoneInfo(timezone_name) + if timezone_name != "UTC": + ZoneInfo(timezone_name) except (TypeError, ValueError, ZoneInfoNotFoundError): invalid.append("timezone") if enabled is True: diff --git a/pyproject.toml b/pyproject.toml index 917e991964..a7a51d87d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,12 +11,7 @@ requires-python = ">=3.11" license = "Apache-2.0" license-files = ["LICENSE", "NOTICE", "LICENSE-MIT"] authors = [{ name = "LoopX contributors" }] -dependencies = [ - # Windows does not ship the IANA database used by zoneinfo; keep the - # cross-platform periodic-report default (UTC) and explicit IANA zones - # valid in installed releases as well as source checkouts. - "tzdata>=2024.1", -] +dependencies = [] [project.urls] Homepage = "https://huangruiteng.github.io/loopx/" diff --git a/tests/capabilities/test_periodic_report_machine_defaults.py b/tests/capabilities/test_periodic_report_machine_defaults.py index 319883bf79..27cfd6e85e 100644 --- a/tests/capabilities/test_periodic_report_machine_defaults.py +++ b/tests/capabilities/test_periodic_report_machine_defaults.py @@ -2,6 +2,7 @@ import json from pathlib import Path +from zoneinfo import ZoneInfoNotFoundError import pytest @@ -19,6 +20,7 @@ resolve_goal_periodic_report_subscription, select_goal_periodic_report_executor, ) +import loopx.capabilities.periodic_report.machine_defaults as machine_defaults_module from loopx.capabilities.periodic_report.machine_store import ( configure_periodic_report_machine_defaults, ) @@ -96,6 +98,20 @@ def test_machine_defaults_require_a_route_when_weekly_reports_are_enabled() -> N normalize_loopx_machine_defaults(payload) +def test_utc_default_does_not_require_a_platform_timezone_database(monkeypatch) -> None: + """Windows source installs must accept the built-in UTC default.""" + + def missing_timezone(_name: str) -> None: + raise ZoneInfoNotFoundError("test timezone database unavailable") + + monkeypatch.setattr(machine_defaults_module, "ZoneInfo", missing_timezone) + payload = _defaults(enabled=False) + payload["namespaces"]["periodic_report"]["timezone"] = "UTC" + + normalized = normalize_loopx_machine_defaults(payload) + assert normalized["namespaces"]["periodic_report"]["timezone"] == "UTC" + + def test_goal_override_beats_machine_default() -> None: goal = _goal("research") goal["control_plane"] = {