Add version-pinned reconciliation decisions
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user