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-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 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue