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: Run the offline tests with:
```text ```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 python -m pytest tests -q
``` ```
## Firebird write policy ## Firebird write policy
Default 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 once published — never a second, independent version number. Fiscal semantics
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py, are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump.""" 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 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] [project]
name = "gti-fiscal-hub" name = "gti-fiscal-hub"
version = "1.3.14" version = "1.3.15"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"fastapi>=0.115,<1", "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 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. 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 ## 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 - **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 - **Agent 2** (installation `bb50884a-5e78-4f26-8cac-804932dae8de`): **1.3.8, informado pelo
operador** — ambiente de teste/homologação. operador** — ambiente de teste/homologação.
- Hub e Agent são versionados **independentemente** — ver `docs/VERSIONING.md`. - 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. - **main (master)**: `0035_cfop_ippt_write_mode` (34 migrações). Head única linear.
- **branch `feature/cfop-ippt-write`**: renumerada para `0035_cfop_ippt_write_mode` (down_revision = `0034_reference_data_registry`). - **Merge de `feature/cfop-ippt-write` em `master` CONCLUÍDO**:
- **Teste de Merge Real (Missão 7 / A.1)**: - `agent/tests/test_cfop_ippt_auto_apply.py`: versão da branch mantida com 15 testes de segurança.
- Testado via `git merge --no-commit --no-ff feature/cfop-ippt-write` e abortado limpo. - `release/RELEASE_MANIFEST.json`: reconciliado com head 0035 e versão 1.3.15.
- Head do Alembic única: `0035_cfop_ippt_write_mode (head)` (zero split estrutural no banco). - `Installation.cfop_ippt_write_mode`: coluna adicionada com **DEFAULT OFF (`"DISABLED"`)**. Zero escrita habilitada automaticamente.
- Conflitos de arquivos git mapeados: `agent/tests/test_cfop_ippt_auto_apply.py` e `release/RELEASE_MANIFEST.json`. - 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`): - **Inventário Oficial dos 9 Datasets Existentes** (`central/app/datasets/inventory.py`):
1. NCM (Siscomex) 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) 7. FCP (Portal Nacional NF-e)
8. ANP (Portal Nacional NF-e) 8. ANP (Portal Nacional NF-e)
9. NCM_UTRIB (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).* - **Status dos Datasets P2, P3 e P4**:
- **Métricas dos Datasets P2, P3 e P4**: - `CFOP`: 619 códigos (IT 2023.002 v2.10, SHA-256 `c95c...ff6f`), status `DRAFT`.
- **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`.
- **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`.
- **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`.
- **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)**:
- **Validação de CBS/IBS e GTIN (A.4 - 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`.
- CBS 2026: 0,9% (ADCT art. 125, II / LC Reforma Tributária). - **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`.
- 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).
## 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. - **Comando Canônico de Testes** (documentado em `CLAUDE.md`):
- **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. ```text
- **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. 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). - **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.
- **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. - **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).
- **Modo Recomendado**: Somente leitura e DRY-RUN (sem `AUTO_FILL` ativo) pelas primeiras $N = 3$ execuções ou 7 dias de monitoramento contínuo. - **Runbook de Disaster Recovery do Hub**: Procedimento de recuperação rápida em menos de 3 minutos formalizado em `docs/RUNBOOK_DR_HUB.md`.
- **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`.
## Índice de Documentos-Chave (docs/) ## Índice de Documentos-Chave (docs/)
+1 -1
View File
@@ -1 +1 @@
1.3.14 1.3.15