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
12 changes: 12 additions & 0 deletions docs/architecture/rfcs/typescript-control-plane-migration-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ label; no legacy prediction is retained without a concrete display consumer.
State-only refresh remains legal without a delivery claim; this patch does
not require every status refresh to declare progress. Existing write-time
enum rejection, settlement evidence, quota, and gate checks remain in force.
- Follow-up claim validation shares one TS diagnosis with historical projection.
New writes reject progress paired with preparation-only work, primary outcome
paired with blocker work/typed blocked observation, and primary outcome paired
with an explicit follow-through requirement. Historical conflicts remain
readable as `unknown` plus `delivery_claim_conflicts`; persisted records and
settled receipts are never rewritten. Partial progress and valid blocker
writebacks remain legal. This is an intentional authoring/readback behavior
correction, not a change to small-delivery policy or a new evidence validator.
Refresh validates individual fields in their established order, then checks
the normalized claim before registry access or lock creation. Invalid input
therefore takes precedence over store errors, including in dry-run mode;
state-dependent admission and writeback still share the same runtime lock.
- Legacy outcome-marker/hint configuration remains readable and preserves
whether an outcome floor is configured. Its words no longer classify runs.
No persisted history is rewritten and no new default-off flag restores the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@ classification 保留为历史标签;没有明确展示消费者时,不保
- 历史字段缺失或不受支持时保持 unknown。unknown 中断连续小规模/outcome-gap
证据计数,不视为成功或推断出的失败。未配置 floor 且没有 outcome 时,保留
`not_configured` 展示哨兵值。
- 后续声明校验与历史投影共用一份 TS 诊断:新写入拒绝“进展 + 仅准备”、
“主要成果 + blocker/typed blocked observation”和“主要成果 + 显式继续义务”
的矛盾组合。历史冲突以 `unknown` 和 `delivery_claim_conflicts` 保持可读,
不改写持久化记录或已结算 receipt;合法局部进展和 blocker 写回不受影响。
这是有意的写入/读取行为修正,不调整小步交付策略,也不是新的证据验证器。
Refresh 先按既有顺序校验各字段,再以归一化结果检查组合语义,之后才读取
registry 和创建锁。因此非法输入优先于存储错误返回,dry-run 也一致;依赖
当前状态的准入与写回仍在同一 runtime 锁内完成。
- 新交付声明通过现有 writer API 写显式 enum,例如
`refresh-state --delivery-outcome ... --delivery-batch-scale ...`。
纯状态刷新仍可不声明交付;本批不强迫每次刷新声明进展。既有写入 enum 校验、
Expand Down
7 changes: 2 additions & 5 deletions examples/shared-goal-authority-e2e/mutants.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,8 @@ def apply(source: str) -> str:
move_guard_outside_lock("require_registry_source_write_allowed")),),
WRITER_TEST + "test_waiting_override_writer_rechecks_registry_binding_inside_shared_state_lock"),
Case("remove_refresh_cas", (("loopx/state_refresh.py", replacement(
''' if current_state_text != expected_write_state_text:
raise ValueError(
"active goal state changed while refresh-state was qualifying "
"its semantic writeback; retry from the current state"
)''', "")),),
"if current_state_text != expected_write_state_text:",
"if False: # DELIBERATE MUTANT: bypass stale-state rejection.")),),
WRITER_TEST + "test_concurrent_public_refresh_preserves_the_newer_owned_paragraph"),
Case("fence_unshared_state_lock", ((COORDINATION + "legacy_writer_fence.ts", replacement(
"withFileMutationLock(statePath, () =>",
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/effect_runtime_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
} from "./work_items/action_portfolio.ts";
import { projectQuotaPlanningHorizon } from "./work_items/planning_horizon.ts";
import { projectDeliveryHistory } from "./work_items/delivery_history.ts";
import { validateDeliveryClaim } from "./work_items/delivery_outcome.ts";
import {
evaluateTaskLeaseAcquireDecision,
evaluateTaskLeaseWriteScopesOverlap,
Expand Down Expand Up @@ -410,6 +411,7 @@ export function createEffectRuntimeHandlers(
["work_item.planning_inventory.detail", projectTodoPlanningInventoryDetail],
["work_item.refresh_recommendation.resolve", resolveRefreshRecommendation],
["work_item.delivery_history.project", projectDeliveryHistory],
["work_item.delivery_claim.validate", validateDeliveryClaim],
["goal.vision_checkpoint.evaluate", buildVisionCheckpoint],
["goal.vision_wait.coverage", projectVisionWaitCoverage],
["goal.shared_goal_alignment.project", projectSharedGoalAlignment],
Expand Down
13 changes: 12 additions & 1 deletion loopx/control_plane/work_items/delivery_history.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Compact transport for the typed delivery-history read model, never a writer."""
"""Compact transport for typed delivery diagnostics and pre-write validation."""
from __future__ import annotations

from collections.abc import Mapping
Expand Down Expand Up @@ -70,3 +70,14 @@ def project_delivery_history(
# Display-only annotation after the decision; narrative never enters TS.
hint["latest_classification"] = _text(run.get("classification")).strip()
return result


def require_consistent_delivery_claim(record: Mapping[str, Any]) -> None:
"""Reject contradictory authored claims before effects; add no new fields."""
if not any(record.get(key) for key in ("delivery_outcome", "delivery_turn_kind", "outcome_followthrough_required")):
return
result = effect_runtime_result("work_item.delivery_claim.validate", _run_facts(record))
if not isinstance(result, dict) or result.get("schema_version") != "delivery_claim_validation_v0":
raise RuntimeError("TypeScript delivery claim validation shape mismatch")
if result.get("valid") is not True:
raise ValueError("contradictory delivery claim: " + ", ".join(result.get("conflicts") or []))
8 changes: 7 additions & 1 deletion loopx/control_plane/work_items/delivery_history.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { JsonObject } from "../effect_program.ts";
import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts";
import { requireJsonObject } from "../runtime_decode.ts";
import { DELIVERY_OUTCOMES, isTurnScopedSettlementOutcome, type DeliveryOutcome } from "./delivery_outcome.ts";
import { DELIVERY_OUTCOMES, diagnoseDeliveryClaim, isTurnScopedSettlementOutcome, type DeliveryOutcome } from "./delivery_outcome.ts";

const TURN_KINDS = [
"contract_only_preparation", "compact_evidence", "blocker_writeback",
Expand Down Expand Up @@ -117,6 +117,12 @@ export function projectDeliveryHistory(value: unknown): JsonObject {
}
const floorConfigured = input.outcome_floor_configured;
const runs: DeliverySignal[] = input.runs.map(decodeRun).map((run) => {
const conflicts = diagnoseDeliveryClaim({ ...run });
if (conflicts.length > 0) return {
delivery_outcome: "unknown", delivery_batch_scale: batchScale(run.delivery_batch_scale),
delivery_turn_kind: "unknown", outcome_followthrough: null,
delivery_claim_conflicts: conflicts,
};
const outcome = outcomeSignal(run.delivery_outcome, floorConfigured);
const kind = turnKind(run, outcome);
return {
Expand Down
31 changes: 31 additions & 0 deletions loopx/control_plane/work_items/delivery_outcome.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts";
import { requireJsonObject } from "../runtime_decode.ts";
import type { JsonObject } from "../effect_program.ts";

export const DELIVERY_OUTCOMES = [
"surface_only",
Expand All @@ -17,6 +19,35 @@ export type DeliveryOutcome = (typeof DELIVERY_OUTCOMES)[number];
export type MaterialDeliveryOutcome =
(typeof MATERIAL_DELIVERY_OUTCOMES)[number];

export type DeliveryClaimConflict =
| "progress_with_preparation_only"
| "primary_outcome_with_blocker"
| "primary_outcome_with_followthrough";

/** Validate combinations, not prose or evidence truth. Historical readers use
* these same codes without rewriting already committed records or receipts. */
export function diagnoseDeliveryClaim(raw: JsonObject): DeliveryClaimConflict[] {
const outcome = String(raw.delivery_outcome ?? "").trim();
const kind = String(raw.delivery_turn_kind ?? "").trim();
const conflicts: DeliveryClaimConflict[] = [];
if ((outcome === "outcome_progress" || outcome === "primary_goal_outcome")
&& kind === "contract_only_preparation") conflicts.push("progress_with_preparation_only");
const observation = jsonObject(raw.progress_observation);
if (outcome === "primary_goal_outcome" && (kind === "blocker_writeback"
|| (observation?.schema_version === "typed_progress_observation_v0" && observation.result_class === "blocked"))) {
conflicts.push("primary_outcome_with_blocker");
}
if (outcome === "primary_goal_outcome" && raw.outcome_followthrough_required === true) {
conflicts.push("primary_outcome_with_followthrough");
}
return conflicts;
}

export function validateDeliveryClaim(value: unknown): JsonObject {
const conflicts = diagnoseDeliveryClaim(requireJsonObject(value, "delivery claim"));
return { schema_version: "delivery_claim_validation_v0", valid: conflicts.length === 0, conflicts };
}

const STABLE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
const PROGRESS_DELIVERY_OUTCOMES = new Set<DeliveryOutcome>([
"outcome_progress",
Expand Down
4 changes: 4 additions & 0 deletions loopx/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ def write_reserved_run_artifacts(
render_markdown: Callable[[dict[str, Any]], str],
) -> None:
from .control_plane.quota.usage_collector import ingest_usage_into_run_record
from .control_plane.work_items.delivery_history import require_consistent_delivery_claim

require_consistent_delivery_claim(record)
require_consistent_delivery_claim(index_record)

# Producer call site for GH-C95: normalize any compact usage measurement onto
# the durable run record and its index row before append. Fail closed here so
Expand Down
Loading