feat: deterministic cClassTrib resolver + gated CCLASSTRIB auto-apply (default OFF), RTC panel summary; 1.1.9

- resolver (rules RTC-R0..R4/A1..A3/G1..G2) over the official cClassTrib table 2026-06-22 and LC 214/2025 annexes; local vs official catalog comparison
- ONE root-cause RTC blocker per product (missing/unresolved/ambiguous/conflict/official mismatch/catalog outdated/evidence missing)
- auto-apply: C000025.CCLASSTRIB only, three switches (FIREBIRD_WRITES + RTC_AUTO_APPLY_ENABLED + Hub authorization), per-product re-read and re-decision, never overwrites, rowcount 1, audit table rtc_write_audits (migration 0027)
- copy-only write E2E (280/280 applied, only CCLASSTRIB changed, 2nd run 0 writes, stale decision 0 writes); real databases untouched
- docs: cclasstrib_resolution, rtc_auto_apply, unified_fiscal_completeness

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 21:18:34 -03:00
co-authored by Claude Sonnet 5
parent 88f4318072
commit 9f08998800
51 changed files with 16461 additions and 47 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ import os
import sqlite3
from pathlib import Path
AGENT_VERSION_DEFAULT = "1.1.8"
AGENT_VERSION_DEFAULT = "1.1.9"
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")
@@ -0,0 +1,136 @@
"""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.
"""
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.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
class RtcApplyAborted(Exception):
"""The product is left untouched; `reason` is reported in the audit."""
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
def _text(value) -> str:
return "" if value is None else str(value).strip()
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 select_candidates(findings: dict, products: list[dict], *, limit: int = DEFAULT_MAX_PER_JOB) -> tuple[list[dict], int]:
"""Products READY_TO_APPLY: empty CCLASSTRIB + a deterministic resolution, no other RTC problem. Returns (candidates up to `limit`, total ready)."""
by_code = {str(p.get("CODIGO") or ""): p for p in products}
ready = []
for code, found in findings.items():
res = found.get("rtc_resolution") or {}
if not res.get("auto_apply_candidate"):
continue
rtc_types = {b["type"] for b in found.get("blockers", []) if b["category"] == "RTC"}
if rtc_types != {"CCLASSTRIB_MISSING"}:
continue
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"]})
return ready[:limit], len(ready)
def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now: str, job_id=None, today: date | None = None) -> dict:
"""One product, one short transaction. `decision` = {"local_catalog_hash": ...} captured when the candidate was chosen. Raises RtcApplyAborted (nothing written)."""
field = "CCLASSTRIB"
assert field in ALLOWED_RTC_WRITE_FIELDS
today = today or date.today()
code, proposed = candidate["product_code"], candidate["proposed"]
cur = conn.cursor()
try:
schema, catalog, extras = load_rtc_context(cur) # fresh: the ERP is asked again, nothing cached
caps = detect_rtc_capabilities(schema, param_value=extras["param_value"], catalog=catalog, physical_fk=extras["physical_fk"])
if extras["param_conflict"] or not rtc_enabled(caps):
raise RtcApplyAborted("RTC_NO_LONGER_ENABLED")
if not caps["RTC_CATALOG_VALID"]:
raise RtcApplyAborted("RTC_CATALOG_INVALID")
local_rows = (catalog or {}).get("rows") or {}
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,))
rows = cur.fetchall()
if len(rows) != 1:
raise RtcApplyAborted("PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND")
current, ncm = _text(rows[0][0]), _digits(rows[0][1])
if current:
raise RtcApplyAborted("VALUE_ALREADY_SET_NEVER_OVERWRITTEN" if current != proposed else "NO_WRITE_REQUIRED")
if ncm != candidate["ncm"]:
raise RtcApplyAborted("PRODUCT_CHANGED_NCM")
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"]:
raise RtcApplyAborted("DECISION_CHANGED")
cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND ({field} IS NULL OR TRIM({field}) = '')", (proposed, code))
if cur.rowcount != 1:
conn.rollback()
raise RtcApplyAborted("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 != proposed:
raise RuntimeError(f"post-write verification failed for {code}: {after!r} != {proposed!r}")
official = compat["official_catalog"]
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}
except RtcApplyAborted:
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
raise
except Exception:
try:
conn.rollback()
except Exception: # noqa: BLE001
pass
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."""
applied, aborted, reasons = [], [], {}
for candidate in candidates:
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})
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)}
+26 -4
View File
@@ -11,12 +11,14 @@ import json
from collections import Counter
from datetime import date, datetime, timezone
from central.app.cclasstrib_resolver import compare_catalogs, local_catalog_hash, resolve_cclasstrib
from central.app.rtc_findings import refine_rtc_finding, summarize_rtc
from central.app.unified_fiscal_coverage import PIS_COFINS_FIELDS, catalog_integrity, evaluate_product, legal_effective_context, rtc_enabled
PRODUCT_TABLE = "C000025"
RTC_CCLASSTRIB_TABLE = "RTC_CCLASSTRIB"
RTC_CST_TABLE = "RTC_CST"
PRODUCT_COLUMNS = ("CODIGO", "CCLASSTRIB") + PIS_COFINS_FIELDS + ("PISCOFINS", "INCIDENCIA_PISCOFINS")
PRODUCT_COLUMNS = ("CODIGO", "CCLASSTRIB", "CLASSIFICACAO_FISCAL") + PIS_COFINS_FIELDS + ("PISCOFINS", "INCIDENCIA_PISCOFINS")
TRUTHY = {"1", "S", "T", "TRUE"}
@@ -67,13 +69,23 @@ def load_rtc_context(cursor) -> tuple[dict, dict | None, dict]:
class_cols = schema.get(RTC_CCLASSTRIB_TABLE, set())
if {"CCLASSTRIB", "CSTIBSCBS"} <= class_cols and "CSTIBSCBS" in schema.get(RTC_CST_TABLE, set()):
validity = "INICIO_VIGENCIA" in class_cols and "FIM_VIGENCIA" in class_cols
cursor.execute("SELECT TRIM(CCLASSTRIB), TRIM(CSTIBSCBS)" + (", INICIO_VIGENCIA, FIM_VIGENCIA" if validity else "") + " FROM RTC_CCLASSTRIB")
extra = [c for c in ("TIPO_ALIQUOTA", "PRED_IBS", "PRED_CBS", "IND_NFE", "IND_NFCE") if c in class_cols]
cursor.execute("SELECT TRIM(CCLASSTRIB), TRIM(CSTIBSCBS)" + (", INICIO_VIGENCIA, FIM_VIGENCIA" if validity else "") + "".join(f", {c}" for c in extra) + " FROM RTC_CCLASSTRIB")
rows = cursor.fetchall()
classes = {r[0]: (r[1] or None) for r in rows}
cursor.execute("SELECT TRIM(CSTIBSCBS) FROM RTC_CST")
catalog = {"classes": classes, "cst": {r[0] for r in cursor.fetchall()}, "duplicates": len(rows) - len(classes)}
if validity:
catalog["validity"] = {r[0]: (r[2], r[3]) for r in rows}
offset = 4 if validity else 2
catalog["rows"] = {r[0]: {"cst": r[1], "start": (r[2] if validity else None), "end": (r[3] if validity else None), **{c: r[offset + i] for i, c in enumerate(extra)}} for r in rows}
for value in catalog["rows"].values():
for key in ("TIPO_ALIQUOTA", "PRED_IBS", "PRED_CBS", "IND_NFE", "IND_NFCE"):
if key in value:
value[key.lower()] = value.pop(key)
for flag in ("ind_nfe", "ind_nfce"):
if flag in value:
value[flag] = str(value[flag]).strip() in ("1", "True", "true")
return schema, catalog, extras
@@ -87,9 +99,10 @@ def read_products(cursor, schema: dict) -> list[dict]:
return [dict(zip(columns, row)) for row in cursor.fetchall()]
def assess_from_connection(conn, company: dict | None, *, today: date | None = None, rtc_override: bool | None = None) -> dict:
def assess_from_connection(conn, company: dict | None, *, today: date | None = None, rtc_override: bool | None = None, ncm_valid: dict | None = None) -> dict:
"""One read-only pass. Returns {"context": {...}, "findings": {product_code: evaluate_product(...)}}.
rtc_override forces RTC on/off for simulations WITHOUT touching the ERP parameter (tests / what-if only)."""
rtc_override forces RTC on/off for simulations WITHOUT touching the ERP parameter (tests / what-if only).
ncm_valid: {product_code: bool} from the decision engine (EXACT_NCM); a product whose NCM is not confirmed never gets a cClassTrib proposal."""
cursor = conn.cursor()
schema, catalog, extras = load_rtc_context(cursor)
caps = detect_rtc_capabilities(schema, param_value=extras["param_value"], catalog=catalog, physical_fk=extras["physical_fk"])
@@ -102,8 +115,15 @@ def assess_from_connection(conn, company: dict | None, *, today: date | None = N
warn_products: set = set()
samples: list = []
applicability: Counter = Counter()
local_rows = (catalog or {}).get("rows") or {}
compat = compare_catalogs(local_rows, today) if (enabled and local_rows) else None
resolver_ctx = {"rtc_enabled": enabled, "local_catalog_valid": bool(caps["RTC_CATALOG_VALID"])}
for p in products:
found = evaluate_product(p, company, rtc, today)
if enabled:
code0 = str(p.get("CODIGO") or "")
resolution = resolve_cclasstrib({**p, "ncm_valid": bool((ncm_valid or {}).get(code0, False))}, company, resolver_ctx, local_rows, compat=compat, today=today)
refine_rtc_finding(found, p, resolution, compat["per_code"] if compat else None)
found["input_fingerprint"] = hashlib.sha256(json.dumps({k: (None if v is None else str(v).strip()) for k, v in p.items()} | {"rtc": enabled}, sort_keys=True).encode()).hexdigest()
code = str(p.get("CODIGO") or "")
findings[code] = found
@@ -119,5 +139,7 @@ def assess_from_connection(conn, company: dict | None, *, today: date | None = N
"rtc_observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "legal_effective_context": legal_effective_context((company or {}).get("crt")),
"catalog_integrity": integrity, "crt": (company or {}).get("crt"), "capabilities": caps, "products_evaluated": len(products),
"pis_cofins_applicability": dict(applicability),
"rtc_resolution": summarize_rtc(findings, compat) if enabled else None, "local_catalog_hash": local_catalog_hash(local_rows) if local_rows else None,
"official_catalog": (compat or {}).get("official_catalog"),
"non_canonical_warnings": {"products": len(warn_products), "by_type": dict(sorted(warn_by_type.items())), "samples": samples}},
"findings": findings}