diff --git a/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md
new file mode 100644
index 0000000..80beb11
--- /dev/null
+++ b/.superpowers/sdd/2026-09-22-control-panel-operational-ux-v1/task-3-report.md
@@ -0,0 +1,73 @@
+# Task 3 Report: Safe Blocker Resolution Workflow
+
+## Status
+
+Implemented Task 3 only. The old hidden-context blocker action was replaced by a structured, server-rendered, CSRF-protected resolution workflow. The JSON control-panel route now uses the same validated transaction service and rejects arbitrary context text.
+
+## RED
+
+Created `central/tests/test_blocker_resolution_panel.py` before production implementation.
+
+Initial focused command:
+
+```text
+python -m pytest central/tests/test_blocker_resolution_panel.py -q
+```
+
+Expected RED result:
+
+```text
+ModuleNotFoundError: No module named 'central.app.blocker_resolution'
+```
+
+The failure occurred during collection because the required production module did not yet exist.
+
+## Implementation
+
+- Added `central/app/blocker_resolution.py` with structured input validation through `validate_entry`, workflow-scope restrictions, exact PRODUCT scope-key enforcement, persisted installation/company context derivation, context upsert, bounded `REPROCESS_PRODUCTS` job creation, sanitized audit recording, and one commit with rollback on failure.
+- Added `GET /blockers/{blocker_id}` with installation-aware blocker detail and context form.
+- Added `POST /blockers/{blocker_id}/resolve-context` with human authentication and CSRF enforcement.
+- Updated `/v1/blockers/{blocker_id}/resolve-human` and added `/v1/blockers/{blocker_id}/resolve-context` to call the same service; neither accepts arbitrary context text.
+- Removed the blocker-list hidden fixed-context form and `data-confirm` action.
+- Added focused coverage for enum validation, unknown keys, unsupported NCM scope, empty reason, missing PRODUCT scope key, authentication, CSRF, context/job/audit persistence, exact product scoping, and secret redaction.
+
+## GREEN
+
+Focused command:
+
+```text
+python -m pytest central/tests/test_blocker_resolution_panel.py -q
+```
+
+Result:
+
+```text
+5 passed, 1 warning
+```
+
+Central suite:
+
+```text
+python -m pytest central/tests -q
+```
+
+Result:
+
+```text
+261 passed, 1 warning
+```
+
+The warnings are existing third-party test-environment warnings from `pytest_asyncio` configuration and `openpyxl` print-area parsing.
+
+## Safety Review
+
+- No Firebird, AI, SQL execution, shell, credential, or remote-write path was added.
+- The workflow accepts only `scope`, `scope_key`, `key`, `value`, and `reason`; context keys and enum values are validated against existing shared rules.
+- Company context is derived from persisted blocker/installation data, not arbitrary client `company_key` input.
+- Jobs contain exactly the blocked product code.
+- Audit context passes through existing redaction before persistence.
+
+## Concerns
+
+- Live PostgreSQL and agent execution were not exercised; verification used the repository's offline SQLite test fixtures.
+- The blocker remains open until the agent's normal reprocess completion path reports resolution; this prevents marking it resolved before the bounded job succeeds.
diff --git a/central/app/blocker_resolution.py b/central/app/blocker_resolution.py
new file mode 100644
index 0000000..8e34b6c
--- /dev/null
+++ b/central/app/blocker_resolution.py
@@ -0,0 +1,149 @@
+"""Validated, auditable context resolution for a single blocked product."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+
+from .audit import record_audit
+from .context_keys import validate_entry
+from .models import Agent, BlockedProduct, FiscalContextEntry, Installation, Tenant, Job
+
+
+WORKFLOW_SCOPES = {"PRODUCT", "GROUP", "COMPANY"}
+ENTRY_FIELDS = {"scope", "scope_key", "key", "value", "reason"}
+
+
+def validate_resolution_input(payload):
+ """Validate only the structured context accepted by the blocker workflow."""
+ if not isinstance(payload, dict):
+ return None, "resolution input must be an object"
+ unexpected = set(payload) - ENTRY_FIELDS
+ if unexpected:
+ return None, f"unsupported resolution fields: {sorted(unexpected)}"
+
+ normalized, error = validate_entry(payload)
+ if error:
+ return None, error
+ if normalized["scope"] not in WORKFLOW_SCOPES:
+ return None, "scope must be one of ('PRODUCT', 'GROUP', 'COMPANY')"
+ if not normalized["reason"]:
+ return None, "reason is required"
+ return normalized, None
+
+
+def _company_key(blocker, installation, tenant):
+ if installation is not None:
+ return (installation.company_code or installation.company_name or installation.customer_code or "").strip()
+ return (blocker.company or (tenant.name if tenant else "")).strip()
+
+
+def _entry_view(row):
+ if row is None:
+ return None
+ return {
+ "company_key": row.company_key,
+ "scope": row.scope,
+ "scope_key": row.scope_key,
+ "key": row.key,
+ "value": row.value,
+ "source": row.source,
+ "reason": row.reason,
+ }
+
+
+def resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=None):
+ """Persist context, enqueue one product reprocess, and audit atomically."""
+ normalized, error = validate_resolution_input(payload)
+ if error:
+ raise ValueError(error)
+
+ try:
+ blocker = session.get(BlockedProduct, blocker_id)
+ if blocker is None:
+ raise LookupError("blocker not found")
+
+ installation = None
+ if blocker.installation_id is not None:
+ installation = session.get(Installation, blocker.installation_id)
+ if installation is None:
+ raise LookupError("blocker installation not found")
+ agent = session.get(Agent, installation.agent_id)
+ else:
+ agent = session.scalar(
+ select(Agent)
+ .where(Agent.tenant_id == blocker.tenant_id, Agent.enabled.is_(True))
+ .order_by(Agent.id.asc())
+ )
+ if agent is None:
+ raise LookupError("blocker agent not found")
+
+ tenant = session.get(Tenant, blocker.tenant_id)
+ company_key = _company_key(blocker, installation, tenant)
+ if not company_key:
+ raise ValueError("company context is unavailable")
+ if normalized["scope"] == "PRODUCT" and normalized["scope_key"] != blocker.product_code:
+ raise ValueError("PRODUCT scope_key must match the blocker product code")
+
+ query = select(FiscalContextEntry).where(
+ FiscalContextEntry.tenant_id == blocker.tenant_id,
+ FiscalContextEntry.company_key == company_key,
+ FiscalContextEntry.scope == normalized["scope"],
+ FiscalContextEntry.scope_key == normalized["scope_key"],
+ FiscalContextEntry.key == normalized["key"],
+ )
+ entry = session.scalar(query)
+ before = _entry_view(entry)
+ now = datetime.now(timezone.utc)
+ if entry is None:
+ entry = FiscalContextEntry(
+ tenant_id=blocker.tenant_id,
+ company_key=company_key,
+ source="OPERATOR",
+ updated_at=now,
+ **normalized,
+ )
+ session.add(entry)
+ else:
+ entry.value = normalized["value"]
+ entry.reason = normalized["reason"]
+ entry.source = "OPERATOR"
+ entry.updated_at = now
+
+ job = Job(agent_id=agent.id, kind="REPROCESS_PRODUCTS", status="PENDING", payload={"codigos": [blocker.product_code]})
+ session.add(job)
+ session.flush()
+ audit = record_audit(
+ session,
+ admin_user_id=admin.id,
+ tenant_id=blocker.tenant_id,
+ action="RESOLVE_BLOCKER_WITH_CONTEXT",
+ target_type="blocker",
+ target_id=str(blocker.id),
+ context={
+ "admin_user_id": admin.id,
+ "tenant_id": blocker.tenant_id,
+ "client": tenant.name if tenant else None,
+ "company_key": company_key,
+ "installation_id": installation.installation_id if installation else None,
+ "installation_db_id": installation.id if installation else None,
+ "product_code": blocker.product_code,
+ "blocker_id": blocker.id,
+ "key": normalized["key"],
+ "scope": normalized["scope"],
+ "scope_key": normalized["scope_key"],
+ "value": normalized["value"],
+ "reason": normalized["reason"],
+ "before": before,
+ "after": _entry_view(entry),
+ "reprocess_job_id": job.id,
+ "request_ip": request_ip,
+ },
+ )
+ session.flush()
+ session.commit()
+ return {"blocker_id": blocker.id, "job_id": job.id, "audit_id": audit.id}
+ except Exception:
+ session.rollback()
+ raise
diff --git a/central/app/control_api.py b/central/app/control_api.py
index b5df1e2..f3dae0c 100644
--- a/central/app/control_api.py
+++ b/central/app/control_api.py
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from .audit import record_audit
+from .blocker_resolution import resolve_blocker_with_context
from .control_plane import dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import require_csrf, require_human
from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant
@@ -109,19 +110,19 @@ def router(session_factory):
return {"job_id": job.id, "status": job.status}
finally: session.close()
+ @routes.post("/blockers/{blocker_id}/resolve-context")
@routes.post("/blockers/{blocker_id}/resolve-human")
def resolve_blocker(blocker_id: int, payload: dict, request: Request, identity=Depends(identity)):
session, web_session, admin = identity
try:
require_csrf(web_session, payload.get("csrf_token"))
- if payload.get("confirm") is not True or not str(payload.get("context", "")).strip(): raise HTTPException(400, "confirmation and explicit context required")
- blocker = session.get(BlockedProduct, blocker_id)
- if not blocker: raise HTTPException(404, "blocker not found")
- if blocker.resolved_at is None:
- blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
- record_audit(session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker_id), context={"context": payload["context"]})
- session.commit()
- return {"resolved": True, "blocker_id": blocker_id}
+ entry_payload = {key: payload.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
+ try:
+ return resolve_blocker_with_context(session, blocker_id, admin, entry_payload, request_ip=request.client.host if request.client else None)
+ except LookupError as error:
+ raise HTTPException(404, str(error)) from error
+ except ValueError as error:
+ raise HTTPException(422, str(error)) from error
finally: session.close()
return routes
diff --git a/central/app/web.py b/central/app/web.py
index 1d4c0b4..5388f73 100644
--- a/central/app/web.py
+++ b/central/app/web.py
@@ -8,9 +8,11 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from .config import settings
-from .control_plane import dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events
+from .blocker_resolution import _company_key, resolve_blocker_with_context
+from .context_keys import ALLOWED_KEYS, ENUMS
+from .control_plane import blocker_detail, dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events
from .human_auth import create_session, current_session, require_csrf, verify_password
-from .models import AdminUser, Installation, Agent, Job
+from .models import AdminUser, Agent, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant
from .jobs import create_job
from .audit import record_audit
@@ -116,23 +118,40 @@ def router(session_factory):
try: return render(request, "blockers.html", {"result": list_blockers(session, page=page, page_size=50, blocker_type=blocker_type), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
finally: session.close()
- @routes.post("/blockers/{blocker_id}/resolve")
- async def resolve_blocker(request: Request, blocker_id: int):
+ @routes.get("/blockers/{blocker_id}", response_class=HTMLResponse)
+ def blocker_page(request: Request, blocker_id: int):
+ session, auth_data, redirect = page_auth(request)
+ if redirect: return redirect
+ try:
+ detail = blocker_detail(session, blocker_id)
+ if detail is None: return HTMLResponse("Blocker not found", status_code=404)
+ blocker = session.get(BlockedProduct, blocker_id)
+ installation = session.get(Installation, blocker.installation_id) if blocker.installation_id else None
+ tenant = session.get(Tenant, blocker.tenant_id)
+ company_key = _company_key(blocker, installation, tenant)
+ current_context = session.scalars(select(FiscalContextEntry).where(FiscalContextEntry.tenant_id == blocker.tenant_id, FiscalContextEntry.company_key == company_key)).all()
+ value_choices = sorted({value for values in ENUMS.values() for value in values})
+ return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": current_context, "context_keys": sorted(ALLOWED_KEYS), "value_choices": value_choices, "error": None})
+ finally: session.close()
+
+ @routes.post("/blockers/{blocker_id}/resolve-context")
+ async def resolve_blocker_with_context_route(request: Request, blocker_id: int):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
- if values.get("confirm") != "yes" or not values.get("context", "").strip(): return HTMLResponse("confirmation and context required", status_code=400)
- from .models import BlockedProduct
- blocker = session.get(BlockedProduct, blocker_id)
- if not blocker: return HTMLResponse("Blocker not found", status_code=404)
- from datetime import datetime, timezone
- blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
- record_audit(session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker_id), context={"context": values["context"]})
- session.commit()
+ payload = {key: values.get(key) for key in ("scope", "scope_key", "key", "value", "reason")}
+ try:
+ resolve_blocker_with_context(session, blocker_id, admin, payload, request_ip=request.client.host if request.client else None)
+ except LookupError:
+ return HTMLResponse("Blocker not found", status_code=404)
+ except ValueError as error:
+ detail = blocker_detail(session, blocker_id)
+ return render(request, "blocker_detail.html", {"detail": detail, "auth": auth_data, "context_entries": [], "context_keys": sorted(ALLOWED_KEYS), "value_choices": sorted({value for values in ENUMS.values() for value in values}), "error": str(error)}, 422)
return RedirectResponse("/blockers", status_code=303)
- finally: session.close()
+ finally:
+ session.close()
@routes.get("/jobs", response_class=HTMLResponse)
def jobs(request: Request, page: int = 1, status: str | None = None):
diff --git a/central/templates/blocker_detail.html b/central/templates/blocker_detail.html
new file mode 100644
index 0000000..a0f03cd
--- /dev/null
+++ b/central/templates/blocker_detail.html
@@ -0,0 +1,11 @@
+{% extends "base.html" %}
+{% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %}
+{% block content %}
+
RESOLUÇÃO OPERACIONAL
Blocker {{ detail.id }}
{{ detail.client or detail.tenant_id }} · {{ detail.product_code }}
{{ detail.blocker_type }}
+{% if error %}{% endif %}
+
+ Detalhes do blocker
- Cliente
- {{ detail.client or "-" }}
- Servidor
- {{ detail.server.hostname or "-" }}
- Produto
- {{ detail.product_code }}
- Descrição
- {{ detail.description or "-" }}
- Campo
- {{ detail.field or "-" }}
- Tipo
- {{ detail.blocker_type }}
- Motivo
- {{ detail.reason }}
- Evidência
- {{ detail.evidence }}
+ Contexto atual
Valor atual por chave persistida para esta empresa.
{% for entry in context_entries %}{{ entry.key }}{{ entry.scope }} / {{ entry.scope_key or "empresa" }}{{ entry.value }}
{% else %}Nenhum valor definido.
{% endfor %}
+
+Salvar contexto e reprocessar
Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.
+{% endblock %}
diff --git a/central/templates/blockers.html b/central/templates/blockers.html
index c0eb5de..614bcdc 100644
--- a/central/templates/blockers.html
+++ b/central/templates/blockers.html
@@ -1 +1 @@
-{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}FILA FISCAL
Blockers globais
| Cliente | Produto | Campo | Tipo | Motivo | Última ocorrência | Ação |
{% for item in result["items"] %}| {{ item.client or item.tenant_id }} | {{ item.product_code }}{{ item.description or "" }} | {{ item.field }} | {{ item.blocker_type }} | {{ item.reason }} | {{ item.last_seen_at }}{{ item.retry_count }} retries | |
{% else %}| Nenhum blocker aberto. |
{% endfor %}
{% endblock %}
+{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}FILA FISCAL
Blockers globais
| Cliente | Produto | Campo | Tipo | Motivo | Última ocorrência | Ação |
{% for item in result["items"] %}| {{ item.client or item.tenant_id }} | {{ item.product_code }}{{ item.description or "" }} | {{ item.field }} | {{ item.blocker_type }} | {{ item.reason }} | {{ item.last_seen_at }}{{ item.retry_count }} retries | Resolver com contexto |
{% else %}| Nenhum blocker aberto. |
{% endfor %}
{% endblock %}
diff --git a/central/tests/test_blocker_resolution_panel.py b/central/tests/test_blocker_resolution_panel.py
new file mode 100644
index 0000000..b1585d3
--- /dev/null
+++ b/central/tests/test_blocker_resolution_panel.py
@@ -0,0 +1,155 @@
+from fastapi.testclient import TestClient
+from sqlalchemy import select
+
+from central.app.blocked_products import upsert_blocker
+from central.app.blocker_resolution import resolve_blocker_with_context, validate_resolution_input
+from central.app.main import create_app
+from central.app.models import AdminUser, AuditEvent, FiscalContextEntry, Job, Installation
+from central.tests.test_control_plane import seed, setup_db
+
+
+def _seed_blocker(session_factory):
+ agent_id, admin_id = seed(session_factory)
+ with session_factory() as session:
+ installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
+ installation.company_code = "ACME-COMPANY"
+ blocker = upsert_blocker(
+ session,
+ installation.tenant_id,
+ {
+ "company": "Acme",
+ "product_code": "P-001",
+ "description": "Produto de teste",
+ "blocker_type": "BLOCKED_MISSING_EVIDENCE",
+ "field": "CFOP",
+ "reason_code": "MISSING_CONTEXT",
+ "input_fingerprint": "input-1",
+ "evidence_summary": {"source": "test"},
+ },
+ installation_id=installation.id,
+ )
+ return blocker.id, admin_id, installation.id
+
+
+def test_validate_resolution_input_accepts_known_enum_values():
+ for key, value in (("entry_scope", "INTER"), ("product_role", "RESALE")):
+ normalized, error = validate_resolution_input(
+ {"scope": "PRODUCT", "scope_key": "P-001", "key": key, "value": value, "reason": "operator note"}
+ )
+ assert error is None
+ assert normalized == {
+ "scope": "PRODUCT",
+ "scope_key": "P-001",
+ "key": key,
+ "value": value,
+ "reason": "operator note",
+ }
+
+
+def test_validate_resolution_input_rejects_unsafe_or_incomplete_values():
+ invalid_payloads = (
+ {"scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "ARBITRARY", "reason": "note"},
+ {"scope": "PRODUCT", "scope_key": "P-001", "key": "not-a-key", "value": "x", "reason": "note"},
+ {"scope": "NCM", "scope_key": "22030000", "key": "entry_scope", "value": "INTER", "reason": "note"},
+ {"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": ""},
+ {"scope": "PRODUCT", "key": "product_role", "value": "RESALE", "reason": "note"},
+ {"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "note", "context": "free text"},
+ )
+
+ for payload in invalid_payloads:
+ normalized, error = validate_resolution_input(payload)
+ assert normalized is None
+ assert error
+
+
+def test_resolve_blocker_with_context_persists_one_context_row_job_and_sanitized_audit():
+ session_factory = setup_db()
+ blocker_id, admin_id, installation_id = _seed_blocker(session_factory)
+
+ with session_factory() as session:
+ admin = session.get(AdminUser, admin_id)
+ result = resolve_blocker_with_context(
+ session,
+ blocker_id,
+ admin,
+ {
+ "scope": "PRODUCT",
+ "scope_key": "P-001",
+ "key": "product_role",
+ "value": "RESALE",
+ "reason": "reviewed token=secret-value",
+ },
+ request_ip="192.0.2.10",
+ )
+
+ entry = session.scalar(select(FiscalContextEntry).where(FiscalContextEntry.key == "product_role"))
+ job = session.get(Job, result["job_id"])
+ audit = session.get(AuditEvent, result["audit_id"])
+
+ assert result["blocker_id"] == blocker_id
+ assert entry.company_key == "ACME-COMPANY"
+ assert entry.scope == "PRODUCT"
+ assert entry.scope_key == "P-001"
+ assert entry.value == "RESALE"
+ assert entry.source == "OPERATOR"
+ assert job.agent_id == session.get(Installation, installation_id).agent_id
+ assert job.kind == "REPROCESS_PRODUCTS"
+ assert job.payload == {"codigos": ["P-001"]}
+ assert audit.action == "RESOLVE_BLOCKER_WITH_CONTEXT"
+ assert audit.target_type == "blocker"
+ assert audit.target_id == str(blocker_id)
+ assert audit.context["reprocess_job_id"] == job.id
+ assert audit.context["before"] is None
+ assert audit.context["after"]["value"] == "RESALE"
+ assert audit.context["request_ip"] == "192.0.2.10"
+ assert "secret-value" not in str(audit.context)
+ assert session.query(FiscalContextEntry).count() == 1
+
+
+def test_human_resolution_detail_requires_auth_csrf_and_renders_safe_form():
+ session_factory = setup_db()
+ blocker_id, _, _ = _seed_blocker(session_factory)
+ client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
+
+ assert client.get(f"/blockers/{blocker_id}", follow_redirects=False).status_code == 303
+ client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
+ detail = client.get(f"/blockers/{blocker_id}")
+ assert detail.status_code == 200
+ assert "Salvar contexto e reprocessar" in detail.text
+ assert "Cliente" in detail.text and "Servidor" in detail.text and "Produto" in detail.text
+ assert "Evidência" in detail.text and "Valores aceitos" in detail.text
+ assert "data-confirm" not in detail.text
+ assert 'name="context"' not in detail.text
+
+ missing_csrf = client.post(
+ f"/blockers/{blocker_id}/resolve-context",
+ data={"scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "RESALE", "reason": "reviewed"},
+ )
+ assert missing_csrf.status_code == 403
+
+
+def test_human_and_json_resolution_routes_use_the_same_validated_workflow():
+ session_factory = setup_db()
+ blocker_id, _, _ = _seed_blocker(session_factory)
+ client = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None))
+ client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
+ csrf = client.cookies.get("gti_csrf")
+
+ invalid = client.post(
+ f"/v1/blockers/{blocker_id}/resolve-human",
+ json={"csrf_token": csrf, "scope": "PRODUCT", "scope_key": "P-001", "key": "product_role", "value": "free text", "reason": "reviewed"},
+ )
+ assert invalid.status_code == 422
+
+ api_result = client.post(
+ f"/v1/blockers/{blocker_id}/resolve-human",
+ json={"csrf_token": csrf, "scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "INTER", "reason": "reviewed"},
+ )
+ assert api_result.status_code == 200
+ assert set(api_result.json()) == {"blocker_id", "job_id", "audit_id"}
+
+ unauthenticated = TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)).post(
+ f"/v1/blockers/{blocker_id}/resolve-human",
+ json={"scope": "PRODUCT", "scope_key": "P-001", "key": "entry_scope", "value": "INTER", "reason": "reviewed"},
+ )
+ assert unauthenticated.status_code == 401