0001 called Base.metadata.create_all(), building the schema from the current models, so on a fresh PostgreSQL 0002 collided on datasets.raw_sha256 and `alembic upgrade head` could never run from zero. 0001 is now an explicit, frozen baseline: the seven base tables exactly as the real history created them, before 0002/0006 extended them. Databases already past 0001 never re-run it. Proof: a fresh `upgrade head` produces a schema identical (pg_dump --schema-only) to the long-lived dev database. 0019 repairs a latent bug found by that comparison: 0006 used server_default="now()" as a string, which PostgreSQL froze at the instant the column was created, so every Agent and Job got the same created_at. It also adds three indexes the models declare but no migration created. central/tests/test_migration_baseline.py guards (via AST) against migrations importing the models or using string timestamp defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18 KiB
GTI Fiscal — Pilot Deployment Guide
Living document for operating a real pilot: one Hub, one or more client
Agent installations. See docs/OPERATIONS.md for the day-to-day command
reference (creating jobs, checking status); this document is about getting a
new environment stood up and kept alive.
RC1 (1.0.0-rc1) — supported path
Hub central (Docker: Hub + PostgreSQL, deploy/hub)
^ HTTPS (TLS terminated by a reverse proxy)
|
Agent Windows (service "GTIFiscalAgent", deploy/agent-windows)
| local read-only connection
SGV Firebird (the client's C000025 / C000004)
The Hub never accesses Firebird, never stores Firebird credentials and never holds the client's database path. Only the Agent, on the client's Windows host, opens
Firebird. RC1 pilots run FISCAL_MODE=DRY_RUN + FIREBIRD_WRITES=0: the Agent is technically unable to write (no write connection is ever opened, the executor is never called).
Hub (GTI infrastructure)
cd deploy/hub && cp .env.example .env— setPOSTGRES_PASSWORD(openssl rand -hex 24),BASE_URL(public HTTPS URL). LeaveHUB_BIND=127.0.0.1and publish it through the reverse proxy.docker compose -f docker-compose.prod.yml --env-file .env up -d --build— PostgreSQL (internal network only, persistent volume, healthcheck), Hub (migrations at start, healthcheck,restart: unless-stopped, json-file logs 10 MB x 5).- Seed the validated datasets once: on the development machine
sh export_datasets_seed.sh > gti_datasets_seed.dump; on the Hub hostsh seed_datasets.sh gti_datasets_seed.dump(refuses ifdatasetsis not empty; reference tables only, never tenants/agents/jobs). - Register the Agent:
docker compose -f docker-compose.prod.yml exec fiscal-hub python central/scripts/register_agent.py --company "<Client>" --name "<installation>"— the token is printed once; store it only in the Agent's local.env. - Health:
GET /health(Postgres included),GET /version(release/engine/decision versions).APP_ENV=productionrefuses to start whenAGENT_DEV_TOKENis set. - Backup/restore:
sh backup.sh(pg_dump -Fc intoBACKUP_DIR, keeps 14) andsh restore.sh <dump>(asks for the word RESTORE, stops the Hub, restores, restarts; migrations rerun at start).
Agent (client host, PowerShell as Administrator, inside the extracted GTIFiscalAgent-1.0.0-rc1 folder)
Build the package on the release machine: python scripts/build_agent_package.py -> dist/GTIFiscalAgent-1.0.0-rc1.zip (+ .sha256; secret-scanned, no .env).
| step | command |
|---|---|
| install | .\gti-agent.ps1 install (venv + dependencies + service registration; does not start) |
| configure | .\gti-agent.ps1 configure (prompts; secrets never echoed) or configure -FromTemplate .\pilot-construcao.env.example |
| preflight | .\gti-agent.ps1 preflight -> `PREFLIGHT=PASS |
| start / stop / restart | .\gti-agent.ps1 start (refuses when preflight fails) |
| status | .\gti-agent.ps1 status |
| uninstall | .\gti-agent.ps1 uninstall [-Purge] |
Agent configuration (per installation, .env in the install folder)
GTI_HUB_URL, AGENT_ID, AGENT_TOKEN; COMPANY_CODE / COMPANY_CNPJ (company selection), COMPANY_CRT_SOURCE = AUTO | EMISSOR | C000004; COMPANY_UF only as an explicit
override (it never replaces the resolver: it must agree with what the database proves); FIREBIRD_DATABASE_PATH, FB_USER (read-only account), FB_PASSWORD; FISCAL_MODE=DRY_RUN, FIREBIRD_WRITES=0;
POLL_INTERVAL_SECONDS, AGENT_VERSION. AUTO resolves the CRT only when EMISSOR and C000004 agree; a conflict makes the preflight FAIL until EMISSOR or C000004 is chosen explicitly.
The first row of C000004 is never used to pick the company.
Preflight (agent/scripts/preflight.py, also enforced at service start)
HUB_REACHABLE, AUTH, HUB_VERSION_COMPATIBLE (major.minor), FIREBIRD_REACHABLE, FIREBIRD_VERSION_ODS, C000004, C000025, CAPABILITIES (required: NCM, IPI, CEST), COMPANY_CONTEXT (UF, CRT),
SCHEMA_TYPE_GUARDS, SQLITE, LOG_DIRECTORY, FISCAL_MODE, FIREBIRD_WRITES (DRY_RUN + FIREBIRD_WRITES=1 is a misconfiguration -> FAIL).
Pilot profiles
deploy/agent-windows/pilot-construcao.env.example (first pilot: company 000001, UF BA, CRT 1, COMPANY_CRT_SOURCE=AUTO, DRY_RUN). Planned, not installed: profiles/mmmodas|rincao|uniao.env.example
(COMPANY_CRT_SOURCE=EMISSOR required: EMISSOR and C000004 disagree on the CRT). Never reuse the path of a local database copy as the client's real path.
Zero-write proof
python scripts/prove_zero_write.py snapshot <FDB> before.json ... run the pilot ... snapshot <FDB> after.json ... compare before.json after.json (database DML counters, SHA-256 of every row of C000025/C000004, row counts).
The sections below are the previous manual instructions (still accurate for development); the RC1 scripts above are the supported way to deploy.
Architecture
┌────────────────────────────┐
│ Hub server (GTI/Proxmox) │
│ Docker Compose: │
│ - postgres (data volume) │
│ - fiscal-hub (FastAPI) │
└─────────────┬────────────────┘
│ HTTPS (reverse proxy terminates TLS)
┌────────────────────────┼────────────────────────┐
│ │ │
┌─────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
│ Client A — Agent │ │ Client B — Agent │ │ Client C — Agent │
│ Windows Service │ │ Windows Service │ │ Windows Service │
│ + local Firebird │ │ + local Firebird │ │ + local Firebird │
│ + local SQLite audit│ │ + local SQLite audit│ │ + local SQLite audit│
└────────────────────┘ └────────────────────┘ └────────────────────┘
The Hub never has Firebird credentials and never opens a Firebird connection. Each Agent has its own token and only ever reads/writes the one Firebird database it was configured for.
Hub server requirements
- Docker + Docker Compose v2.
- Enough disk for the Postgres volume (starts small; grows with
classification_cache/jobshistory — no large binary data). - One inbound port for the Hub API (default
8000, or whatever your reverse proxy forwards to it). Postgres itself does not need a host port —docker-compose.ymlintentionally does not publish5432; only containers on the compose network reach it.
Deploying the Hub
git clone <repo> && cd Projeto
cp .env.example .env
# Edit .env: set a real POSTGRES_PASSWORD, leave AGENT_DEV_TOKEN empty,
# set APP_ENV=production, BASE_URL to the public HTTPS URL.
docker compose up -d postgres fiscal-hub
curl http://localhost:8000/health # {"status": "ok"}
fiscal-hub's container command runs alembic upgrade head before starting
uvicorn, so schema migrations apply automatically on every deploy/restart.
Both services have restart: unless-stopped and Docker healthchecks
(pg_isready for Postgres, an HTTP GET to /health for the Hub).
Never put AGENT_DEV_TOKEN in a production .env — it auto-bootstraps
an agent from a fixed shared token on every request, fine for local dev,
wrong for a pilot with real client secrets. Leave it empty; register real
Agents with central/scripts/register_agent.py (see below).
Persistence
- Postgres data lives in the named volume
postgres_data. Back it up withdocker exec <postgres-container> pg_dump -U gti gti_fiscal > backup.sql(orpg_dumpallfor the whole cluster) on whatever schedule your operations policy requires. - Always run
alembic upgrade head(the container does this automatically) rather than hand-editing the schema. An empty database is initialized by that same command — never withalembic stamporcreate_all.0001is an explicit, frozen baseline (it used to callBase.metadata.create_all(), which made0002collide ondatasets.raw_sha256on any fresh database);central/tests/test_migration_baseline.pykeeps that from coming back. 0019repairsagents.created_at/updated_atandjobs.created_at, whose defaults0006had frozen at the instant it ran (rows created before0019keep that frozen value — their real creation time is not recoverable).
HTTPS / reverse proxy
The Hub itself speaks plain HTTP. For a pilot reachable outside the Hub's
own host, put it behind a reverse proxy (nginx, Caddy, Traefik — whatever
the GTI/Proxmox environment already standardizes on) that terminates TLS and
forwards to fiscal-hub:8000. Nothing in the app assumes a particular
proxy; BASE_URL in .env is informational only (not currently used to
generate absolute links, but keep it accurate for anyone reading the config).
Registering an Agent (per client installation)
Run once per Agent installation, from anywhere with access to Postgres — easiest is inside the running Hub container:
docker exec <fiscal-hub-container> python central/scripts/register_agent.py \
--company "Client Company Name" --name "loja-01"
This prints AGENT_ID, a freshly generated AGENT_TOKEN (shown once — copy
it immediately), and COMPANY_ID. Only the token's SHA-256 hash is ever
stored in Postgres (same token_hash mechanism /v1/classify already used
— no new auth system). Each Agent gets its own token; one Agent's token
never authenticates another Agent's jobs (agent_id is checked on every
Job operation).
Configuring an Agent installation
On the client machine, copy agent/.env.example to agent/.env and fill in
the values from registration plus that machine's Firebird details:
GTI_HUB_URL=https://hub.example.com
AGENT_ID=<from registration>
AGENT_TOKEN=<from registration>
COMPANY_ID=<from registration>
COMPANY_UF=BA
FIREBIRD_DATABASE_PATH=C:\ERP\dados\PRODUCAO.FDB
FB_USER=...
FB_PASSWORD=...
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=10
AGENT_VERSION=0.1.0
FIREBIRD_WRITES must default to 0. See "Write safety" below before
ever setting it to 1.
Installing the Agent as a Windows Service
Requires pywin32 (pip install pywin32, or pip install -e ".[windows-service]"
from agent/).
# From an elevated (Administrator) terminal, in agent/service/:
python gti_agent_windows_service.py --startup=auto install
python gti_agent_windows_service.py start
# One-time, so Windows restarts it automatically after a crash:
sc.exe failure "GTIFiscalAgent" reset= 86400 actions= restart/60000/restart/60000/restart/60000
gti_agent_windows_service.py is a thin wrapper: it does not reimplement
polling, heartbeat, or the pipeline — it calls run_agent_service.run()
directly, stopping it via the Service Control Manager's own stop signal
instead of SIGINT/SIGTERM (which don't apply to services running with no
console). See the module's docstring for the exact commands.
Install / configure / start / status / stop / restart / uninstall
# Install (registers with SCM, Startup=Automatic)
python gti_agent_windows_service.py --startup=auto install
# Configure: edit agent/.env, no service restart needed until you `restart`
notepad agent\.env
# Start
python gti_agent_windows_service.py start
# Status
sc.exe query GTIFiscalAgent
# Stop
python gti_agent_windows_service.py stop
# Restart (picks up any agent/.env changes)
python gti_agent_windows_service.py restart
# Uninstall
python gti_agent_windows_service.py stop
python gti_agent_windows_service.py remove
Updating the Agent's version
- Stop the service:
python gti_agent_windows_service.py stop - Replace the
agent/code with the new version (git pull, or copy files). - Bump
AGENT_VERSIONinagent/.envif you track it per-release. - Start the service again:
python gti_agent_windows_service.py start. - Confirm the new version shows up via
GET /v1/agents/meon the Hub.
There is no auto-update mechanism yet (deliberately out of scope for this pilot) — updates are a manual stop/replace/start.
Running without installing as a service (dev/testing)
cd agent
python scripts/run_agent_service.py # runs forever, Ctrl+C to stop
python scripts/run_agent_service.py --max-iterations 5 # bounded, for scripting/tests
Creating and checking work
See docs/OPERATIONS.md for the full curl examples
(POST /v1/jobs with kind: REPROCESS_ALL or REPROCESS_PRODUCTS,
GET /v1/jobs/{id} for status). Every Job is scoped to the Agent whose
token created it — no cross-Agent visibility.
Checking Agent status
curl $GTI_HUB_URL/v1/agents/me -H "Authorization: Bearer $AGENT_TOKEN"
Returns agent_id, company_id, enabled, last_seen_at, last_status,
schema_fingerprint, agent_version, and a derived status of ONLINE or
OFFLINE (offline if no heartbeat arrived within
AGENT_ONLINE_THRESHOLD_SECONDS, default 120s — configurable on the Hub).
No dashboard yet; this is the query an operator or a future dashboard would
poll.
Write safety: enabling FIREBIRD_WRITES for a real client
Do not set FIREBIRD_WRITES=1 against a real client database casually.
The authorization model has two independent layers, both required:
- Path:
AUTHORIZED_WRITE_DATABASE_PATHin that installation'sagent/.envmust be set to the exact absolute path ofFIREBIRD_DATABASE_PATHfor that same installation. If it's unset, the guard falls back to the developer-only disposable test database path (TESTESOFICIAIS.FDB), so a real client path is refused automatically — authorization is never inferred from the filename or the.FDBextension. - Schema fingerprint:
EXPECTED_SCHEMA_FINGERPRINTmust equal the live schema fingerprint that installation reports in its heartbeat (schema_fingerprint, visible viaGET /v1/agents/me). If the live schema ever drifts from what was verified, writes are refused until an operator re-verifies and updates this value.
Both checks happen before any write connection is opened
(scripts/firebird_write_executor.py::assert_authorized_write_path /
assert_schema_fingerprint_authorized), and before any product is even
read from Firebird for that job.
Even with both layers satisfied, auto-apply only ever touches
C000025.IPI, and only when the TIPI match is rate_kind=NUMERIC and
unambiguous. No real client database has been written to during this or
any prior mission — all writes so far were against TESTESOFICIAIS.FDB.
Logs and diagnostics
- Agent: rotating local log file (
AGENT_LOG_PATHinagent/.env, defaultagent/logs/agent.log, 5MB × 5 backups). Contains startup, heartbeat outcomes, job received/finished, products processed, write counts, failures, and Hub reconnects — never secrets (FB_PASSWORDandAGENT_TOKENare never logged). Detailed per-product audit is in the Agent's SQLite file (AGENT_STATE_DB, tableipi_write_log). - Hub: structured
loggingoutput (visible viadocker compose logs fiscal-hub) for startup, health-check failures, heartbeats, and the full Job lifecycle (created/claimed/completed/failed, with sanitized errors). Coarse per-Job audit persists in Postgres (jobs.result_summary).
Troubleshooting
| Symptom | Check |
|---|---|
Agent shows OFFLINE |
Is the service running (sc.exe query GTIFiscalAgent)? Check agent/logs/agent.log for heartbeat failures — usually GTI_HUB_URL unreachable or wrong AGENT_TOKEN. |
Job stuck PENDING |
Confirm the Agent that owns it is running and polling (its agent_id must match the Job's agent_id — an Agent never claims another Agent's Job). |
Job FAILED |
GET /v1/jobs/{id} for the sanitized error. Common causes: Firebird unreachable, FIREBIRD_WRITES=1 misconfigured (see "Write safety"), Hub unreachable at report time (the Agent keeps the local ipi_write_log regardless). |
writes_attempted always 0 with FIREBIRD_WRITES=1 |
Check both AUTHORIZED_WRITE_DATABASE_PATH and EXPECTED_SCHEMA_FINGERPRINT are set and correct for that installation. |
Hub /health returns 503 |
Postgres is down or unreachable; check docker compose ps and docker compose logs postgres. |
Rollback / uninstalling an Agent
- Stop and remove the Windows service (see above).
- Delete the
agent/installation directory on that machine (its SQLite audit and logs are local-only; nothing to clean up centrally). - On the Hub, disable the Agent rather than deleting history: set
enabled = falsefor thatagentsrow (no HTTP endpoint for this yet — run it viapsql/a short script againstDATABASE_URL, the same accessregister_agent.pyuses). Its past Jobs andclassification_cacheentries remain for audit.
Rolling back the Hub
docker compose down does not remove the postgres_data volume — data
survives. To roll back to a previous Hub image/version, redeploy that
version's code and run alembic upgrade head again (migrations are
additive, so this is safe going forward; there is no tested downgrade path
for 0006, so don't alembic downgrade in a pilot without restoring from a
backup first).