teqo-backup/docs/ops/teqo-1313-deploy.md
Francisco Solla 87ab9a2b1d
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
Deploy / verify (push) Has been cancelled
docs(ops131): registra a decisão A e o runbook do cutover
Decisão A (site público + /campanha no domínio novo, passkeys re-registradas uma vez) refletida no plano; runbook de cutover/rollback no docs/ops/teqo-1313-deploy.md com pré-flight, ingress/DNS, env, verificação e janela read-only do WordPress; changelog da entrega.
2026-11-08 20:30:49 -03:00

2034 lines
133 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Runbook: deploy do site 1313 no homeserver (OPS53 + OPS71 + OPS103 + OPS104 + OPS106 + OPS107 — início automático, produção com review)
Desde a OPS104 o deploy **começa sozinho**: todo merge em `main` (evento
`push`) inicia o run do `deploy.yml` no head, e um segundo gatilho re-despacha
um run novo se `main` andar durante o verify/staging. O que continua manual é
a **publicação em produção**: o job `deploy-production` espera a aprovação do
environment `production`. Antes de tocar o homeserver, o job `verify` (hosted)
roda a suíte **full** (incl. e2e full) **uma vez** e gateia os dois alvos:
`deploy-staging` → aprovação do environment `production` →
`deploy-production` (OPS103).
Desde a OPS107 o approval de produção **não ocupa a lane compartilhada**
(`concurrency` só no `deploy-staging`): runs novos seguem fazendo verify e
staging e ficam aprováveis independentemente — o operador aprova **só o run
escolhido** (tipicamente o último com staging verde) e rejeita os demais.
## Gatilho e fluxo
1. **Início automático (OPS104):** merge em `main` → o run nasce no head; o
job `preflight` (hosted) pula o run quando já existe outro deploy
`queued`/`in_progress`. Um run aguardando aprovação de produção (`waiting`)
**não** conta: um merge nessa janela começa um run novo e os dois ficam
pendentes (o humano aprova qual promover — e o staging do run novo **não**
espera: desde a OPS107 o approval pendente não segura o grupo).
`workflow_dispatch` (GitHub →
Actions → **Deploy** → Run workflow, ref: `main`) segue como escape manual e
**não** passa pelo guard.
2. Job `preflight` (hosted, `scripts/deploy-preflight.mjs`): consulta os runs
do próprio workflow; sem deploy ativo → segue. Falha de API é fail-open
(o guard é dedupe, não gate de segurança).
3. Job `verify` (hosted `ubuntu-latest`, ~50 min): suíte full sem skips —
check-test-locations → lint → format → typecheck → knip → cycles → unit →
int (migrate+seed nos services) → build → e2e full. **Intocado pelas
OPS103/OPS104**: verifica o commit, não o alvo.
4. Se `verify` verde, o job `deploy-staging` (`needs: [verify]`,
`environment: staging`, sem reviewer) roda no runner self-hosted e executa
`TEQO_ENV=staging bash scripts/deploy-homeserver.sh <sha>` — publica o SHA
em `staging.jorgesolla1313.com.br` (container `teqo-staging`,
`127.0.0.1:1314`).
5. Com staging verde, o job `deploy-production` (`needs: [deploy-staging]`,
`environment: production`) **aguarda a aprovação do reviewer** e então
executa `TEQO_ENV=production bash scripts/deploy-homeserver.sh <sha>` —
publica o **mesmo SHA** em produção (`teqo-1313`, `127.0.0.1:1313`).
Staging vermelho = produção nunca roda. O job de produção **não tem**
`concurrency` desde a OPS107: a avaliação de concurrency acontece antes do
gate do environment e um run `waiting` segurava o grupo `deploy-homeserver`,
travando o staging (e a aprovação) dos runs novos. O grupo vive **só** no
`deploy-staging`, que não espera approval (só a vez no próprio grupo) e
sempre drena; a execução
entre runs é serializada pela cadeia `needs`, pelo runner self-hosted
único e pelo `flock` do host. **Operação:** aprove **só o run escolhido**
(tipicamente o último com staging verde) e **rejeite os demais** — runs
não aprovados não publicam nada; se dois forem aprovados, o último deploy
executado vence.
6. **Requeue (OPS104):** com o staging verde, o job hosted `requeue`
(`scripts/deploy-requeue.mjs`, **fora** do grupo `deploy-homeserver` —
um run aguardando aprovação não pode bloquear o requeue) compara o head de
`main` com o SHA do run; se `main` andou durante o verify/staging, dispara
um run novo no head novo. Falha aqui é fail-red (job vermelho): staging
velho nunca fica silencioso.
7. O script, no **homeserver**: `flock` único (compose, workspace e registry
são compartilhados — um deploy por vez no host; desde a OPS107 o
`concurrency` do workflow só existe no staging, então o lock ÚNICO é a
serialização real entre runs e cobre invocação manual; desde a OPS102
o run vai até o fim com o SHA do dispatch mesmo se `main` avançar durante o
verify; o workspace é clonado/atualizado de `TEQO_REPO_URL` — default
`https://github.com/fsolla/teqo.git`, público) →
guard "already deployed" (revision do container do ambiente) → **build do
migrator** (o estágio migrator não roda `next build` — builda mesmo contra o
schema antigo; BuildKit, secrets do env file do ambiente) → push do migrator
em `localhost:5000` (tag **qualificada de registry** — INF13: a única ref
que o compose referencia) → swap dos tags de imagem no
`~/stack/docker-compose.yml` (backup antes; sed ancorado ao serviço do
ambiente — a linha do outro ambiente não é tocada) → **migrations**
(`docker compose --profile maintenance run --rm teqo-<env>-migrate
</dev/null`, já com a imagem do SHA novo) → **build do runner** (contra o
banco JÁ migrado — o `next build` da geração estática lê o schema novo,
OPS66; `--network host` com proxy socat por ambiente na `stack_default` para
alcançar o `postgres`; o proxy é criado idempotentemente pelo script) →
push do runner (tag qualificada de registry) → `docker compose up -d
teqo-<env>` → healthcheck → smoke (`/`, `/campanha/login`, `/admin`,
barreira 307, WebAuthn login-options, `api/revalidate` com o secret real do
ambiente).
8. Falha = job vermelho; nada é publicado pela metade (rollback automático
após o swap, **só do ambiente que falhou**).
9. **`verify` vermelho (OPS106):** o workflow separado `auto-unblock.yml`
(`workflow_run` no Deploy, só `push` + `main` + `failure`) dispara **um**
agente autônomo de desbloqueio no homeserver — launcher síncrono de ~1 min
que cria/atualiza a Issue token `auto-unblock` e destaca o agente
(`scripts/auto-unblock-agent.mjs`, cap de 4h, flock `/tmp/teqo-unblock.lock`);
o agente provisiona um worktree `fix/*` com banco **local** `teqo_wt*`
(guard fail-closed), roda a skill `/bug-fix` headless e entrega PR Ready
(auto-merge nativo). Agente ativo + nova falha = só comentário no token
(nunca enfileira); sem PR = token `blocked` para o humano. O `deploy.yml` e
o `verify` não mudam, e não há auto-retry do deploy.
**Primeiro deploy (verificação ao vivo):** o caminho de estreia do OPS104 é
mergear em `main` e observar o run nascer sozinho: log do `preflight`
(`should_deploy=true`), `verify` verde, staging publicado e `deploy-production`
aguardando aprovação. Para validar a OPS107, deixe esse run `waiting`, dispare
um segundo (merge/dispatch) e confirme que o `deploy-staging` dele roda com o
grupo livre; aprove só o escolhido e rejeite o outro. Para o cutover OPS71, confira no log do job
`deploy-production` o `set-url` do workspace (origem Forgejo → GitHub), o
`deployed_sha` lido do container e o guard "already deployed" num segundo run
do mesmo SHA. Na OPS103, staging primeiro (bootstrap abaixo), inspeção humana,
aprovação, produção.
## Onde roda cada coisa
| Máquina | Papel |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **GitHub hosted** (ubuntu) | CI de PR (`ci-pr.yml`, job `checks`) e job `verify` do `deploy.yml` (suíte full). Nunca toca o homeserver. |
| **homeserver** (8c/16GB) | todo o stack (`~/stack/docker-compose.yml`): postgres `teqo_1313`, forgejo, registry `localhost:5000`, cloudflared, `teqo-1313` na 127.0.0.1:1313 e `teqo-staging` na 127.0.0.1:1314 (OPS103; DB `teqo_staging`, env `~/stack/teqo-staging.env`, bucket `teqo-media-staging`); **runner self-hosted do GitHub** (labels `self-hosted`, `homeserver`; instalado 2026-08-19 — Issue #113 OPS71-INFRA; `~/actions-runner` v2.336.0, systemd user `actions.runner.fsolla-teqo.teqo-1313-runner.service` — `systemctl --user status/restart`); workspace `~/teqo-deploy` (clone de `github.com/fsolla/teqo`, público); o clone operacional da C225 `~/teqo-falas-web` é separado e guarda somente código/dependências, com estado durável em `~/falas-web` |
| **workstation** | dev/agentes apenas — o runner do Forgejo foi **desligado** no cutover (o schedule do `ci.yml` antigo não tem mais razão; religar é reversível) |
Segredos **não** ficam no GitHub: o script sourceia o env file do ambiente
(`~/stack/teqo-1313.env` em produção, `~/stack/teqo-staging.env` em staging) e
`~/stack/.env` (chmod 600) no próprio homeserver. O repo GitHub é público — o
clone do workspace não precisa de credencial. No GitHub ficam apenas os
secrets de integração: `FORGEJO_API_TOKEN` (flips pós-merge) e
`CURSOR_API_KEY` (archive helper, dormente).
## Garage e uploads de mídia
O runtime usa o endpoint S3 do arquivo de ambiente. O endpoint documentado
`http://host.docker.internal:3900` depende do gateway da rede Docker; depois de
uma alteração nas redes, o alias `host-gateway` pode resolver para um gateway
que não alcança o Garage. Nesse caso, o upload do Payload fica esperando o
`PutObject` e a interface permanece em **Enviando...**, embora o PostgreSQL e o
resto do site estejam saudáveis.
O `scripts/deploy-homeserver.sh` trata esse caminho antes das migrations: ele
sonda o endpoint a partir da imagem migrator, testa os gateways IPv4 da rede
`stack_default` quando o alias falha e gera um override operacional por
ambiente (`~/stack/docker-compose.media-production.yml` ou
`~/stack/docker-compose.media-staging.yml`). O override altera somente
`S3_ENDPOINT` nos dois serviços do ambiente selecionado; credenciais e arquivos
de ambiente não são reescritos. Depois do rollout, o mesmo endpoint é sondado de
dentro do container e uma falha executa o rollback.
O probe é fail-closed: se nenhum gateway responder, o deploy para antes de
aplicar migrations. O override é um artefato operacional do stack, não um
arquivo versionado. Para diagnosticar o estado atual:
```bash
ssh homeserver
cd ~/stack
set -a; source ~/stack/teqo-1313.env; set +a
docker network inspect -f '{{range .IPAM.Config}}{{println .Gateway}}{{end}}' stack_default
docker exec teqo-1313 node -e "fetch(process.env.S3_ENDPOINT, {signal: AbortSignal.timeout(5000)}).then(r => console.log(r.status)).catch(e => { console.error(e); process.exit(1) })"
```
O comando `docker exec` deve concluir em poucos segundos, mesmo que o Garage
responda `403` à raiz; timeout ou `ENETUNREACH` significa que o caminho do
container ainda não alcança o bucket.
## Staging (OPS103)
Alvo real e descartável para validar o SHA (migração/build/rollout/smoke) antes
de produção. Mesmo run, mesmo `verify`, mesmo script — o que muda é
`TEQO_ENV=staging` e o environment `staging` (sem reviewer; a aprovação que
importa é a de `production`).
| Item | Staging | Produção |
| ----------- | ------------------------------------------------------------------------------ | ------------------------------------------ |
| Container | `teqo-staging` (127.0.0.1:1314) | `teqo-1313` (127.0.0.1:1313) |
| Migrator | `teqo-staging-migrate` | `teqo-1313-migrate` |
| DB | `teqo_staging` | `teqo_1313` |
| Env file | `~/stack/teqo-staging.env` | `~/stack/teqo-1313.env` |
| Imagens | `localhost:5000/teqo-staging(-migrator):$SHA` | `localhost:5000/teqo-1313(-migrator):$SHA` |
| Bucket | `teqo-media-staging` | `teqo-media` |
| URL | `https://staging.jorgesolla1313.com.br` (noindex) | `https://jorgesolla1313.com.br` |
| Build proxy | `teqo-staging-build-proxy` (127.0.0.1:5434) | `teqo-1313-build-proxy` (127.0.0.1:5433) |
| Lock | `/tmp/teqo-deploy.lock` (compartilhado — compose/workspace/registry são um só) | idem |
### Bootstrap do homeserver (uma vez, manual — fora do repo)
1. **DB:** criar `teqo_staging` no Postgres do stack
(`docker compose exec postgres createdb -U teqo teqo_staging`).
2. **Env file:** `~/stack/teqo-staging.env` (chmod 600) com `DATABASE_URL`
apontando para `teqo_staging`, `PAYLOAD_SECRET` próprio,
`NEXT_PUBLIC_SITE_URL=https://staging.jorgesolla1313.com.br`,
`REVALIDATE_SECRET` próprio, `S3_*` do bucket `teqo-media-staging` e
`STAGING_TEST_ACCOUNT_PASSWORD` (senha sintética da conta de teste dos
agentes — OPS125; nunca commitada).
3. **Compose:** adicionar `teqo-staging` e `teqo-staging-migrate` ao
`~/stack/docker-compose.yml`, espelhando os serviços de produção
(`pull_policy: never`, label `org.opencontainers.image.revision` **por
serviço**, healthcheck, `extra_hosts: host-gateway`) e a porta
`127.0.0.1:1314`.
4. **Bucket:** criar `teqo-media-staging` no Garage com key própria — nunca
reutilizar a key/bucket de produção.
5. **Tunnel/DNS:** ingress `staging.jorgesolla1313.com.br` →
`http://localhost:1314` no cloudflared + DNS no Cloudflare. **FEITO em
2026-09-15** (túnel `home` remotely-managed, `network_mode: host`): no
ingress (config API, versão 7) foi inserido `staging.jorgesolla1313.com.br
→ http://localhost:1314` antes do catch-all `http_status:404`; CNAME
`staging` → `<tunnel-id>.cfargotunnel.com` (proxied) criado na zona
`jorgesolla1313.com.br` (`2ae7416abc4ea930db5ce6782504c578`) via
`CLOUDFLARE_API_TOKEN` em `~/Code/infra-solla/.env`; `docker restart
cloudflared` aplicou. Validação: `https://staging.jorgesolla1313.com.br`
responde 200 (`/`, `/campanha/login`, `/admin`) pela edge com o staging
container. Opcional (defesa em profundidade): header `X-Robots-Tag:
noindex` no ingress — o noindex versionado (robots.ts + metadata, OPS103)
já cobre crawlers.
6. **GitHub Environments:** criar `staging` (sem reviewer) e `production`
(required reviewer `fsolla`) em Settings → Environments — **antes do
primeiro dispatch** (environment `production` sem reviewer é fail-open).
7. **Migrate + seed sintético (antes do primeiro dispatch):** no homeserver,
com o workspace em `~/teqo-deploy`, criar o proxy de staging manualmente
(o deploy também cria, idempotente), migrar e semear. O guard local passa
porque o alvo é `127.0.0.1` — **sem** `ALLOW_REMOTE_DB`:
```bash
ssh homeserver
cd ~/teqo-deploy && pnpm install
docker inspect teqo-staging-build-proxy >/dev/null 2>&1 || docker run -d \
--name teqo-staging-build-proxy --network stack_default \
--restart unless-stopped -p 127.0.0.1:5434:5434 \
alpine/socat TCP-LISTEN:5434,fork TCP:postgres:5432
set -a; source ~/stack/teqo-staging.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5434}"
pnpm migrate
pnpm db:seed:minimal
```
Nunca copiar dados de produção (PII/LGPD): o seed mínimo é sintético.
8. **Conta de teste dos agentes (OPS125):** criar/atualizar a conta
`coordinator` sintética reutilizável para testes manuais em staging (o passo
obrigatório de verificação pós-deploy do `work-issue` saiu na OPS128; a infra
fica). Com o env de staging exportado e o `DATABASE_URL` reescrito
para o proxy do build (`127.0.0.1:5434`):
```bash
ssh homeserver
cd ~/teqo-deploy && pnpm install
set -a; source ~/stack/teqo-staging.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5434}"
STAGING_TEST_ACCOUNT_CONFIRM=1 pnpm campaign:staging:test-account
```
Idempotente (upsert por email) — re-rodar após recriar o banco de staging.
O guard recusa qualquer alvo que não seja `teqo_staging`: `ALLOW_REMOTE_DB`,
`TEQO_ENV` ≠ `staging` e host fora do allowlist local falham fechado (o nome
do banco é o discriminador honesto — o host é reescrito para `127.0.0.1` pelo
proxy). A credencial (`agente-teste@teqo.invalid` + a senha em
`STAGING_TEST_ACCOUNT_PASSWORD`) é fornecida pelo humano que opera o
homeserver **no momento do teste**; nunca entra no PR/Issue nem no env do
agente, e o agente nunca recebe `DATABASE_URL` de staging/produção.
Nota: o `teqo_staging` deste homeserver é **cópia da produção** (com PII
real) — a linha "o seed mínimo é sintético" do passo 7 acima está
desatualizada; a correção dos docs é débito do OPS103, dono do ambiente.
### Limites do staging (o que ele NÃO valida)
- e2e automatizado (o full continua no `verify`, que valida o commit);
- carga, caos, multi-região;
- integrações origin-bound sem configuração própria: WebAuthn/passkeys,
Google OAuth e envio de e-mail (Resend). "Staging verde" ≠ "produção
garantida" — é validação de migração/build/rollout/smoke + inspeção humana;
- dados são sintéticos; nunca PII real.
## Cutover OPS71 (passos manuais, ordem)
1. Remotes locais (config compartilhada pelos worktrees):
`git remote set-url origin git@github.com:fsolla/teqo.git` e
`git remote add forgejo ssh://git@192.168.15.142:2222/fsolla/teqo.git`
(ou `https://git.solla.dev/fsolla/teqo.git`) — o repo Forgejo congela em
main; o tracker (Issues) segue vivo por API.
2. GitHub repo: secrets `FORGEJO_API_TOKEN` + `CURSOR_API_KEY`
(Settings → Secrets and variables → Actions) e **`Allow auto-merge`**
(Settings → General → Pull Requests; ou `PATCH /repos/...` com
`allow_auto_merge: true`). Repo é público: em Settings → Actions →
General, defina fork PR workflows como não executar (ou exigir aprovação).
3. PAT local: criar token GitHub com escopo `repo` e exportar `GITHUB_TOKEN`
(para `node scripts/github-pr.mjs` e `pnpm configure:branch-protection`).
4. `GITHUB_TOKEN=… pnpm configure:branch-protection` — required check
`CI (PR) / checks` (literal de match: `checks` — o GitHub casa pelo nome do
check-run; a UI exibe workflow/job; pin PR #742), 0 reviews,
`enforce_admins: true`. **Antes do primeiro push** (senão o PR mergearia
sem required check).
5. **Desligar o runner do Forgejo** na workstation (ex.:
`systemctl --user stop forgejo-runner`) — após o primeiro PR GitHub
validado; para o schedule do `ci.yml` antigo.
6. Instalar o runner self-hosted do GitHub no homeserver (labels
`self-hosted` + `homeserver`; usuário com docker + acesso a `~/stack`;
registrar em infra-solla: `STATE.md`).
7. Push do PR do OPS71 → CI verde → auto-merge → flip da Issue no Forgejo →
deploy manual de validação.
Rollback do cutover (se o GitHub falhar cedo): religar o runner do Forgejo e
abrir PRs no Forgejo a partir do main congelado — os workflows de
`.forgejo/workflows/` foram removidos na OPS71 Fase 2, mas restauram do git
history (`git revert` do PR da remoção, ou clone do main congelado do Forgejo)
— remotes: `git remote set-url origin ssh://git@192.168.15.142:2222/fsolla/teqo.git`.
## Rollback (do deploy)
A imagem anterior **não** fica mais local após a limpeza pós-deploy (INF3/F2:
`docker builder prune` + remoção das tags locais antigas, exceto o SHA em uso)
— o registry `localhost:5000` **nunca deleta** e é a fonte do rollback. O
compose anterior fica em `~/stack/docker-compose.yml.pre-<sha-do-deploy-que-falhou>`
(um por deploy; como o staging roda antes no mesmo SHA, o backup de produção é
o compose já com a linha do staging — o restore devolve o arquivo inteiro, o
que é o correto).
```bash
ssh homeserver
cd ~/stack
# 1. apontar o compose de volta para o SHA anterior (o que estava rodando):
# o backup do deploy que falhou é o compose com os tags antigos
ls docker-compose.yml.pre-* # escolha o anterior ao deploy ruim
cp docker-compose.yml.pre-<sha-anterior> docker-compose.yml
# 2. puxar do registry (a imagem antiga já não fica local após a limpeza;
# o compose referencia a tag qualificada — INF13 — e o pull basta):
docker pull localhost:5000/teqo-1313:<sha-anterior>
# Cuidado (janela de transição pós-INF13): backups gerados ANTES do fix
# usam tags bare (`image: teqo-1313:<sha>`); se o backup escolhido tiver
# tag sem prefixo, recrie o alias antes de subir:
# docker tag localhost:5000/teqo-1313:<sha-anterior> teqo-1313:<sha-anterior>
# 3. subir:
docker compose up -d teqo-1313
```
Para **staging**, o mesmo procedimento com as identidades do ambiente
(`teqo-staging`, `localhost:5000/teqo-staging:<sha-anterior>`,
`docker compose up -d teqo-staging`) — o rollback de um ambiente não toca o
container do outro (só restaura as linhas de imagem do arquivo compartilhado).
**Caveat:** uma migration de schema aplicada no passo de migrate **não** é
desfeita pelo rollback (Payload é append-only). Código velho sobre schema novo
pode se comportar mal — o caminho primário minimiza essa janela (migrate só
roda após migrator build+push+swap ok; rollout só após runner build ok). Desde
o OPS66 o migrate roda **antes** do build do runner (o build precisa do schema
novo): um build do runner que falhe depois do migrate deixa o banco à frente
do código — migrations são append-only e revisadas antes do deploy (checklist);
a correção se re-mergeia e o próximo deploy completo publica.
## Falhas conhecidas
| Sintoma | Causa | Tratamento |
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prod regride para um SHA antigo após re-dispatch fora de ordem | O `flock` serializa mas não ordena; o "already deployed" não pega revision diferente | Evitar re-dispatchar SHA antigo (o requeue da OPS104 sempre despacha o head atual); se acontecer, re-dispatchar o `main` atual (rollback manual disponível na seção Rollback). Revisitar se uma regressão fora-de-ordem acontecer mesmo com o requeue |
| Job verde sem deploy ("already deployed") | O container rodando já tem a revision do SHA do job (ex.: run duplicado da mesma HEAD — `workflow_dispatch`, ou push + requeue da OPS104) | Nada a fazer — o site já roda esse SHA. Forçar rebuild da MESMA HEAD não é suportado via dispatch: edite o tag da imagem no compose (`image: localhost:5000/teqo-1313:<sha>-rebuild`) e `docker compose up -d`, ou espere um commit novo |
| Merge durante aprovação pendente de produção: staging do run novo ficava `pending` (resolvido) | O job `waiting` de approval **segurava o grupo** `deploy-homeserver` (a avaliação de concurrency acontece antes do approval — comportamento do GitHub) | **Resolvido na OPS107:** o `concurrency` vive só no `deploy-staging` (que nunca espera approval); o `deploy-production` não ocupa lane, então runs novos fazem staging e ficam aprováveis. Aprove só o run escolhido e rejeite os demais — runs não aprovados não publicam; multi-approve = o último deploy executado vence (rollback manual se um SHA indesejado publicar) |
| Merge durante `deploy-production` `in_progress` não gera requeue | O push nasce, o `preflight` vê o deploy ativo e pula; o requeue daquele run já rodou (logo após o staging) | Nada nasce até o próximo merge ou `workflow_dispatch`. Revisitar na primeira ocorrência real → 2º requeue após produção verde, ou refinar o guard |
| Job de deploy nunca roda / runner offline | Runner self-hosted do homeserver não instalado ou parado (passo manual do cutover OPS71) | Instalar/religar o runner (labels `self-hosted`, `homeserver`); os PRs e o `verify` não dependem dele — só os deploys |
| Deploy dispara mas o job `verify` falha | Regressão real na suíte full | Corrigir e re-dispatchar — o homeserver nunca é tocado |
| `No such image` ao recriar o container fora de deploy (ex.: `compose up -d` pós-reboot/migração) | O compose referenciava uma tag local bare (`teqo-1313:<sha>`) que desapareceu sem recriação (achado INF8/INF13, 24/08: só restava a ref do registry) | Corrigido no INF13: o compose passa a referenciar a tag **qualificada** (`localhost:5000/teqo-1313:<sha>`) a partir do primeiro deploy pós-fix — até lá, produção ainda roda o compose bare (recrie o alias via `docker tag` se necessário). Recovery geral: `docker pull localhost:5000/teqo-1313:<sha>` (+ `docker tag` se o compose em uso tiver tag bare) e `docker compose up -d` |
| Deploy para logo após o migrate ("Done." e nada mais, EXIT=0) | `docker compose run` anexa stdin por padrão — o container consome o resto do script; o bash chega a EOF e termina sem rodar o rollout | O script já usa `< /dev/null` no `run --rm` do migrate (não remover); sintoma visto 2026-08-17 no primeiro deploy |
| Build falha: `network mode "stack_default" not supported by buildkit` | BuildKit (drivers docker e docker-container) recusa rede bridge custom no `--network` | O script builda com `--network host` + proxy socat do ambiente (prod `teqo-1313-build-proxy` em 127.0.0.1:5433; staging `teqo-staging-build-proxy` em 127.0.0.1:5434, na `stack_default`, criado idempotentemente com `restart: unless-stopped`) + `DATABASE_URL` reescrita para o loopback |
| Build OOM no homeserver | Laptop 8c/16GB com o stack ativo (~12GB livres medidos) | Re-dispatch; se recorrente, item futuro: build na workstation com túnel |
| Build do runner falha: `relation "..." does not exist` no `next build` | Migration nova criou tabela lida em geração estática. Pré-OPS66 a ordem era build→migrate e o deploy morria aqui para sempre (incidente 2026-08-18, S2) | Pós-OPS66 não deve ocorrer: migrate roda antes do build do runner. Se reaparecer, confira no log se o passo migrate rodou; recovery manual: `docker build --target migrator` + `docker run --rm --network stack_default --env-file ~/stack/teqo-1313.env localhost:5000/teqo-1313-migrator:<sha>` e re-dispatch do workflow |
| Workspace clone sem o SHA novo | O workspace antigo apontava para o Forgejo local (pré-OPS71) e o fetch não achou o SHA | O script agora re-aponta `origin` para `TEQO_REPO_URL` (idempotente) antes do fetch — se ainda falhar, `git -C ~/teqo-deploy remote -v` e corrigir manualmente |
| Migrate falha | Drift/erro de schema | Job vermelho; site segue no container antigo; corrigir e re-mergear |
| Smoke falha pós-up | Regressão de runtime | Rollback automático (restore + `up -d`) + job vermelho; investigar |
| `deploy-staging` falha cedo (`teqo-staging.env`/serviço ausente) | Bootstrap do staging incompleto (DB, env file, serviços no compose ou environment `staging`) | Rodar o bootstrap da seção Staging; produção nunca é tocada (staging vermelho fail-closa o job `deploy-production`) |
| `verify` vermelho | Regressão real na suíte full (ou flake — #882/#906) | O `auto-unblock.yml` dispara o agente sozinho (OPS106): acompanhe a Issue `auto-unblock` (token) e o PR `fix/*`. Sem PR, o token fica `blocked` — inspecione o log em `~/teqo-unblock/logs/`. Correção manual segue possível (re-merge ou `workflow_dispatch`) |
| Agente de desbloqueio não disparou (token inexistente) | Bootstrap do homeserver incompleto — opencode sem auth, Node/pnpm fora do PATH do runner, PAT ausente/sem push, Docker/porta 5432, label ausente | Rodar `pnpm unblock:check` no homeserver (com `GITHUB_TOKEN` e `AUTOMERGE_PAT` exportados) e seguir a seção Auto-unblock (OPS106) abaixo |
| Launcher `auto-unblock` morre em `fetch failed` (nenhum token criado) | Node fetch ignora HTTP(S)\_PROXY sem NODE_USE_ENV_PROXY (Node ≥24); no homeserver o GitHub só sai pelo proxy CONNECT do runner | OPS126: `scripts/auto-unblock.sh` liga o `NODE_USE_ENV_PROXY` quando há proxy no env; manter `HTTPS_PROXY/HTTP_PROXY` no `~/actions-runner/.env`. Re-dispatch ou aguarde a próxima falha do `verify` |
| Agente morre com exit 127 (`opencode`: No such file or directory) | Serviço do runner não carrega o profile → `~/.opencode/bin` fora do PATH; ou opencode nem instalado | OPS126: `resolveOpenCodeBinary` (`OPENCODE_BIN` → `$HOME/.opencode/bin` → PATH); o launcher falha fechado (token `blocked`) se faltar. Instale/autentique via `pnpm unblock:check` |
| Token `auto-unblock` preso (agente morto, sem PR) | Wrapper morreu (OOM/timeout/erro) — lock livre, token < TTL se torna `skip_comment` na próxima falha | O TTL de 3h reclaima o token automaticamente (comenta + fecha + novo agente). Antes disso, inspecione o log e a branch `fix/*`; para liberar na hora, feche a Issue token com um comentário |
| Capa do Instagram na home em `500` / link do próprio Instagram na Central sempre "Instagram indisponível no momento" | O container é IPv4-only e a rota IPv4 até o CDN do Instagram (`instagram.fssa2-1.fna.fbcdn.net`) é instável nesta rede; a API (`graph.instagram.com`) funciona, mas o arquivo não chega (`media_url` presente e download estourando) | A rede `teqo-ipv6` (`enable_ipv6`; sub-redes **fixadas** `10.0.13.0/24` + ULA `fd00:1313:1313::/64`) está anexada a `teqo-1313`/`teqo-staging` no compose (backup `docker-compose.yml.pre-ipv6-*`). Com duas redes, o `host-gateway`/default route saem pela sub-rede nova: o Garage (3900) precisa do par de regras de ufw IN + FWD para `10.0.13.0/24` (espelha o allow do `10.0.11.0/24`) — sem elas o upload da peça falha com `connect ETIMEDOUT 10.0.0.1:3900`. Se voltar: confirme IPv6 no container (`/proc/net/if_inet6`), o fetch do CDN e o TCP/HTTP do Garage de dentro dele; o resolver agora faz retry com teto de conexão. Post-mortem: `docs/postmortems/2026-09-25-instagram-cdn-ipv4-instavel.md` |
## Auto-unblock (OPS106)
Uma falha do job `verify` dispara **um** agente autônomo de desbloqueio no
homeserver (workflow `.github/workflows/auto-unblock.yml`, runner self-hosted),
com o mesmo contexto de `pnpm worktree fix`: worktree `fix/*` provisionado pelo
dono (`scripts/worktree.mjs fix --headless`), bancos **locais** `teqo_wt*`/
`*_test`, skill `/bug-fix` headless (`opencode run --command bug-fix`), PR Ready
com auto-merge nativo. O registro visível é a Issue token `auto-unblock`, que
linka o run vermelho; o token aberto + o `flock /tmp/teqo-unblock.lock` são o
single-flight (falha com agente ativo só comenta — nunca enfileira).
Guardrails: banco alvo tem de ser local e casar `teqo_wt*`/`*_test` (senão o
agente nem lança; nunca `teqo_1313`/`teqo_staging`, nunca `~/stack/*.env`);
`DATABASE_URL`/`ALLOW_REMOTE_DB`/`TEQO_ENV` são removidos do env do agente; o
agente roda com o PAT (`AUTOMERGE_PAT`) só no processo destacado, via credential
helper não-persistente; cap de 4h; produção continua só com aprovação humana e
o deploy não tem auto-retry.
### Bootstrap (uma vez, manual — fora do repo)
1. **opencode no homeserver:** instalado e autenticado no usuário do runner
(`opencode auth login`; o provider do modelo default — `deepseek/deepseek-flash`
via env `OPENCODE_WORKTREE_MODEL` para override — precisa estar presente).
O serviço do runner **não** carrega o profile interativo, então
`~/.opencode/bin` fica fora do PATH: o OPS126 resolve o binário
(`OPENCODE_BIN` → `$HOME/.opencode/bin/opencode` → PATH) e o launcher falha
fechado se não achar. Rede: o homeserver só alcança o GitHub pelo proxy
CONNECT local (`github-tunnel` + `http-proxy-socks`; `~/actions-runner/.env`
com `HTTPS_PROXY/HTTP_PROXY`) — o `scripts/auto-unblock.sh` liga o
`NODE_USE_ENV_PROXY` para o `fetch` do Node respeitá-lo.
2. **PAT:** secret `AUTOMERGE_PAT` no repo com os escopos `contents: write`,
`pull requests: write` e `issues: write` (o agente faz push/PR e o outcome
comenta/fecha a Issue; o built-in token do job não sobrevive ao processo
destacado). O armar do auto-merge continua no `agent-pr-ready-automerge.yml`.
3. **Docker/Postgres dev:** o runner user precisa de Docker; o agente sobe o
container dev do repo (`docker compose -p teqo` com o `docker-compose.yml`
da raiz — serviço `postgres`, init em `docker/postgres/init`; porta 5432) —
**não** é o Postgres do stack (`stack_default`), então nenhum banco de
prod/staging é alcançável pelo env do agente.
4. **Deps do checkout:** o workflow roda `pnpm install --frozen-lockfile` no
checkout do runner **antes** do launcher — o wrapper destacado carrega
`scripts/worktree.mjs`, que importa `dotenv`/`pg` no load, e o
`actions/checkout` limpa `node_modules` no runner self-hosted. O `--check`
valida `node_modules` (rode-o de um checkout com deps instaladas).
5. **Dir do agente:** `$HOME/teqo-unblock/{logs,state,worktrees}` (criado pelo
próprio launcher; `WORKTREES_ROOT=$HOME/teqo-unblock/worktrees`).
6. **Label:** `auto-unblock` (o launcher cria idempotentemente; o token nunca
recebe `ready`/`in-progress` e não entra na fila de claim).
7. **Smoke:** com `GITHUB_TOKEN` (built-in/PAT) e `AUTOMERGE_PAT` exportados,
`pnpm unblock:check` valida node/git/pnpm/flock/timeout/docker (binário +
daemon)/opencode/auth, porta 5432, dirs, deps do repo, label e
`permissions.push` do PAT — fail-closed com a mensagem acionável.
### Operação
- **Token `auto-unblock` aberto** = agente ativo (ou órfão dentro do TTL de 3h).
Falha nova com token aberto: comenta no token, não dispara.
- **Sem PR ao fim:** token recebe `blocked` e fica aberto (gate humano) — o log
está em `~/teqo-unblock/logs/unblock-<run-id>.log`.
- **Limpeza:** worktrees/databases acumulam um punhado por falha; apague com
`pnpm worktree kill` de dentro do worktree (dropa os bancos do worktree).
Gatilho para automatizar retention: **≥3 worktrees/DBs `teqo-unblock`
acumulados** (`ls ~/teqo-unblock/worktrees`).
## Backup passivo no Forgejo (OPS76-FOLLOWUP, 2026-08-21)
O GitHub é a única fonte (main, branches, PRs, CI, tracker). O Forgejo mantém
um **backup passivo** via **pull mirror nativo**: o repo `teqo-backup` no
Forgejo (`https://git.solla.dev/fsolla/teqo-backup`) espelha o GitHub
automaticamente — **não serve push, não recebe PRs, é somente leitura**.
- **Repo:** `fsolla/teqo-backup` no Forgejo (`mirror: true`), criado via
`POST /api/v1/repos/migrate` com `clone_addr=https://github.com/fsolla/teqo.git`,
`service=github`, `mirror=true`, intervalo **8h** (`mirror_interval`).
- **Automação:** o próprio Forgejo sincroniza no intervalo (sem workflow).
Force sync manual: `POST /api/v1/repos/fsolla/teqo-backup/mirror-sync`
(o "Synchronize now" da UI).
- **Cobertura:** espelha todas as branches (54/54 verificadas) e tags; o
histórico do mirror reflete o do GitHub (que é a convergência OPS75 +
OPS76). Não espelha issues/PRs/labels (GitHub é o tracker).
- **Validação:** `git ls-remote` do `teqo-backup` deve retornar o mesmo SHA
de `refs/heads/main` do GitHub; conferir em `mirror_updated` o último sync.
- **O repo `fsolla/teqo` original do Forgejo** fica **congelado** no ponto da
convergência (OPS75) — arquivo histórico, sem mais uso.
## Segurança (decisão deliberada)
- Os jobs `deploy-staging`/`deploy-production` usam
`runs-on: [self-hosted, homeserver]` — executam como o usuário do runner no
homeserver. Estão **apenas** no `deploy.yml` (início automático por `push`
em `main` ou `workflow_dispatch`; ambos passam pelo mesmo `verify` full),
nunca no `ci-pr.yml`; o hosted (`preflight`/`verify`/`requeue`) não tem
acesso ao homeserver e o self-hosted não recebe secrets de produção (as
envs vivem no próprio homeserver, inclusive as de staging — nunca como
GitHub secrets). `main` só anda por PR mergeado com CI verde; o deploy só
roda com `verify` full verde (**exceção OPS106**: o agente de desbloqueio é
um job self-hosted disparado por `verify` vermelho — launcher fino + agente
destacado com guardrails fail-closed; ver seção Auto-unblock). Fork-PRs não
rodam CI (same-repo gate + setting do repo).
- **Agente de desbloqueio (OPS106):** o `auto-unblock.yml` roda código na
máquina de produção justamente quando o `verify` falha. Mitigações: gatilho
restrito (push em `main` + job `verify` falho conferido pela jobs API),
launcher fino (só decide/spawna e sai), `flock` + Issue token
(single-flight), agente sem `RUNNER_TRACKING_ID` com cap de 4h, banco local
`teqo_wt*`/`*_test` fail-closed, env sem
`DATABASE_URL`/`ALLOW_REMOTE_DB`/`TEQO_ENV`, PAT por credential helper
não-persistente, entrega só por PR Ready com auto-merge (nunca push direto
em `main`) e produção ainda com approval humano. Risco residual aceito: o
agente tem Docker e o PAT no homeserver — sem sandbox/usuário dedicado
nesta fatia (revisitar só com evidência de abuso).
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver.
- Staging sem PII real (seed sintético), bucket/DB/env próprios e noindex —
nunca compartilha credencial, bucket ou dados com produção.
## C149 — Google OAuth da agenda (envs novas + GCP)
O botão **"Conectar com o Google"** da agenda usa um OAuth client do GCP. Sem
as envs, o diálogo cai no runbook antigo da service account — que segue como
fallback e não foi removida por esta entrega.
1. No GCP (mesmo projeto da service account), crie um OAuth client **Web
application** com os redirect URIs:
- `https://jorgesolla1313.com.br/campanha/agenda/google-oauth/callback`
- `http://localhost:<porta>/campanha/agenda/google-oauth/callback` (dev)
2. Publique o app em produção — **unverified** é aceito (uso interno). Em
"Testing" o refresh token expira em ~7 dias e o estado vira `erro` na UI.
3. Adicione ao `~/stack/teqo-1313.env` (homeserver, chmod 600):
`GOOGLE_CALENDAR_OAUTH_CLIENT_ID` e `GOOGLE_CALENDAR_OAUTH_CLIENT_SECRET`.
4. Deploy normal (`deploy.yml` — o merge em `main` já dispara; `workflow_dispatch`
segue como escape). O callback monta o redirect a partir de
`NEXT_PUBLIC_SITE_URL` — precisa bater exatamente com o URI registrado no
passo 1.
Desconectar no Teqo apaga o refresh token daquele lado; para revogar o acesso
de fato, revogue o app em `myaccount.google.com/permissions` (a UI orienta o
passo).
## Busca por tema e demais capacidades de IA — envs do app
As chamadas de IA do app (busca por tema do acervo e da Central, título de
demanda, metadados de corte, catalogação de peças, reranking do Sollinha e
transcrição de voz) rodam no **runtime do container** e leem as chaves do
`env_file` do compose (`~/stack/teqo-1313.env` / `~/stack/teqo-staging.env`).
O `~/stack/.env` do homeserver (que o `deploy-homeserver.sh` sourceia para
registry e BuildKit secrets) **não** chega ao runtime. Chave ausente não
derruba o boot: a capacidade degrada em silêncio — a busca por tema mostra
"indisponível agora" e o título cai no fallback determinístico (incidente
2026-09-24; post-mortem em
`docs/postmortems/2026-09-24-busca-por-tema-indisponivel.md`).
Pré-requisitos de produção e staging: `DEEPSEEK_API_KEY` (busca por tema,
Sollinha, título/metadados) e `DEEPINFRA_API_KEY` (transcrição de voz). O
`~/stack/.env` do homeserver já tem as duas; copie as linhas para o env file
do app (idempotente):
```bash
ssh homeserver
for f in ~/stack/teqo-1313.env ~/stack/teqo-staging.env; do
grep -q '^DEEPSEEK_API_KEY=' "$f" || grep '^DEEPSEEK_API_KEY=' ~/stack/.env >> "$f"
grep -q '^DEEPINFRA_API_KEY=' "$f" || grep '^DEEPINFRA_API_KEY=' ~/stack/.env >> "$f"
done
```
O deploy seguinte recria o container com as chaves. Desde o incidente, o
`deploy-homeserver.sh` audita o env do container recém-saído do healthcheck e
anota no run (`::warning::`) qualquer chave de feature ausente — não falha o
deploy (quais chaves existem é decisão humana), mas a perda de capacidade
aparece no próprio run.
### Egress até o provedor — proxy na tailnet
A rota do homeserver (Vivo) até `api.deepseek.com` (CloudFront) é
intermitente — medido em 2026-09-25: 1/3 de connect, enquanto a estação de
trabalho (que sai pelo exit node `mail-relay` da tailnet) dava 3/3. Para não
depender dessa rota, o egress do app passa por um **proxy HTTP na estação**
(`100.94.122.26:3128`), que usa o exit node:
- **Onde roda:** container Docker `teqo-egress-proxy` na estação
(`--network host`, `--restart unless-stopped`, imagem `node:24-alpine`),
com o script `scripts/egress-proxy.mjs` deste repo (cópia operacional em
`~/teqo-egress-proxy/proxy.mjs`). Bind só na IP da tailnet e allowlist de
cliente = o homeserver (`100.119.220.31`); só túneis `CONNECT` para 443.
- **Como o app usa:** `NODE_USE_ENV_PROXY=1` + `HTTPS_PROXY=http://100.94.122.26:3128`
- `NO_PROXY` (internos) nos env files de produção e staging. O Node 24 honra
o proxy no `fetch()` — zero mudança de código.
- **Subir/atualizar:**
`docker run -d --name teqo-egress-proxy --restart unless-stopped --network host -e PROXY_HOST=100.94.122.26 -e PROXY_PORT=3128 -e PROXY_ALLOWED_CLIENTS=100.119.220.31,127.0.0.1,::1 -v ~/teqo-egress-proxy:/app:ro node:24-alpine node /app/proxy.mjs`
- **Limite conhecido:** a estação é ponto único — desligada, as capacidades
de IA degradam (fail-safe) e o probe de egress do deploy avisa. Se um dia o
`mail-relay` (sempre ligado) tiver acesso SSH, mover o proxy para lá é a
evolução natural.
- **Diagnóstico:** `docker logs teqo-egress-proxy`; do container,
`docker exec teqo-1313 node -e "fetch('https://api.deepseek.com/').then(r=>console.log(r.status)).catch(e=>console.log(e.cause?.code))"`.
Desde 2026-09-25 o `deploy-homeserver.sh` também **probe o egress de IA de
dentro do container** (após o healthcheck, antes do smoke) e emite
`::warning::` se `api.deepseek.com` não responder — não falha o deploy.
## OPS79 — última migração da plataforma antiga → nova (vertical campanha)
Operação de dados executada em 2026-08-23. Ver assistência lógica completa:
`docs/plans/ops79-ultima-migracao-dados-campanha.md` (intenção) e
`…campanha-impl.md` (impl). **A fonte (Neon, plataforma antiga) está congelada
desde o OPS80** — zero escritas desde 2026-08-23 22:30 UTC; a URL antiga segue
viva na Vercel apenas tecnicamente (desligamento de infra é o OPS81).
**Descoberta-chave da reconciliação:** a vertical campanha JÁ estava 100% íntegra
no `teo_1313` após o OPS51 (dump Neon→homeserver) — nenhum dado de operação de
campanha ficou órfão; o time de campanha não escreveu na URL antiga depois do
OPS51. O único residual real entre as plataformas era a `signature` pública
1486 (e seu contato/subscription), cujo contato originário **não existia** no
target (o id 2221 do target é OUTRA pessoa, Jorge Solla).
### Reconciliador (read-only, repetível)
Compara contagens + junta de IDs das 13 coleções da vertical e o conteúdo
semântico das join-tables de relações contra a fonte Neon.
```bash
ssh homeserver
cd ~/teqo-deploy
git fetch origin main && git checkout main && git pull --ff-only origin main
pnpm install
set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL
set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313)
# o host `postgres` só resolve dentro da rede stack_default; do host usa-se o
# proxy socat do build (127.0.0.1:5433):
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:reconcile
```
Esperado: `19/19 PASS` (a `municipality_rels` reporta `+3 absorvida pós-OPS51` —
edições de portfólio na plataforma nova, não-dado). Exit `1` em qualquer nova
divergência. Nunca imprime PII.
### Migração do residual (one-off, com guard de confirmação)
Copia a `signature` 1486 + seu contato + subscription (a transação que o
`submitPetitionSignature` faz junto), reusando petition/consent já presentes e
criando o contato com **id novo** (nunca o 2221, ocupado por outra pessoa).
```bash
# dry-run (valida referentes e imprime o plano sem escrever):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --dry-run
# aplica (ESCREVE em prod — exige a flag de intenção explícita):
OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --apply
```
Resultado registrado em 2026-08-23: contact novo **2225** (Juares Lagimar de
Souza), signature **1486** preservada (petition `fim-escala-6x1`, consent 2),
subscription **1492**; sequences ajustadas (`setval` com `true` → próximo id =
último+1). Sequências conferidas após: contact 2225 / signature 1486 /
subscription 1492. Pós-verificação: `signature` 1485=1485 (Neon×target) e
reconcile 19/19; smoke `/`, `/campanha`, `/admin` 200.
### Rollback
A migração é idempotente (fail-closed: aborta se referentes/ids divergirem). Se
precisar desfazer, DELETar as 3 linhas + ajustar sequences de volta:
`DELETE FROM signature WHERE id=1486; DELETE FROM subscription WHERE id=1492;
DELETE FROM contact_phones WHERE _parent_id=2225; DELETE FROM contact WHERE id=2225;`
e `setval('contact_id_seq',2224)`, `setval('signature_id_seq',1485)`,
`setval('subscription_id_seq',1491)`.
## OPS84 — Verificação de valores (não só entidades) da migração
Extensão do reconciliador OPS79: compara TODAS as colunas de conteúdo (não só
ids/contagens) das 13 tabelas da vertical campanha entre a fonte congelada
(Neon) e a base nova (`teqo_1313`). Foco confirmado em `vote_pledge` (votos
declarados + 3 cenários de estimativa).
**Resultado registrado em 2026-08-24:** 13/13 tabelas PASS — **0 divergências
de conteúdo**. A migração de valores está 100% íntegra. Neon e target com
dados idênticos em todas as 13 tabelas da vertical.
### Verificador de valores (read-only, repetível)
```bash
ssh homeserver
cd ~/teqo-deploy && git fetch origin main && git checkout main && git pull --ff-only origin main
pnpm install
set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL
set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313)
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
# Todas as 13 tabelas (foco em vote_pledge):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
pnpm ops84:reconcile-values
# Tabela isolada (debug/iteração):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
pnpm ops84:reconcile-values --table vote_pledge
# Com relatório markdown (anexável ao issue):
NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
pnpm ops84:reconcile-values --report /tmp/ops84-report.md
```
**Esperado:** `13/13 PASS` com 0 content diffs. Exit `1` se houver divergência
de conteúdo. Colunas classified em 3 buckets:
- **content** — falha o run (aceite: "0 divergência" ou "N listadas")
- **derived** — hook-derivadas (`declared_at/by`, `estimated_at/by`,
`created_at`, `updated_at`); informativas, não falham
- **sensitive** — PII; valores nunca impressos, só contados
Colunas jsonb comparadas via `col::text` (preserva `1` vs `1.0` como
divergência; SQL NULL ≠ JSON null). Zero normalização (trim/round/coalesce).
### Fallback: Neon inacessível (OPS81 já executado)
Se o Neon estiver inacessível (OPS81 desligou a infra), usar o dump do OPS51
como base de comparação:
```bash
# Restaurar dump em scratch DB local:
createdb teqo_1313_compare_src
pg_restore -d teqo_1313_compare_src \
/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump
# Apontar NEON_DATABASE_URL para o scratch:
NEON_DATABASE_URL="postgresql://teqo:teqo@localhost:5432/teqo_1313_compare_src" \
DATABASE_URL="$DATABASE_URL" \
pnpm ops84:reconcile-values --report /tmp/ops84-report-dump.md
```
**Nota:** dump é pré-freeze (2026-08-17) — deltas legítimos entre 08-17 e
08-23 (quando o Neon foi congelado) aparecem como divergências derivadas. O
relatório declara qual base foi usada.
### Rollback
Não há escrita; "rollback" = rodar de novo (idempotente, read-only).
## C155 — backfill do acervo de falas em produção
Operação de dados do catálogo de falas (`speech`/`speechSegment`, C153/C154):
processa as 54ª–57ª legislaturas (~1.011 discursos) contra o `teqo_1313` e
imprime um relatório por legislatura + a cobertura do banco. O import é
idempotente por `sourceKey` (reexecutar atualiza metadados e pula VOD/ASR do
que já tem segmentos), então o run é resumível — falha de rede em uma
legislatura não derruba as outras.
**Pré-requisitos:** o deploy que aplicou as migrations do catálogo
(`20260913_001112_add_speech_catalog`, `20260913_001200_add_speech_segment_trgm_index`,
`20260913_145820_add_speech_search_text`, `20260913_150000_backfill_speech_search_text_trgm_index`)
já está em produção desde o deploy de `424ee311` (2026-09-13); `DEEPINFRA_API_KEY`
no `~/stack/.env` do homeserver; `~/teqo-backfill` no SHA desejado com
`pnpm install`; espaço em `/srv/hdd/backups/teqo-camara` para o cache de MP4
(o cache do C153 foi descartado com o worktree; o backfill baixa de novo).
```bash
ssh homeserver
source ~/.nvm/nvm.sh # Node 24 (engines do repo)
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
set -a; source ~/stack/.env; set +a # DEEPINFRA_API_KEY
set -a; source ~/stack/teqo-1313.env; set +a # DATABASE_URL + NODE_ENV=production
# o host `postgres` só resolve na rede do stack; do host usa-se o proxy socat:
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export CAMARA_IMPORT_CONFIRM=1 # guard C155: escrita em produção
# smoke (4 discursos, ~2 min; repetir para provar idempotência: 0 criados/0 ASR):
pnpm camara:import --all --limit 1 --out /srv/hdd/backups/teqo-camara
# run completo (tmux; ~10–15h de parede, resumível):
tmux new -d -s c155 "pnpm camara:import --all --out /srv/hdd/backups/teqo-camara 2>&1 | tee -a /srv/hdd/backups/c155-run.log"
# validação pós-run (read-only):
pnpm camara:import --coverage --out /srv/hdd/backups/teqo-camara
pnpm camara:import --verify-links 3 --out /srv/hdd/backups/teqo-camara
```
O guard `CAMARA_IMPORT_CONFIRM=1` é exigido sempre que a escrita não é
provadamente local (`NODE_ENV=production`, host remoto ou `ALLOW_REMOTE_DB`);
`--coverage` e `--verify-links` são read-only e não exigem a flag. A escrita é
confinada a `speech`/`speechSegment`/rels; nenhum objeto de mídia vai para o
S3 (os MP4 ficam só no cache local). Crédito CC BY 4.0 da Câmara mantido no
admin/relatórios.
**Resultado registrado (2026-09-13/14):** **997 discursos** em produção
(54ª=1, 55ª=348, 56ª=414, 57ª=234) — 947 com trecho, 924 com vídeo, 920 com
segmentos; ASR 925 chamadas / 45,4h / ~US$1,23; LLM ~US$0,11; ~11h de máquina
(9h de runs + ~2h de retry por data). A 55ª veio pelo fallback de listagem
por ano/mês (a API responde 500 em **outubro/2018**; ~14 discursos ficaram
fora — reexecutar `pnpm camara:import --date <dia>` quando a fonte voltar).
O smoke idempotente (`--all --limit 1` duas vezes) provou 0 criados/0 ASR/0 LLM
na segunda passada; `--verify-links 3` deu 17/18 ok (1 warning transitório de
CDN). Relatórios completos em `/srv/hdd/backups/teqo-camara/reports/` e no
changelog `docs/changelog/2026-09-13-c155.md`.
### Rollback
O import é aditivo e idempotente; não há migração para desfazer. Para remover
uma legislatura (ex.: reimportar do zero), apagar os discursos e seus
segmentos — as rels (`speech_rels`, `speech_topics`, `speech_scopes`) caem por
cascade (o índice de sentido sai antes; a FK dos vetores é `SET NULL` sobre
coluna NOT NULL, então o SQL cru precisa apagá-los por conta própria):
```sql
DELETE FROM "speech_embedding" WHERE "speech_id" IN (SELECT id FROM "speech" WHERE "legislature" = '55');
DELETE FROM "speech_segment" WHERE "speech_id" IN (SELECT id FROM "speech" WHERE "legislature" = '55');
DELETE FROM "speech" WHERE "legislature" = '55';
```
Para desfazer o acervo inteiro: `DELETE FROM "speech_embedding"; DELETE FROM
"speech_segment"; DELETE FROM "speech";` (a cobertura `--coverage` volta a
zero). Reexecutar `--all` reconstrói, e `pnpm acervo:index` refaz o índice.
### C229 — índice de sentido do acervo
O modo **"Por tema"** do acervo (Câmara + internet) passou a ser similaridade
por embeddings (C229): as falas importadas precisam entrar no índice vetorial
antes de aparecerem nessa busca. O passo é manual, idempotente por hash de
conteúdo + modelo e deve ser rodado **depois** de qualquer import (C155/C225):
```bash
# no homeserver, com o env de produção carregado (DEEPINFRA_API_KEY)
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
set -a; source ~/stack/.env; set +a
set -a; source ~/stack/teqo-1313.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
# ensaio (read-only): quanto falta indexar e quantos trechos
pnpm acervo:index --dry-run
# indexação (write guard do C229)
ACERVO_INDEX_CONFIRM=1 pnpm acervo:index --source all
# calibração/verificação do aceite (read-only; imprime top-N com score)
pnpm acervo:index --probe "combate à oposição" --top 10
pnpm acervo:index --probe "impeachment" --top 10
```
O relatório JSON fica em `data/acervo-index/reports/` (gitignored) com
indexadas/puladas/falhas/trechos/tokens/custo. Os temas do aceite e 3–4 temas
de controle validam `SPEECH_SEMANTIC_MIN_COSINE` (`src/lib/speechSemantic.ts`);
registre os top-N e o valor final no changelog. Reexecutar só paga o que mudou.
## C225 — rodada de falas da internet no acervo de produção
A fonte **Falas na internet** do acervo (C216) já tem busca, player, transcrição e
origem, mas a produção só recebe fala web quando alguém executa esta operação.
A ingestão é a esteira do C215 (`pnpm falas-web:import`); a descoberta e a
curadoria são do C218. Esta seção é o procedimento manual, repetível e
reversível para rodar um lote contra `teqo_1313` e o bucket privado
`teqo-media`. O guard `FALAS_WEB_IMPORT_CONFIRM=1` continua sendo um ato
humano: nenhum agente, workflow ou skill o exporta.
**Estado desta entrega (2026-09-25):** o guard e este runbook foram entregues;
esta entrega não executou a rodada de produção. A data, o lote, os criados,
pulados, falhas, custos e o caminho dos relatórios são preenchidos por quem
operar depois do merge.
### Responsabilidades e limites
- A descoberta/curadoria pode ser feita na workstation, mas a cópia do lote e o
estado operacional devem terminar no diretório durável do homeserver. O
`last-run.json` do host é a fonte de verdade da próxima janela.
- O CLI é a única etapa que baixa, transcreve, classifica, espelha e deduplica.
Não crie cron, fila, collection de estado, migration ou uma segunda esteira.
- O acervo da Câmara (`origin = camara`, 997 discursos no baseline registrado em
2026-09-13/14) não é reprocessado por esta rodada.
- O download temporário pode existir durante o processamento em `/tmp`; o
destino da mídia espelhada é o bucket privado. O temp é removido pelo `finally`
do pipeline. Não use um diretório local como fila de upload.
- `internetSpeechMedia` continua privada e servida pelo acervo autenticado. Não
copie a URL do bucket para um canal público.
- Mídia acima de 2 GiB não passa pelo `fs.readFile` do Payload: o follow-up
C225-large cria a row com um placeholder e sobrescreve a mesma key por upload
multipart direto no S3. O teto técnico desta esteira é 5 GiB; acima disso a
falha fica em `pending`, sem fingir que a mídia foi espelhada.
### Pré-requisitos
1. O commit que contém C215, C216, C218, C223, C224 e C225 está em produção.
2. No homeserver há Node 24 (`source ~/.nvm/nvm.sh`), `pnpm`, acesso ao GitHub,
`flock`, `curl`, `psql` e espaço para o clone e os temporários do download.
3. `~/stack/.env` contém `DEEPINFRA_API_KEY` e `DEEPSEEK_API_KEY`; a primeira
chave faz a transcrição e a segunda a classificação. `~/stack/teqo-1313.env`
contém `DATABASE_URL`, `PAYLOAD_SECRET`, `NODE_ENV=production` e as quatro
variáveis `S3_*` do bucket. Não copie esses arquivos para o repositório.
4. O `yt-dlp` instalado funciona no host e tem `yt-dlp-ejs` disponível. A
resolução do CLI usa `YTDLP_PATH → PATH`; o CLI também valida `ffmpeg` e usa
o binário empacotado do projeto como fallback.
5. A pessoa responsável tem um arquivo de cookies Netscape revogável para as
plataformas que exigirem sessão. O arquivo nunca é commitado, impresso nem
enviado ao Issue.
### Workspace e estado durável
Use dois diretórios diferentes:
- `~/teqo-falas-web`: clone descartável, pinado no SHA que será executado; não é
o workspace do runner (`~/teqo-deploy`) e não guarda o watermark.
- `~/falas-web`: estado, lotes, descobertas, relatórios e logs duráveis da
operação, fora do clone e do diretório gitignored da workstation.
```bash
set -euo pipefail
export WORKTREE="$HOME/teqo-falas-web"
export FALAS_WEB_DIR="$HOME/falas-web"
export EXPECTED_SHA="<SHA-do-merge>"
install -d -m 700 "$FALAS_WEB_DIR" "$FALAS_WEB_DIR/reports" "$FALAS_WEB_DIR/runs"
if [ ! -d "$WORKTREE/.git" ]; then
git clone https://github.com/fsolla/teqo.git "$WORKTREE"
fi
cd "$WORKTREE"
git fetch origin
git checkout --detach "$EXPECTED_SHA"
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
pnpm install --frozen-lockfile
```
Se o C218 for executado dentro desse clone, aponte o diretório gitignored para o
estado durável uma única vez, sem apagar um diretório existente sem inspeção:
```bash
set -euo pipefail
if [ -L "$WORKTREE/data/falas-web" ]; then
test "$(readlink -f "$WORKTREE/data/falas-web")" = "$(readlink -f "$FALAS_WEB_DIR")"
elif [ -e "$WORKTREE/data/falas-web" ]; then
printf '%s\n' 'data/falas-web existe e não é o symlink durável esperado.' >&2
exit 1
else
mkdir -p "$WORKTREE/data"
ln -s "$FALAS_WEB_DIR" "$WORKTREE/data/falas-web"
fi
```
Se a descoberta ocorrer na workstation, copie para `~/falas-web` apenas o
artefato `discovery-*.json` e o lote `batch-*.json`. O `last-run.json` do host é
o dono do estado: não sobrescreva o watermark dele com uma cópia mais antiga da
workstation. Se o host ainda não tiver estado, a primeira rodada é a varredura
inicial; caso já exista, o operador incorpora somente o lote pendente ao estado
durável. Não use `data/falas-web` local da workstation como estado de produção.
O contrato do estado é o do C218: `lastRunAt` só avança depois de uma importação
com exit 0; falhas ficam em `pending` com `attempts`; itens duvidosos ficam em
`review`. A gravação é atômica (arquivo temporário + `mv`) e deve ocorrer por
último, depois de ler o relatório JSON do C215.
### Cookies e yt-dlp
O C215 não recebe cookies por argumento. Crie um wrapper fora do repo que
aponta para o binário real e habilita somente o runtime JavaScript disponível
na máquina. `--no-js-runtimes` evita que um Deno instalado, mas não preparado,
vença o Node; `--cookies` aplica a sessão somente à rodada. O arquivo deve ter
permissão `600` e ser removível sem tocar no clone.
```bash
set -euo pipefail
install -d -m 700 "$FALAS_WEB_DIR/bin"
YTDLP_REAL="$(command -v yt-dlp)"
test -n "$YTDLP_REAL"
test -r "$FALAS_WEB_DIR/cookies.txt"
cat > "$FALAS_WEB_DIR/bin/yt-dlp" <<EOF
#!/usr/bin/env bash
set -euo pipefail
exec "$YTDLP_REAL" --no-js-runtimes --js-runtimes node --cookies "$FALAS_WEB_DIR/cookies.txt" "\$@"
EOF
chmod 700 "$FALAS_WEB_DIR/bin/yt-dlp"
chmod 600 "$FALAS_WEB_DIR/cookies.txt"
export YTDLP_PATH="$FALAS_WEB_DIR/bin/yt-dlp"
```
O preflight usa uma URL real do lote, sem baixar o vídeo:
```bash
"$YTDLP_PATH" --dump-json --no-playlist --no-warnings --skip-download '<url-do-lote>'
```
A saída deve ser JSON e o log não deve indicar runtime JavaScript ausente,
`yt-dlp-ejs` ausente ou cookie inválido. Para revogar a sessão, apague apenas
`~/falas-web/cookies.txt` e `~/falas-web/bin/yt-dlp`; não apague o lote nem o
histórico de relatórios. A skill C218 continua sem receber `YTDLP_PATH`,
cookies ou a flag de produção.
### Sessão de produção e preflight
O env file preserva os hosts da rede do compose; a sessão no host precisa dos
endpoints loopback. Não edite `~/stack/teqo-1313.env`: reescreva as variáveis
somente depois de `source`.
```bash
source ~/.nvm/nvm.sh
cd "$WORKTREE"
set -a
source "$HOME/stack/.env"
source "$HOME/stack/teqo-1313.env"
set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export S3_ENDPOINT="${S3_ENDPOINT/host.docker.internal/127.0.0.1}"
export FALAS_WEB_DIR
export YTDLP_PATH="$FALAS_WEB_DIR/bin/yt-dlp"
```
Antes de qualquer escrita, confira o alvo exato, as dependências e o acesso ao bucket:
```bash
set -euo pipefail
test "$NODE_ENV" = production
case "$DATABASE_URL" in
*@127.0.0.1:5433/teqo_1313) ;;
*) printf '%s\n' 'DATABASE_URL não é o alvo de produção esperado.' >&2; exit 1 ;;
esac
test "${S3_ENDPOINT%/}" = 'http://127.0.0.1:3900'
test "$S3_BUCKET" = 'teqo-media'
for name in PAYLOAD_SECRET DEEPINFRA_API_KEY DEEPSEEK_API_KEY S3_BUCKET S3_ENDPOINT S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
test -n "${!name:-}" || { printf 'falta %s\n' "$name" >&2; exit 1; }
done
test "$(psql "$DATABASE_URL" -Atqc 'select current_database()')" = 'teqo_1313'
node --input-type=module <<'NODE'
import { HeadBucketCommand, S3Client } from '@aws-sdk/client-s3'
const client = new S3Client({
region: process.env.S3_REGION || 'garage',
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
},
})
await client.send(new HeadBucketCommand({ Bucket: process.env.S3_BUCKET }))
NODE
```
O `HeadBucket` precisa responder com sucesso; não imprima a resposta nem as
credenciais. O proxy de build `teqo-1313-build-proxy` em `127.0.0.1:5433` deve
existir. Se o preflight de download, o Postgres ou o Garage falhar, pare: não
troque o alvo para um banco remoto da workstation e não contorne as guardas.
### Rodada manual
Escolha um lote curado em `~/falas-web/batch-<stamp>.json`. A primeira execução
é um smoke; não rode a varredura inteira de uma vez. Mantenha o lock pelo
processo inteiro, inclusive leitura do relatório e gravação de `last-run.json`:
uma segunda invocação do C218 ou do import não pode começar enquanto este
shell estiver aberto.
```bash
set -euo pipefail
export RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"
exec 9>"$FALAS_WEB_DIR/.lock"
flock -n 9 || { printf '%s\n' 'já existe uma rodada web' >&2; exit 1; }
```
1. **Planejar, sem escrita e sem flag:**
```bash
export FINDINGS="$FALAS_WEB_DIR/batch-<stamp>.json"
pnpm falas-web:import --findings "$FINDINGS" --out "$FALAS_WEB_DIR" --dry-run
```
Leia o relatório em `~/falas-web/reports/`. `invalid` e `duplicates` devem
ser zero. Confira cada `plannedEntries[].sourceKey` e `action`; um `create`
para uma URL que já entrou exige corrigir a curadoria antes de ingerir.
2. **Smoke, dentro do lock:**
```bash
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import \
--findings "$FINDINGS" --limit 1 --out "$FALAS_WEB_DIR" 2>&1 |
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
:
else
exit 1
fi
```
O relatório deve mostrar a entrada processada, o estágio e o resultado. A
mídia espelhada deve aparecer no bucket `teqo-media`; o player e a rota de
mídia do acervo são a prova de leitura, não uma URL pública. A cauda do lote
permanece no próprio `batch-<stamp>.json`.
3. **Lote curado completo:** rode o mesmo comando sem `--limit` e no mesmo lock:
```bash
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import \
--findings "$FINDINGS" --out "$FALAS_WEB_DIR" 2>&1 |
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
:
else
exit 1
fi
```
Exit 0 pode representar sucesso total ou parcial; leia `created`, `updated`,
`skipped`, `failures`, `asrSeconds`, `asrCostUsd`, `llmCostUsd` e
`durationMs` no JSON. Exit 1 significa que nenhum achado entrou e exige
retentativa, não um recibo verde.
4. **Prova de idempotência:** repita o lote completo sem `--reprocess`, ainda
com o lock:
```bash
if FALAS_WEB_IMPORT_CONFIRM=1 pnpm falas-web:import --findings "$FINDINGS" --out "$FALAS_WEB_DIR" 2>&1 |
tee -a "$FALAS_WEB_DIR/runs/$RUN_ID.log"; then
:
else
exit 1
fi
```
O relatório deve mostrar `skipped` para itens completos, nenhum download/ASR
novo e nenhum objeto adicional no bucket. A segunda rodada deve usar a
mesma identidade `web:<plataforma>:<externalId|url canônica>`; não use uma
URL alternativa para "forçar" o resultado.
5. **Inventário e fonte da rodada:** registre o relatório, o lote e o estado em
`~/falas-web`; atualize `last-run.json` por último. Uma falha vira `pending`
com o estágio e a tentativa, e só uma importação com exit 0 avança
`lastRunAt`. Não marque a janela como coberta por memória da sessão. Mantenha
o FD 9 aberto até essa gravação; só depois execute `exec 9>&-`.
6. **Ensaio de rollback:** antes de encerrar a primeira rodada, escolha uma
fala de teste, registre o `id`/`sourceKey`, peça confirmação humana para a
exclusão e remova pelo admin. Confirme no inventário e no Garage que a fala,
os segmentos e o objeto espelhado sumiram. Remova o `sourceKey` de qualquer
`pending`/`review` que ainda o referencie. Se essa prova não for feita, a
rodada fica bloqueada para aprovação operacional.
7. **Índice de sentido (C229):** depois do import, rode o passo do C229
(`ACERVO_INDEX_CONFIRM=1 pnpm acervo:index --source web`, ou `--source all`
para pegar as duas origens). Sem ele, as falas novas não aparecem na busca
"Por tema"; o `--probe` confere o aceite e o relatório traz tokens/custo.
O passo está detalhado na seção "C229 — índice de sentido do acervo".
### Inventário read-only
As consultas abaixo não escrevem no banco:
```bash
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
SELECT origin, platform, count(*) AS total
FROM speech
GROUP BY origin, platform
ORDER BY origin, platform;
SELECT id, "sourceKey", "speechAt", platform, origin
FROM speech
WHERE origin = 'web'
ORDER BY "speechAt" DESC;
SELECT count(*) AS web_total,
count(*) FILTER (WHERE "mirroredMedia" IS NOT NULL) AS web_with_media
FROM speech
WHERE origin = 'web';
SQL
```
Compare a contagem de `origin = 'camara'` com o baseline antes da rodada e
confirme que ela não diminuiu. Para o bucket, use o cliente S3/Garage aprovado
pela operação e confira o objeto pelo identificador da mídia, sem imprimir a
chave de acesso ou o segredo. Depois abra
`/campanha/comunicacao/acervo` → **Falas na internet** com uma sessão de
campanha e confirme player, transcrição, data e origem de uma fala da rodada.
### Rollback
Não copie o `DELETE` SQL do C155 para esta entrega. A remoção de uma fala web
deve passar pelo Payload, porque o hook `deleteSpeechAssets` remove segmentos e
registros de `internetSpeechMedia`, e o plugin de storage remove o objeto do
bucket:
1. Faça o inventário read-only e anote o `id`/`sourceKey` exato.
2. No admin autenticado, exclua uma fala `origin = web` da rodada de teste.
3. Confirme no inventário que a linha e os segmentos sumiram e, no Garage,
que o objeto associado sumiu.
4. Repita para outras falas somente com identificação e confirmação humanas.
Não apague diretamente a tabela `speech`, `speech_segment` ou
`internet_speech_media`; isso pode deixar relações e objetos órfãos. Remoção em
massa é uma operação destrutiva nova e precisa de item, flag, teste e runbook
próprios.
### Falhas e decisões
- **Runtime JS/YouTube:** o wrapper usa Node; se o binário não tiver
`yt-dlp-ejs`, a aquisição falha no estágio `acquisition`. Atualize o yt-dlp e
reinstale o componente pela via oficial; não coloque arquivos remotos no repo.
- **Cookies expirados:** a falha fica no relatório e em `pending`; atualize o
arquivo revogável e repita o lote. Nunca registre o conteúdo do cookie.
- **Egress do homeserver:** a rota para APIs/CDNs pode ser intermitente. O
preflight é um gate: se `--dump-json` ou o Garage falhar, a rodada não começa.
Não silencie a falha nem troque o banco de produção por um alvo remoto.
- **Mídia acima de 2 GiB:** o código anterior falhava no `fs.readFile` do Node
antes de chegar ao S3. O follow-up C225-large usa upload multipart e uma row
placeholder; confirme no relatório que a etapa `media` terminou e que o objeto
tem o tamanho real. O teto desta implementação é 5 GiB.
- **S3 ausente ou endpoint errado:** o novo guard recusa escrita não-local sem
as quatro `S3_*`; um erro de conectividade aparece no relatório. O env file do
container não deve ser editado para acomodar o host.
- **Espaço ou lock:** o `flock` serializa rodadas; um lock ocupado encerra o
comando. O diretório do clone e o diretório durável precisam de espaço para
dependências, lotes, relatórios e o temporário de cada download.
- **Fim da rodada:** registrar o resultado medido no changelog e o
`last-run.json` durável é parte da operação, não uma tarefa opcional.
### Resultado registrado
Preencher após uma execução real, sem apagar o histórico anterior:
```text
data/hora:
lote:
sourceKeys/resultados:
criados/atualizados/pulados/falhas:
custo/tempo:
bucket e estado durável:
idempotência:
falhas/pendências:
```
O resultado da primeira rodada e a prova da segunda são a evidência de que a
fonte web foi realmente carregada em produção; o merge deste runbook, sozinho,
não representa uma rodada concluída.
## C160 — reparo do link oficial do acervo (PDF direto do Diário)
Operação de dados do acervo de falas (C153/C154/C155): troca o
`officialTextUrl` legado (`dc_20b.asp`/`dc_20.asp`/`montaPdf.asp`, que pendura
no browser) pelo PDF direto do Diário
(`https://imagem.camara.leg.br/Imagem/d/pdf/<arquivo>#page=<página>`). É
script-only (nenhuma migration): resolve uma vez por publicação
(`<out>/diario/`), é idempotente e reporta cobertura (falas/datas atualizadas,
falhas e legado restante).
**Pré-requisitos:** o SHA do branch/commit com o C160 (a passada original rodou
de `~/teqo-backfill` no SHA `766e4ae9`, 2026-09-14); `~/stack/.env` +
`~/stack/teqo-1313.env`; `~/teqo-backfill` com `pnpm install` no SHA. O
`git fetch` do homeserver alcança o GitHub pelo bundle/com o remote disponível
(na passada original o clone apontava para repo local e usou-se `git fetch
~/c160.bundle <branch>`).
```bash
ssh homeserver
source ~/.nvm/nvm.sh
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
set -a; source ~/stack/.env; set +a
set -a; source ~/stack/teqo-1313.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export CAMARA_IMPORT_CONFIRM=1 # guard C160: escrita em produção
# dry-run (read-only; aquece o cache por publicação em <out>/diario):
pnpm camara:import --repair-links --dry-run --out /srv/hdd/backups/teqo-camara
# escrita (idempotente; 2ª passada = 0 atualizações):
pnpm camara:import --repair-links --out /srv/hdd/backups/teqo-camara
# validação (0 legadas restantes):
pnpm camara:import --repair-links --dry-run --out /srv/hdd/backups/teqo-camara
```
O guard `CAMARA_IMPORT_CONFIRM=1` é exigido sempre que a escrita não é
provadamente local (`NODE_ENV=production`, host remoto ou `ALLOW_REMOTE_DB`);
`--dry-run` é read-only e não exige a flag. A escrita é confinada a
`speech.officialTextUrl`; VOD, transcrições e segmentos não são tocados.
**Comportamento medido da Câmara (2026-09-14):** o legado `.gov.br` responde
302 para `.leg.br` (hop lento, 25–135s) e o `.leg.br` responde 302 para
`montaPdf.asp?narquivo=…&npagina=…` (~5s); o PDF direto responde 200/206
`application/pdf` em <1s. O resolver reescreve `.gov.br`→`.leg.br` no lookup,
segue o redirect e parseia o `montaPdf`; a resolução é cacheada uma vez por
publicação (`diario/<data>-<coleção>[-s<suplemento>].json`) e cada PDF é
provado com GET Range antes de gravar. Falha de resolução deixa a fala
intocada, o run sai 1 e a próxima passada é idempotente.
**Resultado registrado (2026-09-14):** dry-run achou 965 falas legadas em 504
publicações (33 em `selCodColecaoCsv=J` e 1 com o parâmetro vazio, normalizadas
para `D`; a Câmara devolve "Documento não encontrado" nos códigos obsoletos e a
mesma data/página resolve em `D`) + 32 falas sem link (fallback
YouTube/oculto); resolução cacheada das 504 publicações (retries absorvem
`fetch failed` transiente); o write atualizou **965 falas** em 58s
(`remainingLegacy: 0`, 0 falhas) e a validação read-only deu 965 diretas/0
legadas com o `--coverage` inalterado (997 discursos). Relatórios em
`/srv/hdd/backups/teqo-camara/reports/repair-links-*.json` e changelog
`docs/changelog/2026-09-14-c160.md`.
### Rollback
Não há migração; o reparo só reescreve `speech.officialTextUrl` e o valor
anterior de cada fala está em `updates[].previousUrl` no JSON do run. O import
(`pnpm camara:import --date <dia>`) reconstrói o link a partir da API (a fonte
é a URL legada devolvida pela Câmara); reexecutar o reparo é idempotente.
## C163 — relatório de cidade — mobilização do 2º turno (leitura read-only)
O gerador de PDF por cidade (`/work-issue` C163) extrai um snapshot da **base
de produção** sem escrever nada: a sessão Postgres é aberta com
`default_transaction_read_only=on` (options da connection string, aplicada pelo
próprio `scripts/extract-city-report-snapshot.mjs`) e o render acontece fora do
homeserver. Nenhum artefato é persistido na base; o snapshot e o PDF/MD são
gitignored (`data/relatorios-cidade/`, `docs/research/relatorios-cidade/`).
Fluxo completo e contrato dos JSONs: skill `.agents/skills/relatorio-cidade`.
Receita do homeserver (checkout de scratch, **nunca** o `~/teqo-deploy` do
deploy em andamento):
```bash
ssh homeserver
source ~/.nvm/nvm.sh # Node 24 (engines do repo)
test -d ~/teqo-report || git clone https://github.com/fsolla/teqo.git ~/teqo-report
cd ~/teqo-report
git fetch origin main && git checkout main && git pull --ff-only origin main
# tsx vem do install completo (o deploy usa --prod e não traz devDeps):
[ -d node_modules/tsx ] || pnpm install --prod=false
set -a; source ~/stack/teqo-1313.env; set +a
# o host `postgres` só resolve na rede do stack; do host usa-se o proxy socat:
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export CITY_REPORT_CONFIRM=1 # guard de intenção explícita
NODE_OPTIONS="--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs" \
node scripts/extract-city-report-snapshot.mjs \
--municipality=<slug> --out=data/relatorios-cidade/<slug>.snapshot.json
```
Depois, na workstation: `scp homeserver:~/teqo-report/data/relatorios-cidade/<slug>.snapshot.json data/relatorios-cidade/`
e `node scripts/build-city-report.mjs --snapshot=... --research=...`. O snapshot
carrega `readAt` + SHA do código — o PDF é datado por construção.
Guardas: sem `CITY_REPORT_CONFIRM=1` o extrator recusa; a URL com `options`
read-only é aplicada antes do Payload subir (qualquer write estoura no servidor);
o builder local não chama API externa (os números oficiais do 2º turno vêm do
artefato commitado). Nunca aponte o extrator para o banco local esperando
dados de produção — o caminho é o proxy do homeserver.
## C195 — ingestão do pacote do reel na biblioteca privada
O comando `pnpm reels:ingest <diretório>` publica um pacote de reel produzido
fora do site (`metadata.json` + `reel.mp4` + `capa.png` + `narracao.srt` e
opcionais `reel-audio.mp4`/`narracao.mp3`/`roteiro.md`) na biblioteca privada do
`/campanha` pela Local API. Roda **na imagem de manutenção do ambiente** (o
pacote chega por rsync; nenhuma credencial de campanha vai para a workstation) e
é **idempotente pelo hash do shot list**: re-ingerir o mesmo pacote atualiza a
mesma entrada e preserva o status (nunca ressuscita um reel despublicado).
O caminho é **staging primeiro**, sempre. O dry-run é o padrão e não escreve:
```bash
# da workstation — copia o pacote para o homeserver:
rsync -av /caminho/do/pacote/ homeserver:/srv/reels/<slug>/
# no homeserver:
ssh homeserver
cd ~/stack
# 1) conferência (dry-run; mostra alvo, operação, artefatos e falha se o pacote estiver incompleto):
docker compose --profile maintenance run --rm \
-v /srv/reels/<slug>:/pkg:ro \
teqo-staging-migrate pnpm reels:ingest /pkg
# 2) escrita no staging (exige TEQO_ENV + confirmação; o par é fail-closed):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=staging -e REELS_INGEST_CONFIRM=1 \
-v /srv/reels/<slug>:/pkg:ro \
teqo-staging-migrate pnpm reels:ingest /pkg --apply
# 3) validação no staging (biblioteca/painel) e só então produção, com TEQO_ENV=production
# e o serviço teqo-1313-migrate.
```
Guardas: fora de teste o `TEQO_ENV=staging|production` é obrigatório e o
**nome exato** do banco é o discriminador (`teqo_staging`/`teqo_1313` — o host do
socat é `127.0.0.1` nos dois, então host não distingue); `ALLOW_REMOTE_DB` é
recusado; sem as 4 `S3_*` o comando recusa (o container gravaria em disco
efêmero); `--apply` sem `REELS_INGEST_CONFIRM=1` recusa. Falha no meio não deixa
entrada parcial: os documentos vão numa transação e a publicação é a última
escrita (objeto órfão no bucket é o único resíduo; o nome determinístico faz o
re-run sobrescrevê-lo). O plano de implementação completo vive em
`docs/plans/reels-ingestao-impl.md`; a migration `add_reel_source_hash` é
aditiva e aplicada pelo deploy.
### Rollback
Sem migração para desfazer. O rollback funcional é o kill switch: despublicar o
reel no admin tira da biblioteca e para de servir os arquivos. Para remover de
vez, apagar o `reel` (e as `reelMedia` órfãs) no admin; re-ingerir recria a
entrada (as mesmas chaves de objeto são sobrescritas).
## C231 — ingestão do acervo de fotos do Flickr
O comando `pnpm flickr:import` traz as fotos da conta `depjorgesolla` para o
acervo privado (`archivePhoto`): listagem + metadados pela API oficial e o
original de cada foto no bucket. A identidade é o **id da foto no Flickr** —
reexecutar converge ("novas / já existiam / falharam com motivo") e nunca
duplica; vídeos do photostream são contados e pulados. Nada é publicado e nada
no Flickr é tocado.
Chave e conta vêm do env do stack (`FLICKR_API_KEY`, `FLICKR_USER_ID` — nunca no
repo). O dry-run é o padrão e não escreve:
```bash
# no homeserver:
ssh homeserver
cd ~/stack
# 1) plano (dry-run; lê a API e o banco, não baixa nem escreve):
docker compose --profile maintenance run --rm \
teqo-staging-migrate pnpm flickr:import --limit 5
# 2) escrita no staging (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=staging -e FLICKR_IMPORT_CONFIRM=1 \
teqo-staging-migrate pnpm flickr:import --apply --limit 5
# 3) validação no staging e só então produção (TEQO_ENV=production, serviço teqo-1313-migrate)
# 4) inventário a qualquer momento (read-only, sem Flickr):
docker compose --profile maintenance run --rm \
teqo-staging-migrate pnpm flickr:import --verify
```
Os recibos JSON ficam em `data/flickr/reports/` do container — monte um volume
(`-v $HOME/flickr-reports:/app/data/flickr`) para preservá-los. Guardas: fora de
teste o `TEQO_ENV=staging|production` é obrigatório com o **nome exato** do
banco (`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4
`S3_*` o `--apply` recusa (o container gravaria em disco efêmero); e
`FLICKR_IMPORT_CONFIRM=1` é exigido em alvo não-local. A chave da API nunca
entra em recibo/log.
### Backfill de EXIF (`--refresh-metadata`)
Fotos ingeridas antes da correção do mapper gravaram `exif: []` (a API devolve
`raw`/`clean` como `{ _content }`). O modo re-busca o EXIF das linhas
existentes, atualiza só o que mudou e escreve apenas no banco — exige
`FLICKR_API_KEY` e as guardas de escrita, mas **não** exige S3:
```bash
# no homeserver (canary e depois o lote completo):
docker compose --profile maintenance run --rm \
-v $HOME/flickr-reports:/app/data/flickr \
-e TEQO_ENV=production -e FLICKR_IMPORT_CONFIRM=1 \
teqo-1313-migrate pnpm flickr:import --refresh-metadata --limit 5
docker compose --profile maintenance run --rm \
-v $HOME/flickr-reports:/app/data/flickr \
-e TEQO_ENV=production -e FLICKR_IMPORT_CONFIRM=1 \
teqo-1313-migrate pnpm flickr:import --refresh-metadata
```
O recibo (`flickr-*-refresh.json`) conta atualizadas/inalteradas/falhas com
motivo; o `--verify` mostra a cobertura (`EXIF: N`). O modo é idempotente:
reexecutar converge e linhas inalteradas não são reescritas.
Rollback: a migration `add_archive_photo` é aditiva. O rollback funcional é
apagar as linhas do `archivePhoto` no admin (o objeto correspondente no bucket
pode ser removido à parte); reexecutar a ingestão recria as mesmas chaves de
objeto.
## C232 — catalogação com IA do acervo de fotos
O comando `pnpm archive:catalog` pré-cataloga as fotos do acervo (`archivePhoto`)
com um engine de visão LOCAL: legenda/descrição, atividade/cena, texto visível,
temas do vocabulário existente, município (gazetteer) e pessoas públicas (só do
catálogo curado, lidas do TEXTO — nunca de rosto). A curadoria da assessoria
vence: campo editado no admin entra em "Campos curados" e a catalogação nunca
sobrescreve; reexecutar converge ("processadas / puladas / falharam com motivo")
e só as pendentes são processadas. Nada é publicado.
O engine é qualquer servidor OpenAI-compatible na **rede privada** (Ollama,
llama.cpp, vLLM). Exemplo: servir o modelo na workstation com GPU (o mesmo padrão
do ML do Immich no tailnet) e apontar o container para lá:
```bash
# na workstation (GPU), ex. com Ollama:
ollama serve # porta 11434, alcançável pelo tailnet
ollama pull qwen2.5vl:7b
# no homeserver:
ssh homeserver
cd ~/stack
# 0) plano (dry-run; lê a fila, não chama o engine nem escreve):
docker compose --profile maintenance run --rm \
-e ARCHIVE_VISION_BASE_URL=http://100.94.122.26:11434/v1 \
-e ARCHIVE_VISION_MODEL=qwen2.5vl:7b \
teqo-staging-migrate pnpm archive:catalog --limit 5
# 1) canário no staging (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=staging -e ARCHIVE_CATALOG_CONFIRM=1 \
-e ARCHIVE_VISION_BASE_URL=http://100.94.122.26:11434/v1 \
-e ARCHIVE_VISION_MODEL=qwen2.5vl:7b \
teqo-staging-migrate pnpm archive:catalog --apply --limit 5
# 2) validação no staging e só então produção (TEQO_ENV=production, serviço teqo-1313-migrate)
# 3) inventário a qualquer momento (read-only, sem engine):
docker compose --profile maintenance run --rm \
teqo-staging-migrate pnpm archive:catalog --verify
```
Os recibos JSON ficam em `data/archive/reports/` do container — monte um volume
(`-v ~/archive-reports:/app/data/archive`) para preservá-los. Guardas: fora de
teste o `TEQO_ENV=staging|production` é obrigatório com o **nome exato** do banco
(`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4 `S3_*` o
`--apply` recusa (o acervo não estaria acessível no alvo); `ARCHIVE_CATALOG_CONFIRM=1`
é exigido em alvo não-local; e o endpoint do engine precisa estar na rede privada
— host público é recusado e exige `ARCHIVE_VISION_ALLOW_REMOTE=1` explícito
(registrado no recibo com host/modelo e o escopo). Falha por foto é isolada e
nomeada (nada é escrito; a próxima execução retenta); o lote de 6,5k é sequencial
(~4–10 h). A chave do engine (quando houver) nunca entra em recibo/log.
Rollback: a migration `add_archive_photo_catalog` é aditiva; o rollback funcional
é limpar o grupo `catalog`/`curatedFields` no admin. Reexecutar nunca duplica nem
sobrescreve curadoria.
## C245 — catalogação em produção: camada de metadados primeiro
O mesmo comando roda em **duas camadas independentes** (migration aditiva
`add_archive_photo_metadata_checked`, coluna `catalog.metadataCheckedAt`):
- **metadados** (`--metadata-only`): sem engine e sem mídia — lê o texto já
ingerido (título, descrição, álbuns, tags) e deriva **município** (gazetteer)
e **temas**. Grava `catalog.metadataCheckedAt` e **não** preenche
`catalogedAt`, então a foto continua na fila da IA. Nunca escreve
`catalog.people` (a faceta de pessoa é da C244, por facial).
- **IA** (padrão, seção acima): legenda/descrição/atividade/texto visível;
quando roda, marca `catalogedAt` **e** `metadataCheckedAt` (a derivação de
texto roda nessa passada).
Ordem em produção (staging primeiro):
```bash
# 1) canário de metadados no staging (sem engine, sem S3):
ssh homeserver && cd ~/stack
docker compose --profile maintenance run --rm \
-e TEQO_ENV=staging -e ARCHIVE_CATALOG_CONFIRM=1 \
teqo-staging-migrate pnpm archive:catalog --metadata-only --apply --limit 5
# 2) lote de metadados no staging e, validado, em produção (serviço/banco do alvo):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_CATALOG_CONFIRM=1 \
teqo-1313-migrate pnpm archive:catalog --metadata-only --apply
# 3) depois de cada lote, buste o cache do álbum: o write roda no container de
# manutenção e o ISR do app não é revalidado por ele.
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
# 4) inventário das DUAS camadas (read-only; sai 1 se a camada selecionada
# tiver pendentes):
docker compose --profile maintenance run --rm teqo-1313-migrate pnpm archive:catalog --verify
docker compose --profile maintenance run --rm teqo-1313-migrate pnpm archive:catalog --metadata-only --verify
```
A camada de IA não muda os comandos da seção C232: com o engine no ar, o
`--apply` padrão processa toda foto sem `catalogedAt` (as já marcadas pelos
metadados inclusive) e preserva o município/temas gravados; depois do lote,
repita o passo 3.
Guardas: `--metadata-only --apply` exige `ARCHIVE_CATALOG_CONFIRM=1` fora de
dev local e `TEQO_ENV` casando o nome exato do banco (`teqo_staging`/
`teqo_1313`) — **não** exige `S3_*` nem engine (não há mídia no caminho). O
recibo JSON registra `layer` e o `--verify` sempre imprime as duas camadas; o
exit 1 segue a camada selecionada.
Rollback: a migration `add_archive_photo_metadata_checked` é aditiva
(`catalog.metadataCheckedAt` + índice); o rollback funcional é anular o marker
via API/DB (o campo é `readOnly` no admin) e reexecutar. Reexecutar converge:
foto com `metadataCheckedAt` não é reprocessada pela camada pura, a IA continua
pegando quem não tem `catalogedAt` e a curadoria nunca é sobrescrita.
## C242 — busca por selfie aberta (índice anônimo do acervo)
A busca por selfie (`/fotos/encontre` + `POST /api/fotos/selfie`) cobre **qualquer
visitante**: o índice é anônimo (foto + descritor 128-d em `archivePhotoFace`),
sem nome, sem vínculo com pessoa e sem adesão prévia. A selfie nunca sai do
aparelho do visitante (o navegador envia só o vetor). A flag
`photoAlbum.selfieSearchEnabled` nasce `false` e é o kill switch imediato.
### Abertura (ordem obrigatória)
1. **Canal de remoção** no admin (`/admin` → Configurações → Álbum de fotos):
`https://` ou `mailto:` válido. Sem ele o álbum não publica e o lote de
aprovação recusa.
2. **Consentimentos** (textos versionados em `scripts/lib/faceConsentTexts.mjs`):
no serviço de manutenção do alvo, com o env file do stack:
```bash
cd ~/stack
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e FACE_CONSENT_SEED_CONFIRM=1 \
teqo-1313-migrate pnpm seed:face-consents --apply
```
(dry-run sem `--apply` mostra o estado; o guard exige `TEQO_ENV` casando o
banco exato.)
3. **Aprovar o acervo** (drafts → approved; `removed` nunca é tocado). Desde o
C249 cada draft passa pelo **preflight de integridade** do C246 antes de
aprovar: o objeto é baixado e decodificado pelo mesmo caminho da rota
pública; objeto ausente/corrompido fica **fora do lote**, é nomeado no recibo
(`skippedBroken` com stage + motivo) e o comando sai **1**. O modo plano
(sem `--apply`) também inspeciona (read-only) e sai 1 se houver quebrados; o
custo do lote é download+decode de toda a fila, então comece pelo canário:
```bash
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \
teqo-1313-migrate pnpm archive:publish --apply --limit 20 # canário
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \
teqo-1313-migrate pnpm archive:publish --apply
```
Ao fim, buste a tag pública em um processo que a possui (o write do CLI não
revalida o servidor Next):
```bash
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
```
4. **Indexar** (horas; o engine roda local no maintenance, sem endpoint externo):
```bash
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 \
-v /srv/face-reports:/app/data/face/reports \
teqo-1313-migrate pnpm faces:index --apply --limit 20 # canário
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 \
-v /srv/face-reports:/app/data/face/reports \
teqo-1313-migrate pnpm faces:index --apply # lote completo
docker compose --profile maintenance run --rm \
-v /srv/face-reports:/app/data/face/reports \
teqo-1313-migrate pnpm faces:index --verify # read-only; sai 1 com pendências
```
A busca por selfie reflete a indexação em até ~60 s (cache em memória do
índice). A faceta "Pessoa pública" do C244 lê um mapa cacheado pela tag
`archivePhotos`, que o lote não busta sozinho: buste a tag também depois da
indexação (mesmo `curl` do fim do passo 3).
5. **Ligar** `Busca por selfie` no global Álbum de fotos. Caminho principal pela
CLI (C247; grava pelo Local API e busta a tag `global_photoAlbum` no servidor
vivo na mesma execução — o write fora do Next não revalidaria sozinho):
```bash
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e OPS_GLOBAL_CONFIRM=1 \
-v /srv/ops-global-reports:/app/data/ops-global \
teqo-1313-migrate pnpm ops:global photoAlbum --selfie-search on --apply
# rollback: --selfie-search off --apply (o recibo em data/ops-global/reports/
# carrega o valor anterior e o comando de rollback)
```
O admin continua valendo (`/admin` → Configurações → Álbum de fotos). Sem
deploy; desligar fecha a página (404), a API e a seção da home imediatamente.
### Remoção e privacidade
- **Sair do índice**: a própria pessoa usa “Minha presença” no fluxo (selfie
confere; os descritores correspondentes são apagados e o rosto deixa de ser
encontrado). O pedido de **remoção de foto** continua no canal do álbum.
- **Desaprovar/remover foto**: os descritores daquela foto são apagados pelos
hooks da collection (e o read aprovado nunca os mostra).
- **Rollback**: desligue a flag; a migration `add_archive_photo_face` é aditiva,
exceto o drop da `face_subject` (tabela vazia em produção).
### Guardas e recibos
`--apply` exige `TEQO_ENV` casando o banco exato e a flag de confirmação do
comando (`FACE_INDEX_CONFIRM`/`ARCHIVE_PUBLISH_CONFIRM`/`FACE_CONSENT_SEED_CONFIRM`),
mais as quatro `S3_*` quando a mídia for remota (indexação). Recibos JSON em
`data/face/reports/` e `data/archive/reports/` (nunca levam vetor nem bytes).
## C244 — filtro "Pessoa pública" por reconhecimento facial no álbum
A faceta `pessoa` do álbum (`/fotos?pessoa=<slug>`) e a linha "Quem aparece"
deixam de sair do texto `catalog.people` da ficha: passam a listar **apenas as
figuras públicas do catálogo curado** reconhecidas nas fotos pelo índice facial
do C242. A identificação biométrica é permitida só para esse catálogo (aval
jurídico registrado na decisão do dono de 2026-10-01); qualquer rosto fora dele
continua anônimo e nunca é nomeado. Não há score em nenhuma superfície.
A curadoria vive na collection `faceFigure` (admin-only, grupo Comunicação):
uma linha por figura (nome público, slug = `?pessoa=<slug>`, `active`) com 1–8
descritores de referência (retratos oficiais/arquivo, `source` de proveniência).
O match é **derivado em leitura** contra o índice existente (nada é
reprocessado: o vínculo não pode divergir do índice) e cacheado sob a tag
`archivePhotos`. Sem a linha do aviso (`busca-selfie-indice`) o filtro fica
**fechado** (fail-closed).
### Abertura (ordem obrigatória)
1. **Re-seed do aviso PRIMEIRO** — o texto do C242 foi atualizado para declarar
o filtro de figuras curadas; um enrollment antes disso deixaria o aviso
público em produção desatualizado:
```bash
cd ~/stack
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e FACE_CONSENT_SEED_CONFIRM=1 \
teqo-1313-migrate pnpm seed:face-consents --apply
```
Confira o texto novo em `/fotos/encontre` (o parágrafo do catálogo curado).
2. **Catálogo inicial** (58 figuras: as 6 nomeadas + o roster S30), sem
referências — figura sem referência nunca aparece:
```bash
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e FACE_FIGURE_SEED_CONFIRM=1 \
teqo-1313-migrate pnpm seed:face-figures --apply
```
3. **Enrolar as referências** de cada figura (1–3 retratos oficiais/arquivo por
pessoa; exatamente 1 rosto por imagem, senão o comando recusa):
```bash
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e FACE_FIGURE_CONFIRM=1 \
-v /srv/figuras:/app/figuras \
teqo-1313-migrate pnpm faces:enroll-figure \
--figure lula --name "Lula" --full-name "Luiz Inácio Lula da Silva" \
--source "retrato oficial 2023" --image /app/figuras/lula-1.jpg --apply
```
`--replace` substitui as referências existentes; rodar de novo com a mesma
imagem não duplica (dedupe por vetor). Plan/dry-run é o default (sem engine).
4. **Bust da tag** (o CLI roda fora do Next):
```bash
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
```
5. **Conferir** no site: a faceta lista só figuras com foto reconhecida; a
grade de `?pessoa=<slug>` e "Quem aparece" mostram só figuras curadas.
Nenhum terceiro é nomeado em nenhum estado.
### Revisão de falsos positivos (por exceção)
O limiar é o mesmo do C240 (0,45, conservador). Ao encontrar um falso positivo
na revisão por exceção: **desative a figura** ou **remova a referência ruim** no
admin (`Comunicação → Figuras públicas`; o hook revalida o álbum na hora) e
registre o caso. Falso positivo é débito de curadoria, nunca dado.
### Rollback
Sem deploy: desative as figuras (checkbox `Ativa`) ou remova as referências no
admin — o filtro esvazia imediatamente. A migration `add_face_figure` é
aditiva; o rollback de código pode deixar a tabela (inofensiva). Desligar o
álbum (`photoAlbum.published = false`) fecha tudo.
### Guardas e recibos
`--apply` do seed/enrollment exige `TEQO_ENV` casando o banco exato e a flag do
comando (`FACE_FIGURE_SEED_CONFIRM`/`FACE_FIGURE_CONFIRM`). As imagens de
referência são locais (nunca entram no repo nem no S3); recibos JSON em
`data/face/reports/` sem vetor nem bytes.
## C246 — integridade do acervo no Garage (fotos quebradas)
O comando `pnpm archive:integrity` varre os originais do acervo (`archivePhoto`):
baixa **cada** objeto pelo mesmo caminho da rota pública e do índice facial (o SDK
S3 valida o checksum na resposta) e força o decode com sharp. Objeto ausente é
`ausente`; falha de leitura (checksum) ou arquivo que não decodifica é
`corrompida` — o recibo nomeia cada uma com o motivo. A varredura é **read-only**,
sai **1** enquanto houver mídia quebrada **pública** (linha `approved` quebrada —
o critério de convergência; uma `draft` quebrada é registrada no recibo mas não
bloqueia, senão o rebaixamento do `--apply` nunca convergiria) e sempre grava o
recibo em `data/archive/reports/`.
Com `--apply`, cada foto quebrada é recuperada do Flickr pelo `flickrId` (mesmo
fallback `getSizes` da ingestão C231): o original baixado é decodificado para
provar que serve, o objeto é substituído **na mesma chave** pelo caminho de
escrita da própria collection (`payload.update` + `filePath`) — curadoria,
metadados e `publicationStatus` intocados — e o recibo guarda a URL-fonte, os
bytes e o sha256. Foto cuja fonte sumiu (ou não decodifica) **sai do público**
(`approved → draft`) com registro; `removed` nunca é tocado. Rodar `--only` no
canário primeiro:
```bash
# no homeserver:
ssh homeserver
cd ~/stack
# 1) canário read-only nas fotos do achado (80/83) — não escreve:
# (a limpeza pós-deploy remove a tag local do migrator; o registry a preserva)
docker pull "$(docker inspect -f '{{.Config.Image}}' teqo-1313 | sed 's/teqo-1313:/teqo-1313-migrator:/')"
docker compose --profile maintenance run --rm \
-v ~/archive-reports:/app/data/archive \
teqo-1313-migrate pnpm archive:integrity --only 80,83
# 2) reparo do canário (exige TEQO_ENV + confirmação; as 4 S3_* e FLICKR_API_KEY):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \
-v ~/archive-reports:/app/data/archive \
teqo-1313-migrate pnpm archive:integrity --apply --only 80,83
# 3) conferir as duas URLs públicas com GET completo (o HEAD não lê o corpo e
# não valida o checksum — dá 200 mesmo com objeto corrompido):
# curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
# https://jorgesolla1313.com.br/fotos/80/midia
# (idem para /fotos/83/midia)
# 4) varredura completa (read-only; ~6,5k downloads no bucket local; sai 1 se achar):
docker compose --profile maintenance run --rm \
-v ~/archive-reports:/app/data/archive \
teqo-1313-migrate pnpm archive:integrity
# 5) reparo do que a varredura acusou (mesma linha com --apply):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \
-v ~/archive-reports:/app/data/archive \
teqo-1313-migrate pnpm archive:integrity --apply
# 6) convergência: re-rodar a varredura; 0 pendências = aceite
```
Guardas: fora de teste o `--apply` exige `TEQO_ENV` casando o **nome exato** do
banco (`teqo_staging`/`teqo_1313`), `ARCHIVE_INTEGRITY_CONFIRM=1` e
`FLICKR_API_KEY`; alvo não-local exige as 4 `S3_*` **em qualquer modo** (sem S3 a
varredura mediria o disco efêmero do container); `ALLOW_REMOTE_DB` é recusado.
`--concurrency` (default 3) e `--limit`/`--only` são os canários; o recibo traz
varridas/ok/ausentes/corrompidas/removidas, e no reparo recuperadas/
irrecuperáveis/falhas. A varredura **não** conserta nada: só o `--apply` escreve.
O download do original no reparo tem teto de **15 min** — um original grande
(~50 MB, ex. panorâmica 7952px) leva minutos no link lento da casa; falha de
download é `failed` retentável, nunca rebaixamento.
**Originais >50 MB e o checksum do Garage (incidente 2026-10-02):** o adapter
`storage-s3` sobe arquivos grandes via multipart e o Garage gravava um
`x-amz-checksum-crc32` que não bate com o corpo — todo GET do objeto falhava na
validação do SDK (500 no proxy público), mesmo com o conteúdo correto. O client
do plugin ficou com `requestChecksumCalculation: 'WHEN_REQUIRED'` (não anexa
checksum de upload; a leitura segue validando o que o objeto tiver). Se um
objeto legado aparecer com esse sintoma (`checksum mismatch` no recibo e no
GET), a correção pontual é reescrever os mesmos bytes com **PUT único** pelo
client S3 (o PUT único grava o checksum certo) — ver changelog
`2026-10-02-c246-garage-checksum-multipart.md`.
Depois de um `--apply` que rebaixou alguma foto, buste a tag pública em um
processo que a possui (o write do CLI não revalida o servidor Next):
```bash
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
```
Fotos reparadas que continuam `approved` voltam a servir sozinhas (mesma chave,
rota `force-dynamic`) — e o índice facial reprocessa sozinho o que tiver
marcador stale (`pnpm faces:index`). Uma foto rebaixada a `draft` **não volta
por engano** no lote: desde o C249 o `pnpm archive:publish` inspeciona cada
draft pelo mesmo `inspectPrivateMediaObject` desta varredura antes de aprovar —
a quebrada fica fora do lote, sai nomeada no recibo (`skippedBroken`) e o
comando termina 1. O aviso antigo ("só rode o publish depois de uma varredura
limpa") deixou de ser a única proteção; ainda vale rodar a varredura limpa
primeiro, porque ela é quem **repara** (o publish não repara, só pula e nomeia).
Rollback: sem migration nesta entrega; o comando nunca apaga objeto nem linha —
uma foto rebaixada por engano volta a `approved` pela ficha no admin (com o
canal de remoção configurado) ou pelo publish depois de o objeto inspecionar
limpo.
## C248 — miniaturas AVIF do álbum público (backfill das derivadas)
O comando `pnpm archive:thumbnails` gera a miniatura AVIF 720px q60 de cada foto
**aprovada** do acervo como um objeto-irmão determinístico no mesmo store
privado do original (`flickr-<id>.<ext>` → `flickr-<id>-grade.avif`); nada de
banco, nada de original alterado. A rota `/fotos/<id>/midia?tamanho=grade`
serve a derivada direto quando o cliente manda `Accept: image/avif`; sem o
token, ou com a derivada ausente/ilegível, ela responde o JPEG on-the-fly de
sempre e regenera a derivada em background (`after()`). A aprovação de uma foto
no admin também aquece a derivada (best-effort; falha nunca bloqueia). O lote
cobre o que foi aprovado antes disso e o que o hook perder (CLI em lote,
restart).
```bash
# no homeserver:
ssh homeserver
cd ~/stack
# 1) plano read-only: elegíveis do álbum aprovado e quantas derivadas faltam
docker compose --profile maintenance run --rm \
-v /srv/archive-reports:/app/data/archive \
teqo-1313-migrate pnpm archive:thumbnails
# 2) canário (exige TEQO_ENV + confirmação; as 4 S3_* no alvo):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_THUMBNAILS_CONFIRM=1 \
-v /srv/archive-reports:/app/data/archive \
teqo-1313-migrate pnpm archive:thumbnails --apply --limit 20
# 3) lote completo (encode AVIF local; ~6,5k fotos):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e ARCHIVE_THUMBNAILS_CONFIRM=1 \
-v /srv/archive-reports:/app/data/archive \
teqo-1313-migrate pnpm archive:thumbnails --apply
# 4) aceite: cobertura 100% (read-only; sai 1 se faltar alguma):
docker compose --profile maintenance run --rm \
-v /srv/archive-reports:/app/data/archive \
teqo-1313-migrate pnpm archive:thumbnails --verify
# 5) conferir uma resposta real negociada (espera content-type image/avif):
curl -sD - -o /dev/null -H 'Accept: image/avif' \
"https://jorgesolla1313.com.br/fotos/<id>/midia?tamanho=grade"
```
Guardas: fora de teste o `--apply` exige `TEQO_ENV` casando o **nome exato** do
banco (`teqo_staging`/`teqo_1313`), `ARCHIVE_THUMBNAILS_CONFIRM=1` e — em alvo
não-local — as 4 `S3_*` (o original vive no bucket); `ALLOW_REMOTE_DB` é
recusado. O plano e o `--verify` são read-only. Reexecutar converge: derivada
existente é pulada por HEAD; original ausente/ilegível vira "pulada" com o
motivo no recibo (a C246 recupera o original e um novo `--apply` regenera);
falha de rede/upload fica registrada como falha e retenta na próxima execução.
**Serialize com `archive:catalog` e `archive:integrity`** (C245/C246 tocam os
mesmos originais) e rode em janela própria. `--concurrency` default 2 (o mesmo
teto do processo web), `--only`/`--limit` são os canários; recibos em
`data/archive/reports/`.
Nada a revalidar depois do lote: a rota descobre a derivada por chave
determinística a cada request (`force-dynamic`, `no-store`). Rollback: sem
migration nesta entrega; basta remover os objetos `flickr-<id>-grade.avif` do
bucket (a rota volta ao resize on-the-fly sozinha) — o original nunca é tocado.
## C230-followup — importação do Instagram para a Central de Conteúdos
O comando `pnpm content:instagram:import` traz as mídias recentes do perfil
oficial `@depjorgesolla` para a Central pelo caminho oficial (Graph API da
própria conta; a credencial é a do global `Social Feed`): cada novidade vira
uma peça no pipeline C220 (download do arquivo do próprio perfil, transcrição e
catalogação); com `--publish`, é publicada ao fim do lote o que esta rodada
terminou `pronto`, e com `--publish-existing` também entram os **rascunhos
prontos já na janela** (lote importado sem publicar), do post mais antigo para
o mais novo — o listing público ordena por `-publishedAt`, então o mais recente
lidera. Falha fica rascunho e é nomeada no recibo. Reexecutar converge
("novas / já estavam / falharam com motivo") e nunca duplica: a identidade é o
post, não a grafia da URL. Nada de scraping, terceiro ou stories; a mídia de
terceiro nunca é baixada (o match é dentro do feed da própria conta). A
varredura lê até o teto da própria edge (10K mídias) com early-stop por data —
a janela nunca é cortada por um teto arbitrário de páginas.
O dry-run é o padrão e não escreve (lê o feed e o banco):
```bash
# no homeserver:
ssh homeserver
cd ~/stack
# 1) plano (dry-run; mostra janela, candidatos e peça-link previstos):
docker compose --profile maintenance run --rm \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --days 60
# 2) canário no alvo (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --apply --publish --limit 2
# 3) lote completo — rode em tmux: o processamento baixa, transcreve e cataloga
# cada peça em sequência (docker compose run sem -d segura a sessão SSH):
# --publish-existing fecha a lacuna de um lote que já entrou como rascunho.
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --apply --publish --publish-existing --days 60
```
**Retirar peças anteriores a um corte** (ex.: antes do início da campanha
eleitoral). O comando despublica (kill switch: preserva slug, arquivo e
`publishedAt`; reversível pela ficha) as peças cujo **post original** é
anterior à data de corte — uma data civil na Bahia, a mesma do calendário
eleitoral. Nas Eleições 2026 a propaganda é permitida a partir de **16/08**
(Lei 9.504/97, art. 36; Resolução TSE nº 23.760/2026), então o corte é
`--before 2026-08-16`. Peça publicada que o feed não consegue datar nunca é
tocada: aparece no recibo como "sem data no feed".
```bash
# 1) plano (dry-run; lista o que sairia, o período e o que não tem data):
docker compose --profile maintenance run --rm \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:withdraw --before 2026-08-16
# 2) retirada (exige TEQO_ENV + confirmação):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_WITHDRAW_CONFIRM=1 \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:withdraw --before 2026-08-16 --apply
```
`--scan-days <n>` (default 90) é só a profundidade da varredura do feed usada
para datar as peças publicadas; reexecutar converge (o que já é rascunho sai do
conjunto). O recibo nomeia cada peça retirada, o período e as falhas.
**Apagar peças anteriores a um corte (expurgo definitivo).** Diferente da
retirada, o `content:instagram:prune` faz **hard delete** da peça e da mídia
privada (arquivo e still do C226): a linha sai do catálogo, então uma importação
futura não a recria como rascunho nem o `--publish-existing` a publica de novo.
Use quando a peça fora do período não deve mais existir — o caso clássico é o
rascunho pré-campanha que o `--publish-existing` ressuscita. Peça que o feed
oficial não consegue datar **nunca é tocada** (apagar sem data seria risco de
apagar peça de campanha) e aparece nomeada no recibo.
```bash
# 1) plano (dry-run; lista o que sairia, o período e o que não tem data):
docker compose --profile maintenance run --rm \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16
# 2) expurgo (exige TEQO_ENV + confirmação; IRREVERSÍVEL):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_PRUNE_CONFIRM=1 \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:prune --before 2026-08-16 --apply
```
`--scan-days <n>` (default **365**) é a profundidade da varredura do feed usada
para datar as peças; o plano varre rascunhos e publicadas. Reexecutar converge
(0 anteriores ao corte). O recibo nomeia cada peça apagada, o período e as
falhas; a revalidação da Central só roda se alguma publicada saiu (mídia órfã é
limpa após a linha, best-effort, como no delete da ficha).
Os recibos JSON ficam em `data/content-instagram/` do container — monte o
volume acima para preservá-los. Com `--publish`, o CLI revalida a Central
pública ao final pela rota oficial de revalidação
(`POST {NEXT_PUBLIC_SITE_URL}/api/revalidate?tag=contentPieces`, com
`REVALIDATE_SECRET` do env do stack — a tag entrou na allowlist do endpoint,
como o álbum do C233); se a revalidação falhar, o
recibo nomeia o motivo e o fallback manual no homeserver é
`curl -X POST http://localhost:1313/api/revalidate?tag=contentPieces -H "x-revalidate-secret: $REVALIDATE_SECRET"`
(no staging, `:1314`). Guardas: fora de teste o
`TEQO_ENV=staging|production` é obrigatório com o **nome exato** do banco
(`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4 `S3_*` o
`--apply` recusa (o job gravaria em disco efêmero); e
`CONTENT_INSTAGRAM_IMPORT_CONFIRM=1` é exigido em alvo não-local. Sem
credencial no global o comando falha fechado com a mensagem de produto; o token
nunca entra em recibo/log.
**Rede:** o job baixa o arquivo do CDN do Instagram e a rota IPv4 do container
é instável (post-mortem `2026-09-25-instagram-cdn-ipv4-instavel.md`) — o
serviço de manutenção usado precisa estar anexado à rede `teqo-ipv6` do stack
(a mesma do container de produção). Sem ela, parte dos vídeos vira peça-link
por `indisponivel` (honesto, mas não é o desejado).
**Reprocessar uma falha:** um `falhou` fica rascunho e a reimportação **pula**
o que já existe — o retry é o botão "Reprocessar" da ficha (C211), não uma
segunda rodada do CLI. `--limit` deixa a cauda no lote: rode de novo (o CLI
pula o que já entrou).
Rollback: nada de schema. O kill switch é despublicar/apagar as peças no admin
(a Central pública atualiza na hora pelo tag `contentPieces`).
## OPS131 — cutover do domínio jorgesolla.com.br
Decisão de produto: **A — cutover total**. O site público e `/campanha` passam a
viver em `https://jorgesolla.com.br`; as passkeys são re-registradas uma vez no
domínio novo (o RP ID do WebAuthn é o hostname de `NEXT_PUBLIC_SITE_URL`). O
login por senha continua funcionando durante e depois da troca.
### Pré-flight (evidência de 2026-11-08)
- **WordPress:** sitemap com **43 posts** publicados (último em 2026-08-17),
2 categorias (`noticias`, `uncategorized`), tags e author; o page sitemap só
tem `home`. O REST do WordPress não responde a partir do homeserver/workstation
(timeout/WAF), mas o inventário foi lido pela API pública via proxy externo.
- **Teqo:** **43 posts** publicados (último em 2026-08-18) — paridade 43/43, sem
conteúdo novo a importar. O `pnpm db:seed:posts` não roda a partir do
homeserver enquanto o REST do WordPress estiver bloqueado; o registro desta
paridade é a evidência do OPS130.
- **Redirects:** os 43 permalinks `/YYYY/MM/DD/<slug>/` estão em
`scripts/lib/legacyWordPressRedirects.mjs` (destino = caminho canônico Teqo).
Categorias, tags, author, feeds, paginação e arquivos de data vão para
`/artigos`; `/home` vai para `/`; `wp-admin`/`wp-login.php` para `/`.
- **Passkeys:** 4 usuários / 7 credenciais no RP ID atual
(`jorgesolla1313.com.br`); serão re-registradas no domínio novo.
- **Metadados:** `metadata.URL` já é `https://jorgesolla.com.br`;
`NEXT_PUBLIC_SITE_URL` de produção ainda é `https://jorgesolla1313.com.br`.
### Janela de cutover
1. **Congelar o WordPress (read-only)** e guardar o backup final. Confirmar que
o sitemap continua com os 43 posts; se houver post novo, regenerar o mapa e
rodar o teste unitário antes de seguir.
2. **Env de produção:** editar `~/stack/teqo-1313.env` com
`NEXT_PUBLIC_SITE_URL=https://jorgesolla.com.br` (backup do arquivo antes).
Como o valor é inlined no build, o deploy precisa rebuildar.
3. **Cloudflare:** no túnel remotely-managed, adicionar o ingress
`jorgesolla.com.br` → `http://localhost:1313`; criar o DNS proxied do apex
para o túnel e o `www` (CNAME para o apex ou para o túnel). Na zona
`jorgesolla1313.com.br`, criar uma Redirect Rule 301 de
`https://jorgesolla1313.com.br/*` para `https://jorgesolla.com.br` preservando
`http.request.uri.path`. No `www`, 301 para o apex. **Não tocar em MX, TXT,
SPF, DKIM ou DMARC** — o e-mail `@jorgesolla.com.br` continua no mesmo lugar.
4. **Deploy:** rodar o workflow de deploy (push em `main` ou `workflow_dispatch`)
e aprovar produção. O build novo inlina o domínio novo.
5. **Passkeys:** avisar os 4 usuários para re-registrar a passkey em
`https://jorgesolla.com.br/campanha` depois do switch. Quem usa senha não
precisa fazer nada; as credenciais antigas ficam no banco, mas o browser não
as oferece no domínio novo.
6. **Verificação (read-only):**
- `https://jorgesolla.com.br/` e `https://www.jorgesolla.com.br/` → 200
(www 301 para o apex), canonical/OG no domínio novo;
- `https://jorgesolla1313.com.br/artigos` → 301 para
`https://jorgesolla.com.br/artigos` (Redirect Rule do Cloudflare);
- amostras do WordPress: `/2026/08/17/bruno-reis-.../` → caminho canônico,
`/category/noticias/`, `/tag/lula/`, `/2026/08/` → `/artigos` (o Next
responde **308 permanente** para os redirects in-app; o Cloudflare usa 301
no domínio 1313);
- `/api/media/file/...` e a imagem OG respondem 200 no domínio novo;
- `/campanha` autentica por senha; uma passkey nova é registrada e usada.
7. **WordPress:** manter read-only por ~30 dias (janela de rollback), depois
desligar o host antigo. O backup/dump fica arquivado; as uploads antigas não
são proxiadas (não há imagem do WordPress no corpo dos 43 posts do Teqo).
### Rollback
- Reverter `NEXT_PUBLIC_SITE_URL` para `https://jorgesolla1313.com.br` e
redeployar; as passkeys antigas voltam a valer.
- Remover o ingress/DNS de `jorgesolla.com.br` do túnel e restaurar o A record
do WordPress (`128.201.75.110`) enquanto a janela de rollback estiver aberta.
- O WordPress fica read-only, então o rollback não perde conteúdo novo.
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)
- `.github/workflows/deploy.yml` — preflight → verify → deploy-staging → requeue/deploy-production (OPS103/OPS104)
- `docs/plans/ops53-ci-deploy-homeserver*.md` — intenção e decisões (era Forgejo)
- `docs/plans/ops71-ci-github-actions-tracker-forgejo*.md` — o cutover
- `docs/plans/ops103-staging-homeserver*.md` — staging com verify único e aprovações separadas
- `docs/plans/ops104-disparo-automatico-do-deploy*.md` — início automático + requeue (staging automático, produção com review)
- infra-solla: `STATE.md`, `plano-infra-final.md` §"Arquitetura de deploy"