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]
|
||||
|
||||
@@ -1249,6 +1249,52 @@ def _validate_reconcile(node: GraphNode) -> list[DataflowDiagnostic]:
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _validate_reconciliation_decisions(
|
||||
node: GraphNode,
|
||||
) -> list[DataflowDiagnostic]:
|
||||
diagnostics: list[DataflowDiagnostic] = []
|
||||
for field_name in (
|
||||
"decision_key_column",
|
||||
"decision_input_column",
|
||||
"decision_ref_column",
|
||||
"action_column",
|
||||
"actor_column",
|
||||
"decided_at_column",
|
||||
"reason_column",
|
||||
"correction_column",
|
||||
):
|
||||
if not _non_empty_text(node.config.get(field_name)):
|
||||
diagnostics.append(
|
||||
_node_field_error(
|
||||
node,
|
||||
"reconcile.decisions.column",
|
||||
"Every decision mapping column must be named.",
|
||||
field_name,
|
||||
)
|
||||
)
|
||||
actions = node.config.get("allowed_actions")
|
||||
if (
|
||||
not isinstance(actions, list)
|
||||
or not actions
|
||||
or len(actions) > 25
|
||||
or any(
|
||||
not isinstance(action, str) or not action.strip()
|
||||
for action in actions
|
||||
)
|
||||
or len({action.strip() for action in actions if isinstance(action, str)})
|
||||
!= len(actions)
|
||||
):
|
||||
diagnostics.append(
|
||||
_node_field_error(
|
||||
node,
|
||||
"reconcile.decisions.actions",
|
||||
"Add between one and 25 unique allowed decision actions.",
|
||||
"allowed_actions",
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _validate_subflow(node: GraphNode) -> list[DataflowDiagnostic]:
|
||||
diagnostics: list[DataflowDiagnostic] = []
|
||||
for field_name, code, message in (
|
||||
@@ -1415,6 +1461,7 @@ def _register_config_validators() -> None:
|
||||
"limit": _validate_limit,
|
||||
"quality.rules": _validate_quality,
|
||||
"reconcile.compare": _validate_reconcile,
|
||||
"reconcile.decisions": _validate_reconciliation_decisions,
|
||||
"subflow": _validate_subflow,
|
||||
"output": _validate_no_config,
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ DOCUMENTATION = (
|
||||
"Every graph node declares typed inputs, configuration, output schema, and validation rules. "
|
||||
"Source nodes pin inline content or governed Datasource references; combine, filter, transform, "
|
||||
"quality, reconciliation, reusable-subflow, and output nodes remain explicit in the canonical graph. "
|
||||
"Reconciliation rows expose stable key hashes, explicit before/after values, and input hashes so a later governed human decision can be replayed only while its inputs still match. "
|
||||
"Reconciliation rows expose stable key hashes, explicit before/after values, and input hashes. A separate decision-table input can annotate exact matches, invalidate changed inputs, and report orphaned decisions without silently rewriting business data. "
|
||||
"Expressions use the typed Dataflow expression language and never execute arbitrary host or database "
|
||||
"code. Selecting a node may request a bounded intermediate preview; preview rows are transient, "
|
||||
"privacy-filtered for the actor, and are not retained as run output. SQL editing compiles into the same "
|
||||
|
||||
@@ -530,6 +530,85 @@ _NODE_TYPES = (
|
||||
},
|
||||
sql_support="none",
|
||||
),
|
||||
NodeTypeDefinition(
|
||||
type="reconcile.decisions",
|
||||
category="quality",
|
||||
label="Apply review decisions",
|
||||
description="Attach versioned human decisions only while their reconciliation input hash still matches.",
|
||||
icon="list-checks",
|
||||
input_ports=(
|
||||
NodePortDefinition(id="records", label="Reconciliation rows"),
|
||||
NodePortDefinition(id="decisions", label="Decision rows"),
|
||||
),
|
||||
config_fields=(
|
||||
NodeConfigField(
|
||||
id="decision_key_column",
|
||||
label="Decision key hash column",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
NodeConfigField(
|
||||
id="decision_input_column",
|
||||
label="Decision input hash column",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
NodeConfigField(
|
||||
id="decision_ref_column",
|
||||
label="Decision reference column",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
NodeConfigField(
|
||||
id="action_column",
|
||||
label="Action column",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
NodeConfigField(
|
||||
id="actor_column",
|
||||
label="Actor reference column",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
NodeConfigField(
|
||||
id="decided_at_column",
|
||||
label="Decision time column",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
NodeConfigField(
|
||||
id="reason_column",
|
||||
label="Reason column",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
NodeConfigField(
|
||||
id="correction_column",
|
||||
label="Correction column",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
NodeConfigField(
|
||||
id="allowed_actions",
|
||||
label="Allowed actions",
|
||||
kind="column_list",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
default_config={
|
||||
"decision_key_column": "key_hash",
|
||||
"decision_input_column": "input_hash",
|
||||
"decision_ref_column": "decision_ref",
|
||||
"action_column": "action",
|
||||
"actor_column": "actor_ref",
|
||||
"decided_at_column": "decided_at",
|
||||
"reason_column": "reason",
|
||||
"correction_column": "correction",
|
||||
"allowed_actions": ["accept", "reject", "correct", "defer"],
|
||||
},
|
||||
sql_support="none",
|
||||
),
|
||||
NodeTypeDefinition(
|
||||
type="subflow",
|
||||
category="transform",
|
||||
|
||||
@@ -640,6 +640,67 @@ def _reconcile(context: SchemaPropagationContext) -> SchemaPropagationResult:
|
||||
return SchemaPropagationResult(state, tuple(diagnostics))
|
||||
|
||||
|
||||
def _reconciliation_decisions(
|
||||
context: SchemaPropagationContext,
|
||||
) -> SchemaPropagationResult:
|
||||
node = context.node
|
||||
records = context.port_state("records")
|
||||
decisions = context.port_state("decisions")
|
||||
decision_columns = [
|
||||
str(node.config.get(field_name) or "")
|
||||
for field_name in (
|
||||
"decision_key_column",
|
||||
"decision_input_column",
|
||||
"decision_ref_column",
|
||||
"action_column",
|
||||
"actor_column",
|
||||
"decided_at_column",
|
||||
"reason_column",
|
||||
"correction_column",
|
||||
)
|
||||
]
|
||||
diagnostics = [
|
||||
*_unknown_columns(
|
||||
node,
|
||||
records,
|
||||
["_reconciliation_key_hash", "_reconciliation_input_hash"],
|
||||
field_name="records",
|
||||
),
|
||||
*_unknown_columns(
|
||||
node,
|
||||
decisions,
|
||||
decision_columns,
|
||||
field_name="decisions",
|
||||
),
|
||||
]
|
||||
state = SchemaState(
|
||||
records.columns
|
||||
| frozenset(
|
||||
{
|
||||
"_decision_state",
|
||||
"_decision_action",
|
||||
"_decision_ref",
|
||||
"_decision_actor_ref",
|
||||
"_decision_at",
|
||||
"_decision_reason",
|
||||
"_decision_correction",
|
||||
}
|
||||
),
|
||||
open=records.open,
|
||||
types={
|
||||
**records.types,
|
||||
"_decision_state": "string",
|
||||
"_decision_action": "string",
|
||||
"_decision_ref": "string",
|
||||
"_decision_actor_ref": "string",
|
||||
"_decision_at": "datetime",
|
||||
"_decision_reason": "string",
|
||||
"_decision_correction": "object",
|
||||
},
|
||||
)
|
||||
return SchemaPropagationResult(state, tuple(diagnostics))
|
||||
|
||||
|
||||
def _comparison_columns(value: object) -> tuple[list[str], list[str]]:
|
||||
left: list[str] = []
|
||||
right: list[str] = []
|
||||
@@ -982,6 +1043,7 @@ def register_schema_propagators() -> None:
|
||||
"limit": _identity,
|
||||
"quality.rules": _quality,
|
||||
"reconcile.compare": _reconcile,
|
||||
"reconcile.decisions": _reconciliation_decisions,
|
||||
"subflow": _subflow,
|
||||
"output": _identity,
|
||||
}
|
||||
|
||||
@@ -1953,6 +1953,7 @@ def _register_sql_renderers() -> None:
|
||||
"limit": _render_limit,
|
||||
"quality.rules": _render_unsupported,
|
||||
"reconcile.compare": _render_unsupported,
|
||||
"reconcile.decisions": _render_unsupported,
|
||||
"subflow": _render_unsupported,
|
||||
"output": _render_noop,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user