|
|
@@ -72,7 +72,6 @@ class CandidateService:
|
|
|
) -> CandidateRef:
|
|
|
trace, root = await self._require_owner_trace(trace_id)
|
|
|
root_trace_id = root.trace_id
|
|
|
- operation_id = f"candidate:{trace_id}:{effective_at_sequence}"
|
|
|
input_payload = {
|
|
|
"operation": operation,
|
|
|
"content": content,
|
|
|
@@ -81,6 +80,15 @@ class CandidateService:
|
|
|
input_hash = sha256(
|
|
|
canonical_json(input_payload).encode("utf-8")
|
|
|
).hexdigest()
|
|
|
+ # The command identity must survive a process crash and a subsequent
|
|
|
+ # model retry. Sequence numbers identify attempts, not durable intent.
|
|
|
+ command_hash = sha256(
|
|
|
+ canonical_json({
|
|
|
+ "trace_id": trace_id,
|
|
|
+ "input_hash": input_hash,
|
|
|
+ }).encode("utf-8")
|
|
|
+ ).hexdigest()
|
|
|
+ operation_id = f"candidate:{command_hash}"
|
|
|
async with self._lock_for(root_trace_id):
|
|
|
ledger = await self._load_ledger(root_trace_id)
|
|
|
existing_operation = next(
|
|
|
@@ -200,7 +208,7 @@ class CandidateService:
|
|
|
root_trace_id,
|
|
|
completed.model_dump(mode="json"),
|
|
|
)
|
|
|
- await self._emit_candidate_registered(candidate_ref)
|
|
|
+ await self._emit_candidate_registered(candidate_ref, operation_id)
|
|
|
return candidate_ref
|
|
|
|
|
|
async def validate_report_refs(
|
|
|
@@ -496,9 +504,10 @@ class CandidateService:
|
|
|
"Candidate revise requires a non-passed validation"
|
|
|
)
|
|
|
state = ledger.current_state(action.candidate_ref)
|
|
|
- expected_operation_id = (
|
|
|
- f"candidate-review:{parent_trace_id}:{effective_at_sequence}:"
|
|
|
- f"{key[0]}:{key[1]}:{action.action}"
|
|
|
+ expected_operation_id = self._review_operation_id(
|
|
|
+ parent_trace_id,
|
|
|
+ child_trace_id,
|
|
|
+ action,
|
|
|
)
|
|
|
same_completed_operation = any(
|
|
|
item.operation_id == expected_operation_id
|
|
|
@@ -519,6 +528,7 @@ class CandidateService:
|
|
|
ledger,
|
|
|
action,
|
|
|
parent_trace_id=parent_trace_id,
|
|
|
+ child_trace_id=child_trace_id,
|
|
|
root_trace_id=root.trace_id,
|
|
|
effective_at_sequence=effective_at_sequence,
|
|
|
)
|
|
|
@@ -531,6 +541,7 @@ class CandidateService:
|
|
|
action: CandidateReviewAction,
|
|
|
*,
|
|
|
parent_trace_id: str,
|
|
|
+ child_trace_id: str,
|
|
|
root_trace_id: str,
|
|
|
effective_at_sequence: int,
|
|
|
) -> tuple[CandidateLedger, CandidateLifecycleRecord]:
|
|
|
@@ -538,9 +549,10 @@ class CandidateService:
|
|
|
candidate_id=action.candidate_ref.candidate_id,
|
|
|
revision=action.candidate_ref.revision,
|
|
|
)
|
|
|
- operation_id = (
|
|
|
- f"candidate-review:{parent_trace_id}:{effective_at_sequence}:"
|
|
|
- f"{pointer.candidate_id}:{pointer.revision}:{action.action}"
|
|
|
+ operation_id = self._review_operation_id(
|
|
|
+ parent_trace_id,
|
|
|
+ child_trace_id,
|
|
|
+ action,
|
|
|
)
|
|
|
input_hash = sha256(
|
|
|
canonical_json(action).encode("utf-8")
|
|
|
@@ -657,7 +669,29 @@ class CandidateService:
|
|
|
await self._emit_lifecycle(adopted, root_trace_id)
|
|
|
return ledger, adopted
|
|
|
|
|
|
- async def _emit_candidate_registered(self, candidate: CandidateRef) -> None:
|
|
|
+ @staticmethod
|
|
|
+ def _review_operation_id(
|
|
|
+ parent_trace_id: str,
|
|
|
+ child_trace_id: str,
|
|
|
+ action: CandidateReviewAction,
|
|
|
+ ) -> str:
|
|
|
+ action_hash = sha256(
|
|
|
+ canonical_json(action).encode("utf-8")
|
|
|
+ ).hexdigest()
|
|
|
+ command_hash = sha256(
|
|
|
+ canonical_json({
|
|
|
+ "parent_trace_id": parent_trace_id,
|
|
|
+ "child_trace_id": child_trace_id,
|
|
|
+ "action_hash": action_hash,
|
|
|
+ }).encode("utf-8")
|
|
|
+ ).hexdigest()
|
|
|
+ return f"candidate-review:{command_hash}"
|
|
|
+
|
|
|
+ async def _emit_candidate_registered(
|
|
|
+ self,
|
|
|
+ candidate: CandidateRef,
|
|
|
+ operation_id: str,
|
|
|
+ ) -> None:
|
|
|
if self.event_service is None:
|
|
|
return
|
|
|
await self.event_service.emit_after_commit(
|
|
|
@@ -674,15 +708,11 @@ class CandidateService:
|
|
|
source_trace_id=candidate.owner_trace_id,
|
|
|
event_type="candidate.lifecycle_changed",
|
|
|
event_key=(
|
|
|
- f"candidate.lifecycle_changed:candidate:"
|
|
|
- f"{candidate.owner_trace_id}:{candidate.created_at_sequence}:proposed"
|
|
|
+ f"candidate.lifecycle_changed:{operation_id}:proposed"
|
|
|
),
|
|
|
effective_at_sequence=candidate.created_at_sequence,
|
|
|
payload={"lifecycle": CandidateLifecycleRecord(
|
|
|
- operation_id=(
|
|
|
- f"candidate:{candidate.owner_trace_id}:"
|
|
|
- f"{candidate.created_at_sequence}"
|
|
|
- ),
|
|
|
+ operation_id=operation_id,
|
|
|
candidate=CandidatePointer(
|
|
|
candidate_id=candidate.candidate_id,
|
|
|
revision=candidate.revision,
|