diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b65a2c9..eda5899 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -13,7 +13,7 @@ | workplan | FIN-WP-0002 | finished | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md | | workplan | FIN-WP-0003 | proposed | — | workplans/FIN-WP-0003-fabric-authority-boundary.md | | workplan | FIN-WP-0004 | active | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | -| workplan | FIN-WP-0005 | proposed | — | workplans/FIN-WP-0005-datev-accounting-adapter-operations.md | +| workplan | FIN-WP-0005 | active | — | workplans/FIN-WP-0005-datev-accounting-adapter-operations.md | | task | FIN-WP-0000-T01 | done | — | workplans/FIN-WP-0000-repo-integration.md | | task | FIN-WP-0000-T02 | done | — | workplans/FIN-WP-0000-repo-integration.md | | task | FIN-WP-0000-T03 | done | — | workplans/FIN-WP-0000-repo-integration.md | @@ -36,9 +36,9 @@ | task | FIN-WP-0004-T01 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | | task | FIN-WP-0004-T02 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | | task | FIN-WP-0004-T03 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | -| task | FIN-WP-0004-T04 | todo | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | +| task | FIN-WP-0004-T04 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | | task | FIN-WP-0004-T05 | wait | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | -| task | FIN-WP-0004-T06 | todo | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | +| task | FIN-WP-0004-T06 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | | task | FIN-WP-0004-T07 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | | task | FIN-WP-0004-T08 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | | task | FIN-WP-0004-T09 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md | diff --git a/docs/fin-resource-authority-contract-v0.1.md b/docs/fin-resource-authority-contract-v0.1.md index f13dae8..be6e645 100644 --- a/docs/fin-resource-authority-contract-v0.1.md +++ b/docs/fin-resource-authority-contract-v0.1.md @@ -160,6 +160,24 @@ Delivery is idempotent on `record_id`; revisions are append-only and must reference the current record of the same type. Fin-hub stores these in `planning_evidence`, never in its booked-cost ledger. +## Fin-hub → resource-control: bounded financial constraints + +`FinancialConstraintSignal` exposes only the financial context needed to rank +a procurement or optimization case: applicable budget ceiling, active +commitment amount, budget utilization, and runway pressure. Each signal has a +stable content-derived ID, exact currency and effective period, optional +resource/service/environment scope, non-secret provenance, and generation +time. + +Budget ceilings and active commitments are labelled `policy_constraint`. +Budget utilization and runway are labelled `informational` or +`informational_warning` according to the current threshold. Consumers must not +turn an informational signal into an approval gate, nor reproduce fin-hub's +budget, burn, or runway calculations. Missing authoritative values produce no +signal rather than a synthetic zero. The projection carries no payment +credentials, private invoice data, unrelated ledger rows, or procurement +authority. + ## Reconciliation invariants 1. Each booked financial fact has exactly one authoritative fin-hub record. diff --git a/docs/resource-cost-exchange-operations.md b/docs/resource-cost-exchange-operations.md new file mode 100644 index 0000000..8746e88 --- /dev/null +++ b/docs/resource-cost-exchange-operations.md @@ -0,0 +1,59 @@ +# Resource-cost exchange operations + +This runbook operates contract v0.1 between fin-hub and resource-control. The +authority matrix remains in `fin-resource-authority-contract-v0.1.md`. + +## Compatibility and delivery + +- Producers pin `schema_version`. A consumer rejects an unknown version; it + must not discard fields or reinterpret a new version as v0.1. +- Delivery identity is `financial_fact_id` for booked facts and `record_id` for + planning evidence. Redelivery is idempotent; different content under the + same ID is rejected. +- Corrections and forecast/allocation revisions are append-only and point to + the current predecessor. Consumers retain superseded records for audit and + variance work. +- Resource joins use an explicit mapping from stable `financial_fact_id` to a + resource-control-owned `resource_id`. Provider labels, filenames, and prose + are not resource identity. For the current compute case the target ID is + `resource:hosteurope:railiance01`. +- Currency conversion is never implicit. Reconciliation is performed per + currency and effective period. + +## Reconciliation procedure + +For each delivery window, record and compare on both sides: + +1. current booked-fact IDs, count, effective totals per currency, correction + edges, and source-evidence references; +2. planning record IDs/count, current revisions, forecast totals, allocation + fact references, and explicit residuals; +3. the join set and missing resource IDs, without copying resource authority + into fin-hub; +4. returned variance/optimization IDs and provenance, confirming none was + inserted as a second booked financial fact. + +Duplicate replay must leave all counts and totals unchanged. A correction must +replace the effective total exactly once while preserving the predecessor. + +## Operator visibility + +`exchange_health` reports only bounded metadata and amounts. It surfaces: + +- rejected planning deliveries by claimed ID and safe validation metadata; +- booked facts lacking both a resource binding and cost attribution; +- current forecasts whose effective period has ended; and +- allocations that reference a missing current financial fact. + +Rejected payload bodies are not retained. Only a content fingerprint, claimed +record ID/type, safe validation locations/codes, delivery count, and timestamp +are stored. Resolve error-severity issues before reconciliation; warnings need +an explicit disposition. Retain the health report with the round-trip evidence. + +## Upgrade and review + +Add a new schema alongside v0.1, publish fixtures, and dual-run it. Compare +canonical IDs, counts, money totals, corrections/revisions, joins, exceptions, +and provenance. Resource-control and fin-hub owners jointly approve the +compatibility window and removal date. Roll back by selecting the prior pinned +producer/consumer version; never rewrite existing evidence to downgrade it. diff --git a/src/fin_hub/schemas/exchange.py b/src/fin_hub/schemas/exchange.py index 8436c2c..aada121 100644 --- a/src/fin_hub/schemas/exchange.py +++ b/src/fin_hub/schemas/exchange.py @@ -227,6 +227,73 @@ class CommitmentCandidate(MonetaryRecord): return money(value, non_negative=True) +class FinancialConstraintSignal(BaseModel): + """Bounded fin-hub authority signal for procurement ranking.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal["0.1"] = "0.1" + record_type: Literal["financial_constraint"] = "financial_constraint" + signal_id: str = Field(min_length=1, max_length=256) + signal_kind: Literal[ + "budget_ceiling", "active_commitment", "burn_pressure", "runway_pressure" + ] + classification: Literal[ + "policy_constraint", "informational_warning", "informational" + ] + domain_slug: str = Field(min_length=1, max_length=64) + resource_id: str | None = None + service_id: str | None = None + environment: str | None = None + currency: str + period_start: date + period_end: date + amount: Decimal | None = None + metric_value: Decimal | None = None + metric_unit: Literal["currency_per_month", "ratio", "months"] | None = None + commitment_state: Literal["active"] | None = None + source_evidence: list[str] = Field(min_length=1) + generated_at: datetime + summary: str = Field(min_length=1, max_length=512) + + @field_validator("currency") + @classmethod + def validate_currency(cls, value: str) -> str: + return currency_code(value) + + @field_validator("amount", mode="before") + @classmethod + def validate_amount(cls, value): + return None if value is None else money(value, non_negative=True) + + @field_validator("metric_value", mode="before") + @classmethod + def validate_metric(cls, value): + if value is None: + return None + normalized = Decimal(str(value)) + if not normalized.is_finite() or normalized < 0: + raise ValueError("metric_value must be a finite non-negative decimal") + return normalized + + @model_validator(mode="after") + def validate_signal(self): + if self.period_end < self.period_start: + raise ValueError("period_end cannot precede period_start") + if self.signal_kind in {"budget_ceiling", "active_commitment"}: + if self.amount is None: + raise ValueError(f"{self.signal_kind} requires amount") + if self.classification != "policy_constraint": + raise ValueError(f"{self.signal_kind} must be a policy_constraint") + if self.signal_kind == "active_commitment" and self.commitment_state != "active": + raise ValueError("active_commitment requires commitment_state=active") + if self.signal_kind == "burn_pressure" and self.metric_unit != "ratio": + raise ValueError("burn_pressure requires a ratio metric") + if self.signal_kind == "runway_pressure" and self.metric_unit != "months": + raise ValueError("runway_pressure requires a months metric") + return self + + PlanningEvidence = Annotated[ ForecastEvidence | UsageObservation diff --git a/src/fin_hub/services/exchange.py b/src/fin_hub/services/exchange.py index 9304311..f90cb0c 100644 --- a/src/fin_hub/services/exchange.py +++ b/src/fin_hub/services/exchange.py @@ -3,20 +3,196 @@ from __future__ import annotations import json +import hashlib import sqlite3 from calendar import monthrange -from datetime import date +from dataclasses import asdict, dataclass +from datetime import date, datetime, timezone +from decimal import Decimal from pathlib import Path +from typing import Mapping -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError -from fin_hub.money import minor_money, money -from fin_hub.schemas.exchange import BookedCostEvidence, PlanningEvidence +from fin_hub.money import currency_code, minor_money, money +from fin_hub.schemas.exchange import ( + BookedCostEvidence, + FinancialConstraintSignal, + PlanningEvidence, +) from fin_hub.services.ledger import _connect, default_ledger_path _PLANNING_ADAPTER = TypeAdapter(PlanningEvidence) +@dataclass(frozen=True) +class FinancialConstraintExport: + schema_version: str + artifact_type: str + signals: tuple[FinancialConstraintSignal, ...] + disclaimer: str + + def as_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class ExchangeQualityIssue: + code: str + severity: str + record_id: str | None + detail: str + + +@dataclass(frozen=True) +class ExchangeHealthReport: + schema_version: str + artifact_type: str + booked_fact_count: int + current_planning_record_count: int + rejected_delivery_count: int + issues: tuple[ExchangeQualityIssue, ...] + + def as_dict(self) -> dict: + return asdict(self) + + +def _signal_id(*parts: object) -> str: + canonical = "\x1f".join(str(part) for part in parts) + return "financial-constraint:" + hashlib.sha256(canonical.encode()).hexdigest() + + +def financial_constraint_projection( + *, + domain_slug: str, + period_start: date, + period_end: date, + currency: str, + source_evidence: tuple[str, ...], + budget_ceiling: Decimal | str | int | float | None = None, + active_commitment: Decimal | str | int | float | None = None, + spent: Decimal | str | int | float | None = None, + monthly_burn: Decimal | str | int | float | None = None, + runway_months: Decimal | str | int | float | None = None, + runway_threshold_months: Decimal | str | int | float | None = None, + resource_id: str | None = None, + service_id: str | None = None, + environment: str | None = None, + generated_at: datetime | None = None, +) -> FinancialConstraintExport: + """Create the minimal affordability view without exposing ledger details.""" + + if not domain_slug.strip(): + raise ValueError("domain_slug is required") + if period_end < period_start: + raise ValueError("period_end cannot precede period_start") + if not source_evidence or any(not item.strip() for item in source_evidence): + raise ValueError("at least one non-empty source_evidence reference is required") + normalized_currency = currency_code(currency) + timestamp = generated_at or datetime.now(timezone.utc) + common = { + "domain_slug": domain_slug.strip(), + "resource_id": resource_id, + "service_id": service_id, + "environment": environment, + "currency": normalized_currency, + "period_start": period_start, + "period_end": period_end, + "source_evidence": list(source_evidence), + "generated_at": timestamp, + } + signals: list[FinancialConstraintSignal] = [] + + def add(kind: str, classification: str, summary: str, **values: object) -> None: + identity_values = tuple((name, values[name]) for name in sorted(values)) + signals.append( + FinancialConstraintSignal( + signal_id=_signal_id( + domain_slug, + period_start, + period_end, + normalized_currency, + resource_id, + service_id, + environment, + kind, + identity_values, + source_evidence, + ), + signal_kind=kind, + classification=classification, + summary=summary, + **common, + **values, + ) + ) + + ceiling = None if budget_ceiling is None else money(budget_ceiling, non_negative=True) + committed = ( + None if active_commitment is None else money(active_commitment, non_negative=True) + ) + spent_amount = None if spent is None else money(spent, non_negative=True) + burn = None if monthly_burn is None else money(monthly_burn, non_negative=True) + + if ceiling is not None: + add( + "budget_ceiling", + "policy_constraint", + "Applicable budget ceiling for the effective period.", + amount=ceiling, + ) + if committed is not None and committed > 0: + add( + "active_commitment", + "policy_constraint", + "Funds already committed during the effective period.", + amount=committed, + commitment_state="active", + ) + if ceiling is not None and spent_amount is not None and ceiling > 0: + utilisation = spent_amount / ceiling + add( + "burn_pressure", + "informational_warning" if utilisation >= Decimal("0.8") else "informational", + "Current spend as a ratio of the applicable budget ceiling.", + amount=spent_amount, + metric_value=utilisation, + metric_unit="ratio", + ) + if runway_months is not None: + months = Decimal(str(runway_months)) + if not months.is_finite() or months < 0: + raise ValueError("runway_months must be a finite non-negative decimal") + threshold = ( + None + if runway_threshold_months is None + else Decimal(str(runway_threshold_months)) + ) + if threshold is not None and (not threshold.is_finite() or threshold < 0): + raise ValueError( + "runway_threshold_months must be a finite non-negative decimal" + ) + under_pressure = threshold is not None and months < threshold + add( + "runway_pressure", + "informational_warning" if under_pressure else "informational", + "Projected runway compared with the fin-hub viability threshold.", + amount=burn, + metric_value=months, + metric_unit="months", + ) + + return FinancialConstraintExport( + schema_version="0.1", + artifact_type="financial_constraint_projection", + signals=tuple(signals), + disclaimer=( + "Bounded affordability signals only. Not payment authority, procurement " + "approval, a provider commitment, or a replacement for fin-hub calculations." + ), + ) + + def _ensure_planning_schema(conn: sqlite3.Connection) -> None: conn.execute( """ @@ -34,6 +210,18 @@ def _ensure_planning_schema(conn: sqlite3.Connection) -> None: "CREATE INDEX IF NOT EXISTS ix_planning_evidence_type_current " "ON planning_evidence (record_type, is_current)" ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS planning_evidence_rejections ( + payload_fingerprint TEXT PRIMARY KEY, + claimed_record_id TEXT, + claimed_record_type TEXT, + error_json TEXT NOT NULL, + delivery_count INTEGER NOT NULL DEFAULT 1, + last_received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) conn.commit() @@ -44,11 +232,32 @@ def ingest_planning_evidence( ) -> PlanningEvidence: """Validate and idempotently retain planning evidence outside booked spend.""" - record = _PLANNING_ADAPTER.validate_python(payload) - canonical = record.model_dump_json() ledger = ledger_path or default_ledger_path() with _connect(ledger) as conn: _ensure_planning_schema(conn) + try: + record = _PLANNING_ADAPTER.validate_python(payload) + except ValidationError as error: + payload_fingerprint = hashlib.sha256( + json.dumps(payload, sort_keys=True, default=str).encode() + ).hexdigest() + safe_errors = error.errors(include_url=False, include_input=False) + conn.execute( + "INSERT INTO planning_evidence_rejections " + "(payload_fingerprint, claimed_record_id, claimed_record_type, error_json) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(payload_fingerprint) DO UPDATE SET " + "delivery_count = delivery_count + 1, last_received_at = CURRENT_TIMESTAMP", + ( + payload_fingerprint, + str(payload.get("record_id", ""))[:256] or None, + str(payload.get("record_type", ""))[:64] or None, + json.dumps(safe_errors, sort_keys=True, default=str), + ), + ) + conn.commit() + raise + canonical = record.model_dump_json() conn.execute("BEGIN IMMEDIATE") existing = conn.execute( "SELECT payload_json FROM planning_evidence WHERE record_id = ?", @@ -81,7 +290,11 @@ def ingest_planning_evidence( return record -def booked_cost_projection(*, ledger_path: Path | None = None) -> list[BookedCostEvidence]: +def booked_cost_projection( + *, + ledger_path: Path | None = None, + fact_resource_ids: Mapping[str, str] | None = None, +) -> list[BookedCostEvidence]: """Project current authoritative facts without exposing raw invoice content.""" ledger = ledger_path or default_ledger_path() @@ -90,6 +303,16 @@ def booked_cost_projection(*, ledger_path: Path | None = None) -> list[BookedCos "SELECT * FROM ledger_entries WHERE is_current = 1 ORDER BY id" ).fetchall() projected: list[BookedCostEvidence] = [] + bindings = fact_resource_ids or {} + current_fact_ids = {row["financial_fact_id"] for row in rows} + unknown_bindings = set(bindings) - current_fact_ids + if unknown_bindings: + raise ValueError( + "resource bindings reference unknown current financial facts: " + + ", ".join(sorted(unknown_bindings)) + ) + if any(not value.strip() for value in bindings.values()): + raise ValueError("resource binding IDs must be non-empty") for row in rows: amount = minor_money(int(row["amount_minor"])) adjustment_kind = row["adjustment_kind"] @@ -126,6 +349,7 @@ def booked_cost_projection(*, ledger_path: Path | None = None) -> list[BookedCos gross_amount=gross_amount, adjustment_amount=adjustment_amount, effective_amount=amount, + resource_id=bindings.get(row["financial_fact_id"]), service_id=row["category"], environment=row["environment"], cost_attribution_key=row["cost_attribution_key"], @@ -136,6 +360,93 @@ def booked_cost_projection(*, ledger_path: Path | None = None) -> list[BookedCos return projected +def exchange_health( + *, + ledger_path: Path | None = None, + fact_resource_ids: Mapping[str, str] | None = None, + as_of: date | None = None, +) -> ExchangeHealthReport: + """Summarize actionable exchange-quality failures without raw payloads.""" + + ledger = ledger_path or default_ledger_path() + today = as_of or datetime.now(timezone.utc).date() + booked = booked_cost_projection( + ledger_path=ledger, fact_resource_ids=fact_resource_ids + ) + issues: list[ExchangeQualityIssue] = [] + for fact in booked: + if fact.resource_id is None and fact.cost_attribution_key is None: + issues.append( + ExchangeQualityIssue( + code="unattributed_booked_cost", + severity="warning", + record_id=fact.financial_fact_id, + detail=( + f"{fact.effective_amount} {fact.currency} has neither an external " + "resource binding nor a cost attribution key" + ), + ) + ) + + with _connect(ledger) as conn: + _ensure_planning_schema(conn) + planning_rows = conn.execute( + "SELECT record_id, record_type, payload_json FROM planning_evidence " + "WHERE is_current = 1 ORDER BY record_id" + ).fetchall() + rejection_rows = conn.execute( + "SELECT claimed_record_id, delivery_count FROM planning_evidence_rejections " + "ORDER BY claimed_record_id" + ).fetchall() + + for row in planning_rows: + payload = json.loads(row["payload_json"]) + if row["record_type"] == "forecast" and date.fromisoformat( + payload["period_end"] + ) < today: + issues.append( + ExchangeQualityIssue( + code="stale_forecast", + severity="warning", + record_id=row["record_id"], + detail=f"forecast period ended before {today.isoformat()}", + ) + ) + if row["record_type"] == "allocation": + known = {fact.financial_fact_id for fact in booked} + missing = sorted(set(payload["financial_fact_ids"]) - known) + if missing: + issues.append( + ExchangeQualityIssue( + code="allocation_missing_financial_fact", + severity="error", + record_id=row["record_id"], + detail="missing current facts: " + ", ".join(missing), + ) + ) + + for row in rejection_rows: + issues.append( + ExchangeQualityIssue( + code="rejected_planning_delivery", + severity="error", + record_id=row["claimed_record_id"], + detail=f"rejected delivery observed {row['delivery_count']} time(s)", + ) + ) + + return ExchangeHealthReport( + schema_version="0.1", + artifact_type="resource_cost_exchange_health", + booked_fact_count=len(booked), + current_planning_record_count=len(planning_rows), + rejected_delivery_count=sum(int(row["delivery_count"]) for row in rejection_rows), + issues=tuple( + sorted(issues, key=lambda issue: (issue.code, issue.record_id or "")) + ), + ) + + def ingest_resource_forecast( payload: dict, *, diff --git a/tests/test_exchange.py b/tests/test_exchange.py index 7487c46..10b9e82 100644 --- a/tests/test_exchange.py +++ b/tests/test_exchange.py @@ -13,6 +13,8 @@ from fin_hub.schemas.exchange import ( ) from fin_hub.services.exchange import ( booked_cost_projection, + exchange_health, + financial_constraint_projection, ingest_planning_evidence, ingest_resource_forecast, ) @@ -192,6 +194,33 @@ def test_booked_cost_projection_uses_current_corrected_fact(tmp_path: Path): assert records[0].environment == "production" +def test_hosteurope_fact_joins_external_resource_by_stable_fact_id(tmp_path: Path): + source = tmp_path / "hosteurope.csv" + ledger = tmp_path / "ledger.db" + source.write_text( + "product,amount,currency,invoice_date,environment\n" + "Server,49.00,EUR,2026-07-01,production\n", + encoding="utf-8", + ) + import_csv(source, "hosteurope", ledger_path=ledger) + fact = booked_cost_projection(ledger_path=ledger)[0] + + joined = booked_cost_projection( + ledger_path=ledger, + fact_resource_ids={ + fact.financial_fact_id: "resource:hosteurope:railiance01" + }, + ) + + assert joined[0].resource_id == "resource:hosteurope:railiance01" + assert joined[0].provider == "hosteurope" + with pytest.raises(ValueError, match="unknown current financial facts"): + booked_cost_projection( + ledger_path=ledger, + fact_resource_ids={"fact:unknown": "resource:hosteurope:railiance01"}, + ) + + def test_sqlalchemy_price_uses_shared_invariants(): price = EngagementPrice( client_id="acme", @@ -205,3 +234,110 @@ def test_sqlalchemy_price_uses_shared_invariants(): ) with pytest.raises(ValueError, match="does not match"): _validate_engagement_price(None, None, price) + + +def test_financial_constraints_are_bounded_typed_and_idempotent(): + kwargs = { + "domain_slug": "financials", + "period_start": date(2026, 8, 1), + "period_end": date(2026, 8, 31), + "currency": "eur", + "budget_ceiling": "100.00", + "active_commitment": "20.00", + "spent": "85.00", + "monthly_burn": "30.00", + "runway_months": "2.5", + "runway_threshold_months": "3", + "resource_id": "resource:hosteurope:railiance01", + "source_evidence": ("fin-hub:budget:2026-08", "fin-hub:runway:2026-08"), + "generated_at": datetime(2026, 8, 11, 13, 0, tzinfo=timezone.utc), + } + + first = financial_constraint_projection(**kwargs) + second = financial_constraint_projection(**kwargs) + + assert first == second + assert [signal.signal_kind for signal in first.signals] == [ + "budget_ceiling", + "active_commitment", + "burn_pressure", + "runway_pressure", + ] + assert first.signals[0].classification == "policy_constraint" + assert first.signals[2].classification == "informational_warning" + assert first.signals[3].metric_value == Decimal("2.5") + assert all(signal.currency == "EUR" for signal in first.signals) + assert "Not payment authority" in first.disclaimer + + +def test_financial_constraints_do_not_invent_missing_authoritative_values(): + export = financial_constraint_projection( + domain_slug="financials", + period_start=date(2026, 8, 1), + period_end=date(2026, 8, 31), + currency="EUR", + source_evidence=("fin-hub:budget:none",), + generated_at=datetime(2026, 8, 11, 13, 0, tzinfo=timezone.utc), + ) + + assert export.signals == () + + +def test_financial_constraint_rejects_invalid_period_or_unbounded_provenance(): + with pytest.raises(ValueError, match="period_end"): + financial_constraint_projection( + domain_slug="financials", + period_start=date(2026, 9, 1), + period_end=date(2026, 8, 31), + currency="EUR", + source_evidence=("fin-hub:budget",), + ) + with pytest.raises(ValueError, match="source_evidence"): + financial_constraint_projection( + domain_slug="financials", + period_start=date(2026, 8, 1), + period_end=date(2026, 8, 31), + currency="EUR", + source_evidence=(), + ) + + +def test_exchange_health_exposes_rejection_stale_forecast_and_unattributed_cost( + tmp_path: Path, +): + ledger = tmp_path / "ledger.db" + source = tmp_path / "hosteurope.csv" + source.write_text( + "product,amount,currency,invoice_date,environment\n" + "Shared service,10.00,EUR,2026-07-01,production\n", + encoding="utf-8", + ) + import_csv(source, "hosteurope", ledger_path=ledger) + stale = { + **_common_planning(), + "record_type": "forecast", + "record_id": "forecast:stale", + "period_start": "2026-01-01", + "period_end": "2026-01-31", + "scenario": "base", + "forecast_version": "v1", + "costs": {}, + "assumptions": [], + } + ingest_planning_evidence(stale, ledger_path=ledger) + invalid = {**stale, "record_id": "forecast:rejected", "currency": "EURO"} + with pytest.raises(ValidationError): + ingest_planning_evidence(invalid, ledger_path=ledger) + with pytest.raises(ValidationError): + ingest_planning_evidence(invalid, ledger_path=ledger) + + health = exchange_health(ledger_path=ledger, as_of=date(2026, 8, 11)) + + assert health.booked_fact_count == 1 + assert health.current_planning_record_count == 1 + assert health.rejected_delivery_count == 2 + assert {issue.code for issue in health.issues} == { + "rejected_planning_delivery", + "stale_forecast", + "unattributed_booked_cost", + } diff --git a/workplans/FIN-WP-0004-resource-cost-evidence-contract.md b/workplans/FIN-WP-0004-resource-cost-evidence-contract.md index 39458b8..c56542b 100644 --- a/workplans/FIN-WP-0004-resource-cost-evidence-contract.md +++ b/workplans/FIN-WP-0004-resource-cost-evidence-contract.md @@ -141,7 +141,7 @@ forecast into a temporary fin-hub ledger. ```task id: FIN-WP-0004-T04 -status: todo +status: done priority: medium state_hub_task_id: "3620fc2a-be3d-40aa-b357-d7741078c192" ``` @@ -158,6 +158,15 @@ an informational warning. Done when resource-control can evaluate affordability and commitment pressure without reproducing fin-hub's allocator or runway logic. +Completed 2026-08-11: added the v0.1 `FinancialConstraintSignal` and a +deterministic projection for budget ceiling, active commitment, budget +utilization, and runway pressure. Signals carry exact currency, effective +period, resource/service/environment scope, stable IDs, classification, +generation time, and non-secret provenance. Policy constraints are distinct +from informational warnings; unavailable authority values are omitted rather +than invented as zero. The artifact excludes credentials, private invoice +content, unrelated ledger rows, payment authority, and procurement approval. + ## T05 — Implement and reconcile the first round trip ```task @@ -193,7 +202,7 @@ the contract but do not satisfy the operational booked-cost round trip. ```task id: FIN-WP-0004-T06 -status: todo +status: done priority: medium state_hub_task_id: "a1309d51-32c3-4a9d-99f5-4dfb7abf4b3e" ``` @@ -206,6 +215,16 @@ unattributed booked cost, stale forecasts, and reconciliation failures. Done when the contract supports storage, compute or cluster capacity, and a shared-service allocation without changing the authority boundary. +Completed 2026-08-11: validated an externally bound Host Europe compute fact +for `resource:hosteurope:railiance01` and the existing shared-service +allocation path without deriving resource identity from provider labels or +filenames. Added a safe exchange-health report for rejected deliveries, +unattributed booked cost, stale forecasts, and allocations with missing +financial facts. Rejected payload content is not retained. Versioning, +compatibility, correction/revision, per-currency reconciliation, upgrade, +dual-run, and rollback procedures are documented in +`docs/resource-cost-exchange-operations.md`. + ## T07 — Correct cost-report dimensions and currency safety ```task @@ -284,16 +303,16 @@ current reporting-month basis. ## Acceptance -- [ ] One reviewed authority matrix is linked from both repositories. -- [ ] Booked financial facts remain authoritative only in fin-hub. -- [ ] Resource identity and technical allocation remain authoritative only in +- [x] One reviewed authority matrix is linked from both repositories. +- [x] Booked financial facts remain authoritative only in fin-hub. +- [x] Resource identity and technical allocation remain authoritative only in resource-control. -- [ ] Forecasts cannot be confused with invoices or booked expenditure. -- [ ] Exchange records carry stable identifiers, periods, provenance, and +- [x] Forecasts cannot be confused with invoices or booked expenditure. +- [x] Exchange records carry stable identifiers, periods, provenance, and correction semantics. - [ ] Backup completes a reconciled round trip with no double counting. -- [ ] At least one non-storage and one shared-service case validate the +- [x] At least one non-storage and one shared-service case validate the generalized contract. -- [ ] Cost reports never mix currency or environment dimensions. -- [ ] Booked-fact import and corrections are idempotent under changed delivery. -- [ ] SQLite and SQLAlchemy paths enforce the same money and price invariants. +- [x] Cost reports never mix currency or environment dimensions. +- [x] Booked-fact import and corrections are idempotent under changed delivery. +- [x] SQLite and SQLAlchemy paths enforce the same money and price invariants.