Advance resource cost evidence contract

This commit is contained in:
tegwick 2026-08-11 15:49:16 +02:00
parent d1528944c3
commit 49519a715a
7 changed files with 631 additions and 21 deletions

View file

@ -13,7 +13,7 @@
| workplan | FIN-WP-0002 | finished | — | workplans/FIN-WP-0002-client-attribution-and-billing-basis.md | | 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-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-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-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-T02 | done | — | workplans/FIN-WP-0000-repo-integration.md |
| task | FIN-WP-0000-T03 | 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-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-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-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-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-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-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 | | task | FIN-WP-0004-T09 | done | — | workplans/FIN-WP-0004-resource-cost-evidence-contract.md |

View file

@ -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 reference the current record of the same type. Fin-hub stores these in
`planning_evidence`, never in its booked-cost ledger. `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 ## Reconciliation invariants
1. Each booked financial fact has exactly one authoritative fin-hub record. 1. Each booked financial fact has exactly one authoritative fin-hub record.

View file

@ -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.

View file

@ -227,6 +227,73 @@ class CommitmentCandidate(MonetaryRecord):
return money(value, non_negative=True) 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[ PlanningEvidence = Annotated[
ForecastEvidence ForecastEvidence
| UsageObservation | UsageObservation

View file

@ -3,20 +3,196 @@
from __future__ import annotations from __future__ import annotations
import json import json
import hashlib
import sqlite3 import sqlite3
from calendar import monthrange 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 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.money import currency_code, minor_money, money
from fin_hub.schemas.exchange import BookedCostEvidence, PlanningEvidence from fin_hub.schemas.exchange import (
BookedCostEvidence,
FinancialConstraintSignal,
PlanningEvidence,
)
from fin_hub.services.ledger import _connect, default_ledger_path from fin_hub.services.ledger import _connect, default_ledger_path
_PLANNING_ADAPTER = TypeAdapter(PlanningEvidence) _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: def _ensure_planning_schema(conn: sqlite3.Connection) -> None:
conn.execute( conn.execute(
""" """
@ -34,6 +210,18 @@ def _ensure_planning_schema(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_planning_evidence_type_current " "CREATE INDEX IF NOT EXISTS ix_planning_evidence_type_current "
"ON planning_evidence (record_type, is_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() conn.commit()
@ -44,11 +232,32 @@ def ingest_planning_evidence(
) -> PlanningEvidence: ) -> PlanningEvidence:
"""Validate and idempotently retain planning evidence outside booked spend.""" """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() ledger = ledger_path or default_ledger_path()
with _connect(ledger) as conn: with _connect(ledger) as conn:
_ensure_planning_schema(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") conn.execute("BEGIN IMMEDIATE")
existing = conn.execute( existing = conn.execute(
"SELECT payload_json FROM planning_evidence WHERE record_id = ?", "SELECT payload_json FROM planning_evidence WHERE record_id = ?",
@ -81,7 +290,11 @@ def ingest_planning_evidence(
return record 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.""" """Project current authoritative facts without exposing raw invoice content."""
ledger = ledger_path or default_ledger_path() 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" "SELECT * FROM ledger_entries WHERE is_current = 1 ORDER BY id"
).fetchall() ).fetchall()
projected: list[BookedCostEvidence] = [] 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: for row in rows:
amount = minor_money(int(row["amount_minor"])) amount = minor_money(int(row["amount_minor"]))
adjustment_kind = row["adjustment_kind"] adjustment_kind = row["adjustment_kind"]
@ -126,6 +349,7 @@ def booked_cost_projection(*, ledger_path: Path | None = None) -> list[BookedCos
gross_amount=gross_amount, gross_amount=gross_amount,
adjustment_amount=adjustment_amount, adjustment_amount=adjustment_amount,
effective_amount=amount, effective_amount=amount,
resource_id=bindings.get(row["financial_fact_id"]),
service_id=row["category"], service_id=row["category"],
environment=row["environment"], environment=row["environment"],
cost_attribution_key=row["cost_attribution_key"], cost_attribution_key=row["cost_attribution_key"],
@ -136,6 +360,93 @@ def booked_cost_projection(*, ledger_path: Path | None = None) -> list[BookedCos
return projected 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( def ingest_resource_forecast(
payload: dict, payload: dict,
*, *,

View file

@ -13,6 +13,8 @@ from fin_hub.schemas.exchange import (
) )
from fin_hub.services.exchange import ( from fin_hub.services.exchange import (
booked_cost_projection, booked_cost_projection,
exchange_health,
financial_constraint_projection,
ingest_planning_evidence, ingest_planning_evidence,
ingest_resource_forecast, ingest_resource_forecast,
) )
@ -192,6 +194,33 @@ def test_booked_cost_projection_uses_current_corrected_fact(tmp_path: Path):
assert records[0].environment == "production" 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(): def test_sqlalchemy_price_uses_shared_invariants():
price = EngagementPrice( price = EngagementPrice(
client_id="acme", client_id="acme",
@ -205,3 +234,110 @@ def test_sqlalchemy_price_uses_shared_invariants():
) )
with pytest.raises(ValueError, match="does not match"): with pytest.raises(ValueError, match="does not match"):
_validate_engagement_price(None, None, price) _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",
}

View file

@ -141,7 +141,7 @@ forecast into a temporary fin-hub ledger.
```task ```task
id: FIN-WP-0004-T04 id: FIN-WP-0004-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "3620fc2a-be3d-40aa-b357-d7741078c192" 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 Done when resource-control can evaluate affordability and commitment pressure
without reproducing fin-hub's allocator or runway logic. 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 ## T05 — Implement and reconcile the first round trip
```task ```task
@ -193,7 +202,7 @@ the contract but do not satisfy the operational booked-cost round trip.
```task ```task
id: FIN-WP-0004-T06 id: FIN-WP-0004-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "a1309d51-32c3-4a9d-99f5-4dfb7abf4b3e" 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 Done when the contract supports storage, compute or cluster capacity, and a
shared-service allocation without changing the authority boundary. 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 ## T07 — Correct cost-report dimensions and currency safety
```task ```task
@ -284,16 +303,16 @@ current reporting-month basis.
## Acceptance ## Acceptance
- [ ] One reviewed authority matrix is linked from both repositories. - [x] One reviewed authority matrix is linked from both repositories.
- [ ] Booked financial facts remain authoritative only in fin-hub. - [x] Booked financial facts remain authoritative only in fin-hub.
- [ ] Resource identity and technical allocation remain authoritative only in - [x] Resource identity and technical allocation remain authoritative only in
resource-control. resource-control.
- [ ] Forecasts cannot be confused with invoices or booked expenditure. - [x] Forecasts cannot be confused with invoices or booked expenditure.
- [ ] Exchange records carry stable identifiers, periods, provenance, and - [x] Exchange records carry stable identifiers, periods, provenance, and
correction semantics. correction semantics.
- [ ] Backup completes a reconciled round trip with no double counting. - [ ] 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. generalized contract.
- [ ] Cost reports never mix currency or environment dimensions. - [x] Cost reports never mix currency or environment dimensions.
- [ ] Booked-fact import and corrections are idempotent under changed delivery. - [x] Booked-fact import and corrections are idempotent under changed delivery.
- [ ] SQLite and SQLAlchemy paths enforce the same money and price invariants. - [x] SQLite and SQLAlchemy paths enforce the same money and price invariants.