From 26405b01443380c287efdf7669fdd53d12dc440e Mon Sep 17 00:00:00 2001 From: gti Date: Mon, 28 Sep 2026 22:14:39 -0300 Subject: [PATCH] feat(missao-8): bump Hub 1.3.15, restauracao de cfop_ippt_auto_apply e elevacao de cobertura --- CLAUDE.md | 5 + .../gti_fiscal_agent/cfop_ippt_auto_apply.py | 185 ++++++++++++++++ central/app/version.py | 2 +- central/pyproject.toml | 2 +- central/tests/test_mission_08_coverage.py | 202 ++++++++++++++++++ docs/CFOP_IPPT_WRITE_CAPABILITY_V1.md | 67 ++++++ docs/PROJECT_STATE.md | 72 +++---- release/VERSION | 2 +- 8 files changed, 498 insertions(+), 39 deletions(-) create mode 100644 agent/src/gti_fiscal_agent/cfop_ippt_auto_apply.py create mode 100644 central/tests/test_mission_08_coverage.py create mode 100644 docs/CFOP_IPPT_WRITE_CAPABILITY_V1.md diff --git a/CLAUDE.md b/CLAUDE.md index 68b5b90..fcfe0d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,9 +13,14 @@ modify database objects or permissions, or store credentials and other secrets. Run the offline tests with: ```text +# Suíte completa canônica (1160+ testes: raiz + Hub + Agent): +python -m pytest tests central/tests agent/tests -q + +# Apenas testes unitários offline da raiz: python -m pytest tests -q ``` + ## Firebird write policy Default policy: diff --git a/agent/src/gti_fiscal_agent/cfop_ippt_auto_apply.py b/agent/src/gti_fiscal_agent/cfop_ippt_auto_apply.py new file mode 100644 index 0000000..4daa792 --- /dev/null +++ b/agent/src/gti_fiscal_agent/cfop_ippt_auto_apply.py @@ -0,0 +1,185 @@ +"""CFOP/IPPT auto-apply: fills or corrects ONLY C000025.CFOP and C000025.IPPT, and nothing else, +when central.app.field_contract.resolve_product already classified the cell AUTO_FILL_SAFE +(state=CHANGE, write_eligible=True) for this product. + +Safety model (defense in depth, default OFF), mirrors gti_fiscal_agent.rtc_auto_apply: the Hub sets +a per-installation, per-capability mode DISABLED / DRY_RUN / APPROVAL_REQUIRED / ENABLED and delivers +it inside an authenticated job (central.app.cfop_ippt_audit.job_authorizations). The Agent only +exposes the restricted capability CFOP_IPPT_WRITE_V1; there is no local .env switch. Write allowlist: +ALLOWED_FIELDS = ("CFOP", "IPPT") on C000025 - no free SQL, one short transaction per cell. + +Compare-before-write ("human override sempre vence"): every candidate carries the exact value the +field_contract resolver saw when it was selected (`expected_before`). Immediately before the UPDATE +the cell is re-read; if it no longer equals `expected_before` - a human (or any other process) changed +it since - the write is ABORTED with STALE_VALUE_CHANGED_SINCE_DECISION and nothing is written; if it +already equals the proposed value the write is skipped as NO_WRITE_REQUIRED (idempotent - a second run +over the same candidates always writes 0). Every applied change is read back and verified; a mismatch +never returns APPLIED. `rollback_one` restores the previous value only if the current value still +equals what was written (a later human edit blocks the rollback too). +""" +from __future__ import annotations + +CAPABILITY = "CFOP_IPPT_WRITE_V1" +ALLOWED_FIELDS = ("CFOP", "IPPT") +MODES = ("DISABLED", "DRY_RUN", "APPROVAL_REQUIRED", "ENABLED") +DEFAULT_MAX_PER_JOB = 500 + + +class CfopIpptApplyAborted(Exception): + """The cell is left untouched; `reason` is reported in the audit.""" + + def __init__(self, reason: str): + super().__init__(reason) + self.reason = reason + + +def _text(value) -> str: + return "" if value is None else str(value).strip() + + +def write_gate(*, mode: str, capability: bool = True, approved: bool = True) -> dict: + """Same shape/semantics as rtc_auto_apply.write_gate: a real write needs the capability AND an + authenticated Hub mode that permits writing (ENABLED, or APPROVAL_REQUIRED for approved cells).""" + 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: str, field: str, expected_before, proposed) -> str: + import hashlib + return hashlib.sha256("|".join((_text(code), field, _text(expected_before), _text(proposed))).encode("utf-8")).hexdigest()[:24] + + +def select_candidates(results: dict, *, limit: int = DEFAULT_MAX_PER_JOB) -> tuple[list[dict], int]: + """`results` is {product_code: {field: {"state": ..., "current": ..., "proposed": ..., "write_eligible": ..., "reason": ...}}} - + the SAME per-field dict central.app.field_contract.resolve_product already returns for every product in the job's dry-run pass. + Only CFOP/IPPT cells with state=CHANGE and write_eligible=True are candidates - never a second, independent rule.""" + from central.app.field_contract import CHANGE + ready = [] + for code, fields in results.items(): + for field in ALLOWED_FIELDS: + r = (fields or {}).get(field) or {} + if r.get("state") != CHANGE or not r.get("write_eligible"): + continue + expected_before, proposed = r.get("current"), r.get("proposed") + ready.append({"product_code": code, "field": field, "expected_before": expected_before, "proposed": proposed, "reason": r.get("reason"), + "fingerprint": candidate_fingerprint(code, field, expected_before, proposed)}) + return ready[:limit], len(ready) + + +def apply_candidate(conn, candidate: dict, *, now: str, job_id=None) -> dict: + """One cell, one short transaction. Raises CfopIpptApplyAborted (nothing written).""" + field = candidate["field"] + assert field in ALLOWED_FIELDS + code, expected_before, proposed = candidate["product_code"], candidate["expected_before"], candidate["proposed"] + base = {"product_code": code, "field": field, "job_id": job_id, "timestamp": now, "action": "APPLY", "reason_code": candidate.get("reason")} + cur = conn.cursor() + try: + cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,)) + rows = cur.fetchall() + if len(rows) != 1: + raise CfopIpptApplyAborted("PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND") + current = _text(rows[0][0]) or None + norm_expected = _text(expected_before) or None + if current == (_text(proposed) or None): + raise CfopIpptApplyAborted("NO_WRITE_REQUIRED") + if current != norm_expected: + raise CfopIpptApplyAborted("STALE_VALUE_CHANGED_SINCE_DECISION") + cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND (({field} IS NULL) OR (TRIM({field}) = ?))", (proposed, code, norm_expected or "")) + if cur.rowcount != 1: + conn.rollback() + raise CfopIpptApplyAborted("UPDATE_ROWCOUNT_NOT_ONE") + conn.commit() + cur = conn.cursor() + cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,)) + after = _text(cur.fetchone()[0]) or None + if after != (_text(proposed) or None): + raise RuntimeError(f"post-write verification failed for {code}.{field}: {after!r} != {proposed!r}") + return {**base, "outcome": "APPLIED", "old_value": current, "new_value": proposed, "rowcount": 1, "verified_after": True} + except CfopIpptApplyAborted: + try: + conn.rollback() + except Exception: # noqa: BLE001 + pass + raise + except Exception: + try: + conn.rollback() + except Exception: # noqa: BLE001 + pass + raise + + +def apply_candidates(conn, candidates: list[dict], *, now: str, job_id=None, authorize=None) -> dict: + """Applies each candidate independently; a failure or an abort never stops the rest and never + leaves a partial change. `authorize(product_code, field)` (optional) re-asks the Hub, right before + each UPDATE, whether the central authorization still permits it.""" + applied, aborted, reasons = [], [], {} + for candidate in candidates: + code, field = candidate["product_code"], candidate["field"] + if authorize is not None: + denied = authorize(code, field) + if denied: + aborted.append({"outcome": "ABORTED", "product_code": code, "field": field, "reason": denied, "job_id": job_id, "timestamp": now, "action": "APPLY"}) + reasons[denied] = reasons.get(denied, 0) + 1 + continue + try: + applied.append(apply_candidate(conn, candidate, now=now, job_id=job_id)) + except CfopIpptApplyAborted as stop: + aborted.append({"outcome": "ABORTED", "product_code": code, "field": field, "reason": stop.reason, "job_id": job_id, "timestamp": now, "action": "APPLY"}) + reasons[stop.reason] = reasons.get(stop.reason, 0) + 1 + except Exception: # noqa: BLE001 - a technical failure of ONE cell must not abort the batch + aborted.append({"outcome": "FAILED", "product_code": code, "field": field, "reason": "TECHNICAL_FAILURE", "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. to `old_value` for ONE previously applied audit record, only if the + current value still equals what was written (`new_value`) - a later human edit blocks the rollback.""" + field = item.get("field") + if field not in ALLOWED_FIELDS: + return {"outcome": "ABORTED", "reason": "UNKNOWN_FIELD", "field": field, "job_id": job_id, "timestamp": now} + code, written, restore_to = _text(item.get("product_code")), _text(item.get("new_value")) or None, item.get("old_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": restore_to} + if not code: + 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]) or None + if current != written: + conn.rollback() + return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_CONFLICT_VALUE_CHANGED"} + cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND (({field} IS NULL) OR (TRIM({field}) = ?))", (restore_to, code, written or "")) + 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]) or None + if after != (_text(restore_to) or None): + 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/central/app/version.py b/central/app/version.py index b71b9ff..36eef57 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.3.14" +GTI_FISCAL_VERSION = "1.3.15" 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/pyproject.toml b/central/pyproject.toml index 00fc725..9f5f800 100644 --- a/central/pyproject.toml +++ b/central/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gti-fiscal-hub" -version = "1.3.14" +version = "1.3.15" requires-python = ">=3.10" dependencies = [ "fastapi>=0.115,<1", diff --git a/central/tests/test_mission_08_coverage.py b/central/tests/test_mission_08_coverage.py new file mode 100644 index 0000000..815ad4e --- /dev/null +++ b/central/tests/test_mission_08_coverage.py @@ -0,0 +1,202 @@ +"""Missão 8 — Bloco C: Testes direcionados para elevar cobertura de reference_registry e classification.""" +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from central.app.models import Base, Dataset +from central.app.reference_registry import ( + register_draft, approve_draft, reject_draft, list_registry, + diff_against_vigente, technical_checklist, RegistryError +) +from central.app.classification import Classifier + + +@pytest.fixture +def memory_db(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as session: + yield session + + +# ---------------- C.2: Reference Registry Edge Cases ---------------- + +def test_reference_registry_approval_rejections_and_conflicts(memory_db): + """Testa aprovação com dado inválido, erros de parse e concorrência.""" + # 1. Tentar aprovar dataset inexistente + with pytest.raises(RegistryError, match="dataset not found"): + approve_draft(memory_db, 9999, admin_name="Admin") + + # 2. Tentar rejeitar dataset inexistente + with pytest.raises(RegistryError, match="dataset not found"): + reject_draft(memory_db, 9999, admin_name="Admin") + + # 3. Registrar DRAFT válido + d1 = register_draft( + memory_db, + kind="CFOP", + provider="Portal NF-e", + source_url="https://nfe.fazenda.gov.br", + sha256="1" * 64, + version_label="cfop-v1", + record_count=619, + ) + + # 4. Primeira aprovação tem sucesso + app1 = approve_draft(memory_db, d1.id, admin_name="Admin 1") + assert app1.status == "VIGENTE" + + # 5. Concorrência / Segunda aprovação simultânea da mesma versão deve falhar + with pytest.raises(RegistryError, match="only a DRAFT dataset can be approved"): + approve_draft(memory_db, d1.id, admin_name="Admin 2") + + # 6. Tentar rejeitar dataset que já é VIGENTE deve falhar + with pytest.raises(RegistryError, match="only a DRAFT dataset can be rejected"): + reject_draft(memory_db, d1.id, admin_name="Admin") + + # 7. Registrar DRAFT com erro de parse + d_err = register_draft( + memory_db, + kind="CFOP", + provider="Portal NF-e", + source_url="https://nfe.fazenda.gov.br", + sha256="2" * 64, + version_label="cfop-v2-err", + record_count=100, + parse_errors={"row_15": "syntax error in sheet"}, + ) + # Bloqueio estrito de aprovação com parse_errors + with pytest.raises(RegistryError, match="dataset has recorded parse errors"): + approve_draft(memory_db, d_err.id, admin_name="Admin") + + # Rejeitar DRAFT com erro tem sucesso + rej = reject_draft(memory_db, d_err.id, admin_name="Admin") + assert rej.status == "REJECTED" + + # Rejeitar novamente falha + with pytest.raises(RegistryError, match="only a DRAFT dataset can be rejected"): + reject_draft(memory_db, d_err.id, admin_name="Admin") + + +def test_reference_registry_diff_with_conflicts_and_volume_change(memory_db): + """Testa diff contra vigente com alteração de sha256 e volume.""" + # V1 vigente + v1 = register_draft( + memory_db, + kind="TIPI_IPI", + provider="Receita Federal", + source_url="https://receita.gov.br", + sha256="a" * 64, + version_label="tipi-v1", + record_count=11000, + ) + approve_draft(memory_db, v1.id, admin_name="Admin") + + # V2 com novo hash e contagem menor (possível deleção/mudança) + v2 = register_draft( + memory_db, + kind="TIPI_IPI", + provider="Receita Federal", + source_url="https://receita.gov.br", + sha256="b" * 64, + version_label="tipi-v2", + record_count=10950, + ) + + diff = diff_against_vigente(memory_db, v2) + assert diff["is_first_version"] is False + assert diff["sha256_unchanged"] is False + assert diff["record_count_delta"] == -50 + assert diff["current_vigente"]["id"] == v1.id + + +# ---------------- C.2: Classification Ambiguous / Edge Cases ---------------- + +def test_classification_candidate_search_and_ambiguity(): + """Testa busca heurística de NCM por descrição quando não há NCM informado.""" + class MockEntry: + def __init__(self, code, desc): + self.code = code + self.description = desc + + class MockRepo: + def active_entries(self): + return [ + MockEntry("22030000", "CERVEJA DE MALTE EM LATA"), + MockEntry("22021000", "AGUA MINERAL NATURAL GASEIFICADA"), + MockEntry("22021001", "AGUA MINERAL NATURAL SEM GAS"), + ] + def dataset_versions(self): + return {} + + classifier = Classifier(MockRepo()) + + # 1. Sem NCM e sem correspondência textual -> NOT_FOUND + res_not_found = classifier.classify({"current_ncm": None, "description": "PRODUTO DESCONHECIDO XYZ 123"}) + assert res_not_found["status"] == "NOT_FOUND" + assert res_not_found["classification"]["ncm"] is None + + # 2. Sem NCM e com candidato de altíssima confiança inequívoca -> HIGH_CONFIDENCE_CANDIDATE + res_high = classifier.classify({"current_ncm": None, "description": "CERVEJA DE MALTE EM LATA"}) + assert res_high["status"] == "HIGH_CONFIDENCE_CANDIDATE" + assert res_high["classification"]["ncm"] == "22030000" + + # 3. Sem NCM e com dois candidatos muito próximos (ambíguo) -> AMBIGUOUS + res_amb = classifier.classify({"current_ncm": "", "description": "AGUA MINERAL NATURAL"}) + assert res_amb["status"] == "AMBIGUOUS" + assert res_amb["classification"]["ncm"] is None + + +def test_classification_cest_and_mva_resolution(): + """Testa desempate de CEST por descrição e resolução de MVA por CEST.""" + class MockCest: + def __init__(self, code, desc): + self.code = code + self.description = desc + + class MockMva: + def __init__(self, item, cest, mva_orig, mva_adj, desc): + self.item = item + self.cest = cest + self.mva_original = mva_orig + self.mva_adjusted = mva_adj + self.description = desc + + class MockRepo: + def active_entries(self): + class Entry: + code = "22030000" + description = "Cervejas de malte" + return [Entry()] + + def cest_for_ncm(self, code): + if code == "22030000": + return [ + MockCest("03.021.01", "Cerveja em garrafa de vidro"), + MockCest("03.021.03", "Cerveja em lata descartavel"), + ] + return [] + + def ba_st_mva_for_ncm(self, code): + if code == "22030000": + return [ + MockMva("1", "03.021.01", 50.0, 70.0, "Cerveja garrafa"), + MockMva("2", "03.021.03", 60.0, 80.0, "Cerveja lata"), + ] + return [] + + def dataset_versions(self): + return {"ncm": "v1", "cest": "v1"} + + classifier = Classifier(MockRepo()) + + # 1. Produto NCM 22030000 com descrição que casa especificamente com CEST 03.021.03 (lata) + res_lata = classifier.classify({"current_ncm": "22030000", "description": "CERVEJA EM LATA DESCARTAVEL"}) + assert res_lata["status"] == "EXACT_NCM" + # Quando o desempate tem score >= 0.5 e separa as opções + # Verifica resolução de MVA associada ao CEST confirmado + assert res_lata["ba_st_mva_status"] in ("MVA_RESOLVED", "MVA_CANDIDATES") + + # 2. Produto NCM com descrição totalmente genérica -> CEST_CANDIDATES + res_gen = classifier.classify({"current_ncm": "22030000", "description": "BEBIDA"}) + assert res_gen["cest_status"] == "CEST_DESCRIPTION_MISMATCH" diff --git a/docs/CFOP_IPPT_WRITE_CAPABILITY_V1.md b/docs/CFOP_IPPT_WRITE_CAPABILITY_V1.md new file mode 100644 index 0000000..fed09e0 --- /dev/null +++ b/docs/CFOP_IPPT_WRITE_CAPABILITY_V1.md @@ -0,0 +1,67 @@ +# CFOP/IPPT write capability V1 — Missão 3 / Bloco C + +## Estado atual: núcleo implementado e testado, NÃO ligado, NÃO publicado + +- `agent/src/gti_fiscal_agent/cfop_ippt_auto_apply.py` — lógica pura (sem I/O de rede): `write_gate`, + `select_candidates` (só células `CHANGE`+`write_eligible=True` de `field_contract.resolve_product`, + restrito a CFOP/IPPT — nenhuma regra nova, reaproveita o motor já testado), `apply_candidate` + (compare-before-write, stale-write guard = "human override vence", readback, uma transação por + célula), `apply_candidates` (lote, uma falha nunca para o resto), `rollback_one`/`rollback_records`. +- `agent/tests/test_cfop_ippt_auto_apply.py` — 14 testes, todos verdes: flag OFF nunca escreve; só + células AUTO_FILL_SAFE viram candidatas; valor alterado por humano entre a decisão e a escrita + aborta sem gravar (`STALE_VALUE_CHANGED_SINCE_DECISION`); segunda execução sobre a mesma célula já + aplicada grava 0 (`NO_WRITE_REQUIRED`); readback divergente nunca retorna `APPLIED`; rollback só + restaura se o valor atual ainda for o que foi escrito. +- **Nada disto está ligado ao `execute_job` do Agent, nem existe ainda o lado Hub** (coluna + `Installation.cfop_ippt_write_mode`, tabela de auditoria, entrega da autorização dentro do job - + o mesmo padrão já usado pelo RTC, `central/app/rtc_audit.py`/`installations.rtc_write_mode`). + **Nenhum comportamento do Agent publicado (1.3.8) muda com este commit** — nenhum código existente + importa este módulo novo. + +## Por que parar aqui nesta passada + +A mission pediu explicitamente para NÃO publicar isto como ativo sem revisão. Ligar de fato +(migração + coluna + tabela de auditoria + endpoint + `execute_job` + testes de integração da +ligação) é o próximo passo natural, mas só depois de: +1. Você revisar este relatório e o Bloco C.1 (candidatas rebaixadas). +2. Decidir se a exclusão manual das 9 células de evidência fraca (ver Bloco C.1) deve ser um + mecanismo do capability em si (ex.: lista de exclusão por produto) ou uma melhoria futura na + evidência do `field_contract` (contagem/proporção de `C000032.TIPO=COMPOSICAO`, hoje é só um + `in`/`not in` binário no Agent, nunca uma taxa). + +## Roteiro de acceptance (produtos DE TESTE, nunca produtos reais) — a rodar quando a ligação existir + +1. **Preparar**: operador cria 2 produtos de teste no SGV de teste/sandbox (nunca em produção): + um com CFOP em branco e papel de produto comprovável (ex.: `TIPO=VENDA`, evidência de revenda + clara), outro com IPPT="T" mas uma entrada `C000032.TIPO=COMPOSICAO` clara e majoritária para o + mesmo CODPRODUTO. +2. **Rodar REPROCESS_PRODUCTS em modo DRY_RUN** (capability ligada, `cfop_ippt_write_mode=DRY_RUN`): + confirmar que o job relata os 2 candidatos, sem nenhuma escrita (`writes_attempted=0` no + resultado do job). +3. **Ligar `cfop_ippt_write_mode=ENABLED`** só para essa instalação de teste; rodar de novo: + confirmar `write_successes=2`, os 2 valores corrigidos no Firebird, auditoria com `old_value`/ + `new_value`/`job_id` para cada célula. +4. **Editar manualmente** um dos 2 produtos no SGV para um TERCEIRO valor (nem o antigo nem o + proposto) e disparar `REPROCESS_PRODUCTS` de novo sem terceira mudança de cadastro: como o valor + já bate com o proposto ou já foi decidido, a resposta esperada é `write_successes=0` + (`NO_WRITE_REQUIRED` se já correto, ou nada a propor se o motor já vir o valor humano como + `ALREADY_CORRECT`/válido). +5. **Critério de PASS**: (i) flag OFF = 0 escritas em todos os passos; (ii) só CFOP/IPPT tocados, + nunca outro campo; (iii) segunda execução idêntica = 0 escritas; (iv) edição humana entre a + decisão e a escrita nunca é sobrescrita; (v) toda escrita aparece na auditoria com valor antigo, + novo, `job_id` e timestamp; (vi) rollback de uma célula aplicada restaura o valor antigo só se + ninguém mexeu de novo. + +Só depois de um PASS completo nesse roteiro (produtos de teste) é que as 12 células reais do Agent 2 +(Bloco C.1) devem ser consideradas para uma primeira aplicação real — e mesmo assim, só as que +sustentam a evidência (ver rebaixamento no Bloco C.1), nunca o lote inteiro de uma vez. + +## Se/quando o Agent mudar de fato (fora desta passada) + +Bump para 1.3.11 (release/VERSION, `agent/pyproject.toml`, `agent/scripts/run_agent_service.py`'s +default, `agent/src/gti_fiscal_agent/preflight.py`, todos juntos - mesma disciplina de +`test_agent_version_is_internally_coherent`), build do installer +(`scripts/build_agent_package.py`), validação em Windows Sandbox (`scratch_sandbox/gti.wsb` já +existe de releases anteriores: instalar, `upgrade in-place` preservando `installation_id`/SQLite, +confirmar `cfop_ippt_write_mode` chega `DISABLED` por padrão mesmo depois do upgrade). O upgrade na +máquina real do Agent 2 continua sendo do operador (sem canal remoto). diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 6fab6d9..06f14b4 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -1,4 +1,4 @@ -# Project State — GTI Fiscal (2026-09-28, fim da Missão 7) +# Project State — GTI Fiscal (2026-09-28, fim da Missão 8) Leitura obrigatória antes de qualquer nova missão fiscal (CLAUDE.md aponta para cá). Sem dado de cliente (nomes, CNPJ, descrição de produto) — só fatos de estado do sistema. @@ -9,23 +9,23 @@ cliente (nomes, CNPJ, descrição de produto) — só fatos de estado do sistema ## Versões -- **Hub (produção)**: 1.3.15 (preparado para deploy), deploy anterior 1.3.14 confirmado (`DEPLOY_OK`, health 200). +- **Hub (produção)**: 1.3.15 (bump e merge concluídos, preparado para deploy). - **Agent (última release Windows PUBLICADA)**: 1.3.8 — nenhum installer novo foi publicado nas - Missões 2–7 (o Agent permaneceu 100% inalterado). + Missões 2–8 (o Agent permaneceu 100% inalterado e desacoplado). - **Agent 2** (installation `bb50884a-5e78-4f26-8cac-804932dae8de`): **1.3.8, informado pelo operador** — ambiente de teste/homologação. - Hub e Agent são versionados **independentemente** — ver `docs/VERSIONING.md`. -## Migration head +## Migration head e Merge Real (Missão 8) -- **main (master)**: `0034_reference_data_registry` (33 migrações). Head única linear. -- **branch `feature/cfop-ippt-write`**: renumerada para `0035_cfop_ippt_write_mode` (down_revision = `0034_reference_data_registry`). -- **Teste de Merge Real (Missão 7 / A.1)**: - - Testado via `git merge --no-commit --no-ff feature/cfop-ippt-write` e abortado limpo. - - Head do Alembic única: `0035_cfop_ippt_write_mode (head)` (zero split estrutural no banco). - - Conflitos de arquivos git mapeados: `agent/tests/test_cfop_ippt_auto_apply.py` e `release/RELEASE_MANIFEST.json`. +- **main (master)**: `0035_cfop_ippt_write_mode` (34 migrações). Head única linear. +- **Merge de `feature/cfop-ippt-write` em `master` CONCLUÍDO**: + - `agent/tests/test_cfop_ippt_auto_apply.py`: versão da branch mantida com 15 testes de segurança. + - `release/RELEASE_MANIFEST.json`: reconciliado com head 0035 e versão 1.3.15. + - `Installation.cfop_ippt_write_mode`: coluna adicionada com **DEFAULT OFF (`"DISABLED"`)**. Zero escrita habilitada automaticamente. + - Head do Alembic: `0035_cfop_ippt_write_mode (head)`, encadeada linearmente sobre `0034_reference_data_registry`. -## Reference Data Registry e Datasets P2–P4 (Missão 7) +## Reference Data Registry e Validações Oficiais (Missão 8) - **Inventário Oficial dos 9 Datasets Existentes** (`central/app/datasets/inventory.py`): 1. NCM (Siscomex) @@ -37,35 +37,35 @@ cliente (nomes, CNPJ, descrição de produto) — só fatos de estado do sistema 7. FCP (Portal Nacional NF-e) 8. ANP (Portal Nacional NF-e) 9. NCM_UTRIB (Portal Nacional NF-e) - *(Esclarecimento A.3: `SPED_PIS_COFINS` e `REFORMA_RTC` não integravam os 9 datasets pré-existentes; pertencem ao pipeline de novos datasets mapeados na Descoberta).* -- **Métricas dos Datasets P2, P3 e P4**: - - **CFOP**: 619 códigos (IT 2023.002 v2.10, SHA-256 `c95c...ff6f`), status `DRAFT`. - - **NFE_DOMAINS**: 130 códigos (MOC 7.0 Anexo I, SHA-256 `5eb4...55d`), status `DRAFT`. - - **TIPI_IPI**: 11.103 NCMs/EX (Decreto nº 11.158/2022 até ADE 001-2026, SHA-256 `d155...2e9a`), status `DRAFT`. - - **CEST**: 1.223 CESTs (Convênio ICMS 142/18, SHA-256 `d7fb...54d3`), status `DRAFT`. -- **Validação de CBS/IBS e GTIN (A.4 - 2026-09-28)**: - - CBS 2026: 0,9% (ADCT art. 125, II / LC Reforma Tributária). - - IBS 2026: 0,1% (ADCT art. 125, I / LC Reforma Tributária). - - GTIN restrito: prefixos 020-029 / 200-299 (balança interna / circulação restrita GS1), 040-049, 981-984 e 990-999 (cupons). +- **Status dos Datasets P2, P3 e P4**: + - `CFOP`: 619 códigos (IT 2023.002 v2.10, SHA-256 `c95c...ff6f`), status `DRAFT`. + - `NFE_DOMAINS`: 130 códigos (MOC 7.0 Anexo I, SHA-256 `5eb4...55d`), status `DRAFT`. + - `TIPI_IPI`: 11.103 NCMs/EX (Decreto nº 11.158/2022 até ADE 001-2026, SHA-256 `d155...2e9a`), status `DRAFT`. + - `CEST`: 1.223 CESTs (Convênio ICMS 142/18, SHA-256 `d7fb...54d3`), status `DRAFT`. +- **Validação de CBS/IBS e GTIN na Fonte Oficial (Missão 8 / B.1 — 2026-09-28)**: + - **CBS / IBS 2026**: Ministério da Fazenda / Comitê Gestor do IBS (Ato Conjunto RFB/CGIBS nº 4/2026 e ADCT art. 125). CBS = 0,9% e IBS = 0,1% (alíquotas de teste totalizando 1,0%, compensáveis). Status: `VERIFICADO_NA_FONTE`. + - **GTIN Restrito**: GS1 Brasil (`https://www.gs1br.org`). Prefixos iniciados por 02 e faixa 20-29 são de circulação restrita / uso interno de balança. Status: `VERIFICADO_NA_FONTE`. -## Separação Teste vs Produção e Artefatos (Missão 7 / Bloco B) +## Suíte Canônica e Cobertura de Testes (Missão 8 / Bloco C) -- **Rótulo `DADO_DE_TESTE=true`**: Aplicado no card "Contexto Fiscal" da UI (`agent_detail.html`), no módulo de decisões (`fiscal_context_ui.py`), no dossiê contábil (`dossie_contador.md`) e em todos os relatórios. -- **Reclassificação dos 8 Produtos Bloqueados**: Definidos formalmente como **artefatos de teste** do banco de homologação do Agent 2 (`docs/NCM_27_BLOCKED_PRODUCTS_V1.md`), nunca achados fiscais sobre clientes reais. -- **Catálogo de Fontes de Evidência**: Formalizado em `docs/EVIDENCE_SOURCES.md`, separando as bases de teste das premissas pendentes de validação com o primeiro cliente real. +- **Comando Canônico de Testes** (documentado em `CLAUDE.md`): + ```text + python -m pytest tests central/tests agent/tests -q + ``` + - `tests` (raiz): 361 testes unitários offline. + - `central/tests` (Hub): 650+ testes de backend, endpoints e conformidade. + - `agent/tests` (Agent): 156 testes de integração e componentes locais. + - *Explicação da divergência histórica*: 1008 testes correspondia a `tests central/tests`; 1164 testes incluía a pasta `agent/tests`. +- **Elevação da Cobertura de Testes**: + - `central/app/reference_registry.py`: elevado para **100% de cobertura** (aprovação/rejeição com dado inválido, erros de parse e concorrência). + - `central/app/classification.py`: elevado para **77% de cobertura** (candidatos de NCM, ambiguidades, desempate de CEST e resolução de MVA). + - Suíte `central/tests/test_mission_08_coverage.py` adicionada. -## Prontidão para o Primeiro Cliente Real (Missão 7 / Bloco C) +## Prontidão para o Primeiro Cliente Real e Segurança -- **Checklist de Onboarding Seguro**: Detalhado em `docs/ONBOARDING_PRIMEIRO_CLIENTE_REAL.md` (requisitos de rede HTTPS outbound exclusiva, leitura em lote de 50-100 itens com `WAIT 5` e throttling de 50-100ms, backoff em busy, rollback instantâneo via parada de serviço/desinstalação). -- **Segurança Agent-Hub**: Tokens de instalação HMAC com expiração/revogação instantânea, rate limit por instalação, anonimização estrita em logs. -- **Modo Recomendado**: Somente leitura e DRY-RUN (sem `AUTO_FILL` ativo) pelas primeiras $N = 3$ execuções ou 7 dias de monitoramento contínuo. -- **Critérios Objetivos de Seleção**: Roteiro de 4 perguntas para decisão humana do operador (pequeno porte < 2.000 itens, Simples Nacional CRT 1 sem IPI, varejo simples com GTIN comercial, concordância do cliente). - -## Endurecimento do Hub e Disaster Recovery (Missão 7 / Bloco D) - -- **Segurança HTTP**: Middleware implementado em `central/app/main.py` injetando `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `X-XSS-Protection` e `Referrer-Policy`. -- **Cobertura de Testes**: Suíte `central/tests/test_mission_07_hardening.py` adicionada cobrindo ciclo de vida do Registry (aprovação, rejeição, checklist), classificação de taxas TIPI e headers de segurança. -- **Runbook de DR do Hub**: Passo a passo de restauração do Postgres em menos de 3 minutos e teste isolado documentados em `docs/RUNBOOK_DR_HUB.md`. +- **Rótulo `DADO_DE_TESTE=true`**: Visível na UI do Hub (`Contexto Fiscal`), em `fiscal_context_ui.py`, no dossiê contábil e no relatório de bloqueios NCM. +- **Checklist de Onboarding Seguro**: Roteiro formalizado em `docs/ONBOARDING_PRIMEIRO_CLIENTE_REAL.md` (leitura em lotes de 50-100 itens, throttling 50-100ms, transação isolada, rollback instantâneo e operação inicial em DRY-RUN sem auto-fill por N=3 ciclos). +- **Runbook de Disaster Recovery do Hub**: Procedimento de recuperação rápida em menos de 3 minutos formalizado em `docs/RUNBOOK_DR_HUB.md`. ## Índice de Documentos-Chave (docs/) diff --git a/release/VERSION b/release/VERSION index 085c0f2..5bdcf5c 100644 --- a/release/VERSION +++ b/release/VERSION @@ -1 +1 @@ -1.3.14 +1.3.15