Add version-pinned reconciliation decisions

This commit is contained in:
2026-08-04 11:45:35 +02:00
parent 3991c179a4
commit 314e7c3edf
15 changed files with 724 additions and 7 deletions
+212
View File
@@ -979,6 +979,205 @@ def _comparison_fields(value: object) -> tuple[tuple[str, str], ...]:
return tuple(fields)
def _apply_reconciliation_decisions(
records: list[dict[str, Any]],
decisions: list[dict[str, Any]],
config: dict[str, Any],
*,
node_id: str,
) -> tuple[list[dict[str, Any]], tuple[str, ...]]:
columns = {
name: str(config[name])
for name in (
"decision_key_column",
"decision_input_column",
"decision_ref_column",
"action_column",
"actor_column",
"decided_at_column",
"reason_column",
"correction_column",
)
}
allowed_actions = {
str(action)
for action in config.get("allowed_actions", ())
}
decision_index: dict[str, dict[str, Any]] = {}
for row_number, decision in enumerate(decisions, start=1):
key_hash = _required_decision_hash(
decision.get(columns["decision_key_column"]),
row_number=row_number,
label="key hash",
node_id=node_id,
)
_required_decision_hash(
decision.get(columns["decision_input_column"]),
row_number=row_number,
label="input hash",
node_id=node_id,
)
for field_name, label in (
("decision_ref_column", "reference"),
("action_column", "action"),
("actor_column", "actor reference"),
("decided_at_column", "decision time"),
):
_required_decision_text(
decision.get(columns[field_name]),
row_number=row_number,
label=label,
node_id=node_id,
)
action = str(decision[columns["action_column"]]).strip()
if action not in allowed_actions:
raise PipelineExecutionError(
f"Decision row {row_number} uses an action outside the governed action set.",
node_id=node_id,
)
correction = decision.get(columns["correction_column"])
if correction is not None and not isinstance(correction, dict):
raise PipelineExecutionError(
f"Decision row {row_number} correction must be an object or null.",
node_id=node_id,
)
if action == "correct" and not correction:
raise PipelineExecutionError(
f"Decision row {row_number} requires a non-empty correction object.",
node_id=node_id,
)
if key_hash in decision_index:
raise PipelineExecutionError(
"Decision rows contain more than one current decision for a reconciliation key.",
node_id=node_id,
)
decision_index[key_hash] = decision
matched_keys: set[str] = set()
stale_count = 0
output: list[dict[str, Any]] = []
for record in records:
key_hash = str(record.get("_reconciliation_key_hash") or "")
input_hash = str(record.get("_reconciliation_input_hash") or "")
if not _is_sha256(key_hash) or not _is_sha256(input_hash):
raise PipelineExecutionError(
"Decision application requires reconciliation key and input hashes.",
node_id=node_id,
)
decision = decision_index.get(key_hash)
item = dict(record)
if decision is None:
_set_decision_fields(item, state="unreviewed")
output.append(item)
continue
matched_keys.add(key_hash)
decision_input_hash = str(
decision[columns["decision_input_column"]]
).strip()
state = "applied" if decision_input_hash == input_hash else "stale"
if state == "stale":
stale_count += 1
_set_decision_fields(
item,
state=state,
action=str(decision[columns["action_column"]]).strip(),
decision_ref=str(
decision[columns["decision_ref_column"]]
).strip(),
actor_ref=str(decision[columns["actor_column"]]).strip(),
decided_at=str(decision[columns["decided_at_column"]]).strip(),
reason=_optional_decision_text(
decision.get(columns["reason_column"])
),
correction=decision.get(columns["correction_column"]),
)
output.append(item)
unmatched_count = len(decision_index.keys() - matched_keys)
messages = tuple(
message
for count, message in (
(
stale_count,
f"{stale_count} decision(s) are stale because reconciliation inputs changed.",
),
(
unmatched_count,
f"{unmatched_count} decision(s) no longer match a current reconciliation row.",
),
)
if count
)
return output, messages
def _required_decision_hash(
value: object,
*,
row_number: int,
label: str,
node_id: str,
) -> str:
text = str(value or "").strip()
if not _is_sha256(text):
raise PipelineExecutionError(
f"Decision row {row_number} requires a SHA-256 {label}.",
node_id=node_id,
)
return text
def _is_sha256(value: str) -> bool:
if len(value) != 64:
return False
try:
int(value, 16)
except ValueError:
return False
return True
def _required_decision_text(
value: object,
*,
row_number: int,
label: str,
node_id: str,
) -> str:
text = str(value or "").strip()
if not text:
raise PipelineExecutionError(
f"Decision row {row_number} requires a {label}.",
node_id=node_id,
)
return text
def _optional_decision_text(value: object) -> str | None:
text = str(value or "").strip()
return text or None
def _set_decision_fields(
row: dict[str, Any],
*,
state: str,
action: str | None = None,
decision_ref: str | None = None,
actor_ref: str | None = None,
decided_at: str | None = None,
reason: str | None = None,
correction: object | None = None,
) -> None:
row["_decision_state"] = state
row["_decision_action"] = action
row["_decision_ref"] = decision_ref
row["_decision_actor_ref"] = actor_ref
row["_decision_at"] = decided_at
row["_decision_reason"] = reason
row["_decision_correction"] = correction
def _unique_row_index(
rows: list[dict[str, Any]],
columns: list[str],
@@ -1443,6 +1642,18 @@ def _execute_subflow_node(
)
def _execute_reconciliation_decisions(
context: OperatorExecutionContext,
) -> OperatorExecutionResult:
rows, messages = _apply_reconciliation_decisions(
context.outputs[context.inputs_by_port["records"][0]],
context.outputs[context.inputs_by_port["decisions"][0]],
context.node.config,
node_id=context.node.id,
)
return OperatorExecutionResult(rows=rows, messages=messages)
def _register_executors() -> None:
executors = {
"source.inline": _execute_inline_source,
@@ -1546,6 +1757,7 @@ def _register_executors() -> None:
node_id=context.node.id,
)
),
"reconcile.decisions": _execute_reconciliation_decisions,
"subflow": _execute_subflow_node,
"output": lambda context: OperatorExecutionResult(
rows=[dict(row) for row in context.input_rows]