feat: central RTC write modes (DISABLED/DRY_RUN/APPROVAL/ENABLED), R4 suggestion-only, preview/approval, per-record rollback, bulk blocker upsert; 1.2.0

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 22:46:05 -03:00
co-authored by Claude Sonnet 5
parent 3c10422e81
commit 42dc172763
50 changed files with 2948 additions and 915 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import os
import sqlite3
from pathlib import Path
AGENT_VERSION_DEFAULT = "1.1.9"
AGENT_VERSION_DEFAULT = "1.2.0"
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")
+84 -14
View File
@@ -1,23 +1,26 @@
"""RTC auto-apply: fills C000025.CCLASSTRIB, and NOTHING else, when a deterministic rule proves the class.
Safety model (every gate must hold, default is OFF everywhere):
FIREBIRD_WRITES effective (FISCAL_MODE=AUTO_APPLY + path/schema authorization) AND RTC_AUTO_APPLY_ENABLED (this Agent) AND Hub authorization (per installation)
Write allowlist: ALLOWED_RTC_WRITE_FIELDS = ["CCLASSTRIB"]. One short transaction per product. Immediately before the UPDATE the product is re-read and
the whole decision is re-derived from fresh data; if ANYTHING changed (RTC switch, catalog, NCM, current value, decision) that product is ABORTED.
An existing value is never overwritten. Every applied change returns an audit record with the old value, so it can be reverted manually and auditably.
Safety model (defense in depth, every gate must hold, default is OFF): the Hub sets a per-installation mode DISABLED / DRY_RUN / APPROVAL_REQUIRED / ENABLED and
delivers it inside an authenticated job. The Agent only exposes the restricted capability RTC_CCLASSTRIB_WRITE_V1; there is no local switch to edit (no agent.env, no FIREBIRD_WRITES).
Write allowlist: ALLOWED_RTC_WRITE_FIELDS = ["CCLASSTRIB"] on C000025 (no free SQL exists). One short transaction per product. Immediately before the UPDATE the product,
company, RTC parameter and local catalog are re-read and the whole decision is re-derived; if ANYTHING changed that product is ABORTED and nothing is written.
An existing value is never overwritten. Every applied change returns an audit record with the old value; ROLLBACK restores it only if the value still equals what was written.
"""
from __future__ import annotations
import re
from datetime import date
from central.app.cclasstrib_resolver import compare_catalogs, local_catalog_hash, resolve_cclasstrib
from central.app.cclasstrib_resolver import RULE_VERSION, compare_catalogs, local_catalog_hash, resolve_cclasstrib
from central.app.unified_fiscal_coverage import rtc_enabled
from .unified_fiscal import detect_rtc_capabilities, load_rtc_context
ALLOWED_RTC_WRITE_FIELDS = ("CCLASSTRIB",)
DEFAULT_MAX_PER_JOB = 500
CAPABILITY = "RTC_CCLASSTRIB_WRITE_V1"
MODES = ("DISABLED", "DRY_RUN", "APPROVAL_REQUIRED", "ENABLED")
MAX_PREVIEW_CANDIDATES = 5000
class RtcApplyAborted(Exception):
@@ -36,10 +39,22 @@ def _digits(value) -> str:
return re.sub(r"\D", "", _text(value))
def write_gate(*, firebird_writes: bool, agent_enabled: bool, hub_authorized: bool) -> dict:
"""{"allowed": bool, "missing": [...]}: writes are possible only when all three switches are on."""
missing = [name for name, on in (("FIREBIRD_WRITES", firebird_writes), ("RTC_AUTO_APPLY_ENABLED", agent_enabled), ("HUB_AUTHORIZATION", hub_authorized)) if not on]
return {"allowed": not missing, "missing": missing, "firebird_writes": bool(firebird_writes), "agent_enabled": bool(agent_enabled), "hub_authorized": bool(hub_authorized)}
def write_gate(*, mode: str, capability: bool = True, approved: bool = True) -> dict:
"""{"allowed": bool, "missing": [...]}: a real write needs the capability, an authenticated Hub mode that permits writing (ENABLED, or APPROVAL_REQUIRED for approved products)."""
mode = mode if mode in MODES else "DISABLED"
missing = []
if not capability:
missing.append("CAPABILITY")
if mode not in ("ENABLED", "APPROVAL_REQUIRED"):
missing.append("HUB_MODE_" + mode)
elif mode == "APPROVAL_REQUIRED" and not approved:
missing.append("HUB_APPROVAL")
return {"allowed": not missing, "missing": missing, "mode": mode, "capability": bool(capability)}
def candidate_fingerprint(code, ncm, description, current) -> str:
import hashlib
return hashlib.sha256("|".join((_text(code), _digits(ncm), _text(description), _text(current))).encode("utf-8")).hexdigest()[:24]
def select_candidates(findings: dict, products: list[dict], *, limit: int = DEFAULT_MAX_PER_JOB) -> tuple[list[dict], int]:
@@ -56,7 +71,8 @@ def select_candidates(findings: dict, products: list[dict], *, limit: int = DEFA
p = by_code.get(code, {})
if _text(p.get("CCLASSTRIB")):
continue
ready.append({"product_code": code, "proposed": res["resolved_cclasstrib"], "cst": res["resolved_cst_ibscbs"], "rule_id": res["rule_id"], "ncm": _digits(p.get("CLASSIFICACAO_FISCAL")), "confidence_type": res["confidence_type"]})
ready.append({"product_code": code, "proposed": res["resolved_cclasstrib"], "cst": res["resolved_cst_ibscbs"], "rule_id": res["rule_id"], "ncm": _digits(p.get("CLASSIFICACAO_FISCAL")), "confidence_type": res["confidence_type"], "description": _text(p.get("PRODUTO"))[:255], "current": "", "basis": f'{res["rule_id"]}:{res["confidence_type"]}',
"fingerprint": candidate_fingerprint(code, p.get("CLASSIFICACAO_FISCAL"), p.get("PRODUTO"), "")})
return ready[:limit], len(ready)
@@ -78,7 +94,14 @@ def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now
fresh_hash = local_catalog_hash(local_rows)
if decision.get("local_catalog_hash") and fresh_hash != decision["local_catalog_hash"]:
raise RtcApplyAborted("LOCAL_CATALOG_CHANGED_SINCE_DECISION")
cur.execute(f"SELECT {field}, CLASSIFICACAO_FISCAL FROM C000025 WHERE CODIGO = ?", (code,))
cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004")
crts = {_text(r[0]) for r in cur.fetchall()}
expected_crt = _text((company or {}).get("crt"))
if not expected_crt or crts != {expected_crt}:
raise RtcApplyAborted("COMPANY_CONTEXT_CHANGED")
if expected_crt != "3":
raise RtcApplyAborted("REGIME_NOT_NORMAL")
cur.execute(f"SELECT {field}, CLASSIFICACAO_FISCAL, PRODUTO FROM C000025 WHERE CODIGO = ?", (code,))
rows = cur.fetchall()
if len(rows) != 1:
raise RtcApplyAborted("PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND")
@@ -87,6 +110,8 @@ def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now
raise RtcApplyAborted("VALUE_ALREADY_SET_NEVER_OVERWRITTEN" if current != proposed else "NO_WRITE_REQUIRED")
if ncm != candidate["ncm"]:
raise RtcApplyAborted("PRODUCT_CHANGED_NCM")
if candidate.get("fingerprint") and candidate_fingerprint(code, ncm, rows[0][2], current) != candidate["fingerprint"]:
raise RtcApplyAborted("PRODUCT_CHANGED_SINCE_DECISION")
compat = compare_catalogs(local_rows, today)
resolution = resolve_cclasstrib({"CODIGO": code, "CLASSIFICACAO_FISCAL": ncm, "ncm_valid": True}, company, {"rtc_enabled": True, "local_catalog_valid": True}, local_rows, compat=compat, today=today)
if not resolution.auto_apply_candidate or resolution.resolved_cclasstrib != proposed or resolution.rule_id != candidate["rule_id"]:
@@ -106,7 +131,8 @@ def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now
return {"outcome": "APPLIED", "product_code": code, "field": field, "old_value": None, "new_value": proposed, "cst_ibscbs": resolution.resolved_cst_ibscbs, "rule_id": resolution.rule_id,
"confidence_type": resolution.confidence_type, "evidence": {"inputs_used": resolution.inputs_used, "evidence": resolution.evidence[:5], "reason": resolution.reason},
"official_source_version": {"cclasstrib_table": official["publication"], "cclasstrib_sha256": official["sha256"], "lc214_sha256": resolution.source_references[0]["sha256"]},
"local_catalog_hash": fresh_hash, "timestamp": now, "job_id": job_id, "rowcount": 1, "verified_after": True}
"local_catalog_hash": fresh_hash, "timestamp": now, "job_id": job_id, "rowcount": 1, "verified_after": True, "action": "APPLY", "rule_version": RULE_VERSION,
"company_crt": expected_crt, "description_snapshot": _text(rows[0][2])[:255], "rtc_status": "ENABLED", "local_catalog_state": (compat.get("per_code") or {}).get(proposed), "authorization_origin": decision.get("origin")}
except RtcApplyAborted:
try:
conn.rollback()
@@ -128,9 +154,53 @@ def apply_candidates(conn, candidates: list[dict], *, company: dict, decision: d
try:
applied.append(apply_candidate(conn, candidate, company=company, decision=decision, now=now, job_id=job_id, today=today))
except RtcApplyAborted as stop:
aborted.append({"outcome": "ABORTED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": stop.reason, "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now})
aborted.append({"outcome": "ABORTED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": stop.reason, "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now, "action": "APPLY", "authorization_origin": decision.get("origin")})
reasons[stop.reason] = reasons.get(stop.reason, 0) + 1
except Exception as error: # noqa: BLE001 - technical failure of ONE product must not abort the batch
aborted.append({"outcome": "FAILED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": f"{type(error).__name__}", "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now})
reasons["TECHNICAL_FAILURE"] = reasons.get("TECHNICAL_FAILURE", 0) + 1
return {"applied": applied, "aborted": aborted, "aborted_reasons": reasons, "write_attempts": len(candidates), "write_successes": len(applied)}
def rollback_one(conn, item: dict, *, now: str, job_id=None) -> dict:
"""Restores C000025.CCLASSTRIB to empty for ONE previously applied audit record, only if the value still equals what was written. No free SQL: the only statement is fixed."""
field = "CCLASSTRIB"
assert field in ALLOWED_RTC_WRITE_FIELDS
code, written = _text(item.get("product_code")), _text(item.get("written_value"))
base = {"product_code": code, "field": field, "job_id": job_id, "timestamp": now, "action": "ROLLBACK", "rollback_of": item.get("audit_id"), "old_value": written, "new_value": None, "rule_id": item.get("rule_id")}
if not code or not written or item.get("restore_value"):
return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_REQUEST_INVALID"}
cur = conn.cursor()
try:
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
rows = cur.fetchall()
if len(rows) != 1:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND"}
current = _text(rows[0][0])
if current != written:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_CONFLICT_VALUE_CHANGED" if current else "ALREADY_EMPTY"}
cur.execute(f"UPDATE C000025 SET {field} = NULL WHERE CODIGO = ? AND TRIM({field}) = ?", (code, written))
if cur.rowcount != 1:
conn.rollback()
return {**base, "outcome": "ABORTED", "reason": "UPDATE_ROWCOUNT_NOT_ONE"}
conn.commit()
cur = conn.cursor()
cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,))
after = _text(cur.fetchone()[0])
conn.commit()
if after:
raise RuntimeError("post-rollback verification failed")
return {**base, "outcome": "ROLLED_BACK", "rowcount": 1, "verified_after": True}
except Exception as error: # noqa: BLE001
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
return {**base, "outcome": "FAILED", "reason": type(error).__name__}
def rollback_records(conn, items: list[dict], *, now: str, job_id=None) -> dict:
results = [rollback_one(conn, item, now=now, job_id=job_id) for item in items]
return {"rollbacks": results, "rolled_back": sum(1 for r in results if r["outcome"] == "ROLLED_BACK")}