feat: Hub-side plumbing for CFOP/IPPT write capability (flag DEFAULT OFF)

Missão 4 / Bloco E.1-E.2, on feature/cfop-ippt-write only (never main):

- Migration 0033_cfop_ippt_write_mode: Installation.cfop_ippt_write_mode
  (DISABLED|DRY_RUN|APPROVAL_REQUIRED|ENABLED, DEFAULT 'DISABLED' for every
  installation, existing and new) + append-only cfop_ippt_write_audits table.
- central/app/cfop_ippt_audit.py mirrors rtc_audit.py for this narrower
  capability: job_authorizations(installation) (delivered inside the
  authenticated job/heartbeat authorizations, never a local Agent switch) and
  record_cfop_ippt_write_audits (persists applied/aborted cells; only CFOP/
  IPPT are ever accepted, everything else silently ignored).
- central/app/web.py: POST /agents/{id}/cfop-ippt-write - ADMIN only (SSO
  Gerenciador), CSRF, typing GRAVAR required for any mode other than
  DISABLED, every change audited (CFOP_IPPT_WRITE_MODE). Small toggle UI in
  agent_detail.html next to Contexto Fiscal.
- Wired into the existing job/heartbeat authorization payload and job
  completion handling in central/app/api.py, alongside (never mixed with)
  the RTC wiring.
- 18 new tests (central/tests/test_cfop_ippt_write_plumbing.py): default
  DISABLED everywhere (job payload AND /v1/agents/me/authorizations); a
  forged completion payload can never change the mode (read fresh from the
  Installation row, never from Agent-supplied JSON); PADRAO role gets 403
  with no audit row; ADMIN + wrong/missing CSRF is refused; ADMIN without
  typing GRAVAR is refused (422); disabling never requires GRAVAR; invalid
  mode is refused; the audit recorder only accepts CFOP/IPPT, silently
  drops any other field (e.g. a stray CCLASSTRIB row).
- Two existing RTC tests updated for the additive authorizations-dict key
  (cfop_ippt_write_mode) they now also carry.

Still NOT wired into agent/scripts/run_agent_service.py::execute_job - no
Agent code calls select_candidates/apply_candidates yet, so nothing writes
to Firebird from this branch. That wiring, an Agent version bump, a new
installer and Windows Sandbox validation are the next step, deliberately
left for after review (see docs/CFOP_IPPT_WRITE_CAPABILITY_V1.md) - this
mission explicitly asked not to publish this as an active release yet.

FULL_SUITE on this branch: 1 known, expected failure -
test_manifest_records_the_real_migration_head - because this branch adds
migration 0033 that was never merged to master/deployed; RELEASE_MANIFEST.json
intentionally still describes master/production (head 0032). Every other
test passes, including all CFOP/IPPT and RTC-adjacent tests.

REAL_PRODUCT_FISCAL_WRITES=0 (no write path is reachable from any Agent
code on any branch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-28 11:22:02 -03:00
co-authored by Claude Sonnet 5
parent a2bad050f9
commit efc8b54bc7
11 changed files with 266 additions and 8 deletions
+7 -2
View File
@@ -12,6 +12,7 @@ from .config import settings
from .datasets.service import DatasetService
from .jobs import SYNC_KINDS, claim_next_job, complete_job, create_job, fail_job, schedule_automatic_sync
from .rtc_audit import job_authorizations, record_rtc_write_audits
from .cfop_ippt_audit import job_authorizations as cfop_ippt_job_authorizations, record_cfop_ippt_write_audits
from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, resolve_stale_fiscal_blockers, upsert_blocker, upsert_blockers_bulk
from .context_keys import validate_entry
from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job
@@ -260,7 +261,9 @@ def router(session_factory, bootstrap_token):
session, agent = identity
try:
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
return job_authorizations(session, installation) if installation is not None else {"rtc_write_mode": "DISABLED"}
if installation is None:
return {"rtc_write_mode": "DISABLED", "cfop_ippt_write_mode": "DISABLED"}
return {**job_authorizations(session, installation), **cfop_ippt_job_authorizations(installation)}
finally:
session.close()
@@ -407,7 +410,8 @@ def router(session_factory, bootstrap_token):
session.commit()
logger.info("job claimed job_id=%s agent_id=%s kind=%s", job.id, agent.id, job.kind)
view = _job_view(job)
view["authorizations"] = job_authorizations(session, installation) if installation is not None else {"rtc_write_mode": "DISABLED"} # decided centrally, per installation, default OFF
view["authorizations"] = ({**job_authorizations(session, installation), **cfop_ippt_job_authorizations(installation)} if installation is not None
else {"rtc_write_mode": "DISABLED", "cfop_ippt_write_mode": "DISABLED"}) # decided centrally, per installation, default OFF
return view
finally: session.close()
@@ -443,6 +447,7 @@ def router(session_factory, bootstrap_token):
resolve_product_blockers(session, agent.tenant_id, product_code, installation_id=installation.id if installation else None)
if installation is not None:
record_rtc_write_audits(session, tenant_id=agent.tenant_id, installation=installation, agent=agent, job_id=job_id, rtc_auto_apply=result_summary.get("rtc_auto_apply"), evaluated_all=bool((result_summary.get("fiscal_context") or {}).get("evaluated_all")))
record_cfop_ippt_write_audits(session, tenant_id=agent.tenant_id, installation=installation, agent=agent, job_id=job_id, cfop_ippt_apply=result_summary.get("cfop_ippt_apply"))
if installation is not None and (result_summary.get("fiscal_context") or {}).get("evaluated_all"):
resolve_stale_fiscal_blockers(session, agent.tenant_id, installation.id, pass_started)
if not complete_job(session, job, result_summary): raise HTTPException(409, "job is not RUNNING")