diff --git a/agent/pyproject.toml b/agent/pyproject.toml
index fea607c..89c4abd 100644
--- a/agent/pyproject.toml
+++ b/agent/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "gti-fiscal-agent"
-version = "1.1.9"
+version = "1.2.0"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"]
diff --git a/agent/scripts/run_agent_service.py b/agent/scripts/run_agent_service.py
index 43710fa..6f1c4cd 100644
--- a/agent/scripts/run_agent_service.py
+++ b/agent/scripts/run_agent_service.py
@@ -105,9 +105,8 @@ class Config:
self.agent_jobs_enabled = True
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_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.agent_version = env.get("AGENT_VERSION", "1.2.0")
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
@@ -208,30 +207,47 @@ def _fiscal_blocked_products(products, findings):
found = findings.get(code) or {}
for blocker in found.get("blockers") or []:
out.append({"company": "", "product_code": code, "gtin": product.get("GTIN") or product.get("EAN_GTIN"), "description": product.get("PRODUTO"), "blocker_type": "BLOCKED_FISCAL_DATA",
- "field": blocker["category"], "reason_code": blocker["type"], "evidence_summary": {"reason": blocker["reason"], "applicability": found.get("applicability")},
+ "field": blocker["category"], "reason_code": blocker["type"], "evidence_summary": {"reason": blocker["reason"], "applicability": found.get("applicability"), **({"human": blocker["human"]} if blocker.get("human") else {})},
"input_fingerprint": found.get("input_fingerprint", "")})
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
+ """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."""
from datetime import datetime, timezone
+ auth = job.get("authorizations") or {}
+ mode = auth.get("rtc_write_mode") if auth.get("rtc_write_mode") in _rtc_apply.MODES else "DISABLED"
+ approved = {str(c) for c in (auth.get("rtc_approved_codes") or [])}
+ limit = int(getattr(config, "rtc_auto_apply_max_per_job", _rtc_apply.DEFAULT_MAX_PER_JOB))
+ rtc_on = bool(fiscal["context"].get("rtc_enabled"))
+ all_candidates, ready_total = _rtc_apply.select_candidates(fiscal["findings"], products, limit=_rtc_apply.MAX_PREVIEW_CANDIDATES)
+ gate = _rtc_apply.write_gate(mode=mode, capability=True, approved=bool(approved))
+ summary = {"gate": gate, "mode": mode if mode != "DISABLED" else "REPORT_ONLY", "hub_mode": mode, "capability": _rtc_apply.CAPABILITY, "ready_total": ready_total, "max_per_job": limit, "write_attempts": 0, "write_successes": 0,
+ "applied": [], "aborted": [], "aborted_reasons": {}, "rollbacks": [], "allowed_fields": list(_rtc_apply.ALLOWED_RTC_WRITE_FIELDS),
+ "preview": [{k: c[k] for k in ("product_code", "description", "ncm", "current", "proposed", "cst", "rule_id", "basis")} for c in all_candidates] if mode != "DISABLED" and rtc_on else [],
+ "preview_truncated": ready_total > len(all_candidates)}
+ if not rtc_on:
+ return summary
+ now = datetime.now(timezone.utc).isoformat(timespec="seconds")
+ rollbacks = [r for r in (auth.get("rtc_rollbacks") or []) if isinstance(r, dict)]
+ if mode == "ENABLED":
+ wanted = all_candidates[:limit]
+ elif mode == "APPROVAL_REQUIRED":
+ wanted = [c for c in all_candidates if c["product_code"] in approved][:limit]
+ else:
+ wanted = []
+ if not (rollbacks or (gate["allowed"] and wanted)):
+ return summary
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"))
+ 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"))
+ summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")})
finally:
connection.close()
- summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")})
return summary
@@ -289,7 +305,7 @@ def execute_job(job, config, executor, state, env):
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"]:
+ if rtc_apply["write_successes"] or any(r.get("outcome") == "ROLLED_BACK" for r in rtc_apply["rollbacks"]):
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)]
@@ -307,7 +323,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.1.9",
+ "release_version": env.get("AGENT_VERSION") or "1.2.0",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
@@ -395,7 +411,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, "rtc_auto_apply": True, "rtc_auto_apply_enabled": bool(getattr(config, "rtc_auto_apply_enabled", False))},
+ "database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True, "RTC_CCLASSTRIB_WRITE_V1": True},
"fiscal_engine_version": env.get("FISCAL_ENGINE_VERSION", config.agent_version), "dataset_versions": {},
}
if hasattr(client, "heartbeat_metadata"):
@@ -465,7 +481,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.9")
+ report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.2.0")
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":
diff --git a/agent/src/gti_fiscal_agent/preflight.py b/agent/src/gti_fiscal_agent/preflight.py
index 51f63b1..ebacaa7 100644
--- a/agent/src/gti_fiscal_agent/preflight.py
+++ b/agent/src/gti_fiscal_agent/preflight.py
@@ -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")
diff --git a/agent/src/gti_fiscal_agent/rtc_auto_apply.py b/agent/src/gti_fiscal_agent/rtc_auto_apply.py
index f6ddd14..57e3f8f 100644
--- a/agent/src/gti_fiscal_agent/rtc_auto_apply.py
+++ b/agent/src/gti_fiscal_agent/rtc_auto_apply.py
@@ -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")}
diff --git a/agent/tests/test_rtc_auto_apply.py b/agent/tests/test_rtc_auto_apply.py
index db5e7ad..e8f5dcf 100644
--- a/agent/tests/test_rtc_auto_apply.py
+++ b/agent/tests/test_rtc_auto_apply.py
@@ -27,6 +27,7 @@ class FakeFirebird:
for code, change in (catalog_tweak or {}).items():
self.catalog[code] = {**self.catalog[code], **change}
self.cst_table = {r["cst"] for r in ref.OFFICIAL_CCLASSTRIB} # RTC_CST is its own table: a class may point to a CST that is not in it
+ self.crt = ("3",) # C000004.CRT rows
self.statements, self.commits, self.rollbacks = [], 0, 0
self._pending = {}
@@ -71,14 +72,23 @@ class FakeCursor:
self._rows = [(c,) for c in sorted(db.cst_table)]
elif up.startswith("SELECT CODIGO, CCLASSTRIB, CLASSIFICACAO_FISCAL"):
self._rows = [(c, v["CCLASSTRIB"], v["CLASSIFICACAO_FISCAL"], "N", "NENHUMA") for c, v in sorted(db.products.items())]
- elif up.startswith("SELECT CCLASSTRIB, CLASSIFICACAO_FISCAL FROM C000025"):
+ elif up.startswith("SELECT DISTINCT TRIM(CRT) FROM C000004"):
+ self._rows = [(c,) for c in db.crt]
+ elif up.startswith("SELECT CCLASSTRIB, CLASSIFICACAO_FISCAL, PRODUTO FROM C000025"):
code = params[0]
- self._rows = [(db.products[code]["CCLASSTRIB"], db.products[code]["CLASSIFICACAO_FISCAL"])] if code in db.products else []
+ self._rows = [(db.products[code]["CCLASSTRIB"], db.products[code]["CLASSIFICACAO_FISCAL"], db.products[code].get("PRODUTO", ""))] if code in db.products else []
if code in db.products and db.products[code].get("DUP"):
self._rows = self._rows * 2
elif up.startswith("SELECT CCLASSTRIB FROM C000025"):
code = params[0]
self._rows = [(db.products[code]["CCLASSTRIB"],)]
+ elif up.startswith("UPDATE C000025 SET CCLASSTRIB = NULL WHERE CODIGO = ? AND TRIM(CCLASSTRIB) = ?"):
+ code, written = params
+ product = db.products.get(code)
+ hit = product is not None and (product["CCLASSTRIB"] or "").strip() == written
+ self.rowcount = 1 if hit else 0
+ if hit:
+ db._pending[code] = None
elif up.startswith("UPDATE C000025 SET CCLASSTRIB = ? WHERE CODIGO = ?"):
value, code = params
product = db.products.get(code)
@@ -115,21 +125,22 @@ def run(db, cands, out, **kw):
return apply_mod.apply_candidates(db, cands, company=NORMAL, decision={"local_catalog_hash": out["context"]["local_catalog_hash"]}, now="2026-09-24T20:00:00+00:00", job_id=7, today=TODAY, **kw)
-PRODUCTS = {"000001": product("10063021"), "000002": product("39235000"), "000003": product("15171000"), "000004": product("30049099"), "000005": product("39235000", "000001")}
+PRODUCTS = {"000001": product("10063021"), "000002": product("04090000"), "000003": product("15171000"), "000004": product("30049099"), "000005": product("39235000", "000001")}
-def test_gate_needs_all_three_switches_and_defaults_to_closed():
- assert apply_mod.write_gate(firebird_writes=True, agent_enabled=True, hub_authorized=True)["allowed"] is True
- for combo in ((False, True, True), (True, False, True), (True, True, False), (False, False, False)):
- g = apply_mod.write_gate(firebird_writes=combo[0], agent_enabled=combo[1], hub_authorized=combo[2])
+def test_gate_is_driven_by_the_central_mode_only_and_defaults_to_closed():
+ assert apply_mod.write_gate(mode="ENABLED")["allowed"] is True
+ assert apply_mod.write_gate(mode="APPROVAL_REQUIRED", approved=True)["allowed"] is True
+ for kwargs in ({"mode": "DISABLED"}, {"mode": "DRY_RUN"}, {"mode": "APPROVAL_REQUIRED", "approved": False}, {"mode": "ENABLED", "capability": False}, {"mode": "bogus"}, {"mode": ""}):
+ g = apply_mod.write_gate(**kwargs)
assert g["allowed"] is False and g["missing"]
- assert apply_mod.ALLOWED_RTC_WRITE_FIELDS == ("CCLASSTRIB",)
+ assert apply_mod.ALLOWED_RTC_WRITE_FIELDS == ("CCLASSTRIB",) and apply_mod.CAPABILITY == "RTC_CCLASSTRIB_WRITE_V1"
def test_only_proven_products_become_candidates():
db = FakeFirebird(PRODUCTS)
out, cands, total = candidates_of(db)
- assert sorted((c["product_code"], c["proposed"], c["rule_id"]) for c in cands) == [("000001", "200003", "RTC-R3"), ("000002", "000001", "RTC-R4"), ("000003", "200003", "RTC-R3")]
+ assert sorted((c["product_code"], c["proposed"], c["rule_id"]) for c in cands) == [("000001", "200003", "RTC-R3"), ("000002", "200034", "RTC-R3"), ("000003", "200003", "RTC-R3")]
assert total == 3 and out["context"]["rtc_resolution"]["ready_to_apply"] == 3 and out["context"]["rtc_resolution"]["ambiguous"] == 1 # medicine chapter is ambiguous
assert out["context"]["rtc_resolution"]["valid_existing"] == 1 # 000005 already has the right class
assert "000004" not in {c["product_code"] for c in cands}
@@ -140,7 +151,7 @@ def test_apply_writes_only_cclasstrib_with_audit_and_second_run_writes_nothing()
out, cands, _ = candidates_of(db)
result = run(db, cands, out)
assert result["write_successes"] == 3 and result["write_attempts"] == 3 and result["aborted"] == []
- assert {c: db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")} == {"000001": "200003", "000002": "000001", "000003": "200003"}
+ assert {c: db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")} == {"000001": "200003", "000002": "200034", "000003": "200003"}
updates = [s for s, _ in db.statements if s.upper().startswith("UPDATE")]
assert len(updates) == 3 and all(re.fullmatch(r"UPDATE C000025 SET CCLASSTRIB = \? WHERE CODIGO = \? AND \(CCLASSTRIB IS NULL OR TRIM\(CCLASSTRIB\) = ''\)", u) for u in updates)
assert not [s for s, _ in db.statements if re.match(r"(INSERT|DELETE|ALTER|DROP)", s.strip(), re.I)]
@@ -184,7 +195,7 @@ def test_product_changed_between_decision_and_write_is_left_alone():
db = FakeFirebird(PRODUCTS)
out, cands, _ = candidates_of(db)
db.products["000001"]["CLASSIFICACAO_FISCAL"] = "30049099" # NCM edited in the ERP meanwhile
- db.products["000002"]["CCLASSTRIB"] = "000001" # operator filled it meanwhile (same value)
+ db.products["000002"]["CCLASSTRIB"] = "200034" # operator filled it meanwhile (same value)
db.products["000003"]["CCLASSTRIB"] = "200034" # ... or with a DIFFERENT value: never overwritten
result = run(db, cands, out)
assert result["aborted_reasons"] == {"PRODUCT_CHANGED_NCM": 1, "NO_WRITE_REQUIRED": 1, "VALUE_ALREADY_SET_NEVER_OVERWRITTEN": 1} and result["write_successes"] == 0
@@ -192,7 +203,7 @@ def test_product_changed_between_decision_and_write_is_left_alone():
def test_duplicate_code_rowcount_protection_and_isolation_of_failures():
- products = {**PRODUCTS, "000002": product("39235000", DUP=True)}
+ products = {**PRODUCTS, "000002": product("04090000", DUP=True)}
db = FakeFirebird(products)
out, cands, _ = candidates_of(db)
result = run(db, cands, out)
@@ -216,7 +227,7 @@ def test_official_mismatch_of_an_existing_class_is_reported():
def test_batch_limit_bounds_the_blast_radius():
- db = FakeFirebird({f"{i:06d}": product("39235000") for i in range(1, 8)})
+ db = FakeFirebird({f"{i:06d}": product("04090000") for i in range(1, 8)})
out, cands, total = candidates_of(db, limit=3)
assert len(cands) == 3 and total == 7
@@ -252,40 +263,110 @@ def _fiscal_for(db):
return assess(db)
-def test_job_reports_ready_products_but_writes_nothing_with_default_switches(monkeypatch):
+def _products_of(db):
+ return [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"], "PRODUTO": v.get("PRODUTO", "")} for c, v in db.products.items()]
+
+
+def _no_write(service, monkeypatch):
+ monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no write connection may be opened")))
+
+
+def test_job_without_a_central_mode_or_with_disabled_or_dry_run_never_writes(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
- monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no write connection may be opened")))
- products = [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"]} for c, v in db.products.items()]
- for cfg_kwargs, auth in (({}, False), ({"firebird_writes": True}, False), ({"firebird_writes": True, "rtc_auto_apply_enabled": True}, False), ({"rtc_auto_apply_enabled": True}, True), ({"firebird_writes": True}, True)):
- config = _config(**cfg_kwargs)
- summary = service._rtc_auto_apply({}, config, {"job_id": 1, "authorizations": {"rtc_auto_apply": auth}}, products, fiscal, NORMAL)
- assert summary["mode"] == "REPORT_ONLY" and summary["gate"]["allowed"] is False and summary["ready_total"] == 3 and summary["write_attempts"] == 0 and summary["applied"] == []
+ _no_write(service, monkeypatch)
+ for auth in ({}, {"rtc_write_mode": "DISABLED"}, {"rtc_write_mode": "DRY_RUN"}, {"rtc_write_mode": "bogus"}, {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": []}, {"rtc_auto_apply": True}):
+ summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": auth}, _products_of(db), fiscal, NORMAL)
+ assert summary["gate"]["allowed"] is False and summary["ready_total"] == 3 and summary["write_attempts"] == 0 and summary["applied"] == []
+ off = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "DISABLED"}}, _products_of(db), fiscal, NORMAL)
+ dry = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "DRY_RUN"}}, _products_of(db), fiscal, NORMAL)
+ assert off["preview"] == [] and off["mode"] == "REPORT_ONLY"
+ assert [c["product_code"] for c in dry["preview"]] == ["000001", "000002", "000003"] and all(c["rule_id"] == "RTC-R3" for c in dry["preview"]) and dry["capability"] == "RTC_CCLASSTRIB_WRITE_V1"
-def test_job_applies_only_when_all_three_switches_are_open(monkeypatch):
+def test_r4_suggestions_are_never_in_the_preview_and_never_written(monkeypatch):
+ service, _config = _service()
+ db = FakeFirebird({"000001": product("39235000"), "000002": product("39235000")})
+ fiscal = _fiscal_for(db)
+ _no_write(service, monkeypatch)
+ summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
+ assert summary["ready_total"] == 0 and summary["preview"] == [] and summary["write_attempts"] == 0
+
+
+def test_enabled_mode_applies_without_any_local_switch(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS)
fiscal = _fiscal_for(db)
monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db))
- products = [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"]} for c, v in db.products.items()]
- config = _config(firebird_writes=True, rtc_auto_apply_enabled=True)
- summary = service._rtc_auto_apply({}, config, {"job_id": 9, "authorizations": {"rtc_auto_apply": True}}, products, fiscal, NORMAL)
- assert summary["mode"] == "AUTO_APPLY" and summary["write_successes"] == 3 and summary["allowed_fields"] == ["CCLASSTRIB"] and all(a["job_id"] == 9 for a in summary["applied"])
- assert [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == ["200003", "000001", "200003"]
+ summary = service._rtc_auto_apply({}, _config(), {"job_id": 9, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL) # default config: no firebird_writes, no env switch
+ assert summary["mode"] == "ENABLED" and summary["write_successes"] == 3 and summary["allowed_fields"] == ["CCLASSTRIB"] and all(a["job_id"] == 9 and a["authorization_origin"] == "HUB_MODE:ENABLED" for a in summary["applied"])
+ assert [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == ["200003", "200034", "200003"]
+ a = summary["applied"][0]
+ assert a["rule_version"] and a["company_crt"] == "3" and a["action"] == "APPLY" and a["rtc_status"] == "ENABLED" and a["local_catalog_state"] == "MATCH_SAFE" and a["old_value"] is None
-def test_job_never_writes_when_the_erp_says_rtc_is_off_even_with_all_switches_open(monkeypatch):
+def test_approval_required_writes_only_the_approved_products(monkeypatch):
+ service, _config = _service()
+ db = FakeFirebird(PRODUCTS)
+ fiscal = _fiscal_for(db)
+ monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db))
+ summary = service._rtc_auto_apply({}, _config(), {"job_id": 9, "authorizations": {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": ["000002"]}}, _products_of(db), fiscal, NORMAL)
+ assert summary["write_successes"] == 1 and [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == [None, "200034", None]
+
+
+def test_job_never_writes_when_the_erp_says_rtc_is_off_even_in_enabled_mode(monkeypatch):
service, _config = _service()
db = FakeFirebird(PRODUCTS, param=(0,))
fiscal = _fiscal_for(db)
- monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no write connection may be opened")))
- products = [{"CODIGO": c, "CCLASSTRIB": None, "CLASSIFICACAO_FISCAL": "39235000"} for c in db.products]
- summary = service._rtc_auto_apply({}, _config(firebird_writes=True, rtc_auto_apply_enabled=True), {"job_id": 1, "authorizations": {"rtc_auto_apply": True}}, products, fiscal, NORMAL)
+ _no_write(service, monkeypatch)
+ products = [{"CODIGO": c, "CCLASSTRIB": None, "CLASSIFICACAO_FISCAL": "04090000"} for c in db.products]
+ summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, products, fiscal, NORMAL)
assert summary["write_attempts"] == 0 and summary["ready_total"] == 0
+def test_company_regime_or_fingerprint_change_aborts_before_the_update():
+ db = FakeFirebird(PRODUCTS)
+ out, cands, _ = candidates_of(db)
+ db.crt = ("1",) # the company was switched to Simples between decision and write
+ res = run(db, cands, out)
+ assert res["write_successes"] == 0 and res["aborted_reasons"] == {"COMPANY_CONTEXT_CHANGED": 3}
+ db.crt = ("3", "1") # ambiguous company context is also refused
+ assert run(db, cands, out)["aborted_reasons"] == {"COMPANY_CONTEXT_CHANGED": 3}
+ db.crt = ("3",)
+ db.products["000001"]["PRODUTO"] = "RENAMED AFTER THE DECISION"
+ res = run(db, cands, out)
+ assert res["aborted_reasons"] == {"PRODUCT_CHANGED_SINCE_DECISION": 1} and res["write_successes"] == 2 and db.products["000001"]["CCLASSTRIB"] is None
+ assert not any("UPDATE" in q.upper() and "C000004" in q.upper() for q, _ in db.statements)
+
+
+def test_rollback_restores_only_when_the_value_still_equals_the_written_one_and_reapply_works():
+ db = FakeFirebird(PRODUCTS)
+ out, cands, _ = candidates_of(db)
+ first = run(db, cands, out)
+ assert first["write_successes"] == 3
+ items = [{"audit_id": i + 1, "product_code": a["product_code"], "written_value": a["new_value"], "rule_id": a["rule_id"]} for i, a in enumerate(first["applied"])]
+ db.products["000002"]["CCLASSTRIB"] = "200035" # an operator changed it afterwards
+ res = apply_mod.rollback_records(db, items, now="2026-09-24T21:00:00+00:00", job_id=8)
+ by = {r["product_code"]: r for r in res["rollbacks"]}
+ assert res["rolled_back"] == 2 and by["000002"]["outcome"] == "ABORTED" and by["000002"]["reason"] == "ROLLBACK_CONFLICT_VALUE_CHANGED" and db.products["000002"]["CCLASSTRIB"] == "200035"
+ assert by["000001"]["outcome"] == "ROLLED_BACK" and by["000001"]["rollback_of"] == 1 and by["000001"]["old_value"] == "200003" and by["000001"]["new_value"] is None and db.products["000001"]["CCLASSTRIB"] is None
+ again = apply_mod.rollback_records(db, items, now="2026-09-24T21:05:00+00:00", job_id=9)["rollbacks"]
+ assert [r["reason"] for r in again if r["product_code"] == "000001"] == ["ALREADY_EMPTY"]
+ out2, cands2, _ = candidates_of(db) # reapply: the emptied products are candidates again, the changed one is not
+ assert sorted(c["product_code"] for c in cands2) == ["000001", "000003"] and run(db, cands2, out2)["write_successes"] == 2
+
+
+def test_rollback_uses_one_fixed_statement_and_rejects_malformed_requests():
+ db = FakeFirebird(PRODUCTS)
+ bad = apply_mod.rollback_records(db, [{"audit_id": 1, "product_code": "000001", "written_value": ""}, {"audit_id": 2, "product_code": "000001", "written_value": "200003", "restore_value": "999999"}], now="t")
+ assert [r["reason"] for r in bad["rollbacks"]] == ["ROLLBACK_REQUEST_INVALID"] * 2 and db.statements == []
+ db.products["000001"]["CCLASSTRIB"] = "200003"
+ apply_mod.rollback_records(db, [{"audit_id": 3, "product_code": "000001", "written_value": "200003"}], now="t")
+ writes = [q for q, _ in db.statements if q.strip().upper().startswith(("UPDATE", "INSERT", "DELETE"))]
+ assert writes == ["UPDATE C000025 SET CCLASSTRIB = NULL WHERE CODIGO = ? AND TRIM(CCLASSTRIB) = ?"]
+
+
class _Closeable:
def __init__(self, db):
self._db = db
@@ -295,3 +376,26 @@ class _Closeable:
def close(self):
pass
+
+
+def test_firebird_unavailable_for_the_write_connection_fails_the_job_closed_with_zero_writes(monkeypatch):
+ service, _config = _service()
+ db = FakeFirebird(PRODUCTS)
+ fiscal = _fiscal_for(db)
+ monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(OSError("Firebird unavailable")))
+ import pytest as _pytest
+ with _pytest.raises(OSError):
+ service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL)
+ assert all(v["CCLASSTRIB"] is None for c, v in db.products.items() if c != "000005") and not [q for q, _ in db.statements if q.upper().startswith("UPDATE")]
+
+
+def test_connection_is_closed_even_when_a_product_fails_mid_batch(monkeypatch):
+ service, _config = _service()
+ db = FakeFirebird({**PRODUCTS, "000002": product("04090000", DUP=True)})
+ fiscal = _fiscal_for(db)
+ closed = []
+ conn = _Closeable(db)
+ conn.close = lambda: closed.append(True)
+ 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"]
diff --git a/central/alembic/versions/0028_rtc_write_modes.py b/central/alembic/versions/0028_rtc_write_modes.py
new file mode 100644
index 0000000..0f384da
--- /dev/null
+++ b/central/alembic/versions/0028_rtc_write_modes.py
@@ -0,0 +1,47 @@
+"""RTC central write modes per installation, candidate preview/approval table, richer audit + rollback tracking.
+
+Revision ID: 0028_rtc_write_modes
+Revises: 0027_rtc_auto_apply
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = "0028_rtc_write_modes"
+down_revision = "0027_rtc_auto_apply"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column("installations", sa.Column("rtc_write_mode", sa.String(24), nullable=False, server_default="DISABLED"))
+ op.execute("UPDATE installations SET rtc_write_mode = 'ENABLED' WHERE rtc_auto_apply_authorized") # keeps an existing explicit authorization
+ for name, kind in (("rule_version", sa.String(32)), ("company_crt", sa.String(8)), ("authorization_origin", sa.String(48)), ("description_snapshot", sa.String(255)),
+ ("installation_uuid", sa.String(64)), ("rtc_status", sa.String(24)), ("action", sa.String(16)), ("rollback_state", sa.String(16)), ("rollback_of", sa.Integer()),
+ ("local_catalog_state", sa.String(32))):
+ op.add_column("rtc_write_audits", sa.Column(name, kind, nullable=True))
+ op.create_index("ix_rtc_write_audits_rollback_of", "rtc_write_audits", ["rollback_of"])
+ op.create_table(
+ "rtc_candidates",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False, index=True),
+ sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False, index=True),
+ sa.Column("product_code", sa.String(64), nullable=False),
+ sa.Column("description", sa.String(255), nullable=True),
+ sa.Column("ncm", sa.String(16), nullable=True),
+ sa.Column("current_value", sa.String(16), nullable=True),
+ sa.Column("proposed", sa.String(16), nullable=False),
+ sa.Column("cst_ibscbs", sa.String(8), nullable=True),
+ sa.Column("rule_id", sa.String(32), nullable=False),
+ sa.Column("basis", sa.String(255), nullable=True),
+ sa.Column("status", sa.String(16), nullable=False, server_default="PENDING", index=True),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.UniqueConstraint("installation_id", "product_code", name="uq_rtc_candidate_product"),
+ )
+
+
+def downgrade():
+ op.drop_table("rtc_candidates")
+ op.drop_index("ix_rtc_write_audits_rollback_of", table_name="rtc_write_audits")
+ for name in ("rule_version", "company_crt", "authorization_origin", "description_snapshot", "installation_uuid", "rtc_status", "action", "rollback_state", "rollback_of", "local_catalog_state"):
+ op.drop_column("rtc_write_audits", name)
+ op.drop_column("installations", "rtc_write_mode")
diff --git a/central/app/api.py b/central/app/api.py
index 1a4bbce..edea918 100644
--- a/central/app/api.py
+++ b/central/app/api.py
@@ -11,8 +11,8 @@ from .classification import ENGINE_VERSION, Classifier
from .config import settings
from .datasets.service import DatasetService
from .jobs import SYNC_KINDS, claim_next_job, complete_job, create_job, fail_job, schedule_automatic_sync
-from .rtc_audit import record_rtc_write_audits
-from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, resolve_stale_fiscal_blockers, upsert_blocker
+from .rtc_audit import job_authorizations, record_rtc_write_audits
+from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, resolve_stale_fiscal_blockers, upsert_blocker, upsert_blockers_bulk
from .context_keys import validate_entry
from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job
from .events import record_event
@@ -395,7 +395,7 @@ def router(session_factory, bootstrap_token):
session.commit()
logger.info("job claimed job_id=%s agent_id=%s kind=%s", job.id, agent.id, job.kind)
view = _job_view(job)
- view["authorizations"] = {"rtc_auto_apply": bool(installation is not None and installation.rtc_auto_apply_authorized)} # decided centrally, per installation, default OFF
+ view["authorizations"] = job_authorizations(session, installation) if installation is not None else {"rtc_write_mode": "DISABLED"} # decided centrally, per installation, default OFF
return view
finally: session.close()
@@ -426,12 +426,11 @@ def router(session_factory, bootstrap_token):
result_summary = payload.get("result_summary", {})
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
pass_started = datetime.now(timezone.utc)
- for blocker in result_summary.get("blocked_products", []):
- upsert_blocker(session, agent.tenant_id, blocker, installation_id=installation.id if installation else None)
+ upsert_blockers_bulk(session, agent.tenant_id, list(result_summary.get("blocked_products", [])), installation_id=installation.id if installation else None)
for product_code in result_summary.get("resolved_products", []):
resolve_product_blockers(session, agent.tenant_id, product_code, installation_id=installation.id if installation else None)
if installation is not None:
- record_rtc_write_audits(session, tenant_id=agent.tenant_id, installation=installation, agent=agent, job_id=job_id, rtc_auto_apply=result_summary.get("rtc_auto_apply"))
+ record_rtc_write_audits(session, tenant_id=agent.tenant_id, installation=installation, agent=agent, job_id=job_id, rtc_auto_apply=result_summary.get("rtc_auto_apply"), evaluated_all=bool((result_summary.get("fiscal_context") or {}).get("evaluated_all")))
if installation is not None and (result_summary.get("fiscal_context") or {}).get("evaluated_all"):
resolve_stale_fiscal_blockers(session, agent.tenant_id, installation.id, pass_started)
if not complete_job(session, job, result_summary): raise HTTPException(409, "job is not RUNNING")
diff --git a/central/app/blocked_products.py b/central/app/blocked_products.py
index 042a5fe..9ff389c 100644
--- a/central/app/blocked_products.py
+++ b/central/app/blocked_products.py
@@ -7,19 +7,26 @@ from .models import BlockedProduct
from .operational_state import blocker_fingerprint
-def upsert_blocker(session, tenant_id: int, payload: dict, *, installation_id: int | None = None) -> BlockedProduct:
+def _fingerprint_of(tenant_id: int, payload: dict, installation_id: int | None) -> str:
supplied_fingerprint = payload.get("blocker_fingerprint")
if installation_id is None:
- fingerprint = supplied_fingerprint or blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", ""))
+ return supplied_fingerprint or blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", ""))
elif supplied_fingerprint:
- fingerprint = blocker_fingerprint(supplied_fingerprint, "", "", "", "", "", installation_id=installation_id)
+ return blocker_fingerprint(supplied_fingerprint, "", "", "", "", "", installation_id=installation_id)
else:
- fingerprint = blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", ""), installation_id=installation_id)
- row = session.scalar(select(BlockedProduct).where(BlockedProduct.blocker_fingerprint == fingerprint))
+ return blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", ""), installation_id=installation_id)
+
+
+def upsert_blocker(session, tenant_id: int, payload: dict, *, installation_id: int | None = None, commit: bool = True, existing: dict | None = None) -> BlockedProduct:
+ """`existing` ({fingerprint: row}) and `commit=False` let a caller upsert thousands of blockers with one lookup per chunk and one commit (see upsert_blockers_bulk)."""
+ fingerprint = _fingerprint_of(tenant_id, payload, installation_id)
+ row = existing.get(fingerprint) if existing is not None else session.scalar(select(BlockedProduct).where(BlockedProduct.blocker_fingerprint == fingerprint))
now = datetime.now(timezone.utc)
if row is None:
row = BlockedProduct(tenant_id=tenant_id, installation_id=installation_id, company=payload.get("company", ""), product_code=str(payload.get("product_code", "")), gtin=payload.get("gtin"), description=payload.get("description"), blocker_type=payload.get("blocker_type", "BLOCKED_MISSING_EVIDENCE"), field=payload.get("field", ""), reason_code=payload.get("reason_code", ""), evidence_summary=payload.get("evidence_summary") or {}, input_fingerprint=payload.get("input_fingerprint", ""), blocker_fingerprint=fingerprint, first_seen_at=now, last_seen_at=now)
session.add(row)
+ if existing is not None:
+ existing[fingerprint] = row
else:
row.last_seen_at = now
row.evidence_summary = payload.get("evidence_summary") or row.evidence_summary
@@ -27,10 +34,29 @@ def upsert_blocker(session, tenant_id: int, payload: dict, *, installation_id: i
if row.installation_id is None and installation_id is not None:
row.installation_id = installation_id
row.resolved_at = None
- session.commit()
+ if commit:
+ session.commit()
return row
+BULK_CHUNK = 1000
+
+
+def upsert_blockers_bulk(session, tenant_id: int, payloads: list, *, installation_id: int | None = None) -> int:
+ """Same semantics as `upsert_blocker` per item, but O(chunks) statements instead of O(products): one IN-lookup per chunk, one commit at the end."""
+ n = 0
+ for start in range(0, len(payloads), BULK_CHUNK):
+ chunk = [p for p in payloads[start:start + BULK_CHUNK] if isinstance(p, dict)]
+ prints = {_fingerprint_of(tenant_id, p, installation_id) for p in chunk}
+ existing = {r.blocker_fingerprint: r for r in session.scalars(select(BlockedProduct).where(BlockedProduct.blocker_fingerprint.in_(prints)))} if prints else {}
+ for payload in chunk:
+ upsert_blocker(session, tenant_id, payload, installation_id=installation_id, commit=False, existing=existing)
+ n += 1
+ session.flush()
+ session.commit()
+ return n
+
+
def list_blockers(session, tenant_id: int, *, input_fingerprint: str | None = None, blocker_type: str | None = None, field: str | None = None, installation_id: int | None = None):
query = select(BlockedProduct).where(BlockedProduct.tenant_id == tenant_id, BlockedProduct.resolved_at.is_(None))
if installation_id is not None:
diff --git a/central/app/cclasstrib_resolver.py b/central/app/cclasstrib_resolver.py
index 4093fde..22139e7 100644
--- a/central/app/cclasstrib_resolver.py
+++ b/central/app/cclasstrib_resolver.py
@@ -8,17 +8,27 @@ Nothing here guesses. A class is proposed only when an EXPLICIT rule, backed by
Operation assumption (documented, product-level default): an ordinary domestic sale of the good. Operation-dependent classes (buyer is a public body,
exports, ZFM/ALC, credit presumed, ...) cannot be carried by a product default; when the annex offers such alternatives the answer is AMBIGUOUS.
+Rule classes (what a rule is ALLOWED to do)
+ AUTO_APPLY_SAFE positive, explicit evidence: may be written to the ERP (only when every runtime gate also holds)
+ VALIDATION_ONLY used to validate/explain, never proposes a value
+ SUGGESTION_ONLY diagnostic suggestion shown to a human; NEVER authorizes an UPDATE
+ BLOCKED_BY_EVIDENCE the evidence is missing/ambiguous: safe-block with a human reason
+
Rules
- RTC-R0 RTC disabled in the ERP -> NOT_APPLICABLE
- RTC-R1 Simples family (CRT 1/2/4) -> INSUFFICIENT_EVIDENCE (NT 2025.002 v1.51: orientation for these taxpayers pending)
- RTC-R2 no valid 8-digit NCM (decision engine did not confirm) -> INSUFFICIENT_EVIDENCE
- RTC-A1 NCM listed in a REVOKED annex or an annex without a unique product-default class, or under a conditional item text -> AMBIGUOUS
- RTC-A2 NCM listed in more than one annex -> AMBIGUOUS
- RTC-A3 NCM in a chapter whose special regime depends on the nature/operation of the good, not on a code list -> AMBIGUOUS
- RTC-R3 NCM listed in exactly one annex whose product-default class is unique, item text has no condition -> RESOLVED_WITH_EXPLICIT_RULE
- RTC-R4 NCM listed in NO annex and in no nature-based chapter -> RESOLVED_WITH_EXPLICIT_RULE 000001 (integral taxation, LC 214 art. 4 general rule)
-A proposed class must additionally be present in the local ERP catalog, MATCH the official table, be in force today, be usable in NF-e/NFC-e (the SGV's own
-lookup filter) and carry a CST that exists; otherwise the answer degrades to INSUFFICIENT_EVIDENCE with the exact reason.
+ RTC-R0 RTC disabled in the ERP VALIDATION_ONLY -> NOT_APPLICABLE
+ RTC-G1 local RTC catalog failed integrity BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE
+ RTC-R1 regime without an official rule (Simples family, unknown) BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE
+ RTC-R2 no valid 8-digit NCM (decision engine did not confirm) BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE
+ RTC-A1 annex without a unique product-default class / conditional text / revoked annex -> AMBIGUOUS
+ RTC-A2 NCM listed in more than one annex -> AMBIGUOUS
+ RTC-A3 chapter whose regime depends on the nature/operation of the good -> AMBIGUOUS
+ RTC-R3 NCM listed in EXACTLY ONE annex with a unique default class, condition-free item, regime normal (CRT 3)
+ AUTO_APPLY_SAFE -> RESOLVED_WITH_EXPLICIT_RULE
+ RTC-R4 NCM in NO annex and in no nature-based chapter: full taxation is the residual reading of LC 214 art. 4
+ SUGGESTION_ONLY -> SUGGESTION_ONLY (000001). Absence of a special regime is NOT positive evidence: never written.
+ RTC-G2 proposed class missing/different in the local SGV catalog, out of force or unusable in NF-e/NFC-e BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE (RTC_LOCAL_CATALOG_INCOMPATIBLE)
+A proposed class must additionally be MATCH_SAFE against the official table (local SGV catalog confirms every attribute that matters), in force today, usable in
+NF-e/NFC-e (the SGV's own lookup filter) and carry a CST that exists; otherwise the answer degrades with the exact reason.
"""
from __future__ import annotations
@@ -32,7 +42,12 @@ from . import rtc_reference_data as ref
SIMPLES_FAMILY = frozenset({"1", "2", "4"})
RESOLVED_STATUSES = ("RESOLVED_DETERMINISTIC", "RESOLVED_WITH_EXPLICIT_RULE")
-STATUSES = RESOLVED_STATUSES + ("AMBIGUOUS", "INSUFFICIENT_EVIDENCE", "NOT_APPLICABLE")
+STATUSES = RESOLVED_STATUSES + ("SUGGESTION_ONLY", "AMBIGUOUS", "INSUFFICIENT_EVIDENCE", "NOT_APPLICABLE")
+RULE_VERSION = "rtc-rules-v2"
+AUTO_APPLY_SAFE, VALIDATION_ONLY, SUGGESTION_ONLY, BLOCKED_BY_EVIDENCE = "AUTO_APPLY_SAFE", "VALIDATION_ONLY", "SUGGESTION_ONLY", "BLOCKED_BY_EVIDENCE"
+RULE_CLASSES = {"RTC-R0": VALIDATION_ONLY, "RTC-G1": BLOCKED_BY_EVIDENCE, "RTC-R1": BLOCKED_BY_EVIDENCE, "RTC-R2": BLOCKED_BY_EVIDENCE, "RTC-A1": BLOCKED_BY_EVIDENCE, "RTC-A2": BLOCKED_BY_EVIDENCE,
+ "RTC-A3": BLOCKED_BY_EVIDENCE, "RTC-R3": AUTO_APPLY_SAFE, "RTC-R4": SUGGESTION_ONLY, "RTC-G2": BLOCKED_BY_EVIDENCE}
+HUMAN_REASONS = {"SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": "Enquadramento RTC depende de regra ainda não determinável com segurança.", "COMPANY_REGIME_UNKNOWN": "Regime tributário da empresa não determinado."}
# Goods annexes whose product-default class is unique in the official table (verified by `verify_annex_class_map`).
ANNEX_DEFAULT_CLASS = {"I": "200003", "VII": "200034", "VIII": "200035", "XV": "200014"} # IX is out: 515001 (deferral with the same inputs) is a sibling class
@@ -55,14 +70,23 @@ class Resolution:
evidence: list = field(default_factory=list)
source_references: list = field(default_factory=list)
+ @property
+ def rule_class(self) -> str:
+ return RULE_CLASSES.get(self.rule_id, BLOCKED_BY_EVIDENCE)
+
@property
def auto_apply_candidate(self) -> bool:
- return self.status in RESOLVED_STATUSES and bool(self.resolved_cclasstrib)
+ """Only an AUTO_APPLY_SAFE rule with a proven class may ever become a write. Suggestions never do."""
+ return self.rule_class == AUTO_APPLY_SAFE and self.status in RESOLVED_STATUSES and bool(self.resolved_cclasstrib)
+
+ @property
+ def suggested_cclasstrib(self) -> str | None:
+ return self.resolved_cclasstrib if self.status == "SUGGESTION_ONLY" else None
def as_dict(self) -> dict:
- return {"status": self.status, "rule_id": self.rule_id, "reason": self.reason, "resolved_cclasstrib": self.resolved_cclasstrib, "resolved_cst_ibscbs": self.resolved_cst_ibscbs,
- "confidence_type": self.confidence_type, "inputs_used": self.inputs_used, "evidence": self.evidence, "source_references": self.source_references,
- "auto_apply_candidate": self.auto_apply_candidate}
+ return {"status": self.status, "rule_id": self.rule_id, "rule_class": self.rule_class, "rule_version": RULE_VERSION, "reason": self.reason, "resolved_cclasstrib": self.resolved_cclasstrib,
+ "resolved_cst_ibscbs": self.resolved_cst_ibscbs, "confidence_type": self.confidence_type, "inputs_used": self.inputs_used, "evidence": self.evidence,
+ "source_references": self.source_references, "auto_apply_candidate": self.auto_apply_candidate}
def _digits(value) -> str:
@@ -85,34 +109,50 @@ def _num(value):
def compare_catalogs(local_rows: dict, today: date | None = None) -> dict:
"""local_rows: {code: {cst, tipo_aliquota, p_red_ibs, p_red_cbs, ind_nfe, ind_nfce, start, end}} read from the ERP. Never writes anywhere.
- Per code: MATCH | LOCAL_ONLY | OFFICIAL_ONLY | MISMATCH | EXPIRED (official table is the reference)."""
+ Per code (the official table is the reference):
+ MATCH_SAFE every attribute the local table carries that matters to a product default agrees (CST, reductions, NF-e/NFC-e usability)
+ MATCH_WITH_NON_MATERIAL_DIFFERENCE only a scheduled local end date (class still in force today) differs
+ MISMATCH CST, reductions or NF-e/NFC-e usability differ
+ EXPIRED the official class ended
+ MISSING_LOCAL official class absent from the SGV catalog (local outdated)
+ MISSING_OFFICIAL local class the official table does not know"""
today = today or date.today()
official = _official_by_code()
result, details = {}, {}
for code in sorted(set(local_rows) | set(official)):
loc, off = local_rows.get(code), official.get(code)
if off is None:
- result[code] = "LOCAL_ONLY"
+ result[code] = "MISSING_OFFICIAL"
elif off.get("end") and date.fromisoformat(off["end"]) < today:
result[code] = "EXPIRED"
details[code] = ["OFFICIAL_END_PASSED"] + (["PRESENT_LOCALLY"] if loc is not None else [])
elif loc is None:
- result[code] = "OFFICIAL_ONLY"
+ result[code] = "MISSING_LOCAL"
else:
- problems = []
+ problems, soft, info = [], [], []
if str(loc.get("cst") or "").strip() != off["cst"]:
problems.append("CST")
if "p_red_ibs" in loc and "p_red_cbs" in loc and (_num(loc.get("p_red_ibs")) != _num(off["p_red_ibs"]) or _num(loc.get("p_red_cbs")) != _num(off["p_red_cbs"])):
problems.append("REDUCTION") # only attributes the local table actually carries are compared
if "ind_nfe" in loc and "ind_nfce" in loc and (bool(loc.get("ind_nfe")) != off["ind_nfe"] or bool(loc.get("ind_nfce")) != off["ind_nfce"]):
problems.append("DOCUMENT_FLAGS")
- result[code] = "MISMATCH" if problems else "MATCH"
- if problems:
- details[code] = problems
- counts = {k: sum(1 for v in result.values() if v == k) for k in ("MATCH", "LOCAL_ONLY", "OFFICIAL_ONLY", "MISMATCH", "EXPIRED")}
- material = counts["MISMATCH"] + counts["LOCAL_ONLY"] # a local class the official table contradicts or does not know
- expired_present = sum(1 for c, d in details.items() if "PRESENT_LOCALLY" in d)
- compat = "FULL" if counts["OFFICIAL_ONLY"] == 0 and expired_present == 0 and material == 0 else ("PARTIAL_LOCAL_OUTDATED" if material == 0 else "MATERIAL_MISMATCH")
+ start, end = str(loc.get("start") or "")[:10], str(loc.get("end") or "")[:10]
+ if start and date.fromisoformat(start) > today:
+ problems.append("LOCAL_NOT_IN_FORCE_YET")
+ elif start and off.get("start") and start != off["start"]:
+ info.append("VIGENCY_START_DIFFERS_BOTH_IN_FORCE") # informational: the class is in force today on both sides, so the emission effect is identical
+ if end and date.fromisoformat(end) < today:
+ problems.append("LOCAL_CLASS_ENDED")
+ elif end:
+ soft.append("LOCAL_END_SCHEDULED") # the class still works today; the local table schedules an end the official one does not
+ result[code] = "MISMATCH" if problems else ("MATCH_WITH_NON_MATERIAL_DIFFERENCE" if soft else "MATCH_SAFE")
+ if problems or soft or info:
+ details[code] = problems + soft + info
+ keys = ("MATCH_SAFE", "MATCH_WITH_NON_MATERIAL_DIFFERENCE", "MISMATCH", "EXPIRED", "MISSING_LOCAL", "MISSING_OFFICIAL")
+ counts = {k: sum(1 for v in result.values() if v == k) for k in keys}
+ material = counts["MISMATCH"] + counts["MISSING_OFFICIAL"] # a local class the official table contradicts or does not know
+ expired_present = sum(1 for d in details.values() if "PRESENT_LOCALLY" in d)
+ compat = "FULL" if counts["MISSING_LOCAL"] == 0 and expired_present == 0 and material == 0 else ("PARTIAL_LOCAL_OUTDATED" if material == 0 else "MATERIAL_MISMATCH")
return {"per_code": result, "details": details, "counts": counts, "expired_present_locally": expired_present, "compatibility": compat,
"official_catalog": {"sha256": ref.OFFICIAL_CATALOG["sha256"], "publication": ref.OFFICIAL_CATALOG["publication"], "file": ref.OFFICIAL_CATALOG["file"]}}
@@ -174,7 +214,7 @@ def _validate_class(code: str, local_rows: dict, compat: dict, today: date):
if loc is None:
return "CLASS_NOT_IN_LOCAL_CATALOG"
state = compat["per_code"].get(code)
- if state != "MATCH":
+ if state != "MATCH_SAFE":
return f"LOCAL_OFFICIAL_{state}"
if official["start"] and date.fromisoformat(official["start"]) > today:
return "CLASS_NOT_YET_IN_FORCE"
@@ -198,8 +238,9 @@ def resolve_cclasstrib(product: dict, company: dict | None, ctx: dict, local_row
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-G1", "LOCAL_RTC_CATALOG_INVALID", inputs_used=used)
if crt in SIMPLES_FAMILY:
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R1", "SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT", inputs_used=used,
- source_references=_references([{"document": "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026)", "note": "IBS/CBS orientations for CRT 1/2/4 pending; taxation from 2027 (LC 214 art. 348)"}]))
- if not crt:
+ source_references=_references([{"document": "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026)", "note": "IBS/CBS orientations for CRT 1/2/4 pending"},
+ {"document": "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026)", "note": "art. 1 par. 1: DF-e obligation for Simples optantes starts 2027-01-01"}]))
+ if crt != "3":
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R1", "COMPANY_REGIME_UNKNOWN", inputs_used=used)
if len(ncm) != 8 or product.get("ncm_valid") is not True:
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R2", "NO_VALID_NCM", inputs_used=used)
@@ -229,5 +270,5 @@ def resolve_cclasstrib(product: dict, company: dict | None, ctx: dict, local_row
problem = _validate_class("000001", local_rows, compat, today)
if problem:
return Resolution("INSUFFICIENT_EVIDENCE", "RTC-G2", problem, inputs_used=used, source_references=_references())
- return Resolution("RESOLVED_WITH_EXPLICIT_RULE", "RTC-R4", "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER", "000001", "000", "EXCLUSION_OF_ALL_SPECIAL_REGIMES", used,
- [{"annex_hits": 0, "chapter": ncm[:2]}], _references())
+ return Resolution("SUGGESTION_ONLY", "RTC-R4", "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER", "000001", "000", "EXCLUSION_OF_ALL_SPECIAL_REGIMES", used,
+ [{"annex_hits": 0, "chapter": ncm[:2], "note": "absence of a special regime is not positive evidence: suggestion only, never written"}], _references())
diff --git a/central/app/models.py b/central/app/models.py
index ec2debc..40a58fc 100644
--- a/central/app/models.py
+++ b/central/app/models.py
@@ -283,7 +283,8 @@ class Installation(Base):
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_changed: Mapped[int | None] = mapped_column(Integer, nullable=True)
# RTC auto-apply is authorized centrally per installation and is OFF until an operator explicitly turns it on (the Agent has its own second switch).
- rtc_auto_apply_authorized: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
+ rtc_auto_apply_authorized: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # legacy (0027); rtc_write_mode is the source of truth
+ rtc_write_mode: Mapped[str] = mapped_column(String(24), default="DISABLED", server_default="DISABLED") # DISABLED | DRY_RUN | APPROVAL_REQUIRED | ENABLED
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -755,4 +756,33 @@ class RtcWriteAudit(Base):
official_source_version: Mapped[dict] = mapped_column(JSON, default=dict)
local_catalog_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
written_at: Mapped[str | None] = mapped_column(String(40), nullable=True)
+ rule_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ company_crt: Mapped[str | None] = mapped_column(String(8), nullable=True)
+ authorization_origin: Mapped[str | None] = mapped_column(String(48), nullable=True)
+ description_snapshot: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ installation_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ rtc_status: Mapped[str | None] = mapped_column(String(24), nullable=True)
+ local_catalog_state: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ action: Mapped[str | None] = mapped_column(String(16), nullable=True) # APPLY | ROLLBACK
+ rollback_state: Mapped[str | None] = mapped_column(String(16), nullable=True) # REQUESTED | DONE | BLOCKED (on the original APPLY record)
+ rollback_of: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
+
+
+class RtcCandidate(Base):
+ """Server-side preview of the products the Agent could fill (R3 only). Rows are proposals; only the Agent's re-derivation at write time decides."""
+ __tablename__ = "rtc_candidates"
+ __table_args__ = (UniqueConstraint("installation_id", "product_code", name="uq_rtc_candidate_product"),)
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
+ installation_id: Mapped[int] = mapped_column(ForeignKey("installations.id"), index=True)
+ product_code: Mapped[str] = mapped_column(String(64))
+ description: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ ncm: Mapped[str | None] = mapped_column(String(16), nullable=True)
+ current_value: Mapped[str | None] = mapped_column(String(16), nullable=True)
+ proposed: Mapped[str] = mapped_column(String(16))
+ cst_ibscbs: Mapped[str | None] = mapped_column(String(8), nullable=True)
+ rule_id: Mapped[str] = mapped_column(String(32))
+ basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ status: Mapped[str] = mapped_column(String(16), default="PENDING", server_default="PENDING", index=True) # PENDING | APPROVED | REJECTED | APPLIED | STALE
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
diff --git a/central/app/panel_presentation.py b/central/app/panel_presentation.py
index 324f8a4..fe2d2d9 100644
--- a/central/app/panel_presentation.py
+++ b/central/app/panel_presentation.py
@@ -121,7 +121,8 @@ def job_kind_label(value):
_FISCAL_LABELS = {
- "CCLASSTRIB_MISSING": "cClassTrib ausente", "CCLASSTRIB_INVALID_FORMAT": "cClassTrib com formato inválido", "CCLASSTRIB_NOT_IN_CATALOG": "cClassTrib fora do catálogo local",
+ "CCLASSTRIB_MISSING": "cClassTrib ausente", "CCLASSTRIB_MISSING_UNRESOLVED": "cClassTrib não determinado", "CCLASSTRIB_AMBIGUOUS": "cClassTrib ambíguo", "CCLASSTRIB_CONFLICT": "cClassTrib conflitante",
+ "CCLASSTRIB_OFFICIAL_MISMATCH": "cClassTrib diverge da tabela oficial", "RTC_LOCAL_CATALOG_INCOMPATIBLE": "Catálogo RTC incompatível", "RTC_RULE_EVIDENCE_MISSING": "Regra fiscal insuficiente", "CCLASSTRIB_INVALID_FORMAT": "cClassTrib com formato inválido", "CCLASSTRIB_NOT_IN_CATALOG": "cClassTrib fora do catálogo local",
"RTC_CST_MISSING": "CST IBS/CBS ausente no catálogo", "RTC_CATALOG_INVALID": "Catálogo RTC local inválido",
"PIS_CST_MISSING": "CST PIS ausente", "PIS_CST_INVALID": "CST PIS inválido", "PIS_RATE_MISSING": "Alíquota PIS ausente", "PIS_RATE_INVALID": "Alíquota PIS inválida", "PIS_CST_INCIDENCE_MISMATCH": "CST PIS incompatível com a incidência",
"COFINS_CST_MISSING": "CST COFINS ausente", "COFINS_CST_INVALID": "CST COFINS inválido", "COFINS_RATE_MISSING": "Alíquota COFINS ausente", "COFINS_RATE_INVALID": "Alíquota COFINS inválida", "COFINS_CST_INCIDENCE_MISMATCH": "CST COFINS incompatível com a incidência",
@@ -161,7 +162,7 @@ def priority_label(value):
return _human_label(value, {"CRITICAL": "Crítica", "HIGH": "Alta", "NORMAL": "Média", "MEDIUM": "Média", "LOW": "Baixa"})
-def rtc_status_view(summary, authorized=False):
+def rtc_status_view(summary, mode="DISABLED"):
"""Discreet Reforma Tributaria status from the last completed job's `fiscal_context`, exactly as the Agent OBSERVED it in that installation's own database.
None when the Agent did not report it (older Agents)."""
context = (summary or {}).get("fiscal_context") if isinstance(summary, dict) else None
@@ -175,7 +176,7 @@ def rtc_status_view(summary, authorized=False):
observed = None
source = context.get("rtc_source") or "PARAMETROS.USAR_REFORMA_TRIBUTARIA"
products = _rtc_products_view(context.get("rtc_resolution"), bool(context["rtc_enabled"]))
- return {"products": products, "apply": _rtc_apply_view(summary.get("rtc_auto_apply"), authorized), "enabled": bool(context["rtc_enabled"]), "label": "Ativa" if context["rtc_enabled"] else "Desativada", "supported": bool(context.get("rtc_supported")),
+ return {"products": products, "apply": _rtc_apply_view(summary.get("rtc_auto_apply"), mode), "enabled": bool(context["rtc_enabled"]), "label": "Ativa" if context["rtc_enabled"] else "Desativada", "supported": bool(context.get("rtc_supported")),
"catalog_valid": bool(context.get("rtc_catalog_valid")), "observed_at": observed, "conflict": bool(context.get("rtc_param_conflict")),
"title": f"Lido no banco do cliente ({source})" + (f" em {observed}" if observed else "") + (" - linhas de PARAMETROS divergentes: RTC tratada como desativada" if context.get("rtc_param_conflict") else "")}
@@ -187,23 +188,22 @@ def _rtc_products_view(resolution, enabled):
total = int(resolution["total"])
valid = int(resolution.get("valid_existing", 0))
ready = int(resolution.get("ready_to_apply", 0))
+ conflicts = int(resolution.get("conflicts", 0)) + int(resolution.get("official_mismatch", 0))
+ catalog = int(resolution.get("catalog_incompatible", 0))
blocked = total - valid - ready
pending = total - valid
- return {"total": total, "valid": valid, "ready": ready, "blocked": blocked, "pending": pending,
+ return {"total": total, "valid": valid, "ready": ready, "blocked": blocked, "pending": pending, "conflicts": conflicts, "catalog_incompatible": catalog, "suggestion_only": int(resolution.get("suggestion_only", 0)),
"headline": f"Reforma Tributária ativa — cClassTrib pendente em {pending} produto{'s' if pending != 1 else ''}" if pending else "Reforma Tributária ativa — todos os produtos com cClassTrib válido",
"catalog": (resolution.get("catalog_compatibility") or {}).get("compatibility")}
-def _rtc_apply_view(report, authorized):
- """State of the (default OFF) automatic fill, for the operator. Never claims a write happened unless the Agent reported it."""
- gate = (report or {}).get("gate") or {}
- applied = len((report or {}).get("applied") or [])
- missing = gate.get("missing") or []
- labels = {"FIREBIRD_WRITES": "escrita no Firebird desligada no Agent", "RTC_AUTO_APPLY_ENABLED": "chave RTC_AUTO_APPLY_ENABLED desligada no Agent", "HUB_AUTHORIZATION": "sem autorização no Hub"}
- if gate.get("allowed"):
- state = "Ativo"
- elif authorized:
- state = "Autorizado no Hub, aguardando o Agent (" + "; ".join(labels.get(m, m) for m in missing if m != "HUB_AUTHORIZATION") + ")" if any(m != "HUB_AUTHORIZATION" for m in missing) else "Autorizado no Hub"
- else:
- state = "Desativado"
- return {"authorized": bool(authorized), "state": state, "applied_last_job": applied, "aborted_last_job": len((report or {}).get("aborted") or []), "mode": (report or {}).get("mode")}
+RTC_MODE_LABELS = {"DISABLED": "Desativada", "DRY_RUN": "Dry-run", "APPROVAL_REQUIRED": "Aprovação manual", "ENABLED": "Automática"}
+RTC_MODE_EXPLANATION = "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente."
+
+
+def _rtc_apply_view(report, mode):
+ """State of the (default DISABLED) centrally controlled fill. Never claims a write happened unless the Agent reported it."""
+ mode = mode if mode in RTC_MODE_LABELS else "DISABLED"
+ report = report or {}
+ return {"mode": mode, "state": RTC_MODE_LABELS[mode], "explanation": RTC_MODE_EXPLANATION, "applied_last_job": len(report.get("applied") or []), "aborted_last_job": len(report.get("aborted") or []),
+ "ready_total": report.get("ready_total"), "capability": report.get("capability"), "authorized": mode in ("ENABLED", "APPROVAL_REQUIRED")}
diff --git a/central/app/rtc_audit.py b/central/app/rtc_audit.py
index 3c5a217..ff30a0e 100644
--- a/central/app/rtc_audit.py
+++ b/central/app/rtc_audit.py
@@ -1,36 +1,103 @@
-"""Persists the Agent's RTC auto-apply audit records (append-only) and mirrors a summary event for the panel."""
+"""Persists the Agent's RTC write audit records (append-only), the candidate preview and the rollback bookkeeping; mirrors a summary event for the panel."""
from __future__ import annotations
+from sqlalchemy import select, update
+
from .events import record_event
-from .models import RtcWriteAudit
+from .models import RtcCandidate, RtcWriteAudit
MAX_AUDITS_PER_JOB = 2000
+MAX_CANDIDATES = 25000
+WRITE_MODES = ("DISABLED", "DRY_RUN", "APPROVAL_REQUIRED", "ENABLED")
+MAX_APPROVED_PER_JOB = 500
+MAX_ROLLBACKS_PER_JOB = 500
-def record_rtc_write_audits(session, *, tenant_id: int, installation, agent, job_id: int | None, rtc_auto_apply: dict | None) -> int:
- """Stores every applied/aborted write of the job. Never trusts the Agent for identity: tenant/installation/agent come from the authenticated context."""
+def _clip(value, size):
+ return None if value is None else str(value)[:size]
+
+
+def job_authorizations(session, installation) -> dict:
+ """What the authenticated job carries to the Agent. Decided centrally per installation; the Agent has no local switch."""
+ mode = installation.rtc_write_mode if installation.rtc_write_mode in WRITE_MODES else "DISABLED"
+ out = {"rtc_write_mode": mode, "rtc_approved_codes": [], "rtc_rollbacks": []}
+ if mode == "APPROVAL_REQUIRED":
+ out["rtc_approved_codes"] = list(session.scalars(select(RtcCandidate.product_code).where(RtcCandidate.installation_id == installation.id, RtcCandidate.status == "APPROVED").limit(MAX_APPROVED_PER_JOB)))
+ for audit in session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.action == "APPLY", RtcWriteAudit.outcome == "APPLIED",
+ RtcWriteAudit.rollback_state == "REQUESTED").limit(MAX_ROLLBACKS_PER_JOB)):
+ out["rtc_rollbacks"].append({"audit_id": audit.id, "product_code": audit.product_code, "written_value": audit.new_value, "rule_id": audit.rule_id})
+ return out
+
+
+def _store_preview(session, tenant_id, installation, summary, evaluated_all):
+ preview = summary.get("preview")
+ if not isinstance(preview, list) or installation.rtc_write_mode == "DISABLED":
+ return
+ existing = {c.product_code: c for c in session.scalars(select(RtcCandidate).where(RtcCandidate.installation_id == installation.id))}
+ seen = set()
+ for item in preview[:MAX_CANDIDATES]:
+ code = _clip(item.get("product_code"), 64)
+ if not code or not item.get("proposed"):
+ continue
+ seen.add(code)
+ row = existing.get(code)
+ if row is None:
+ session.add(RtcCandidate(tenant_id=tenant_id, installation_id=installation.id, product_code=code, description=_clip(item.get("description"), 255), ncm=_clip(item.get("ncm"), 16),
+ current_value=_clip(item.get("current") or None, 16), proposed=_clip(item["proposed"], 16), cst_ibscbs=_clip(item.get("cst"), 8), rule_id=_clip(item.get("rule_id"), 32),
+ basis=_clip(item.get("basis"), 255), status="PENDING"))
+ else:
+ if row.status in ("APPLIED", "STALE"):
+ row.status = "PENDING" # empty again (e.g. after a rollback) or newly eligible
+ if row.proposed != item["proposed"]:
+ row.proposed = _clip(item["proposed"], 16)
+ if row.status == "APPROVED":
+ row.status = "PENDING" # the proposal changed: the earlier approval does not carry over
+ row.description, row.ncm, row.cst_ibscbs = _clip(item.get("description"), 255), _clip(item.get("ncm"), 16), _clip(item.get("cst"), 8)
+ row.rule_id, row.basis = _clip(item.get("rule_id"), 32), _clip(item.get("basis"), 255)
+ if evaluated_all and not summary.get("preview_truncated"):
+ for code, row in existing.items():
+ if code not in seen and row.status in ("PENDING", "APPROVED"):
+ row.status = "STALE"
+
+
+def record_rtc_write_audits(session, *, tenant_id: int, installation, agent, job_id: int | None, rtc_auto_apply: dict | None, evaluated_all: bool = False) -> int:
+ """Stores every applied/aborted/rolled-back write of the job. Never trusts the Agent for identity: tenant/installation/agent come from the authenticated context."""
if not isinstance(rtc_auto_apply, dict):
return 0
- records = list(rtc_auto_apply.get("applied") or []) + list(rtc_auto_apply.get("aborted") or [])
- stored = applied = aborted = 0
+ _store_preview(session, tenant_id, installation, rtc_auto_apply, evaluated_all)
+ records = list(rtc_auto_apply.get("applied") or []) + list(rtc_auto_apply.get("aborted") or []) + list(rtc_auto_apply.get("rollbacks") or [])
+ stored = applied = aborted = rolled = 0
for item in records[:MAX_AUDITS_PER_JOB]:
if not isinstance(item, dict) or item.get("field") != "CCLASSTRIB":
continue
+ action = "ROLLBACK" if item.get("action") == "ROLLBACK" else "APPLY"
+ outcome = str(item.get("outcome", ""))[:16]
+ rollback_of = item.get("rollback_of") if action == "ROLLBACK" else None
+ code = str(item.get("product_code", ""))[:64]
session.add(RtcWriteAudit(
tenant_id=tenant_id, installation_id=installation.id, agent_id=agent.id, job_id=job_id, client=getattr(installation, "display_name", None),
- product_code=str(item.get("product_code", ""))[:64], field="CCLASSTRIB", outcome=str(item.get("outcome", ""))[:16], reason=(item.get("reason") or None) and str(item["reason"])[:96],
- old_value=(None if item.get("old_value") is None else str(item["old_value"])[:64]), new_value=(None if item.get("new_value") is None else str(item["new_value"])[:64]),
+ product_code=code, field="CCLASSTRIB", outcome=outcome, reason=(item.get("reason") or None) and str(item["reason"])[:96],
+ old_value=_clip(item.get("old_value"), 64), new_value=_clip(item.get("new_value"), 64),
rule_id=(item.get("rule_id") or None) and str(item["rule_id"])[:32], evidence=item.get("evidence") or {}, official_source_version=item.get("official_source_version") or {},
- local_catalog_hash=item.get("local_catalog_hash"), written_at=item.get("timestamp")))
+ local_catalog_hash=item.get("local_catalog_hash"), written_at=item.get("timestamp"), rule_version=_clip(item.get("rule_version"), 32), company_crt=_clip(item.get("company_crt"), 8),
+ authorization_origin=_clip(item.get("authorization_origin"), 48), description_snapshot=_clip(item.get("description_snapshot"), 255), installation_uuid=_clip(getattr(installation, "installation_id", None), 64),
+ rtc_status=_clip(item.get("rtc_status"), 24), local_catalog_state=_clip(item.get("local_catalog_state"), 32), action=action, rollback_of=rollback_of if isinstance(rollback_of, int) else None))
stored += 1
- if item.get("outcome") == "APPLIED":
+ candidate_of = (RtcCandidate.installation_id == installation.id) & (RtcCandidate.product_code == code)
+ if action == "ROLLBACK":
+ if isinstance(rollback_of, int):
+ session.execute(update(RtcWriteAudit).where(RtcWriteAudit.id == rollback_of, RtcWriteAudit.installation_id == installation.id).values(rollback_state="DONE" if outcome == "ROLLED_BACK" else "BLOCKED"))
+ if outcome == "ROLLED_BACK":
+ rolled += 1
+ session.execute(update(RtcCandidate).where(candidate_of).values(status="PENDING"))
+ elif outcome == "APPLIED":
applied += 1
+ session.execute(update(RtcCandidate).where(candidate_of).values(status="APPLIED"))
else:
aborted += 1
if stored:
- record_event(session, tenant_id=tenant_id, installation_id=installation.id, agent_id=agent.id, severity="WARNING" if applied else "INFO", event_type="RTC_AUTO_APPLY", job_id=job_id,
- message=f"cClassTrib preenchido em {applied} produto(s); {aborted} abortado(s)",
- context={"applied": applied, "aborted": aborted, "reasons": rtc_auto_apply.get("aborted_reasons") or {}, "mode": rtc_auto_apply.get("mode")})
- if stored:
- session.commit()
+ record_event(session, tenant_id=tenant_id, installation_id=installation.id, agent_id=agent.id, severity="WARNING" if (applied or rolled) else "INFO", event_type="RTC_AUTO_APPLY", job_id=job_id,
+ message=f"cClassTrib: {applied} preenchido(s), {rolled} revertido(s), {aborted} abortado(s)",
+ context={"applied": applied, "rolled_back": rolled, "aborted": aborted, "reasons": rtc_auto_apply.get("aborted_reasons") or {}, "mode": rtc_auto_apply.get("mode")})
+ session.commit()
return stored
diff --git a/central/app/rtc_findings.py b/central/app/rtc_findings.py
index 059aa4d..c2842e5 100644
--- a/central/app/rtc_findings.py
+++ b/central/app/rtc_findings.py
@@ -5,12 +5,13 @@ the single RTC blocker of a product is refined to the cause that matters, never
"""
from __future__ import annotations
-from .cclasstrib_resolver import Resolution
+from .cclasstrib_resolver import HUMAN_REASONS, Resolution
RTC_CATALOG_REASONS = ("LOCAL_OFFICIAL_", "CLASS_NOT_IN_LOCAL_CATALOG", "CLASS_NOT_IN_OFFICIAL_TABLE", "CLASS_NOT_YET_IN_FORCE", "CLASS_NOT_USABLE_IN_NFE_NFCE", "LOCAL_RTC_CATALOG_INVALID")
EVIDENCE_REASONS = ("NO_VALID_NCM", "COMPANY_REGIME_UNKNOWN")
-RTC_TYPES = ("CCLASSTRIB_MISSING", "CCLASSTRIB_MISSING_UNRESOLVED", "CCLASSTRIB_AMBIGUOUS", "CCLASSTRIB_CONFLICT", "CCLASSTRIB_OFFICIAL_MISMATCH", "RTC_LOCAL_CATALOG_OUTDATED",
+RTC_TYPES = ("CCLASSTRIB_MISSING", "CCLASSTRIB_MISSING_UNRESOLVED", "CCLASSTRIB_AMBIGUOUS", "CCLASSTRIB_CONFLICT", "CCLASSTRIB_OFFICIAL_MISMATCH", "RTC_LOCAL_CATALOG_INCOMPATIBLE",
"RTC_RULE_EVIDENCE_MISSING", "CCLASSTRIB_INVALID_FORMAT", "CCLASSTRIB_NOT_IN_CATALOG", "RTC_CST_MISSING", "RTC_CATALOG_INVALID")
+BAD_LOCAL_STATES = ("MISMATCH", "EXPIRED", "MISSING_OFFICIAL")
def _text(value) -> str:
@@ -19,8 +20,9 @@ def _text(value) -> str:
def slim(resolution: Resolution) -> dict:
"""Small, audit-grade view kept in the job result (full evidence stays in the resolver output)."""
- return {"status": resolution.status, "rule_id": resolution.rule_id, "reason": resolution.reason, "resolved_cclasstrib": resolution.resolved_cclasstrib,
- "resolved_cst_ibscbs": resolution.resolved_cst_ibscbs, "confidence_type": resolution.confidence_type, "auto_apply_candidate": resolution.auto_apply_candidate}
+ return {"status": resolution.status, "rule_id": resolution.rule_id, "rule_class": resolution.rule_class, "reason": resolution.reason, "resolved_cclasstrib": resolution.resolved_cclasstrib,
+ "resolved_cst_ibscbs": resolution.resolved_cst_ibscbs, "confidence_type": resolution.confidence_type, "auto_apply_candidate": resolution.auto_apply_candidate,
+ "suggested_cclasstrib": resolution.suggested_cclasstrib}
def refine_rtc_finding(found: dict, product: dict, resolution: Resolution | None, compat_state: dict | None) -> dict:
@@ -37,19 +39,24 @@ def refine_rtc_finding(found: dict, product: dict, resolution: Resolution | None
if "CCLASSTRIB_MISSING" in kinds:
if resolution.auto_apply_candidate:
new = ("CCLASSTRIB_MISSING", f"READY_TO_APPLY:{resolution.resolved_cclasstrib}:{resolution.rule_id}")
+ elif resolution.status == "SUGGESTION_ONLY":
+ new = ("CCLASSTRIB_MISSING_UNRESOLVED", f"{resolution.rule_id}:SUGGESTION_ONLY:{resolution.resolved_cclasstrib}")
elif resolution.status == "AMBIGUOUS":
new = ("CCLASSTRIB_AMBIGUOUS", f"{resolution.rule_id}:{resolution.reason}")
elif resolution.reason.startswith(RTC_CATALOG_REASONS):
- new = ("RTC_LOCAL_CATALOG_OUTDATED", resolution.reason)
+ new = ("RTC_LOCAL_CATALOG_INCOMPATIBLE", resolution.reason)
elif resolution.reason in EVIDENCE_REASONS:
new = ("RTC_RULE_EVIDENCE_MISSING", resolution.reason)
else:
new = ("CCLASSTRIB_MISSING_UNRESOLVED", f"{resolution.rule_id}:{resolution.reason}")
- found["blockers"] = others + [{"type": new[0], "category": "RTC", "reason": new[1]}]
+ blocker = {"type": new[0], "category": "RTC", "reason": new[1]}
+ if resolution.reason in HUMAN_REASONS:
+ blocker["human"] = HUMAN_REASONS[resolution.reason]
+ found["blockers"] = others + [blocker]
return found
if not rtc_blockers and value: # a valid existing class: compare, never overwrite
state = (compat_state or {}).get(value)
- if state in ("MISMATCH", "EXPIRED", "LOCAL_ONLY"):
+ if state in BAD_LOCAL_STATES:
found["blockers"] = others + [{"type": "CCLASSTRIB_OFFICIAL_MISMATCH", "category": "RTC", "reason": f"LOCAL_VS_OFFICIAL:{state}"}]
elif resolution.auto_apply_candidate and resolution.resolved_cclasstrib != value:
found["blockers"] = others + [{"type": "CCLASSTRIB_CONFLICT", "category": "RTC", "reason": f"HAS:{value};RULE:{resolution.rule_id}:{resolution.resolved_cclasstrib}"}]
@@ -59,8 +66,9 @@ def refine_rtc_finding(found: dict, product: dict, resolution: Resolution | None
def summarize_rtc(findings: dict, compat: dict | None = None) -> dict:
- """Counts for the panel and the audit: how many products already have a valid class, how many are ready to be filled, how many are blocked and why."""
- out = {"total": 0, "valid_existing": 0, "ready_to_apply": 0, "ambiguous": 0, "insufficient": 0, "conflicts": 0, "official_mismatch": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": {}, "by_reason": {}}
+ """Counts for the panel and the audit: valid, auto-resolvable (safe), suggested (diagnostic only), blocked and conflicting, catalog-incompatible."""
+ out = {"total": 0, "valid_existing": 0, "ready_to_apply": 0, "suggestion_only": 0, "ambiguous": 0, "insufficient": 0, "conflicts": 0, "official_mismatch": 0, "catalog_incompatible": 0,
+ "not_applicable": 0, "other_blocked": 0, "by_rule": {}, "by_reason": {}}
for found in findings.values():
out["total"] += 1
if found.get("applicability", {}).get("RTC") != "REQUIRED":
@@ -77,11 +85,15 @@ def summarize_rtc(findings: dict, compat: dict | None = None) -> dict:
out["conflicts"] += 1
elif "CCLASSTRIB_OFFICIAL_MISMATCH" in types:
out["official_mismatch"] += 1
+ elif "RTC_LOCAL_CATALOG_INCOMPATIBLE" in types or "RTC_CATALOG_INVALID" in types:
+ out["catalog_incompatible"] += 1
elif res.get("auto_apply_candidate") and "CCLASSTRIB_MISSING" in types:
out["ready_to_apply"] += 1
+ elif res.get("status") == "SUGGESTION_ONLY":
+ out["suggestion_only"] += 1
elif "CCLASSTRIB_AMBIGUOUS" in types:
out["ambiguous"] += 1
- elif types & {"CCLASSTRIB_MISSING_UNRESOLVED", "RTC_RULE_EVIDENCE_MISSING", "RTC_LOCAL_CATALOG_OUTDATED", "CCLASSTRIB_MISSING"}:
+ elif types & {"CCLASSTRIB_MISSING_UNRESOLVED", "RTC_RULE_EVIDENCE_MISSING", "CCLASSTRIB_MISSING"}:
out["insufficient"] += 1
else:
out["other_blocked"] += 1
diff --git a/central/app/unified_fiscal_coverage.py b/central/app/unified_fiscal_coverage.py
index f113694..12b35b8 100644
--- a/central/app/unified_fiscal_coverage.py
+++ b/central/app/unified_fiscal_coverage.py
@@ -236,9 +236,11 @@ def evaluate_product(product: dict, company: dict | None, rtc: dict | None, toda
def legal_effective_context(crt) -> dict:
- """INFORMATIONAL ONLY (never a blocker, never part of completeness). The operational rule stays ERP_RTC_ENABLED; this records what the legislation
- says about IBS/CBS in documents for the company regime so a difference in timing can be explained without inventing a rule."""
+ """INFORMATIONAL ONLY (never a blocker, never part of completeness). The operational rule stays ERP_RTC_ENABLED; this records what the official acts say about the
+ obligation to issue DF-e with IBS/CBS for the company regime, so a timing difference can be explained without inventing a rule.
+ Source: Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1: NF-e (55) and NFC-e (65) from 2026-08-03; par. 1: Simples optantes from 2027-01-01."""
simples = _text(crt) in SIMPLES_FAMILY
return {"crt": _text(crt) or None, "informational_only": True,
- "ibs_cbs_in_documents": "FROM_2027_FOR_SIMPLES_ORIENTATION_PENDING" if simples else ("MANDATORY_FROM_2026-01-01" if _text(crt) == "3" else "UNKNOWN_REGIME"),
- "sources": ["NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT; IBS/CBS taxation of these taxpayers only from 2027", "LC 214/2025 art. 348"]}
+ "ibs_cbs_in_documents": "MANDATORY_FROM_2027-01-01" if simples else ("MANDATORY_FROM_2026-08-03" if _text(crt) == "3" else "UNKNOWN_REGIME"),
+ "cclasstrib_orientation": "PENDING_OFFICIAL_NT" if simples else ("TABLE_AND_LC214_ANNEXES" if _text(crt) == "3" else "UNKNOWN_REGIME"),
+ "sources": ["Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1 I, II and par. 1", "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT", "LC 214/2025 art. 348"]}
diff --git a/central/app/version.py b/central/app/version.py
index 6a1b769..827ccff 100644
--- a/central/app/version.py
+++ b/central/app/version.py
@@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds
once published — never a second, independent version number. Fiscal semantics
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
-GTI_FISCAL_VERSION = "1.1.9"
+GTI_FISCAL_VERSION = "1.2.0"
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
diff --git a/central/app/web.py b/central/app/web.py
index 4dadf6a..ff3cafd 100644
--- a/central/app/web.py
+++ b/central/app/web.py
@@ -8,18 +8,18 @@ from urllib.parse import parse_qs, urlencode
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
-from sqlalchemy import select
+from sqlalchemy import func, select, update
from . import agent_distribution, client_sync, config, enrollment, sso
from .config import settings
from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context
from .context_keys import ALLOWED_KEYS, ENUMS
from .installation_identity import reference_agent_version
-from .panel_presentation import job_kind_label, job_status_label, rtc_status_view
+from .panel_presentation import RTC_MODE_EXPLANATION, RTC_MODE_LABELS, job_kind_label, job_status_label, rtc_status_view
from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_summary, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import create_session, current_session, require_csrf, verify_password
from .panel_presentation import format_ui_datetime
-from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
+from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, RtcCandidate, RtcWriteAudit, Tenant
from .jobs import create_job
from .audit import record_audit
@@ -296,7 +296,7 @@ def router(session_factory):
if installation.processing_enabled and last_sync is not None:
next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds)
last_summary = session.scalar(select(Job.result_summary).where(Job.agent_id == agent.id, Job.status == "COMPLETED", Job.kind.in_(("INITIAL_SYNC", "SYNC_INCREMENTAL", "REPROCESS_ALL"))).order_by(Job.id.desc()).limit(1))
- return {"rtc": rtc_status_view(last_summary, bool(installation.rtc_auto_apply_authorized)), "rtc_authorized": bool(installation.rtc_auto_apply_authorized), "enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60,
+ return {"rtc": rtc_status_view(last_summary, installation.rtc_write_mode), "rtc_mode": installation.rtc_write_mode, "enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60,
"last_sync": format_ui_datetime(last_sync) if last_sync else None, "next_sync": format_ui_datetime(next_sync) if next_sync else None,
"last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id, "kind_label": job_kind_label(last_job.kind), "status_label": job_status_label(last_job.status)} if last_job else None}
@@ -319,30 +319,111 @@ def router(session_factory):
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
finally: session.close()
- @routes.post("/agents/{agent_id}/rtc-auto-apply")
- async def rtc_auto_apply_authorization(request: Request, agent_id: int):
- """Central authorization of the automatic cClassTrib fill (default OFF). Enabling requires an explicit typed confirmation; every change is audited."""
+ def _rtc_target(session, agent_id):
+ agent = session.get(Agent, agent_id)
+ installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None
+ return agent, installation
+
+ @routes.get("/agents/{agent_id}/rtc")
+ async def rtc_control_page(request: Request, agent_id: int):
+ """Central cClassTrib control: write mode, candidate preview (server-side pagination), approvals and audit/rollback."""
+ session, auth_data, redirect = page_auth(request)
+ if redirect: return redirect
+ try:
+ agent, installation = _rtc_target(session, agent_id)
+ if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
+ q = request.query_params
+ status = q.get("status") or "PENDING"
+ page = max(1, int(q.get("page", "1") or 1)) if str(q.get("page", "1")).isdigit() else 1
+ size = 50
+ base = select(RtcCandidate).where(RtcCandidate.installation_id == installation.id)
+ counts = dict(session.execute(select(RtcCandidate.status, func.count()).where(RtcCandidate.installation_id == installation.id).group_by(RtcCandidate.status)).all())
+ shown = base.where(RtcCandidate.status == status) if status in ("PENDING", "APPROVED", "REJECTED", "APPLIED", "STALE") else base
+ total = int(session.scalar(select(func.count()).select_from(shown.subquery())) or 0)
+ rows = list(session.scalars(shown.order_by(RtcCandidate.product_code).offset((page - 1) * size).limit(size)))
+ audits = list(session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.outcome.in_(("APPLIED", "ROLLED_BACK"))).order_by(RtcWriteAudit.id.desc()).limit(25)))
+ ops = _ops_view(session, installation, agent)
+ return render(request, "rtc_control.html", {"agent_id": agent_id, "installation": installation, "mode": installation.rtc_write_mode, "modes": RTC_MODE_LABELS, "explanation": RTC_MODE_EXPLANATION, "ops": ops,
+ "rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits})
+ finally: session.close()
+
+ @routes.post("/agents/{agent_id}/rtc-mode")
+ async def rtc_write_mode(request: Request, agent_id: int):
+ """Central authorization of the cClassTrib fill, per installation. Default DISABLED; ENABLED needs a typed confirmation; every change is audited."""
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
- agent = session.get(Agent, agent_id)
- installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None
+ agent, installation = _rtc_target(session, agent_id)
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
- decision = values.get("decision")
- if decision == "authorize":
- if (values.get("confirm") or "").strip().upper() != "PREENCHER":
- return HTMLResponse("Digite PREENCHER para confirmar a autorização", status_code=422)
- installation.rtc_auto_apply_authorized = True
- elif decision == "revoke":
- installation.rtc_auto_apply_authorized = False
- else:
- return HTMLResponse("decisão inválida", status_code=422)
- record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_AUTO_APPLY_{decision.upper()}", target_type="installation", target_id=installation.installation_id,
- context={"authorized": installation.rtc_auto_apply_authorized}, severity="WARNING")
+ mode = (values.get("mode") or "").strip().upper()
+ if mode not in RTC_MODE_LABELS:
+ return HTMLResponse("modo inválido", status_code=422)
+ if mode == "ENABLED" and (values.get("confirm") or "").strip().upper() != "PREENCHER":
+ return HTMLResponse("Digite PREENCHER para confirmar o modo automático", status_code=422)
+ previous = installation.rtc_write_mode
+ installation.rtc_write_mode = mode
+ installation.rtc_auto_apply_authorized = mode == "ENABLED"
+ record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_WRITE_MODE", target_type="installation", target_id=installation.installation_id,
+ context={"from": previous, "to": mode}, severity="WARNING")
session.commit()
- return RedirectResponse(f"/agents/{agent_id}", status_code=303)
+ return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
+ finally: session.close()
+
+ @routes.post("/agents/{agent_id}/rtc-candidates")
+ async def rtc_candidate_action(request: Request, agent_id: int):
+ session, auth_data, redirect = page_auth(request)
+ if redirect: return redirect
+ try:
+ values = await form_values(request); web_session, admin = auth_data
+ require_csrf(web_session, values.get("csrf_token"))
+ agent, installation = _rtc_target(session, agent_id)
+ if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
+ action = values.get("action")
+ scope = RtcCandidate.installation_id == installation.id
+ ids = [int(x) for x in parse_qs((await request.body()).decode()).get("ids", []) if x.isdigit()]
+ select_all = values.get("all") == "1" or request.query_params.get("all") == "1"
+ changed = 0
+ if action in ("approve", "reject"):
+ new, old = ("APPROVED", ("PENDING",)) if action == "approve" else ("REJECTED", ("PENDING", "APPROVED"))
+ cond = scope & RtcCandidate.status.in_(old) & (RtcCandidate.id.in_(ids) if not select_all else RtcCandidate.status.in_(old))
+ changed = session.execute(update(RtcCandidate).where(cond).values(status=new)).rowcount
+ record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_CANDIDATES_{action.upper()}", target_type="installation", target_id=installation.installation_id, context={"changed": changed, "all": select_all}, severity="WARNING")
+ elif action == "apply_approved":
+ if installation.rtc_write_mode != "APPROVAL_REQUIRED":
+ return HTMLResponse("Disponível apenas no modo Aprovação manual", status_code=409)
+ codes = list(session.scalars(select(RtcCandidate.product_code).where(scope, RtcCandidate.status == "APPROVED").order_by(RtcCandidate.product_code).limit(500)))
+ if codes:
+ job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": codes, "reason": "RTC_APPLY_APPROVED", "requested_by": admin.id})
+ record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_APPLY_APPROVED", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "products": len(codes)}, severity="WARNING")
+ else:
+ return HTMLResponse("ação inválida", status_code=422)
+ session.commit()
+ return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
+ finally: session.close()
+
+ @routes.post("/agents/{agent_id}/rtc-rollback")
+ async def rtc_rollback_request(request: Request, agent_id: int):
+ """Requests the rollback of ONE audit record. The Agent executes it only if the value in the ERP still equals what was written; no free SQL exists."""
+ session, auth_data, redirect = page_auth(request)
+ if redirect: return redirect
+ try:
+ values = await form_values(request); web_session, admin = auth_data
+ require_csrf(web_session, values.get("csrf_token"))
+ agent, installation = _rtc_target(session, agent_id)
+ if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
+ if (values.get("confirm") or "").strip().upper() != "REVERTER":
+ return HTMLResponse("Digite REVERTER para confirmar", status_code=422)
+ audit = session.get(RtcWriteAudit, int(values.get("audit_id") or 0))
+ if audit is None or audit.installation_id != installation.id or audit.action != "APPLY" or audit.outcome != "APPLIED" or audit.rollback_state in ("REQUESTED", "DONE"):
+ return HTMLResponse("registro não reversível", status_code=409)
+ audit.rollback_state = "REQUESTED"
+ job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": [audit.product_code], "reason": "RTC_ROLLBACK", "requested_by": admin.id})
+ record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_ROLLBACK_REQUESTED", target_type="installation", target_id=installation.installation_id,
+ context={"audit_id": audit.id, "product_code": audit.product_code, "job_id": job.id}, severity="WARNING")
+ session.commit()
+ return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
finally: session.close()
@routes.post("/agents/{agent_id}/sync-now")
diff --git a/central/pyproject.toml b/central/pyproject.toml
index 6dd01ef..cc25c03 100644
--- a/central/pyproject.toml
+++ b/central/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "gti-fiscal-hub"
-version = "1.1.9"
+version = "1.2.0"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115,<1",
diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html
index 18ef3ee..e38873b 100644
--- a/central/templates/agent_detail.html
+++ b/central/templates/agent_detail.html
@@ -9,8 +9,7 @@
Reprocessar tudo
{% if ops.enabled %}
{% else %}{% endif %}
- Processamento
- {{ "Ativo" if ops.enabled else "Pausado" }} · sincronização automática a cada {{ ops.interval_minutes }} min
- Última sincronização
- {{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}
- Sincronização agendada
- {{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}
{% if ops.rtc %}- Reforma Tributária
- {{ ops.rtc.label }}
{% endif %}{% if ops.rtc and ops.rtc.enabled %}{% if ops.rtc.products %}- Produtos RTC
- {{ ops.rtc.products.headline }}
Total {{ ops.rtc.products.total }} · com cClassTrib válido {{ ops.rtc.products.valid }} · prontos para preenchimento {{ ops.rtc.products.ready }} · ambíguos/bloqueados {{ ops.rtc.products.blocked }} {% endif %}
-- Preenchimento automático
- {{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %}
-
{% endif %}- Último job
- {% if ops.last_job %}{{ ops.last_job.kind_label }} · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}
+Preenchimento automático{{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %} · Controle e candidatos cClassTrib{% endif %}Último job{% if ops.last_job %}{{ ops.last_job.kind_label }} · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}
Contexto da empresa
- Cliente
- {{ detail.client or "Não informado" }}
- Nome de exibição
- {{ detail.display_name or "Não informado" }}
- Hostname
- {{ detail.hostname or "Não informado" }}
- Empresa
- {{ detail.company_name or detail.company_code or "Não informado" }}
- UF
- {{ detail.company_uf or "Não informado" }}
- CRT
- {{ detail.company_crt or "Não informado" }}
- Versão do agent
- {{ detail.agent_version_display }}
- Último heartbeat
- {{ detail.last_seen_display }} · {{ detail.last_seen_age }}
- Firebird
- {{ detail.firebird_version or "Não informado" }} · ODS {{ detail.firebird_ods or "Não informado" }}
- IDs técnicos
- Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}
{% for event in events[:8] %}{% else %}Nenhum evento.
{% endfor %}
diff --git a/central/templates/rtc_control.html b/central/templates/rtc_control.html
new file mode 100644
index 0000000..2f53aed
--- /dev/null
+++ b/central/templates/rtc_control.html
@@ -0,0 +1,41 @@
+{% extends "base.html" %}
+{% block title %}cClassTrib | GTI Fiscal{% endblock %}
+{% block content %}
+← Instalação / REFORMA TRIBUTÁRIA
Preenchimento de cClassTrib
+
{{ explanation }}
+
+{% set rtc = ops.rtc %}
+{% if rtc and rtc.products %}
+
+Produtos{{ rtc.products.total }}
+cClassTrib válido{{ rtc.products.valid }}
+Auto-resolvíveis{{ rtc.products.ready }}
+Bloqueados{{ rtc.products.blocked }}
+Conflitos{{ rtc.products.conflicts }}
+Catálogo incompatível{{ rtc.products.catalog_incompatible }}
+
+{% endif %}
+
+Modo de preenchimento
Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.
+
+
+Candidatos
{{ total }} produto(s) neste filtro.
+
+
+
+Auditoria e reversão
Cada preenchimento pode ser revertido individualmente, desde que o valor no ERP ainda seja o gravado.
+| # | Produto | Ação | Resultado | Antes → Depois | Regra | Origem | Reversão |
+{% for a in audits %}| {{ a.id }} | {{ a.product_code }} · {{ a.description_snapshot or "" }} | {{ a.action or "APPLY" }} | {{ a.outcome }}{% if a.reason %} ({{ a.reason }}){% endif %} | {{ a.old_value or "vazio" }} → {{ a.new_value or "vazio" }} | {{ a.rule_id }} | {{ a.authorization_origin }} |
+{% if a.action == "APPLY" and a.outcome == "APPLIED" and not a.rollback_state %}{% else %}{{ a.rollback_state or "" }}{% endif %} |
+{% else %}| Nenhum preenchimento registrado. |
{% endfor %}
+{% endblock %}
diff --git a/central/tests/test_cclasstrib_resolver.py b/central/tests/test_cclasstrib_resolver.py
index 07cf4e4..40063b2 100644
--- a/central/tests/test_cclasstrib_resolver.py
+++ b/central/tests/test_cclasstrib_resolver.py
@@ -44,7 +44,7 @@ def test_rtc_off_is_not_applicable_and_never_a_candidate():
def test_simples_is_insufficient_until_the_official_orientation_exists():
r = resolve("10063021", company=SIMPLES)
- assert r.status == "INSUFFICIENT_EVIDENCE" and r.rule_id == "RTC-R1" and not r.auto_apply_candidate and r.source_references[-1]["document"].startswith("NT 2025.002")
+ assert r.status == "INSUFFICIENT_EVIDENCE" and r.rule_id == "RTC-R1" and not r.auto_apply_candidate and r.source_references[-1]["document"].startswith("Ato Conjunto RFB/CGIBS") and r.rule_class == "BLOCKED_BY_EVIDENCE"
assert resolve("10063021", company={"crt": None}).reason == "COMPANY_REGIME_UNKNOWN"
@@ -88,35 +88,59 @@ def test_nature_based_chapters_are_ambiguous():
assert r.status == "AMBIGUOUS" and r.rule_id in ("RTC-A3", "RTC-A1", "RTC-A2"), ncm
-def test_residual_ncm_outside_every_annex_and_sensitive_chapter_is_integral_taxation():
+def test_residual_ncm_outside_every_annex_is_only_a_suggestion_and_never_a_write_candidate():
r = resolve("39235000") # plastic closures: no annex, no nature-based regime
- assert r.status == "RESOLVED_WITH_EXPLICIT_RULE" and r.resolved_cclasstrib == "000001" and r.resolved_cst_ibscbs == "000" and r.rule_id == "RTC-R4" and r.confidence_type == "EXCLUSION_OF_ALL_SPECIAL_REGIMES"
- assert r.auto_apply_candidate and annex_matches("39235000") == []
+ assert r.status == "SUGGESTION_ONLY" and r.resolved_cclasstrib == "000001" and r.suggested_cclasstrib == "000001" and r.rule_id == "RTC-R4" and r.rule_class == "SUGGESTION_ONLY"
+ assert r.auto_apply_candidate is False and annex_matches("39235000") == [] and r.confidence_type == "EXCLUSION_OF_ALL_SPECIAL_REGIMES"
+ assert "not positive evidence" in r.evidence[0]["note"] and r.as_dict()["auto_apply_candidate"] is False
+
+
+def test_only_auto_apply_safe_rules_can_authorize_a_write():
+ from central.app.cclasstrib_resolver import RULE_CLASSES
+ assert [rid for rid, cls in RULE_CLASSES.items() if cls == "AUTO_APPLY_SAFE"] == ["RTC-R3"]
+ assert RULE_CLASSES["RTC-R4"] == "SUGGESTION_ONLY" and RULE_CLASSES["RTC-R0"] == "VALIDATION_ONLY"
+ assert {c for c in RULE_CLASSES.values()} == {"AUTO_APPLY_SAFE", "VALIDATION_ONLY", "SUGGESTION_ONLY", "BLOCKED_BY_EVIDENCE"}
+
+
+def test_regime_other_than_normal_never_reaches_a_positive_rule():
+ for company in ({"crt": "2"}, {"crt": "4"}, {"crt": "9"}, {"crt": ""}):
+ assert not resolve("10063021", company=company).auto_apply_candidate
def test_a_class_missing_or_different_in_the_local_catalog_is_never_proposed():
- r = resolve("39235000", local=local_from_official(skip=("000001",)))
+ r = resolve("10063021", local=local_from_official(skip=("200003",)))
assert r.status == "INSUFFICIENT_EVIDENCE" and r.reason == "CLASS_NOT_IN_LOCAL_CATALOG" and not r.auto_apply_candidate
- r2 = resolve("39235000", local=local_from_official(tweak={"000001": {"cst": "010"}}))
+ r2 = resolve("10063021", local=local_from_official(tweak={"200003": {"cst": "010"}}))
assert r2.reason == "LOCAL_OFFICIAL_MISMATCH" and not r2.auto_apply_candidate
r3 = resolve("10063021", local=local_from_official(tweak={"200003": {"ind_nfe": False, "ind_nfce": False}}))
assert r3.reason == "LOCAL_OFFICIAL_MISMATCH"
def test_invalid_local_catalog_blocks_every_proposal():
- assert resolve("39235000", ctx={"rtc_enabled": True, "local_catalog_valid": False}).reason == "LOCAL_RTC_CATALOG_INVALID"
+ assert resolve("10063021", ctx={"rtc_enabled": True, "local_catalog_valid": False}).reason == "LOCAL_RTC_CATALOG_INVALID"
def test_catalog_comparison_classifies_every_code_and_flags_material_problems():
full = compare_catalogs(local_from_official(), TODAY)
- assert full["counts"]["MATCH"] == 161 and full["counts"]["EXPIRED"] == 3 and full["compatibility"] == "PARTIAL_LOCAL_OUTDATED" # 220001-3 ended on 2026-01-01
+ assert full["counts"]["MATCH_SAFE"] == 161 and full["counts"]["EXPIRED"] == 3 and full["compatibility"] == "PARTIAL_LOCAL_OUTDATED" # 220001-3 ended on 2026-01-01
assert compare_catalogs(local_from_official(skip=("220001", "220002", "220003")), TODAY)["compatibility"] == "FULL"
outdated = compare_catalogs(local_from_official(skip=("000005", "200053")), TODAY)
- assert outdated["counts"]["OFFICIAL_ONLY"] == 2 and outdated["compatibility"] == "PARTIAL_LOCAL_OUTDATED" and outdated["counts"]["MISMATCH"] == 0
+ assert outdated["counts"]["MISSING_LOCAL"] == 2 and outdated["compatibility"] == "PARTIAL_LOCAL_OUTDATED" and outdated["counts"]["MISMATCH"] == 0
local = local_from_official(tweak={"200003": {"p_red_ibs": "60"}})
local["999999"] = {"cst": "000", "p_red_ibs": "0", "p_red_cbs": "0", "ind_nfe": True, "ind_nfce": True}
bad = compare_catalogs(local, TODAY)
- assert bad["per_code"]["200003"] == "MISMATCH" and bad["per_code"]["999999"] == "LOCAL_ONLY" and bad["compatibility"] == "MATERIAL_MISMATCH"
+ assert bad["per_code"]["200003"] == "MISMATCH" and bad["per_code"]["999999"] == "MISSING_OFFICIAL" and bad["compatibility"] == "MATERIAL_MISMATCH"
+
+
+def test_non_material_differences_are_classified_but_not_treated_as_a_mismatch():
+ earlier = compare_catalogs(local_from_official(tweak={"200003": {"start": "2025-05-05"}}), TODAY) # SGV imports an earlier start: both in force today => same emission effect
+ assert earlier["per_code"]["200003"] == "MATCH_SAFE" and earlier["details"]["200003"] == ["VIGENCY_START_DIFFERS_BOTH_IN_FORCE"]
+ assert compare_catalogs(local_from_official(tweak={"200003": {"start": "2027-01-01"}}), TODAY)["per_code"]["200003"] == "MISMATCH"
+ assert compare_catalogs(local_from_official(tweak={"200003": {"end": "2026-01-01"}}), TODAY)["per_code"]["200003"] == "MISMATCH"
+ local = local_from_official(tweak={"200003": {"end": "2027-12-31"}}) # a scheduled local end the official table does not have
+ c = compare_catalogs(local, TODAY)
+ assert c["per_code"]["200003"] == "MATCH_WITH_NON_MATERIAL_DIFFERENCE" and c["counts"]["MISMATCH"] == 0
+ assert resolve("10063021", local=local).status == "INSUFFICIENT_EVIDENCE" # auto-apply requires MATCH_SAFE: a non-material difference still blocks the WRITE
def test_local_catalog_hash_is_stable_and_sensitive():
diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py
index c69253f..98fd8d5 100644
--- a/central/tests/test_control_panel_pages.py
+++ b/central/tests/test_control_panel_pages.py
@@ -594,27 +594,87 @@ def _installation(client, agent_id):
from central.app.models import Installation as Inst
with client._session_factory() as session:
row = session.scalar(select(Inst).where(Inst.agent_id == agent_id))
- return bool(row.rtc_auto_apply_authorized)
+ return row.rtc_write_mode
-def test_rtc_auto_apply_authorization_is_off_by_default_and_needs_csrf_and_a_typed_confirmation():
+def test_rtc_write_mode_is_disabled_by_default_and_needs_csrf_valid_mode_and_a_typed_confirmation_for_automatic():
client, agent_id, _ = _panel_client()
- assert _installation(client, agent_id) is False
+ assert _installation(client, agent_id) == "DISABLED"
csrf = client.cookies.get("gti_csrf")
- url = f"/agents/{agent_id}/rtc-auto-apply"
- assert client.post(url, data={"csrf_token": "wrong", "decision": "authorize", "confirm": "PREENCHER"}).status_code == 403
- assert client.post(url, data={"csrf_token": csrf, "decision": "authorize"}).status_code == 422
- assert client.post(url, data={"csrf_token": csrf, "decision": "authorize", "confirm": "sim"}).status_code == 422
- assert _installation(client, agent_id) is False
- assert client.post(url, data={"csrf_token": csrf, "decision": "authorize", "confirm": "preencher"}, follow_redirects=False).status_code == 303
- assert _installation(client, agent_id) is True
- assert client.post(url, data={"csrf_token": csrf, "decision": "revoke"}, follow_redirects=False).status_code == 303
- assert _installation(client, agent_id) is False
- assert client.post(url, data={"csrf_token": csrf, "decision": "other"}).status_code == 422
+ url = f"/agents/{agent_id}/rtc-mode"
+ assert client.post(url, data={"csrf_token": "wrong", "mode": "DRY_RUN"}).status_code == 403
+ assert client.post(url, data={"csrf_token": csrf, "mode": "ENABLED"}).status_code == 422
+ assert client.post(url, data={"csrf_token": csrf, "mode": "ENABLED", "confirm": "sim"}).status_code == 422
+ assert client.post(url, data={"csrf_token": csrf, "mode": "bogus"}).status_code == 422
+ assert _installation(client, agent_id) == "DISABLED"
+ for mode, extra in (("DRY_RUN", {}), ("APPROVAL_REQUIRED", {}), ("ENABLED", {"confirm": "preencher"}), ("DISABLED", {})):
+ assert client.post(url, data={"csrf_token": csrf, "mode": mode, **extra}, follow_redirects=False).status_code == 303
+ assert _installation(client, agent_id) == mode
from central.app.models import AuditEvent
with client._session_factory() as session:
- actions = [a.action for a in session.scalars(select(AuditEvent).order_by(AuditEvent.id)).all() if a.action.startswith("RTC_AUTO_APPLY")]
- assert actions == ["RTC_AUTO_APPLY_AUTHORIZE", "RTC_AUTO_APPLY_REVOKE"]
+ actions = [a.action for a in session.scalars(select(AuditEvent).order_by(AuditEvent.id)).all() if a.action.startswith("RTC_")]
+ assert actions == ["RTC_WRITE_MODE"] * 4
+
+
+def _seed_rtc(client, agent_id, n=120):
+ from central.app.models import Installation as Inst, RtcCandidate, RtcWriteAudit
+ with client._session_factory() as session:
+ inst = session.scalar(select(Inst).where(Inst.agent_id == agent_id))
+ inst.rtc_write_mode = "APPROVAL_REQUIRED"
+ for i in range(n):
+ session.add(RtcCandidate(tenant_id=inst.tenant_id, installation_id=inst.id, product_code=f"C{i:04d}", description=f"PRODUTO {i}", ncm="10063021", proposed="200003", cst_ibscbs="200", rule_id="RTC-R3", basis="RTC-R3:ANNEX", status="PENDING"))
+ audit = RtcWriteAudit(tenant_id=inst.tenant_id, installation_id=inst.id, agent_id=agent_id, product_code="A1", field="CCLASSTRIB", outcome="APPLIED", old_value=None, new_value="200003", rule_id="RTC-R3", action="APPLY")
+ session.add(audit)
+ session.commit()
+ return audit.id
+
+
+def test_rtc_control_page_paginates_candidates_on_the_server_and_shows_modes_counts_and_audit():
+ client, agent_id, _ = _panel_client()
+ _seed_rtc(client, agent_id)
+ page1 = client.get(f"/agents/{agent_id}/rtc").text
+ assert "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente." in page1
+ for label in ("Desativada", "Dry-run", "Aprovação manual", "Automática"):
+ assert label in page1
+ assert "Pendentes (120)" in page1 and "C0000" in page1 and "C0049" in page1 and "C0050" not in page1 and "Página 1 de 3" in page1 and "Aplicar aprovados agora" in page1
+ page3 = client.get(f"/agents/{agent_id}/rtc?page=3").text
+ assert "C0119" in page3 and "C0099" not in page3 and "Página 3 de 3" in page3
+ assert "Reverter" in page3
+
+
+def test_rtc_candidate_actions_approve_reject_apply_and_rollback_are_csrf_protected_and_scoped():
+ from central.app.models import Job, RtcCandidate, RtcWriteAudit
+ client, agent_id, _ = _panel_client()
+ audit_id = _seed_rtc(client, agent_id, n=10)
+ csrf = client.cookies.get("gti_csrf")
+ url = f"/agents/{agent_id}/rtc-candidates"
+ assert client.post(url, data={"csrf_token": "wrong", "action": "approve"}).status_code == 403
+ with client._session_factory() as session:
+ ids = [c.id for c in session.scalars(select(RtcCandidate).order_by(RtcCandidate.id)).all()]
+ assert client.post(url, content=f"csrf_token={csrf}&action=approve&ids={ids[0]}&ids={ids[1]}", headers={"content-type": "application/x-www-form-urlencoded"}, follow_redirects=False).status_code == 303
+ assert client.post(f"{url}?all=1", data={"csrf_token": csrf, "action": "approve"}, follow_redirects=False).status_code == 303
+ assert client.post(url, content=f"csrf_token={csrf}&action=reject&ids={ids[2]}", headers={"content-type": "application/x-www-form-urlencoded"}, follow_redirects=False).status_code == 303
+ with client._session_factory() as session:
+ counts = {c.product_code: c.status for c in session.scalars(select(RtcCandidate)).all()}
+ assert list(counts.values()).count("REJECTED") == 1 and list(counts.values()).count("APPROVED") == 9
+ jobs_before = len(session.scalars(select(Job)).all())
+ assert client.post(url, data={"csrf_token": csrf, "action": "apply_approved"}, follow_redirects=False).status_code == 303
+ with client._session_factory() as session:
+ job = session.scalars(select(Job).order_by(Job.id.desc())).first()
+ assert len(session.scalars(select(Job)).all()) == jobs_before + 1 and job.kind == "REPROCESS_PRODUCTS" and job.payload["reason"] == "RTC_APPLY_APPROVED" and len(job.payload["codigos"]) == 9
+ assert client.post(url, data={"csrf_token": csrf, "action": "nope"}).status_code == 422
+ roll = f"/agents/{agent_id}/rtc-rollback"
+ assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id}).status_code == 422 # typed confirmation required
+ assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id, "confirm": "reverter"}, follow_redirects=False).status_code == 303
+ assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id, "confirm": "REVERTER"}).status_code == 409 # already requested: no duplicate
+ with client._session_factory() as session:
+ assert session.get(RtcWriteAudit, audit_id).rollback_state == "REQUESTED"
+
+
+def test_apply_approved_is_refused_outside_the_approval_mode():
+ client, agent_id, _ = _panel_client()
+ csrf = client.cookies.get("gti_csrf")
+ assert client.post(f"/agents/{agent_id}/rtc-candidates", data={"csrf_token": csrf, "action": "apply_approved"}).status_code == 409
def test_panel_shows_rtc_products_summary_and_the_pending_message_only_when_rtc_is_on():
@@ -626,6 +686,6 @@ def test_panel_shows_rtc_products_summary_and_the_pending_message_only_when_rtc_
_complete_sync(client, agent_id, {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True, "rtc_resolution": resolution})
on = client.get(f"/agents/{agent_id}").text
assert "Reforma Tributária ativa — cClassTrib pendente em 521 produtos" in on and "prontos para preenchimento 280" in on and "ambíguos/bloqueados 241" in on
- assert "Preenchimento automático" in on and "Desativado" in on and "digite PREENCHER" in on
+ assert "Preenchimento automático" in on and "Desativada" in on and f"/agents/{agent_id}/rtc" in on
for legacy in ("Escopo anterior", "Escopo ampliado"):
assert legacy not in on
diff --git a/central/tests/test_health.py b/central/tests/test_health.py
index 978c1ad..2ce1aae 100644
--- a/central/tests/test_health.py
+++ b/central/tests/test_health.py
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
def test_version_endpoint_reports_release_and_fiscal_versions():
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
- assert body["version"] == "1.1.9" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
+ assert body["version"] == "1.2.0" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
def test_production_refuses_the_dev_token(monkeypatch):
diff --git a/central/tests/test_rtc_security.py b/central/tests/test_rtc_security.py
new file mode 100644
index 0000000..6cdae2f
--- /dev/null
+++ b/central/tests/test_rtc_security.py
@@ -0,0 +1,87 @@
+"""Security review of the centrally controlled cClassTrib write: who can change the mode, what the Agent can and cannot decide, tenant scoping, no secrets in audits."""
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from central.app.models import AuditEvent, Installation, RtcCandidate, RtcWriteAudit
+from central.tests.test_control_panel_pages import _panel_client, _seed_rtc
+from central.tests.test_zero_touch_hub import H, OPERATIONAL, _client, age_last_sync, finish, hb
+
+
+def test_new_rtc_routes_require_an_operator_session():
+ client, agent_id, _ = _panel_client()
+ client.cookies.clear()
+ for method, path in (("get", f"/agents/{agent_id}/rtc"), ("post", f"/agents/{agent_id}/rtc-mode"), ("post", f"/agents/{agent_id}/rtc-candidates"), ("post", f"/agents/{agent_id}/rtc-rollback")):
+ response = getattr(client, method)(path, follow_redirects=False)
+ assert response.status_code in (303, 401, 403), (path, response.status_code)
+ assert "login" in response.headers.get("location", "login") or response.status_code in (401, 403)
+
+
+def test_default_mode_is_disabled_and_the_agent_cannot_choose_it():
+ client, engine = _client()
+ hb(client)
+ with Session(engine) as s:
+ assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED"
+ job = client.get("/v1/jobs/next", headers=H).json()
+ assert job["authorizations"]["rtc_write_mode"] == "DISABLED"
+ # a forged result cannot change the mode, approve candidates or request rollbacks: none of those are read from the payload
+ forged = {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_write_mode": "ENABLED", "authorizations": {"rtc_write_mode": "ENABLED", "rtc_approved_codes": ["X"]},
+ "rtc_auto_apply": {"mode": "ENABLED", "hub_mode": "ENABLED", "preview": [{"product_code": "X", "proposed": "200003", "rule_id": "RTC-R3", "status": "APPROVED"}]}}
+ client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": forged}, headers=H)
+ with Session(engine) as s:
+ assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED"
+ assert s.scalars(select(RtcCandidate)).all() == [] # DISABLED stores no preview at all
+
+
+def test_preview_from_the_agent_never_arrives_approved_and_never_approves_itself():
+ client, engine = _client()
+ hb(client)
+ with Session(engine) as s:
+ s.scalars(select(Installation)).one().rtc_write_mode = "APPROVAL_REQUIRED"
+ s.commit()
+ item = {"product_code": "X1", "description": "D", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "b", "status": "APPROVED"}
+ finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": {"evaluated_all": True}, "rtc_auto_apply": {"preview": [item]}})
+ with Session(engine) as s:
+ assert [c.status for c in s.scalars(select(RtcCandidate)).all()] == ["PENDING"]
+ age_last_sync(engine, 4000); hb(client)
+ assert client.get("/v1/jobs/next", headers=H).json()["authorizations"]["rtc_approved_codes"] == []
+
+
+def test_a_rollback_result_cannot_touch_another_installations_audit():
+ client, engine = _client()
+ hb(client)
+ with Session(engine) as s:
+ inst = s.scalars(select(Installation)).one()
+ foreign = RtcWriteAudit(tenant_id=inst.tenant_id, installation_id=inst.id + 999, agent_id=inst.agent_id, product_code="F", field="CCLASSTRIB", outcome="APPLIED", new_value="200003", action="APPLY", rollback_state="REQUESTED")
+ # the FK is not enforced by the SQLite test DB; the scoping is what is under test
+ s.add(foreign); s.commit(); foreign_id = foreign.id
+ done = {"outcome": "ROLLED_BACK", "product_code": "F", "field": "CCLASSTRIB", "old_value": "200003", "new_value": None, "action": "ROLLBACK", "rollback_of": foreign_id}
+ finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"rollbacks": [done]}})
+ with Session(engine) as s:
+ assert s.get(RtcWriteAudit, foreign_id).rollback_state == "REQUESTED" # untouched
+
+
+def test_rollback_and_candidate_actions_are_scoped_to_the_operators_installation_and_audited():
+ client, agent_id, _ = _panel_client()
+ _seed_rtc(client, agent_id, n=3)
+ csrf = client.cookies.get("gti_csrf")
+ with client._session_factory() as s:
+ other = RtcWriteAudit(tenant_id=1, installation_id=987654, agent_id=agent_id, product_code="Z", field="CCLASSTRIB", outcome="APPLIED", new_value="200003", action="APPLY")
+ s.add(other); s.commit(); other_id = other.id
+ assert client.post(f"/agents/{agent_id}/rtc-rollback", data={"csrf_token": csrf, "audit_id": other_id, "confirm": "REVERTER"}).status_code == 409
+ client.post(f"/agents/{agent_id}/rtc-candidates?all=1", data={"csrf_token": csrf, "action": "approve"}, follow_redirects=False)
+ with client._session_factory() as s:
+ actions = [a.action for a in s.scalars(select(AuditEvent)).all()]
+ assert "RTC_CANDIDATES_APPROVE" in actions
+
+
+def test_audit_rows_never_carry_credentials_or_tokens():
+ client, engine = _client()
+ hb(client)
+ applied = {"outcome": "APPLIED", "product_code": "10", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY", "password": "p", "token": "t",
+ "evidence": {"reason": "r"}}
+ finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"applied": [applied]}})
+ with Session(engine) as s:
+ row = s.scalars(select(RtcWriteAudit)).one()
+ blob = repr([getattr(row, c.name) for c in RtcWriteAudit.__table__.columns])
+ assert "password" not in blob.lower() and "'t'" not in blob and "Bearer" not in blob
+ assert not any("pass" in c.name or "token" in c.name or "secret" in c.name for c in RtcWriteAudit.__table__.columns)
diff --git a/central/tests/test_unified_fiscal_coverage.py b/central/tests/test_unified_fiscal_coverage.py
index 476184c..f6af987 100644
--- a/central/tests/test_unified_fiscal_coverage.py
+++ b/central/tests/test_unified_fiscal_coverage.py
@@ -56,7 +56,8 @@ def test_rtc_enabled_needs_schema_and_erp_switch():
def test_legal_context_is_informational_and_never_creates_a_blocker():
simples, normal = legal_effective_context("1"), legal_effective_context("3")
- assert simples["informational_only"] and "2027" in simples["ibs_cbs_in_documents"] and normal["ibs_cbs_in_documents"] == "MANDATORY_FROM_2026-01-01"
+ assert simples["informational_only"] and simples["ibs_cbs_in_documents"] == "MANDATORY_FROM_2027-01-01" and normal["ibs_cbs_in_documents"] == "MANDATORY_FROM_2026-08-03"
+ assert simples["cclasstrib_orientation"] == "PENDING_OFFICIAL_NT" and any("Ato Conjunto RFB/CGIBS n 4/2026" in x for x in simples["sources"])
assert legal_effective_context(None)["ibs_cbs_in_documents"] == "UNKNOWN_REGIME"
# the operational rule is the ERP switch only: a Simples company with RTC on is validated, one with RTC off is not, whatever the legal timing says
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, SIMPLES, ON)) == {"CCLASSTRIB_MISSING"} and kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, SIMPLES, OFF)) == set()
diff --git a/central/tests/test_zero_touch_hub.py b/central/tests/test_zero_touch_hub.py
index 1f91f17..f61c517 100644
--- a/central/tests/test_zero_touch_hub.py
+++ b/central/tests/test_zero_touch_hub.py
@@ -270,15 +270,15 @@ def test_job_carries_the_central_rtc_authorization_default_off_and_follows_the_i
client, engine = _client()
hb(client)
job = client.get("/v1/jobs/next", headers=H).json()
- assert job["authorizations"] == {"rtc_auto_apply": False} # never on by default
+ assert job["authorizations"] == {"rtc_write_mode": "DISABLED", "rtc_approved_codes": [], "rtc_rollbacks": []} # never on by default
client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {}}, headers=H)
with Session(engine) as s:
inst = s.scalars(select(Installation)).one()
- inst.rtc_auto_apply_authorized = True
+ inst.rtc_write_mode = "ENABLED"
s.commit()
age_last_sync(engine, 4000)
hb(client)
- assert client.get("/v1/jobs/next", headers=H).json()["authorizations"] == {"rtc_auto_apply": True}
+ assert client.get("/v1/jobs/next", headers=H).json()["authorizations"]["rtc_write_mode"] == "ENABLED"
def test_rtc_write_audits_are_persisted_with_server_side_identity_and_never_duplicated_per_job():
@@ -307,3 +307,61 @@ def test_report_only_jobs_leave_no_audit_rows():
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"mode": "REPORT_ONLY", "applied": [], "aborted": [], "ready_total": 280}})
with Session(engine) as s:
assert s.scalars(select(RtcWriteAudit)).all() == []
+
+
+def _set_mode(engine, mode):
+ with Session(engine) as s:
+ s.scalars(select(Installation)).one().rtc_write_mode = mode
+ s.commit()
+
+
+def test_preview_is_stored_only_when_the_central_mode_wants_it_and_approvals_travel_in_the_next_job():
+ from central.app.models import RtcCandidate
+ client, engine = _client()
+ hb(client)
+ preview = [{"product_code": f"P{i}", "description": f"ITEM {i}", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "RTC-R3:ANNEX"} for i in range(3)]
+ ctx = {"rtc_enabled": True, "evaluated_all": True}
+ finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx, "rtc_auto_apply": {"mode": "REPORT_ONLY", "preview": preview, "preview_truncated": False}})
+ with Session(engine) as s:
+ assert s.scalars(select(RtcCandidate)).all() == [] # DISABLED: nothing is stored
+ _set_mode(engine, "APPROVAL_REQUIRED")
+ age_last_sync(engine, 4000); hb(client)
+ finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx, "rtc_auto_apply": {"mode": "APPROVAL_REQUIRED", "preview": preview, "preview_truncated": False}})
+ with Session(engine) as s:
+ rows = s.scalars(select(RtcCandidate).order_by(RtcCandidate.product_code)).all()
+ assert [(r.product_code, r.status, r.proposed) for r in rows] == [("P0", "PENDING", "200003"), ("P1", "PENDING", "200003"), ("P2", "PENDING", "200003")]
+ rows[1].status = "APPROVED"
+ s.commit()
+ age_last_sync(engine, 4000); hb(client)
+ job = client.get("/v1/jobs/next", headers=H).json()
+ assert job["authorizations"]["rtc_approved_codes"] == ["P1"] # only what a human approved
+ client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx,
+ "rtc_auto_apply": {"mode": "APPROVAL_REQUIRED", "preview": [p for p in preview if p["product_code"] != "P2"], "preview_truncated": False,
+ "applied": [{"outcome": "APPLIED", "product_code": "P1", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY", "rule_version": "rtc-rules-v2",
+ "company_crt": "3", "authorization_origin": "HUB_MODE:APPROVAL_REQUIRED", "description_snapshot": "ITEM 1", "rtc_status": "ENABLED", "local_catalog_state": "MATCH_SAFE"}]}}}, headers=H)
+ with Session(engine) as s:
+ status = {r.product_code: r.status for r in s.scalars(select(RtcCandidate)).all()}
+ assert status == {"P0": "PENDING", "P1": "APPLIED", "P2": "STALE"} # applied is recorded; a product no longer eligible on a full evaluation becomes STALE
+ from central.app.models import RtcWriteAudit
+ audit = s.scalars(select(RtcWriteAudit)).one()
+ assert (audit.rule_version, audit.company_crt, audit.authorization_origin, audit.description_snapshot, audit.action, audit.local_catalog_state) == ("rtc-rules-v2", "3", "HUB_MODE:APPROVAL_REQUIRED", "ITEM 1", "APPLY", "MATCH_SAFE")
+ assert audit.installation_uuid
+
+
+def test_rollback_request_travels_in_the_job_and_its_result_closes_the_original_audit():
+ from central.app.models import RtcWriteAudit
+ client, engine = _client()
+ hb(client)
+ applied = {"outcome": "APPLIED", "product_code": "P1", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY"}
+ finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"applied": [applied]}})
+ with Session(engine) as s:
+ audit = s.scalars(select(RtcWriteAudit)).one(); audit.rollback_state = "REQUESTED"; audit_id = audit.id
+ s.commit()
+ age_last_sync(engine, 4000); hb(client)
+ job = client.get("/v1/jobs/next", headers=H).json()
+ assert job["authorizations"]["rtc_rollbacks"] == [{"audit_id": audit_id, "product_code": "P1", "written_value": "200003", "rule_id": "RTC-R3"}]
+ done = {"outcome": "ROLLED_BACK", "product_code": "P1", "field": "CCLASSTRIB", "old_value": "200003", "new_value": None, "rule_id": "RTC-R3", "action": "ROLLBACK", "rollback_of": audit_id}
+ client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"rollbacks": [done]}}}, headers=H)
+ with Session(engine) as s:
+ rows = s.scalars(select(RtcWriteAudit).order_by(RtcWriteAudit.id)).all()
+ assert [(r.action, r.outcome, r.rollback_state, r.rollback_of) for r in rows] == [("APPLY", "APPLIED", "DONE", None), ("ROLLBACK", "ROLLED_BACK", None, audit_id)]
diff --git a/deploy/agent-windows/agent.env.template b/deploy/agent-windows/agent.env.template
index 1c403f7..8fbeb8c 100644
--- a/deploy/agent-windows/agent.env.template
+++ b/deploy/agent-windows/agent.env.template
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.1.9
+AGENT_VERSION=1.2.0
diff --git a/deploy/agent-windows/gti-agent.ps1 b/deploy/agent-windows/gti-agent.ps1
index 91a6b33..0d9367c 100644
--- a/deploy/agent-windows/gti-agent.ps1
+++ b/deploy/agent-windows/gti-agent.ps1
@@ -164,7 +164,7 @@ switch ($Command) {
"GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId",
"COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource",
"FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass",
- 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.1.9'
+ 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.2.0'
)
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
}
diff --git a/deploy/agent-windows/pilot-construcao.env.example b/deploy/agent-windows/pilot-construcao.env.example
index eb14d67..114e97c 100644
--- a/deploy/agent-windows/pilot-construcao.env.example
+++ b/deploy/agent-windows/pilot-construcao.env.example
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.1.9
+AGENT_VERSION=1.2.0
diff --git a/deploy/agent-windows/profiles/mmmodas.env.example b/deploy/agent-windows/profiles/mmmodas.env.example
index 6b36fbd..fcd1faa 100644
--- a/deploy/agent-windows/profiles/mmmodas.env.example
+++ b/deploy/agent-windows/profiles/mmmodas.env.example
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.1.9
+AGENT_VERSION=1.2.0
diff --git a/deploy/agent-windows/profiles/rincao.env.example b/deploy/agent-windows/profiles/rincao.env.example
index 1cffc9e..519c965 100644
--- a/deploy/agent-windows/profiles/rincao.env.example
+++ b/deploy/agent-windows/profiles/rincao.env.example
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.1.9
+AGENT_VERSION=1.2.0
diff --git a/deploy/agent-windows/profiles/uniao.env.example b/deploy/agent-windows/profiles/uniao.env.example
index 5a9f85a..4b255e5 100644
--- a/deploy/agent-windows/profiles/uniao.env.example
+++ b/deploy/agent-windows/profiles/uniao.env.example
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.1.9
+AGENT_VERSION=1.2.0
diff --git a/deploy/hub/.env.example b/deploy/hub/.env.example
index 4ffd04c..04e342b 100644
--- a/deploy/hub/.env.example
+++ b/deploy/hub/.env.example
@@ -1,5 +1,5 @@
# GTI Fiscal Hub — production environment (copy to .env, never commit .env)
-GTI_FISCAL_VERSION=1.1.9
+GTI_FISCAL_VERSION=1.2.0
# REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24
POSTGRES_PASSWORD=
diff --git a/deploy/hub/docker-compose.prod.yml b/deploy/hub/docker-compose.prod.yml
index af693b3..3edad53 100644
--- a/deploy/hub/docker-compose.prod.yml
+++ b/deploy/hub/docker-compose.prod.yml
@@ -36,7 +36,7 @@ services:
build:
context: ../..
dockerfile: central/Dockerfile
- image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.1.9}
+ image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.2.0}
restart: unless-stopped
depends_on:
postgres:
diff --git a/docs/AI_PROJECT_STATE.md b/docs/AI_PROJECT_STATE.md
index 01db4d9..1501b1a 100644
--- a/docs/AI_PROJECT_STATE.md
+++ b/docs/AI_PROJECT_STATE.md
@@ -534,3 +534,11 @@ See `docs/PRE_AI_LOCAL_COMPLETION_V3_REPORT.md` for measured values.
Remaining drift is naming, column order, extra DB defaults, redundant indexes.
- `deploy/hub/*.sh` are forced to LF (`.gitattributes`); `backup.sh` + restore
into a scratch database were exercised on production.
+
+## RTC / cClassTrib closure (2026-09-24/25) — see docs/rtc_auto_apply.md, docs/cclasstrib_resolution.md, docs/unified_fiscal_completeness.md
+- Unified fiscal coverage (PIS/COFINS applicability + conditional RTC) is one definition/queue/panel; RTC is ON only if the ERP says so (`PARAMETROS.USAR_REFORMA_TRIBUTARIA`, read live).
+- cClassTrib resolver (`central/app/cclasstrib_resolver.py`, `rtc-rules-v2`): every rule is AUTO_APPLY_SAFE / VALIDATION_ONLY / SUGGESTION_ONLY / BLOCKED_BY_EVIDENCE. Only **RTC-R3** (LC 214 annex I/VII/VIII/XV, clean item, CRT 3) authorizes a write; **RTC-R4 is suggestion-only**. Simples: SAFE_BLOCKED (no official orientation; Ato Conjunto RFB/CGIBS 4/2026: Simples from 2027-01-01). Catalog states per class: MATCH_SAFE / MATCH_WITH_NON_MATERIAL_DIFFERENCE / MISMATCH / EXPIRED / MISSING_LOCAL / MISSING_OFFICIAL; write only for MATCH_SAFE.
+- Write is **centrally authorized per installation** (`installations.rtc_write_mode`: DISABLED default / DRY_RUN / APPROVAL_REQUIRED / ENABLED; migration 0028), delivered only inside the authenticated job (`authorizations`). The Agent has no local switch, only the capability `RTC_CCLASSTRIB_WRITE_V1`; allowlist = `C000025.CCLASSTRIB`.
+- Preview/approval (`rtc_candidates`), audit (`rtc_write_audits`, append-only, rich fields) and per-record rollback (value must still equal the written one) live in the Hub; panel page `/agents/{id}/rtc`.
+- Blocker persistence is bulk (`upsert_blockers_bulk`); 21k products persist in ~5 s (was 43 s).
+- Proofs: `scripts/rtc_copy_write_e2e.py` (copy only; restores from .fbk; 324 tables hashed), `scripts/scale_hub_test.py`, `docs/runs/*`. Real client DBs: 0 writes. Agent 2 stays DISABLED.
diff --git a/docs/DEPLOY_HUB.md b/docs/DEPLOY_HUB.md
index 919eaf5..8d08d64 100644
--- a/docs/DEPLOY_HUB.md
+++ b/docs/DEPLOY_HUB.md
@@ -16,7 +16,7 @@ A variável de ambiente `GTI_SSH_PASSWORD`, se existir, tem prioridade. A senha
## Deploy
```
powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1
-powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 -PublishAgent -Version 1.1.9
+powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 -PublishAgent -Version 1.2.0
```
Parâmetros: `-Version` (padrão `release/VERSION`), `-PublishAgent`, `-SkipTests`, `-DryRun`.
diff --git a/docs/cclasstrib_resolution.md b/docs/cclasstrib_resolution.md
index 16fc979..66b1e64 100644
--- a/docs/cclasstrib_resolution.md
+++ b/docs/cclasstrib_resolution.md
@@ -42,7 +42,7 @@ Premissa documentada: **venda ordinária no mercado interno** (default de produt
| RTC-A2 | NCM listado em mais de um anexo | `AMBIGUOUS` |
| RTC-A3 | capítulos 01–14, 27, 30, 49, 71, 87, 88, 89, 90 e posições 9619, 2207, 3826, 8703: regime definido pela natureza/operação do bem | `AMBIGUOUS` |
| RTC-R3 | NCM em **exatamente um** anexo com classe-padrão única (I→`200003`, VII→`200034`, VIII→`200035`, XV→`200014`) e item sem condição | `RESOLVED_WITH_EXPLICIT_RULE` |
-| RTC-R4 | NCM em **nenhum** anexo e fora dos capítulos acima ⇒ tributação integral (regra geral, LC 214 art. 4) | `RESOLVED_WITH_EXPLICIT_RULE` → `000001` |
+| RTC-R4 | NCM em **nenhum** anexo e fora dos capítulos acima ⇒ tributação integral (regra geral, LC 214 art. 4). **Exclusão residual não é evidência positiva** | `SUGGESTION_ONLY` → `000001` (nunca candidato de escrita) |
| RTC-G2 | classe proposta ausente/diferente no catálogo local, fora de vigência ou inutilizável em NF-e/NFC-e | `INSUFFICIENT_EVIDENCE` |
`RESOLVED_DETERMINISTIC` (regra sem premissa de operação) não é usado: toda resolução carrega a premissa "venda ordinária" e por isso é `RESOLVED_WITH_EXPLICIT_RULE`.
@@ -65,3 +65,7 @@ Estado como está hoje: TESTESOFICIAIS e CONSTRUCAO têm RTC ligada e regime Sim
## Limitações
Simples fica bloqueado até haver orientação oficial (ou decisão explícita de empresa, não implementada); operações especiais não são cobertas pelo default de produto; a extração dos anexos é textual (itens condicionais viram ambíguos de propósito); o catálogo oficial usado é o de 22/06/2026; sem autorização, nada é escrito (ver `docs/rtc_auto_apply.md`).
+
+
+## Classes de regra (rtc-rules-v2)
+`AUTO_APPLY_SAFE` (única hoje: **RTC-R3**, CRT 3, anexo único, item limpo, classe `MATCH_SAFE`) · `VALIDATION_ONLY` (R0 RTC desligada, valida valor existente) · `SUGGESTION_ONLY` (**R4**) · `BLOCKED_BY_EVIDENCE` (R1 Simples/regime desconhecido, R2 NCM inválido, A1–A3 ambíguo, G1/G2 catálogo). Escrita só com evidência POSITIVA explícita: nunca por similaridade, frequência, LLM ou "não achei nada mais". Vigência local vs oficial: início diferente com a classe em vigor nos dois lados é informativo (`MATCH_SAFE`); fim local agendado = `MATCH_WITH_NON_MATERIAL_DIFFERENCE` (não escreve); início futuro ou fim vencido = `MISMATCH`. Fontes: `docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json`. Escrita/aprovação/rollback: `docs/rtc_auto_apply.md`.
diff --git a/docs/rtc_auto_apply.md b/docs/rtc_auto_apply.md
index 43c431f..48cd0e3 100644
--- a/docs/rtc_auto_apply.md
+++ b/docs/rtc_auto_apply.md
@@ -1,32 +1,37 @@
-# Preenchimento automático do cClassTrib (RTC auto-apply)
+# cClassTrib — preenchimento controlado centralmente (RTC_CCLASSTRIB_WRITE_V1)
-Código: `agent/src/gti_fiscal_agent/rtc_auto_apply.py` · integração `agent/scripts/run_agent_service.py::_rtc_auto_apply` · Hub `central/app/rtc_audit.py`, migração `0027_rtc_auto_apply`, rota `POST /agents/{id}/rtc-auto-apply`.
-**Estado de entrega: mecanismo pronto, DESLIGADO em todo lugar. Nenhuma escrita em banco real foi feita ou é possível sem as três chaves abaixo.**
+O Agent **nunca** decide sozinho escrever no ERP. Não há chave local (`agent.env`, `FIREBIRD_WRITES`, PowerShell, RDP): a autorização vem **somente** dentro do job autenticado enviado pelo Hub. O Agent expõe apenas a capability restrita `RTC_CCLASSTRIB_WRITE_V1` (heartbeat).
-## Escopo de escrita
-Uma coluna: `C000025.CCLASSTRIB`. `ALLOWED_RTC_WRITE_FIELDS = ["CCLASSTRIB"]`; o SQL é uma constante (`UPDATE C000025 SET CCLASSTRIB = ? WHERE CODIGO = ? AND (CCLASSTRIB IS NULL OR TRIM(CCLASSTRIB) = '')`). Não há write genérico de produto, nem escrita em NFCE/itens/catálogo RTC.
-Só candidatos `RESOLVED_*` (ver `docs/cclasstrib_resolution.md`) com `CCLASSTRIB` vazio e nenhum outro problema RTC. Nunca sobrescreve: valor igual ⇒ `NO_WRITE_REQUIRED`; valor diferente ⇒ blocker `CCLASSTRIB_CONFLICT` (a política de correção precisa de aprovação futura).
+## Modos (por instalação, painel: Agent → "Controle e candidatos cClassTrib")
+| Modo | Efeito |
+|---|---|
+| `DISABLED` (padrão) | só informa; nada de preview, nada de escrita |
+| `DRY_RUN` | calcula e envia o preview dos candidatos; zero escrita |
+| `APPROVAL_REQUIRED` | escreve só os produtos aprovados por um humano no painel (lote de até 500 por job) |
+| `ENABLED` | escreve os candidatos determinísticos (até `RTC_AUTO_APPLY_MAX_PER_JOB`, padrão 500, por job); exige digitar `PREENCHER` |
-## Três chaves (todas necessárias; padrão = fechadas)
-1. `FIREBIRD_WRITES` efetivo no Agent (`FISCAL_MODE=AUTO_APPLY` + caminho/esquema autorizados — as travas já existentes);
-2. `RTC_AUTO_APPLY_ENABLED=1` no `agent.env` (chave própria; `RTC_AUTO_APPLY_MAX_PER_JOB`, padrão 500, limita o raio de ação);
-3. autorização do **Hub** por instalação (`installations.rtc_auto_apply_authorized`, padrão `false`), entregue com cada job (`authorizations.rtc_auto_apply`) e alterada só pelo painel: botão "Autorizar" exige CSRF e digitar `PREENCHER`; toda mudança gera `audit_events` (`RTC_AUTO_APPLY_AUTHORIZE/REVOKE`).
-Sem as três: modo `REPORT_ONLY` (conta candidatos, abre nenhuma conexão de escrita). O Agent reporta a capability `rtc_auto_apply` no heartbeat.
+Texto do painel: "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente."
+Mudança de modo = evento de auditoria administrativa (`RTC_WRITE_MODE`). Migration `0028_rtc_write_modes` (padrão `DISABLED`; instalações que já tinham a autorização 0027 viram `ENABLED`).
-## Proteção contra decisão velha (por produto, uma transação curta)
-Imediatamente antes do UPDATE o Agent **relê tudo do banco**: parâmetro `USAR_REFORMA_TRIBUTARIA` (todas as linhas; divergência ⇒ aborta), integridade do catálogo, hash do catálogo (deve ser o da decisão), a linha do produto (única; NCM igual ao da decisão; `CCLASSTRIB` ainda vazio) e **re-deriva** a resolução com dados novos (mesma classe e mesma regra). Qualquer diferença ⇒ `ABORTED` daquele produto (`RTC_NO_LONGER_ENABLED`, `RTC_CATALOG_INVALID`, `LOCAL_CATALOG_CHANGED_SINCE_DECISION`, `PRODUCT_CHANGED_NCM`, `VALUE_ALREADY_SET_NEVER_OVERWRITTEN`, `NO_WRITE_REQUIRED`, `DECISION_CHANGED`, `PRODUCT_ROW_NOT_UNIQUE`, `UPDATE_ROWCOUNT_NOT_ONE`). `rowcount` deve ser 1; releitura confirma o valor; a FK física `C000025.CCLASSTRIB → RTC_CCLASSTRIB` é a segunda barreira. Falha de um produto não interrompe os demais.
-Idempotência: com o valor correto gravado o produto deixa de ser candidato (`valid_existing`); segunda execução = 0 escritas.
+## O que pode ser escrito
+Somente `C000025.CCLASSTRIB` (allowlist `ALLOWED_RTC_WRITE_FIELDS`), somente quando **todos** valem: RTC ligada no ERP; regra `AUTO_APPLY_SAFE` (hoje só **RTC-R3**: NCM em exatamente um anexo I/VII/VIII/XV da LC 214, item limpo, CRT 3, uma única classe); classe `MATCH_SAFE` no catálogo local vs oficial; CST válido; empresa relida (`C000004.CRT` = único valor esperado e = 3); RTC/parâmetro/catálogo relidos; produto inalterado (NCM + descrição + valor, fingerprint); valor atual vazio. **R4 (exclusão residual → 000001) é só sugestão e nunca vira candidato.** Nunca sobrescreve: valor igual → `NO_WRITE_REQUIRED`; diferente → `CCLASSTRIB_CONFLICT` (bloqueio). Qualquer gate falho → zero escrita para aquele produto.
-## Auditoria e rollback lógico
-Cada tentativa vira uma linha em `rtc_write_audits` (append-only): `tenant/installation/agent` (do contexto autenticado, nunca do payload), `client`, `job_id`, `product_code`, `field`, `outcome` (`APPLIED|ABORTED|FAILED`), `reason`, **`old_value`** (sempre nulo hoje: só se preenche campo vazio), `new_value`, `rule_id`, `evidence` (regra, entradas, item do anexo), `official_source_version` (data + sha256 da tabela cClassTrib e sha256 da LC 214), `local_catalog_hash`, `written_at`. Um resumo vai como evento `RTC_AUTO_APPLY` (severidade WARNING quando houve escrita). Reverter = `UPDATE C000025 SET CCLASSTRIB = NULL` para os `product_code` do `job_id` (não há botão de rollback nesta versão).
+Sequência por produto (uma transação curta): reler tudo → recalcular a decisão → conferir fingerprint → `UPDATE C000025 SET CCLASSTRIB = ? WHERE CODIGO = ? AND (CCLASSTRIB IS NULL OR TRIM(CCLASSTRIB)='')` → rowcount = 1 → reler → commit → auditoria.
-## Painel
-Mesmo painel, mesma fila. No detalhe do Agent, com RTC ativa: "Reforma Tributária ativa — cClassTrib pendente em N produtos", "Produtos RTC: total · válidos · prontos para preenchimento · ambíguos/bloqueados", estado do preenchimento automático ("Desativado / Autorizado no Hub, aguardando o Agent (…) / Ativo") e o formulário de autorização. RTC desligada não mostra nada disto.
+## Preview e aprovação
+O Agent envia os candidatos (até 5.000 por job; `preview_truncated` sinaliza excesso) → tabela `rtc_candidates` (PENDING/APPROVED/REJECTED/APPLIED/STALE). O painel pagina no servidor (50/página), filtra por status, aprova/rejeita selecionados ou todos os pendentes e dispara "Aplicar aprovados agora". Uma proposta que muda invalida a aprovação anterior; produto que deixa de ser elegível numa avaliação completa vira `STALE`.
+
+## Auditoria (append-only, `rtc_write_audits`)
+cliente, agent, `installation_uuid`, produto, snapshot da descrição, campo, valor antigo/novo, regra + versão (`rtc-rules-v2`), evidência, hashes/versões das fontes oficiais, hash e estado (`MATCH_SAFE`) do catálogo local, status RTC, CRT da empresa, timestamp, job, origem da autorização (`HUB_MODE:`), resultado, ação (`APPLY`/`ROLLBACK`). Identidade vem do contexto autenticado, nunca do payload. Sem segredos.
+
+## Rollback por registro
+Painel → "Reverter" (digitar `REVERTER`) marca o registro `REQUESTED` e cria um job; o Hub entrega `{audit_id, product_code, written_value}` no job. O Agent só restaura (`SET CCLASSTRIB = NULL`) se o valor atual **ainda for igual ao gravado**; senão `ROLLBACK_CONFLICT_VALUE_CHANGED` (nada muda). Um único statement fixo, sem SQL livre. O registro original vira `DONE`/`BLOCKED`; o produto volta a ser candidato.
## Validação
-- Testes: `agent/tests/test_rtc_auto_apply.py` (gates, candidatos, escrita restrita, idempotência, RTC desligada entre decisão e escrita, catálogo alterado/inválido, NCM alterado, valor existente, duplicata, limite por job), `central/tests/test_zero_touch_hub.py`, `central/tests/test_control_panel_pages.py`, `tests/test_rtc_copy_write_guard.py`.
-- **E2E de escrita em CÓPIA descartável** (`scripts/rtc_copy_write_e2e.py`; cópia `scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB` via gbak; o script recusa qualquer outro caminho): empresa da cópia trocada para regime normal (CRT 3) como experimento; 280 candidatos → 280 escritas verificadas (`000001`×203, `200034`×75, `200014`×2), somente a coluna `CCLASSTRIB` mudou (diff da tabela inteira), 0 órfãos de FK/CST, catálogo RTC local intacto (145 linhas antes e depois), cobertura da cópia 0% → 53,55%; decisão velha (RTC desligada na cópia entre decisão e escrita) → 280 abortos, 0 escritas; segunda execução → 0 candidatos, 0 escritas. Resultado em `docs/runs/rtc_copy_write_e2e.json`.
+- Testes: `agent/tests/test_rtc_auto_apply.py`, `central/tests/test_zero_touch_hub.py`, `test_control_panel_pages.py`, `test_rtc_security.py`, `test_cclasstrib_resolver.py`.
+- **E2E em CÓPIA** (`scripts/rtc_copy_write_e2e.py`, `docs/runs/rtc_copy_write_e2e.json`): restaura a cópia do `.fbk`; DISABLED/DRY_RUN/decisão obsoleta = 0 escrita; aprovação de 5 → exatamente 5; ENABLED → 77 (total); reler; FK/CST órfãos 0; segunda execução 0; rollback de 3 + 1 conflito recusado; reaplicação de 3; idempotência 0; hash de 324 tabelas: `NON_TARGET_COLUMNS_CHANGED=0`, `CATALOG_CHANGED=0`, `PARAMETERS_CHANGED=0`, `DOCUMENT_TABLES_CHANGED=0`, nenhuma outra tabela alterada.
+- Escala (`scripts/scale_hub_test.py`, 21.000 produtos, payload 7,96 MB): persistência 43 s → 5,5 s (upsert em lote; regime estável 64 statements); painel < 0,6 s.
- Bancos reais: 0 escritas, 0 tentativas.
## Limitações
-Simples continua bloqueado (sem orientação oficial); `CCLASSTRIB_CONFLICT` só reporta; não há rollback automático; o texto dos anexos é interpretado de forma conservadora (condição ⇒ ambíguo). Para o piloto real: escolher uma instalação em regime normal com RTC ligada, ligar as três chaves e revisar a amostra de auditoria antes.
+Simples continua bloqueado (sem orientação oficial: NT 2025.002 v1.51; Ato Conjunto RFB/CGIBS 4/2026 fixa a obrigação de Simples em 2027-01-01). Regras além de R3 exigem evidência positiva nova. O Agent 2 permanece em `DISABLED`.
diff --git a/docs/runs/rtc_copy_write_e2e.json b/docs/runs/rtc_copy_write_e2e.json
index 446d924..c9a51f5 100644
--- a/docs/runs/rtc_copy_write_e2e.json
+++ b/docs/runs/rtc_copy_write_e2e.json
@@ -1,12 +1,14 @@
{
- "database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (gbak restore of TESTESOFICIAIS)",
+ "database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (restored from its .fbk at start)",
"real_database_writes": 0,
"real_database_write_attempts": 0,
+ "mechanism": "job authorizations only (no env switches)",
"copy_original_crt": [
"1"
],
- "copy_crt_for_experiment": "3",
"copy_rtc_param": 1,
+ "copy_crt_for_experiment": "3",
+ "tables_hashed": 324,
"coverage_before": {
"total": 521,
"complete": 0,
@@ -14,14 +16,61 @@
"unclassified": 0,
"coverage_percent": 0.0
},
+ "disabled_run": {
+ "write_attempts": 0,
+ "preview": 0,
+ "ready_total": 77
+ },
+ "dry_run": {
+ "write_attempts": 0,
+ "preview": 77,
+ "ready_total": 77,
+ "rules": [
+ "RTC-R3"
+ ],
+ "sample": [
+ {
+ "product_code": "000024",
+ "description": "COXINHA ( 6 UNID )",
+ "ncm": "19022000",
+ "current": "",
+ "proposed": "200034",
+ "cst": "200",
+ "rule_id": "RTC-R3",
+ "basis": "RTC-R3:ANNEX_TEXT_MATCH"
+ },
+ {
+ "product_code": "000027",
+ "description": "RISOLE DE CAMARAO",
+ "ncm": "19022000",
+ "current": "",
+ "proposed": "200034",
+ "cst": "200",
+ "rule_id": "RTC-R3",
+ "basis": "RTC-R3:ANNEX_TEXT_MATCH"
+ },
+ {
+ "product_code": "000028",
+ "description": "SALGADOS (MIXTO 10 UND)",
+ "ncm": "19022000",
+ "current": "",
+ "proposed": "200034",
+ "cst": "200",
+ "rule_id": "RTC-R3",
+ "basis": "RTC-R3:ANNEX_TEXT_MATCH"
+ }
+ ]
+ },
"resolution_before": {
"total": 521,
"valid_existing": 0,
- "ready_to_apply": 280,
+ "ready_to_apply": 77,
+ "suggestion_only": 203,
"ambiguous": 215,
"insufficient": 26,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -35,196 +84,189 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
},
"stale_decision_run": {
- "write_attempts": 280,
- "write_successes": 0,
- "aborted_reasons": {
- "RTC_NO_LONGER_ENABLED": 280
- }
- },
- "first_run": {
- "mode": "AUTO_APPLY",
- "ready_total": 280,
- "write_attempts": 280,
- "write_successes": 280,
- "aborted_reasons": {},
- "audit_sample": [
- {
- "outcome": "APPLIED",
- "product_code": "000023",
- "field": "CCLASSTRIB",
- "old_value": null,
- "new_value": "000001",
- "cst_ibscbs": "000",
- "rule_id": "RTC-R4",
- "confidence_type": "EXCLUSION_OF_ALL_SPECIAL_REGIMES",
- "evidence": {
- "inputs_used": {
- "ncm": "16025000",
- "crt": "3",
- "rtc_enabled": true
- },
- "evidence": [
- {
- "annex_hits": 0,
- "chapter": "16"
- }
- ],
- "reason": "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER"
- },
- "official_source_version": {
- "cclasstrib_table": "2026-06-22",
- "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
- "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680"
- },
- "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
- "timestamp": "2026-09-25T00:07:47+00:00",
- "job_id": 424242,
- "rowcount": 1,
- "verified_after": true
- },
- {
- "outcome": "APPLIED",
- "product_code": "000024",
- "field": "CCLASSTRIB",
- "old_value": null,
- "new_value": "200034",
- "cst_ibscbs": "200",
- "rule_id": "RTC-R3",
- "confidence_type": "ANNEX_TEXT_MATCH",
- "evidence": {
- "inputs_used": {
- "ncm": "19022000",
- "crt": "3",
- "rtc_enabled": true
- },
- "evidence": [
- {
- "annex": "VII",
- "item": 9,
- "prefix": "19022000",
- "clean": true,
- "revoked": false,
- "text": "Massas alimentícias dos códigos 1902.20.00 e 1902.30.00 da NCM/SH"
- }
- ],
- "reason": "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS"
- },
- "official_source_version": {
- "cclasstrib_table": "2026-06-22",
- "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
- "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680"
- },
- "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
- "timestamp": "2026-09-25T00:07:47+00:00",
- "job_id": 424242,
- "rowcount": 1,
- "verified_after": true
- },
- {
- "outcome": "APPLIED",
- "product_code": "000027",
- "field": "CCLASSTRIB",
- "old_value": null,
- "new_value": "200034",
- "cst_ibscbs": "200",
- "rule_id": "RTC-R3",
- "confidence_type": "ANNEX_TEXT_MATCH",
- "evidence": {
- "inputs_used": {
- "ncm": "19022000",
- "crt": "3",
- "rtc_enabled": true
- },
- "evidence": [
- {
- "annex": "VII",
- "item": 9,
- "prefix": "19022000",
- "clean": true,
- "revoked": false,
- "text": "Massas alimentícias dos códigos 1902.20.00 e 1902.30.00 da NCM/SH"
- }
- ],
- "reason": "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS"
- },
- "official_source_version": {
- "cclasstrib_table": "2026-06-22",
- "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
- "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680"
- },
- "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
- "timestamp": "2026-09-25T00:07:47+00:00",
- "job_id": 424242,
- "rowcount": 1,
- "verified_after": true
- }
- ]
- },
- "rows_changed": 280,
- "columns_changed": [
- "CCLASSTRIB"
- ],
- "fk_orphans": 0,
- "cst_orphans": 0,
- "written_by_class": {
- "000001": 203,
- "200014": 2,
- "200034": 75
- },
- "catalog_rows_unchanged": 145,
- "coverage_after": {
- "total": 521,
- "complete": 279,
- "blocked": 242,
- "unclassified": 0,
- "coverage_percent": 53.55
- },
- "resolution_after": {
- "total": 521,
- "valid_existing": 280,
- "ready_to_apply": 0,
- "ambiguous": 215,
- "insufficient": 26,
- "conflicts": 0,
- "official_mismatch": 0,
- "not_applicable": 0,
- "other_blocked": 0,
- "by_rule": {
- "RTC-A1": 41,
- "RTC-A3": 10,
- "RTC-R2": 26,
- "RTC-R4": 203,
- "RTC-R3": 77,
- "RTC-A2": 164
- },
- "catalog_compatibility": {
- "compatibility": "MATERIAL_MISMATCH",
- "counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
- "MISMATCH": 11,
- "EXPIRED": 3
- },
- "expired_present_locally": 3
- }
- },
- "second_run": {
- "ready_total": 0,
"write_attempts": 0,
"write_successes": 0
},
+ "approval_run": {
+ "without_approval_attempts": 0,
+ "approved": 5,
+ "write_successes": 5,
+ "only_approved": true
+ },
+ "enabled_jobs": [
+ {
+ "write_attempts": 72,
+ "write_successes": 72,
+ "aborted": {}
+ },
+ {
+ "write_attempts": 0,
+ "write_successes": 0,
+ "aborted": {}
+ }
+ ],
+ "total_applied": 77,
+ "audit_sample": [
+ {
+ "outcome": "APPLIED",
+ "product_code": "000024",
+ "field": "CCLASSTRIB",
+ "old_value": null,
+ "new_value": "200034",
+ "cst_ibscbs": "200",
+ "rule_id": "RTC-R3",
+ "confidence_type": "ANNEX_TEXT_MATCH",
+ "evidence": {
+ "inputs_used": {
+ "ncm": "19022000",
+ "crt": "3",
+ "rtc_enabled": true
+ },
+ "evidence": [
+ {
+ "annex": "VII",
+ "item": 9,
+ "prefix": "19022000",
+ "clean": true,
+ "revoked": false,
+ "text": "Massas alimentícias dos códigos 1902.20.00 e 1902.30.00 da NCM/SH"
+ }
+ ],
+ "reason": "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS"
+ },
+ "official_source_version": {
+ "cclasstrib_table": "2026-06-22",
+ "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
+ "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680"
+ },
+ "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
+ "timestamp": "2026-09-25T01:06:30+00:00",
+ "job_id": 2,
+ "rowcount": 1,
+ "verified_after": true,
+ "action": "APPLY",
+ "rule_version": "rtc-rules-v2",
+ "company_crt": "3",
+ "description_snapshot": "COXINHA ( 6 UNID )",
+ "rtc_status": "ENABLED",
+ "local_catalog_state": "MATCH_SAFE",
+ "authorization_origin": "HUB_MODE:APPROVAL_REQUIRED"
+ },
+ {
+ "outcome": "APPLIED",
+ "product_code": "000027",
+ "field": "CCLASSTRIB",
+ "old_value": null,
+ "new_value": "200034",
+ "cst_ibscbs": "200",
+ "rule_id": "RTC-R3",
+ "confidence_type": "ANNEX_TEXT_MATCH",
+ "evidence": {
+ "inputs_used": {
+ "ncm": "19022000",
+ "crt": "3",
+ "rtc_enabled": true
+ },
+ "evidence": [
+ {
+ "annex": "VII",
+ "item": 9,
+ "prefix": "19022000",
+ "clean": true,
+ "revoked": false,
+ "text": "Massas alimentícias dos códigos 1902.20.00 e 1902.30.00 da NCM/SH"
+ }
+ ],
+ "reason": "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS"
+ },
+ "official_source_version": {
+ "cclasstrib_table": "2026-06-22",
+ "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
+ "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680"
+ },
+ "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
+ "timestamp": "2026-09-25T01:06:30+00:00",
+ "job_id": 2,
+ "rowcount": 1,
+ "verified_after": true,
+ "action": "APPLY",
+ "rule_version": "rtc-rules-v2",
+ "company_crt": "3",
+ "description_snapshot": "RISOLE DE CAMARAO",
+ "rtc_status": "ENABLED",
+ "local_catalog_state": "MATCH_SAFE",
+ "authorization_origin": "HUB_MODE:APPROVAL_REQUIRED"
+ }
+ ],
+ "audit_fields_complete": true,
+ "rows_changed": 77,
+ "fk_orphans": 0,
+ "cst_orphans": 0,
+ "coverage_after": {
+ "total": 521,
+ "complete": 77,
+ "blocked": 444,
+ "unclassified": 0,
+ "coverage_percent": 14.78
+ },
+ "second_run": {
+ "write_attempts": 0,
+ "write_successes": 0,
+ "ready_total": 0
+ },
+ "rollback": {
+ "rolled_back": 3,
+ "conflict": [
+ "ABORTED",
+ "ROLLBACK_CONFLICT_VALUE_CHANGED"
+ ],
+ "records": [
+ "ROLLED_BACK",
+ "ROLLED_BACK",
+ "ROLLED_BACK",
+ "ABORTED"
+ ],
+ "reread": {
+ "000024": "",
+ "000027": "",
+ "000028": "",
+ "000032": "200035"
+ }
+ },
+ "reapply": {
+ "write_successes": 3,
+ "codes": [
+ "000024",
+ "000027",
+ "000028"
+ ],
+ "expected": [
+ "000024",
+ "000027",
+ "000028"
+ ]
+ },
+ "idempotency_after_reapply": {
+ "write_attempts": 0,
+ "write_successes": 0
+ },
+ "NON_TARGET_COLUMNS_CHANGED": 0,
+ "CATALOG_CHANGED": 0,
+ "PARAMETERS_CHANGED": 0,
+ "DOCUMENT_TABLES_CHANGED": 0,
+ "OTHER_TABLES_CHANGED": 0,
+ "changed_tables": [],
"IDEMPOTENT": true,
- "ONLY_CCLASSTRIB_CHANGED": true,
"PASS": true
}
\ No newline at end of file
diff --git a/docs/runs/rtc_resolution_audit_sample.csv b/docs/runs/rtc_resolution_audit_sample.csv
index 6ac3e32..1ffde67 100644
--- a/docs/runs/rtc_resolution_audit_sample.csv
+++ b/docs/runs/rtc_resolution_audit_sample.csv
@@ -13,89 +13,89 @@ TESTESOFICIAIS,000018,02071100,,,AMBIGUOUS,RTC-A1,ANNEX_I_NEEDS_OPERATION_OR_CON
TESTESOFICIAIS,000019,07133321,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
TESTESOFICIAIS,000021,07089000,,,AMBIGUOUS,RTC-A1,ANNEX_XV_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
TESTESOFICIAIS,000022,07089000,,,AMBIGUOUS,RTC-A1,ANNEX_XV_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-TESTESOFICIAIS,000023,16025000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
TESTESOFICIAIS,000024,19022000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
TESTESOFICIAIS,000025,04061010,,,AMBIGUOUS,RTC-A1,ANNEX_I_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
TESTESOFICIAIS,000026,03055100,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
TESTESOFICIAIS,000027,19022000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
TESTESOFICIAIS,000028,19022000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000001,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000002,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000003,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000004,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000005,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000006,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000007,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000008,25232910,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000009,25232910,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000010,25232910,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000011,68118200,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000012,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000013,68101100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000014,68101100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000015,40139000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000016,40139000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+TESTESOFICIAIS,000029,04089100,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
CONSTRUCAO,000017,30045020,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000018,56075090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000019,56075090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-CONSTRUCAO,000020,56075090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000075,38089199,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000076,38089119,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000077,38089119,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000078,38089996,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000086,90303390,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000092,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000093,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000094,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000095,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000096,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000097,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000108,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000109,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000113,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000114,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000115,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000116,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000117,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+CONSTRUCAO,000118,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
MMMODAS,000002,19022000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000003,61046300,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000006,61046300,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000008,61046200,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000011,61091000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000013,61051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000014,61051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000016,61051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000017,62111100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000018,62111100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000019,62034200,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000020,42021220,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000022,42021900,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000023,65040090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000024,61091000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000025,61091000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000026,61061000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000027,62064000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000028,62046200,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-MMMODAS,000029,62034300,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000001,21050090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,000729,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,000730,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,000731,90031990,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,000732,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,000733,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,000735,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,000736,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001248,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001259,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001260,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001261,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001262,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001263,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001264,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001265,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001266,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001267,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001270,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+MMMODAS,001272,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
RINCAO,000004,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
RINCAO,000005,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
RINCAO,000006,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
RINCAO,000007,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
RINCAO,000008,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
RINCAO,000009,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000010,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000011,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000012,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000013,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000014,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000015,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000016,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
RINCAO,000017,21069090,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000018,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000019,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
RINCAO,000020,21069090,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000021,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-RINCAO,000022,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000008,33059000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000009,21039021,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000026,21069090,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000027,21069090,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000032,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000033,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000034,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000035,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000036,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000037,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000038,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000039,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000040,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+RINCAO,000041,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
UNIAO,000014,19059010,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000015,95051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000016,21032010,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000017,95030099,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
UNIAO,000018,38089999,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
UNIAO,000019,38089999,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
UNIAO,000020,38089999,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000021,95051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000022,22021000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000023,19053100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000024,82041100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
UNIAO,000025,04089900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
UNIAO,000026,15171000,,200003,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_I_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000027,82054000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
UNIAO,000028,04090000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000029,19053100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
UNIAO,000030,04022120,,,AMBIGUOUS,RTC-A1,ANNEX_I_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
-UNIAO,000031,19053100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000032,19019090,,200003,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_I_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000034,20081100,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000035,15121911,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000046,20089900,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000047,23023010,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000050,20089900,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000052,20089900,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000054,20057000,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000056,20081100,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000058,15121911,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000064,02101200,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)"
+UNIAO,000077,20089900,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)"
diff --git a/docs/runs/rtc_resolution_dryrun.json b/docs/runs/rtc_resolution_dryrun.json
index 3137d97..f9cd935 100644
--- a/docs/runs/rtc_resolution_dryrun.json
+++ b/docs/runs/rtc_resolution_dryrun.json
@@ -16,10 +16,12 @@
"total": 521,
"valid_existing": 0,
"ready_to_apply": 0,
+ "suggestion_only": 0,
"ambiguous": 0,
"insufficient": 521,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -31,11 +33,12 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
@@ -50,7 +53,7 @@
"context": {
"rtc_param": "1",
"rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA",
- "rtc_observed_at": "2026-09-25T00:11:38+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:28+00:00",
"local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -76,10 +79,12 @@
"total": 521,
"valid_existing": 0,
"ready_to_apply": 0,
+ "suggestion_only": 0,
"ambiguous": 0,
"insufficient": 521,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -91,11 +96,12 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
@@ -110,7 +116,7 @@
"context": {
"rtc_param": "1",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:38+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:28+00:00",
"local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -135,11 +141,13 @@
"resolution_summary": {
"total": 521,
"valid_existing": 0,
- "ready_to_apply": 280,
+ "ready_to_apply": 77,
+ "suggestion_only": 203,
"ambiguous": 215,
"insufficient": 26,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -165,11 +173,12 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
@@ -177,18 +186,20 @@
"by_status": {
"AMBIGUOUS": 215,
"INSUFFICIENT_EVIDENCE": 26,
- "RESOLVED_WITH_EXPLICIT_RULE": 280
+ "SUGGESTION_ONLY": 203,
+ "RESOLVED_WITH_EXPLICIT_RULE": 77
},
"rtc_blocker_types": {
"CCLASSTRIB_AMBIGUOUS": 215,
"RTC_RULE_EVIDENCE_MISSING": 26,
- "CCLASSTRIB_MISSING": 280
+ "CCLASSTRIB_MISSING_UNRESOLVED": 203,
+ "CCLASSTRIB_MISSING": 77
},
- "AUTO_APPLY_CANDIDATES": 280,
+ "AUTO_APPLY_CANDIDATES": 77,
"context": {
"rtc_param": "1",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:38+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:29+00:00",
"local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -220,10 +231,12 @@
"total": 3712,
"valid_existing": 0,
"ready_to_apply": 0,
+ "suggestion_only": 0,
"ambiguous": 0,
"insufficient": 3712,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -235,11 +248,12 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
@@ -254,7 +268,7 @@
"context": {
"rtc_param": "1",
"rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA",
- "rtc_observed_at": "2026-09-25T00:11:39+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:29+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -280,10 +294,12 @@
"total": 3712,
"valid_existing": 0,
"ready_to_apply": 0,
+ "suggestion_only": 0,
"ambiguous": 0,
"insufficient": 3712,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -295,11 +311,12 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
@@ -314,7 +331,7 @@
"context": {
"rtc_param": "1",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:39+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:30+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -339,11 +356,13 @@
"resolution_summary": {
"total": 3712,
"valid_existing": 0,
- "ready_to_apply": 2725,
+ "ready_to_apply": 0,
+ "suggestion_only": 2725,
"ambiguous": 973,
"insufficient": 14,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -367,30 +386,31 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
},
"by_status": {
- "RESOLVED_WITH_EXPLICIT_RULE": 2725,
+ "SUGGESTION_ONLY": 2725,
"AMBIGUOUS": 973,
"INSUFFICIENT_EVIDENCE": 14
},
"rtc_blocker_types": {
- "CCLASSTRIB_MISSING": 2725,
+ "CCLASSTRIB_MISSING_UNRESOLVED": 2725,
"CCLASSTRIB_AMBIGUOUS": 973,
"RTC_RULE_EVIDENCE_MISSING": 14
},
- "AUTO_APPLY_CANDIDATES": 2725,
+ "AUTO_APPLY_CANDIDATES": 0,
"context": {
"rtc_param": "1",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:40+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:30+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -427,7 +447,7 @@
"context": {
"rtc_param": "0",
"rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA",
- "rtc_observed_at": "2026-09-25T00:11:43+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:33+00:00",
"local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
"official_catalog": null,
"catalog_integrity": {
@@ -449,10 +469,12 @@
"total": 20996,
"valid_existing": 0,
"ready_to_apply": 0,
+ "suggestion_only": 0,
"ambiguous": 0,
"insufficient": 20996,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -464,11 +486,12 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
@@ -483,7 +506,7 @@
"context": {
"rtc_param": "0",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:44+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:35+00:00",
"local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -508,54 +531,59 @@
"resolution_summary": {
"total": 20996,
"valid_existing": 0,
- "ready_to_apply": 20831,
- "ambiguous": 49,
+ "ready_to_apply": 1,
+ "suggestion_only": 20565,
+ "ambiguous": 314,
"insufficient": 116,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
"RTC-R3": 1,
- "RTC-R4": 20830,
+ "RTC-R4": 20565,
"RTC-R2": 116,
- "RTC-A3": 48,
+ "RTC-A3": 313,
"RTC-A1": 1
},
"by_reason": {
"ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS": 1,
- "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER": 20830,
+ "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER": 20565,
"NO_VALID_NCM": 116,
- "SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD": 48,
+ "SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD": 313,
"ANNEX_XV_NEEDS_OPERATION_OR_CONDITION": 1
},
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
},
"by_status": {
- "RESOLVED_WITH_EXPLICIT_RULE": 20831,
+ "RESOLVED_WITH_EXPLICIT_RULE": 1,
+ "SUGGESTION_ONLY": 20565,
"INSUFFICIENT_EVIDENCE": 116,
- "AMBIGUOUS": 49
+ "AMBIGUOUS": 314
},
"rtc_blocker_types": {
- "CCLASSTRIB_MISSING": 20831,
+ "CCLASSTRIB_MISSING": 1,
+ "CCLASSTRIB_MISSING_UNRESOLVED": 20565,
"RTC_RULE_EVIDENCE_MISSING": 116,
- "CCLASSTRIB_AMBIGUOUS": 49
+ "CCLASSTRIB_AMBIGUOUS": 314
},
- "AUTO_APPLY_CANDIDATES": 20831,
+ "AUTO_APPLY_CANDIDATES": 1,
"context": {
"rtc_param": "0",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:49+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:39+00:00",
"local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -592,7 +620,7 @@
"context": {
"rtc_param": "0",
"rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA",
- "rtc_observed_at": "2026-09-25T00:11:50+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:40+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": null,
"catalog_integrity": {
@@ -613,11 +641,13 @@
"resolution_summary": {
"total": 894,
"valid_existing": 0,
- "ready_to_apply": 530,
+ "ready_to_apply": 63,
+ "suggestion_only": 467,
"ambiguous": 364,
"insufficient": 0,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -642,28 +672,31 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
},
"by_status": {
- "RESOLVED_WITH_EXPLICIT_RULE": 530,
- "AMBIGUOUS": 364
+ "SUGGESTION_ONLY": 467,
+ "AMBIGUOUS": 364,
+ "RESOLVED_WITH_EXPLICIT_RULE": 63
},
"rtc_blocker_types": {
- "CCLASSTRIB_MISSING": 530,
- "CCLASSTRIB_AMBIGUOUS": 364
+ "CCLASSTRIB_MISSING_UNRESOLVED": 467,
+ "CCLASSTRIB_AMBIGUOUS": 364,
+ "CCLASSTRIB_MISSING": 63
},
- "AUTO_APPLY_CANDIDATES": 530,
+ "AUTO_APPLY_CANDIDATES": 63,
"context": {
"rtc_param": "0",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:50+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:41+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -688,11 +721,13 @@
"resolution_summary": {
"total": 894,
"valid_existing": 0,
- "ready_to_apply": 530,
+ "ready_to_apply": 63,
+ "suggestion_only": 467,
"ambiguous": 364,
"insufficient": 0,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -717,28 +752,31 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
},
"by_status": {
- "RESOLVED_WITH_EXPLICIT_RULE": 530,
- "AMBIGUOUS": 364
+ "SUGGESTION_ONLY": 467,
+ "AMBIGUOUS": 364,
+ "RESOLVED_WITH_EXPLICIT_RULE": 63
},
"rtc_blocker_types": {
- "CCLASSTRIB_MISSING": 530,
- "CCLASSTRIB_AMBIGUOUS": 364
+ "CCLASSTRIB_MISSING_UNRESOLVED": 467,
+ "CCLASSTRIB_AMBIGUOUS": 364,
+ "CCLASSTRIB_MISSING": 63
},
- "AUTO_APPLY_CANDIDATES": 530,
+ "AUTO_APPLY_CANDIDATES": 63,
"context": {
"rtc_param": "0",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:50+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:41+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -775,7 +813,7 @@
"context": {
"rtc_param": "0",
"rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA",
- "rtc_observed_at": "2026-09-25T00:11:52+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:42+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": null,
"catalog_integrity": {
@@ -797,10 +835,12 @@
"total": 8942,
"valid_existing": 0,
"ready_to_apply": 0,
+ "suggestion_only": 0,
"ambiguous": 0,
"insufficient": 8942,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -812,11 +852,12 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
@@ -831,7 +872,7 @@
"context": {
"rtc_param": "0",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:52+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:43+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
@@ -856,11 +897,13 @@
"resolution_summary": {
"total": 8942,
"valid_existing": 0,
- "ready_to_apply": 7419,
+ "ready_to_apply": 989,
+ "suggestion_only": 6430,
"ambiguous": 1432,
"insufficient": 91,
"conflicts": 0,
"official_mismatch": 0,
+ "catalog_incompatible": 0,
"not_applicable": 0,
"other_blocked": 0,
"by_rule": {
@@ -894,30 +937,33 @@
"catalog_compatibility": {
"compatibility": "MATERIAL_MISMATCH",
"counts": {
- "MATCH": 131,
- "LOCAL_ONLY": 0,
- "OFFICIAL_ONLY": 19,
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
"MISMATCH": 11,
- "EXPIRED": 3
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
},
"expired_present_locally": 3
}
},
"by_status": {
- "RESOLVED_WITH_EXPLICIT_RULE": 7419,
+ "SUGGESTION_ONLY": 6430,
+ "RESOLVED_WITH_EXPLICIT_RULE": 989,
"AMBIGUOUS": 1432,
"INSUFFICIENT_EVIDENCE": 91
},
"rtc_blocker_types": {
- "CCLASSTRIB_MISSING": 7419,
+ "CCLASSTRIB_MISSING_UNRESOLVED": 6430,
+ "CCLASSTRIB_MISSING": 989,
"CCLASSTRIB_AMBIGUOUS": 1432,
"RTC_RULE_EVIDENCE_MISSING": 91
},
- "AUTO_APPLY_CANDIDATES": 7419,
+ "AUTO_APPLY_CANDIDATES": 989,
"context": {
"rtc_param": "0",
"rtc_source": "SIMULATION",
- "rtc_observed_at": "2026-09-25T00:11:54+00:00",
+ "rtc_observed_at": "2026-09-25T01:07:45+00:00",
"local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
"official_catalog": {
"sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
diff --git a/docs/runs/scale_hub_test.json b/docs/runs/scale_hub_test.json
new file mode 100644
index 0000000..6551fa6
--- /dev/null
+++ b/docs/runs/scale_hub_test.json
@@ -0,0 +1,48 @@
+{
+ "products": 21000,
+ "result_payload_bytes": 8342480,
+ "result_payload_mb": 7.96,
+ "persist_first_pass": {
+ "status": 200,
+ "seconds": 5.46,
+ "sql_statements": 26043,
+ "statements_per_product": 1.24
+ },
+ "persist_steady_state": {
+ "status": 200,
+ "seconds": 3.45,
+ "sql_statements": 64,
+ "statements_per_product": 0.0
+ },
+ "rows": {
+ "blocked": 21000,
+ "rtc_candidates": 5000
+ },
+ "panel_pages": {
+ "dashboard": {
+ "status": 200,
+ "seconds": 0.209,
+ "html_kb": 10.3
+ },
+ "agent_detail": {
+ "status": 200,
+ "seconds": 0.196,
+ "html_kb": 10.7
+ },
+ "rtc_control_p1": {
+ "status": 200,
+ "seconds": 0.036,
+ "html_kb": 18.6
+ },
+ "rtc_control_p50": {
+ "status": 200,
+ "seconds": 0.017,
+ "html_kb": 18.9
+ },
+ "blockers": {
+ "status": 200,
+ "seconds": 0.228,
+ "html_kb": 40.2
+ }
+ }
+}
\ No newline at end of file
diff --git a/docs/runs/unified_coverage_RINCAO.json b/docs/runs/unified_coverage_RINCAO.json
new file mode 100644
index 0000000..a401dda
--- /dev/null
+++ b/docs/runs/unified_coverage_RINCAO.json
@@ -0,0 +1,462 @@
+{
+ "mode": "FULL (Hub decision + fiscal)",
+ "database": "RINCAO",
+ "crt": "3",
+ "products": 894,
+ "fiscal_products_read": 894,
+ "BEFORE": {
+ "COMPLETE_PRODUCTS": 894,
+ "SAFE_BLOCKED_PRODUCTS": 0,
+ "COVERAGE_PERCENT": 100.0,
+ "UNKNOWN_APPLICABILITY_previous_model": 815
+ },
+ "runs": {
+ "ERP_AS_FOUND": {
+ "rtc_enabled": false,
+ "TOTAL_PRODUCTS": 894,
+ "COMPLETE_PRODUCTS": 893,
+ "SAFE_BLOCKED_PRODUCTS": 1,
+ "FAILED_PRODUCTS": 0,
+ "UNCLASSIFIED_PRODUCTS": 0,
+ "COVERAGE_PERCENT": 99.89,
+ "INVARIANT_PASS": true,
+ "PIS_BLOCKERS": 1,
+ "COFINS_BLOCKERS": 1,
+ "RTC_BLOCKERS": 0,
+ "OTHER_BLOCKERS": 0,
+ "blockers_by_type": {
+ "COFINS_CST_INVALID": 1,
+ "PIS_CST_INVALID": 1
+ },
+ "applicability": {
+ "OPTIONAL": 894
+ },
+ "rtc_applicability": {
+ "NOT_APPLICABLE": 894
+ },
+ "changed_products": 1,
+ "examples": [
+ {
+ "product": "000866",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "COFINS_CST_INVALID",
+ "PIS_CST_INVALID"
+ ]
+ }
+ ],
+ "non_canonical_warnings": 78,
+ "context": {
+ "rtc_enabled": false,
+ "erp_rtc_enabled": false,
+ "rtc_supported": true,
+ "rtc_catalog_valid": true,
+ "rtc_param": "0",
+ "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA",
+ "rtc_param_rows": 1,
+ "rtc_param_conflict": false,
+ "rtc_observed_at": "2026-09-25T01:22:08+00:00",
+ "legal_effective_context": {
+ "crt": "3",
+ "informational_only": true,
+ "ibs_cbs_in_documents": "MANDATORY_FROM_2026-08-03",
+ "cclasstrib_orientation": "TABLE_AND_LC214_ANNEXES",
+ "sources": [
+ "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1 I, II and par. 1",
+ "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT",
+ "LC 214/2025 art. 348"
+ ]
+ },
+ "catalog_integrity": {
+ "classes": 145,
+ "cst": 18,
+ "orphan_classes": 0,
+ "bad_class_format": 0,
+ "bad_cst_format": 0,
+ "duplicates": 0,
+ "valid": true
+ },
+ "crt": "3",
+ "products_evaluated": 894,
+ "pis_cofins_applicability": {
+ "OPTIONAL": 894
+ },
+ "rtc_resolution": null,
+ "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
+ "official_catalog": null,
+ "non_canonical_warnings": {
+ "products": 78,
+ "by_type": {
+ "CST_COFINS_NON_CANONICAL_FORMAT": 78,
+ "CST_PIS_NON_CANONICAL_FORMAT": 78
+ },
+ "samples": [
+ {
+ "product_code": "000139",
+ "type": "CST_PIS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000139",
+ "type": "CST_COFINS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000140",
+ "type": "CST_PIS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000140",
+ "type": "CST_COFINS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000141",
+ "type": "CST_PIS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000141",
+ "type": "CST_COFINS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000142",
+ "type": "CST_PIS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000142",
+ "type": "CST_COFINS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000143",
+ "type": "CST_PIS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ },
+ {
+ "product_code": "000143",
+ "type": "CST_COFINS_NON_CANONICAL_FORMAT",
+ "reason": "EXIT:4->04"
+ }
+ ]
+ }
+ }
+ },
+ "RTC_OFF_SIMULATION": {
+ "rtc_enabled": false,
+ "TOTAL_PRODUCTS": 894,
+ "COMPLETE_PRODUCTS": 893,
+ "SAFE_BLOCKED_PRODUCTS": 1,
+ "FAILED_PRODUCTS": 0,
+ "UNCLASSIFIED_PRODUCTS": 0,
+ "COVERAGE_PERCENT": 99.89,
+ "INVARIANT_PASS": true,
+ "PIS_BLOCKERS": 1,
+ "COFINS_BLOCKERS": 1,
+ "RTC_BLOCKERS": 0,
+ "OTHER_BLOCKERS": 0,
+ "blockers_by_type": {
+ "COFINS_CST_INVALID": 1,
+ "PIS_CST_INVALID": 1
+ },
+ "applicability": {
+ "OPTIONAL": 894
+ },
+ "rtc_applicability": {
+ "NOT_APPLICABLE": 894
+ },
+ "changed_products": 1,
+ "examples": [
+ {
+ "product": "000866",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "COFINS_CST_INVALID",
+ "PIS_CST_INVALID"
+ ]
+ }
+ ],
+ "non_canonical_warnings": 78
+ },
+ "RTC_ON_SIMULATION": {
+ "rtc_enabled": true,
+ "TOTAL_PRODUCTS": 894,
+ "COMPLETE_PRODUCTS": 0,
+ "SAFE_BLOCKED_PRODUCTS": 894,
+ "FAILED_PRODUCTS": 0,
+ "UNCLASSIFIED_PRODUCTS": 0,
+ "COVERAGE_PERCENT": 0.0,
+ "INVARIANT_PASS": true,
+ "PIS_BLOCKERS": 1,
+ "COFINS_BLOCKERS": 1,
+ "RTC_BLOCKERS": 894,
+ "OTHER_BLOCKERS": 0,
+ "blockers_by_type": {
+ "COFINS_CST_INVALID": 1,
+ "PIS_CST_INVALID": 1,
+ "RTC_RULE_EVIDENCE_MISSING": 894
+ },
+ "applicability": {
+ "OPTIONAL": 894
+ },
+ "rtc_applicability": {
+ "REQUIRED": 894
+ },
+ "changed_products": 894,
+ "examples": [
+ {
+ "product": "000001",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000004",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000005",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000006",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000007",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000008",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000009",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000010",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000011",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000012",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000013",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000014",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000015",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000016",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000017",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000018",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000019",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000020",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000021",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000022",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000023",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000024",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000025",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000026",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000027",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000028",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000029",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000030",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000031",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ },
+ {
+ "product": "000032",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "RTC_RULE_EVIDENCE_MISSING"
+ ]
+ }
+ ],
+ "non_canonical_warnings": 78
+ }
+ },
+ "SAFETY": {
+ "FIREBIRD_WRITES": 0,
+ "WRITE_ATTEMPTS": 0,
+ "WRITE_SUCCESSES": 0,
+ "connection_mode": "READ COMMITTED READ ONLY (Agent reader); no write-capable connection opened"
+ }
+}
\ No newline at end of file
diff --git a/docs/runs/unified_coverage_UNIAO.json b/docs/runs/unified_coverage_UNIAO.json
new file mode 100644
index 0000000..399b6ae
--- /dev/null
+++ b/docs/runs/unified_coverage_UNIAO.json
@@ -0,0 +1,393 @@
+{
+ "mode": "FULL (Hub decision + fiscal)",
+ "database": "UNIAO",
+ "crt": "1",
+ "products": 8942,
+ "fiscal_products_read": 8942,
+ "BEFORE": {
+ "COMPLETE_PRODUCTS": 8851,
+ "SAFE_BLOCKED_PRODUCTS": 91,
+ "COVERAGE_PERCENT": 98.98,
+ "UNKNOWN_APPLICABILITY_previous_model": 8170
+ },
+ "runs": {
+ "ERP_AS_FOUND": {
+ "rtc_enabled": false,
+ "TOTAL_PRODUCTS": 8942,
+ "COMPLETE_PRODUCTS": 8851,
+ "SAFE_BLOCKED_PRODUCTS": 91,
+ "FAILED_PRODUCTS": 0,
+ "UNCLASSIFIED_PRODUCTS": 0,
+ "COVERAGE_PERCENT": 98.98,
+ "INVARIANT_PASS": true,
+ "PIS_BLOCKERS": 0,
+ "COFINS_BLOCKERS": 0,
+ "RTC_BLOCKERS": 0,
+ "OTHER_BLOCKERS": 0,
+ "blockers_by_type": {},
+ "applicability": {
+ "NOT_APPLICABLE": 8942
+ },
+ "rtc_applicability": {
+ "NOT_APPLICABLE": 8942
+ },
+ "changed_products": 0,
+ "examples": [],
+ "non_canonical_warnings": 2,
+ "context": {
+ "rtc_enabled": false,
+ "erp_rtc_enabled": false,
+ "rtc_supported": true,
+ "rtc_catalog_valid": true,
+ "rtc_param": "0",
+ "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA",
+ "rtc_param_rows": 1,
+ "rtc_param_conflict": false,
+ "rtc_observed_at": "2026-09-25T01:27:07+00:00",
+ "legal_effective_context": {
+ "crt": "1",
+ "informational_only": true,
+ "ibs_cbs_in_documents": "MANDATORY_FROM_2027-01-01",
+ "cclasstrib_orientation": "PENDING_OFFICIAL_NT",
+ "sources": [
+ "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1 I, II and par. 1",
+ "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT",
+ "LC 214/2025 art. 348"
+ ]
+ },
+ "catalog_integrity": {
+ "classes": 145,
+ "cst": 18,
+ "orphan_classes": 0,
+ "bad_class_format": 0,
+ "bad_cst_format": 0,
+ "duplicates": 0,
+ "valid": true
+ },
+ "crt": "1",
+ "products_evaluated": 8942,
+ "pis_cofins_applicability": {
+ "NOT_APPLICABLE": 8942
+ },
+ "rtc_resolution": null,
+ "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
+ "official_catalog": null,
+ "non_canonical_warnings": {
+ "products": 2,
+ "by_type": {
+ "CST_COFINS_INVALID_IGNORED": 2
+ },
+ "samples": [
+ {
+ "product_code": "000304",
+ "type": "CST_COFINS_INVALID_IGNORED",
+ "reason": "ENTRY:000"
+ },
+ {
+ "product_code": "003090",
+ "type": "CST_COFINS_INVALID_IGNORED",
+ "reason": "EXIT:060"
+ }
+ ]
+ }
+ }
+ },
+ "RTC_OFF_SIMULATION": {
+ "rtc_enabled": false,
+ "TOTAL_PRODUCTS": 8942,
+ "COMPLETE_PRODUCTS": 8851,
+ "SAFE_BLOCKED_PRODUCTS": 91,
+ "FAILED_PRODUCTS": 0,
+ "UNCLASSIFIED_PRODUCTS": 0,
+ "COVERAGE_PERCENT": 98.98,
+ "INVARIANT_PASS": true,
+ "PIS_BLOCKERS": 0,
+ "COFINS_BLOCKERS": 0,
+ "RTC_BLOCKERS": 0,
+ "OTHER_BLOCKERS": 0,
+ "blockers_by_type": {},
+ "applicability": {
+ "NOT_APPLICABLE": 8942
+ },
+ "rtc_applicability": {
+ "NOT_APPLICABLE": 8942
+ },
+ "changed_products": 0,
+ "examples": [],
+ "non_canonical_warnings": 2
+ },
+ "RTC_ON_SIMULATION": {
+ "rtc_enabled": true,
+ "TOTAL_PRODUCTS": 8942,
+ "COMPLETE_PRODUCTS": 0,
+ "SAFE_BLOCKED_PRODUCTS": 8942,
+ "FAILED_PRODUCTS": 0,
+ "UNCLASSIFIED_PRODUCTS": 0,
+ "COVERAGE_PERCENT": 0.0,
+ "INVARIANT_PASS": true,
+ "PIS_BLOCKERS": 0,
+ "COFINS_BLOCKERS": 0,
+ "RTC_BLOCKERS": 8942,
+ "OTHER_BLOCKERS": 0,
+ "blockers_by_type": {
+ "CCLASSTRIB_MISSING_UNRESOLVED": 8942
+ },
+ "applicability": {
+ "NOT_APPLICABLE": 8942
+ },
+ "rtc_applicability": {
+ "REQUIRED": 8942
+ },
+ "changed_products": 8851,
+ "examples": [
+ {
+ "product": "000008",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000009",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000014",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000015",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000016",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000017",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000018",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000019",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000020",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000021",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000022",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000023",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000024",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000025",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000026",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000027",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000028",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000029",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000030",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000031",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000032",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000033",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000034",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000035",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000036",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000037",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000038",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000039",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000040",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000041",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ }
+ ],
+ "non_canonical_warnings": 2
+ }
+ },
+ "SAFETY": {
+ "FIREBIRD_WRITES": 0,
+ "WRITE_ATTEMPTS": 0,
+ "WRITE_SUCCESSES": 0,
+ "connection_mode": "READ COMMITTED READ ONLY (Agent reader); no write-capable connection opened"
+ }
+}
\ No newline at end of file
diff --git a/docs/runs/unified_coverage_e2e.json b/docs/runs/unified_coverage_e2e.json
index 1e402b4..a276657 100644
--- a/docs/runs/unified_coverage_e2e.json
+++ b/docs/runs/unified_coverage_e2e.json
@@ -1,49 +1,70 @@
{
"mode": "FULL (Hub decision + fiscal)",
- "database": "TESTESOFICIAIS",
+ "database": "CONSTRUCAO",
"crt": "1",
- "products": 521,
- "fiscal_products_read": 521,
+ "products": 3712,
+ "fiscal_products_read": 3712,
"BEFORE": {
- "COMPLETE_PRODUCTS": 495,
- "SAFE_BLOCKED_PRODUCTS": 26,
- "COVERAGE_PERCENT": 95.01,
- "UNKNOWN_APPLICABILITY_previous_model": 518
+ "COMPLETE_PRODUCTS": 3698,
+ "SAFE_BLOCKED_PRODUCTS": 14,
+ "COVERAGE_PERCENT": 99.62,
+ "UNKNOWN_APPLICABILITY_previous_model": 3712
},
"runs": {
"ERP_AS_FOUND": {
"rtc_enabled": true,
- "TOTAL_PRODUCTS": 521,
+ "TOTAL_PRODUCTS": 3712,
"COMPLETE_PRODUCTS": 0,
- "SAFE_BLOCKED_PRODUCTS": 521,
+ "SAFE_BLOCKED_PRODUCTS": 3712,
"FAILED_PRODUCTS": 0,
"UNCLASSIFIED_PRODUCTS": 0,
"COVERAGE_PERCENT": 0.0,
"INVARIANT_PASS": true,
- "PIS_BLOCKERS": 1,
- "COFINS_BLOCKERS": 1,
- "RTC_BLOCKERS": 521,
+ "PIS_BLOCKERS": 0,
+ "COFINS_BLOCKERS": 0,
+ "RTC_BLOCKERS": 3712,
"OTHER_BLOCKERS": 0,
"blockers_by_type": {
- "CCLASSTRIB_MISSING": 521,
- "COFINS_CST_INCIDENCE_MISMATCH": 1,
- "PIS_CST_INCIDENCE_MISMATCH": 1
+ "CCLASSTRIB_MISSING_UNRESOLVED": 3712
},
"applicability": {
- "NOT_APPLICABLE": 519,
- "REQUIRED": 2
+ "NOT_APPLICABLE": 3712
},
"rtc_applicability": {
- "REQUIRED": 521
+ "REQUIRED": 3712
},
- "changed_products": 495,
+ "changed_products": 3698,
"examples": [
+ {
+ "product": "000001",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000002",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000003",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
{
"product": "000004",
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -51,7 +72,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -59,7 +80,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000007",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -67,7 +96,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -75,7 +104,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -83,7 +112,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -91,7 +120,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000012",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -99,7 +136,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000014",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -107,7 +152,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000016",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -115,7 +168,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -123,7 +176,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -131,7 +184,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000020",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -139,7 +200,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -147,7 +208,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -155,7 +216,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -163,7 +224,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -171,7 +232,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -179,7 +240,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -187,7 +248,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -195,7 +256,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -203,83 +264,19 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
- "product": "000032",
+ "product": "000030",
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000033",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000034",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000035",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000036",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000038",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000040",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000041",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000042",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
}
],
- "non_canonical_warnings": 1,
+ "non_canonical_warnings": 0,
"context": {
"rtc_enabled": true,
"erp_rtc_enabled": true,
@@ -289,13 +286,15 @@
"rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA",
"rtc_param_rows": 1,
"rtc_param_conflict": false,
- "rtc_observed_at": "2026-09-24T20:40:30+00:00",
+ "rtc_observed_at": "2026-09-25T01:33:41+00:00",
"legal_effective_context": {
"crt": "1",
"informational_only": true,
- "ibs_cbs_in_documents": "FROM_2027_FOR_SIMPLES_ORIENTATION_PENDING",
+ "ibs_cbs_in_documents": "MANDATORY_FROM_2027-01-01",
+ "cclasstrib_orientation": "PENDING_OFFICIAL_NT",
"sources": [
- "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT; IBS/CBS taxation of these taxpayers only from 2027",
+ "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1 I, II and par. 1",
+ "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT",
"LC 214/2025 art. 348"
]
},
@@ -309,113 +308,132 @@
"valid": true
},
"crt": "1",
- "products_evaluated": 521,
+ "products_evaluated": 3712,
"pis_cofins_applicability": {
- "NOT_APPLICABLE": 519,
- "REQUIRED": 2
+ "NOT_APPLICABLE": 3712
+ },
+ "rtc_resolution": {
+ "total": 3712,
+ "valid_existing": 0,
+ "ready_to_apply": 0,
+ "suggestion_only": 0,
+ "ambiguous": 0,
+ "insufficient": 3712,
+ "conflicts": 0,
+ "official_mismatch": 0,
+ "catalog_incompatible": 0,
+ "not_applicable": 0,
+ "other_blocked": 0,
+ "by_rule": {
+ "RTC-R1": 3712
+ },
+ "by_reason": {
+ "SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": 3712
+ },
+ "catalog_compatibility": {
+ "compatibility": "MATERIAL_MISMATCH",
+ "counts": {
+ "MATCH_SAFE": 131,
+ "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0,
+ "MISMATCH": 11,
+ "EXPIRED": 3,
+ "MISSING_LOCAL": 19,
+ "MISSING_OFFICIAL": 0
+ },
+ "expired_present_locally": 3
+ }
+ },
+ "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151",
+ "official_catalog": {
+ "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654",
+ "publication": "2026-06-22",
+ "file": "cClassTrib 2026-06-22.xlsx"
},
"non_canonical_warnings": {
- "products": 1,
- "by_type": {
- "CST_COFINS_NON_CANONICAL_FORMAT": 2,
- "CST_PIS_NON_CANONICAL_FORMAT": 2
- },
- "samples": [
- {
- "product_code": "001434",
- "type": "CST_PIS_NON_CANONICAL_FORMAT",
- "reason": "ENTRY:2->02"
- },
- {
- "product_code": "001434",
- "type": "CST_PIS_NON_CANONICAL_FORMAT",
- "reason": "EXIT:2->02"
- },
- {
- "product_code": "001434",
- "type": "CST_COFINS_NON_CANONICAL_FORMAT",
- "reason": "ENTRY:2->02"
- },
- {
- "product_code": "001434",
- "type": "CST_COFINS_NON_CANONICAL_FORMAT",
- "reason": "EXIT:2->02"
- }
- ]
+ "products": 0,
+ "by_type": {},
+ "samples": []
}
}
},
"RTC_OFF_SIMULATION": {
"rtc_enabled": false,
- "TOTAL_PRODUCTS": 521,
- "COMPLETE_PRODUCTS": 494,
- "SAFE_BLOCKED_PRODUCTS": 27,
+ "TOTAL_PRODUCTS": 3712,
+ "COMPLETE_PRODUCTS": 3698,
+ "SAFE_BLOCKED_PRODUCTS": 14,
"FAILED_PRODUCTS": 0,
"UNCLASSIFIED_PRODUCTS": 0,
- "COVERAGE_PERCENT": 94.82,
+ "COVERAGE_PERCENT": 99.62,
"INVARIANT_PASS": true,
- "PIS_BLOCKERS": 1,
- "COFINS_BLOCKERS": 1,
+ "PIS_BLOCKERS": 0,
+ "COFINS_BLOCKERS": 0,
"RTC_BLOCKERS": 0,
"OTHER_BLOCKERS": 0,
- "blockers_by_type": {
- "COFINS_CST_INCIDENCE_MISMATCH": 1,
- "PIS_CST_INCIDENCE_MISMATCH": 1
- },
+ "blockers_by_type": {},
"applicability": {
- "NOT_APPLICABLE": 519,
- "REQUIRED": 2
+ "NOT_APPLICABLE": 3712
},
"rtc_applicability": {
- "NOT_APPLICABLE": 521
+ "NOT_APPLICABLE": 3712
},
- "changed_products": 1,
- "examples": [
- {
- "product": "000700",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "COFINS_CST_INCIDENCE_MISMATCH",
- "PIS_CST_INCIDENCE_MISMATCH"
- ]
- }
- ],
- "non_canonical_warnings": 1
+ "changed_products": 0,
+ "examples": [],
+ "non_canonical_warnings": 0
},
"RTC_ON_SIMULATION": {
"rtc_enabled": true,
- "TOTAL_PRODUCTS": 521,
+ "TOTAL_PRODUCTS": 3712,
"COMPLETE_PRODUCTS": 0,
- "SAFE_BLOCKED_PRODUCTS": 521,
+ "SAFE_BLOCKED_PRODUCTS": 3712,
"FAILED_PRODUCTS": 0,
"UNCLASSIFIED_PRODUCTS": 0,
"COVERAGE_PERCENT": 0.0,
"INVARIANT_PASS": true,
- "PIS_BLOCKERS": 1,
- "COFINS_BLOCKERS": 1,
- "RTC_BLOCKERS": 521,
+ "PIS_BLOCKERS": 0,
+ "COFINS_BLOCKERS": 0,
+ "RTC_BLOCKERS": 3712,
"OTHER_BLOCKERS": 0,
"blockers_by_type": {
- "CCLASSTRIB_MISSING": 521,
- "COFINS_CST_INCIDENCE_MISMATCH": 1,
- "PIS_CST_INCIDENCE_MISMATCH": 1
+ "CCLASSTRIB_MISSING_UNRESOLVED": 3712
},
"applicability": {
- "NOT_APPLICABLE": 519,
- "REQUIRED": 2
+ "NOT_APPLICABLE": 3712
},
"rtc_applicability": {
- "REQUIRED": 521
+ "REQUIRED": 3712
},
- "changed_products": 495,
+ "changed_products": 3698,
"examples": [
+ {
+ "product": "000001",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000002",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000003",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
{
"product": "000004",
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -423,7 +441,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -431,7 +449,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000007",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -439,7 +465,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -447,7 +473,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -455,7 +481,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -463,7 +489,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000012",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -471,7 +505,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000014",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -479,7 +521,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000016",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -487,7 +537,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -495,7 +545,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -503,7 +553,15 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
+ ]
+ },
+ {
+ "product": "000020",
+ "BEFORE_STATUS": "COMPLETE",
+ "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
+ "REASON": [
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -511,7 +569,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -519,7 +577,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -527,7 +585,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -535,7 +593,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -543,7 +601,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -551,7 +609,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -559,7 +617,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -567,7 +625,7 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
@@ -575,83 +633,19 @@
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
},
{
- "product": "000032",
+ "product": "000030",
"BEFORE_STATUS": "COMPLETE",
"AFTER_STATUS": "BLOCKED_FISCAL_DATA",
"REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000033",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000034",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000035",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000036",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000038",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000040",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000041",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
- ]
- },
- {
- "product": "000042",
- "BEFORE_STATUS": "COMPLETE",
- "AFTER_STATUS": "BLOCKED_FISCAL_DATA",
- "REASON": [
- "CCLASSTRIB_MISSING"
+ "CCLASSTRIB_MISSING_UNRESOLVED"
]
}
],
- "non_canonical_warnings": 1
+ "non_canonical_warnings": 0
}
},
"SAFETY": {
diff --git a/docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json b/docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json
new file mode 100644
index 0000000..897faf2
--- /dev/null
+++ b/docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json
@@ -0,0 +1,95 @@
+{
+ "purpose": "Official acts consulted for the cClassTrib determination (closure mission). Authority order: legislation > Portal NF-e > Receita > SPED. Local files are ignored by git; hashes pin exactly what was read.",
+ "authority_order": [
+ "LC 214/2025 and amendments (LC 227/2026)",
+ "Portal Nacional da NF-e (NT 2025.002-RTC v1.51, NT 2026.00x, cClassTrib table)",
+ "Receita Federal / CGIBS Atos Conjuntos (1/2025, 4/2026)",
+ "SPED"
+ ],
+ "findings": {
+ "simples_cclasstrib_orientation": "NONE_OFFICIAL_YET (NT 2025.002 v1.51 defers CRT 1/2/4; Ato Conjunto RFB/CGIBS 4/2026 sets the Simples obligation from 2027-01-01)",
+ "nfe_nfce_ibs_cbs_obligation": "from 2026-08-03 (Ato Conjunto RFB/CGIBS 4/2026 art. 1)"
+ },
+ "files": [
+ {
+ "local_file": "sources_cache/legal/ato_conj_1_2025.bin",
+ "sha256": "5354059c82c78902bd870ee241c068ecbe177cc36c3486867824d63195d257b7",
+ "bytes": 132565,
+ "published_name": "ATO CONJUNTO RFB_CGIBS Nº 1 DE 22 DE DEZEMBRO DE 2025.pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Fri, 25 Sep 2026 00:41:10 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/ato_conj_4_2026.bin",
+ "sha256": "64ab779b13ad3df47530bf9a77a07cd42402ebd5b2cff5ede403b0169c0f7b99",
+ "bytes": 119007,
+ "published_name": "ATO CONJUNTO RFB_CGIBS Nº 4 DE 30 DE julho DE 2026.pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Fri, 25 Sep 2026 00:41:09 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/ato_tec_1_2026.bin",
+ "sha256": "b67ba6228b7f9b4d55e982187d8d89ea680b56e4e97753f20f569ab29a71b67f",
+ "bytes": 107050,
+ "published_name": "ATO TÉCNICO CONJUNTO RFB_CGIBS Nº 1 DE 31 DE JULHO DE 2026.pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Fri, 25 Sep 2026 00:41:08 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/ato_tec_2_2026.bin",
+ "sha256": "11cb025e36ac93168a61f35dd47c469c344ff65dd88be6bf45cb34826dba73e1",
+ "bytes": 165111,
+ "published_name": "ATO TECNICO CONJUNTO RFB_SUFIS_CGIBS_DIRETORIA-EXECUTIVA N 2 DE 21 DE AGOSTO DE 2026.PDF",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Fri, 25 Sep 2026 00:41:07 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/nt2025_002_v151.bin",
+ "sha256": "a4aaaa181522b43cd90b502f8ccb9e4bdabea30fed838e2d0790be5ba77254a4",
+ "bytes": 2182361,
+ "published_name": "NT_2025.002_v1.51_RTC.pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Thu, 24 Sep 2026 19:04:50 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/nt2026_001.bin",
+ "sha256": "703310f95bff855aad9a4a7004515ce29ffff343334c53f0c45e9b32390296cd",
+ "bytes": 1010960,
+ "published_name": "NT_2026.001_v1.02b - PAA NFe.pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Fri, 25 Sep 2026 00:41:41 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/nt2026_002_v110a.bin",
+ "sha256": "705f771830565c209d815659347ac4ea251901c77064cc163ce5c93f311e6e64",
+ "bytes": 767698,
+ "published_name": "NT_2026.002_v1.10a_DANFE_Simpl_Tp2_Emi_Offline_Autoriz_Alerta_v2.pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Thu, 24 Sep 2026 19:04:51 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/nt2026_006.bin",
+ "sha256": "47a65abde315fed1e6a8c6085a67ca483935cb57877ae5715d365377a725e05d",
+ "bytes": 767681,
+ "published_name": "NT_2026.006_v1.00_RTC_VinculacaoPagamento[2775].pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Thu, 24 Sep 2026 19:04:52 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/nt2026_007.bin",
+ "sha256": "559fcd7d1b495549099498ff5c1ba4165f65d4b5ad5b34dbe802869d4619d917",
+ "bytes": 733482,
+ "published_name": "NT2026.007_v1.00 - Emissão de NF-e sem IE e RV LCC.pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Fri, 25 Sep 2026 00:41:40 GMT"
+ },
+ {
+ "local_file": "sources_cache/legal/nt2026_009.bin",
+ "sha256": "50b7dec40bba8928b4d544feb4fdbd8c8bac16e37c99d926ddb2834cf246f15d",
+ "bytes": 310342,
+ "published_name": "NT2026.009_v1.00_Alteracao_RV_CFOP.pdf",
+ "content_type": "application/pdf",
+ "accessed_http_date": "Thu, 24 Sep 2026 19:04:53 GMT"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/release/RELEASE_MANIFEST.json b/release/RELEASE_MANIFEST.json
index b584d7a..a4a0548 100644
--- a/release/RELEASE_MANIFEST.json
+++ b/release/RELEASE_MANIFEST.json
@@ -1,14 +1,14 @@
{
"artifact": "RELEASE_MANIFEST",
- "GTI_FISCAL_VERSION": "1.1.9",
+ "GTI_FISCAL_VERSION": "1.2.0",
"generated_at": "2026-09-24T14:13:15+00:00",
"hub": {
- "version": "1.1.9",
+ "version": "1.2.0",
"api_compat": "1.0",
"engine_version": "0.3.0",
"decision_version": "mission-04c-ipi-ex-split"
},
- "agent_version": "1.1.9",
+ "agent_version": "1.2.0",
"fiscal_engine": {
"ENGINE_VERSION": "0.3.0",
"DECISION_VERSION": "mission-04c-ipi-ex-split",
@@ -17,10 +17,10 @@
"ICMS_CONTEXT_ENGINE_VERSION": "icms-context-v1"
},
"migrations": {
- "alembic_head_in_repo": "0027_rtc_auto_apply",
- "applied_revision": "0027_rtc_auto_apply",
+ "alembic_head_in_repo": "0028_rtc_write_modes",
+ "applied_revision": "0028_rtc_write_modes",
"applied_revision_source": "production Hub 10.0.10.117 (gti-fiscal-hub-fiscal-hub-1), verified 2026-09-24",
- "count": 27
+ "count": 28
},
"datasets_active": [
{
diff --git a/release/VERSION b/release/VERSION
index 512a1fa..26aaba0 100644
--- a/release/VERSION
+++ b/release/VERSION
@@ -1 +1 @@
-1.1.9
+1.2.0
diff --git a/scripts/rtc_copy_write_e2e.py b/scripts/rtc_copy_write_e2e.py
index c9764f3..561238c 100644
--- a/scripts/rtc_copy_write_e2e.py
+++ b/scripts/rtc_copy_write_e2e.py
@@ -1,19 +1,23 @@
-"""COPY-ONLY write E2E for the RTC auto-apply (C000025.CCLASSTRIB).
+"""COPY-ONLY write E2E for the centrally controlled RTC auto-apply (C000025.CCLASSTRIB).
python scripts/rtc_copy_write_e2e.py
-Runs ONLY against scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (a gbak restore of TESTESOFICIAIS). It refuses any other path, and refuses every real database.
-Scenario (all through the Agent's own code path `run_agent_service._rtc_auto_apply`):
- 1. the copy's company is switched to regime normal (C000004.CRT='3'): a disposable experiment, so the rules can act (the real company is Simples => 0 candidates);
- 2. assess -> candidates; 3. apply with all three gates open; 4. re-read, FK/CST checks, coverage; 5. ONLY CCLASSTRIB changed (whole-table diff);
- 6. second run: 0 candidates, 0 writes; 7. stale-decision abort (RTC switched off on the copy between decision and write) writes nothing.
+Runs ONLY against scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (restored from its own .fbk at the start, so every run is reproducible). It refuses any other path and every
+real database. Everything goes through the Agent's own code path `run_agent_service._rtc_auto_apply`, driven ONLY by the authenticated job `authorizations` (no env switches):
+ 0 restore + company -> regime normal (documented experiment on the copy); 1 whole-database baseline hashes;
+ 2 DISABLED (0 writes, no preview) / DRY_RUN (preview, 0 writes) / stale decision (RTC off between decision and write => 0 writes);
+ 3 APPROVAL_REQUIRED with 5 approved products (only those 5 written) / then ENABLED until nothing is left;
+ 4 reread, FK + CST integrity, coverage; 5 second run = 0 writes;
+ 6 rollback of 3 audit records (+1 conflict: value changed by an operator => refused); 7 reread, reapply, idempotency;
+ 8 invariants: NON_TARGET_COLUMNS_CHANGED, CATALOG_CHANGED, PARAMETERS_CHANGED, DOCUMENT_TABLES_CHANGED, OTHER_TABLES_CHANGED all 0.
Writes go to the copy and nowhere else.
"""
+import hashlib
import importlib.util
import json
import os
+import subprocess
import sys
-from datetime import date
from pathlib import Path
from types import SimpleNamespace
@@ -27,7 +31,9 @@ from central.app.operational_state import classify_product_state, summarize_oper
from gti_fiscal_agent import firebird_reader, unified_fiscal # noqa: E402
COPY = str((ROOT / "scratch_rtc_e2e" / "TESTESOFICIAIS_RTC_COPY.FDB").resolve())
+FBK = str((ROOT / "scratch_rtc_e2e" / "TESTESOFICIAIS_RTC_COPY.fbk").resolve())
NCM_OK = set((ROOT / "docs" / "runs" / "ncm_valid_codes.txt").read_text().split())
+DOC_TABLE_HINTS = ("NFCE", "NFE", "NF_", "CUPOM", "VENDA", "CTE", "MDFE")
def guard(path):
@@ -58,82 +64,159 @@ def digits(v):
return "".join(ch for ch in str(v or "") if ch.isdigit())
-def snapshot_table(cur, table, key, skip_cols=()):
+def restore_copy():
+ guard(COPY)
+ gbak = Path(os.environ["FIREBIRD_CLIENT_LIBRARY"]).parent / "gbak.exe"
+ done = subprocess.run([str(gbak), "-c", "-rep", "-n", "-user", os.environ["FB_USER"], "-pas", os.environ["FB_PASSWORD"], FBK, COPY], capture_output=True, text=True, timeout=600)
+ if done.returncode != 0:
+ raise SystemExit(f"restore failed: {done.stderr[-300:]}")
+
+
+def tables(cur):
+ cur.execute("SELECT TRIM(RDB$RELATION_NAME) FROM RDB$RELATIONS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0 AND RDB$VIEW_BLR IS NULL AND RDB$EXTERNAL_FILE IS NULL ORDER BY 1")
+ return [r[0] for r in cur.fetchall()]
+
+
+def table_hash(cur, table, skip_cols=()):
cur.execute(f"SELECT * FROM {table}")
cols = [d[0] for d in cur.description]
- ki = cols.index(key)
- return cols, {r[ki]: {c: v for c, v in zip(cols, r) if c not in skip_cols} for r in cur.fetchall()}
+ keep = [i for i, c in enumerate(cols) if c not in skip_cols]
+ digest = hashlib.sha256()
+ rows = 0
+ for row in cur.fetchall():
+ digest.update(repr([row[i] for i in keep]).encode("utf-8", "replace"))
+ rows += 1
+ return f"{rows}:{digest.hexdigest()[:20]}"
-def coverage(env, company, ncm_valid, override=None):
+def baseline():
+ conn = connect(False); cur = conn.cursor()
+ out = {t: table_hash(cur, t, skip_cols=("CCLASSTRIB",) if t == "C000025" else ()) for t in tables(cur)}
+ cur.execute("SELECT CODIGO, CCLASSTRIB FROM C000025")
+ values = {str(r[0]).strip(): (r[1] or "").strip() for r in cur.fetchall()}
+ conn.close()
+ return out, values
+
+
+def diff_tables(before, after):
+ return sorted(t for t in before if before[t] != after.get(t))
+
+
+def coverage(env, company, ncm_valid):
conn = firebird_reader._connect(env)
try:
- out = unified_fiscal.assess_from_connection(conn, company, ncm_valid=ncm_valid, rtc_override=override)
+ out = unified_fiscal.assess_from_connection(conn, company, ncm_valid=ncm_valid)
finally:
conn.close()
products = firebird_reader.read_products(env)
states = [classify_product_state(p, {"status": "EXACT_NCM"}, fiscal=out["findings"].get(str(p["CODIGO"]))) for p in products]
s = summarize_operational_states(states)
- return out, {"total": s["TOTAL_PRODUCTS"], "complete": s["COMPLETE_PRODUCTS"], "blocked": s["SAFE_BLOCKED"], "unclassified": s["UNCLASSIFIED"], "coverage_percent": round(100 * s["COMPLETE_PRODUCTS"] / s["TOTAL_PRODUCTS"], 2)}
+ return out, products, {"total": s["TOTAL_PRODUCTS"], "complete": s["COMPLETE_PRODUCTS"], "blocked": s["SAFE_BLOCKED"], "unclassified": s["UNCLASSIFIED"], "coverage_percent": round(100 * s["COMPLETE_PRODUCTS"] / s["TOTAL_PRODUCTS"], 2)}
def main():
load_dotenv(ROOT / ".env") # credentials only when the script runs, never as an import side effect
guard(COPY)
service = load_service()
+ restore_copy()
env = {"FIREBIRD_DATABASE_PATH": COPY, "FB_USER": os.environ["FB_USER"], "FB_PASSWORD": os.environ["FB_PASSWORD"], "FIREBIRD_CLIENT_LIBRARY": os.environ.get("FIREBIRD_CLIENT_LIBRARY")}
- report = {"database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (gbak restore of TESTESOFICIAIS)", "real_database_writes": 0, "real_database_write_attempts": 0}
- # ---- 1. company -> regime normal on the COPY (documented experiment)
+ report = {"database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (restored from its .fbk at start)", "real_database_writes": 0, "real_database_write_attempts": 0, "mechanism": "job authorizations only (no env switches)"}
rw = connect(True); cur = rw.cursor()
cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004"); report["copy_original_crt"] = [r[0] for r in cur.fetchall()]
cur.execute("UPDATE C000004 SET CRT = '3'"); rw.commit()
cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004"); company = {"crt": cur.fetchone()[0]}
- report["copy_crt_for_experiment"] = company["crt"]
- _, before_c25 = snapshot_table(cur, "C000025", "CODIGO")
cur.execute("SELECT USAR_REFORMA_TRIBUTARIA FROM PARAMETROS"); report["copy_rtc_param"] = cur.fetchone()[0]
+ cur.execute("SELECT CODIGO, CLASSIFICACAO_FISCAL FROM C000025"); ncm_valid = {str(c).strip(): digits(n) in NCM_OK for c, n in cur.fetchall()}
rw.commit(); rw.close()
- ncm_valid = {str(c).strip(): digits(v["CLASSIFICACAO_FISCAL"]) in NCM_OK for c, v in before_c25.items()}
- # ---- 2. assess (as found on the copy)
- out, cov_before = coverage(env, company, ncm_valid)
+ report["copy_crt_for_experiment"] = company["crt"]
+ base, base_values = baseline()
+ report["tables_hashed"] = len(base)
+ config = SimpleNamespace(rtc_auto_apply_max_per_job=500, database_path=COPY) # NOTE: no firebird_writes, no rtc switch: the Agent has none
+
+ def run_job(mode, approved=(), rollbacks=(), job_id=1):
+ out, products, _ = coverage(env, company, ncm_valid)
+ job = {"job_id": job_id, "authorizations": {"rtc_write_mode": mode, "rtc_approved_codes": list(approved), "rtc_rollbacks": list(rollbacks)}}
+ return service._rtc_auto_apply(env, config, job, products, out, company), out
+
+ _, _, cov_before = coverage(env, company, ncm_valid)
report["coverage_before"] = cov_before
- report["resolution_before"] = {k: v for k, v in out["context"]["rtc_resolution"].items() if k != "by_reason"}
- products = firebird_reader.read_products(env)
- config = SimpleNamespace(firebird_writes=True, rtc_auto_apply_enabled=True, rtc_auto_apply_max_per_job=500, database_path=COPY)
- job = {"job_id": 424242, "authorizations": {"rtc_auto_apply": True}}
- # ---- 7 (first, on purpose): stale decision - RTC switched off on the copy between decision and write => nothing is written
- rw = connect(True); c = rw.cursor(); c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 0"); rw.commit(); rw.close()
- stale = service._rtc_auto_apply(env, config, job, products, out, company)
- report["stale_decision_run"] = {"write_attempts": stale["write_attempts"], "write_successes": stale["write_successes"], "aborted_reasons": stale["aborted_reasons"]}
- rw = connect(True); c = rw.cursor(); c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 1"); rw.commit(); rw.close()
- # ---- 3. apply
- first = service._rtc_auto_apply(env, config, job, products, out, company)
- report["first_run"] = {"mode": first["mode"], "ready_total": first["ready_total"], "write_attempts": first["write_attempts"], "write_successes": first["write_successes"], "aborted_reasons": first["aborted_reasons"],
- "audit_sample": first["applied"][:3]}
- # ---- 4/5. re-read, FK, whole-table diff
+ # ---- 2. DISABLED / DRY_RUN / stale decision: zero writes
+ disabled, _ = run_job("DISABLED")
+ dry, out0 = run_job("DRY_RUN")
+ report["disabled_run"] = {"write_attempts": disabled["write_attempts"], "preview": len(disabled["preview"]), "ready_total": disabled["ready_total"]}
+ report["dry_run"] = {"write_attempts": dry["write_attempts"], "preview": len(dry["preview"]), "ready_total": dry["ready_total"], "rules": sorted({c["rule_id"] for c in dry["preview"]}), "sample": dry["preview"][:3]}
+ report["resolution_before"] = {k: v for k, v in out0["context"]["rtc_resolution"].items() if k != "by_reason"}
+ assert base == baseline()[0], "DISABLED/DRY_RUN wrote something"
+ rw = connect(True); c = rw.cursor(); c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 0"); rw.commit()
+ stale, _ = run_job("ENABLED")
+ c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 1"); rw.commit(); rw.close()
+ report["stale_decision_run"] = {"write_attempts": stale["write_attempts"], "write_successes": stale["write_successes"]}
+ # ---- 3. APPROVAL_REQUIRED (5 approved) then ENABLED
+ preview_codes = [c["product_code"] for c in dry["preview"]]
+ approved = preview_codes[:5]
+ unapproved = run_job("APPROVAL_REQUIRED", approved=[])[0]
+ part, _ = run_job("APPROVAL_REQUIRED", approved=approved, job_id=2)
+ report["approval_run"] = {"without_approval_attempts": unapproved["write_attempts"], "approved": len(approved), "write_successes": part["write_successes"], "only_approved": sorted(a["product_code"] for a in part["applied"]) == sorted(approved)}
+ applied = list(part["applied"])
+ jobs = 0
+ while True:
+ jobs += 1
+ step, _ = run_job("ENABLED", job_id=100 + jobs)
+ applied += step["applied"]
+ report.setdefault("enabled_jobs", []).append({"write_attempts": step["write_attempts"], "write_successes": step["write_successes"], "aborted": step["aborted_reasons"]})
+ if not step["write_attempts"] or jobs >= 200:
+ break
+ report["total_applied"] = len(applied)
+ report["audit_sample"] = applied[:2]
+ report["audit_fields_complete"] = all(a.get(k) is not None for a in applied for k in ("rule_id", "rule_version", "company_crt", "authorization_origin", "description_snapshot", "timestamp", "job_id", "local_catalog_hash", "official_source_version"))
+ # ---- 4. reread + integrity + coverage
+ mid, mid_values = baseline()
+ report["rows_changed"] = sum(1 for k, v in mid_values.items() if base_values.get(k) != v)
ro = connect(False); cur = ro.cursor()
- cols, after_c25 = snapshot_table(cur, "C000025", "CODIGO")
- changed = {code: [c for c in cols if before_c25[code][c] != row[c]] for code, row in after_c25.items() if any(before_c25[code][c] != row[c] for c in cols)}
- report["rows_changed"] = len(changed)
- report["columns_changed"] = sorted({c for v in changed.values() for c in v})
cur.execute("SELECT COUNT(*) FROM C000025 p WHERE p.CCLASSTRIB IS NOT NULL AND NOT EXISTS (SELECT 1 FROM RTC_CCLASSTRIB c WHERE c.CCLASSTRIB = p.CCLASSTRIB)"); report["fk_orphans"] = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM C000025 p JOIN RTC_CCLASSTRIB c ON c.CCLASSTRIB = p.CCLASSTRIB WHERE NOT EXISTS (SELECT 1 FROM RTC_CST s WHERE s.CSTIBSCBS = c.CSTIBSCBS)"); report["cst_orphans"] = cur.fetchone()[0]
- cur.execute("SELECT CCLASSTRIB, COUNT(*) FROM C000025 WHERE CCLASSTRIB IS NOT NULL GROUP BY 1"); report["written_by_class"] = {r[0].strip(): r[1] for r in cur.fetchall()}
- cur.execute("SELECT COUNT(*) FROM RTC_CCLASSTRIB"); report["catalog_rows_unchanged"] = cur.fetchone()[0]
ro.close()
- out2, cov_after = coverage(env, company, ncm_valid)
+ _, _, cov_after = coverage(env, company, ncm_valid)
report["coverage_after"] = cov_after
- report["resolution_after"] = {k: v for k, v in out2["context"]["rtc_resolution"].items() if k != "by_reason"}
- # ---- 6. second run
- products2 = firebird_reader.read_products(env)
- second = service._rtc_auto_apply(env, config, job, products2, out2, company)
- report["second_run"] = {"ready_total": second["ready_total"], "write_attempts": second["write_attempts"], "write_successes": second["write_successes"]}
- report["IDEMPOTENT"] = second["write_attempts"] == 0 and second["write_successes"] == 0
- report["ONLY_CCLASSTRIB_CHANGED"] = report["columns_changed"] in ([], ["CCLASSTRIB"])
- report["PASS"] = bool(report["IDEMPOTENT"] and report["ONLY_CCLASSTRIB_CHANGED"] and report["fk_orphans"] == 0 and report["cst_orphans"] == 0 and first["write_successes"] == first["write_attempts"] > 0
- and report["stale_decision_run"]["write_successes"] == 0 and report["rows_changed"] == first["write_successes"])
+ # ---- 5. second run
+ second, _ = run_job("ENABLED", job_id=300)
+ report["second_run"] = {"write_attempts": second["write_attempts"], "write_successes": second["write_successes"], "ready_total": second["ready_total"]}
+ # ---- 6. rollback of 3 records + 1 conflict
+ print(json.dumps({k: report[k] for k in ("disabled_run","dry_run","stale_decision_run","approval_run","enabled_jobs","coverage_before","resolution_before")}, default=str)[:2500]) if len(applied) < 4 else None
+ victims = applied[:4]
+ rw = connect(True); c = rw.cursor(); c.execute("UPDATE C000025 SET CCLASSTRIB = '200035' WHERE CODIGO = ?", (victims[3]["product_code"],)); rw.commit(); rw.close() # an operator changed it afterwards
+ requests = [{"audit_id": 900 + i, "product_code": v["product_code"], "written_value": v["new_value"], "rule_id": v["rule_id"]} for i, v in enumerate(victims)]
+ roll, _ = run_job("DISABLED", rollbacks=requests, job_id=400) # rollback needs the central request, not a write mode
+ outcomes = {r["product_code"]: (r["outcome"], r.get("reason")) for r in roll["rollbacks"]}
+ report["rollback"] = {"rolled_back": sum(1 for o, _ in outcomes.values() if o == "ROLLED_BACK"), "conflict": outcomes[victims[3]["product_code"]], "records": [outcomes[v["product_code"]][0] for v in victims]}
+ ro = connect(False); cur = ro.cursor()
+ cur.execute("SELECT CODIGO, CCLASSTRIB FROM C000025 WHERE CODIGO IN (%s)" % ",".join("?" * 4), tuple(v["product_code"] for v in victims))
+ after_roll = {str(r[0]).strip(): (r[1] or "").strip() for r in cur.fetchall()}
+ ro.close()
+ report["rollback"]["reread"] = {k: v for k, v in after_roll.items()}
+ # ---- 7. reapply + idempotency
+ again, _ = run_job("ENABLED", job_id=500)
+ report["reapply"] = {"write_successes": again["write_successes"], "codes": sorted(a["product_code"] for a in again["applied"]), "expected": sorted(v["product_code"] for v in victims[:3])}
+ third, _ = run_job("ENABLED", job_id=501)
+ report["idempotency_after_reapply"] = {"write_attempts": third["write_attempts"], "write_successes": third["write_successes"]}
+ # ---- 8. invariants
+ final, final_values = baseline()
+ changed_tables = diff_tables(base, final)
+ report["NON_TARGET_COLUMNS_CHANGED"] = 1 if "C000025" in changed_tables else 0
+ report["CATALOG_CHANGED"] = len([t for t in changed_tables if t.startswith("RTC_")])
+ report["PARAMETERS_CHANGED"] = len([t for t in changed_tables if t == "PARAMETROS"])
+ report["DOCUMENT_TABLES_CHANGED"] = len([t for t in changed_tables if any(h in t for h in DOC_TABLE_HINTS)])
+ report["OTHER_TABLES_CHANGED"] = len(changed_tables)
+ report["changed_tables"] = changed_tables
+ report["IDEMPOTENT"] = second["write_attempts"] == 0 and third["write_attempts"] == 0
+ report["PASS"] = bool(report["IDEMPOTENT"] and report["fk_orphans"] == 0 and report["cst_orphans"] == 0 and not changed_tables and report["total_applied"] > 5
+ and report["disabled_run"]["write_attempts"] == 0 and report["dry_run"]["write_attempts"] == 0 and report["dry_run"]["rules"] == ["RTC-R3"]
+ and report["stale_decision_run"]["write_successes"] == 0 and report["approval_run"]["without_approval_attempts"] == 0 and report["approval_run"]["only_approved"]
+ and report["rows_changed"] == report["total_applied"] and report["rollback"]["rolled_back"] == 3 and report["rollback"]["conflict"][0] == "ABORTED"
+ and report["rollback"]["reread"][victims[3]["product_code"]] == "200035" and all(report["rollback"]["reread"][v["product_code"]] == "" for v in victims[:3])
+ and report["reapply"]["codes"] == report["reapply"]["expected"] and report["audit_fields_complete"])
(ROOT / "docs" / "runs" / "rtc_copy_write_e2e.json").write_text(json.dumps(report, ensure_ascii=False, indent=1, default=str), encoding="utf-8")
- print(json.dumps({k: report[k] for k in ("copy_original_crt", "copy_rtc_param", "coverage_before", "coverage_after", "first_run", "stale_decision_run", "rows_changed", "columns_changed", "fk_orphans", "cst_orphans", "second_run", "IDEMPOTENT", "ONLY_CCLASSTRIB_CHANGED", "PASS")},
- ensure_ascii=False, default=str)[:3000])
+ print(json.dumps({k: report[k] for k in report if k not in ("audit_sample", "coverage_before", "resolution_before")}, ensure_ascii=False, default=str)[:3500])
if __name__ == "__main__":
diff --git a/scripts/scale_hub_test.py b/scripts/scale_hub_test.py
new file mode 100644
index 0000000..52191ac
--- /dev/null
+++ b/scripts/scale_hub_test.py
@@ -0,0 +1,88 @@
+"""SCALE test of the Hub side for a ~21k-product installation (synthetic payloads shaped like the real MMMODAS run).
+
+ python scripts/scale_hub_test.py [--products 21000] [--out docs/runs/scale_hub_test.json]
+
+Measures: job-result payload size, Hub persistence (blockers upsert + RTC preview + audits) with SQL statement counts (N+1 check),
+and the panel pages that read the data (dashboard, agent detail, RTC control, blockers). In-process SQLite: timings are a conservative
+upper bound for the PostgreSQL production database only for CPU-bound work; statement counts are engine-independent.
+"""
+import argparse
+import logging
+import json
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+from sqlalchemy import event, select # noqa: E402
+from sqlalchemy.orm import Session # noqa: E402
+
+from central.tests import test_zero_touch_hub as zt # noqa: E402
+
+logging.disable(logging.CRITICAL) # the Hub logs each job result; at 21k products that is megabytes of noise
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--products", type=int, default=21000)
+ parser.add_argument("--out", default=str(ROOT / "docs" / "runs" / "scale_hub_test.json"))
+ args = parser.parse_args()
+ n = args.products
+ client, engine = zt._client()
+ counter = {"n": 0}
+ event.listen(engine, "before_cursor_execute", lambda *a, **k: counter.__setitem__("n", counter["n"] + 1))
+ zt.hb(client)
+ with Session(engine) as s:
+ from central.app.models import Installation
+ s.scalars(select(Installation)).one().rtc_write_mode = "APPROVAL_REQUIRED"
+ s.commit()
+ blocked = [{"company": "", "product_code": f"{i:06d}", "gtin": None, "description": f"PRODUTO DE TESTE {i}", "blocker_type": "BLOCKED_FISCAL_DATA", "field": "RTC", "reason_code": "CCLASSTRIB_MISSING_UNRESOLVED",
+ "evidence_summary": {"reason": "RTC-R4:SUGGESTION_ONLY:000001", "human": "cClassTrib sem regra determinística"}, "input_fingerprint": f"fp{i:08d}"} for i in range(n)]
+ preview = [{"product_code": f"{i:06d}", "description": f"PRODUTO DE TESTE {i}", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "RTC-R3:ANNEX"} for i in range(5000)]
+ operational = {**zt.OPERATIONAL, "TOTAL_PRODUCTS": n, "COMPLETE": 0, "SAFE_BLOCKED": n, "BLOCKED_MISSING_EVIDENCE": n}
+ summary = {"operational": operational, "products_processed": n, "products_changed": n, "blocked_products": blocked, "resolved_products": [],
+ "fiscal_context": {"rtc_enabled": True, "evaluated_all": True, "rtc_resolution": {"total": n, "valid_existing": 0, "ready_to_apply": 5000, "ambiguous": 100, "catalog_incompatible": 0}},
+ "rtc_auto_apply": {"mode": "APPROVAL_REQUIRED", "ready_total": 5000, "preview": preview, "preview_truncated": False, "applied": [], "aborted": [], "rollbacks": []}}
+ payload = json.dumps({"result_summary": summary})
+ report = {"products": n, "result_payload_bytes": len(payload), "result_payload_mb": round(len(payload) / 1048576, 2)}
+ job = client.get("/v1/jobs/next", headers=zt.H).json()
+ counter["n"] = 0
+ t0 = time.perf_counter()
+ response = client.post(f"/v1/jobs/{job['job_id']}/complete", content=payload, headers={**zt.H, "content-type": "application/json"})
+ report["persist_first_pass"] = {"status": response.status_code, "seconds": round(time.perf_counter() - t0, 2), "sql_statements": counter["n"], "statements_per_product": round(counter["n"] / n, 2)}
+ # a second full evaluation with the same content (steady state: every 30 min per installation)
+ zt.age_last_sync(engine, 4000); zt.hb(client)
+ job = client.get("/v1/jobs/next", headers=zt.H).json()
+ counter["n"] = 0
+ t0 = time.perf_counter()
+ response = client.post(f"/v1/jobs/{job['job_id']}/complete", content=payload, headers={**zt.H, "content-type": "application/json"})
+ report["persist_steady_state"] = {"status": response.status_code, "seconds": round(time.perf_counter() - t0, 2), "sql_statements": counter["n"], "statements_per_product": round(counter["n"] / n, 2)}
+ with Session(engine) as s:
+ from central.app.models import BlockedProduct, RtcCandidate
+ report["rows"] = {"blocked": len(s.scalars(select(BlockedProduct.id)).all()), "rtc_candidates": len(s.scalars(select(RtcCandidate.id)).all())}
+ # panel pages (authenticated operator session)
+ from central.tests.test_control_panel_pages import _panel_client
+ pc, agent_id, _ = _panel_client()
+ import central.tests.test_control_panel_pages as cp
+ cp._seed_rtc(pc, agent_id, n=5000)
+ from central.app.blocked_products import upsert_blocker
+ from central.app.models import Installation as Inst
+ with pc._session_factory() as s:
+ inst = s.scalar(select(Inst).where(Inst.agent_id == agent_id))
+ for b in blocked:
+ upsert_blocker(s, inst.tenant_id, b, installation_id=inst.id)
+ s.commit()
+ pages = {}
+ for name, url in (("dashboard", "/"), ("agent_detail", f"/agents/{agent_id}"), ("rtc_control_p1", f"/agents/{agent_id}/rtc"), ("rtc_control_p50", f"/agents/{agent_id}/rtc?page=50"), ("blockers", "/blockers")):
+ counter["n"] = 0
+ t0 = time.perf_counter()
+ r = pc.get(url)
+ pages[name] = {"status": r.status_code, "seconds": round(time.perf_counter() - t0, 3), "html_kb": round(len(r.content) / 1024, 1)}
+ report["panel_pages"] = pages
+ Path(args.out).write_text(json.dumps(report, indent=1), encoding="utf-8")
+ print(json.dumps(report))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py
index 71c0963..99c5f3c 100644
--- a/tests/test_release_candidate.py
+++ b/tests/test_release_candidate.py
@@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT))
def test_versions_are_coherent_across_hub_agent_and_packages():
from central.app.version import GTI_FISCAL_VERSION
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
- assert v == GTI_FISCAL_VERSION == "1.1.9"
+ assert v == GTI_FISCAL_VERSION == "1.2.0"
pep = v.replace("-rc", "rc")
for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
- assert '"AGENT_VERSION", "1.1.9"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
+ assert '"AGENT_VERSION", "1.2.0"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
if manifest.exists():
m = json.loads(manifest.read_text(encoding="utf-8"))