feat: ingest State Hub session tokens without booking them

Store mixed measured/estimated/superseded aggregates as session-token
evidence. Map kinds onto the evidence-basis vocabulary, keep unknown
months unknown, and refuse to post the snapshot as booked spend or
resource-control usage.
This commit is contained in:
tegwick 2026-08-15 19:21:41 +02:00
parent 22b21be552
commit d343888a41
10 changed files with 578 additions and 1 deletions

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import json
import sys
from datetime import date
from pathlib import Path
from fin_hub.coupling.canon import emit_viability_alert
@ -18,6 +19,11 @@ from fin_hub.services.alerts import evaluate_budget_alerts
from fin_hub.services.allocation import shared_cost_allocations
from fin_hub.services.billing import build_billing_basis
from fin_hub.services.evaluate import evaluate_runway
from fin_hub.services.session_tokens import (
ingest_session_token_file,
list_current_session_token_evidence,
summarize_session_token_evidence,
)
from fin_hub.services.evidence import write_runway_evidence
from fin_hub.services.ledger import (
client_margin_report,
@ -204,6 +210,27 @@ def _cmd_ledger_plan_month(args: argparse.Namespace) -> int:
return 0
def _cmd_ledger_ingest_session_tokens(args: argparse.Namespace) -> int:
record = ingest_session_token_file(Path(args.path), ledger_path=_ledger_path(args))
print(json.dumps(summarize_session_token_evidence(record).as_dict(), indent=2))
return 0
def _cmd_ledger_session_tokens(args: argparse.Namespace) -> int:
period_start = date.fromisoformat(f"{args.period}-01") if args.period else None
records = list_current_session_token_evidence(
ledger_path=_ledger_path(args),
period_start=period_start,
)
print(
json.dumps(
[summarize_session_token_evidence(record).as_dict() for record in records],
indent=2,
)
)
return 0
def _cmd_ledger_allocations(args: argparse.Namespace) -> int:
reports = shared_cost_allocations(ledger_path=_ledger_path(args))
print(json.dumps([report.as_dict() for report in reports], indent=2, default=str))
@ -380,6 +407,22 @@ def build_parser() -> argparse.ArgumentParser:
ledger_plan_month.add_argument("--ledger", help="Ledger database path")
ledger_plan_month.set_defaults(func=_cmd_ledger_plan_month)
ledger_ingest_tokens = ledger_sub.add_parser(
"ingest-session-tokens",
help="Ingest a State Hub token-aggregate snapshot (not booked spend)",
)
ledger_ingest_tokens.add_argument("path")
ledger_ingest_tokens.add_argument("--ledger", help="Ledger database path")
ledger_ingest_tokens.set_defaults(func=_cmd_ledger_ingest_session_tokens)
ledger_session_tokens = ledger_sub.add_parser(
"session-tokens",
help="Summarize current session-token evidence by measurement basis",
)
ledger_session_tokens.add_argument("--period", help="Period start month in YYYY-MM")
ledger_session_tokens.add_argument("--ledger", help="Ledger database path")
ledger_session_tokens.set_defaults(func=_cmd_ledger_session_tokens)
ledger_allocations = ledger_sub.add_parser(
"allocations",
help="Reconcile resource-control allocation evidence to booked facts",

View file

@ -0,0 +1,87 @@
"""Session-token evidence ingested from State Hub aggregates."""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
MEASUREMENT_KINDS = ("measured", "allocated", "estimated", "superseded")
EVIDENCE_BASIS = {
"measured": "measured",
"allocated": "derived",
"estimated": "estimated",
"superseded": "excluded",
}
class SessionTokenSlice(BaseModel):
model_config = ConfigDict(extra="forbid")
tokens_in: int = Field(ge=0)
tokens_out: int = Field(ge=0)
event_count: int = Field(ge=0)
confidence: Decimal | None = None
method: str | None = None
@field_validator("confidence")
@classmethod
def validate_confidence(cls, value: Decimal | None) -> Decimal | None:
if value is None:
return None
if not value.is_finite() or value < 0 or value > 1:
raise ValueError("confidence must be between 0 and 1")
return value
class SessionTokenAssociation(BaseModel):
model_config = ConfigDict(extra="forbid")
scope: Literal["repo", "workplan", "model"]
scope_id: str = Field(min_length=1, max_length=256)
label: str | None = None
measurement_kind: Literal["measured", "allocated", "estimated", "superseded"]
tokens_in: int = Field(ge=0)
tokens_out: int = Field(ge=0)
event_count: int = Field(ge=0, default=1)
class SessionTokenEvidence(BaseModel):
"""State Hub session-token snapshot. Not booked spend and not usage_observation."""
model_config = ConfigDict(extra="forbid")
schema_version: Literal["0.1"] = "0.1"
record_type: Literal["session_token_evidence"] = "session_token_evidence"
record_id: str = Field(min_length=1, max_length=256)
revision_of: str | None = None
source_system: Literal["state-hub"] = "state-hub"
period_start: date
period_end: date
captured_at: datetime
source_evidence: list[str] = Field(min_length=1)
totals: dict[str, SessionTokenSlice] = Field(default_factory=dict)
associations: list[SessionTokenAssociation] = Field(default_factory=list)
@field_validator("totals")
@classmethod
def validate_totals(cls, value: dict[str, SessionTokenSlice]) -> dict[str, SessionTokenSlice]:
unknown = set(value) - set(MEASUREMENT_KINDS)
if unknown:
raise ValueError(f"unknown measurement_kind: {', '.join(sorted(unknown))}")
return value
@model_validator(mode="after")
def validate_period_and_methods(self) -> "SessionTokenEvidence":
if self.period_end < self.period_start:
raise ValueError("period_end cannot precede period_start")
allocated = self.totals.get("allocated")
if (
allocated is not None
and allocated.event_count > 0
and not (allocated.method and allocated.method.strip())
):
raise ValueError("allocated totals with events require method")
return self

View file

@ -232,6 +232,10 @@ def ingest_planning_evidence(
) -> PlanningEvidence:
"""Validate and idempotently retain planning evidence outside booked spend."""
if payload.get("record_type") == "session_token_evidence":
raise ValueError(
"session_token_evidence is not resource-control planning evidence"
)
ledger = ledger_path or default_ledger_path()
with _connect(ledger) as conn:
_ensure_planning_schema(conn)

View file

@ -275,6 +275,17 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
recorded_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS session_token_evidence (
record_id TEXT PRIMARY KEY,
revision_of TEXT,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
source_system TEXT NOT NULL,
payload_json TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
recorded_at TEXT NOT NULL
);
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")}

View file

@ -0,0 +1,239 @@
"""Store State Hub session-token aggregates outside booked spend and RC usage."""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from datetime import date
from decimal import Decimal
from pathlib import Path
from pydantic import ValidationError
from fin_hub.schemas.session_tokens import EVIDENCE_BASIS, SessionTokenEvidence
from fin_hub.services.ledger import _connect, _utc_now, default_ledger_path
def _ensure_session_token_schema(conn) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS session_token_evidence (
record_id TEXT PRIMARY KEY,
revision_of TEXT,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
source_system TEXT NOT NULL,
payload_json TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
recorded_at TEXT NOT NULL
)
"""
)
conn.commit()
def ingest_session_token_evidence(
payload: dict,
*,
ledger_path: Path | None = None,
) -> SessionTokenEvidence:
"""Retain a State Hub aggregate snapshot. Never a booked fact or RC usage row."""
if payload.get("record_type") == "usage_observation":
raise ValueError(
"usage_observation is resource-control planning evidence; "
"session tokens use ingest_session_token_evidence"
)
if payload.get("record_type") == "booked_cost":
raise ValueError("session token evidence cannot be posted as booked spend")
try:
record = SessionTokenEvidence.model_validate(payload)
except ValidationError as error:
raise ValueError(error.errors(include_url=False, include_input=False)) from error
ledger = ledger_path or default_ledger_path()
canonical = record.model_dump_json()
with _connect(ledger) as conn:
_ensure_session_token_schema(conn)
conn.execute("BEGIN IMMEDIATE")
existing = conn.execute(
"SELECT payload_json FROM session_token_evidence WHERE record_id = ?",
(record.record_id,),
).fetchone()
if existing is not None:
if json.loads(existing["payload_json"]) != json.loads(canonical):
raise ValueError("record_id already exists with different content")
return record
if record.revision_of is not None:
predecessor = conn.execute(
"SELECT is_current FROM session_token_evidence WHERE record_id = ?",
(record.revision_of,),
).fetchone()
if predecessor is None:
raise ValueError("revision_of must reference an existing session-token record")
if predecessor["is_current"] != 1:
raise ValueError("revision_of must reference the current record")
conn.execute(
"UPDATE session_token_evidence SET is_current = 0 WHERE record_id = ?",
(record.revision_of,),
)
conn.execute(
"""
INSERT INTO session_token_evidence (
record_id, revision_of, period_start, period_end, source_system,
payload_json, is_current, recorded_at
) VALUES (?, ?, ?, ?, ?, ?, 1, ?)
""",
(
record.record_id,
record.revision_of,
record.period_start.isoformat(),
record.period_end.isoformat(),
record.source_system,
canonical,
_utc_now(),
),
)
conn.commit()
return record
def list_current_session_token_evidence(
*,
ledger_path: Path | None = None,
period_start: date | None = None,
period_end: date | None = None,
) -> list[SessionTokenEvidence]:
ledger = ledger_path or default_ledger_path()
query = "SELECT payload_json FROM session_token_evidence WHERE is_current = 1"
params: list[object] = []
if period_start is not None:
query += " AND period_start = ?"
params.append(period_start.isoformat())
if period_end is not None:
query += " AND period_end = ?"
params.append(period_end.isoformat())
query += " ORDER BY period_start, record_id"
with _connect(ledger) as conn:
_ensure_session_token_schema(conn)
rows = conn.execute(query, params).fetchall()
return [SessionTokenEvidence.model_validate_json(row["payload_json"]) for row in rows]
@dataclass(frozen=True)
class SessionTokenKindTotal:
basis: str
tokens_in: int | None
tokens_out: int | None
event_count: int
confidence: Decimal | None
method: str | None
def as_dict(self) -> dict:
payload = asdict(self)
payload["confidence"] = None if self.confidence is None else str(self.confidence)
return payload
@dataclass(frozen=True)
class SessionTokenSummary:
record_id: str
period_start: date
period_end: date
source_system: str
unknown_residual: bool
by_basis: dict[str, SessionTokenKindTotal]
tokens_measured: int | None
tokens_derived: int | None
tokens_estimated: int | None
coverage: Decimal | None
def as_dict(self) -> dict:
return {
"record_id": self.record_id,
"period_start": self.period_start.isoformat(),
"period_end": self.period_end.isoformat(),
"source_system": self.source_system,
"unknown_residual": self.unknown_residual,
"by_basis": {key: value.as_dict() for key, value in self.by_basis.items()},
"tokens_measured": self.tokens_measured,
"tokens_derived": self.tokens_derived,
"tokens_estimated": self.tokens_estimated,
"coverage": None if self.coverage is None else str(self.coverage),
}
def _slice_total(record: SessionTokenEvidence, kind: str) -> SessionTokenKindTotal | None:
slice_ = record.totals.get(kind)
if slice_ is None or slice_.event_count == 0:
return None
return SessionTokenKindTotal(
basis=EVIDENCE_BASIS[kind],
tokens_in=slice_.tokens_in,
tokens_out=slice_.tokens_out,
event_count=slice_.event_count,
confidence=slice_.confidence,
method=slice_.method,
)
def summarize_session_token_evidence(record: SessionTokenEvidence) -> SessionTokenSummary:
by_basis: dict[str, SessionTokenKindTotal] = {}
for kind in ("measured", "allocated", "estimated"):
total = _slice_total(record, kind)
if total is not None:
by_basis[total.basis] = total
measured = by_basis.get("measured")
derived = by_basis.get("derived")
estimated = by_basis.get("estimated")
unknown = not by_basis
def _tokens(total: SessionTokenKindTotal | None) -> int | None:
if total is None:
return None
return total.tokens_in + total.tokens_out
tokens_measured = _tokens(measured)
tokens_derived = _tokens(derived)
tokens_estimated = _tokens(estimated)
known = [
value
for value in (tokens_measured, tokens_derived, tokens_estimated)
if value is not None
]
coverage = None
if known:
denominator = sum(known)
coverage = (
Decimal(tokens_measured or 0) / Decimal(denominator)
if denominator
else Decimal("0")
)
return SessionTokenSummary(
record_id=record.record_id,
period_start=record.period_start,
period_end=record.period_end,
source_system=record.source_system,
unknown_residual=unknown,
by_basis=by_basis,
tokens_measured=tokens_measured,
tokens_derived=tokens_derived,
tokens_estimated=tokens_estimated,
coverage=coverage,
)
def ingest_session_token_file(path: Path, *, ledger_path: Path | None = None) -> SessionTokenEvidence:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("session token snapshot must be a JSON object")
return ingest_session_token_evidence(payload, ledger_path=ledger_path)
__all__ = [
"SessionTokenKindTotal",
"SessionTokenSummary",
"ingest_session_token_evidence",
"ingest_session_token_file",
"list_current_session_token_evidence",
"summarize_session_token_evidence",
]