Advance resource cost evidence contract
This commit is contained in:
parent
d1528944c3
commit
49519a715a
7 changed files with 631 additions and 21 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
59
docs/resource-cost-exchange-operations.md
Normal file
59
docs/resource-cost-exchange-operations.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue