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
11 changes: 10 additions & 1 deletion docs/reference/protocols/periodic-report-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,12 +536,21 @@ The optional automatic path uses the provider-neutral TypeScript
`post_writeback` capability-hook contract. The CLI composition root registers
`periodic_report.runtime_trigger` only when the Goal's local control-plane
configuration explicitly enables a periodic-report profile. Core dispatches
only after the primary `refresh-state` durable writeback and exact settlement
only after the primary `refresh-state` or `todo complete` durable writeback and exact settlement
readback have succeeded with complete Goal, Agent, Turn, and effect identity.
Todo-bound settlements carry a non-empty Todo id; Todo-less autonomous replans
carry an explicit `null` Todo id rather than inventing a Todo identity. The
best-effort rollout-event log is not dispatch authority.

New Todo completions checkpoint a TypeScript-owned `completion_receipt_id` in
the primary transaction. Ordinary completion and same-Turn terminal closeout
have distinct ids even when their timestamps are equal. Hook dispatch and
composition recovery use that committed id, so a later timestamp change cannot
create a duplicate intent. Existing completions without the field retain their
original timestamp-derived identity; replay does not migrate or rewrite them.
Canonical receipt replay also recovers the optional hook after primary commit
if its sidecar was not checkpointed. These ids grant no additional authority.

