diff --git a/agent/pyproject.toml b/agent/pyproject.toml
index 340d212..9844170 100644
--- a/agent/pyproject.toml
+++ b/agent/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "gti-fiscal-agent"
-version = "1.3.8"
+version = "1.3.9"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"]
diff --git a/agent/scripts/run_agent_service.py b/agent/scripts/run_agent_service.py
index ba13613..a7619e9 100644
--- a/agent/scripts/run_agent_service.py
+++ b/agent/scripts/run_agent_service.py
@@ -107,7 +107,7 @@ class Config:
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB)))
- self.agent_version = env.get("AGENT_VERSION", "1.3.8")
+ self.agent_version = env.get("AGENT_VERSION", "1.3.9")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
# Pilot write authorization: both must be explicitly set by the installer for
@@ -356,7 +356,7 @@ def execute_job(job, config, executor, state, env):
"fiscal_context": fiscal_context,
"rtc_auto_apply": rtc_apply,
"fiscal_mode": getattr(config, "fiscal_mode", None),
- "release_version": env.get("AGENT_VERSION") or "1.3.8",
+ "release_version": env.get("AGENT_VERSION") or "1.3.9",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
@@ -550,7 +550,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
def enforce_startup_preflight(env):
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
from gti_fiscal_agent.preflight import run_preflight
- report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.8")
+ report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.9")
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
if report["PREFLIGHT"] != "PASS":
diff --git a/agent/src/gti_fiscal_agent/preflight.py b/agent/src/gti_fiscal_agent/preflight.py
index d2ba899..854ef10 100644
--- a/agent/src/gti_fiscal_agent/preflight.py
+++ b/agent/src/gti_fiscal_agent/preflight.py
@@ -9,7 +9,7 @@ import os
import sqlite3
from pathlib import Path
-AGENT_VERSION_DEFAULT = "1.3.8"
+AGENT_VERSION_DEFAULT = "1.3.9"
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
diff --git a/central/app/fiscal_context_ui.py b/central/app/fiscal_context_ui.py
new file mode 100644
index 0000000..e2b94f1
--- /dev/null
+++ b/central/app/fiscal_context_ui.py
@@ -0,0 +1,92 @@
+"""Contexto Fiscal: the ADMIN-only, human-language view of the handful of fiscal decisions field_contract.resolve_product genuinely cannot
+derive from the ERP alone (see central/app/enrichment/fiscal_completion.py). Reuses FiscalContextEntry (the SAME table blocker_resolution
+already writes operator answers into) - no new persistence model, no opaque JSON box in the UI.
+
+Each decision here was reduced from a larger candidate list by auditing REAL production data (Agent 2, 2026-09-27/28): icms_rate/credit_rate
+and rounding were REMOVED (proven NOT_APPLICABLE / ALREADY_DERIVED for this CRT=1 company - see git history), leaving only the three that
+real evidence could not resolve unattended. Evidence text below is a dated, one-time manual read-only measurement (see the audit trail in
+git commit messages), not a live query - the Hub has no direct Firebird access; only the Agent does, and this evidence has not yet been
+piped through the heartbeat. This is disclosed in the UI itself so the admin never mistakes it for a live number."""
+from __future__ import annotations
+
+DECISIONS = [
+ {
+ "key": "icms_treatment", "scope": "COMPANY", "title": "Tratamento padrão de ICMS dos produtos comuns",
+ "evidence": "CSOSN padrão do ERP (C000004.ICMS_CSOSN): 102. 487 de 521 produtos cadastrados já usam 102. As exceções existentes "
+ "(500 = ICMS-ST retido, em 5 produtos; 101 = com direito a crédito, em 4 produtos; 103, em 1 produto) são preservadas "
+ "e nunca sobrescritas - a confirmação abaixo só afeta os produtos com CSOSN em branco (28 hoje).",
+ "suggestion": "TAXED", "allowed_values": ["TAXED", "EXEMPT", "NOT_TAXED", "ST_WITHHELD", "SUSPENDED", "DEFERRED"],
+ "affected_fields": ["CST", "CSOSN"], "confidence": "Média (real, medida em produção; não é garantia de que todo produto novo seja 102)",
+ "measured_at": "2026-09-27",
+ },
+ {
+ "key": "pis_cofins_profile", "scope": "COMPANY", "title": "Perfil de PIS/COFINS (código e alíquota de entrada/saída)",
+ "evidence": "34.946 itens de NFC-e emitidos; 3.336 (9,5%) carregam um CST de PIS/COFINS na nota, sempre o código 01 - mas os "
+ "mesmos produtos têm os campos de cadastro (código de saída, alíquota) sempre em branco no SGV, incluindo os poucos "
+ "produtos que TÊM esses campos preenchidos manualmente (a nota emitida ignora o valor cadastrado). Isso indica que a "
+ "emissão não lê esse cadastro para este cliente - mas não é conclusivo o bastante para marcar os 6 campos como "
+ "não aplicável sem confirmação humana.", "suggestion": None,
+ "affected_fields": ["COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS"],
+ "confidence": "Baixa para um perfil único da empresa; considerar tratar como não aplicável ao cadastro (pendente de confirmação contábil)",
+ "measured_at": "2026-09-27",
+ },
+ {
+ "key": "entry_scope", "scope": "COMPANY", "title": "CFOP de entrada padrão (compras dentro/fora do estado)",
+ "evidence": "Apenas 12 documentos de compra reais no ERP para 526 produtos; 11 sem CFOP preenchido, 1 com CFOP intra-estadual. "
+ "Evidência real insuficiente para qualquer sugestão - o CFOP de entrada normalmente é definido pela operação/documento "
+ "de compra, não pelo cadastro do produto.", "suggestion": None,
+ "affected_fields": ["CFOP_ENTRADA"], "confidence": "Muito baixa - recomendação: tratar como definido no momento da operação, não como cadastro obrigatório",
+ "measured_at": "2026-09-27",
+ },
+]
+
+
+VALID_KEYS = {d["key"] for d in DECISIONS}
+
+
+def set_fiscal_context_decision(session, *, tenant_id: int, company_key: str, key: str, value: str | None, reason: str, admin, installation_id: int):
+ """Confirm/alter/clear one Contexto Fiscal decision. value=None clears it (the product/group falls back to whatever the engine can
+ prove on its own - never a silent default). Reuses the SAME table and validator the blocker-resolution workflow already writes
+ through; flags every already-enriched product on this installation for re-evaluation so the new (or cleared) policy takes effect."""
+ from datetime import datetime, timezone
+ from sqlalchemy import select
+ from . import context_keys
+ from .audit import record_audit
+ from .enrichment.status import mark_for_reevaluation
+ from .models import FiscalContextEntry
+
+ if key not in VALID_KEYS:
+ raise ValueError(f"unknown Contexto Fiscal key {key!r}")
+ entry = session.scalar(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == tenant_id, FiscalContextEntry.company_key == company_key,
+ FiscalContextEntry.scope == "COMPANY", FiscalContextEntry.scope_key == "", FiscalContextEntry.key == key))
+ before = entry.value if entry else None
+ if value is None:
+ if entry is not None:
+ session.delete(entry)
+ else:
+ normalized, error = context_keys.validate_entry({"scope": "COMPANY", "key": key, "value": value, "reason": reason})
+ if error:
+ raise ValueError(error)
+ if entry is None:
+ entry = FiscalContextEntry(tenant_id=tenant_id, company_key=company_key, source="OPERATOR", updated_at=datetime.now(timezone.utc), **normalized)
+ session.add(entry)
+ else:
+ entry.value, entry.reason, entry.source, entry.updated_at = normalized["value"], normalized["reason"], "OPERATOR", datetime.now(timezone.utc)
+ flagged = mark_for_reevaluation(session, installation_id)
+ record_audit(session, admin_user_id=admin.id, tenant_id=tenant_id, action="FISCAL_CONTEXT_DECISION", target_type="installation", target_id=company_key,
+ context={"key": key, "from": before, "to": value, "reason": reason, "operator": admin.display_name or admin.username, "role": admin.external_role or admin.auth_source, "products_flagged": flagged},
+ severity="WARNING")
+
+
+def build_fiscal_context_view(session, tenant_id: int, company_key: str) -> list[dict]:
+ from sqlalchemy import select
+ from .models import FiscalContextEntry
+ current = {(r.scope, r.scope_key, r.key): r for r in session.scalars(
+ select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == tenant_id, FiscalContextEntry.company_key == company_key))}
+ out = []
+ for d in DECISIONS:
+ row = current.get((d["scope"], "", d["key"]))
+ out.append({**d, "current_value": row.value if row else None, "current_reason": row.reason if row else None,
+ "last_changed": row.updated_at.isoformat() if row and getattr(row, "updated_at", None) else None,
+ "last_changed_by": row.source if row else None})
+ return out
diff --git a/central/app/version.py b/central/app/version.py
index 819aec2..73ce7de 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.8"
+GTI_FISCAL_VERSION = "1.3.9"
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
diff --git a/central/app/web.py b/central/app/web.py
index f10f663..8581492 100644
--- a/central/app/web.py
+++ b/central/app/web.py
@@ -23,6 +23,7 @@ from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapsh
from .jobs import create_job
from .audit import record_audit
from .enrichment import kb as kb_mod, status as enrichment_status
+from .fiscal_context_ui import build_fiscal_context_view, set_fiscal_context_decision
from .enrichment.observability import coverage_metrics, kb_summary, official_sources, provider_sources
@@ -291,7 +292,8 @@ def router(session_factory):
for event in events:
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25, scope="all")
- return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN", scope="all")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25, scope="all"), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent), "features": _features_view(session, installation, enrich_code), "enrich_code": enrich_code or "", "is_admin": _is_privileged(auth_data[1])})
+ fiscal_context = build_fiscal_context_view(session, installation.tenant_id, _company_key(None, installation, None))
+ return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN", scope="all")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25, scope="all"), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent), "features": _features_view(session, installation, enrich_code), "enrich_code": enrich_code or "", "is_admin": _is_privileged(auth_data[1]), "fiscal_context": fiscal_context})
finally: session.close()
def _features_view(session, installation, enrich_code=None):
@@ -344,6 +346,32 @@ def router(session_factory):
return RedirectResponse(f"/agents/{agent_id}", status_code=303)
finally: session.close()
+ @routes.post("/agents/{agent_id}/fiscal-context")
+ async def set_fiscal_context(request: Request, agent_id: int):
+ """Contexto Fiscal (ADMIN only, CSRF, audited): confirm/alter/clear one of the handful of real fiscal decisions field_contract
+ cannot derive from the ERP alone. Writes through FiscalContextEntry - the same table blocker_resolution already writes to."""
+ session, auth_data, redirect = page_auth(request)
+ if redirect: return redirect
+ try:
+ values = await form_values(request); web_session, admin = auth_data
+ require_csrf(web_session, values.get("csrf_token"))
+ agent, installation = _rtc_target(session, agent_id)
+ if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
+ if not _is_privileged(admin):
+ return _deny(session, admin, installation.tenant_id, "fiscal_context:set", installation.installation_id)
+ key = (values.get("key") or "").strip()
+ action = (values.get("fc_action") or "confirm").strip()
+ value = None if action == "clear" else (values.get("value") or "").strip() or None
+ reason = (values.get("reason") or "").strip() or f"{action} via painel"
+ company_key = _company_key(None, installation, None)
+ try:
+ set_fiscal_context_decision(session, tenant_id=installation.tenant_id, company_key=company_key, key=key, value=value, reason=reason, admin=admin, installation_id=installation.id)
+ except ValueError as error:
+ return HTMLResponse(str(error), status_code=422)
+ session.commit()
+ return RedirectResponse(f"/agents/{agent_id}#contexto-fiscal", status_code=303)
+ finally: session.close()
+
@routes.post("/agents/{agent_id}/enrichment/reevaluate")
async def reevaluate_enrichment(request: Request, agent_id: int):
"""Explicit, idempotent 'evaluate again' for one product (code=...) or every tracked product that is not enriched yet. KB/cache first; providers only when really needed."""
diff --git a/central/pyproject.toml b/central/pyproject.toml
index a8f9a32..66a49af 100644
--- a/central/pyproject.toml
+++ b/central/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "gti-fiscal-hub"
-version = "1.3.8"
+version = "1.3.9"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115,<1",
diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html
index fdb92f7..0b7bb70 100644
--- a/central/templates/agent_detail.html
+++ b/central/templates/agent_detail.html
@@ -10,6 +10,19 @@
{% if ops.enabled %}
{% else %}{% endif %}
- Processamento
- {{ "Ativo" if ops.enabled else "Pausado" }} · sincronização automática a cada {{ ops.interval_minutes }} min
- Última sincronização
- {{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}
- Sincronização agendada
- {{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}
{% if ops.rtc %}- Reforma Tributária
- {{ ops.rtc.label }}
{% endif %}{% if ops.rtc and ops.rtc.enabled %}{% if ops.rtc.products and not ops.rtc.products.applicable %}- cClassTrib
- {{ ops.rtc.products.label }}
{% elif ops.rtc.products %}- Produtos RTC
- {{ ops.rtc.products.headline }}
Total {{ ops.rtc.products.total }} · com cClassTrib válido {{ ops.rtc.products.valid }} · prontos para preenchimento {{ ops.rtc.products.ready }} · ambíguos/bloqueados {{ ops.rtc.products.blocked }} {% endif %}
- Preenchimento automático (RTC, não é o Enriquecimento)
- {{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %} · Controle e candidatos cClassTrib
{% endif %}- Último job
- {% if ops.last_job %}{{ ops.last_job.kind_label }} · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}
+{% if is_admin %}Contexto Fiscal
Somente as decisões que o motor fiscal (field_contract) realmente não consegue provar sozinho no ERP. Visível apenas para administradores.
+{% for d in fiscal_context %}{{ d.title }}
Escopo: {{ {"COMPANY":"Empresa","GROUP":"Grupo","PRODUCT":"Produto"}[d.scope] }}
+- Valor atual
- {{ d.current_value or "Não confirmado" }}{% if d.last_changed %} · alterado em {{ d.last_changed }} por {{ d.last_changed_by }}{% endif %}
+- Sugestão
- {{ d.suggestion or "Sem sugestão automática — evidência insuficiente" }}
+- Evidência
- {{ d.evidence }} (medido manualmente em {{ d.measured_at }} — não é uma consulta ao vivo)
+- Confiança
- {{ d.confidence }}
+- Campos afetados
- {{ d.affected_fields|join(", ") }}
+
+{% endfor %}{% endif %}
Funcionalidades (Enriquecimento de produtos novos)
Decididas no Hub para esta instalação — o Agent não tem chave local. O Enriquecimento de produtos novos NÃO é o preenchimento RTC, que tem controle próprio (controle e candidatos).
{% if features.waiting %}Aguardando configuração de funcionalidades: esta instalação ainda não processa nada.
{% endif %}
- Corretor fiscal
- {{ "Ativo" if features.corrector_enabled else "Desativado" }}
- Enriquecimento de produtos novos
- {{ features.state_label }}
diff --git a/central/tests/test_fiscal_context_ui.py b/central/tests/test_fiscal_context_ui.py
new file mode 100644
index 0000000..9ea8e17
--- /dev/null
+++ b/central/tests/test_fiscal_context_ui.py
@@ -0,0 +1,78 @@
+"""Contexto Fiscal (Agent detail, ADMIN only): confirm/alter/clear one of the reduced set of real fiscal decisions
+(icms_treatment/pis_cofins_profile/entry_scope). CSRF, RBAC and audit reuse the exact same mechanism already proven for
+/agents/{id}/features; persistence reuses FiscalContextEntry, the same table blocker_resolution already writes to."""
+from sqlalchemy import select
+
+from central.app.models import AuditEvent, FiscalContextEntry, Installation, ProductEnrichment
+from central.tests.test_control_panel_pages import _panel_client
+from central.tests.test_rtc_security import _become, _post
+
+
+def _company_key(client):
+ with client._session_factory() as s:
+ inst = s.scalars(select(Installation)).one()
+ return (inst.company_code or inst.company_name or "").strip(), inst.tenant_id, inst.id
+
+
+def test_fiscal_context_tab_lists_only_the_three_reduced_decisions_with_evidence():
+ client, agent_id, _ = _panel_client()
+ _become(client, "ADMIN")
+ page = client.get(f"/agents/{agent_id}").text
+ assert "Contexto Fiscal" in page
+ for title in ("Tratamento padrão de ICMS", "Perfil de PIS/COFINS", "CFOP de entrada padrão"):
+ assert title in page
+ assert "487 de 521" in page # real evidence shown, not a raw internal key name
+ assert "Tratamento padrão de ICMS dos produtos comuns
" in page # human title heads the card; internal key only appears as a hidden form field
+
+
+def test_fiscal_context_hidden_from_non_admin():
+ client, agent_id, _ = _panel_client()
+ _become(client, "PADRAO")
+ page = client.get(f"/agents/{agent_id}").text
+ assert "Contexto Fiscal" not in page
+
+
+def test_confirming_a_decision_is_csrf_admin_and_audited_and_flags_reevaluation():
+ client, agent_id, _ = _panel_client()
+ company_key, tenant_id, installation_id = _company_key(client)
+ with client._session_factory() as s:
+ s.add(ProductEnrichment(tenant_id=tenant_id, installation_id=installation_id, product_code="P1", status="RESOLVED", ui_status="PRONTO_PARA_PREENCHER"))
+ s.commit()
+ _become(client, "PADRAO")
+ assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 403
+ _become(client, "ADMIN")
+ assert client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": "bad", "key": "icms_treatment", "value": "TAXED", "fc_action": "confirm"}, follow_redirects=False).status_code in (400, 403)
+ assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 303
+ with client._session_factory() as s:
+ entry = s.scalars(select(FiscalContextEntry).where(FiscalContextEntry.key == "icms_treatment")).one()
+ assert entry.scope == "COMPANY" and entry.value == "TAXED" and entry.source == "OPERATOR"
+ acts = [e.action for e in s.scalars(select(AuditEvent))]
+ assert "FISCAL_CONTEXT_DECISION" in acts
+ row = s.scalars(select(ProductEnrichment)).one()
+ assert row.reevaluate is True # existing products get a chance to pick up the new policy - never a silent no-op
+
+
+def test_clearing_a_decision_deletes_the_entry_never_a_default_value():
+ client, agent_id, _ = _panel_client()
+ company_key, tenant_id, installation_id = _company_key(client)
+ _become(client, "ADMIN")
+ assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", value="TAXED", fc_action="confirm").status_code == 303
+ assert _post(client, f"/agents/{agent_id}/fiscal-context", key="icms_treatment", fc_action="clear").status_code == 303
+ with client._session_factory() as s:
+ assert s.scalars(select(FiscalContextEntry).where(FiscalContextEntry.key == "icms_treatment")).first() is None
+
+
+def test_invalid_value_is_rejected_never_silently_stored():
+ client, agent_id, _ = _panel_client()
+ _become(client, "ADMIN")
+ resp = client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": client.cookies.get("gti_csrf"), "key": "icms_treatment", "value": "NOT_A_REAL_TREATMENT", "fc_action": "confirm"}, follow_redirects=False)
+ assert resp.status_code == 422
+ with client._session_factory() as s:
+ assert s.scalars(select(FiscalContextEntry)).first() is None
+
+
+def test_unknown_key_is_refused():
+ client, agent_id, _ = _panel_client()
+ _become(client, "ADMIN")
+ resp = client.post(f"/agents/{agent_id}/fiscal-context", data={"csrf_token": client.cookies.get("gti_csrf"), "key": "not_a_real_key", "value": "x", "fc_action": "confirm"}, follow_redirects=False)
+ assert resp.status_code == 422
diff --git a/central/tests/test_health.py b/central/tests/test_health.py
index 0464d45..d0a0cb0 100644
--- a/central/tests/test_health.py
+++ b/central/tests/test_health.py
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
def test_version_endpoint_reports_release_and_fiscal_versions():
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
- assert body["version"] == "1.3.8" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
+ assert body["version"] == "1.3.9" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
def test_production_refuses_the_dev_token(monkeypatch):
diff --git a/release/RELEASE_MANIFEST.json b/release/RELEASE_MANIFEST.json
index 03a8a61..e6a8889 100644
--- a/release/RELEASE_MANIFEST.json
+++ b/release/RELEASE_MANIFEST.json
@@ -1,14 +1,14 @@
{
"artifact": "RELEASE_MANIFEST",
- "GTI_FISCAL_VERSION": "1.3.8",
- "generated_at": "2026-09-28T00:30:00+00:00",
+ "GTI_FISCAL_VERSION": "1.3.9",
+ "generated_at": "2026-09-28T01:15:00+00:00",
"hub": {
- "version": "1.3.8",
+ "version": "1.3.9",
"api_compat": "1.0",
"engine_version": "0.3.0",
"decision_version": "mission-04c-ipi-ex-split"
},
- "agent_version": "1.3.8",
+ "agent_version": "1.3.9",
"fiscal_engine": {
"ENGINE_VERSION": "0.3.0",
"DECISION_VERSION": "mission-04c-ipi-ex-split",
@@ -19,7 +19,7 @@
"migrations": {
"alembic_head_in_repo": "0032_contribuinte_ipi",
"applied_revision": "0032_contribuinte_ipi",
- "applied_revision_source": "confirmed live via docker exec alembic current on gti-fiscal-hub-fiscal-hub-1 during the 1.3.7 deploy; migration 0032 did not change again in 1.3.8",
+ "applied_revision_source": "confirmed live via docker exec alembic current on gti-fiscal-hub-fiscal-hub-1 during the 1.3.7 deploy; migration 0032 did not change again in 1.3.8/1.3.9",
"count": 32
},
"datasets_active": [
@@ -89,5 +89,5 @@
"record_count": 11103
}
],
- "note": "1.3.8: fixes REPROCESS_PRODUCTS hardcoding company=None for _unified_fiscal/RTC (it isn't in SYNC_JOB_KINDS) - now resolves the real company context the same deterministic way every sync job already does, never guesses, falls back to None only when genuinely unresolved. No schema change. Agent code changed (run_agent_service.py): a new GTIFiscalAgent-Setup-1.3.8 installer is published this release."
+ "note": "1.3.9: adds the ADMIN-only Contexto Fiscal section to the Agent detail page (central/app/fiscal_context_ui.py) - confirm/alter/clear the 3 real fiscal decisions field_contract cannot derive alone (icms_treatment/pis_cofins_profile/entry_scope, reduced from an original 5-item list by auditing real production data: icms_rate/credit_rate and rounding were proven NOT_APPLICABLE/ALREADY_DERIVED for this CRT=1 company and dropped). Persists through the existing FiscalContextEntry table, CSRF+RBAC+audit reusing the same mechanism as /agents/{id}/features. No schema change. Hub only - Agent unchanged, no new installer published this release."
}
\ No newline at end of file
diff --git a/release/VERSION b/release/VERSION
index e05cb33..d4c4950 100644
--- a/release/VERSION
+++ b/release/VERSION
@@ -1 +1 @@
-1.3.8
+1.3.9
diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py
index fc50f9f..5cee719 100644
--- a/tests/test_release_candidate.py
+++ b/tests/test_release_candidate.py
@@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT))
def test_versions_are_coherent_across_hub_agent_and_packages():
from central.app.version import GTI_FISCAL_VERSION
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
- assert v == GTI_FISCAL_VERSION == "1.3.8"
+ assert v == GTI_FISCAL_VERSION == "1.3.9"
pep = v.replace("-rc", "rc")
for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
- assert '"AGENT_VERSION", "1.3.8"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
+ assert '"AGENT_VERSION", "1.3.9"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
if manifest.exists():
m = json.loads(manifest.read_text(encoding="utf-8"))