feat(approval): add task workflow and waiver decisions
This commit is contained in:
@@ -24,11 +24,13 @@ from app.db.migration_preflight import (
|
||||
)
|
||||
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
|
||||
from app.models.ai_memory import MemoryEntry
|
||||
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
|
||||
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
|
||||
from app.models.risk_observation import RiskObservation
|
||||
|
||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||
HEAD_REVISION = "20260716_0012"
|
||||
HEAD_REVISION = "20260716_0014"
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||
|
||||
@@ -424,6 +426,11 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"due_at",
|
||||
),
|
||||
"ix_risk_dispositions_assignee": ("tenant_id", "assignee"),
|
||||
"ix_risk_dispositions_tenant_waiver_expiry": (
|
||||
"tenant_id",
|
||||
"lifecycle_status",
|
||||
"waiver_expires_at",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_check_constraint(
|
||||
@@ -441,6 +448,21 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"risk_dispositions",
|
||||
"ck_risk_dispositions_version",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
"ck_risk_dispositions_waiver_request",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
"ck_risk_dispositions_waiver_decision",
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"risk_dispositions",
|
||||
"ck_risk_dispositions_waiver_lifecycle",
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"risk_disposition_events",
|
||||
@@ -495,6 +517,24 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
)
|
||||
}
|
||||
assert "response_json" in risk_disposition_event_columns
|
||||
risk_disposition_columns = {
|
||||
str(item["name"])
|
||||
for item in inspect(engine).get_columns("risk_dispositions", schema="public")
|
||||
}
|
||||
assert {
|
||||
"waiver_requester_id",
|
||||
"waiver_requester_name",
|
||||
"waiver_requested_at",
|
||||
"waiver_reason",
|
||||
"waiver_scope",
|
||||
"waiver_expires_at",
|
||||
"waiver_conditions_json",
|
||||
"waiver_decision",
|
||||
"waiver_decider_id",
|
||||
"waiver_decider_name",
|
||||
"waiver_decided_at",
|
||||
"waiver_decision_reason",
|
||||
}.issubset(risk_disposition_columns)
|
||||
with engine.connect() as connection:
|
||||
append_only_trigger_count = int(
|
||||
connection.scalar(
|
||||
@@ -509,6 +549,151 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
or 0
|
||||
)
|
||||
assert append_only_trigger_count == 1
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"approval_tasks",
|
||||
"uq_approval_tasks_tenant_id",
|
||||
("tenant_id", "id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"approval_tasks",
|
||||
"uq_approval_tasks_tenant_node_entry",
|
||||
("tenant_id", "node_entry_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"approval_tasks",
|
||||
"uq_approval_tasks_node_participant",
|
||||
("tenant_id", "node_instance_id", "assignee_kind", "assignee_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"approval_task_events",
|
||||
"uq_approval_task_events_actor_request",
|
||||
("tenant_id", "actor_id", "request_id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"approval_task_events",
|
||||
"uq_approval_task_events_task_version",
|
||||
("tenant_id", "task_id", "result_task_version"),
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"approval_tasks",
|
||||
("tenant_id", "parent_task_id"),
|
||||
"approval_tasks",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"approval_tasks",
|
||||
("tenant_id", "expense_case_id"),
|
||||
"expense_cases",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"approval_task_events",
|
||||
("tenant_id", "task_id"),
|
||||
"approval_tasks",
|
||||
)
|
||||
_assert_no_foreign_key(
|
||||
engine,
|
||||
"approval_tasks",
|
||||
("claim_id",),
|
||||
"expense_claims",
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"approval_tasks",
|
||||
{
|
||||
"uq_approval_tasks_open_root_per_claim": ("tenant_id", "claim_id"),
|
||||
"ix_approval_tasks_personal_inbox": (
|
||||
"tenant_id",
|
||||
"assignee_kind",
|
||||
"assignee_key",
|
||||
"status",
|
||||
"due_at",
|
||||
),
|
||||
"ix_approval_tasks_tenant_queue": (
|
||||
"tenant_id",
|
||||
"status",
|
||||
"priority_score",
|
||||
"due_at",
|
||||
),
|
||||
"ix_approval_tasks_tenant_claim": (
|
||||
"tenant_id",
|
||||
"claim_id",
|
||||
"node_sequence",
|
||||
),
|
||||
"ix_approval_tasks_tenant_node": (
|
||||
"tenant_id",
|
||||
"node_instance_id",
|
||||
"sequence_order",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_postgresql_index_predicate(
|
||||
engine,
|
||||
"approval_tasks",
|
||||
"uq_approval_tasks_open_root_per_claim",
|
||||
"task_kind",
|
||||
"root",
|
||||
"status",
|
||||
"waiting",
|
||||
"pending",
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"approval_task_events",
|
||||
{
|
||||
"ix_approval_task_events_tenant_task_time": (
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"occurred_at",
|
||||
),
|
||||
"ix_approval_task_events_tenant_node_time": (
|
||||
"tenant_id",
|
||||
"node_instance_id",
|
||||
"occurred_at",
|
||||
),
|
||||
},
|
||||
)
|
||||
for constraint_name in (
|
||||
"ck_approval_tasks_task_kind",
|
||||
"ck_approval_tasks_status",
|
||||
"ck_approval_tasks_version",
|
||||
"ck_approval_tasks_priority_score",
|
||||
"ck_approval_tasks_evidence_completeness",
|
||||
"ck_approval_task_events_type",
|
||||
"ck_approval_task_events_actor_type",
|
||||
"ck_approval_task_events_version",
|
||||
):
|
||||
table_name = (
|
||||
"approval_task_events"
|
||||
if constraint_name.startswith("ck_approval_task_events")
|
||||
else "approval_tasks"
|
||||
)
|
||||
_assert_check_constraint(engine, table_name, constraint_name)
|
||||
approval_event_columns = {
|
||||
str(item["name"])
|
||||
for item in inspect(engine).get_columns("approval_task_events", schema="public")
|
||||
}
|
||||
assert "response_json" in approval_event_columns
|
||||
with engine.connect() as connection:
|
||||
approval_append_only_trigger_count = int(
|
||||
connection.scalar(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM pg_trigger trigger "
|
||||
"JOIN pg_class relation ON relation.oid = trigger.tgrelid "
|
||||
"WHERE relation.relname = 'approval_task_events' "
|
||||
"AND trigger.tgname = 'trg_approval_task_events_append_only' "
|
||||
"AND NOT trigger.tgisinternal"
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
assert approval_append_only_trigger_count == 1
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"memory_entries",
|
||||
@@ -1335,6 +1520,10 @@ def _assert_base_schema(engine: Engine) -> None:
|
||||
("20260716_0011_risk_disposition.py", "downgrade"),
|
||||
("20260716_0012_risk_disposition_response_snapshot.py", "upgrade"),
|
||||
("20260716_0012_risk_disposition_response_snapshot.py", "downgrade"),
|
||||
("20260716_0013_approval_tasks.py", "upgrade"),
|
||||
("20260716_0013_approval_tasks.py", "downgrade"),
|
||||
("20260716_0014_risk_waiver_decision.py", "upgrade"),
|
||||
("20260716_0014_risk_waiver_decision.py", "downgrade"),
|
||||
],
|
||||
)
|
||||
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
|
||||
@@ -1363,6 +1552,30 @@ def test_risk_disposition_snapshot_migration_refuses_lossy_downgrade() -> None:
|
||||
assert operation_guard.mutation_calls == []
|
||||
|
||||
|
||||
def test_approval_task_migration_refuses_non_empty_audit_chain_downgrade() -> None:
|
||||
migration = _load_migration_module("20260716_0013_approval_tasks.py")
|
||||
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
|
||||
operation_guard.bind.scalar = lambda _statement: 1
|
||||
migration.op = operation_guard
|
||||
|
||||
with pytest.raises(RuntimeError, match="audit chain is not empty"):
|
||||
migration.downgrade()
|
||||
|
||||
assert operation_guard.mutation_calls == []
|
||||
|
||||
|
||||
def test_risk_waiver_migration_refuses_lossy_audit_downgrade() -> None:
|
||||
migration = _load_migration_module("20260716_0014_risk_waiver_decision.py")
|
||||
operation_guard = _UnsupportedDialectOperationGuard(dialect_name="postgresql")
|
||||
operation_guard.bind.scalar = lambda _statement: 1
|
||||
migration.op = operation_guard
|
||||
|
||||
with pytest.raises(RuntimeError, match="immutable waiver audit data exists"):
|
||||
migration.downgrade()
|
||||
|
||||
assert operation_guard.mutation_calls == []
|
||||
|
||||
|
||||
def test_head_model_declares_soft_claim_reference_and_organization_only_active_index() -> None:
|
||||
claim_column = RiskObservation.__table__.c.claim_id
|
||||
assert not claim_column.foreign_keys
|
||||
@@ -1379,6 +1592,83 @@ def test_head_model_declares_soft_claim_reference_and_organization_only_active_i
|
||||
assert "scope_type IN ('department', 'enterprise')" in predicate
|
||||
|
||||
|
||||
def test_approval_task_model_declares_tenant_safety_and_open_root_invariant() -> None:
|
||||
claim_column = ApprovalTask.__table__.c.claim_id
|
||||
assert not claim_column.foreign_keys
|
||||
assert inspect(ApprovalTask).relationships["claim"].viewonly is True
|
||||
|
||||
constraint_names = {
|
||||
constraint.name for constraint in ApprovalTask.__table__.constraints
|
||||
}
|
||||
assert {
|
||||
"uq_approval_tasks_tenant_id",
|
||||
"uq_approval_tasks_tenant_node_entry",
|
||||
"uq_approval_tasks_node_participant",
|
||||
"fk_approval_tasks_tenant_parent",
|
||||
"ck_approval_tasks_status",
|
||||
"ck_approval_tasks_version",
|
||||
"ck_approval_tasks_priority_score",
|
||||
"ck_approval_tasks_evidence_completeness",
|
||||
}.issubset(constraint_names)
|
||||
|
||||
open_root_index = next(
|
||||
index
|
||||
for index in ApprovalTask.__table__.indexes
|
||||
if index.name == "uq_approval_tasks_open_root_per_claim"
|
||||
)
|
||||
assert open_root_index.unique is True
|
||||
predicate = str(open_root_index.dialect_options["postgresql"]["where"])
|
||||
assert "task_kind = 'root'" in predicate
|
||||
assert "status IN ('waiting', 'pending')" in predicate
|
||||
|
||||
event_constraint_names = {
|
||||
constraint.name for constraint in ApprovalTaskEvent.__table__.constraints
|
||||
}
|
||||
assert {
|
||||
"uq_approval_task_events_actor_request",
|
||||
"uq_approval_task_events_task_version",
|
||||
"fk_approval_task_events_tenant_task",
|
||||
"ck_approval_task_events_version",
|
||||
}.issubset(event_constraint_names)
|
||||
assert ApprovalTaskEvent.__table__.c.response_json.nullable is False
|
||||
|
||||
|
||||
def test_risk_waiver_model_declares_decision_metadata_constraints() -> None:
|
||||
constraint_names = {
|
||||
constraint.name for constraint in RiskDisposition.__table__.constraints
|
||||
}
|
||||
assert {
|
||||
"ck_risk_dispositions_waiver_request",
|
||||
"ck_risk_dispositions_waiver_decision",
|
||||
"ck_risk_dispositions_waiver_lifecycle",
|
||||
}.issubset(constraint_names)
|
||||
lifecycle_constraint = next(
|
||||
constraint
|
||||
for constraint in RiskDisposition.__table__.constraints
|
||||
if constraint.name == "ck_risk_dispositions_lifecycle"
|
||||
)
|
||||
assert "waived" in str(lifecycle_constraint.sqltext)
|
||||
assert "waiver_rejected" in str(lifecycle_constraint.sqltext)
|
||||
|
||||
waiver_index = next(
|
||||
index
|
||||
for index in RiskDisposition.__table__.indexes
|
||||
if index.name == "ix_risk_dispositions_tenant_waiver_expiry"
|
||||
)
|
||||
assert tuple(column.name for column in waiver_index.columns) == (
|
||||
"tenant_id",
|
||||
"lifecycle_status",
|
||||
"waiver_expires_at",
|
||||
)
|
||||
event_action_constraint = next(
|
||||
constraint
|
||||
for constraint in RiskDispositionEvent.__table__.constraints
|
||||
if constraint.name == "ck_risk_disposition_events_action"
|
||||
)
|
||||
assert "approve_waiver" in str(event_action_constraint.sqltext)
|
||||
assert "reject_waiver" in str(event_action_constraint.sqltext)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"database_url",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user