The hook input contains only the committed receipt identity, stable state
revision, and derived `periodic_report_stage_completion_receipt_v0`. Its result
is an idempotent `periodic_report.trigger_evaluation` intent with an empty write
Expand Down
2 changes: 2 additions & 0 deletions loopx/cli_commands/post_writeback.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def dispatch_committed_cli_post_writeback_hooks(
committed_at: str,
hooks: Sequence[PostWritebackHookRegistration],
projection_builder: PostWritebackProjectionBuilder | None,
receipt_id: str | None = None,
) -> dict[str, Any]:
"""Bridge one committed CLI mutation into the TS-owned hook lifecycle.

Expand Down Expand Up @@ -216,6 +217,7 @@ def dispatch_committed_cli_post_writeback_hooks(
"effect_id": str(identity.get("effect_id") or ""),
},
"state_version": state_version,
**({"receipt_id": receipt_id} if receipt_id is not None else {}),
"committed_at": committed_at,
"projection": projection,
},
Expand Down
4 changes: 3 additions & 1 deletion loopx/cli_commands/todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ def handle_todo_command(
):
identity = settlement_identity.as_dict()
committed_at = str(payload.get("updated_at") or "").strip()
receipt_id = payload.get("completion_receipt_id")
if committed_at:
# Capability evidence comes only from a Turn journal the TS
# journal owner validated against this completion's full
Expand All @@ -789,7 +790,8 @@ def handle_todo_command(
goal_id=args.goal_id,
event_kind="todo_complete",
identity=identity,
state_version=committed_at,
state_version=receipt_id or committed_at,
receipt_id=receipt_id,
committed_at=committed_at,
hooks=post_writeback_hooks,
projection_builder=post_writeback_projection_builder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export interface CoordinationProjectionCommitInput {
const TODO_CONTRACT_REVISION_FIELDS: readonly (readonly string[])[] = [
["completion_validation_revision", "completion_validation_revision_history"],
["completion_result"],
["completion_receipt_id"],
];

interface HistoricalTodoContract {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({
"successor_todo_ids",
"completion_continuation",
"completion_recovery",
"completion_receipt_id",
"replan_obligation_id",
"target_key",
"cadence",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def _freeze(value: Any) -> Any:
'successor_todo_ids',
'completion_continuation',
'completion_recovery',
'completion_receipt_id',
'replan_obligation_id',
'target_key',
'cadence',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"successor_todo_ids",
"completion_continuation",
"completion_recovery",
"completion_receipt_id",
"replan_obligation_id",
"target_key",
"cadence",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,7 @@ export async function executeCoordinationTodoTerminalLifecycle(
completion_identity_source:
completion === null ? null : completion.completion_identity_source,
completed_at: target.todo.completed_at,
completion_receipt_id: target.todo.completion_receipt_id ?? null,
...(completionResult === null ? {} : {completion_result: completionResult}),
...(acceptanceEvidence === null ? {} : {goal_acceptance_completion: acceptanceEvidence}),
// A preview that omits this would show an unconditional close for work the
Expand Down
46 changes: 34 additions & 12 deletions loopx/control_plane/post_writeback_hook_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ interface PostWritebackSource extends JsonObject {
durable: boolean;
identity: JsonObject & { goal_id: string; todo_id: string | null };
state_version: string;
receipt_id?: string | null;
committed_at: string;
projection: JsonObject;
}
Expand Down Expand Up @@ -375,6 +376,7 @@ function decodeSourceFields(value: unknown): PostWritebackSource {
"durable",
"identity",
"state_version",
...(Object.hasOwn(source, "receipt_id") ? ["receipt_id"] : []),
"committed_at",
"projection",
],
Expand Down Expand Up @@ -421,6 +423,9 @@ function decodeSourceFields(value: unknown): PostWritebackSource {
source.state_version,
"source.state_version",
),
...(Object.hasOwn(source, "receipt_id")
? {receipt_id: optionalPythonStrippedString(source.receipt_id, "source.receipt_id")}
: {}),
committed_at: pythonStrippedString(
source.committed_at,
"source.committed_at",
Expand Down Expand Up @@ -596,7 +601,11 @@ function sourceHookInput(source: PostWritebackSource, readScope: string[]): Json
event_kind: source.event_kind,
identity: source.identity,
state_version: source.state_version,
committed_at: source.committed_at,
// Old sources retain their byte-for-byte identity. New lifecycle receipts
// carry an immutable committed id; their clock is diagnostic, not a version.
...(source.receipt_id == null
? {committed_at: source.committed_at}
: {receipt_id: source.receipt_id}),
};
const eventId = `pwr_${sha256(pythonCanonicalJson(receiptFacts)).slice(0, 24)}`;
const projection = Object.fromEntries(
Expand Down Expand Up @@ -738,6 +747,25 @@ function intentKey(intent: unknown): string | null {
: null;
}

function validateStoredReceipt(
request: TransactionRequest,
admitted: AdmittedHook,
receipt: JsonObject,
): JsonObject {
// An explicit primary receipt id binds the dispatch independently of a
// later projection timestamp. Validate and return the stored clock rather
// than rewriting history; legacy timestamp identities remain exact.
const hookInput = request.source?.receipt_id != null
? {...admitted.hook_input, receipt: {
...requireJsonObject(admitted.hook_input.receipt, "hook_input.receipt"),
recorded_at: receipt.recorded_at,
}}
: admitted.hook_input;
return validatePostWritebackHookReceipt({
registration: admitted.contract, hook_input: hookInput, receipt,
});
}

function recordReceiptConflict(
inspection: Inspection,
admitted: AdmittedHook,
Expand Down Expand Up @@ -859,11 +887,7 @@ async function inspectTransaction(request: TransactionRequest): Promise<Inspecti
if (read.receipt !== null) {
let receipt: JsonObject;
try {
receipt = validatePostWritebackHookReceipt({
registration: admitted.contract,
hook_input: hookInput,
receipt: read.receipt,
});
receipt = validateStoredReceipt(request, admitted, read.receipt);
} catch {
inspection.blocked_dispatch_ids.add(dispatchId);
inspection.result_slots.push({
Expand Down Expand Up @@ -975,15 +999,13 @@ async function storeReceipt(
if (current.receipt !== null) {
let validated: JsonObject;
try {
validated = validatePostWritebackHookReceipt({
registration: admitted.contract,
hook_input: admitted.hook_input,
receipt: current.receipt,
});
validated = validateStoredReceipt(request, admitted, current.receipt);
} catch {
return { status: "conflict", receipt: null };
}
if (pythonCanonicalJson(validated) === pythonCanonicalJson(receipt)) {
const candidate = request.source?.receipt_id != null
? {...receipt, recorded_at: validated.recorded_at} : receipt;
if (pythonCanonicalJson(validated) === pythonCanonicalJson(candidate)) {
return validated.status === "retryable_failure"
? { status: "stored", receipt: validated }
: { status: "replayed", receipt: validated };
Expand Down
12 changes: 11 additions & 1 deletion loopx/control_plane/todos/completion_transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ def materialized_todo_completion_replay(
"status": "done",
"completion_continuation": fence.get("completion_continuation"),
"completion_recovery": todo.get("completion_recovery"),
"completion_receipt_id": todo.get("completion_receipt_id"),
"handoff_mode": handoff["handoff_mode"],
"mutation_authority": dict(mutation_authority),
"state_file": state_file,
Expand Down Expand Up @@ -423,12 +424,21 @@ def _valid_completion_settlement(result: Mapping[str, Any]) -> bool:
and (state.get("recovery") is None or state.get("recovery") in _RECOVERIES)
and isinstance(updates, Mapping)
and all(
key in {"completion_continuation", "completion_recovery"}
key in {"completion_continuation", "completion_recovery", "completion_receipt_id"}
and isinstance(value, str)
for key, value in updates.items()
)
and updates.get("completion_continuation") == state.get("continuation")
and updates.get("completion_recovery") == state.get("recovery")
and (
updates.get("completion_receipt_id") is None
or (
isinstance(updates["completion_receipt_id"], str)
and updates["completion_receipt_id"].startswith("tcw_")
and BARE_SHA256_PATTERN.fullmatch(updates["completion_receipt_id"][4:])
is not None
)
)
and (receipt is None or _valid_receipt(receipt))
)

Expand Down
9 changes: 9 additions & 0 deletions loopx/control_plane/todos/completion_transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,15 @@ export function reduceTodoCompletionTransaction(
metadataResult.updates,
"completion metadata updates",
);
// Checkpoint the committed phase, not its clock tick. Persisting this id
// lets crash recovery and later reads retain the original hook identity.
updates.completion_receipt_id = `tcw_${canonicalAuthoritySha256({
goal_id: request.goal_id,
todo_id: request.todo_id,
completion_identity_key: identity.key,
continuation: completionStateResult.continuation,
recovery: completionStateResult.recovery,
})}`;
const completionPolicy = evaluateCompletionPolicy(
request.completion_policy_request,
);
Expand Down
2 changes: 2 additions & 0 deletions loopx/control_plane/todos/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,7 @@ def _metadata_value_is_present(value: Any) -> bool:
"completed_at",
"updated_at",
"completion_turn_key",
"completion_receipt_id",
"validation_command",
"validation_command_argv",
"validation_label",
Expand Down Expand Up @@ -1268,6 +1269,7 @@ def format_todo_metadata_line(
successor_todo_ids: Any = None,
completion_continuation: str | None = None,
completion_recovery: str | None = None,
completion_receipt_id: str | None = None,
replan_obligation_id: str | None = None,
resume_when: str | None = None,
resume_monitor_generation: int | str | None = None,
Expand Down
6 changes: 5 additions & 1 deletion loopx/control_plane/todos/field_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,13 @@ function completionUpdates(block: JsonObject, intent: JsonObject, targetStatus:
if (present(intent.completion_metadata_updates_override)) {
const updates = requireJsonObject(intent.completion_metadata_updates_override, "completion metadata override");
if (Object.entries(updates).some(([key, value]) =>
!["completion_continuation", "completion_recovery"].includes(key) || typeof value !== "string")) {
!["completion_continuation", "completion_recovery", "completion_receipt_id"].includes(key) || typeof value !== "string")) {
throw new EffectRuntimeRequestError("TypeScript Todo completion metadata updates shape mismatch");
}
if (updates.completion_receipt_id !== undefined &&
!/^tcw_[0-9a-f]{64}$/u.test(String(updates.completion_receipt_id))) {
throw new EffectRuntimeRequestError("completion_receipt_id is invalid");
}
return {...updates};
}
const result = buildTodoCompletionMetadataUpdates({
Expand Down
1 change: 1 addition & 0 deletions loopx/control_plane/todos/line_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ def apply_todo_update_to_lines(
"completion_recovery": normalize_todo_completion_recovery(
effective_metadata.get("completion_recovery")
),
"completion_receipt_id": effective_metadata.get("completion_receipt_id"),
"resume_when": normalize_todo_resume_when(
effective_metadata.get("resume_when")
),
Expand Down
5 changes: 4 additions & 1 deletion loopx/control_plane/todos/provider_terminal_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,10 @@ def terminal_canonical_todo_if_promoted(
"idempotent_replay": idempotent_replay,
"state_file": str(state_file) if state_file is not None else None,
"project": str(project) if project is not None else None,
"updated_at": payload.get("completed_at") if payload.get("changed") else None,
# A receipt replay must also recover an uncheckpointed optional hook.
"updated_at": payload.get("completed_at") if command == "complete" else (
payload.get("completed_at") if payload.get("changed") else None
),
"next_todos": payload.get("generated_successors") or [],
"mutation_authority": terminal_decision,
"task_lease_fence": terminal_decision,
Expand Down
4 changes: 2 additions & 2 deletions loopx/semantics/project_registry_io_manifest_v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@
},
{
"site": "loopx/cli_commands/post_writeback.py::<module>.dispatch_committed_cli_post_writeback_hooks::codec_read:load_registry#1",
"line": 158,
"line": 159,
"column": 13,
"kind": "codec_read",
"api": "load_registry",
Expand Down Expand Up @@ -927,7 +927,7 @@
},
{
"site": "loopx/cli_commands/todo.py::<module>.handle_todo_command::codec_read:load_registry#7",
"line": 778,
"line": 779,
"column": 38,
"kind": "codec_read",
"api": "load_registry",
Expand Down
Loading
Loading