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
+40 -10
View File
@@ -30,7 +30,7 @@ sys.path.insert(0, str(AGENT_ROOT / "src"))
sys.path.insert(0, str(PROJECT_ROOT)) # ships central/app engine modules read-only (central.app.*)
from gti_fiscal_agent.client import HubClient # noqa: E402
from gti_fiscal_agent import firebird_reader as _firebird_reader, unified_fiscal as _unified # noqa: E402
from gti_fiscal_agent import firebird_reader as _firebird_reader, rtc_auto_apply as _rtc_apply, unified_fiscal as _unified # noqa: E402
from gti_fiscal_agent.firebird_reader import check_connection, read_products # noqa: E402
from gti_fiscal_agent.pipeline import run_pipeline # noqa: E402
from gti_fiscal_agent.runner import REFERENCE_SCHEMA_V1, product_fingerprint, to_hub_product # noqa: E402
@@ -104,7 +104,10 @@ class Config:
# (A legacy AGENT_JOBS_ENABLED line in an old agent.env is deliberately ignored.)
self.agent_jobs_enabled = True
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
self.agent_version = env.get("AGENT_VERSION", "1.1.8")
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
self.rtc_auto_apply_enabled = env.get("RTC_AUTO_APPLY_ENABLED", "0").strip() == "1"
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.1.9")
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
@@ -187,12 +190,12 @@ def open_write_connection(env, database_path, firebird_writes=True):
)
def _unified_fiscal(env, company):
def _unified_fiscal(env, company, ncm_valid=None):
"""PIS/COFINS applicability + RTC (only when the ERP has the reform on), read-only (SELECT only). Fail-closed: if it cannot be assessed the job fails
instead of reporting a coverage that ignores these fields."""
connection = _firebird_reader._connect(env)
try:
return _unified.assess_from_connection(connection, company)
return _unified.assess_from_connection(connection, company, ncm_valid=ncm_valid)
finally:
connection.close()
@@ -210,6 +213,28 @@ def _fiscal_blocked_products(products, findings):
return out
def _rtc_auto_apply(env, config, job, products, fiscal, company):
"""Report (always) and, only when all three gates are open, fill CCLASSTRIB for products whose class is PROVEN by a deterministic rule.
Returns the summary stored in the job result; never raises for a single product."""
gate = _rtc_apply.write_gate(firebird_writes=bool(getattr(config, "firebird_writes", False)), agent_enabled=bool(getattr(config, "rtc_auto_apply_enabled", False)),
hub_authorized=bool((job.get("authorizations") or {}).get("rtc_auto_apply")))
limit = int(getattr(config, "rtc_auto_apply_max_per_job", _rtc_apply.DEFAULT_MAX_PER_JOB))
candidates, ready_total = _rtc_apply.select_candidates(fiscal["findings"], products, limit=limit)
summary = {"gate": gate, "mode": "AUTO_APPLY" if gate["allowed"] else "REPORT_ONLY", "ready_total": ready_total, "max_per_job": limit, "write_attempts": 0, "write_successes": 0, "applied": [], "aborted": [], "aborted_reasons": {},
"allowed_fields": list(_rtc_apply.ALLOWED_RTC_WRITE_FIELDS)}
if not (gate["allowed"] and candidates and fiscal["context"].get("rtc_enabled")):
return summary
from datetime import datetime, timezone
connection = open_write_connection(env, config.database_path)
try:
outcome = _rtc_apply.apply_candidates(connection, candidates, company=company or {}, decision={"local_catalog_hash": fiscal["context"].get("local_catalog_hash")},
now=datetime.now(timezone.utc).isoformat(timespec="seconds"), job_id=job.get("job_id"))
finally:
connection.close()
summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")})
return summary
def execute_job(job, config, executor, state, env):
"""Run the pipeline for one claimed job. Never raises for job-domain
failures (bad Firebird read, classify error) — returns (result_summary, error)."""
@@ -249,7 +274,7 @@ def execute_job(job, config, executor, state, env):
if write_connection is not None:
write_connection.close()
operational = coverage = fiscal_context = None
operational = coverage = fiscal_context = rtc_apply = None
fiscal = None
if sync_kind:
if cache:
@@ -261,13 +286,17 @@ def execute_job(job, config, executor, state, env):
all_decisions = decisions
coverage = build_job_coverage(env, client, products, all_decisions)
company = {"crt": ((coverage or {}).get("company_context") or {}).get("company_crt")}
fiscal = _unified_fiscal(env, company)
ncm_ok = {str(p["CODIGO"]): (d or {}).get("status") == "EXACT_NCM" for p, d in zip(products, all_decisions)}
fiscal = _unified_fiscal(env, company, ncm_ok)
rtc_apply = _rtc_auto_apply(env, config, job, products, fiscal, company)
if rtc_apply["write_successes"]:
fiscal = _unified_fiscal(env, company, ncm_ok) # coverage must reflect the fields that were just filled
findings = fiscal["findings"]
product_states = [classify_product_state(p, d, fiscal=findings.get(str(p["CODIGO"]))) for p, d in zip(products, all_decisions)]
operational = summarize_operational_states(product_states)
fiscal_context = {**fiscal["context"], "evaluated_all": kind != "REPROCESS_PRODUCTS"}
else:
fiscal = _unified_fiscal(env, None)
fiscal = _unified_fiscal(env, None, {str(p["CODIGO"]): (d or {}).get("status") == "EXACT_NCM" for p, d in zip(to_process, decisions)})
fiscal_blocked = _fiscal_blocked_products(products, fiscal["findings"])
fiscal_codes = {b["product_code"] for b in fiscal_blocked}
summary["blocked_products"] = summary["blocked_products"] + fiscal_blocked
@@ -276,8 +305,9 @@ def execute_job(job, config, executor, state, env):
"coverage": coverage,
"operational": operational,
"fiscal_context": fiscal_context,
"rtc_auto_apply": rtc_apply,
"fiscal_mode": getattr(config, "fiscal_mode", None),
"release_version": env.get("AGENT_VERSION") or "1.1.8",
"release_version": env.get("AGENT_VERSION") or "1.1.9",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
@@ -365,7 +395,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
"agent_version": config.agent_version, "last_job_id": last_job_id, "display_name": env.get("AGENT_DISPLAY_NAME", ""),
"customer_code": env.get("CUSTOMER_CODE"), "hostname": env.get("AGENT_HOSTNAME") or os.environ.get("COMPUTERNAME"),
"windows_version": env.get("WINDOWS_VERSION"), "company_code": config.company_id, "company_uf": config.company_uf,
"database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True},
"database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True, "rtc_auto_apply": True, "rtc_auto_apply_enabled": bool(getattr(config, "rtc_auto_apply_enabled", False))},
"fiscal_engine_version": env.get("FISCAL_ENGINE_VERSION", config.agent_version), "dataset_versions": {},
}
if hasattr(client, "heartbeat_metadata"):
@@ -435,7 +465,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.1.8")
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.9")
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":