feat: re-check central RTC authorization right before every write (GET /v1/agents/me/authorizations), human labels on the cClassTrib control page, final corpus/scale evidence; 1.2.1

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-25 08:57:45 -03:00
co-authored by Claude Sonnet 5
parent 42dc172763
commit 7063c8c161
39 changed files with 4231 additions and 58 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gti-fiscal-agent"
version = "1.2.0"
version = "1.2.1"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"]
+29 -4
View File
@@ -106,7 +106,7 @@ class Config:
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB)))
self.agent_version = env.get("AGENT_VERSION", "1.2.0")
self.agent_version = env.get("AGENT_VERSION", "1.2.1")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
# Pilot write authorization: both must be explicitly set by the installer for
@@ -212,6 +212,31 @@ def _fiscal_blocked_products(products, findings):
return out
def _fetch_authorizations(config, job):
"""Current central authorization for this installation, straight from the Hub (authenticated). Tests replace this; production never trusts the job's copy for the write itself."""
return HubClient(config.hub_url, config.agent_token).authorizations()
def _make_authorizer(config, job, mode, approved_at_claim):
"""Re-checks the Hub right before writing (cached for a few seconds): if the mode changed, the product lost its approval, or the Hub cannot be reached, nothing is written (fail closed)."""
state = {"at": None, "auth": None}
def authorize(code):
now = time.monotonic()
if state["auth"] is None or now - state["at"] > 3.0:
try:
state["auth"], state["at"] = _fetch_authorizations(config, job) or {}, now
except Exception: # noqa: BLE001 - unverifiable authorization is not authorization
return "AUTHORIZATION_UNVERIFIABLE"
fresh = state["auth"]
if fresh.get("rtc_write_mode") != mode:
return "AUTHORIZATION_CHANGED"
if mode == "APPROVAL_REQUIRED" and str(code) not in {str(c) for c in fresh.get("rtc_approved_codes") or []}:
return "AUTHORIZATION_CHANGED"
return None
return authorize
def _rtc_auto_apply(env, config, job, products, fiscal, company):
"""Preview (always) and, only when the Hub-delivered mode permits it, fill CCLASSTRIB for products whose class is PROVEN by an AUTO_APPLY_SAFE rule; also executes
Hub-requested rollbacks. Authorization arrives ONLY inside the authenticated job (`authorizations`); there is no local switch. Never raises for a single product."""
@@ -244,7 +269,7 @@ def _rtc_auto_apply(env, config, job, products, fiscal, company):
if rollbacks:
summary["rollbacks"] = _rtc_apply.rollback_records(connection, rollbacks, now=now, job_id=job.get("job_id"))["rollbacks"]
if gate["allowed"] and wanted:
outcome = _rtc_apply.apply_candidates(connection, wanted, company=company or {}, decision={"local_catalog_hash": fiscal["context"].get("local_catalog_hash"), "origin": f"HUB_MODE:{mode}"}, now=now, job_id=job.get("job_id"))
outcome = _rtc_apply.apply_candidates(connection, wanted, company=company or {}, decision={"local_catalog_hash": fiscal["context"].get("local_catalog_hash"), "origin": f"HUB_MODE:{mode}"}, now=now, job_id=job.get("job_id"), authorize=_make_authorizer(config, job, mode, approved))
summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")})
finally:
connection.close()
@@ -323,7 +348,7 @@ def execute_job(job, config, executor, state, env):
"fiscal_context": fiscal_context,
"rtc_auto_apply": rtc_apply,
"fiscal_mode": getattr(config, "fiscal_mode", None),
"release_version": env.get("AGENT_VERSION") or "1.2.0",
"release_version": env.get("AGENT_VERSION") or "1.2.1",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
@@ -481,7 +506,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
def enforce_startup_preflight(env):
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
from gti_fiscal_agent.preflight import run_preflight
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.2.0")
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.2.1")
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
if report["PREFLIGHT"] != "PASS":
+1
View File
@@ -21,6 +21,7 @@ class HubClient:
response = httpx.get(f"{self.url}{path}", headers=self._headers(), timeout=30)
response.raise_for_status(); return response.json()
def identity(self): return self.get_json("/v1/agents/me/identity")
def authorizations(self): return self.get_json("/v1/agents/me/authorizations")
def health(self):
response = httpx.get(f"{self.url}/health", timeout=10)
return response.status_code == 200
+1 -1
View File
@@ -9,7 +9,7 @@ import os
import sqlite3
from pathlib import Path
AGENT_VERSION_DEFAULT = "1.2.0"
AGENT_VERSION_DEFAULT = "1.2.1"
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
+10 -2
View File
@@ -147,10 +147,18 @@ def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now
raise
def apply_candidates(conn, candidates: list[dict], *, company: dict, decision: dict, now: str, job_id=None, today: date | None = None) -> dict:
"""Applies each candidate independently; a failure or an abort never stops the rest and never leaves a partial change."""
def apply_candidates(conn, candidates: list[dict], *, company: dict, decision: dict, now: str, job_id=None, today: date | None = None, authorize=None) -> dict:
"""Applies each candidate independently; a failure or an abort never stops the rest and never leaves a partial change.
`authorize(product_code)` (optional) re-asks the Hub, right before each UPDATE, whether the central authorization still permits it: it returns None to proceed or a
reason (AUTHORIZATION_CHANGED / AUTHORIZATION_UNVERIFIABLE) to skip the product with zero writes."""
applied, aborted, reasons = [], [], {}
for candidate in candidates:
if authorize is not None:
denied = authorize(candidate["product_code"])
if denied:
aborted.append({"outcome": "ABORTED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": denied, "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now, "action": "APPLY", "authorization_origin": decision.get("origin")})
reasons[denied] = reasons.get(denied, 0) + 1
continue
try:
applied.append(apply_candidate(conn, candidate, company=company, decision=decision, now=now, job_id=job_id, today=today))
except RtcApplyAborted as stop:
+38
View File
@@ -254,6 +254,16 @@ def test_catalog_hash_is_recorded_for_audit():
# ---------------------------------------------------------------- job integration (gates)
import pytest # noqa: E402
@pytest.fixture(autouse=True)
def _hub_echoes_the_job_authorization(monkeypatch):
"""By default the Hub still says what the job said; individual tests change that to prove the pre-write re-check."""
from test_run_agent_service import service
monkeypatch.setattr(service, "_fetch_authorizations", lambda config, job: dict(job.get("authorizations") or {}))
def _service():
from test_run_agent_service import _config, service
return service, _config
@@ -399,3 +409,31 @@ def test_connection_is_closed_even_when_a_product_fails_mid_batch(monkeypatch):
monkeypatch.setattr(service, "open_write_connection", lambda env, path: conn)
summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
assert closed == [True] and summary["write_successes"] == 2 and summary["aborted_reasons"]
def test_authorization_revoked_after_the_job_was_claimed_means_zero_writes(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db))
monkeypatch.setattr(service, "_fetch_authorizations", lambda config, job: {"rtc_write_mode": "DISABLED"}) # operator switched it off while the job was running
summary = service._rtc_auto_apply({}, _config(), {"job_id": 5, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
assert summary["write_successes"] == 0 and summary["aborted_reasons"] == {"AUTHORIZATION_CHANGED": 3}
assert [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == [None, None, None]
def test_unreachable_hub_is_not_authorization_and_approval_removal_is_honoured(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db))
def down(config, job):
raise ConnectionError("hub down")
monkeypatch.setattr(service, "_fetch_authorizations", down)
summary = service._rtc_auto_apply({}, _config(), {"job_id": 6, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
assert summary["write_successes"] == 0 and summary["aborted_reasons"] == {"AUTHORIZATION_UNVERIFIABLE": 3}
monkeypatch.setattr(service, "_fetch_authorizations", lambda config, job: {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": ["000001"]}) # 000002 was un-approved meanwhile
summary = service._rtc_auto_apply({}, _config(), {"job_id": 7, "authorizations": {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": ["000001", "000002"]}}, _products_of(db), fiscal, NORMAL)
assert summary["write_successes"] == 1 and db.products["000001"]["CCLASSTRIB"] == "200003" and db.products["000002"]["CCLASSTRIB"] is None