feat(missao-8): bump Hub 1.3.15, restauracao de cfop_ippt_auto_apply e elevacao de cobertura

This commit is contained in:
gti
2026-09-28 22:14:39 -03:00
parent 98754da1c1
commit 26405b0144
8 changed files with 498 additions and 39 deletions
+5
View File
@@ -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:
@@ -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.<field> 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")}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+202
View File
@@ -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"
+67
View File
@@ -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).
+36 -36
View File
@@ -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/)
+1 -1
View File
@@ -1 +1 @@
1.3.14
1.3.15