diff --git a/app/Http/Controllers/InvestmentRelationshipRepairController.php b/app/Http/Controllers/InvestmentRelationshipRepairController.php
index f428b907..13a77c6f 100644
--- a/app/Http/Controllers/InvestmentRelationshipRepairController.php
+++ b/app/Http/Controllers/InvestmentRelationshipRepairController.php
@@ -21,9 +21,10 @@ class InvestmentRelationshipRepairController extends Controller
public function apply(Request $request, InvestmentRelationshipRepair $repair): RedirectResponse|JsonResponse
{
if ($request->input('operation') === 'traceability') {
- $data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'reverse' => ['required', 'boolean']]);
+ $data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'reverse' => ['required', 'boolean'],
+ 'reason' => ['required_if:reverse,true', 'nullable', 'string', 'max:1000'], 'confirm_reverse' => ['accepted_if:reverse,true']]);
try {
- $result = app(InvestmentTerritorialTraceability::class)->apply($data['hash'], $request->user()->id, $data['reverse']);
+ $result = app(InvestmentTerritorialTraceability::class)->apply($data['hash'], $request->user()->id, $data['reverse'], $data['reason'] ?? null);
} catch (HttpException $exception) {
if ($request->expectsJson()) {
throw $exception;
diff --git a/app/Services/InvestmentTerritorialTraceability.php b/app/Services/InvestmentTerritorialTraceability.php
index 401de5b2..80d0fd2a 100644
--- a/app/Services/InvestmentTerritorialTraceability.php
+++ b/app/Services/InvestmentTerritorialTraceability.php
@@ -37,7 +37,12 @@ class InvestmentTerritorialTraceability
$last = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->where('after_data->plan_hash', $hash)->orderByDesc('occurred_at')->orderByDesc('id')->first();
return ['hash' => $hash, 'plan' => $plan, 'code_matches' => ($plan['code_hash'] ?? '') === $this->fingerprint(),
- 'applied' => $last?->action === 'traceability_applied', 'backup_ready' => $this->backupReady($plan['target'], $hash)];
+ 'applied' => $last?->action === 'traceability_applied', 'reversed' => $last?->action === 'traceability_reversed',
+ 'history' => DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->orderByDesc('occurred_at')->limit(20)->get(['id', 'action', 'actor_user_id', 'occurred_at', 'after_data'])->map(function ($event) {
+ $data = json_decode($event->after_data, true);
+
+ return ['id' => $event->id, 'action' => $event->action, 'actor_user_id' => $event->actor_user_id, 'occurred_at' => $event->occurred_at, 'reason' => $data['reason'] ?? null];
+ })->all(), 'backup_ready' => $this->backupReady($plan['target'], $hash)];
}
/** @param array $target */
@@ -67,8 +72,9 @@ class InvestmentTerritorialTraceability
}
/** @return array */
- public function apply(string $hash, int $actor, bool $reverse = false): array
+ public function apply(string $hash, int $actor, bool $reverse = false, ?string $reason = null): array
{
+ abort_if($reverse && trim($reason ?? '') === '', 422, 'Indique o motivo da reversão.');
$preview = $this->preview();
abort_unless($preview && hash_equals($preview['hash'], $hash) && $preview['code_matches'], 409, 'Plano ou código alterado. Reveja a pré-visualização.');
$plan = $preview['plan'];
@@ -79,7 +85,7 @@ class InvestmentTerritorialTraceability
}
abort_unless($preview['backup_ready'], 422, 'Backup/restauro/reversão verificados e atualizados são necessários.');
- return DB::transaction(function () use ($plan, $hash, $actor, $reverse) {
+ return DB::transaction(function () use ($plan, $hash, $actor, $reverse, $reason) {
DB::select('SELECT pg_advisory_xact_lock(81026, 160000)');
$last = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->where('after_data->plan_hash', $hash)->orderByDesc('occurred_at')->orderByDesc('id')->lockForUpdate()->first();
if ((! $reverse && $last?->action === 'traceability_applied') || ($reverse && $last?->action === 'traceability_reversed')) {
@@ -96,6 +102,7 @@ class InvestmentTerritorialTraceability
$values = $update['before'];
$values['version_no'] = $update['after']['version_no'] + 1;
DB::table('cluster_projects')->where('id', $update['id'])->update($values);
+ $before[] = ['id' => $update['id'], 'before' => $update['after'], 'after' => $values];
}
} else {
foreach ($plan['guards'] as $guard) {
@@ -111,7 +118,7 @@ class InvestmentTerritorialTraceability
}
DB::table('audit_events')->insert(['source_system' => 'territory-traceability-v2', 'entity_type' => 'territorial_sources', 'actor_user_id' => $actor,
'action' => $reverse ? 'traceability_reversed' : 'traceability_applied', 'data_classification' => 'internal', 'occurred_at' => DB::raw('clock_timestamp()'),
- 'after_data' => json_encode(['plan_hash' => $hash, 'rows' => $plan['rows'], 'updates' => $before, 'no_new_geography' => true, 'official_policy' => 'accepted_without_revalidation'], JSON_THROW_ON_ERROR)]);
+ 'after_data' => json_encode(['plan_hash' => $hash, 'rows' => $plan['rows'], 'updates' => $before, 'reason' => $reverse ? trim($reason) : null, 'no_new_geography' => true, 'official_policy' => 'accepted_without_revalidation'], JSON_THROW_ON_ERROR)]);
return ['replayed' => false, 'changed' => $reverse ? count($applied['updates']) : count($before), 'associations' => count($plan['rows'])];
});
diff --git a/docs/investmoz/v2/ativacao-rastreabilidade-territorial.md b/docs/investmoz/v2/ativacao-rastreabilidade-territorial.md
index 927e023c..6ead1dcd 100644
--- a/docs/investmoz/v2/ativacao-rastreabilidade-territorial.md
+++ b/docs/investmoz/v2/ativacao-rastreabilidade-territorial.md
@@ -44,3 +44,25 @@ Provas privadas em `storage/app/private/investmoz-v2`: `territory-activation-pla
**Preparado:** código, pré-visualização e aplicação autenticada. **Validado em QA:** aplicação, reversão, permissões e apresentação. **Aplicado na base ativa:** nenhuma alteração de dados deste incremento; ação autenticada do responsável permanece por executar.
Continuam separadas: questões jurídicas específicas de âmbito/versão/aplicabilidade ainda não resolvidas; diferença de cobertura do inventário; ClamD real; primeiro pedido real no Balcão. A aceitação da proveniência oficial não foi reaberta e nenhuma dessas pendências foi concluída pelos testes territoriais.
+
+## Confirmação após as ações autenticadas
+
+Leitura `READ ONLY` de `investemoz:5432` em 09/10/2026 às 00:10:09 UTC (02:10:09 local). A auditoria regista `traceability_applied` às 02:06:31 e `traceability_reversed` às 02:06:47 locais, ambas pelo utilizador 13 e para o mesmo hash do plano preparado. **Estado atual: incremento revertido; referências propostas não permanecem aplicadas.**
+
+As 13 alterações registadas no evento de aplicação correspondem exatamente aos valores propostos. Após reversão, os 13 pares de referências regressaram aos valores anteriores e as versões aumentaram duas unidades, preservando o histórico. Estados de validação e tipos de associação mantiveram-se; os 46 registos territoriais, as fontes legais, a hierarquia e a geometria também foram preservados. A projeção de fontes deste plano deixa de estar ativa após reversão.
+
+Evidência privada: `territory-post-action-confirmation.json`, incluindo identificação das duas ações e comparação por associação. Esta confirmação não realizou escritas na base ativa. Para reaplicar, é necessário preparar uma nova pré-visualização com as versões atuais e provas correspondentes; não repetir o plano revertido nem a aplicação automaticamente.
+
+## Diagnóstico da reversão e novo plano
+
+9 de outubro de 2026. O evento antigo de reversão identifica a conta 13, a ação e o plano, mas **não contém motivo**. Não é possível inferir a intenção humana. No código, `traceability_reversed` é registado quando a ação explícita de reversão é executada; uma falha transacional não gera esse evento. A comparação anterior comprovou que aplicação e restituição funcionaram corretamente, sem perda de validações.
+
+Foi corrigida uma fragilidade de interação: o mesmo botão mudava de aplicar para reverter após sucesso. A reversão passa a exigir um motivo preenchido e a confirmação expressa de restituição das referências, verificados também no servidor. O motivo e os valores restituídos ficam no novo evento de auditoria. O histórico antigo permanece intacto e apresenta «motivo não registado»; não foi preenchido retroativamente. Um plano revertido aparece como tal e o seu botão fica desativado, exigindo novo plano.
+
+**Nova pré-visualização:** mesma página e ação `Aplicar rastreabilidade territorial V2`; destino `investemoz:5432`, 112 entradas e 13 atualizações, agora com guardas da versão **3** das associações. Hash privado do plano: `ec11b9e400b7b127fbc8d42a2afe5e4d2c8a3919216a5d7439fb38b0751c57c6`. Não cria relações ou geometrias nem reabre a aceitação oficial.
+
+**Provas atualizadas:** backup/restauro de 288 tabelas e cinco ficheiros; cópia `desk_restore_20261009_001345_test:55441`. Ensaio aplicou 13 referências, confirmou idempotência e reverteu 13, preservando validações e onze tabelas relacionadas. A prova nova substitui a anterior para esta aplicação e expira em **10/10/2026 às 02:13:54 locais**. Os ficheiros do plano e da prova anteriores foram preservados privadamente.
+
+Os três testes afetados passaram, com 30 asserções, incluindo recusa de reversão sem motivo/confirmação e conservação do motivo no histórico. TypeScript, formatação/lint, PHPStan e build passaram. As pendências de rastreabilidade, jurídicas específicas, cobertura documental, ClamD e primeiro pedido real permanecem separadas. **Não houve reaplicação automática na base ativa.**
+
+O navegador QA confirmou pré-visualização, aplicação, recuperação e reversão após preencher motivo e confirmação; o botão de reversão estava inicialmente desativado. Sem erros de JavaScript. Leitura ativa final confirmou todas as guardas atuais do novo plano e apenas os dois eventos históricos, sendo o último a reversão.
diff --git a/docs/investmoz/v2/backlog-mestre.md b/docs/investmoz/v2/backlog-mestre.md
index b8ff3a57..14a16ccd 100644
--- a/docs/investmoz/v2/backlog-mestre.md
+++ b/docs/investmoz/v2/backlog-mestre.md
@@ -163,3 +163,11 @@ Propostas e [relatório territorial](qualidade-territorial.md) corrigidos para d
| QA | 56 testes PHP/884 asserções, quatro testes de reconciliação, TypeScript, lint, PHPStan, build e percurso autenticado no navegador | Zero eventos deste incremento na leitura ativa final; dados territoriais inalterados |
Restam pontes de rastreabilidade e seis correspondências oportunidade–âncora/cluster sem fundamento explícito recuperado; não se retirou a aceitação existente. Questões jurídicas específicas, diferença de cobertura do inventário, ClamD real e primeiro pedido real permanecem separadas.
+
+### Confirmação ativa — aplicação seguida de reversão
+
+Leitura `READ ONLY` em 09/10/2026 às 02:10:09 locais: auditoria confirma aplicação às 02:06:31 e reversão às 02:06:47, pelo utilizador 13, ambas para o plano preparado. As 13 referências foram aplicadas conforme previsto e depois restituídas, preservando validações, tipos de associação, versões monotónicas e histórico. Os 46 registos, fontes legais e geografia mantiveram-se. **Estado atual V2-08/V2-13: incremento revertido na base ativa**, não instalação permanente concluída. Prova privada: `territory-post-action-confirmation.json`. Nova aplicação exige novo plano para as versões atuais; nenhuma escrita foi repetida durante esta confirmação.
+
+### Novo plano após reversão — 9 de outubro
+
+O motivo humano não consta do evento original e não foi inventado. Corrigida a possibilidade de reversão imediata pelo botão que mudava após aplicação: exige agora motivo e confirmação expressa, conservados na auditoria; planos revertidos ficam desativados. Novo plano preparado com as versões 3 atuais das 13 associações, mantendo 112 entradas e validações existentes. Backup/restauro atualizado: 288 tabelas e cinco ficheiros em `desk_restore_20261009_001345_test:55441`; aplicação/idempotência/reversão verificadas na cópia. Três testes afetados/30 asserções, TypeScript, lint, PHPStan e build passaram. [Pré-visualização e prova nova](ativacao-rastreabilidade-territorial.md): válida até 10/10 às 02:13:54 locais. **Não reaplicado na base ativa; ação autenticada permanece por executar.**
diff --git a/resources/js/components/territorial-traceability-plan.tsx b/resources/js/components/territorial-traceability-plan.tsx
index 9466e314..4b64d9f5 100644
--- a/resources/js/components/territorial-traceability-plan.tsx
+++ b/resources/js/components/territorial-traceability-plan.tsx
@@ -5,6 +5,14 @@ export type TraceabilityPlan = {
code_matches: boolean;
backup_ready: boolean;
applied: boolean;
+ reversed: boolean;
+ history: {
+ id: string;
+ action: string;
+ actor_user_id: number;
+ occurred_at: string;
+ reason: string | null;
+ }[];
plan: {
target: { database: string; port: number };
rows: {
@@ -42,6 +50,8 @@ export default function TerritorialTraceabilityPlan({
hash: preview.hash,
operation: 'traceability',
reverse: false,
+ reason: '',
+ confirm_reverse: false,
});
return (
@@ -68,8 +78,23 @@ export default function TerritorialTraceabilityPlan({
{preview.code_matches
? 'corresponde ao plano'
: 'plano deve ser atualizado'}{' '}
- · Aplicação: {preview.applied ? 'aplicada' : 'não aplicada'}
+ · Aplicação:{' '}
+ {preview.applied
+ ? 'aplicada'
+ : preview.reversed
+ ? 'revertida; novo plano necessário'
+ : 'não aplicada'}
+
+ Histórico de aplicação e reversão
+ {preview.history.map((event) => (
+
+ {event.occurred_at} · {event.action} · conta{' '}
+ {event.actor_user_id} · Motivo:{' '}
+ {event.reason ?? 'não registado no evento original'}
+
+ ))}
+