From 2da1f9bdc471f02c9086a6002ce0ae0c71e34ac3 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Sat, 14 Feb 2026 20:47:50 -0300 Subject: [PATCH 01/24] fix(sap): make DraggableHud use pointer capture to prevent Leaflet pan Co-authored-by: Cursor --- frontend/src/ui/DraggableHud.tsx | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/frontend/src/ui/DraggableHud.tsx b/frontend/src/ui/DraggableHud.tsx index 057471158..d90bed3fa 100644 --- a/frontend/src/ui/DraggableHud.tsx +++ b/frontend/src/ui/DraggableHud.tsx @@ -168,20 +168,21 @@ export function DraggableHud({ ensureOnscreen("double"); }, [draggable, ensureOnscreen]); - // Pointer down: registrar inicio pero no empezar drag aún + // Pointer down: capture immediately to prevent Leaflet from receiving events const handlePointerDown = useCallback((event: React.PointerEvent) => { - if (!draggable) return; // ← NO drag si disabled + if (!draggable) return; - // Ignorar clicks en elementos con data-no-drag const target = event.target as HTMLElement; if (target.closest("[data-no-drag]")) return; const el = containerRef.current; if (!el) return; const hasHandle = !!el.querySelector("[data-drag-handle]"); - if (hasHandle && !target.closest("[data-drag-handle]")) { - return; - } + if (hasHandle && !target.closest("[data-drag-handle]")) return; + + event.preventDefault(); + event.stopPropagation(); + el.setPointerCapture(event.pointerId); dragStartRef.current = { x: event.clientX, y: event.clientY }; dragOffsetRef.current = { @@ -208,14 +209,10 @@ export function DraggableHud({ return; } - // Activar drag mode + // Activar drag mode (capture ya hecha en pointerdown) if (!hasDraggedRef.current) { hasDraggedRef.current = true; setIsDragging(true); - const el = containerRef.current; - if (el && activePointerIdRef.current !== null) { - el.setPointerCapture(activePointerIdRef.current); - } } // Supera threshold, es drag - prevenir eventos para no interferir @@ -232,16 +229,14 @@ export function DraggableHud({ const onWinUp = (ev: PointerEvent) => { if (activePointerIdRef.current === null || ev.pointerId !== activePointerIdRef.current) return; const el = containerRef.current; + if (el) el.releasePointerCapture(ev.pointerId); if (hasDraggedRef.current && el) { - el.releasePointerCapture(activePointerIdRef.current); const latest = posRef.current; const clamped = clampToViewport(latest.x, latest.y, el); setPos(clamped); persistPos(clamped.x, clamped.y); posRef.current = clamped; - - // Prevenir click cuando hubo drag ev.preventDefault(); ev.stopPropagation(); } From d48aae0582fd2810fa6ba185fc20aa7d9da37235 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Sat, 14 Feb 2026 21:28:01 -0300 Subject: [PATCH 02/24] fix(sap): stabilize dev build identity + overlay drag capture PASO 1 - Dev build identity: - Inline script in index.html (vite) auto-unregisters SW on localhost:5173/5174/5177/4173 - ConnectionStatus toast: pointer-events:none on container, only Retry button clickable PASO 2 - Overlay drag capture (Brave/Leaflet): - startDrag: preventDefault + stopPropagation + stopImmediatePropagation - setPointerCapture inmediato en el handle/contenedor - statusHud (Weather): draggable:true, autoHandle:true - DevOverlayHost: Clear SW + reload button visible cuando SW activo Checklist: - URL local: http://localhost:5174/map?start=realtime&debug=1 - BUILD/sha verificado == git rev-parse --short HEAD - Drag Weather OK - Drag Debug HUD OK - Server Unavailable no bloquea UI Co-authored-by: Cursor --- .../maps/realtime/DevOverlayHost.tsx | 28 ++++++++ .../maps/realtime/OverlaySandbox.tsx | 14 ++-- .../maps/realtime/hooks/useMapStatus.ts | 5 +- .../realtime/hooks/useOverlayDockItems.tsx | 4 ++ .../src/components/pwa/ConnectionStatus.tsx | 64 ++++++++++--------- frontend/vite.config.ts | 18 ++++++ 6 files changed, 97 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/maps/realtime/DevOverlayHost.tsx b/frontend/src/components/maps/realtime/DevOverlayHost.tsx index 673a5da7f..829cf3629 100644 --- a/frontend/src/components/maps/realtime/DevOverlayHost.tsx +++ b/frontend/src/components/maps/realtime/DevOverlayHost.tsx @@ -186,7 +186,35 @@ PNBOIA Nearest: ${(layersInfo.pnboiaNearest || []) +
+ Si BUILD no coincide con git rev-parse --short HEAD, limpiar SW: DevTools → Application → Service Workers → Unregister. +
+ {"serviceWorker" in navigator && swStatus === "activo" ? ( + + ) : null} +

+ ); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a0bc6b060..b526bdddc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -54,7 +54,25 @@ export default defineConfig({ { name: 'inject-dev-context-meta', transformIndexHtml(html) { + const devSwKill = [ + '', + ].join('') return html.replace( + '', + '' + devSwKill, + ).replace( '', [ '', From 756bfc85e40b24fd4081730d8f050fad647d76d5 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Sat, 14 Feb 2026 21:49:54 -0300 Subject: [PATCH 03/24] fix(sap): make overlays draggable in Brave and align build identity Co-authored-by: Cursor --- .../maps/realtime/DevOverlayHost.tsx | 21 +++++++++++++++---- .../maps/realtime/OverlaySandbox.tsx | 6 ++++++ frontend/src/ui/DraggableHud.tsx | 5 +++++ frontend/vite.config.ts | 5 +++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/maps/realtime/DevOverlayHost.tsx b/frontend/src/components/maps/realtime/DevOverlayHost.tsx index 829cf3629..101ae0ee5 100644 --- a/frontend/src/components/maps/realtime/DevOverlayHost.tsx +++ b/frontend/src/components/maps/realtime/DevOverlayHost.tsx @@ -56,8 +56,8 @@ export function DevOverlayHost() { setUiMode(isMapRoute && hasNewLayout ? "new-ui" : "legacy"); }, []); - const buildId = versionData?.buildId || versionData?.build_id || import.meta.env.VITE_BUILD_ID || "N/A"; - const gitCommit = versionData?.gitCommit || versionData?.git_sha || versionData?.commit || "N/A"; + const buildId = import.meta.env.VITE_BUILD_ID || "N/A"; + const gitCommit = import.meta.env.VITE_GIT_SHA || "N/A"; const copyDebugInfo = () => { const debugInfo = `iURi Debug Info @@ -85,6 +85,15 @@ PNBOIA Nearest: ${(layersInfo.pnboiaNearest || []) const copyDebugUrl = () => { const url = new URL(window.location.href); + const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.pathname.startsWith("/map")) { + url.searchParams.set("start", "realtime"); + if (isLocalhost) { + url.searchParams.set("__devreload", "1"); + } else { + url.searchParams.delete("__devreload"); + } + } url.searchParams.set("debug", "1"); navigator.clipboard.writeText(url.toString()); }; @@ -194,8 +203,12 @@ PNBOIA Nearest: ${(layersInfo.pnboiaNearest || []) + {centerError ? ( +
GPS: {centerError}
+ ) : null} {/* Botón de activar GPS tracker (si no está activo) */} {showActivateButton && ( diff --git a/frontend/src/components/maps/MyLocationMarker.tsx b/frontend/src/components/maps/MyLocationMarker.tsx index 0b8baaa52..3dc6710f5 100644 --- a/frontend/src/components/maps/MyLocationMarker.tsx +++ b/frontend/src/components/maps/MyLocationMarker.tsx @@ -3,9 +3,9 @@ * Incluye marker + círculo de precisión */ -import React, { useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { useMap } from "react-leaflet"; -import { Circle, Marker, Popup, useMapEvents } from "react-leaflet"; +import { Circle, Marker, Popup, Tooltip, useMapEvents } from "react-leaflet"; import L from "leaflet"; import { useLastLocation } from "../../hooks/useLastLocation"; @@ -42,22 +42,81 @@ interface MyLocationMarkerProps { autoCenter?: boolean; // Si true, centra automáticamente en la primera ubicación autoCenterZoom?: number; // Zoom level para auto-center showAccuracyCircle?: boolean; // Mostrar círculo de precisión + visible?: boolean; // Mostrar/ocultar marker sin desmontar controlador de centrado pollMs?: number; // Intervalo de polling onLocationUpdate?: (loc: { lat: number; lon: number; accuracy_m: number } | null) => void; } +type MarkerLocation = { + device_id: string; + lat: number; + lon: number; + accuracy_m: number; + ts_iso: string; + source: string; + share_precision: "exact" | "coarse" | "none"; +}; + export const MyLocationMarker: React.FC = ({ autoCenter = false, autoCenterZoom = 12, showAccuracyCircle = true, + visible = true, pollMs = 5000, onLocationUpdate, }) => { const map = useMap(); - const { loc, status, error, isStale } = useLastLocation({ pollMs, enabled: true }); + const { loc, status, isStale } = useLastLocation({ pollMs, enabled: true }); const hasAutoCenteredRef = useRef(false); const userInteractedRef = useRef(false); + const requestBrowserGeoCenter = useCallback(() => { + if (!navigator.geolocation) { + window.dispatchEvent( + new CustomEvent("iuri:center-on-me-error", { detail: "GPS unavailable (browser geolocation unsupported)" }) + ); + return; + } + navigator.geolocation.getCurrentPosition( + (pos) => { + const next: MarkerLocation = { + device_id: "browser", + lat: pos.coords.latitude, + lon: pos.coords.longitude, + accuracy_m: Math.max(1, Math.round(pos.coords.accuracy || 30)), + ts_iso: new Date().toISOString(), + source: "browser_geolocation", + share_precision: "exact", + }; + map.setView([next.lat, next.lon], Math.max(map.getZoom(), autoCenterZoom)); + window.dispatchEvent(new CustomEvent("iuri:center-on-me-success")); + }, + (geoErr) => { + const message = + geoErr.code === geoErr.PERMISSION_DENIED + ? "GPS permission denied" + : geoErr.code === geoErr.POSITION_UNAVAILABLE + ? "GPS unavailable" + : "GPS timeout"; + window.dispatchEvent(new CustomEvent("iuri:center-on-me-error", { detail: message })); + }, + { enableHighAccuracy: true, timeout: 7000, maximumAge: 10000 } + ); + }, [autoCenterZoom, map]); + + useEffect(() => { + const handler = () => { + if (loc && status === "ok") { + map.setView([loc.lat, loc.lon], Math.max(map.getZoom(), autoCenterZoom)); + window.dispatchEvent(new CustomEvent("iuri:center-on-me-success")); + return; + } + requestBrowserGeoCenter(); + }; + window.addEventListener("iuri:center-on-me", handler as EventListener); + return () => window.removeEventListener("iuri:center-on-me", handler as EventListener); + }, [autoCenterZoom, loc, map, requestBrowserGeoCenter, status]); + // Trackear interacción del usuario (pan/zoom manual) useMapEvents({ dragstart: () => { @@ -95,8 +154,9 @@ export const MyLocationMarker: React.FC = ({ } }, [loc, onLocationUpdate]); - // Si no hay ubicación o hay error, no renderizar nada - if (!loc || status === "error") { + // El controlador de centrado permanece montado, pero el marker solo se renderiza + // cuando la capa está visible y existe loc del store. + if (!visible || !loc) { return null; } @@ -130,6 +190,9 @@ export const MyLocationMarker: React.FC = ({ {/* Marker "Yo" */} + + My location +
📍 My Location
diff --git a/frontend/src/components/maps/PnboiaLayer.tsx b/frontend/src/components/maps/PnboiaLayer.tsx index 9f19d75e8..9749c4f1f 100644 --- a/frontend/src/components/maps/PnboiaLayer.tsx +++ b/frontend/src/components/maps/PnboiaLayer.tsx @@ -1,25 +1,12 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Marker, Popup, Tooltip } from 'react-leaflet'; -import L from 'leaflet'; -import { Waves, Wind, Thermometer } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { getApiUrl } from '../../config/appConfig'; - -// Fix para íconos de Leaflet -import 'leaflet/dist/leaflet.css'; -import icon from 'leaflet/dist/images/marker-icon.png'; -import iconShadow from 'leaflet/dist/images/marker-shadow.png'; - -const DefaultIcon = L.icon({ - iconUrl: icon, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41] -}); - -L.Marker.prototype.options.icon = DefaultIcon; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMap, useMapEvents } from "react-leaflet"; +import type { Map as MapLibreMap } from "maplibre-gl"; +import * as maplibregl from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { getApiUrl } from "../../config/appConfig"; +import { API_BASE, checkApiHealthOnce, getHealthStateCached } from "../../lib/apiBase"; +import { PMAP1_SUPPORT_STRUCTURES } from "../../lib/geo/pmap1SupportStructures"; +import { registerMapLibre } from "../../lib/mapRefStore"; interface BuoyData { buoy_id: string; @@ -27,79 +14,186 @@ interface BuoyData { state: string; position: { lat: number; lon: number }; distance_km: number; - data: { - water_temperature: number; - salinity: number; - wave_height: number; - wave_period: number; - wave_direction: number; - wind_speed: number; - wind_direction: number; - air_pressure: number; - sea_state: string; - }; - timestamp: string; - source: string; } +type PnboiaDebugInfo = { + total: number; + rendered: number; + points: Array<{ + id: string; + name: string; + lat: number; + lon: number; + distanceKm: number; + }>; +}; + +type PnboiaFeatureProps = { + id: string; + name: string; + kind?: "pmap1" | "pnboia"; + isProbe?: boolean; +}; + +type PnboiaGeoJson = GeoJSON.FeatureCollection; + +const MERGED_SOURCE_ID = "pmap1-pnboia"; +const PMAP1_CIRCLES_LAYER_ID = "pmap1-circles"; +const PNBOIA_CIRCLES_LAYER_ID = "pnboia-circles"; +const PNBOIA_LABELS_LAYER_ID = "pnboia-labels"; +const PNBOIA_FOCUS_SOURCE_ID = "pnboia-focus"; +const PNBOIA_FOCUS_LAYER_ID = "pnboia-focus-ring"; +const PNBOIA_PROBE_LAT = -23.0; +const PNBOIA_PROBE_LON = -42.0; +const FETCH_TIMEOUT_MS = 4000; +const TEST_BUOY_LON = -42.0; +const TEST_BUOY_LAT = -23.0; + +/** Haversine distance in km between two [lat, lon] points */ +const haversineKm = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +}; + +const normalizeBuoys = (rawItems: any[]): BuoyData[] => + rawItems + .map((item, idx) => { + const lat = Number(item?.position?.lat ?? item?.lat); + const lon = Number(item?.position?.lon ?? item?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const id = item?.id ?? item?.buoy_id ?? item?.name ?? `pnboia-${idx + 1}-${lat.toFixed(4)}-${lon.toFixed(4)}`; + const name = String(item?.name ?? item?.id ?? item?.buoy_id ?? id); + return { + buoy_id: String(id), + name, + state: String(item?.state ?? "unknown"), + position: { lat, lon }, + distance_km: Number(item?.distance_km ?? 0), + } as BuoyData; + }) + .filter((item): item is BuoyData => item !== null); + interface PnboiaLayerProps { center: [number, number]; zoom?: number; radiusKm?: number; maxVisible?: number; - mode?: 'cluster' | 'individual'; + mode?: "cluster" | "individual"; showLabels?: boolean; backendDown?: boolean; showStatusBadge?: boolean; onLoadingChange?: (loading: boolean) => void; onErrorChange?: (error: string | null) => void; - /** Leaflet pane for markers (e.g. "pnboiaMarkersPane") */ pane?: string; - /** Called when buoys data is loaded (for debug badge) */ onBuoysLoaded?: (count: number) => void; - /** Called when PNBOIA debug data changes */ - onDebugInfoChange?: (info: { - total: number; - rendered: number; - points: Array<{ - id: string; - name: string; - lat: number; - lon: number; - distanceKm: number; - }>; - }) => void; + onDebugInfoChange?: (info: PnboiaDebugInfo) => void; } +const buildGeoJson = (buoys: BuoyData[], maxVisible: number): PnboiaGeoJson => { + const testBuoyFeature: GeoJSON.Feature = { + type: "Feature", + geometry: { type: "Point", coordinates: [TEST_BUOY_LON, TEST_BUOY_LAT] }, + properties: { id: "test-buoy", name: "TEST BUOY", kind: "pnboia", isProbe: true }, + }; + const pmap1Features: Array> = PMAP1_SUPPORT_STRUCTURES.map( + (item) => ({ + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: [item.lon, item.lat] }, + properties: { id: item.id, name: item.name, kind: "pmap1" as const }, + }) + ); + const sorted = [...buoys].sort((a, b) => a.distance_km - b.distance_km); + const clipped = sorted.slice(0, Math.max(5, maxVisible)); + const buoyFeatures: Array> = clipped.map((buoy) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [buoy.position.lon, buoy.position.lat], + }, + properties: { + id: buoy.buoy_id, + name: buoy.name, + kind: "pnboia", + }, + })); + return { + type: "FeatureCollection", + features: [testBuoyFeature, ...buoyFeatures, ...pmap1Features], + }; +}; + export const PnboiaLayer: React.FC = ({ - center, - zoom = 8, - radiusKm = 500, maxVisible = 50, - mode = 'individual', - showLabels = false, backendDown = false, - showStatusBadge = true, onLoadingChange, onErrorChange, - pane, onBuoysLoaded, onDebugInfoChange, }) => { - const { t } = useTranslation(); + const leafletMap = useMap(); + const [buoys, setBuoys] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const cacheRef = useRef>( - new Map() - ); - const inflightRef = useRef<{ key: string; controller: AbortController } | null>( - null + const [lastFetchStatus, setLastFetchStatus] = useState<"idle" | "loading" | "ok" | "error">("idle"); + const [httpStatus, setHttpStatus] = useState(null); + const [elapsedMs, setElapsedMs] = useState(0); + const [mapReady, setMapReady] = useState(false); + const [apiHealth, setApiHealth] = useState<"idle" | "ok" | "error">(getHealthStateCached()); + const [mapViewKey, setMapViewKey] = useState(0); + useMapEvents({ + moveend: () => setMapViewKey((k) => k + 1), + }); + const debugMode = useMemo(() => { + try { + return new URLSearchParams(window.location.search).get("debug") === "1"; + } catch { + return false; + } + }, []); + + const mapLibreContainerRef = useRef(null); + const mapLibreMapRef = useRef(null); + const initializedRef = useRef(false); + const pendingGeoJsonRef = useRef(buildGeoJson([], maxVisible)); + const focusClearTimerRef = useRef(null); + const [maplibreMapOk, setMaplibreMapOk] = useState(false); + const [sourceOk, setSourceOk] = useState(false); + const [circlesOk, setCirclesOk] = useState(false); + const [lastSetDataTs, setLastSetDataTs] = useState(""); + + const updateSourceData = useMemo( + () => (data: PnboiaGeoJson) => { + pendingGeoJsonRef.current = data; + const map = mapLibreMapRef.current; + if (!map) { + setSourceOk(false); + setCirclesOk(false); + return; + } + setMaplibreMapOk(true); + const src = map.getSource(MERGED_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; + if (!src) { + setSourceOk(false); + setCirclesOk(false); + return; + } + setSourceOk(true); + setCirclesOk(!!map.getLayer(PNBOIA_CIRCLES_LAYER_ID)); + src.setData(data); + setLastSetDataTs(new Date().toISOString().slice(11, 23)); + }, + [] ); - const lastErrorKeyRef = useRef(null); - const lastErrorMessageRef = useRef(null); - const TTL_MS = 60000; - const MAX_RETRIES = 2; useEffect(() => { onLoadingChange?.(loading); @@ -109,283 +203,388 @@ export const PnboiaLayer: React.FC = ({ onErrorChange?.(error); }, [error, onErrorChange]); - const requestKey = useMemo( - () => `${center[0].toFixed(4)}|${center[1].toFixed(4)}|${radiusKm}`, - [center[0], center[1], radiusKm] - ); + useEffect(() => { + if (!debugMode) return; + if (apiHealth !== "idle") return; + void checkApiHealthOnce().then((state) => { + setApiHealth(state); + }); + }, [apiHealth, debugMode]); useEffect(() => { - const lat = center[0]; - const lon = center[1]; - if (backendDown) { - inflightRef.current?.controller.abort(); - inflightRef.current = null; - setBuoys([]); - setError(null); - setLoading(false); - onBuoysLoaded?.(0); + if (!leafletMap || initializedRef.current) { return; } - const cached = cacheRef.current.get(requestKey); - const now = Date.now(); - if (cached && now - cached.ts < TTL_MS) { - setBuoys(cached.data); - setError(null); - setLoading(false); - onBuoysLoaded?.(cached.data.length); + const container = document.createElement("div"); + container.style.position = "absolute"; + container.style.top = "0"; + container.style.left = "0"; + container.style.width = "100%"; + container.style.height = "100%"; + container.style.pointerEvents = "none"; + container.style.zIndex = "1200"; + + const leafletContainer = leafletMap.getContainer(); + const mapPane = leafletContainer.querySelector(".leaflet-map-pane") as HTMLElement | null; + if (!mapPane) { + setMapReady(false); + setError("map not ready"); return; } + mapPane.appendChild(container); + mapLibreContainerRef.current = container; + + const center = leafletMap.getCenter(); + const zoom = leafletMap.getZoom(); + const mapLibre = new maplibregl.Map({ + container, + style: { version: 8, sources: {}, layers: [] }, + center: [center.lng, center.lat], + zoom, + interactive: false, + attributionControl: false, + }); + + const syncMapLibre = () => { + const localMap = mapLibreMapRef.current; + if (!localMap) return; + const c = leafletMap.getCenter(); + localMap.setCenter([c.lng, c.lat]); + localMap.setZoom(leafletMap.getZoom()); + }; + + leafletMap.on("move", syncMapLibre); + leafletMap.on("zoom", syncMapLibre); + leafletMap.on("resize", () => mapLibre.resize()); + + mapLibre.on("load", () => { + try { + mapLibre.addSource(MERGED_SOURCE_ID, { + type: "geojson", + data: pendingGeoJsonRef.current as unknown as GeoJSON.FeatureCollection, + }); + mapLibre.addLayer({ + id: PMAP1_CIRCLES_LAYER_ID, + type: "circle", + source: MERGED_SOURCE_ID, + filter: ["==", ["get", "kind"], "pmap1"], + paint: { + "circle-radius": 10, + "circle-color": "#64748b", + "circle-stroke-width": 2, + "circle-stroke-color": "#94a3b8", + "circle-opacity": 0.92, + }, + }); + mapLibre.addLayer({ + id: PNBOIA_CIRCLES_LAYER_ID, + type: "circle", + source: MERGED_SOURCE_ID, + filter: ["==", ["get", "kind"], "pnboia"], + paint: { + "circle-radius": [ + "case", + ["==", ["get", "isProbe"], true], + 18, + 10, + ], + "circle-color": [ + "case", + ["==", ["get", "isProbe"], true], + "#ef4444", + "#0ea5e9", + ], + "circle-stroke-width": 3, + "circle-stroke-color": "#ffffff", + "circle-opacity": 0.92, + }, + }); + mapLibre.addLayer({ + id: PNBOIA_LABELS_LAYER_ID, + type: "symbol", + source: MERGED_SOURCE_ID, + filter: ["==", ["get", "kind"], "pnboia"], + layout: { + "text-field": ["get", "name"], + "text-size": [ + "case", + ["==", ["get", "isProbe"], true], + 16, + 12, + ], + "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"], + "text-offset": [0, 1.4], + "text-anchor": "top", + }, + paint: { + "text-color": "#ffffff", + "text-halo-color": "#111827", + "text-halo-width": 2, + }, + }); + mapLibre.addSource(PNBOIA_FOCUS_SOURCE_ID, { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }); + mapLibre.addLayer({ + id: PNBOIA_FOCUS_LAYER_ID, + type: "circle", + source: PNBOIA_FOCUS_SOURCE_ID, + paint: { + "circle-radius": 24, + "circle-color": "rgba(0,0,0,0)", + "circle-stroke-width": 4, + "circle-stroke-color": "#fde047", + "circle-opacity": 0.95, + }, + }); + mapLibre.moveLayer(PMAP1_CIRCLES_LAYER_ID); + mapLibre.moveLayer(PNBOIA_CIRCLES_LAYER_ID); + mapLibre.moveLayer(PNBOIA_LABELS_LAYER_ID); + mapLibre.moveLayer(PNBOIA_FOCUS_LAYER_ID); + mapLibre.resize(); + setMapReady(true); + updateSourceData(pendingGeoJsonRef.current); + } catch (layerError) { + setMapReady(false); + setError(layerError instanceof Error ? layerError.message : "map not ready"); + setLastFetchStatus("error"); + } + }); + + mapLibreMapRef.current = mapLibre; + initializedRef.current = true; + registerMapLibre(mapLibre); + + return () => { + registerMapLibre(null); + leafletMap.off("move", syncMapLibre); + leafletMap.off("zoom", syncMapLibre); + leafletMap.off("resize", () => mapLibre.resize()); + if (focusClearTimerRef.current) { + window.clearTimeout(focusClearTimerRef.current); + focusClearTimerRef.current = null; + } + if (mapLibreMapRef.current) { + mapLibreMapRef.current.remove(); + mapLibreMapRef.current = null; + } + if (mapLibreContainerRef.current?.parentElement) { + mapLibreContainerRef.current.parentElement.removeChild(mapLibreContainerRef.current); + } + mapLibreContainerRef.current = null; + initializedRef.current = false; + setMapReady(false); + }; + }, [leafletMap, updateSourceData]); + + useEffect(() => { + if (!mapReady || !leafletMap) return; + updateSourceData(pendingGeoJsonRef.current); + }, [mapReady, leafletMap, updateSourceData]); + + useEffect(() => { + const onGoToBuoy = (event: Event) => { + const detail = (event as CustomEvent<{ lat?: number; lon?: number }>).detail; + const lat = Number(detail?.lat); + const lon = Number(detail?.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + leafletMap.flyTo([lat, lon], 8, { duration: 1.1 }); + const map = mapLibreMapRef.current; + if (!map || !mapReady) return; + const focusSource = map.getSource(PNBOIA_FOCUS_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; + if (!focusSource) return; + focusSource.setData({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { type: "Point", coordinates: [lon, lat] }, + properties: {}, + }, + ], + } as unknown as GeoJSON.FeatureCollection); + if (focusClearTimerRef.current) { + window.clearTimeout(focusClearTimerRef.current); + } + focusClearTimerRef.current = window.setTimeout(() => { + const currentMap = mapLibreMapRef.current; + const currentSource = currentMap?.getSource(PNBOIA_FOCUS_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; + currentSource?.setData({ type: "FeatureCollection", features: [] } as unknown as GeoJSON.FeatureCollection); + focusClearTimerRef.current = null; + }, 1600); + }; + window.addEventListener("iuri:pnboia-go", onGoToBuoy as EventListener); + return () => { + window.removeEventListener("iuri:pnboia-go", onGoToBuoy as EventListener); + }; + }, [leafletMap, mapReady]); + + useEffect(() => { + const points = buoys.map((buoy) => ({ + id: buoy.buoy_id, + name: buoy.name, + lat: buoy.position.lat, + lon: buoy.position.lon, + distanceKm: Number((buoy.distance_km ?? 0).toFixed(2)), + })); + const rendered = Math.min(Math.max(5, maxVisible), buoys.length) + 1; // +1 probe + onDebugInfoChange?.({ + total: buoys.length, + rendered, + points, + }); + window.dispatchEvent(new CustomEvent("iuri:pnboia-list", { detail: points })); + }, [buoys, maxVisible, onDebugInfoChange]); + + useEffect(() => { + const geo = buildGeoJson(buoys, maxVisible); + updateSourceData(geo); + }, [buoys, maxVisible, updateSourceData]); - if (inflightRef.current?.key === requestKey) { + useEffect(() => { + if (!mapReady) { + setLoading(false); + setLastFetchStatus("idle"); return; } - inflightRef.current?.controller.abort(); + let cancelled = false; + const startedAt = performance.now(); const controller = new AbortController(); - inflightRef.current = { key: requestKey, controller }; - setLoading(true); - setError(null); + const timer = window.setTimeout(() => { + controller.abort(); + }, FETCH_TIMEOUT_MS); + + const run = async () => { + setLoading(true); + setError(null); + setLastFetchStatus("loading"); + setHttpStatus(null); - const fetchWithRetry = async (attempt: number): Promise => { try { - const response = await fetch( - getApiUrl( - `/api/v1/pnboia/nearby?lat=${lat}&lon=${lon}&radius_km=${radiusKm}` - ), - { signal: controller.signal } - ); + const listUrl = getApiUrl("/api/v1/pnboia/list"); + const response: Response = await fetch(listUrl, { signal: controller.signal }); + + const ms = Math.round(performance.now() - startedAt); + setElapsedMs(ms); + setHttpStatus(response.status); if (!response.ok) { - const msg = `PNBOIA fetch failed: ${response.status} ${response.statusText}`; - if (response.status === 404) { - cacheRef.current.set(requestKey, { ts: Date.now(), data: [] }); - setBuoys([]); - onBuoysLoaded?.(0); - return; - } - console.warn(msg); - throw new Error('Error al cargar boyas PNBOIA'); + throw new Error(`HTTP ${response.status}`); } const data = await response.json(); - const items = data.buoys || []; - cacheRef.current.set(requestKey, { ts: Date.now(), data: items }); + const rawItems = Array.isArray(data?.buoys) + ? data.buoys + : Array.isArray(data?.items) + ? data.items + : []; + const items = normalizeBuoys(rawItems); + if (cancelled) return; setBuoys(items); onBuoysLoaded?.(items.length); - } catch (err) { - if (controller.signal.aborted) { - return; - } - if (attempt < MAX_RETRIES) { - const delay = 500 * Math.pow(2, attempt); - await new Promise((resolve) => setTimeout(resolve, delay)); - return fetchWithRetry(attempt + 1); - } - const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - if (!backendDown && !errorMessage.includes('404')) { - setError(errorMessage); - if ( - lastErrorKeyRef.current !== requestKey || - lastErrorMessageRef.current !== errorMessage - ) { - lastErrorKeyRef.current = requestKey; - lastErrorMessageRef.current = errorMessage; - console.error('Error cargando boyas PNBOIA:', err); - } - } + setLastFetchStatus("ok"); + } catch (fetchError) { + if (cancelled) return; + const ms = Math.round(performance.now() - startedAt); + setElapsedMs(ms); + setLastFetchStatus("error"); setBuoys([]); onBuoysLoaded?.(0); + if ((fetchError as Error)?.name === "AbortError") { + setError(`timeout ${FETCH_TIMEOUT_MS}ms`); + } else { + setError(fetchError instanceof Error ? fetchError.message : "fetch error"); + } } finally { - if (!controller.signal.aborted) { + if (!cancelled) { setLoading(false); } } }; - fetchWithRetry(0); + void run(); return () => { + cancelled = true; + window.clearTimeout(timer); controller.abort(); }; - }, [backendDown, center[0], center[1], radiusKm, requestKey]); - - // Icono personalizado para boyas - const BuoyIcon = L.divIcon({ - className: 'custom-buoy-marker', - html: `
🏖️
`, - iconSize: [32, 32], - iconAnchor: [16, 16], - popupAnchor: [0, -16] - }); + }, [backendDown, apiHealth, mapReady, onBuoysLoaded]); - const visibleBuoys = useMemo(() => { - if (!buoys.length) return []; - const sorted = [...buoys].sort((a, b) => a.distance_km - b.distance_km); - return sorted.slice(0, Math.max(5, maxVisible)); - }, [buoys, maxVisible]); - - const clusteredBuoys = useMemo(() => { - if (!buoys.length) return []; - const gridSize = 0.4; // ~45km, suficiente para agrupar a bajo zoom - const buckets = new Map(); - - for (const buoy of buoys) { - const latKey = Math.round(buoy.position.lat / gridSize) * gridSize; - const lonKey = Math.round(buoy.position.lon / gridSize) * gridSize; - const key = `${latKey.toFixed(2)}:${lonKey.toFixed(2)}`; - - const existing = buckets.get(key); - if (existing) { - buckets.set(key, { - lat: (existing.lat * existing.count + buoy.position.lat) / (existing.count + 1), - lon: (existing.lon * existing.count + buoy.position.lon) / (existing.count + 1), - count: existing.count + 1, - }); - } else { - buckets.set(key, { lat: buoy.position.lat, lon: buoy.position.lon, count: 1 }); + const statusText = mapReady ? lastFetchStatus : "map not ready"; + const featuresCount = useMemo( + () => buildGeoJson(buoys, maxVisible).features.length, + [buoys, maxVisible] + ); + + const { inViewport, nearest } = useMemo(() => { + let inView = 0; + let nearestBuoy: BuoyData | null = null; + let nearestKm = Infinity; + if (!leafletMap || buoys.length === 0) return { inViewport: 0, nearest: null }; + const bounds = leafletMap.getBounds(); + const center = leafletMap.getCenter(); + const clat = center.lat; + const clon = center.lng; + for (const b of buoys) { + if (bounds.contains([b.position.lat, b.position.lon])) inView++; + const d = haversineKm(clat, clon, b.position.lat, b.position.lon); + if (d < nearestKm) { + nearestKm = d; + nearestBuoy = b; } } + return { inViewport: inView, nearest: nearestBuoy ? { buoy: nearestBuoy, km: nearestKm } : null }; + }, [leafletMap, buoys, mapViewKey]); - const clusters = Array.from(buckets.values()).sort((a, b) => b.count - a.count); - return clusters.slice(0, Math.max(8, maxVisible)); - }, [buoys, maxVisible]); + const goToCaboFrio = useCallback(() => { + leafletMap.flyTo([TEST_BUOY_LAT, TEST_BUOY_LON], Math.max(leafletMap.getZoom(), 8), { animate: true }); + }, [leafletMap]); - useEffect(() => { - const points = buoys.map((buoy) => ({ - id: buoy.buoy_id, - name: buoy.name, - lat: buoy.position.lat, - lon: buoy.position.lon, - distanceKm: Number((buoy.distance_km ?? 0).toFixed(2)), - })); - const rendered = mode === "cluster" ? clusteredBuoys.length : visibleBuoys.length; - onDebugInfoChange?.({ - total: buoys.length, - rendered, - points, - }); - }, [buoys, clusteredBuoys.length, mode, onDebugInfoChange, visibleBuoys.length]); + const goToNearest = useCallback(() => { + if (!nearest) return; + const zoom = Math.max(leafletMap.getZoom(), 8); + leafletMap.flyTo([nearest.buoy.position.lat, nearest.buoy.position.lon], zoom, { animate: true }); + }, [leafletMap, nearest]); return ( <> - {showStatusBadge && loading && ( -
- {t('map.loadingBuoys') || 'Cargando boyas PNBOIA...'} +
+
+ + PNBOIA ON | fetched={buoys.length} | mergedInto={MERGED_SOURCE_ID} | lastUpdate={lastSetDataTs || "-"} + + + {nearest ? ( + + ) : null}
- )} - - {/* No mostrar error si el endpoint no existe (404) */} - {showStatusBadge && error && !error.includes('404') && ( -
- {error} + {inViewport === 0 && buoys.length > 0 ? ( +
+ No buoys in view. Tap GO Cabo Frio. +
+ ) : null} +
+ {debugMode ? ( +
+ API_BASE={API_BASE || "(same-origin)"} | health={apiHealth}
- )} - - {mode === 'cluster' - ? clusteredBuoys.map((cluster, idx) => { - const ClusterIcon = L.divIcon({ - className: 'custom-buoy-cluster', - html: `
${cluster.count}
`, - iconSize: [36, 36], - iconAnchor: [18, 18], - }); - - return ( - - {showLabels ? ( - - {cluster.count} boyas PNBOIA - - ) : null} - - ); - }) - : visibleBuoys.map((buoy) => ( - - {showLabels ? ( - - {buoy.name} • {buoy.buoy_id} ({buoy.data.sea_state || buoy.state}) - - ) : null} - -
-
- 🏖️ -
-
{buoy.name}
-
{buoy.buoy_id}
-
-
- -
-
- Estado: {buoy.data.sea_state || buoy.state || 'n/a'} -
-
- Última actualización: {new Date(buoy.timestamp).toLocaleString()} -
-
- Lectura:{' '} - {buoy.data.wave_height - ? `${buoy.data.wave_height}m olas` - : buoy.data.air_pressure - ? `${buoy.data.air_pressure} hPa` - : buoy.data.wind_speed - ? `${buoy.data.wind_speed} nós` - : 'n/a'} -
-
- Fuente: {buoy.source || 'PNBOIA'} -
- -
-
-
-
- ))} + ) : null} ); }; diff --git a/frontend/src/components/maps/realtime/DevOverlayHost.tsx b/frontend/src/components/maps/realtime/DevOverlayHost.tsx index 7a9ac6984..a21f17596 100644 --- a/frontend/src/components/maps/realtime/DevOverlayHost.tsx +++ b/frontend/src/components/maps/realtime/DevOverlayHost.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useMapEvents } from "react-leaflet"; import { useDebugLayersInfo } from "../../../lib/debugInfoStore"; +import { LayerInspectorOverlay } from "./LayerInspectorOverlay"; type MapClickTracerProps = { seamarksOn: boolean; @@ -30,8 +31,17 @@ export function DevOverlayHost() { const [versionData, setVersionData] = useState(null); const [swStatus, setSwStatus] = useState("checking"); const [uiMode, setUiMode] = useState("detecting"); + const [layersPanelOpen, setLayersPanelOpen] = useState(false); const layersInfo = useDebugLayersInfo(); + const layerlab = useMemo(() => { + try { + return new URLSearchParams(window.location.search).get("layerlab") === "1"; + } catch { + return false; + } + }, []); + useEffect(() => { fetch("/version.json", { headers: { "Cache-Control": "no-store" }, @@ -257,7 +267,29 @@ PNBOIA Nearest: ${(layersInfo.pnboiaNearest || []) > 🔗 Copy Debug URL + {layerlab ? ( + + ) : null}
+ {layerlab && layersPanelOpen ? ( +
+ +
+ ) : null}
); } diff --git a/frontend/src/components/maps/realtime/DiagnosticsPanel.tsx b/frontend/src/components/maps/realtime/DiagnosticsPanel.tsx index c1185fea1..82304cd9d 100644 --- a/frontend/src/components/maps/realtime/DiagnosticsPanel.tsx +++ b/frontend/src/components/maps/realtime/DiagnosticsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; type Props = { title?: string; @@ -12,9 +12,26 @@ export const DiagnosticsPanel: React.FC = ({ children, }) => { const [open, setOpen] = useState(defaultOpen); + const [viewportHeight, setViewportHeight] = useState(() => window.innerHeight); + const compactTray = viewportHeight < 760; + const panelMaxHeight = compactTray + ? Math.max(220, Math.min(320, viewportHeight - 120)) + : Math.max(280, Math.min(520, viewportHeight - 120)); + + useEffect(() => { + const onResize = () => setViewportHeight(window.innerHeight); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); return ( -
+
{title}
@@ -31,7 +48,11 @@ export const DiagnosticsPanel: React.FC = ({
- {open ?
{children}
: null} + {open ? ( +
+ {children} +
+ ) : null}
); }; diff --git a/frontend/src/components/maps/realtime/DiagnosticsPanelContent.tsx b/frontend/src/components/maps/realtime/DiagnosticsPanelContent.tsx index 6978fa867..b76806cd4 100644 --- a/frontend/src/components/maps/realtime/DiagnosticsPanelContent.tsx +++ b/frontend/src/components/maps/realtime/DiagnosticsPanelContent.tsx @@ -1,4 +1,4 @@ -import type { MutableRefObject } from "react"; +import { useMemo, useState, type MutableRefObject } from "react"; import L from "leaflet"; import { MyLocationControls } from "../MyLocationControls"; import VoiceCommandIndicator from "../../voice/VoiceCommandIndicator"; @@ -53,152 +53,203 @@ export function DiagnosticsPanelContent({ seamarksEnvOverride, applySeamarksEnabled, }: Props) { - return ( - <> -
-
Controles
- + const [activeTab, setActiveTab] = useState<"controls" | "gps" | "ais" | "weather" | "system" | "traffic">("controls"); + const tabs = useMemo( + () => [ + { id: "controls", label: "Controls" }, + { id: "gps", label: "GPS" }, + { id: "ais", label: "AIS" }, + { id: "weather", label: "Weather" }, + { id: "system", label: "System" }, + { id: "traffic", label: "Traffic" }, + ] as const, + [] + ); -
-
Cámara
-
- - -
- {cameraMode === "explore" ? ( - - ) : null} -
+ const controlsBlock = ( +
+
Controles
+ -
-
Radio (km)
- -
+ Back to my area + + ) : null}
- { - console.log("Activar GPS clicked"); - }} - extraPanel={ -
-
- Seamarks flag: {String(seamarksEnabledByFlag)} | enabled: {String(seamarksEffectiveEnabled)} | opacity:{" "} - {String(seamarksOpacity)} -
- {import.meta.env.DEV && ( -
- {seamarksEnabledByFlag - ? seamarksTileError - ? "Seamarks: ON but tiles_error" - : `Seamarks: ON (${seamarksReason})` - : `Seamarks: OFF (${seamarksReason})`} -
- )} - {!seamarksEnabledByFlag ? ( -
- Set VITE_OPENSEAMAP_SEAMARKS=1 and restart dev server -
- ) : ( -
- - OpenSeaMap -
- )} +
+
Radio (km)
+ +
+
+ ); + + const gpsBlock = ( + { + console.log("Activar GPS clicked"); + }} + extraPanel={ +
+
+ Seamarks flag: {String(seamarksEnabledByFlag)} | enabled: {String(seamarksEffectiveEnabled)} | opacity:{" "} + {String(seamarksOpacity)}
- } - /> + {import.meta.env.DEV && ( +
+ {seamarksEnabledByFlag + ? seamarksTileError + ? "Seamarks: ON but tiles_error" + : `Seamarks: ON (${seamarksReason})` + : `Seamarks: OFF (${seamarksReason})`} +
+ )} + {!seamarksEnabledByFlag ? ( +
+ Set VITE_OPENSEAMAP_SEAMARKS=1 and restart dev server +
+ ) : ( +
+ + OpenSeaMap +
+ )} +
+ } + /> + ); + const systemBlock = ( +
+
+
+ ); -
- - - - + const weatherBlock = ( +
+ + +
+ ); + + const trafficBlock = ; + + return ( + <> +
+
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === "controls" ? controlsBlock : null} + {activeTab === "gps" ? gpsBlock : null} + {activeTab === "ais" ? trafficBlock : null} + {activeTab === "weather" ? weatherBlock : null} + {activeTab === "system" ? systemBlock : null} + {activeTab === "traffic" ? trafficBlock : null} ); } diff --git a/frontend/src/components/maps/realtime/LayerInspectorOverlay.tsx b/frontend/src/components/maps/realtime/LayerInspectorOverlay.tsx new file mode 100644 index 000000000..58cf62a8d --- /dev/null +++ b/frontend/src/components/maps/realtime/LayerInspectorOverlay.tsx @@ -0,0 +1,332 @@ +/** + * "Photoshop Layers" inspector for map - diagnostic tool. + * Only active when ?debug=1&layerlab=1. + * Toggles visibility of MapLibre layers and Leaflet panes. + */ + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + getMapLibre, + getLeafletMap, + useMapRefStore, +} from "../../../lib/mapRefStore"; + +type LayerInfo = { + id: string; + type: string; + source?: string; + visibility: "visible" | "none"; +}; + +function LayerInspectorOverlayInner() { + const { mapLibre, leafletMap } = useMapRefStore(); + const [activeTab, setActiveTab] = useState<"maplibre" | "leaflet">("maplibre"); + const [search, setSearch] = useState(""); + const [layers, setLayers] = useState([]); + const [initialVisibility, setInitialVisibility] = useState>({}); + const [styleLoaded, setStyleLoaded] = useState(false); + const [leafletPanes, setLeafletPanes] = useState([]); + const [leafletTick, setLeafletTick] = useState(0); + + const mapLibreOk = !!mapLibre; + const leafletOk = !!leafletMap; + + useEffect(() => { + const map = getMapLibre(); + if (!map) { + setLayers([]); + setStyleLoaded(false); + return; + } + const onStyleLoad = () => { + const style = map.getStyle(); + const layerList = (style?.layers ?? []).map((layer: any) => { + const vis = layer.layout?.["visibility"] ?? "visible"; + return { + id: layer.id, + type: layer.type ?? "unknown", + source: layer.source, + visibility: vis === "none" ? "none" : "visible", + } as LayerInfo; + }); + setLayers(layerList); + const init: Record = {}; + layerList.forEach((l) => (init[l.id] = l.visibility)); + setInitialVisibility(init); + setStyleLoaded(true); + }; + if (map.isStyleLoaded()) onStyleLoad(); + map.on("styledata", onStyleLoad); + return () => { + map.off("styledata", onStyleLoad); + }; + }, [mapLibre]); + + useEffect(() => { + const map = getLeafletMap(); + if (!map) { + setLeafletPanes([]); + return; + } + const panes = map.getPanes(); + setLeafletPanes(Object.keys(panes)); + }, [leafletMap, leafletTick]); + + const toggleMapLibreLayer = useCallback( + (layerId: string, visible: boolean) => { + const map = getMapLibre(); + if (!map) return; + try { + map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none"); + setLayers((prev) => + prev.map((l) => (l.id === layerId ? { ...l, visibility: visible ? "visible" : "none" } : l)) + ); + } catch { + // layer might not have layout + } + }, + [] + ); + + const toggleLeafletPane = useCallback((paneName: string, visible: boolean) => { + const map = getLeafletMap(); + if (!map) return; + const pane = map.getPane(paneName); + if (pane) pane.style.display = visible ? "" : "none"; + setLeafletTick((t) => t + 1); + }, []); + + const resetMapLibre = useCallback(() => { + const map = getMapLibre(); + if (!map) return; + Object.entries(initialVisibility).forEach(([id, vis]) => { + try { + map.setLayoutProperty(id, "visibility", vis); + } catch { + // ignore + } + }); + setLayers((prev) => + prev.map((l) => ({ ...l, visibility: initialVisibility[l.id] ?? "visible" })) + ); + }, [initialVisibility]); + + const filteredLayers = useMemo(() => { + if (!search.trim()) return layers; + const q = search.toLowerCase(); + return layers.filter( + (l) => + l.id.toLowerCase().includes(q) || + (l.source ?? "").toLowerCase().includes(q) + ); + }, [layers, search]); + + return ( +
+
+ 🎨 Layer Inspector +
+ +
+ MapLibre: {mapLibreOk ? "yes" : "no"} | layers: {layers.length} | + styleLoaded: {styleLoaded ? "yes" : "no"} +
+
+ Leaflet: {leafletOk ? "yes" : "no"} | panes: {leafletPanes.length} +
+ +
+ + +
+ + {activeTab === "maplibre" && ( + <> + setSearch(e.target.value)} + style={{ + width: "100%", + marginBottom: "8px", + padding: "4px 6px", + background: "#1e293b", + border: "1px solid #475569", + borderRadius: 4, + color: "#e2e8f0", + fontSize: 10, + }} + /> + +
+ {filteredLayers.length === 0 ? ( + + {mapLibreOk ? "No layers or style not loaded" : "No MapLibre map"} + + ) : ( + filteredLayers.map((layer) => ( + + )) + )} +
+ + )} + + {activeTab === "leaflet" && ( +
+ {leafletPanes.length === 0 ? ( + + {leafletOk ? "No panes" : "No Leaflet map"} + + ) : ( + leafletPanes.map((name) => { + const pane = leafletMap?.getPane(name); + const visible = pane ? pane.style.display !== "none" : true; + return ( + + ); + }) + )} +
+ )} +
+ ); +} + +export function LayerInspectorOverlay() { + const layerlab = useMemo(() => { + try { + return new URLSearchParams(window.location.search).get("layerlab") === "1"; + } catch { + return false; + } + }, []); + + const debug = useMemo(() => { + try { + return new URLSearchParams(window.location.search).get("debug") === "1"; + } catch { + return false; + } + }, []); + + if (!debug || !layerlab) return null; + + return ; +} diff --git a/frontend/src/components/maps/realtime/LayerManagerPanel.tsx b/frontend/src/components/maps/realtime/LayerManagerPanel.tsx index d204fb8e7..9737638c9 100644 --- a/frontend/src/components/maps/realtime/LayerManagerPanel.tsx +++ b/frontend/src/components/maps/realtime/LayerManagerPanel.tsx @@ -28,8 +28,8 @@ export const layerDefaults: Record = { ais: { id: "ais", label: "AIS Vessels", enabled: false, priority: "high", minZoom: 6, opacity: 1 }, community_leaflet: { id: "community_leaflet", label: "Community Signals (Leaflet)", enabled: false, priority: "med", minZoom: 8, opacity: 1 }, community_maplibre: { id: "community_maplibre", label: "Community Signals (MapLibre)", enabled: false, priority: "med", minZoom: 8, opacity: 1 }, - my_location_leaflet: { id: "my_location_leaflet", label: "My Location (Leaflet)", enabled: true, priority: "high", minZoom: 0, opacity: 1 }, - my_location_maplibre: { id: "my_location_maplibre", label: "My Location (MapLibre)", enabled: true, priority: "high", minZoom: 0, opacity: 1 }, + my_location_leaflet: { id: "my_location_leaflet", label: "My Location (Leaflet marker)", enabled: true, priority: "high", minZoom: 0, opacity: 1 }, + my_location_maplibre: { id: "my_location_maplibre", label: "My Location (MapLibre symbol)", enabled: true, priority: "high", minZoom: 0, opacity: 1 }, zones_maplibre: { id: "zones_maplibre", label: "Zones (MapLibre)", enabled: false, priority: "low", minZoom: 7, opacity: 0.8 }, seamarks: { id: "seamarks", label: "Seamarks", enabled: true, priority: "low", minZoom: 5, opacity: 0.8, supportsOpacity: true }, pnboia: { @@ -37,7 +37,7 @@ export const layerDefaults: Record = { label: "PNBOIA (buoys)", enabled: true, priority: "high", - minZoom: 10, + minZoom: 6, opacity: 1, maxVisible: 40, supportsMaxVisible: true, @@ -96,7 +96,7 @@ const buildPreset = (preset: PresetId): Record => { base.my_location_leaflet.enabled = true; base.my_location_maplibre.enabled = true; base.pnboia.enabled = true; - base.pnboia.minZoom = 10; + base.pnboia.minZoom = 6; base.pnboia.maxVisible = 40; base.pnboia.showLabels = false; } else if (preset === "informative") { @@ -110,7 +110,7 @@ const buildPreset = (preset: PresetId): Record => { base.my_location_maplibre.enabled = true; base.zones_maplibre.enabled = true; base.pnboia.enabled = true; - base.pnboia.minZoom = 7; + base.pnboia.minZoom = 6; base.pnboia.maxVisible = 120; base.pnboia.showLabels = false; } diff --git a/frontend/src/components/maps/realtime/LayersPanel.tsx b/frontend/src/components/maps/realtime/LayersPanel.tsx index cd7a06ef1..124ee28ac 100644 --- a/frontend/src/components/maps/realtime/LayersPanel.tsx +++ b/frontend/src/components/maps/realtime/LayersPanel.tsx @@ -11,6 +11,13 @@ type Props = { mode: "operativo" | "prevision" | "preparacion"; }; +type PnboiaListItem = { + id: string; + name: string; + lat: number; + lon: number; +}; + type SectionId = "seguridad" | "condiciones" | "navegacion" | "comunidad" | "avanzado"; const SECTION_TITLES: Record = { @@ -113,6 +120,48 @@ export const LayersPanel: React.FC = ({ comunidad: false, avanzado: false, }); + const debugMode = useMemo(() => { + try { + return new URLSearchParams(window.location.search).get("debug") === "1"; + } catch { + return false; + } + }, []); + const [pnboiaDebug, setPnboiaDebug] = useState<{ + enabled?: boolean; + lastFetchStatus?: string; + itemsCount?: number; + lastError?: string | null; + } | null>(null); + const [pnboiaList, setPnboiaList] = useState([]); + + useEffect(() => { + if (!debugMode) return; + const sync = () => { + setPnboiaDebug(((window as any).__iuriPnboiaDebug ?? null) as any); + }; + sync(); + const id = window.setInterval(sync, 1000); + return () => window.clearInterval(id); + }, [debugMode]); + + useEffect(() => { + const onPnboiaList = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!Array.isArray(detail)) return; + const normalized = detail + .map((item) => ({ + id: String(item?.id ?? ""), + name: String(item?.name ?? "PNBOIA"), + lat: Number(item?.lat), + lon: Number(item?.lon), + })) + .filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lon) && item.id); + setPnboiaList(normalized); + }; + window.addEventListener("iuri:pnboia-list", onPnboiaList as EventListener); + return () => window.removeEventListener("iuri:pnboia-list", onPnboiaList as EventListener); + }, []); const locationEnabled = layers.my_location_leaflet.enabled || layers.my_location_maplibre.enabled; @@ -292,6 +341,32 @@ export const LayersPanel: React.FC = ({ ) : null}
+ {layerId === "pnboia" && enabled ? ( +
+
Buoys: {pnboiaList.length}
+
+ {pnboiaList.map((buoy) => ( +
+ {buoy.name} + +
+ ))} +
+
+ ) : null}
); }; @@ -409,6 +484,13 @@ export const LayersPanel: React.FC = ({ Show essentials ) : null} + {debugMode ? ( +
+ pnboia: {pnboiaDebug?.enabled ? "ON" : "OFF"} · status {pnboiaDebug?.lastFetchStatus ?? "idle"} · items{" "} + {pnboiaDebug?.itemsCount ?? 0} + {pnboiaDebug?.lastError ? ` · err ${pnboiaDebug.lastError}` : ""} +
+ ) : null}
{Object.entries(sections).map(([sectionId, layerIds]) => { @@ -432,7 +514,7 @@ export const LayersPanel: React.FC = ({
{renderCombinedRow( "My location", - "Shows your current position on the map.", + "Single toggle for Leaflet marker + MapLibre symbol.", locationEnabled, locationMinZoom, (value) => { diff --git a/frontend/src/components/maps/realtime/MapLayersPanel.tsx b/frontend/src/components/maps/realtime/MapLayersPanel.tsx index 44a0b1aa8..44f3eaedd 100644 --- a/frontend/src/components/maps/realtime/MapLayersPanel.tsx +++ b/frontend/src/components/maps/realtime/MapLayersPanel.tsx @@ -13,6 +13,13 @@ type Props = { mode: "operativo" | "prevision" | "preparacion"; }; +type PnboiaListItem = { + id: string; + name: string; + lat: number; + lon: number; +}; + type SectionId = "seguridad" | "condiciones" | "navegacion" | "comunidad" | "avanzado"; const SECTION_TITLES: Record = { @@ -117,6 +124,48 @@ export const MapLayersPanel: React.FC = ({ comunidad: false, avanzado: false, }); + const debugMode = useMemo(() => { + try { + return new URLSearchParams(window.location.search).get("debug") === "1"; + } catch { + return false; + } + }, []); + const [pnboiaDebug, setPnboiaDebug] = useState<{ + enabled?: boolean; + lastFetchStatus?: string; + itemsCount?: number; + lastError?: string | null; + } | null>(null); + const [pnboiaList, setPnboiaList] = useState([]); + + useEffect(() => { + if (!debugMode) return; + const sync = () => { + setPnboiaDebug(((window as any).__iuriPnboiaDebug ?? null) as any); + }; + sync(); + const id = window.setInterval(sync, 1000); + return () => window.clearInterval(id); + }, [debugMode]); + + useEffect(() => { + const onPnboiaList = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!Array.isArray(detail)) return; + const normalized = detail + .map((item) => ({ + id: String(item?.id ?? ""), + name: String(item?.name ?? "PNBOIA"), + lat: Number(item?.lat), + lon: Number(item?.lon), + })) + .filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lon) && item.id); + setPnboiaList(normalized); + }; + window.addEventListener("iuri:pnboia-list", onPnboiaList as EventListener); + return () => window.removeEventListener("iuri:pnboia-list", onPnboiaList as EventListener); + }, []); const locationEnabled = layers.my_location_leaflet.enabled || layers.my_location_maplibre.enabled; @@ -296,6 +345,32 @@ export const MapLayersPanel: React.FC = ({ ) : null}
+ {layerId === "pnboia" && enabled ? ( +
+
Buoys: {pnboiaList.length}
+
+ {pnboiaList.map((buoy) => ( +
+ {buoy.name} + +
+ ))} +
+
+ ) : null}
); }; @@ -426,6 +501,13 @@ export const MapLayersPanel: React.FC = ({ Show essentials ) : null} + {debugMode ? ( +
+ pnboia: {pnboiaDebug?.enabled ? "ON" : "OFF"} · status {pnboiaDebug?.lastFetchStatus ?? "idle"} · items{" "} + {pnboiaDebug?.itemsCount ?? 0} + {pnboiaDebug?.lastError ? ` · err ${pnboiaDebug.lastError}` : ""} +
+ ) : null}
{Object.entries(sections).map(([sectionId, layerIds]) => { @@ -448,8 +530,8 @@ export const MapLayersPanel: React.FC = ({ section === "navegacion" ? (
{renderCombinedRow( - "My location", - "Shows your current position on the map.", + "My location", + "Single toggle for Leaflet marker + MapLibre symbol.", locationEnabled, locationMinZoom, (value) => { diff --git a/frontend/src/components/maps/realtime/MapRefRegistrar.tsx b/frontend/src/components/maps/realtime/MapRefRegistrar.tsx new file mode 100644 index 000000000..26142a43b --- /dev/null +++ b/frontend/src/components/maps/realtime/MapRefRegistrar.tsx @@ -0,0 +1,19 @@ +/** + * Registers Leaflet map in mapRefStore for LayerInspectorOverlay. + * Must be mounted inside MapContainer. + */ + +import { useEffect } from "react"; +import { useMap } from "react-leaflet"; +import { registerLeafletMap } from "../../../lib/mapRefStore"; + +export function MapRefRegistrar() { + const map = useMap(); + + useEffect(() => { + registerLeafletMap(map); + return () => registerLeafletMap(null); + }, [map]); + + return null; +} diff --git a/frontend/src/components/maps/realtime/ModeSheet.tsx b/frontend/src/components/maps/realtime/ModeSheet.tsx index c28fc9046..fa93d53c7 100644 --- a/frontend/src/components/maps/realtime/ModeSheet.tsx +++ b/frontend/src/components/maps/realtime/ModeSheet.tsx @@ -247,14 +247,24 @@ export const ModeSheet: React.FC = ({ borderBottom: "1px solid rgba(148,163,184,0.2)", }} > -
+
{sheetTitle}
{isPrevision ? "Conditions and forecast" : "Pre-departure checklist and plan"}
-
+
{(isPreparacion || isPrevision) && onModeSelect ? ( <> { + try { + return new URLSearchParams(window.location.search).get("debug") === "1"; + } catch { + return false; + } + }, []); + const [debugHudVisible, setDebugHudVisible] = useState(true); + const shouldShowDebugHud = (technicalMode || forceDebugHud) && debugHudVisible; + return ( <> {!mapMoving && mapMode === "operativo" ? ( @@ -62,7 +73,29 @@ export function OverlayDockHost({ ) : null} - {technicalMode && ( + {forceDebugHud ? ( + + ) : null} + + {shouldShowDebugHud && ( + {runtimeNodes} - + @@ -449,14 +451,18 @@ export function RealTimeMapCore({ )} - - {myLocationLeafletEnabled ? ( - - ) : null} + + {mapLibreEnabled && ( - + {myLocationMapLibreEnabled ? : null} )} @@ -468,7 +474,8 @@ export function RealTimeMapCore({ )} - {pmap1Filtered.map((item) => { + {!pnboiaEnabled && + pmap1Filtered.map((item) => { const iconSymbol = getTypeIconSymbol(item.structureType); const typeLabel = getTypeLabel(item.structureType); const SupportIcon = Leaflet.divIcon({ diff --git a/frontend/src/components/maps/realtime/SeamarksInspectorHost.tsx b/frontend/src/components/maps/realtime/SeamarksInspectorHost.tsx index 5c661feb1..b82742cd3 100644 --- a/frontend/src/components/maps/realtime/SeamarksInspectorHost.tsx +++ b/frontend/src/components/maps/realtime/SeamarksInspectorHost.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CircleMarker, Popup, useMap, useMapEvents } from "react-leaflet"; import type L from "leaflet"; import { buildSeamarkAroundQuery, fetchSeamarksFromOverpass } from "../../../lib/seamarks_overpass"; @@ -73,16 +73,23 @@ function SeamarkInspector({ enabled }: { enabled: boolean }) { } }, []); - // UX mínima: indicar modo "inspector" con cursor pointer cuando está habilitado + // Crosshair solo en modo inspector explícito (no por toggle de layer). const map = useMap(); + const inspectorCursorEnabled = useMemo(() => { + try { + return new URLSearchParams(window.location.search).get("seamark_inspect") === "1"; + } catch { + return false; + } + }, []); useEffect(() => { const el = map.getContainer(); const prev = el.style.cursor; - if (enabled) el.style.cursor = "crosshair"; + if (enabled && inspectorCursorEnabled) el.style.cursor = "crosshair"; return () => { el.style.cursor = prev; }; - }, [enabled, map]); + }, [enabled, inspectorCursorEnabled, map]); const runLookup = useCallback((lat: number, lon: number) => { const radiusM = SEAMARK_INSPECT_RADIUS_M; diff --git a/frontend/src/components/maps/realtime/hooks/useLayerSettings.ts b/frontend/src/components/maps/realtime/hooks/useLayerSettings.ts index ea65af941..060a69439 100644 --- a/frontend/src/components/maps/realtime/hooks/useLayerSettings.ts +++ b/frontend/src/components/maps/realtime/hooks/useLayerSettings.ts @@ -40,6 +40,13 @@ export function useLayerSettings({ const [focusMode, setFocusMode] = useState(false); const [layerSettings, setLayerSettings] = useState>(() => layerDefaults); const didUserChangeLayersRef = useRef(false); + const debugMode = useMemo(() => { + try { + return new URLSearchParams(window.location.search).get("debug") === "1"; + } catch { + return false; + } + }, []); const updateLayerSetting = useCallback( (id: LayerId, patch: Partial) => { @@ -145,7 +152,7 @@ export function useLayerSettings({ const seamarksLayer = layerView.layers.seamarks; const seamarksEnabledByLayer = seamarksLayer.enabled && zoom >= seamarksLayer.minZoom; const pnboiaLayer = layerView.layers.pnboia; - const pnboiaEnabled = pnboiaLayer.enabled && zoom >= pnboiaLayer.minZoom; + const pnboiaEnabled = pnboiaLayer.enabled && (debugMode || zoom >= pnboiaLayer.minZoom); const pnboiaShowLabels = Boolean(pnboiaLayer.showLabels); const bathymetryLayer = layerView.layers.bathymetry; const bathymetryEnabled = bathymetryLayer.enabled && zoom >= bathymetryLayer.minZoom; @@ -155,10 +162,11 @@ export function useLayerSettings({ const barraRiskEnabled = barraRiskLayer.enabled; const pnboiaZoomMode = useMemo<"hidden" | "cluster" | "individual">(() => { - if (zoom < 6) return "hidden"; - if (zoom < 10) return "cluster"; + if (debugMode) return "individual"; + if (zoom < 4) return "hidden"; + if (zoom < 8) return "cluster"; return "individual"; - }, [zoom]); + }, [debugMode, zoom]); const pnboiaMaxVisible = useMemo(() => { const layerCap = pnboiaLayer.maxVisible ?? 160; diff --git a/frontend/src/components/maps/realtime/hooks/useMapStatus.ts b/frontend/src/components/maps/realtime/hooks/useMapStatus.ts index 24adeb19e..ddded0fad 100644 --- a/frontend/src/components/maps/realtime/hooks/useMapStatus.ts +++ b/frontend/src/components/maps/realtime/hooks/useMapStatus.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import type { MutableRefObject } from "react"; import { getApiUrl } from "../../../../config/appConfig"; import { clearBackendDown, isBackendDown, markBackendDown } from "../../../../utils/backendStatus"; +import { checkApiHealthOnce, getHealthStateCached } from "../../../../lib/apiBase"; type Args = { mockEnabled: boolean; @@ -33,27 +34,46 @@ export function useMapStatus({ useEffect(() => { let inFlight = false; let nextCheck = 0; + let lastFailureAt = 0; + const BACKEND_FAIL_WINDOW_MS = 45_000; const checkBackend = async () => { if (inFlight) return; const now = Date.now(); if (now < nextCheck) return; inFlight = true; nextCheck = now + 15000; - const url = getApiUrl("/api/version"); try { + const cachedHealth = getHealthStateCached(); + if (cachedHealth === "ok") { + clearBackendDown(); + setBackendDown(false); + return; + } + if (cachedHealth === "idle") { + const firstHealth = await checkApiHealthOnce(); + if (firstHealth === "ok") { + clearBackendDown(); + setBackendDown(false); + return; + } + } + const url = getApiUrl("/health"); const resp = await fetch(url, { cache: "no-store" }); if (!resp.ok) { console.warn("[useMapStatus] Backend check failed: url=%s status=%d", url, resp.status); markBackendDown(45, `version_http_${resp.status}`); - setBackendDown(true); + lastFailureAt = Date.now(); + setBackendDown(Date.now() - lastFailureAt < BACKEND_FAIL_WINDOW_MS); return; } clearBackendDown(); setBackendDown(false); } catch (err: any) { + const url = getApiUrl("/health"); console.warn("[useMapStatus] Backend check failed: url=%s status=network_error err=%s", url, err?.message || err); markBackendDown(45, err?.message || "version_unavailable"); - setBackendDown(true); + lastFailureAt = Date.now(); + setBackendDown(Date.now() - lastFailureAt < BACKEND_FAIL_WINDOW_MS); } finally { inFlight = false; } diff --git a/frontend/src/components/maps/realtime/hooks/useOverlayDockItems.tsx b/frontend/src/components/maps/realtime/hooks/useOverlayDockItems.tsx index fc8dcae1b..b2ea9e398 100644 --- a/frontend/src/components/maps/realtime/hooks/useOverlayDockItems.tsx +++ b/frontend/src/components/maps/realtime/hooks/useOverlayDockItems.tsx @@ -228,7 +228,7 @@ export function useOverlayDockItems({ draggable: true, autoHandle: true, render: () => ( - + = ({ const [error, setError] = useState(null); const [waves, setWaves] = useState(null); const [health, setHealth] = useState(null); + const [temporarilyUnavailable, setTemporarilyUnavailable] = useState(false); const badgeTone = useMemo(() => { if (!health?.state) return "border-slate-600/40 text-slate-300 bg-slate-900/40"; @@ -63,12 +64,19 @@ export const SeaStatePanel: React.FC = ({ const loadData = useCallback(async () => { setLoading(true); setError(null); + setTemporarilyUnavailable(false); try { const [wavesResp, statusResp] = await Promise.all([ fetch(getApiUrl(`/api/tools/cptec_waves?city_id=${cityId}&day=${day}`)), fetch(getApiUrl("/api/tools/sre_status")), ]); if (!wavesResp.ok) { + if (wavesResp.status >= 500) { + setTemporarilyUnavailable(true); + setHealth({ state: "DEGRADED", reason: "Temporarily unavailable" }); + setWaves(null); + return; + } throw new Error(`waves_http_${wavesResp.status}`); } if (!statusResp.ok) { @@ -84,6 +92,8 @@ export const SeaStatePanel: React.FC = ({ } } catch (err: any) { setError(err?.message || "waves_unavailable"); + setTemporarilyUnavailable(true); + setHealth((prev) => prev ?? { state: "DEGRADED", reason: "Temporarily unavailable" }); } finally { setLoading(false); } @@ -170,7 +180,13 @@ export const SeaStatePanel: React.FC = ({ ) : null}
- {error ? ( + {temporarilyUnavailable ? ( +
+ Temporarily unavailable +
+ ) : null} + + {error && !temporarilyUnavailable ? (
{error === "waves_not_available_for_city" ? "Sin ondas para este city_id." diff --git a/frontend/src/components/marine/WeatherPanel.tsx b/frontend/src/components/marine/WeatherPanel.tsx index 75161cb45..4721af417 100644 --- a/frontend/src/components/marine/WeatherPanel.tsx +++ b/frontend/src/components/marine/WeatherPanel.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { getApiUrl } from "../../config/appConfig"; import { getNumber, setNumber } from "../../lib/ui_prefs"; +import { useLastLocation } from "../../hooks/useLastLocation"; type ForecastItem = { dia?: string; @@ -22,6 +23,10 @@ type WeatherResponse = { atualizacao?: string; }; forecast?: ForecastItem[]; + current?: { + temperature?: number; + description?: string; + }; }; type WeatherHealth = { @@ -29,11 +34,29 @@ type WeatherHealth = { reason?: string | null; }; -type SreStatusResponse = { - ok: boolean; - payload?: { - forecast_weather?: WeatherHealth; - }; +const normalizeForecast = (rawForecast: unknown): ForecastItem[] => { + if (!Array.isArray(rawForecast)) return []; + return rawForecast.map((item: any) => ({ + dia: item?.dia ?? item?.time ?? item?.period, + tempo: item?.tempo ?? item?.description ?? item?.conditions, + maxima: + item?.maxima ?? + item?.temp_max ?? + (typeof item?.temperature === "number" ? `${Math.round(item.temperature)}°C` : undefined), + minima: item?.minima ?? item?.temp_min, + iuv: item?.iuv ?? (typeof item?.precipitation_prob === "number" ? `${item.precipitation_prob}%` : undefined), + })); +}; + +const normalizeWeatherStatus = (raw: any): WeatherHealth => { + const statusRaw = String(raw?.state ?? raw?.status ?? raw?.weather_status ?? "").toUpperCase(); + if (statusRaw.includes("OK") || statusRaw.includes("OPERAT")) { + return { state: "OK", reason: raw?.reason ?? null }; + } + if (statusRaw.includes("DEGRA") || statusRaw.includes("DOWN") || statusRaw.includes("ERROR")) { + return { state: "DEGRADED", reason: raw?.reason ?? raw?.detail ?? null }; + } + return { state: undefined, reason: raw?.reason ?? raw?.detail ?? null }; }; interface WeatherPanelProps { @@ -41,6 +64,7 @@ interface WeatherPanelProps { } export const WeatherPanel: React.FC = ({ defaultCityId = 241 }) => { + const { loc } = useLastLocation({ pollMs: 5000 }); const [cityId, setCityId] = useState(() => getNumber("weather.city_id", defaultCityId, { min: 1 }) ); @@ -48,6 +72,9 @@ export const WeatherPanel: React.FC = ({ defaultCityId = 241 const [error, setError] = useState(null); const [forecast, setForecast] = useState(null); const [health, setHealth] = useState(null); + const [marinoDegraded, setMarinoDegraded] = useState(false); + const isDegradedError = (value: string | null) => + Boolean(value && (value === "forecast_unavailable" || value.startsWith("weather_http_5"))); const badgeTone = useMemo(() => { if (!health?.state) return "border-slate-600/40 text-slate-300 bg-slate-900/40"; @@ -58,30 +85,55 @@ export const WeatherPanel: React.FC = ({ defaultCityId = 241 const loadData = useCallback(async () => { setLoading(true); setError(null); + setMarinoDegraded(false); try { - const [weatherResp, statusResp] = await Promise.all([ - fetch(getApiUrl(`/api/tools/weather_forecast?city_id=${cityId}&days=7`)), - fetch(getApiUrl("/api/tools/sre_status")), - ]); + const statusResp = await fetch(getApiUrl("/api/v1/weather/status")); + if (statusResp.ok) { + const statusJson = await statusResp.json().catch(() => ({})); + const normalized = normalizeWeatherStatus(statusJson); + setHealth(normalized); + if (normalized.state === "DEGRADED") { + setMarinoDegraded(true); + } + } else { + setHealth({ state: "DEGRADED", reason: `HTTP ${statusResp.status}` }); + setMarinoDegraded(true); + } + + if (!loc) { + setForecast(null); + setError("location_required"); + return; + } + + const params = new URLSearchParams({ + lat: loc.lat.toFixed(6), + lon: loc.lon.toFixed(6), + }); + const weatherResp = await fetch(getApiUrl(`/api/v1/weather?${params.toString()}`)); + if (weatherResp.status === 422) { + setForecast(null); + setError("location_required"); + return; + } if (!weatherResp.ok) { throw new Error(`weather_http_${weatherResp.status}`); } - if (!statusResp.ok) { - throw new Error(`sre_status_http_${statusResp.status}`); - } - const weatherJson = (await weatherResp.json()) as WeatherResponse; - const statusJson = (await statusResp.json()) as SreStatusResponse; + const weatherRaw = (await weatherResp.json()) as any; + const weatherJson: WeatherResponse = { + ...(weatherRaw ?? {}), + ok: weatherRaw?.ok !== false, + forecast: normalizeForecast(weatherRaw?.forecast), + }; setForecast(weatherJson); - setHealth(statusJson?.payload?.forecast_weather || null); - if (weatherJson.ok === false) { - setError(weatherJson.error || "forecast_unavailable"); - } + if (weatherJson.ok === false) setError(weatherJson.error || "forecast_unavailable"); } catch (err: any) { setError(err?.message || "forecast_unavailable"); + setMarinoDegraded(true); } finally { setLoading(false); } - }, [cityId]); + }, [loc]); useEffect(() => { void loadData(); @@ -144,9 +196,17 @@ export const WeatherPanel: React.FC = ({ defaultCityId = 241
) : null} - {error ? ( + {marinoDegraded ? ( +
+ Marino degraded +
+ ) : null} + + {error && !isDegradedError(error) ? (
- {error === "ambiguous_city" + {error === "location_required" + ? "Location required: enable location to load weather." + : error === "ambiguous_city" ? "Ciudad ambigua. Usá city_id exacto." : `Error: ${error}`}
@@ -169,7 +229,13 @@ export const WeatherPanel: React.FC = ({ defaultCityId = 241 ))}
) : ( - !error &&
Sin datos de forecast.
+ !error && ( +
+ {forecast?.current?.temperature !== undefined + ? `Current: ${Math.round(forecast.current.temperature)}°C · ${forecast.current.description ?? "—"}` + : "Sin datos de forecast."} +
+ ) )}
diff --git a/frontend/src/components/pwa/ConnectionStatus.tsx b/frontend/src/components/pwa/ConnectionStatus.tsx index f465848e6..ac1e4662b 100644 --- a/frontend/src/components/pwa/ConnectionStatus.tsx +++ b/frontend/src/components/pwa/ConnectionStatus.tsx @@ -4,6 +4,7 @@ import { WifiOff, Wifi, Cloud, CloudOff, RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { fetchWithCircuit } from '../../utils/circuitBreaker'; import { semanticClasses } from '../../theme/semanticColors'; +import { buildApiUrl, checkApiHealthOnce, getHealthStateCached } from '../../lib/apiBase'; const BACKEND_CHECK_INTERVAL = 30000; const BACKEND_BACKOFFS = [2000, 5000, 10000, 20000, 30000]; @@ -38,13 +39,25 @@ export const ConnectionStatus: React.FC = () => { const checkBackend = useCallback(async () => { if (isMapRoute) return true; - const url = new URL('/api/v1/metrics/persistence', window.location.origin).href; + const cachedHealth = getHealthStateCached(); + if (cachedHealth === "ok") { + setBackendOnline(true); + return true; + } + if (cachedHealth === "idle") { + const warmup = await checkApiHealthOnce(); + if (warmup === "ok") { + setBackendOnline(true); + return true; + } + } + const url = buildApiUrl('/health'); try { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 5000); const response = await fetchWithCircuit( - 'GET /api/v1/metrics/persistence', + 'GET /health', url, { method: 'GET', signal: controller.signal }, { maxFailures: 3, cooldownMs: 60_000 } diff --git a/frontend/src/config/appConfig.ts b/frontend/src/config/appConfig.ts index 83100df7d..f1d082458 100644 --- a/frontend/src/config/appConfig.ts +++ b/frontend/src/config/appConfig.ts @@ -1,3 +1,5 @@ +import { API_BASE as API_BASE_ORIGIN } from "../lib/apiBase"; + // frontend/src/config/appConfig.ts // Fuente única de verdad para configuración de entorno @@ -7,16 +9,10 @@ const getEnv = (key: string, fallback = ''): string => { return typeof value === 'string' && value.length > 0 ? value : fallback; }; -// En PROD: same-origin (usa window.location.origin) -// En DEV: preferir same-origin para usar el proxy de Vite (evita CORS y reduce ruido si backend cae) +// En PROD: same-origin ("") +// En DEV: usa VITE_API_BASE o fallback local estable (127.0.0.1:8001) const resolveApiUrl = (): string => { - if (import.meta.env.PROD) { - // PRODUCCIÓN: same-origin (sin CORS) - return ''; - } - - // DESARROLLO: siempre same-origin para forzar proxy de Vite y evitar inconsistencias (5173/5177) y CORS - return ''; + return API_BASE_ORIGIN; }; const normalizeApiBase = (raw: string, fallback = '/api/v1'): string => { diff --git a/frontend/src/copy/auditCopy.ts b/frontend/src/copy/auditCopy.ts index e697c2749..93fa0e022 100644 --- a/frontend/src/copy/auditCopy.ts +++ b/frontend/src/copy/auditCopy.ts @@ -57,7 +57,7 @@ export const auditCopy = { "The mini-iURi Audit Assistant lives inside this Phase 1 audit panel. It doesn't run commands or change anything — it just reads real core metrics and explains them in plain language.", }, banner: { - backendUnavailable: "Backend unavailable (127.0.0.1:8001). UI in read-only mode.", + backendUnavailable: "Backend unavailable. UI in read-only mode.", retry: "Retry", }, demo: { diff --git a/frontend/src/hooks/useLastLocation.ts b/frontend/src/hooks/useLastLocation.ts index 9a109792b..1eff91673 100644 --- a/frontend/src/hooks/useLastLocation.ts +++ b/frontend/src/hooks/useLastLocation.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { fetchWithCircuit } from "../utils/circuitBreaker"; +import { getApiUrl } from "../config/appConfig"; type LastLocation = { device_id: string; @@ -46,8 +47,17 @@ export function useLastLocation(opts?: UseLastLocationOptions): UseLastLocationR const pollMs = opts?.pollMs ?? 5000; // en mar: 5s está bien const enabled = opts?.enabled !== false; const [status, setStatus] = useState<"idle" | "loading" | "ok" | "error">("idle"); - const [loc, setLoc] = useState(null); + const [loc, setLoc] = useState(() => { + try { + const raw = localStorage.getItem("iuri_last_location_cache"); + return raw ? (JSON.parse(raw) as LastLocation) : null; + } catch { + return null; + } + }); const [error, setError] = useState(null); + const lastErrorRef = useRef(null); + const locRef = useRef(loc); const deviceId = useMemo(() => getOrCreateDeviceId(), []); const stopRef = useRef(false); @@ -66,6 +76,10 @@ export function useLastLocation(opts?: UseLastLocationOptions): UseLastLocationR } }, [loc]); + useEffect(() => { + locRef.current = loc; + }, [loc]); + useEffect(() => { if (!enabled) { stopRef.current = true; @@ -74,6 +88,38 @@ export function useLastLocation(opts?: UseLastLocationOptions): UseLastLocationR stopRef.current = false; let timer: number | null = null; + let geoWatchId: number | null = null; + + const setLocSafe = (next: LastLocation | null) => { + locRef.current = next; + setLoc(next); + try { + if (next) { + localStorage.setItem("iuri_last_location_cache", JSON.stringify(next)); + } + } catch { + // ignore storage errors + } + }; + const persistLocation = async (lat: number, lon: number) => { + try { + await fetch(getApiUrl("/api/location/update"), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Device-Id": deviceId, + }, + body: JSON.stringify({ + device: deviceId, + position: { lat, lon }, + source: "browser", + }), + keepalive: true, + }); + } catch { + // ignore backend write failures in UI hook + } + }; const scheduleNext = (delayMs: number) => { if (timer) window.clearTimeout(timer); @@ -85,22 +131,45 @@ export function useLastLocation(opts?: UseLastLocationOptions): UseLastLocationR }; const fetchOnce = async () => { - setStatus((s) => (s === "ok" ? "ok" : "loading")); - setError(null); + setStatus((s) => (s === "idle" ? "loading" : s)); + if (!lastErrorRef.current) { + setError(null); + lastErrorRef.current = null; + } try { - const res = await fetchWithCircuit( + const requestUrl = getApiUrl(`/api/location/last?device_id=${encodeURIComponent(deviceId)}`); + let res = await fetchWithCircuit( "GET /api/location/last", - `/api/location/last?device_id=${encodeURIComponent(deviceId)}`, + requestUrl, { method: "GET", - headers: { Accept: "application/json" }, + headers: { + Accept: "application/json", + "X-Device-Id": deviceId, + }, cache: "no-store", }, { maxFailures: 3, cooldownMs: 45_000 } ); + if (!res && import.meta.env.DEV) { + // En DEV no queremos quedar bloqueados por circuito abierto; intentar fetch directo. + res = await fetch(requestUrl, { + method: "GET", + headers: { + Accept: "application/json", + "X-Device-Id": deviceId, + }, + cache: "no-store", + }).catch(() => null); + } + if (!res) { + const fallbackMsg = lastErrorRef.current || "GPS unavailable (network/circuit open)"; + setStatus(locRef.current ? "ok" : "error"); + setError(fallbackMsg); + lastErrorRef.current = fallbackMsg; failCountRef.current += 1; const nextDelay = Math.min(pollMs * Math.pow(2, failCountRef.current), 5 * 60 * 1000); scheduleNext(nextDelay); @@ -108,10 +177,9 @@ export function useLastLocation(opts?: UseLastLocationOptions): UseLastLocationR } if (!res.ok) { - // Backend ahora siempre devuelve 200 (incluso sin datos) - // Si llega aquí, es un error real (500, network, etc.) - // En modo testing, parar polling después de 3 fallos - const isTesting = import.meta.env.MODE === "testing" || import.meta.env.DEV; + // En modo testing, parar polling después de 3 fallos. + // En DEV debemos seguir reportando para no ocultar HTTP 500 detrás del circuito. + const isTesting = import.meta.env.MODE === "testing"; if (isTesting && failCountRef.current >= 3) { // Parar polling en testing después de 3 fallos stopRef.current = true; @@ -122,10 +190,13 @@ export function useLastLocation(opts?: UseLastLocationOptions): UseLastLocationR } const j = (await res.json().catch(() => ({}))) as ApiResp; - const msg = (j as any)?.detail || `HTTP ${res.status}`; - setStatus("error"); + const detail = (j as any)?.detail; + const msg = detail + ? `GPS unavailable (HTTP ${res.status}): ${detail}` + : `GPS unavailable (HTTP ${res.status})`; + setStatus(locRef.current ? "ok" : "error"); setError(msg); - setLoc(null); + lastErrorRef.current = msg; failCountRef.current += 1; const nextDelay = Math.min(pollMs * Math.pow(2, failCountRef.current), 5 * 60 * 1000); scheduleNext(nextDelay); @@ -137,36 +208,75 @@ export function useLastLocation(opts?: UseLastLocationOptions): UseLastLocationR if (!ll) { // Safe default: backend puede responder "ok pero sin datos" (o stub). // No lo tratamos como error duro para evitar loops y banners agresivos. - setStatus("ok"); + setStatus(locRef.current ? "ok" : "idle"); setError(null); - setLoc(null); failCountRef.current += 1; const nextDelay = Math.min(pollMs * Math.pow(2, failCountRef.current), 5 * 60 * 1000); scheduleNext(nextDelay); return; } - setLoc(ll); + setLocSafe(ll); setStatus("ok"); setError(null); + lastErrorRef.current = null; failCountRef.current = 0; scheduleNext(pollMs); } catch (e: any) { - setStatus("error"); - setError(e?.message || "Error de red"); - setLoc(null); + const msg = e?.message || "GPS unavailable (network error)"; + setStatus(locRef.current ? "ok" : "error"); + setError(msg); + lastErrorRef.current = msg; failCountRef.current += 1; const nextDelay = Math.min(pollMs * Math.pow(2, failCountRef.current), 5 * 60 * 1000); scheduleNext(nextDelay); } }; + if ("geolocation" in navigator) { + geoWatchId = navigator.geolocation.watchPosition( + (pos) => { + const next: LastLocation = { + device_id: deviceId, + lat: pos.coords.latitude, + lon: pos.coords.longitude, + accuracy_m: Math.max(1, Math.round(pos.coords.accuracy || 30)), + ts_iso: new Date(pos.timestamp || Date.now()).toISOString(), + source: "browser_geolocation", + share_precision: "exact", + }; + setLocSafe(next); + void persistLocation(next.lat, next.lon); + setStatus("ok"); + setError(null); + lastErrorRef.current = null; + }, + (geoErr) => { + const message = + geoErr.code === geoErr.PERMISSION_DENIED + ? "GPS permission denied" + : geoErr.code === geoErr.POSITION_UNAVAILABLE + ? "GPS unavailable" + : "GPS timeout"; + if (!locRef.current) { + setStatus("error"); + } + setError(message); + lastErrorRef.current = message; + }, + { enableHighAccuracy: false, timeout: 7000, maximumAge: 120000 } + ); + } + // primer fetch inmediato fetchOnce(); return () => { stopRef.current = true; if (timer) window.clearTimeout(timer); + if (geoWatchId !== null && "geolocation" in navigator) { + navigator.geolocation.clearWatch(geoWatchId); + } }; }, [deviceId, pollMs, enabled]); diff --git a/frontend/src/lib/apiBase.ts b/frontend/src/lib/apiBase.ts new file mode 100644 index 000000000..1fe7b9dfe --- /dev/null +++ b/frontend/src/lib/apiBase.ts @@ -0,0 +1,52 @@ +const DEV_DEFAULT_API_BASE = "http://127.0.0.1:8001"; + +type HealthState = "idle" | "ok" | "error"; + +let cachedHealthState: HealthState = "idle"; +let healthPromise: Promise | null = null; + +const normalizeBase = (value: string): string => value.trim().replace(/\/$/, ""); + +export const getApiBase = (): string => { + const envBase = typeof import.meta.env.VITE_API_BASE === "string" ? import.meta.env.VITE_API_BASE : ""; + const envTrimmed = envBase.trim(); + const rawBase = envTrimmed + ? /^https?:\/\//i.test(envTrimmed) + ? envTrimmed + : "" + : import.meta.env.DEV + ? DEV_DEFAULT_API_BASE + : ""; + return normalizeBase(rawBase); +}; + +export const API_BASE = getApiBase(); + +export const buildApiUrl = (path: string): string => { + if (/^https?:\/\//i.test(path)) return path; + const cleanPath = path.startsWith("/") ? path : `/${path}`; + if (!API_BASE) return cleanPath; + return `${API_BASE}${cleanPath}`; +}; + +export const getHealthStateCached = (): HealthState => cachedHealthState; + +export const checkApiHealthOnce = async (): Promise => { + if (cachedHealthState !== "idle") return cachedHealthState; + if (healthPromise) return healthPromise; + + healthPromise = (async () => { + try { + const resp = await fetch(buildApiUrl("/health"), { cache: "no-store" }); + cachedHealthState = resp.ok ? "ok" : "error"; + return cachedHealthState; + } catch { + cachedHealthState = "error"; + return cachedHealthState; + } + })(); + + return healthPromise.finally(() => { + healthPromise = null; + }); +}; diff --git a/frontend/src/lib/mapRefStore.ts b/frontend/src/lib/mapRefStore.ts new file mode 100644 index 000000000..bc88420a1 --- /dev/null +++ b/frontend/src/lib/mapRefStore.ts @@ -0,0 +1,47 @@ +/** + * Store for MapLibre and Leaflet map refs used by LayerInspectorOverlay. + * PnboiaLayer registers MapLibre map; MapRefRegistrar registers Leaflet map. + */ + +import { useEffect, useState } from "react"; + +export type MapLibreMap = import("maplibre-gl").Map; +export type LeafletMap = import("leaflet").Map; + +let mapLibreRef: MapLibreMap | null = null; +let leafletRef: LeafletMap | null = null; +const listeners = new Set<() => void>(); + +function notify() { + listeners.forEach((fn) => fn()); +} + +export function registerMapLibre(map: MapLibreMap | null) { + mapLibreRef = map; + notify(); +} + +export function registerLeafletMap(map: LeafletMap | null) { + leafletRef = map; + notify(); +} + +export function getMapLibre(): MapLibreMap | null { + return mapLibreRef; +} + +export function getLeafletMap(): LeafletMap | null { + return leafletRef; +} + +export function useMapRefStore() { + const [, setTick] = useState(0); + useEffect(() => { + const fn = () => setTick((n) => n + 1); + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, []); + return { mapLibre: mapLibreRef, leafletMap: leafletRef }; +} From bb321c1cc2c423955e6551ab73ec82208e828a8f Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 18:57:03 -0300 Subject: [PATCH 06/24] docs(sap): add HITL EN+PT translations and wire links Co-authored-by: Cursor --- .../rules/hitl-handshake-done-claim-gate.mdc | 58 ++++++ AGENTS.md | 10 +- docs/core/README.md | 6 + docs/human-in-the-loop/README.en.md | 32 +++ docs/human-in-the-loop/README.md | 32 +++ docs/human-in-the-loop/README.pt.md | 32 +++ docs/human-in-the-loop/manifesto.en.md | 42 ++++ docs/human-in-the-loop/manifesto.md | 42 ++++ docs/human-in-the-loop/manifesto.pt.md | 42 ++++ docs/human-in-the-loop/manual.en.md | 189 ++++++++++++++++++ docs/human-in-the-loop/manual.md | 189 ++++++++++++++++++ docs/human-in-the-loop/manual.pt.md | 189 ++++++++++++++++++ 12 files changed, 862 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/hitl-handshake-done-claim-gate.mdc create mode 100644 docs/human-in-the-loop/README.en.md create mode 100644 docs/human-in-the-loop/README.md create mode 100644 docs/human-in-the-loop/README.pt.md create mode 100644 docs/human-in-the-loop/manifesto.en.md create mode 100644 docs/human-in-the-loop/manifesto.md create mode 100644 docs/human-in-the-loop/manifesto.pt.md create mode 100644 docs/human-in-the-loop/manual.en.md create mode 100644 docs/human-in-the-loop/manual.md create mode 100644 docs/human-in-the-loop/manual.pt.md diff --git a/.cursor/rules/hitl-handshake-done-claim-gate.mdc b/.cursor/rules/hitl-handshake-done-claim-gate.mdc new file mode 100644 index 000000000..20d6db86d --- /dev/null +++ b/.cursor/rules/hitl-handshake-done-claim-gate.mdc @@ -0,0 +1,58 @@ +--- +description: "Human-in-the-Loop Handshake + Done-Claim Gate (iURi) — No solved without proof" +alwaysApply: true +--- + +# Human-in-the-Loop Handshake + Done-Claim Gate (iURi) + +## 0) Regla de oro +Nunca digas "ya está / fixed / listo" si no hay **prueba**. Si no podés probar, decí: **IMPLEMENTED (UNVERIFIED)**. + +## 1) Estados permitidos (obligatorio usar uno) +- PLAN (what + why + stop conditions) +- IMPLEMENTING +- IMPLEMENTED (UNVERIFIED) +- VERIFIED (BUILD) +- VERIFIED (RUNTIME) ← solo si el humano confirmó checklist +- REGRESSION FOUND +- ROLLBACK / RESCUE + +## 2) Done-Claim Gate (NO SOLVED WITHOUT PROOF) +Para declarar VERIFIED (RUNTIME) deben existir, en el mismo mensaje: +- `git rev-parse --short HEAD` +- `npm -C frontend run build` OK (antes y después si hubo regresión) +- Checklist de 3–6 pasos que el humano ejecutó y reportó (pass/fail) + +Si falta algo: queda en IMPLEMENTED (UNVERIFIED) o VERIFIED (BUILD). + +## 3) HITL Handshake (cómo trabajamos) +- Vos (IA) proponés **1 cambio quirúrgico** + **1 checklist corto**. +- Yo (humano) testeo en el mapa y digo: PASS/FAIL + síntoma. +- Recién ahí seguís. Nada de encadenar 10 parches "en tu cabeza". + +## 4) "No time-travel" (anti-split-brain) +Antes de diagnosticar, confirmá SIEMPRE: +- commit servido por el dev server (PID/puerto) == `git rev-parse --short HEAD` +Si no coincide: **STOP** y pedí al humano alinear servidor/build. + +## 5) Guardrails de costo y modelos +- Default: usar el modelo más barato/rápido para leer, planear, y diffs chicos. +- Escalar a Codex solo para implementación puntual y con stop condition clara. +- Si el bug entra en loop (2 intentos sin mejora): proponer rollback al último "known-good" y reintentar con evidencia. + +## 6) Debug sin DevTools (preferido) +No pedir DevTools como requisito. +Preferir: +- indicadores en UI (badges, contadores, "debug=1") +- `curl`/comandos cortos (máximo 2–3) con salidas pegables + +## 7) Cambios: mínimos, auditables, reversibles +- 1 PR / 1 commit final. +- Si working tree sucio: checkpoint commit. +- Siempre incluir "cómo revertir": `git revert `. + +## 8) Prohibición de humo +Prohibido afirmar que "se agregó X" si no podés señalar: +- archivo exacto +- snippet/diff mínimo +- evidencia (build/runtime) diff --git a/AGENTS.md b/AGENTS.md index 0de352986..ff62e6229 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,14 @@ ## Regla de Oro **Si el sistema NO lo puede resolver solo, está fallando.** Cursor NO debe trasladar trabajo al humano salvo casos HUMAN_REQUIRED inevitables. +## Human-in-the-Loop Handshake + Done-Claim Gate (Always) +**Nunca digas "ya está / fixed / listo" sin prueba.** Si no podés probar → **IMPLEMENTED (UNVERIFIED)**. +- Estados: PLAN → IMPLEMENTING → IMPLEMENTED (UNVERIFIED) → VERIFIED (BUILD) → VERIFIED (RUNTIME) ← solo con checklist humano +- Done-Claim Gate: VERIFIED (RUNTIME) exige `git rev-parse --short HEAD` + build OK + checklist 3–6 pasos (pass/fail) +- Handshake: 1 cambio quirúrgico + 1 checklist → humano testea → PASS/FAIL → recién seguir +- No time-travel, debug sin DevTools, cambios reversibles, prohibición de humo +Regla completa: [`.cursor/rules/hitl-handshake-done-claim-gate.mdc`](.cursor/rules/hitl-handshake-done-claim-gate.mdc) + ## Estados de Guardian Cursor Gate ### ✅ PASS @@ -63,4 +71,4 @@ Cuando algo es IMPOSIBLE server-side: --- **Vigente desde:** 2026-01-12 -**Guardian Cursor Gate:** Activo \ No newline at end of file +**Guardian Cursor Gate:** Activo diff --git a/docs/core/README.md b/docs/core/README.md index 8f9691043..4ca1d3852 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -43,3 +43,9 @@ More detail: ## Safety defaults - Safe-by-default behavior and explicit uncertainty when evidence is missing. + +### Human in the Loop (HITL) +- Reality Anchor Protocol: [`docs/human-in-the-loop/README.md`](../human-in-the-loop/README.md) +- Manifesto: [`docs/human-in-the-loop/manifesto.md`](../human-in-the-loop/manifesto.md) +- Manual: [`docs/human-in-the-loop/manual.md`](../human-in-the-loop/manual.md) +- **Translations:** [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) diff --git a/docs/human-in-the-loop/README.en.md b/docs/human-in-the-loop/README.en.md new file mode 100644 index 000000000..624741da1 --- /dev/null +++ b/docs/human-in-the-loop/README.en.md @@ -0,0 +1,32 @@ +[ES](./README.md) | [EN](./README.en.md) | [PT](./README.pt.md) + +**Source of truth:** [ES](./README.md) + +# Human in the Loop (HITL) +## Reality Anchor Protocol ⚓️ + +These documents define the role and method for working with models (Codex/LLMs) without falling into: +- "works in my head" +- invisible regressions +- time travel (wrong build) +- expensive loops + +### Quick read +- **Manifesto (1 page):** [manifesto.en.md](./manifesto.en.md) +- **Operational manual:** [manual.en.md](./manual.en.md) + +### How we use this +1. **Model delivers** → IMPLEMENTED (UNVERIFIED) + short checklist +2. **Human validates** → tests in UI/map and responds PASS or FAIL + symptom +3. **Model declares DONE** → only if human reported PASS with evidence + +Example report (3 lines): +> PASS. PNBOIA visible. Build efb3694, URL /map, checklist 3/3 OK. + +Reminder: **No DevTools by default.** Prefer badges, counters in UI, short `curl` commands. + +### Key concepts +- **Done-Claim Gate:** nobody declares DONE without minimal evidence. +- **Official states:** PROPOSED → IMPLEMENTED (UNVERIFIED) → VERIFIED (BUILD) → VERIFIED (RUNTIME) → DONE (VERIFIED) +- **Model ↔ human handshake:** the human validates runtime, the model doesn't "claim victory" without proof. +- **Observability first:** LayerLab (Photoshop Layers) + probes + UI badges, before DevTools. diff --git a/docs/human-in-the-loop/README.md b/docs/human-in-the-loop/README.md new file mode 100644 index 000000000..a70bb9488 --- /dev/null +++ b/docs/human-in-the-loop/README.md @@ -0,0 +1,32 @@ +[ES](./README.md) | [EN](./README.en.md) | [PT](./README.pt.md) + +**Source of truth:** ES (Español) + +# Human in the Loop (HITL) +## Reality Anchor Protocol ⚓️ + +Estos documentos definen el rol y el método para trabajar con modelos (Codex/LLMs) sin caer en: +- "funciona en mi cabeza" +- regresiones invisibles +- viaje del tiempo (build equivocado) +- loops caros + +### Lectura rápida +- **Manifiesto (1 página):** [manifesto.md](./manifesto.md) +- **Manual operativo:** [manual.md](./manual.md) + +### Cómo lo usamos +1. **Modelo entrega** → IMPLEMENTED (UNVERIFIED) + checklist corto +2. **Humano valida** → testea en UI/mapa y responde PASS o FAIL + síntoma +3. **Modelo declara DONE** → solo si humano reportó PASS con evidencia + +Ejemplo de reporte (3 líneas): +> PASS. PNBOIA visible. Build efb3694, URL /map, checklist 3/3 OK. + +Recordatorio: **No DevTools por defecto.** Preferir badges, contadores en UI, `curl` cortos. + +### Conceptos clave +- **Done-Claim Gate:** nadie declara DONE sin evidencia mínima. +- **Estados oficiales:** PROPOSED → IMPLEMENTED (UNVERIFIED) → VERIFIED (BUILD) → VERIFIED (RUNTIME) → DONE (VERIFIED) +- **Handshake modelo ↔ humano:** el humano valida runtime, el modelo no "canta victoria" sin prueba. +- **Observabilidad primero:** LayerLab (Photoshop Layers) + probes + badges en UI, antes que DevTools. diff --git a/docs/human-in-the-loop/README.pt.md b/docs/human-in-the-loop/README.pt.md new file mode 100644 index 000000000..997a9a66a --- /dev/null +++ b/docs/human-in-the-loop/README.pt.md @@ -0,0 +1,32 @@ +[ES](./README.md) | [EN](./README.en.md) | [PT](./README.pt.md) + +**Source of truth:** [ES](./README.md) + +# Human in the Loop (HITL) +## Reality Anchor Protocol ⚓️ + +Estes documentos definem o papel e o método para trabalhar com modelos (Codex/LLMs) sem cair em: +- "funciona na minha cabeça" +- regressões invisíveis +- viagem no tempo (build errado) +- loops caros + +### Leitura rápida +- **Manifesto (1 página):** [manifesto.pt.md](./manifesto.pt.md) +- **Manual operativo:** [manual.pt.md](./manual.pt.md) + +### Como usamos +1. **Modelo entrega** → IMPLEMENTED (UNVERIFIED) + checklist curto +2. **Humano valida** → testa na UI/mapa e responde PASS ou FAIL + síntoma +3. **Modelo declara DONE** → só se o humano reportou PASS com evidência + +Exemplo de relatório (3 linhas): +> PASS. PNBOIA visível. Build efb3694, URL /map, checklist 3/3 OK. + +Lembrete: **Sem DevTools por padrão.** Preferir badges, contadores na UI, `curl` curtos. + +### Conceitos chave +- **Done-Claim Gate:** ninguém declara DONE sem evidência mínima. +- **Estados oficiais:** PROPOSED → IMPLEMENTED (UNVERIFIED) → VERIFIED (BUILD) → VERIFIED (RUNTIME) → DONE (VERIFIED) +- **Handshake modelo ↔ humano:** o humano valida runtime, o modelo não "canta vitória" sem prova. +- **Observabilidade primeiro:** LayerLab (Photoshop Layers) + probes + badges na UI, antes de DevTools. diff --git a/docs/human-in-the-loop/manifesto.en.md b/docs/human-in-the-loop/manifesto.en.md new file mode 100644 index 000000000..503eecddc --- /dev/null +++ b/docs/human-in-the-loop/manifesto.en.md @@ -0,0 +1,42 @@ +# Human in the Loop (HITL) Manifesto +## Reality Anchor Protocol ⚓️ +A new craft: humans as reality anchors for systems built with models. + +### 1) The core idea +- AI is a tool of the human. +- When the human builds with AI, the human also becomes a tool of the system: **observes, validates, prioritizes, and anchors reality**. +- The model can "solve in its head". The human prevents that from being confused with "solved in the world". + +### 2) What the Reality Anchor does +- Turns "doesn't work / looks weird" into actionable evidence: + - **what should happen** + - **what actually happens** + - **where / when / with which build** +- Prevents "time travel" (fixing one version while looking at another). +- Reduces loops and cost: fewer retries, more certainty. + +### 3) Principles (golden rules) +1. **Nothing is "Done" without evidence.** +2. **Visible first, elegant later.** +3. **Visual diagnosis when there are layers (Photoshop Layers).** +4. **One task, one goal, one commit.** +5. **Mandatory stop conditions**: "we stop when X is visible". +6. **No DevTools by default**: if the human gets lost, design diagnosis in the UI. +7. **Regression = debt**: if something worked, getting back to "works" is worth more than "new feature". + +### 4) Allowed states (anti smoke) +- **PROPOSED**: idea / plan (not applied). +- **IMPLEMENTED (UNVERIFIED)**: applied but not validated. +- **VERIFIED (BUILD)**: build/tests OK. +- **VERIFIED (RUNTIME)**: tested in the real UI. +- **DONE (VERIFIED)**: only when the human confirms runtime. + +### 5) The "Handshake" (model ↔ human) +- Model delivers: `IMPLEMENTED (UNVERIFIED)` + minimal checklist. +- Human responds: `VERIFIED ✅` or `FAILED ❌` + 1 line of evidence. +- Only then is `DONE (VERIFIED)` authorized. + +### 6) Oath of the Reality Anchor +Don't guess. Don't invent. Don't confuse. +Make the invisible visible. +Turn potential into real progress. diff --git a/docs/human-in-the-loop/manifesto.md b/docs/human-in-the-loop/manifesto.md new file mode 100644 index 000000000..bdd5bc368 --- /dev/null +++ b/docs/human-in-the-loop/manifesto.md @@ -0,0 +1,42 @@ +# Human in the Loop (HITL) Manifesto +## Reality Anchor Protocol ⚓️ +Un oficio nuevo: humanos como anclaje de realidad para sistemas construidos con modelos. + +### 1) La idea central +- La IA es una herramienta del humano. +- Cuando el humano construye con IA, el humano también se vuelve una herramienta del sistema: **observa, valida, prioriza y ancla la realidad**. +- El modelo puede "resolver en su cabeza". El humano evita que eso se confunda con "resuelto en el mundo". + +### 2) Qué hace el Reality Anchor +- Convierte "no se ve / anda raro" en evidencia accionable: + - **qué debería pasar** + - **qué pasa realmente** + - **dónde / cuándo / con qué build** +- Evita el "viaje del tiempo" (arreglar una versión mientras se mira otra). +- Reduce loops y costos: menos reintentos, más certeza. + +### 3) Principios (reglas de oro) +1. **Nada está "Done" sin evidencia.** +2. **Primero lo visible, después lo elegante.** +3. **Diagnóstico gráfico cuando hay capas (Photoshop Layers).** +4. **Una tarea, un objetivo, un commit.** +5. **Stop conditions obligatorias**: "paramos cuando X se ve". +6. **No DevTools por defecto**: si el humano se marea, se diseña diagnóstico en UI. +7. **Regresión = deuda**: si algo funcionaba, volver a "funciona" vale más que "nuevo feature". + +### 4) Estados permitidos (anti humo) +- **PROPOSED**: idea / plan (sin aplicar). +- **IMPLEMENTED (UNVERIFIED)**: aplicado pero no validado. +- **VERIFIED (BUILD)**: build/tests OK. +- **VERIFIED (RUNTIME)**: probado en la UI real. +- **DONE (VERIFIED)**: solo cuando el humano confirma runtime. + +### 5) El "Handshake" (modelo ↔ humano) +- Modelo entrega: `IMPLEMENTED (UNVERIFIED)` + checklist mínimo. +- Humano responde: `VERIFIED ✅` o `FAILED ❌` + 1 línea de evidencia. +- Recién ahí se autoriza: `DONE (VERIFIED)`. + +### 6) Juramento del Reality Anchor +No adivinar. No inventar. No marear. +Hacer visible lo invisible. +Convertir potencia en avance real. diff --git a/docs/human-in-the-loop/manifesto.pt.md b/docs/human-in-the-loop/manifesto.pt.md new file mode 100644 index 000000000..1eedca004 --- /dev/null +++ b/docs/human-in-the-loop/manifesto.pt.md @@ -0,0 +1,42 @@ +# Human in the Loop (HITL) Manifesto +## Reality Anchor Protocol ⚓️ +Um ofício novo: humanos como âncora de realidade para sistemas construídos com modelos. + +### 1) A ideia central +- A IA é uma ferramenta do humano. +- Quando o humano constrói com IA, o humano também se torna uma ferramenta do sistema: **observa, valida, prioriza e ancora a realidade**. +- O modelo pode "resolver na cabeça". O humano evita que isso se confunda com "resolvido no mundo". + +### 2) O que faz o Reality Anchor +- Converte "não funciona / anda estranho" em evidência acionável: + - **o que deveria acontecer** + - **o que acontece de fato** + - **onde / quando / com qual build** +- Evita o "viagem no tempo" (corrigir uma versão enquanto se olha outra). +- Reduz loops e custos: menos retentativas, mais certeza. + +### 3) Princípios (regras de ouro) +1. **Nada está "Done" sem evidência.** +2. **Primeiro o visível, depois o elegante.** +3. **Diagnóstico gráfico quando há camadas (Photoshop Layers).** +4. **Uma tarefa, um objetivo, um commit.** +5. **Stop conditions obrigatórias**: "paramos quando X se vê". +6. **Sem DevTools por padrão**: se o humano se perde, desenha-se diagnóstico na UI. +7. **Regressão = dívida**: se algo funcionava, voltar a "funciona" vale mais que "nova feature". + +### 4) Estados permitidos (anti fumaça) +- **PROPOSED**: ideia / plano (sem aplicar). +- **IMPLEMENTED (UNVERIFIED)**: aplicado mas não validado. +- **VERIFIED (BUILD)**: build/tests OK. +- **VERIFIED (RUNTIME)**: testado na UI real. +- **DONE (VERIFIED)**: só quando o humano confirma runtime. + +### 5) O "Handshake" (modelo ↔ humano) +- Modelo entrega: `IMPLEMENTED (UNVERIFIED)` + checklist mínimo. +- Humano responde: `VERIFIED ✅` ou `FAILED ❌` + 1 linha de evidência. +- Só então se autoriza: `DONE (VERIFIED)`. + +### 6) Juramento do Reality Anchor +Não adivinhar. Não inventar. Não confundir. +Tornar visível o invisível. +Converter potência em avanço real. diff --git a/docs/human-in-the-loop/manual.en.md b/docs/human-in-the-loop/manual.en.md new file mode 100644 index 000000000..2e2624c2a --- /dev/null +++ b/docs/human-in-the-loop/manual.en.md @@ -0,0 +1,189 @@ +# Human in the Loop (HITL) Operational Manual +## Reality Anchor Playbook ⚓️ + +## 0) Purpose +This manual defines how we work when a model writes/proposes changes and a human validates in the real UI. +Goal: **fewer loops, lower costs, more certainty, less "works in my head".** + +--- + +## 1) Roles (the new ladder) +### 1.1 Reality Anchor (HITL) +The human who: +- tests real flows, +- detects visual/performance friction, +- captures minimal evidence, +- decides priorities (what matters to the end user). + +### 1.2 Model (LLM / Codex) +The system that: +- proposes hypotheses, +- implements minimal fixes, +- creates diagnostic tools (inspector, probes), +- documents states and stop conditions. + +### 1.3 Ladder (learning without "junior tasks") +- **Apprentice Anchor**: reproduces, captures evidence, checklist. +- **Anchor**: designs probes, reports regressions, prioritizes. +- **Senior Anchor**: creates verification tooling (LayerLab, snapshots), defines metrics. +- **Anchor Lead**: defines standards, reduces cost per issue, trains the team. + +--- + +## 2) Key problem: "mental solution" vs "real solution" +Models can claim "ready" by internal coherence. HITL exists to force a bridge: + +### Guardian HITL-01: Done-Claim Gate (No "Solved" Without Proof) +Rule: +- Nobody (model or human) may declare **DONE** without minimal evidence. + +Minimal evidence for UI: +- Build OK (or equivalent test) +- Exact URL +- 3-step checklist with result +- (optional) screenshot + +Without that, the allowed state is: +- `IMPLEMENTED (UNVERIFIED)` or `VERIFIED (BUILD)`. + +--- + +## 3) Official states (taxonomy) +ALWAYS use one of these states in deliveries: + +- **PROPOSED** + - Plan / hypothesis. +- **IMPLEMENTED (UNVERIFIED)** + - Code applied, real test pending. +- **VERIFIED (BUILD)** + - build/tests OK. +- **VERIFIED (RUNTIME)** + - tested in real UI by HITL. +- **DONE (VERIFIED)** + - only after runtime verification. + +Recommended format at end of each delivery: +- State: +- What's missing: +- HITL checklist: + +--- + +## 4) HITL protocol in 5 steps (always the same) +### Step 1: Anchor reality (time travel antidote) +Confirm: +- which build/commit you're viewing (SERVING_COMMIT / BUILD_ID) +- exact URL used +- correct port (no old instance running) + +### Step 2: Reduce to 1 symptom +Examples: +- "PNBOIA ON but no buoys visible" +- "Center on me spins forever" +- "At 100% zoom the menu disappears" + +### Step 3: Make it observable (without DevTools) +Allowed tools: +- **UI badges** (items=N, status=ok/error) +- **GO button** to known location +- **Probe marker** (exaggerated) +- **Photoshop-style Layer Inspector (LayerLab)** + +Rule: +- If it can't be observed easily, build observability. + +### Step 4: Minimal fix + stop conditions +The model implements: +- minimal diff +- no large refactors +- explicit stop conditions ("we stop when X is visible") + +### Step 5: Final evidence +The human responds with: +- VERIFIED ✅ / FAILED ❌ +- 1 line Expected/Got +- (if applicable) screenshot + +--- + +## 5) Testing modes (universal template) +### Mode A: Visual UI Testing (without DevTools) +Use when: +- layers, z-index, gating, clipping, responsive, overlays, "invisible" render. + +Star tool: +- **LayerLab (Photoshop Layers)**: layer list with ON/OFF + reset. + +### Mode B: CLI Evidence (short commands) +Use when: +- backend/endpoint doubts, health, data exists vs render. + +Rule: +- only short, repeatable, non-destructive commands. + +### Mode C: Scenario Testing (real world) +Use when: +- performance, lag, black tiles, load times, connection. + +### Mode D: Safety & Veracity +Use when: +- risk decisions, degradation, "estimated" vs "real" data. + +--- + +## 6) Evidence kit (minimum viable) +### Minimal report (template) +- **Expected**: +- **Got**: +- **URL**: +- **Build**: +- **Notes** (1 line): + +Example: +> Expected: PNBOIA buoys visible. Got: fetched=5, inView=0, no markers. URL: /map?... Build: efb3694c7 + +--- + +## 7) Recommended tools (to avoid "guessing") +### 7.1 LayerLab (Photoshop Layers Inspector) +Requirements: +- view MapLibre/Leaflet layers (as applicable) +- ON/OFF visibility +- reset +- "layer exists / mounted" indicator +- "source has N features" indicator + +### 7.2 Probes +- huge "PROBE" marker +- badge with counters +- "GO" to known coordinates + +### 7.3 Time Travel Antidote +- always show/obtain `SERVING_COMMIT` +- always show/obtain `BUILD_ID` + +--- + +## 8) Metrics (to make the role billable) +- TTR: Time To Reproduce +- TTE: Time To Evidence +- % fixes verified without regression +- cost per issue (USD) before/after +- number of regressions avoided per week + +--- + +## 9) HITL checklist (short, repeatable) +- [ ] I'm on the correct build (SERVING_COMMIT) +- [ ] Exact URL confirmed +- [ ] Single symptom defined +- [ ] Observability: badge/probe/layerlab +- [ ] Minimal fix applied +- [ ] VERIFIED (RUNTIME) ✅ or FAILED ❌ with evidence + +--- + +## 10) Closing +HITL is not "help". It's a reality control system. +AI brings power. The human brings direction and empirical veracity. +The Reality Anchor prevents the project from burning in loops. diff --git a/docs/human-in-the-loop/manual.md b/docs/human-in-the-loop/manual.md new file mode 100644 index 000000000..08df3f6eb --- /dev/null +++ b/docs/human-in-the-loop/manual.md @@ -0,0 +1,189 @@ +# Human in the Loop (HITL) Manual Operativo +## Reality Anchor Playbook ⚓️ + +## 0) Propósito +Este manual define cómo trabajamos cuando un modelo escribe/propone cambios y un humano valida en la UI real. +Objetivo: **menos loops, menos costos, más certeza, menos "funciona en mi cabeza".** + +--- + +## 1) Roles (la escalera nueva) +### 1.1 Reality Anchor (HITL) +El humano que: +- prueba flujos reales, +- detecta fricción visual/performance, +- captura evidencia mínima, +- decide prioridades (lo que importa al usuario final). + +### 1.2 Modelo (LLM / Codex) +El sistema que: +- propone hipótesis, +- implementa fixes mínimos, +- crea herramientas de diagnóstico (inspector, probes), +- documenta estados y stop conditions. + +### 1.3 Ladder (aprendizaje sin "junior tasks") +- **Apprentice Anchor**: reproduce, captura evidencia, checklist. +- **Anchor**: diseña probes, reporta regresiones, prioriza. +- **Senior Anchor**: crea tooling de verificación (LayerLab, snapshots), define métricas. +- **Anchor Lead**: define estándares, reduce costo por issue, entrena al equipo. + +--- + +## 2) Problema clave: "solución mental" vs "solución real" +Los modelos pueden afirmar "listo" por coherencia interna. HITL existe para forzar un puente: + +### Guardian HITL-01: Done-Claim Gate (No "Solved" Without Proof) +Regla: +- Nadie (modelo o humano) puede declarar **DONE** sin evidencia mínima. + +Evidencia mínima para UI: +- Build OK (o test equivalente) +- URL exacta +- Checklist de 3 pasos con resultado +- (opcional) screenshot + +Sin eso, el estado permitido es: +- `IMPLEMENTED (UNVERIFIED)` o `VERIFIED (BUILD)`. + +--- + +## 3) Estados oficiales (taxonomía) +Usar SIEMPRE uno de estos estados en entregas: + +- **PROPOSED** + - Plan / hipótesis. +- **IMPLEMENTED (UNVERIFIED)** + - Código aplicado, falta prueba real. +- **VERIFIED (BUILD)** + - build/tests OK. +- **VERIFIED (RUNTIME)** + - probado en UI real por HITL. +- **DONE (VERIFIED)** + - solo tras verificación runtime. + +Formato recomendado al final de cada entrega: +- Estado: +- Qué falta: +- Checklist HITL: + +--- + +## 4) Protocolo HITL en 5 pasos (siempre igual) +### Paso 1: Asegurar realidad (antídoto viaje del tiempo) +Confirmar: +- qué build/commit estás viendo (SERVING_COMMIT / BUILD_ID) +- URL exacta usada +- puerto correcto (no hay otra instancia vieja) + +### Paso 2: Reducir a 1 síntoma +Ejemplos: +- "PNBOIA ON pero no se ve ninguna boya" +- "Center on me gira infinito" +- "En zoom 100% el menú desaparece" + +### Paso 3: Hacerlo observable (sin DevTools) +Herramientas permitidas: +- **Badges en UI** (items=N, status=ok/error) +- **Botón GO** a lugar conocido +- **Probe marker** exagerado +- **Layer Inspector estilo Photoshop (LayerLab)** + +Regla: +- Si no se puede observar fácil, se construye observabilidad. + +### Paso 4: Fix mínimo + stop conditions +El modelo implementa: +- mínimo diff +- sin refactors grandes +- stop conditions explícitas ("paramos cuando X se ve") + +### Paso 5: Evidencia final +El humano responde con: +- VERIFIED ✅ / FAILED ❌ +- 1 línea Expected/Got +- (si aplica) screenshot + +--- + +## 5) Modos de testing (plantilla universal) +### Modo A: Visual UI Testing (sin DevTools) +Usar cuando: +- capas, z-index, gating, clipping, responsive, overlays, render "invisible". + +Herramienta estrella: +- **LayerLab (Photoshop Layers)**: lista de layers con ON/OFF + reset. + +### Modo B: CLI Evidence (comandos cortos) +Usar cuando: +- dudas de backend/endpoint, health, data existe vs render. + +Regla: +- solo comandos cortos, repetibles, no destructivos. + +### Modo C: Scenario Testing (mundo real) +Usar cuando: +- performance, lag, tiles negros, tiempos de carga, conexión. + +### Modo D: Safety & Veracity +Usar cuando: +- decisiones con riesgo, degradación, datos "estimados" vs "reales". + +--- + +## 6) Kit de evidencia (mínimo viable) +### Reporte mínimo (plantilla) +- **Expected**: +- **Got**: +- **URL**: +- **Build**: +- **Notes** (1 línea): + +Ejemplo: +> Expected: PNBOIA buoys visibles. Got: fetched=5, inView=0, no markers. URL: /map?... Build: efb3694c7 + +--- + +## 7) Herramientas recomendadas (para evitar "adivinar") +### 7.1 LayerLab (Photoshop Layers Inspector) +Requisitos: +- ver layers MapLibre/Leaflet (según aplique) +- ON/OFF visibilidad +- reset +- indicador "layer exists / mounted" +- indicador "source has N features" + +### 7.2 Probes +- marker enorme "PROBE" +- badge con contadores +- "GO" a coordenadas conocidas + +### 7.3 Time Travel Antidote +- siempre mostrar/obtener `SERVING_COMMIT` +- siempre mostrar/obtener `BUILD_ID` + +--- + +## 8) Métricas (para que sea un rol pagable) +- TTR: Time To Reproduce +- TTE: Time To Evidence +- % fixes verificados sin regresión +- costo por issue (USD) antes/después +- número de regresiones evitadas por semana + +--- + +## 9) Checklist HITL (corto, repetible) +- [ ] Estoy en el build correcto (SERVING_COMMIT) +- [ ] URL exacta confirmada +- [ ] Síntoma único definido +- [ ] Observabilidad: badge/probe/layerlab +- [ ] Fix mínimo aplicado +- [ ] VERIFIED (RUNTIME) ✅ o FAILED ❌ con evidencia + +--- + +## 10) Cierre +HITL no es "ayuda". Es un sistema de control de realidad. +La IA aporta potencia. El humano aporta dirección y veracidad empírica. +El Reality Anchor evita que el proyecto se funda en loops. diff --git a/docs/human-in-the-loop/manual.pt.md b/docs/human-in-the-loop/manual.pt.md new file mode 100644 index 000000000..158df9022 --- /dev/null +++ b/docs/human-in-the-loop/manual.pt.md @@ -0,0 +1,189 @@ +# Human in the Loop (HITL) Manual Operativo +## Reality Anchor Playbook ⚓️ + +## 0) Propósito +Este manual define como trabalhamos quando um modelo escreve/propõe mudanças e um humano valida na UI real. +Objetivo: **menos loops, menos custos, mais certeza, menos "funciona na minha cabeça".** + +--- + +## 1) Papéis (a nova escada) +### 1.1 Reality Anchor (HITL) +O humano que: +- testa fluxos reais, +- detecta fricção visual/performance, +- captura evidência mínima, +- decide prioridades (o que importa ao usuário final). + +### 1.2 Modelo (LLM / Codex) +O sistema que: +- propõe hipóteses, +- implementa correções mínimas, +- cria ferramentas de diagnóstico (inspector, probes), +- documenta estados e stop conditions. + +### 1.3 Ladder (aprendizado sem "tarefas junior") +- **Apprentice Anchor**: reproduz, captura evidência, checklist. +- **Anchor**: desenha probes, reporta regressões, prioriza. +- **Senior Anchor**: cria tooling de verificação (LayerLab, snapshots), define métricas. +- **Anchor Lead**: define padrões, reduz custo por issue, treina a equipe. + +--- + +## 2) Problema chave: "solução mental" vs "solução real" +Os modelos podem afirmar "pronto" por coerência interna. HITL existe para forçar uma ponte: + +### Guardian HITL-01: Done-Claim Gate (Sem "Solved" Sem Prova) +Regra: +- Ninguém (modelo ou humano) pode declarar **DONE** sem evidência mínima. + +Evidência mínima para UI: +- Build OK (ou teste equivalente) +- URL exata +- Checklist de 3 passos com resultado +- (opcional) screenshot + +Sem isso, o estado permitido é: +- `IMPLEMENTED (UNVERIFIED)` ou `VERIFIED (BUILD)`. + +--- + +## 3) Estados oficiais (taxonomia) +Usar SEMPRE um destes estados em entregas: + +- **PROPOSED** + - Plano / hipótese. +- **IMPLEMENTED (UNVERIFIED)** + - Código aplicado, falta teste real. +- **VERIFIED (BUILD)** + - build/tests OK. +- **VERIFIED (RUNTIME)** + - testado na UI real pelo HITL. +- **DONE (VERIFIED)** + - só após verificação runtime. + +Formato recomendado ao final de cada entrega: +- Estado: +- O que falta: +- Checklist HITL: + +--- + +## 4) Protocolo HITL em 5 passos (sempre igual) +### Passo 1: Ancorar realidade (antídoto viagem no tempo) +Confirmar: +- qual build/commit você está vendo (SERVING_COMMIT / BUILD_ID) +- URL exata usada +- porta correta (não há outra instância antiga) + +### Passo 2: Reduzir a 1 sintoma +Exemplos: +- "PNBOIA ON mas não se vê nenhuma boia" +- "Center on me gira infinito" +- "No zoom 100% o menu desaparece" + +### Passo 3: Tornar observável (sem DevTools) +Ferramentas permitidas: +- **Badges na UI** (items=N, status=ok/error) +- **Botão GO** para lugar conhecido +- **Probe marker** exagerado +- **Layer Inspector estilo Photoshop (LayerLab)** + +Regra: +- Se não se pode observar facilmente, constrói-se observabilidade. + +### Passo 4: Fix mínimo + stop conditions +O modelo implementa: +- diff mínimo +- sem refactors grandes +- stop conditions explícitas ("paramos quando X se vê") + +### Passo 5: Evidência final +O humano responde com: +- VERIFIED ✅ / FAILED ❌ +- 1 linha Expected/Got +- (se aplicar) screenshot + +--- + +## 5) Modos de testing (plantilla universal) +### Modo A: Visual UI Testing (sem DevTools) +Usar quando: +- camadas, z-index, gating, clipping, responsive, overlays, render "invisível". + +Ferramenta estrela: +- **LayerLab (Photoshop Layers)**: lista de layers com ON/OFF + reset. + +### Modo B: CLI Evidence (comandos curtos) +Usar quando: +- dúvidas de backend/endpoint, health, data existe vs render. + +Regra: +- só comandos curtos, repetíveis, não destrutivos. + +### Modo C: Scenario Testing (mundo real) +Usar quando: +- performance, lag, tiles pretos, tempos de carga, conexão. + +### Modo D: Safety & Veracity +Usar quando: +- decisões com risco, degradação, dados "estimados" vs "reais". + +--- + +## 6) Kit de evidência (mínimo viável) +### Relatório mínimo (plantilla) +- **Expected**: +- **Got**: +- **URL**: +- **Build**: +- **Notes** (1 linha): + +Exemplo: +> Expected: PNBOIA buoys visíveis. Got: fetched=5, inView=0, no markers. URL: /map?... Build: efb3694c7 + +--- + +## 7) Ferramentas recomendadas (para evitar "adivinhar") +### 7.1 LayerLab (Photoshop Layers Inspector) +Requisitos: +- ver layers MapLibre/Leaflet (conforme aplique) +- ON/OFF visibilidade +- reset +- indicador "layer exists / mounted" +- indicador "source has N features" + +### 7.2 Probes +- marker enorme "PROBE" +- badge com contadores +- "GO" para coordenadas conhecidas + +### 7.3 Antídoto Time Travel +- sempre mostrar/obter `SERVING_COMMIT` +- sempre mostrar/obter `BUILD_ID` + +--- + +## 8) Métricas (para que seja um papel faturavel) +- TTR: Time To Reproduce +- TTE: Time To Evidence +- % fixes verificados sem regressão +- custo por issue (USD) antes/depois +- número de regressões evitadas por semana + +--- + +## 9) Checklist HITL (curto, repetível) +- [ ] Estou no build correto (SERVING_COMMIT) +- [ ] URL exata confirmada +- [ ] Sintoma único definido +- [ ] Observabilidade: badge/probe/layerlab +- [ ] Fix mínimo aplicado +- [ ] VERIFIED (RUNTIME) ✅ ou FAILED ❌ com evidência + +--- + +## 10) Fechamento +HITL não é "ajuda". É um sistema de controle de realidade. +A IA aporta potência. O humano aporta direção e veracidade empírica. +O Reality Anchor evita que o projeto se funda em loops. From 72d7534530e9fe53656eececea21afb40b75d1f5 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 19:18:01 -0300 Subject: [PATCH 07/24] docs(sap): link HITL with CRIT Gate and 8 guardians Co-authored-by: Cursor --- docs/core/README.md | 2 + docs/human-in-the-loop/README.en.md | 8 + docs/human-in-the-loop/README.md | 8 + docs/human-in-the-loop/README.pt.md | 8 + .../reality-anchor-role.en.md | 159 ++++++++++++++++++ docs/human-in-the-loop/reality-anchor-role.md | 159 ++++++++++++++++++ .../reality-anchor-role.pt.md | 159 ++++++++++++++++++ 7 files changed, 503 insertions(+) create mode 100644 docs/human-in-the-loop/reality-anchor-role.en.md create mode 100644 docs/human-in-the-loop/reality-anchor-role.md create mode 100644 docs/human-in-the-loop/reality-anchor-role.pt.md diff --git a/docs/core/README.md b/docs/core/README.md index 4ca1d3852..24cf512e4 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -30,6 +30,8 @@ Each guardian explains a different safety angle of the same decision: - **Airbag (safety)**: risk and damage reduction - **Ledger (traceability)**: audit trail and justification +Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) + More detail: - [`docs/GUARDIANS_SUMMARY_FOR_HUMANS.md`](../GUARDIANS_SUMMARY_FOR_HUMANS.md) - [`docs/GUARDIANS_ROLL_CALL.md`](../GUARDIANS_ROLL_CALL.md) diff --git a/docs/human-in-the-loop/README.en.md b/docs/human-in-the-loop/README.en.md index 624741da1..6d04e1168 100644 --- a/docs/human-in-the-loop/README.en.md +++ b/docs/human-in-the-loop/README.en.md @@ -14,6 +14,7 @@ These documents define the role and method for working with models (Codex/LLMs) ### Quick read - **Manifesto (1 page):** [manifesto.en.md](./manifesto.en.md) - **Operational manual:** [manual.en.md](./manual.en.md) +- **Reality Anchor as a craft:** [reality-anchor-role.en.md](./reality-anchor-role.en.md) ### How we use this 1. **Model delivers** → IMPLEMENTED (UNVERIFIED) + short checklist @@ -25,6 +26,13 @@ Example report (3 lines): Reminder: **No DevTools by default.** Prefer badges, counters in UI, short `curl` commands. +### HITL ↔ CRIT Gate +- **CRIT Gate** governs the system (pre/post checks on model output). +- **HITL** governs the validation process (human in runtime). +- **Done-Claim Gate** is the bridge (nobody declares DONE without evidence). + +[CRIT Gate and 8 guardians](../core/README.md#crit-gate-prepost) | [The eight guardians (lanes)](../core/README.md#the-eight-guardians-lanes) + ### Key concepts - **Done-Claim Gate:** nobody declares DONE without minimal evidence. - **Official states:** PROPOSED → IMPLEMENTED (UNVERIFIED) → VERIFIED (BUILD) → VERIFIED (RUNTIME) → DONE (VERIFIED) diff --git a/docs/human-in-the-loop/README.md b/docs/human-in-the-loop/README.md index a70bb9488..662ceeb44 100644 --- a/docs/human-in-the-loop/README.md +++ b/docs/human-in-the-loop/README.md @@ -14,6 +14,7 @@ Estos documentos definen el rol y el método para trabajar con modelos (Codex/LL ### Lectura rápida - **Manifiesto (1 página):** [manifesto.md](./manifesto.md) - **Manual operativo:** [manual.md](./manual.md) +- **Reality Anchor como oficio:** [reality-anchor-role.md](./reality-anchor-role.md) ### Cómo lo usamos 1. **Modelo entrega** → IMPLEMENTED (UNVERIFIED) + checklist corto @@ -25,6 +26,13 @@ Ejemplo de reporte (3 líneas): Recordatorio: **No DevTools por defecto.** Preferir badges, contadores en UI, `curl` cortos. +### HITL ↔ CRIT Gate +- **CRIT Gate** gobierna el sistema (checks pre/post en modelo). +- **HITL** gobierna el proceso de validación (humano en runtime). +- **Done-Claim Gate** es el puente (nadie declara DONE sin evidencia). + +[CRIT Gate y 8 guardians](../core/README.md#crit-gate-prepost) | [The eight guardians (lanes)](../core/README.md#the-eight-guardians-lanes) + ### Conceptos clave - **Done-Claim Gate:** nadie declara DONE sin evidencia mínima. - **Estados oficiales:** PROPOSED → IMPLEMENTED (UNVERIFIED) → VERIFIED (BUILD) → VERIFIED (RUNTIME) → DONE (VERIFIED) diff --git a/docs/human-in-the-loop/README.pt.md b/docs/human-in-the-loop/README.pt.md index 997a9a66a..3af2648c1 100644 --- a/docs/human-in-the-loop/README.pt.md +++ b/docs/human-in-the-loop/README.pt.md @@ -14,6 +14,7 @@ Estes documentos definem o papel e o método para trabalhar com modelos (Codex/L ### Leitura rápida - **Manifesto (1 página):** [manifesto.pt.md](./manifesto.pt.md) - **Manual operativo:** [manual.pt.md](./manual.pt.md) +- **Reality Anchor como ofício:** [reality-anchor-role.pt.md](./reality-anchor-role.pt.md) ### Como usamos 1. **Modelo entrega** → IMPLEMENTED (UNVERIFIED) + checklist curto @@ -25,6 +26,13 @@ Exemplo de relatório (3 linhas): Lembrete: **Sem DevTools por padrão.** Preferir badges, contadores na UI, `curl` curtos. +### HITL ↔ CRIT Gate +- **CRIT Gate** governa o sistema (checks pre/post no modelo). +- **HITL** governa o processo de validação (humano em runtime). +- **Done-Claim Gate** é a ponte (ninguém declara DONE sem evidência). + +[CRIT Gate e 8 guardians](../core/README.md#crit-gate-prepost) | [The eight guardians (lanes)](../core/README.md#the-eight-guardians-lanes) + ### Conceitos chave - **Done-Claim Gate:** ninguém declara DONE sem evidência mínima. - **Estados oficiais:** PROPOSED → IMPLEMENTED (UNVERIFIED) → VERIFIED (BUILD) → VERIFIED (RUNTIME) → DONE (VERIFIED) diff --git a/docs/human-in-the-loop/reality-anchor-role.en.md b/docs/human-in-the-loop/reality-anchor-role.en.md new file mode 100644 index 000000000..6aee14072 --- /dev/null +++ b/docs/human-in-the-loop/reality-anchor-role.en.md @@ -0,0 +1,159 @@ +# Reality Anchor (HITL) as a Craft +## Same as Always… But Now Packaged as a Role + +### 1) Core idea +In AI labs, this already exists. Not as "a magical new role", but as a set of responsibilities spread across existing profiles. + +What's changing is that, for small teams and for the general population, that set is becoming a recognizable, sellable job: someone who anchors reality when a model "thinks" something is solved. + +In one sentence: +**the model produces solutions; the human produces evidence.** + +--- + +### 2) In labs this already happens (just with other names) +Inside AI organizations, HITL work is usually distributed across existing roles: + +- **QA / Test Engineer** + Testing, regressions, "this broke". +- **Evaluation / Model Evaluation** + Test batteries, metrics, "how well does it work". +- **Red Team / Safety** + Limits, risks, dangerous behaviors. +- **User Research / Dogfooding** + Real friction, "this doesn't make sense", "this is confusing". +- **Data labeling / adjudication** + When you have to decide what's correct and what isn't. +- **SRE / Incidents** + "up or down", operational veracity. + +We didn't invent the wheel: we invented the **manual** so we don't lose it. + +--- + +### 3) What's new (outside the lab) isn't "testing": it's the combination +What distinguishes the "Reality Anchor (HITL)" craft isn't running tests as such, but combining into a single practice: + +1) The model proposes and implements changes (fast, cheap per iteration, but with risk of operational hallucination). +2) The human validates in real runtime (map, UI, performance, latency, visibility). +3) Governance via rules that prevent "imaginary DONE". + +This matters because models can have **internal coherence** and still fail in practice: +- invisible layers +- gates that block UI +- wrong base URL +- old build serving assets +- "works" in theory, but not in the real browser + +The human doesn't add "more abstract intelligence". +They add **reality**. + +--- + +### 4) Why HITL exists even with top models +Because there is a permanent gap: + +- **Mental solution (model):** "It makes sense, it should work". +- **Real solution (human):** "It works here, now, with this build". + +Without a gate, the system becomes expensive smoke: +- repeated "done" +- recurring regressions +- cost loops + +So the central guardrail is: + +#### Done-Claim Gate (No "Solved" Without Proof) +Nobody declares DONE without minimal evidence (build + runtime). + +--- + +### 5) What a Reality Anchor (HITL) actually does +An effective HITL turns feelings ("doesn't show", "acts weird") into actionable evidence: + +- **Expected / Got** in 1 line +- **Exact URL** and **build/commit** (time-travel antidote) +- **Repro steps** minimal (3 steps) +- **Probe/Badge/LayerLab** when DevTools is confusing + +Instead of arguing theory, they design observability: +- badges (items=N, status) +- probes (exaggerated marker) +- "GO" buttons to known coordinates +- Photoshop-style layer inspector + +The philosophy: +**if it can't be seen easily, first build so it can be seen.** + +--- + +### 6) Why this can become real work (and a career path) +AI tends to replace "intermediate" text/code production tasks, but creates demand for: +- empirical validation +- regression control +- behavior evaluation +- claim verification ("it's fixed") + +HITL appears as a bridge role: +- maintains the quality chain, +- reduces cost per iteration, +- and stops the system from feeling perfect. + +It's a "continuity profession" in the automation age: +less creation from scratch, more verification, judgment, evidence, and control. + +--- + +### 7) Professional names (for CV, teams, contracts) +Three "sellable" names depending on context: + +- **Reality Anchor (HITL)** + Short, memorable, describes the essence. +- **AI Validation Operator** + Industrial, direct, "operation work". +- **Model QA / LLM QA** + Classic, easy to understand in companies. + +How to describe it without mystique: +> QA for model-built systems: I design probes, gates, and scenarios; validate runtime; reduce regressions and loops. + +--- + +### 8) Ladder (growth without traditional "junior tasks") +- **Apprentice Anchor:** reproduces, captures evidence, checklist. +- **Anchor:** designs probes/badges, reports regressions, prioritizes. +- **Senior Anchor:** creates tooling (LayerLab, snapshots), defines metrics. +- **Anchor Lead:** veracity standard, lowers cost/issue, trains the team. + +--- + +### Mapping HITL ↔ Guardians +| Guardian (exact name from [docs/core/README.md](../core/README.md)) | HITL mapping | +|---------------------------------------------------------------------|--------------| +| Pacto (ethics) | (no direct mapping) | +| Comando (shell_commands) | (no direct mapping) | +| Núcleo (system_config) | (no direct mapping) | +| Flujo (workflow) | Reproducibility and verification — HITL operationalizes | +| Frontera (project_scope) | (no direct mapping) | +| Veritas (veracity) | Evidence and truthfulness — Done-Claim Gate, PASS/FAIL | +| Airbag (safety) | Regression control, risk reduction | +| Ledger (traceability) | Audit trail (build, checklist, evidence) | + +--- + +### 9) What HITL is NOT (healthy limits) +- Not "doing deep debugging" by default. +- Not mandatory DevTools. +- Not writing new features without control. +- Not declaring DONE without evidence. + +HITL is: +**reality control + loop reduction + minimal evidence.** + +--- + +### 10) Closing +In labs, HITL already exists in pieces. +What's new is packaging it as an explicit, replicable, trainable role, with rules that protect the project from "works in my head". + +When the model dreams, the human holds the helm. diff --git a/docs/human-in-the-loop/reality-anchor-role.md b/docs/human-in-the-loop/reality-anchor-role.md new file mode 100644 index 000000000..8a0bf152f --- /dev/null +++ b/docs/human-in-the-loop/reality-anchor-role.md @@ -0,0 +1,159 @@ +# Reality Anchor (HITL) como oficio +## Lo mismo de siempre… pero ahora empaquetado como rol + +### 1) Idea central +En laboratorios de IA, esto ya existe. No como "un rol mágico nuevo", sino como un conjunto de responsabilidades repartidas entre varios perfiles. + +Lo que está cambiando es que, para equipos chicos y para la población general, ese conjunto se está convirtiendo en un trabajo reconocible y vendible: alguien que ancla la realidad cuando un modelo "piensa" que algo quedó resuelto. + +En una frase: +**el modelo produce soluciones; el humano produce evidencia.** + +--- + +### 2) En los labs esto ya pasa (solo que con otros nombres) +Dentro de organizaciones de IA, el trabajo de HITL suele estar distribuido en roles existentes: + +- **QA / Test Engineer** + Pruebas, regresiones, "esto se rompió". +- **Evaluation / Model Evaluation** + Baterías de tests, métricas, "qué tan bien funciona". +- **Red Team / Safety** + Límites, riesgos, comportamientos peligrosos. +- **User Research / Dogfooding** + Fricción real, "esto no se entiende", "esto confunde". +- **Data labeling / adjudication** + Cuando hay que decidir qué es correcto y qué no. +- **SRE / Incidents** + "está arriba o está caído", veracidad operativa. + +No inventamos la rueda: inventamos el **manual** para no perderla. + +--- + +### 3) Lo nuevo (afuera del lab) no es "testear": es la combinación +Lo distintivo del oficio "Reality Anchor (HITL)" no es ejecutar tests como tal, sino juntar en una sola práctica: + +1) El modelo propone e implementa cambios (rápido, barato por iteración, pero con riesgo de alucinación operativa). +2) El humano valida en runtime real (mapa, UI, performance, latencia, visibilidad). +3) Se gobierna con reglas que impiden "DONE imaginario". + +Esto es clave porque los modelos pueden tener **coherencia interna** y aun así fallar en lo práctico: +- capas invisibles +- gates que bloquean UI +- base URL errónea +- build viejo sirviendo assets +- "funciona" en teoría, pero no en el navegador real + +El humano no aporta "más inteligencia abstracta". +Aporta **realidad**. + +--- + +### 4) Por qué HITL existe incluso con modelos top +Porque hay una diferencia permanente: + +- **Solución mental (modelo):** "Tiene sentido, debería funcionar". +- **Solución real (humano):** "Funciona aquí, ahora, con este build". + +Sin un gate, el sistema se convierte en humo caro: +- "listo" repetido +- regresiones que vuelven +- loops de costo + +Por eso el guardrail central es: + +#### Done-Claim Gate (No "Solved" Without Proof) +Nadie declara DONE sin evidencia mínima (build + runtime). + +--- + +### 5) Qué hace exactamente un Reality Anchor (HITL) +Un HITL efectivo transforma sensaciones ("no se ve", "anda raro") en evidencia accionable: + +- **Expected / Got** en 1 línea +- **URL exacta** y **build/commit** (antídoto del viaje del tiempo) +- **Repro steps** mínimos (3 pasos) +- **Probe/Badge/LayerLab** cuando DevTools marea + +En vez de discutir teorías, diseña observabilidad: +- badges (items=N, status) +- probes (marker exagerado) +- botones "GO" a coordenadas conocidas +- Layer inspector tipo Photoshop + +La filosofía: +**si no se puede ver fácil, primero se construye para verlo.** + +--- + +### 6) Por qué esto puede volverse un trabajo real (y una salida laboral) +La IA tiende a reemplazar tareas "intermedias" de producción de texto/código, pero crea demanda de tareas de: +- validación empírica +- control de regresiones +- evaluación de comportamiento +- verificación de claims ("está resuelto") + +El HITL aparece como un rol puente: +- mantiene la cadena de calidad, +- reduce costos por iteración, +- y evita que el sistema se autoperciba perfecto. + +Es una "profesión de continuidad" en la era de automatización: +menos creación desde cero, más verificación, criterio, evidencia y control. + +--- + +### 7) Nombres profesionales (para CV, equipos y contratos) +Tres nombres "vendibles" según el contexto: + +- **Reality Anchor (HITL)** + Corto, memorable, describe la esencia. +- **AI Validation Operator** + Industrial, directo, "trabajo de operación". +- **Model QA / LLM QA** + Clásico, fácil de entender en empresas. + +Cómo describirlo sin mística: +> QA para sistemas construidos con modelos: diseño probes, gates y escenarios; valido runtime; reduzco regresiones y loops. + +--- + +### 8) Ladder (crecimiento sin "junior tasks" tradicionales) +- **Apprentice Anchor:** reproduce, captura evidencia, checklist. +- **Anchor:** diseña probes/badges, reporta regresiones, prioriza. +- **Senior Anchor:** crea tooling (LayerLab, snapshots), define métricas. +- **Anchor Lead:** estándar de veracidad, baja costo/issue, entrena al equipo. + +--- + +### Mapping HITL ↔ Guardians +| Guardian (exact name from [docs/core/README.md](../core/README.md)) | HITL mapping | +|---------------------------------------------------------------------|--------------| +| Pacto (ethics) | (no direct mapping) | +| Comando (shell_commands) | (no direct mapping) | +| Núcleo (system_config) | (no direct mapping) | +| Flujo (workflow) | Reproducibility and verification — HITL operationalizes | +| Frontera (project_scope) | (no direct mapping) | +| Veritas (veracity) | Evidence and truthfulness — Done-Claim Gate, PASS/FAIL | +| Airbag (safety) | Regression control, risk reduction | +| Ledger (traceability) | Audit trail (build, checklist, evidence) | + +--- + +### 9) Qué NO es HITL (límites saludables) +- No es "hacer debugging profundo" por defecto. +- No es DevTools obligatorio. +- No es escribir features nuevas sin control. +- No es declarar DONE sin evidencia. + +HITL es: +**control de realidad + reducción de loops + evidencia mínima.** + +--- + +### 10) Cierre +En laboratorios, HITL ya existe en pedazos. +Lo nuevo es empaquetarlo como un rol explícito, replicable, entrenable, y con reglas que protegen al proyecto del "funciona en mi cabeza". + +Cuando el modelo sueña, el humano toca el timón. diff --git a/docs/human-in-the-loop/reality-anchor-role.pt.md b/docs/human-in-the-loop/reality-anchor-role.pt.md new file mode 100644 index 000000000..90567b422 --- /dev/null +++ b/docs/human-in-the-loop/reality-anchor-role.pt.md @@ -0,0 +1,159 @@ +# Reality Anchor (HITL) como ofício +## O mesmo de sempre… mas agora empacotado como papel + +### 1) Ideia central +Em laboratórios de IA, isso já existe. Não como "um papel mágico novo", mas como um conjunto de responsabilidades repartidas entre vários perfis. + +O que está mudando é que, para equipes pequenas e para a população em geral, esse conjunto está se tornando um trabalho reconhecível e vendável: alguém que ancora a realidade quando um modelo "acha" que algo ficou resolvido. + +Em uma frase: +**o modelo produz soluções; o humano produz evidência.** + +--- + +### 2) Nos labs isso já acontece (só que com outros nomes) +Dentro de organizações de IA, o trabalho de HITL costuma estar distribuído em papéis existentes: + +- **QA / Test Engineer** + Testes, regressões, "isso quebrou". +- **Evaluation / Model Evaluation** + Baterias de testes, métricas, "quão bem funciona". +- **Red Team / Safety** + Limites, riscos, comportamentos perigosos. +- **User Research / Dogfooding** + Fricção real, "isso não se entende", "isso confunde". +- **Data labeling / adjudication** + Quando é preciso decidir o que é correto e o que não é. +- **SRE / Incidents** + "está no ar ou está caído", veracidade operativa. + +Não inventamos a roda: inventamos o **manual** para não perdê-la. + +--- + +### 3) O novo (fora do lab) não é "testar": é a combinação +O distintivo do ofício "Reality Anchor (HITL)" não é executar testes em si, mas juntar numa única prática: + +1) O modelo propõe e implementa mudanças (rápido, barato por iteração, mas com risco de alucinação operativa). +2) O humano valida em runtime real (mapa, UI, performance, latência, visibilidade). +3) Governa-se com regras que impedem "DONE imaginário". + +Isso é chave porque os modelos podem ter **coerência interna** e mesmo assim falhar na prática: +- camadas invisíveis +- gates que bloqueiam UI +- base URL errada +- build antigo servindo assets +- "funciona" em teoria, mas não no navegador real + +O humano não aporta "mais inteligência abstrata". +Aporta **realidade**. + +--- + +### 4) Por que HITL existe mesmo com modelos top +Porque há uma diferença permanente: + +- **Solução mental (modelo):** "Faz sentido, deveria funcionar". +- **Solução real (humano):** "Funciona aqui, agora, com este build". + +Sem um gate, o sistema vira fumaça cara: +- "pronto" repetido +- regressões que voltam +- loops de custo + +Por isso o guardrail central é: + +#### Done-Claim Gate (No "Solved" Without Proof) +Ninguém declara DONE sem evidência mínima (build + runtime). + +--- + +### 5) O que faz exatamente um Reality Anchor (HITL) +Um HITL efetivo transforma sensações ("não se vê", "anda estranho") em evidência acionável: + +- **Expected / Got** em 1 linha +- **URL exata** e **build/commit** (antídoto da viagem no tempo) +- **Repro steps** mínimos (3 passos) +- **Probe/Badge/LayerLab** quando DevTools confunde + +Em vez de discutir teorias, desenha observabilidade: +- badges (items=N, status) +- probes (marker exagerado) +- botões "GO" para coordenadas conhecidas +- Layer inspector tipo Photoshop + +A filosofia: +**se não se pode ver fácil, primeiro constrói-se para ver.** + +--- + +### 6) Por que isso pode virar trabalho real (e saída laboral) +A IA tende a substituir tarefas "intermediárias" de produção de texto/código, mas cria demanda de tarefas de: +- validação empírica +- controle de regressões +- avaliação de comportamento +- verificação de claims ("está resolvido") + +O HITL aparece como papel ponte: +- mantém a cadeia de qualidade, +- reduz custos por iteração, +- e evita que o sistema se auto-perceba perfeito. + +É uma "profissão de continuidade" na era da automação: +menos criação do zero, mais verificação, critério, evidência e controle. + +--- + +### 7) Nomes profissionais (para CV, equipes e contratos) +Três nomes "vendáveis" conforme o contexto: + +- **Reality Anchor (HITL)** + Curto, memorável, descreve a essência. +- **AI Validation Operator** + Industrial, direto, "trabalho de operação". +- **Model QA / LLM QA** + Clássico, fácil de entender em empresas. + +Como descrever sem mística: +> QA para sistemas construídos com modelos: desenho probes, gates e cenários; valido runtime; reduzo regressões e loops. + +--- + +### 8) Ladder (crescimento sem "junior tasks" tradicionais) +- **Apprentice Anchor:** reproduz, captura evidência, checklist. +- **Anchor:** desenha probes/badges, reporta regressões, prioriza. +- **Senior Anchor:** cria tooling (LayerLab, snapshots), define métricas. +- **Anchor Lead:** padrão de veracidade, baixa custo/issue, treina a equipe. + +--- + +### Mapping HITL ↔ Guardians +| Guardian (exact name from [docs/core/README.md](../core/README.md)) | HITL mapping | +|---------------------------------------------------------------------|--------------| +| Pacto (ethics) | (no direct mapping) | +| Comando (shell_commands) | (no direct mapping) | +| Núcleo (system_config) | (no direct mapping) | +| Flujo (workflow) | Reproducibility and verification — HITL operationalizes | +| Frontera (project_scope) | (no direct mapping) | +| Veritas (veracity) | Evidence and truthfulness — Done-Claim Gate, PASS/FAIL | +| Airbag (safety) | Regression control, risk reduction | +| Ledger (traceability) | Audit trail (build, checklist, evidence) | + +--- + +### 9) O que NÃO é HITL (limites saudáveis) +- Não é "fazer debugging profundo" por padrão. +- Não é DevTools obrigatório. +- Não é escrever features novas sem controle. +- Não é declarar DONE sem evidência. + +HITL é: +**controle de realidade + redução de loops + evidência mínima.** + +--- + +### 10) Fechamento +Em laboratórios, HITL já existe em pedaços. +O novo é empacotá-lo como papel explícito, replicável, treinável, e com regras que protegem o projeto do "funciona na minha cabeça". + +Quando o modelo sonha, o humano segura o leme. From 71ab62e1da069ad53c1954a32cd700d420bb5e52 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 20:27:47 -0300 Subject: [PATCH 08/24] docs(sap): add ops runbooks package Co-authored-by: Cursor --- docs/core/README.md | 2 +- docs/ops/README.md | 4 + docs/ops/runbooks/README.md | 29 +++++++ docs/ops/runbooks/api-base-cors-mismatch.md | 49 ++++++++++++ docs/ops/runbooks/backend-unavailable.md | 42 ++++++++++ docs/ops/runbooks/deploy-rollback.md | 38 ++++++++++ docs/ops/runbooks/location-missing-device.md | 46 +++++++++++ docs/ops/runbooks/pnboia-no-buoys-visible.md | 80 ++++++++++++++++++++ docs/ops/runbooks/weather-degraded.md | 58 ++++++++++++++ 9 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 docs/ops/README.md create mode 100644 docs/ops/runbooks/README.md create mode 100644 docs/ops/runbooks/api-base-cors-mismatch.md create mode 100644 docs/ops/runbooks/backend-unavailable.md create mode 100644 docs/ops/runbooks/deploy-rollback.md create mode 100644 docs/ops/runbooks/location-missing-device.md create mode 100644 docs/ops/runbooks/pnboia-no-buoys-visible.md create mode 100644 docs/ops/runbooks/weather-degraded.md diff --git a/docs/core/README.md b/docs/core/README.md index 24cf512e4..400fdcc52 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -30,7 +30,7 @@ Each guardian explains a different safety angle of the same decision: - **Airbag (safety)**: risk and damage reduction - **Ledger (traceability)**: audit trail and justification -Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) +Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) More detail: - [`docs/GUARDIANS_SUMMARY_FOR_HUMANS.md`](../GUARDIANS_SUMMARY_FOR_HUMANS.md) diff --git a/docs/ops/README.md b/docs/ops/README.md new file mode 100644 index 000000000..710ac1030 --- /dev/null +++ b/docs/ops/README.md @@ -0,0 +1,4 @@ +# iURi Ops + +- **Runbooks:** [docs/ops/runbooks/README.md](runbooks/README.md) — diagnóstico y recuperación sin DevTools +- **WayBack:** [docs/ops/wayback/README.md](wayback/README.md) — snapshots y cambios diff --git a/docs/ops/runbooks/README.md b/docs/ops/runbooks/README.md new file mode 100644 index 000000000..36a00b60c --- /dev/null +++ b/docs/ops/runbooks/README.md @@ -0,0 +1,29 @@ +# iURi Runbooks (Ops) + +Operational runbooks para PMAP/FIPERJ. Evidencia antes de afirmar resuelto (Done-Claim Gate). + +## Propósito +Los runbooks son verdad operativa: pasos reproducibles, sin DevTools, para diagnosticar y recuperar rápido. + +## Cómo usar +1. Elegir síntoma → seguir Quick check +2. Recoger evidencia (curl + output) +3. Aplicar fix mínimo +4. Registrar Proof of recovery antes de cerrar + +## Severidad (SEV0–SEV3) +- **SEV0**: Sistema caído, sin servicio. +- **SEV1**: Funcionalidad core rota (mapa, backend). +- **SEV2**: Degradado, workaround existe. +- **SEV3**: Menor, no bloquea uso principal. + +## Índice +- [Backend unavailable](backend-unavailable.md) +- [PNBOIA: no buoys visible](pnboia-no-buoys-visible.md) +- [Weather degraded](weather-degraded.md) +- [API base / CORS mismatch](api-base-cors-mismatch.md) +- [Deploy rollback](deploy-rollback.md) +- [Location missing device](location-missing-device.md) + +## Relacionado +- [SOP Operações + Cibersegurança](../../sop-operacoes-ciberseguranca.md) — política interna PMAP/FIPERJ diff --git a/docs/ops/runbooks/api-base-cors-mismatch.md b/docs/ops/runbooks/api-base-cors-mismatch.md new file mode 100644 index 000000000..a3dc6189e --- /dev/null +++ b/docs/ops/runbooks/api-base-cors-mismatch.md @@ -0,0 +1,49 @@ +# Runbook: API base / CORS mismatch (SEV1) + +## Symptoms +- Terminal `curl` works but browser UI fails (CORS error or 404) +- Network tab shows CORS error or wrong base URL for API calls + +## Why this happens (plain language) +The frontend fetches from a different origin than the backend allows. Browsers block cross-origin requests unless the server sends `Access-Control-Allow-Origin` for that origin. If the frontend points to the wrong API URL, requests go to the wrong place (404) or get blocked (CORS). + +## Quick check (terminal) +```bash +curl -i http://127.0.0.1:8001/health | sed -n '1,60p' +curl -i -H "Origin: http://localhost:5174" http://127.0.0.1:8001/api/v1/pnboia/list | sed -n '1,80p' +curl -i -H "Origin: http://127.0.0.1:5174" http://127.0.0.1:8001/api/v1/pnboia/list | sed -n '1,80p' +curl -I http://127.0.0.1:5174/ | sed -n '1,40p' +``` + +## Fix (DEV) +- The frontend must point to **one** API base. +- Set `VITE_API_BASE` to the correct absolute URL (e.g. `http://127.0.0.1:8001`). +- The backend must allow the DEV Origin (localhost / 127.0.0.1) consistently in CORS. +- Restart frontend after env change. + +## Fix (PROD) +- Prefer same-origin in prod (API and frontend served from same domain). +- If using a separate domain: document CORS allowlist for the exact Origin (e.g. `https://app.iuri.example.com`). + +## Evidence to capture (copy-paste friendly) +``` +=== API base / CORS runbook evidence === +health response: [first 60 lines] +pnboia list (Origin localhost:5174): [first 80 lines] +pnboia list (Origin 127.0.0.1:5174): [first 80 lines] +frontend headers: [first 40 lines of curl -I] +``` + +## Escalation +- **DevOps:** CORS config is locked or env var not applied. +- **Backend:** CORS allowlist missing or incorrect for known origins. + +## Done criteria +- [ ] UI fetch succeeds (no CORS error). +- [ ] "Backend unavailable" or similar badge disappears. +- [ ] At least one API call returns 200 from browser. +- [ ] `Access-Control-Allow-Origin` in response matches frontend origin. + +## Related +- [Ops index](../README.md) +- [SOP Operações + Cibersegurança](../../sop-operacoes-ciberseguranca.md) diff --git a/docs/ops/runbooks/backend-unavailable.md b/docs/ops/runbooks/backend-unavailable.md new file mode 100644 index 000000000..ca5d48ead --- /dev/null +++ b/docs/ops/runbooks/backend-unavailable.md @@ -0,0 +1,42 @@ +# Runbook: Backend unavailable + +## Symptoms +- UI shows "backend unavailable" badge/banner +- `/health` maybe OK from terminal but UI still says unavailable + +## Quick check +1. `curl http://127.0.0.1:8001/health` +2. Confirm API_BASE shown in debug line (if `debug=1` in URL) + +**Evidence to paste:** +```bash +curl -s http://127.0.0.1:8001/health +# Expected: {"status":"ok"} or similar +``` + +## Likely causes +- Backend process down or restarted +- Port mismatch (UI expecting 8001, backend on other port) +- Network/firewall blocking + +## Fix steps (safe) +- **Option A**: Restart backend (ask operator with access; do not assume sudo) +- **Option B**: `systemctl status iuri-backend` (if applicable) and restart +- Confirm backend listens on port 8001 (or configured port) + +## Proof of recovery +- [ ] `curl http://127.0.0.1:8001/health` returns OK +- [ ] UI badge "backend unavailable" disappears +- [ ] One functional endpoint works: `curl http://127.0.0.1:8001/api/v1/pnboia/list` + +**Evidence to paste (proof):** +```bash +curl -s http://127.0.0.1:8001/api/v1/pnboia/list | head -c 200 +# Expected: JSON with total_buoys or similar +``` + +## Escalate to +- SRE/DevSecOps if repeated or if health flaps + +## Rollback note +- If restart fails, rollback to previous known-good deploy (see [deploy-rollback](deploy-rollback.md)) diff --git a/docs/ops/runbooks/deploy-rollback.md b/docs/ops/runbooks/deploy-rollback.md new file mode 100644 index 000000000..935402054 --- /dev/null +++ b/docs/ops/runbooks/deploy-rollback.md @@ -0,0 +1,38 @@ +# Runbook: Deploy rollback + +## Symptoms +- After deploy, core features regress +- Health OK but map/PNBOIA/other flows broken + +## Quick check +1. Confirm `version.json` / build id +2. Compare with last known-good deploy + +**Evidence to paste:** +```bash +curl -s http://127.0.0.1:8001/version.json 2>/dev/null || curl -s http://127.0.0.1:5173/version.json +# Expected: build_id, version, timestamp +``` + +## Likely causes +- Bad deploy, dependency change, config drift + +## Fix steps (safe) +- Run existing rollback script (if project has one) +- Or: `git revert ` and redeploy +- Do not invent rollback flow; follow project's real ops procedure + +## Proof of recovery +- [ ] version/build_id matches previous known-good +- [ ] Smoke check passes: health + one map flow (e.g. PNBOIA list + 1 buoy visible) +- [ ] No new regressions introduced + +**Evidence to paste (proof):** +- version.json output +- Checklist: health OK, PNBOIA visible (or equivalent) + +## Escalate to +- SRE / DevOps if rollback script fails or commit unclear + +## Rollback note +- Document rollback commit hash and reason. Postmortem without blame. diff --git a/docs/ops/runbooks/location-missing-device.md b/docs/ops/runbooks/location-missing-device.md new file mode 100644 index 000000000..9419a0fdf --- /dev/null +++ b/docs/ops/runbooks/location-missing-device.md @@ -0,0 +1,46 @@ +# Runbook: Location missing device + +## Symptoms +- `/api/location/last` returns `device_id_missing` +- PNBOIA nearby 422 because lat/lon null + +## Quick check +1. `curl http://127.0.0.1:8001/api/location/last?device_id=YOUR_DEVICE` +2. Check response: null vs valid last_location + +**Evidence to paste:** +```bash +curl -s "http://127.0.0.1:8001/api/location/last?device_id=test-device-001" +# Expected: last_location with lat/lon or explicit null +``` + +## Likely causes +- device_id not persisted (storage/consent) +- Location payload mismatch: backend expects device object vs string +- Required fields missing (accuracy, time) per backend schema + +## Fix steps (safe) +1. Ensure device_id is persisted (localStorage/session per project) +2. Update location payload to match backend schema (device object vs string; accuracy, time if required) +3. Retry nearby: `curl` with valid lat/lon + +**Evidence to paste (fix verification):** +```bash +curl -s "http://127.0.0.1:8001/api/location/nearby?lat=-22.9&lon=-42.0&device_id=test-device-001" +# Expected: buoy_count or similar, no 422 +``` + +## Proof of recovery +- [ ] `last_location` not null after valid update +- [ ] Nearby endpoint returns `buoy_count > 0` (or valid response, no 422) +- [ ] UI "Center on me" or similar works + +**Evidence to paste (proof):** +- last response with last_location +- nearby response with buoy_count + +## Escalate to +- Backend if schema mismatch; Frontend if device_id persistence fails + +## Rollback note +- Revert location payload changes if other features break. diff --git a/docs/ops/runbooks/pnboia-no-buoys-visible.md b/docs/ops/runbooks/pnboia-no-buoys-visible.md new file mode 100644 index 000000000..0fafdca4a --- /dev/null +++ b/docs/ops/runbooks/pnboia-no-buoys-visible.md @@ -0,0 +1,80 @@ +# Runbook: PNBOIA — no buoys visible + +## Symptoms +- PNBOIA ON but no buoy markers on map +- `/api/v1/pnboia/list` may return data (total_buoys > 0) + +## Confirm data is real (terminal) +```bash +curl -s http://127.0.0.1:8001/api/v1/pnboia/list | head -c 300 +# Expected: JSON with total_buoys > 0 +``` + +If list fails or returns empty: backend/URL issue → see [backend-unavailable](backend-unavailable.md). + +## In-app checks (no DevTools) +Record exactly: + +- **PNBOIA:** ON/OFF +- **items=N** +- **status=ok/error/loading** +- **any lastError** + +Interpretation: + +- **items=0 + status=error:** fetch/URL/base mismatch. +- **items>0 + no markers:** rendering/layer/gating issue. + +## Most common causes (UI side) + +### Layer gating / zoom threshold +Buoys present but hidden at current zoom. + +- **Action:** zoom in to level 8+ (or "GO Cabo Frio"). +- **Evidence:** screenshot showing zoom + PNBOIA ON + items>0. + +### Coordinate normalization mismatch +API returns `position.lat/lon`, but UI reads `lat/lon` (or vice versa). + +- **Action:** verify runbook [location-missing-device](location-missing-device.md) is not blocking nearby fetch; prefer list mode to render all 5. + +### GeoJSON coordinate order wrong +Must be `[lon, lat]` (not `[lat, lon]`). + +- **Symptom:** Buoys may render far away/off-screen. + +### MapLibre source/layers not attached to the active map +Source exists in code but is not bound to the actual map instance. + +- **Symptom:** items>0 but map unchanged. + +### Filter/style mismatch +Layer filter expects `kind=="pnboia"` but data has different property key/value. + +- **Symptom:** source has features, but layer draws none. + +## Proof checklist (4 artifacts) +Collect these (no DevTools): + +1. Terminal output of `/api/v1/pnboia/list` first 300 chars. +2. Screenshot with: PNBOIA ON, status line (items/status) visible (debug=1), zoom level if possible. +3. Confirm whether "GO Cabo Frio" exists and what happens when pressed. +4. Confirm if PMAP1 icons are visible at Cabo Frio while PNBOIA is not (yes/no). + +## Optional UI-only probe (for developers) +Add a single hardcoded marker at Cabo Frio using the same rendering path as PNBOIA. + +- **If probe appears but real buoys don't:** normalization/filter/data-shape bug. +- **If probe doesn't appear:** layer/source binding bug. + +(Operators: do not modify code; report whether probe exists in the current build.) + +## Escalation routing +- **Frontend dev:** items>0 but no markers; GO works but still no PNBOIA markers. +- **Backend dev:** list endpoint fails or returns success false; nearby fails with 422 for valid lat/lon (schema mismatch). +- **SRE:** health flapping or intermittent 5xx for PNBOIA endpoints. + +## Done criteria +- [ ] PNBOIA ON shows markers on map at Cabo Frio. +- [ ] "GO Cabo Frio" lands where at least one PNBOIA marker is visible. +- [ ] PNBOIA status line (debug=1) shows items>0 with status ok. diff --git a/docs/ops/runbooks/weather-degraded.md b/docs/ops/runbooks/weather-degraded.md new file mode 100644 index 000000000..7a4317749 --- /dev/null +++ b/docs/ops/runbooks/weather-degraded.md @@ -0,0 +1,58 @@ +# Runbook: Weather degraded + +## Symptoms +- Weather panel says "waiting integration" / "degraded" / empty + +## Quick check (terminal) +```bash +curl -fsS http://127.0.0.1:8001/health | head -c 400 && echo +curl -i http://127.0.0.1:8001/api/v1/weather/status | sed -n '1,80p' +curl -i "http://127.0.0.1:8001/api/v1/weather?lat=-22.919&lon=-42.818&location_name=Maric%C3%A1" | sed -n '1,120p' +date +``` + +## What "degraded" means (plain language) +The weather provider (upstream API) is unavailable or failing. The UI should show this state clearly instead of fake data. Veracity: prefer "indisponível" over "inventado". + +## Safe UI behavior (no invention) +- If weather fails: show "Provider degraded" + last_error (if present). +- Never show fake values. +- Never hide the panel completely. +- Show "Location required" ONLY when location truly missing. + +## Common causes (provider / key / rate limit / upstream / cache) +- Provider API down or rate-limited +- Misconfigured API key or URL +- Upstream timeout / network +- Cache miss or stale cache + +## Operator actions (ordered, minimal) +1. Run Quick check (terminal) and capture output. +2. Confirm backend health OK. +3. If status endpoint fails: escalate to backend/integrations. +4. If status OK but weather fails: likely provider/rate limit — document and escalate. +5. Do not block map/other features; weather is non-blocking. + +## Evidence to capture (copy-paste friendly) +``` +=== Weather runbook evidence === +date: [paste output of `date`] +health: [first 400 chars of health] +weather/status: [first 80 lines of status response] +weather?lat=...: [first 120 lines of weather response] +panel screenshot: [attach] +``` + +## Escalation +- **Backend / integrations:** provider consistently fails, status endpoint errors, or rate-limit pattern. +- **SRE:** health flapping or intermittent 5xx for weather endpoints. + +## Done criteria +- [ ] Panel renders (never hidden). +- [ ] Shows clear status: "Provider degraded" or real data — not fake. +- [ ] User can continue using map/other features. +- [ ] last_error visible when present (helps debugging). + +## Related +- [Ops index](../README.md) +- [SOP Operações + Cibersegurança](../../sop-operacoes-ciberseguranca.md) From ef871f657889bfb9a2fbe8ab534ac3830e3e8de9 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 20:42:23 -0300 Subject: [PATCH 09/24] docs(sap): add monitoring spec package Co-authored-by: Cursor --- docs/core/README.md | 2 +- docs/ops/README.md | 1 + docs/ops/monitoring/README.md | 4 + docs/ops/monitoring/evidence-bundle-format.md | 11 +++ docs/ops/monitoring/monitoring-spec.md | 95 +++++++++++++++++++ 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 docs/ops/monitoring/README.md create mode 100644 docs/ops/monitoring/evidence-bundle-format.md create mode 100644 docs/ops/monitoring/monitoring-spec.md diff --git a/docs/core/README.md b/docs/core/README.md index 400fdcc52..b5c879447 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -30,7 +30,7 @@ Each guardian explains a different safety angle of the same decision: - **Airbag (safety)**: risk and damage reduction - **Ledger (traceability)**: audit trail and justification -Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) +Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) | [Monitoring](../ops/monitoring/README.md) More detail: - [`docs/GUARDIANS_SUMMARY_FOR_HUMANS.md`](../GUARDIANS_SUMMARY_FOR_HUMANS.md) diff --git a/docs/ops/README.md b/docs/ops/README.md index 710ac1030..6e77b3f96 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -1,4 +1,5 @@ # iURi Ops - **Runbooks:** [docs/ops/runbooks/README.md](runbooks/README.md) — diagnóstico y recuperación sin DevTools +- **Monitoring:** [docs/ops/monitoring/README.md](monitoring/README.md) — signals, cadence, SEV, evidence - **WayBack:** [docs/ops/wayback/README.md](wayback/README.md) — snapshots y cambios diff --git a/docs/ops/monitoring/README.md b/docs/ops/monitoring/README.md new file mode 100644 index 000000000..1d033dbde --- /dev/null +++ b/docs/ops/monitoring/README.md @@ -0,0 +1,4 @@ +# iURi Monitoring + +- **Spec:** [monitoring-spec.md](monitoring-spec.md) — signals, cadence, SEV, automation +- **Evidence bundle format:** [evidence-bundle-format.md](evidence-bundle-format.md) — copy-paste bundle structure (stub) diff --git a/docs/ops/monitoring/evidence-bundle-format.md b/docs/ops/monitoring/evidence-bundle-format.md new file mode 100644 index 000000000..4d7577d96 --- /dev/null +++ b/docs/ops/monitoring/evidence-bundle-format.md @@ -0,0 +1,11 @@ +# Evidence Bundle Format + +*(Stub — content to be filled in next task)* + +## 1) Overview + +## 2) Fields + +## 3) Copy-paste template + +## 4) Storage & retention diff --git a/docs/ops/monitoring/monitoring-spec.md b/docs/ops/monitoring/monitoring-spec.md new file mode 100644 index 000000000..0c1a60f89 --- /dev/null +++ b/docs/ops/monitoring/monitoring-spec.md @@ -0,0 +1,95 @@ +# iURi Monitoring Spec + +## 1) Purpose (why monitoring is part of veracity) + +Monitoring is part of veracity: we don't declare "healthy" without evidence. If the system fails but reports OK, that's a lie. Monitoring gives us real signals so we can detect silent failures, degraded providers, and regressions. It aligns with HITL (human validates) and Done-Claim Gate (no DONE without proof). + +## 2) Scope + +- **RJ now:** Rio de Janeiro coast, PMAP-RJ coverage (Cabo Frio to Paraty). +- **RJ+SP next:** São Paulo coast added when ready. +- **BR coast later:** National expansion. + +## 3) Signals to Monitor + +| Signal | Endpoint/Source | Cadence | Timeout | Success criteria | Degraded criteria | SEV if failing | Runbook link | +|--------|-----------------|---------|---------|------------------|-------------------|----------------|--------------| +| Backend Health | GET /health | Fast | 5s | 200, status=ok | 5xx or timeout | SEV1 | [backend-unavailable](../runbooks/backend-unavailable.md) | +| OpenAPI reachable | GET /openapi.json | Fast | 5s | 200, valid JSON | 4xx/5xx | SEV2 | [backend-unavailable](../runbooks/backend-unavailable.md) | +| Location read | GET /api/location/last?device_id=... | Medium | 10s | 200, valid schema | 422 / device_id_missing | SEV2 | [location-missing-device](../runbooks/location-missing-device.md) | +| Weather status | GET /api/v1/weather/status | Medium | 10s | 200, provider ok | provider degraded | SEV2 | [weather-degraded](../runbooks/weather-degraded.md) | +| Weather data sanity | GET /api/v1/weather?lat=-22.919&lon=-42.818 | Medium | 15s | 200, temp/humidity present | empty or malformed | SEV2 | [weather-degraded](../runbooks/weather-degraded.md) | +| PNBOIA list | GET /api/v1/pnboia/list | Medium | 15s | 200, total_buoys>0 | empty or 5xx | SEV1 | [pnboia-no-buoys-visible](../runbooks/pnboia-no-buoys-visible.md) | +| PNBOIA nearby | GET /api/v1/pnboia/nearby?lat=-22.9&lon=-42.0 | Medium | 15s | 200, valid response | 422 or 5xx | SEV2 | [pnboia-no-buoys-visible](../runbooks/pnboia-no-buoys-visible.md) | +| Marino weather | GET /api/marino/weather | Medium | 15s | (legacy, unstable) | — | SEV3 | — | +| Marino marine | GET /api/marino/marine | Medium | 15s | (legacy, unstable) | — | SEV3 | — | +| Frontend version | dist/version.json | Slow | 5s | 200, build_id present | missing | SEV3 | [deploy-rollback](../runbooks/deploy-rollback.md) | +| CORS/API base probe | curl with Origin header | Fast (dev) | 5s | CORS headers match | CORS mismatch | SEV1 | [api-base-cors-mismatch](../runbooks/api-base-cors-mismatch.md) | + +## 4) Cadence Model (three loops) + +- **Fast loop (every 1–2 min):** health + critical endpoints (backend, CORS probe in dev). +- **Medium loop (every 10–15 min):** weather/pnboia functional probes, location read. +- **Slow loop (daily):** link checks, runbook drift check, storage/log rotation. + +## 5) SEV Policy (operator-friendly) + +| SEV | Meaning | Stop condition | +|-----|---------|----------------| +| **SEV0** | Data corruption / wrong safety advice / silent failure with "healthy" | Immediate human page. No auto-retry. | +| **SEV1** | Backend unavailable / core endpoints down | 3 retries with backoff; then page. | +| **SEV2** | Degraded provider (weather/pnboia partial) | 5 retries; then escalate. No page unless user impact. | +| **SEV3** | Cosmetic/UI regressions (non-blocking) | Log and queue for next sprint. | + +**Stop conditions:** When to stop auto-retries and escalate: +- SEV0: always escalate immediately. +- SEV1: after 3 failed retries in 5 min. +- SEV2: after 5 failed retries in 15 min. +- SEV3: no auto-retry; manual triage. + +## 6) Automation vs Human Approval (HITL boundary) + +**Allowed automatically:** +- Capture evidence bundle +- Retry with backoff (within stop conditions) +- Switch UI to degraded-safe mode (no invention: show "Provider degraded", never fake data) + +**Requires human:** +- Deploy/rollback in prod +- Changing CORS, API keys, infra, firewall +- Declaring incident resolved ("Done-Claim Gate" — only human can say DONE) + +## 7) Evidence Capture (what we store every incident) + +- Timestamp, region, build_id, API_BASE +- Health snapshot +- Failing endpoints (list) +- Last error (if present) +- Counts: buoys, weather payload fields +- Copy-paste bundle format: [evidence-bundle-format.md](evidence-bundle-format.md) + +## 8) Regions & Probes (RJ baseline) + +| Region | Lat | Lon | Use case | +|--------|-----|-----|----------| +| Maricá | -22.919 | -42.818 | Primary probe | +| Guanabara | -22.9 | -43.2 | Bay area | +| Cabo Frio | -23.0 | -42.0 | Coastal baseline | + +**Rule:** If location missing (device_id_missing), use region probes instead of user location. + +## 9) Roles & Routing (where alerts go) + +| Role | When to page | +|------|--------------| +| **Ops/SRE on-call** | SEV0, SEV1 (backend down), health flapping | +| **Frontend on-call** | SEV1 (CORS/UI fetch), PNBOIA rendering, version.json missing | +| **Data/Provider liaison** | SEV2 (weather/pnboia provider degraded, rate limit) | +| **Product/PMAP liaison** | SEV0 (safety advice wrong), user-impact escalations | + +## 10) Links + +- [Runbooks](../runbooks/README.md) +- [SOP Operações + Cibersegurança](../../sop-operacoes-ciberseguranca.md) +- [Human-in-the-Loop (HITL)](../../human-in-the-loop/README.md) +- [iURi Core](../../core/README.md) From fc05dd8a755b97edb2177e3992cba0301ba9f36e Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 20:55:44 -0300 Subject: [PATCH 10/24] docs(sap): add HITL work-split (architecture vs bugfix lane) Co-authored-by: Cursor --- docs/human-in-the-loop/README.en.md | 1 + docs/human-in-the-loop/README.md | 1 + docs/human-in-the-loop/README.pt.md | 1 + ...rk-split-architecture-vs-bugfix-lane.en.md | 99 +++++++++++++++++++ .../work-split-architecture-vs-bugfix-lane.md | 99 +++++++++++++++++++ ...rk-split-architecture-vs-bugfix-lane.pt.md | 99 +++++++++++++++++++ 6 files changed, 300 insertions(+) create mode 100644 docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.en.md create mode 100644 docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.md create mode 100644 docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.pt.md diff --git a/docs/human-in-the-loop/README.en.md b/docs/human-in-the-loop/README.en.md index 6d04e1168..31d9c47c2 100644 --- a/docs/human-in-the-loop/README.en.md +++ b/docs/human-in-the-loop/README.en.md @@ -15,6 +15,7 @@ These documents define the role and method for working with models (Codex/LLMs) - **Manifesto (1 page):** [manifesto.en.md](./manifesto.en.md) - **Operational manual:** [manual.en.md](./manual.en.md) - **Reality Anchor as a craft:** [reality-anchor-role.en.md](./reality-anchor-role.en.md) +- **Work Split: Architecture vs Bugfix Lane:** [work-split-architecture-vs-bugfix-lane.en.md](./work-split-architecture-vs-bugfix-lane.en.md) ### How we use this 1. **Model delivers** → IMPLEMENTED (UNVERIFIED) + short checklist diff --git a/docs/human-in-the-loop/README.md b/docs/human-in-the-loop/README.md index 662ceeb44..70400b492 100644 --- a/docs/human-in-the-loop/README.md +++ b/docs/human-in-the-loop/README.md @@ -15,6 +15,7 @@ Estos documentos definen el rol y el método para trabajar con modelos (Codex/LL - **Manifiesto (1 página):** [manifesto.md](./manifesto.md) - **Manual operativo:** [manual.md](./manual.md) - **Reality Anchor como oficio:** [reality-anchor-role.md](./reality-anchor-role.md) +- **Work Split: Arquitectura vs Bugfix Lane:** [work-split-architecture-vs-bugfix-lane.md](./work-split-architecture-vs-bugfix-lane.md) ### Cómo lo usamos 1. **Modelo entrega** → IMPLEMENTED (UNVERIFIED) + checklist corto diff --git a/docs/human-in-the-loop/README.pt.md b/docs/human-in-the-loop/README.pt.md index 3af2648c1..e12f8ef40 100644 --- a/docs/human-in-the-loop/README.pt.md +++ b/docs/human-in-the-loop/README.pt.md @@ -15,6 +15,7 @@ Estes documentos definem o papel e o método para trabalhar com modelos (Codex/L - **Manifesto (1 página):** [manifesto.pt.md](./manifesto.pt.md) - **Manual operativo:** [manual.pt.md](./manual.pt.md) - **Reality Anchor como ofício:** [reality-anchor-role.pt.md](./reality-anchor-role.pt.md) +- **Work Split: Arquitetura vs Bugfix Lane:** [work-split-architecture-vs-bugfix-lane.pt.md](./work-split-architecture-vs-bugfix-lane.pt.md) ### Como usamos 1. **Modelo entrega** → IMPLEMENTED (UNVERIFIED) + checklist curto diff --git a/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.en.md b/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.en.md new file mode 100644 index 000000000..30a21a171 --- /dev/null +++ b/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.en.md @@ -0,0 +1,99 @@ +# Work Split: Architecture vs Bugfix Lane (HITL) + +[ES](./work-split-architecture-vs-bugfix-lane.md) | [EN](./work-split-architecture-vs-bugfix-lane.en.md) | [PT](./work-split-architecture-vs-bugfix-lane.pt.md) + +> **Source of truth:** [ES](./work-split-architecture-vs-bugfix-lane.md) +> **Purpose:** avoid "trips to the past" and protect focus, veracity, and cost. + +## Why this split exists +In real systems, models can "solve in their head" and humans discover it doesn't work in practice. +If we mix architecture (high level) with bugfix (low level), the project turns to mud: it scatters, gets expensive, and loses veracity. + +This document defines **two lanes** and a clear **handshake** so work moves forward like a laser, not a swamp. + +## The 2 lanes + +### Lane A: Architecture / Product (high level) +**Goal:** define what the system *is* and what it *promises* to the user. +**Typical outputs:** +- specs, monitoring, runbooks, SOP, roadmap by regions (RJ → RJ+SP → BR coast) +- UX and veracity decisions ("what we show" and "what we don't invent") +- guardians, HITL policies, "done" criteria with evidence + +**Rule:** this lane does **not** stop for one-off bugs. Bugs are routed to Lane B with evidence. + +### Lane B: Bugfix Lane (low level, surgical) +**Goal:** fix ONE thing with minimal change, and prove it with evidence. +**Golden rules:** +- 1 bug → 1 fix → 1 verification → 1 commit (or amend if PR requires) +- No "refactors" during bugfix +- No DONE claim without proof (see Done-Claim Gate) + +## Human roles (HITL) +These roles can be 1 person or several, depending on scale. What matters is **the function**, not the title. + +### 1) Architect / Product (Focus Keeper) +- Defines priorities, scope, and veracity promises. +- Decides what goes in each release and what gets postponed. +- Keeps Lane A clean of reactive urgencies. + +### 2) Reality Anchor (Human tester) +- Observes what the model cannot: real UI, real latency, "visible / not visible". +- Runs runbooks and assembles Evidence Bundles. +- Reports in plain language: "PASS / FAIL" + minimal evidence. + +### 3) Bug Mechanic (Dev) +- Takes the Evidence Bundle and does the minimal fix. +- Keeps changes reversible and isolated. +- Does not discuss "architecture" inside the bugfix. + +### 4) Ops / SRE (Operational veracity) +- Keeps backend up, health, deploy/rollback, monitoring. +- Rule: if the system goes down, that's also a form of lying (no veracity without availability). + +## Mandatory handoff: Evidence Bundle +When something fails, the Reality Anchor doesn't send "feelings", they send a bundle. + +Format: [evidence-bundle-format.md](../ops/monitoring/evidence-bundle-format.md) + +**Minimum to enter Lane B:** +- Symptom (1–2 lines) +- What was tried (1 line) +- Evidence: commands or screenshots (copy/paste friendly) +- "Done criteria" (what means it's fixed) + +## Done-Claim Gate (anti-smoke) +A model or dev **cannot** say "it's done" if there is no evidence. + +A DONE claim must include: +1) What changed (file(s)) +2) How it was verified (reproducible steps) +3) Evidence (real output or screenshot) +4) Result ("PASS" or "FAIL") + +Example (3 lines): +- FIX: PNBOIA markers now render in Layer X +- VERIFY: toggle PNBOIA ON → see 5 buoys; "Go Cabo Frio" centers; screenshot attached +- RESULT: PASS (build OK) + +## Stop conditions (to protect focus and cost) +If any of these occur: +- the fix requires a large refactor +- the problem changes shape (new symptoms) without new evidence +- we enter a "let's try something else" loop +Then: **STOP** and return to Lane A for replanning or to split the problem. + +## How this connects to CRIT Gate and Guardians +This split is a veracity defense: +- **Veritas**: don't invent results, demand evidence +- **Frontera / Flujo**: separate "design" from "incident" +- **Ledger**: traceability (what changed, why, when) +- **Airbag**: reversibility (rollback / revert) + +See: "HITL ↔ CRIT Gate" section in HITL READMEs and [docs/core/README.md](../core/README.md). + +## Quick template for reporting a bug (no DevTools) +- SYMPTOM: +- CONTEXT (URL / region / layer): +- EVIDENCE (terminal + 1 screenshot): +- DONE CRITERIA: diff --git a/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.md b/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.md new file mode 100644 index 000000000..0b9627c99 --- /dev/null +++ b/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.md @@ -0,0 +1,99 @@ +# Work Split: Arquitectura vs Bugfix Lane (HITL) + +[ES](./work-split-architecture-vs-bugfix-lane.md) | [EN](./work-split-architecture-vs-bugfix-lane.en.md) | [PT](./work-split-architecture-vs-bugfix-lane.pt.md) + +> **Source of truth:** ES (este documento). +> **Propósito:** evitar "viajes al pasado" y proteger foco, veracidad y costo. + +## Por qué existe esta división +En sistemas reales, los modelos pueden "resolver en su cabeza" y el humano descubre que en la práctica no funciona. +Si mezclamos arquitectura (alto nivel) con bugfix (bajo nivel), el proyecto se vuelve barro: se dispersa, se encarece, y se pierde veracidad. + +Este documento define **dos carriles** (lanes) y un **handshake** claro para que el trabajo avance como láser, no como pantano. + +## Los 2 carriles + +### Carril A: Arquitectura / Producto (alto nivel) +**Objetivo:** definir lo que el sistema *es* y lo que *promete* al usuario. +**Outputs típicos:** +- specs, monitoring, runbooks, SOP, roadmap por regiones (RJ → RJ+SP → costa BR) +- decisiones de UX y veracidad ("qué se muestra" y "qué no se inventa") +- guardianes, políticas HITL, criterios de "done" con evidencia + +**Regla:** este carril **no** se detiene por bugs puntuales. Los bugs se enrutan al Carril B con evidencia. + +### Carril B: Bugfix Lane (bajo nivel, quirúrgico) +**Objetivo:** arreglar UNA cosa con mínimo cambio, y demostrarlo con evidencia. +**Reglas de oro:** +- 1 bug → 1 fix → 1 verificación → 1 commit (o amend si el PR lo exige) +- No "refactors" durante bugfix +- No reclamar DONE sin prueba (ver Done-Claim Gate) + +## Roles humanos (HITL) +Estos roles pueden ser 1 persona o varios, según escala. Lo importante es **la función**, no el título. + +### 1) Arquitecto / Producto (Focus Keeper) +- Define prioridades, alcance y promesas de veracidad. +- Decide qué entra en cada release y qué se pospone. +- Mantiene el carril A limpio de urgencias reactivas. + +### 2) Reality Anchor (Tester humano) +- Observa lo que el modelo no puede observar: UI real, latencia real, "se ve / no se ve". +- Ejecuta runbooks y arma Evidence Bundles. +- Reporta con lenguaje simple: "PASÓ / FALLÓ" + evidencia mínima. + +### 3) Bug Mechanic (Dev) +- Toma el Evidence Bundle y hace el fix mínimo. +- Mantiene cambios reversibles y aislados. +- No discute "arquitectura" dentro del bugfix. + +### 4) Ops / SRE (Veracidad operacional) +- Mantiene backend arriba, health, deploy/rollback, monitoreo. +- Regla: si el sistema cae, también es una forma de mentira (no hay veracidad sin disponibilidad). + +## Handoff obligatorio: Evidence Bundle +Cuando algo falla, el Reality Anchor no manda "sensaciones", manda un bundle. + +Formato: [evidence-bundle-format.md](../ops/monitoring/evidence-bundle-format.md) + +**Mínimo para entrar al Carril B:** +- Síntoma (1–2 líneas) +- Qué se intentó (1 línea) +- Evidencia: comandos o screenshots (copy/paste friendly) +- "Done criteria" (qué significa que quedó arreglado) + +## Done-Claim Gate (antihumo) +Un modelo o dev **NO** puede decir "ya está" si no existe evidencia. + +Un claim de DONE debe incluir: +1) Qué cambió (archivo(s)) +2) Cómo se verificó (pasos reproducibles) +3) Evidencia (salida real o screenshot) +4) Resultado ("PASS" o "FAIL") + +Ejemplo (3 líneas): +- FIX: PNBOIA markers now render in Layer X +- VERIFY: toggle PNBOIA ON → see 5 buoys; "Go Cabo Frio" centers; screenshot attached +- RESULT: PASS (build OK) + +## Stop conditions (para proteger foco y costo) +Si ocurre cualquiera: +- el fix requiere refactor grande +- el problema cambia de forma (síntomas nuevos) sin nueva evidencia +- se entra en loop "probemos otra cosa" +Entonces: **STOP** y volver a Carril A para replanteo o dividir el problema. + +## Cómo se conecta con CRIT Gate y Guardianes +Esta división es una defensa de veracidad: +- **Veritas**: no inventar resultados, pedir evidencia +- **Frontera / Flujo**: separar "diseño" de "incidente" +- **Ledger**: trazabilidad (qué cambió, por qué, cuándo) +- **Airbag**: reversibilidad (rollback / revert) + +Ver: sección "HITL ↔ CRIT Gate" en los READMEs de HITL y [docs/core/README.md](../core/README.md). + +## Plantilla rápida para reportar un bug (sin DevTools) +- SYMPTOM: +- CONTEXT (URL / región / capa): +- EVIDENCE (terminal + 1 screenshot): +- DONE CRITERIA: diff --git a/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.pt.md b/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.pt.md new file mode 100644 index 000000000..ede0736f3 --- /dev/null +++ b/docs/human-in-the-loop/work-split-architecture-vs-bugfix-lane.pt.md @@ -0,0 +1,99 @@ +# Work Split: Arquitetura vs Bugfix Lane (HITL) + +[ES](./work-split-architecture-vs-bugfix-lane.md) | [EN](./work-split-architecture-vs-bugfix-lane.en.md) | [PT](./work-split-architecture-vs-bugfix-lane.pt.md) + +> **Source of truth:** [ES](./work-split-architecture-vs-bugfix-lane.md) +> **Propósito:** evitar "viagens ao passado" e proteger foco, veracidade e custo. + +## Por que existe esta divisão +Em sistemas reais, os modelos podem "resolver na cabeça" e o humano descobre que na prática não funciona. +Se misturamos arquitetura (alto nível) com bugfix (baixo nível), o projeto vira lama: se dispersa, encarece e perde veracidade. + +Este documento define **dois carriles** (lanes) e um **handshake** claro para que o trabalho avance como laser, não como pântano. + +## Os 2 carriles + +### Carril A: Arquitetura / Produto (alto nível) +**Objetivo:** definir o que o sistema *é* e o que *promete* ao usuário. +**Outputs típicos:** +- specs, monitoring, runbooks, SOP, roadmap por regiões (RJ → RJ+SP → costa BR) +- decisões de UX e veracidade ("o que mostramos" e "o que não inventamos") +- guardians, políticas HITL, critérios de "done" com evidência + +**Regra:** este carril **não** para por bugs pontuais. Os bugs são encaminhados ao Carril B com evidência. + +### Carril B: Bugfix Lane (baixo nível, cirúrgico) +**Objetivo:** corrigir UMA coisa com mudança mínima, e demonstrar com evidência. +**Regras de ouro:** +- 1 bug → 1 fix → 1 verificação → 1 commit (ou amend se o PR exigir) +- Sem "refactors" durante bugfix +- Sem reclamar DONE sem prova (ver Done-Claim Gate) + +## Papéis humanos (HITL) +Estes papéis podem ser 1 pessoa ou várias, conforme a escala. O que importa é **a função**, não o título. + +### 1) Arquitecto / Produto (Focus Keeper) +- Define prioridades, escopo e promessas de veracidade. +- Decide o que entra em cada release e o que é adiado. +- Mantém o carril A limpo de urgências reativas. + +### 2) Reality Anchor (Tester humano) +- Observa o que o modelo não pode: UI real, latência real, "se vê / não se vê". +- Executa runbooks e monta Evidence Bundles. +- Reporta em linguagem simples: "PASS / FAIL" + evidência mínima. + +### 3) Bug Mechanic (Dev) +- Pega o Evidence Bundle e faz o fix mínimo. +- Mantém mudanças reversíveis e isoladas. +- Não discute "arquitetura" dentro do bugfix. + +### 4) Ops / SRE (Veracidade operacional) +- Mantém backend no ar, health, deploy/rollback, monitoramento. +- Regra: se o sistema cai, também é uma forma de mentira (não há veracidade sem disponibilidade). + +## Handoff obrigatório: Evidence Bundle +Quando algo falha, o Reality Anchor não manda "sensações", manda um bundle. + +Formato: [evidence-bundle-format.md](../ops/monitoring/evidence-bundle-format.md) + +**Mínimo para entrar no Carril B:** +- Síntoma (1–2 linhas) +- O que foi tentado (1 linha) +- Evidência: comandos ou screenshots (copy/paste friendly) +- "Done criteria" (o que significa que ficou corrigido) + +## Done-Claim Gate (anti-fumaça) +Um modelo ou dev **não** pode dizer "já está" se não existe evidência. + +Um claim de DONE deve incluir: +1) O que mudou (arquivo(s)) +2) Como foi verificado (passos reproduzíveis) +3) Evidência (saída real ou screenshot) +4) Resultado ("PASS" ou "FAIL") + +Exemplo (3 linhas): +- FIX: PNBOIA markers now render in Layer X +- VERIFY: toggle PNBOIA ON → see 5 buoys; "Go Cabo Frio" centers; screenshot attached +- RESULT: PASS (build OK) + +## Stop conditions (para proteger foco e custo) +Se ocorrer qualquer um: +- o fix exige refactor grande +- o problema muda de forma (sintomas novos) sem nova evidência +- se entra em loop "vamos tentar outra coisa" +Então: **STOP** e voltar ao Carril A para replanejamento ou dividir o problema. + +## Como se conecta com CRIT Gate e Guardians +Esta divisão é uma defesa de veracidade: +- **Veritas**: não inventar resultados, exigir evidência +- **Frontera / Flujo**: separar "design" de "incidente" +- **Ledger**: rastreabilidade (o que mudou, por quê, quando) +- **Airbag**: reversibilidade (rollback / revert) + +Ver: seção "HITL ↔ CRIT Gate" nos READMEs de HITL e [docs/core/README.md](../core/README.md). + +## Modelo rápido para reportar um bug (sem DevTools) +- SYMPTOM: +- CONTEXT (URL / região / camada): +- EVIDENCE (terminal + 1 screenshot): +- DONE CRITERIA: From fa19f1d80af3c179824573e1e627cd4e7c0fa9b8 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 21:05:05 -0300 Subject: [PATCH 11/24] docs(sap): add on-call policy (SRE + escalation) Co-authored-by: Cursor --- docs/core/README.md | 2 +- docs/ops/README.md | 1 + docs/ops/oncall/README.md | 16 ++++++ docs/ops/oncall/oncall-policy.md | 92 ++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 docs/ops/oncall/README.md create mode 100644 docs/ops/oncall/oncall-policy.md diff --git a/docs/core/README.md b/docs/core/README.md index b5c879447..33a6b058a 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -30,7 +30,7 @@ Each guardian explains a different safety angle of the same decision: - **Airbag (safety)**: risk and damage reduction - **Ledger (traceability)**: audit trail and justification -Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) | [Monitoring](../ops/monitoring/README.md) +Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) | [Monitoring](../ops/monitoring/README.md) | [On-Call](../ops/oncall/README.md) More detail: - [`docs/GUARDIANS_SUMMARY_FOR_HUMANS.md`](../GUARDIANS_SUMMARY_FOR_HUMANS.md) diff --git a/docs/ops/README.md b/docs/ops/README.md index 6e77b3f96..dc9fa9ca8 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -2,4 +2,5 @@ - **Runbooks:** [docs/ops/runbooks/README.md](runbooks/README.md) — diagnóstico y recuperación sin DevTools - **Monitoring:** [docs/ops/monitoring/README.md](monitoring/README.md) — signals, cadence, SEV, evidence +- **On-Call & Escalation:** [docs/ops/oncall/README.md](oncall/README.md) — SRE, SEV, escalation, evidence - **WayBack:** [docs/ops/wayback/README.md](wayback/README.md) — snapshots y cambios diff --git a/docs/ops/oncall/README.md b/docs/ops/oncall/README.md new file mode 100644 index 000000000..99362d39c --- /dev/null +++ b/docs/ops/oncall/README.md @@ -0,0 +1,16 @@ +# On-Call & Escalation + +- [Runbooks](../runbooks/README.md) +- [Monitoring spec](../monitoring/monitoring-spec.md) +- [Evidence Bundle](../monitoring/evidence-bundle-format.md) +- [SOP Operações + Cibersegurança](../../sop-operacoes-ciberseguranca.md) + +**Policy:** [oncall-policy.md](oncall-policy.md) + +## Quick summary + +- **When to page:** SEV0/SEV1; health flapping; backend/core down. +- **What to capture:** Evidence Bundle (symptom + terminal + screenshot) before fix. +- **Where to escalate:** Tier 0 → Tier 1 (SRE) → Tier 2 (Backend/Frontend) → Tier 3 (Vendor/Provider). +- **Stop condition:** If fix requires rollback or refactor, escalate to Tier 2. +- **No DONE without evidence:** Done-Claim Gate applies; attach bundle before closing incident. diff --git a/docs/ops/oncall/oncall-policy.md b/docs/ops/oncall/oncall-policy.md new file mode 100644 index 000000000..eafd57170 --- /dev/null +++ b/docs/ops/oncall/oncall-policy.md @@ -0,0 +1,92 @@ +# On-Call Policy (SRE + Escalation) + +## 1) Purpose + +This policy defines how PMAP/FIPERJ handles incidents: who is on-call, how to escalate, and what evidence is required. It aligns with Monitoring, Runbooks, and Evidence Bundle so every incident is traceable and no "DONE" claim is made without proof. + +## 2) What is SRE? + +**SRE (Site Reliability Engineering)** means: people and practices that keep the system up, observable, and recoverable. SREs run health checks, respond to alerts, execute runbooks, and coordinate rollbacks. They do not design features; they protect uptime and veracity. In small teams, SRE can be the same person as Ops or a senior dev wearing the hat. + +## 3) Coverage model + +- **RJ now:** Rio de Janeiro coast (Cabo Frio to Paraty). Primary coverage during business hours; on-call for SEV0/SEV1. +- **RJ+SP next:** São Paulo coast added; may require shared rotation. +- **BR coast later:** National expansion; formal 24/7 rotation, redundancy, and governance. + +When scaling: add formal shift schedule, backup on-call, and change control for incident response. + +## 4) Roles + +| Role | Definition | +|------|------------| +| **Tier 0 Operator** | First responder. Runs runbooks, captures evidence, reports PASS/FAIL. Does not deploy or change infra. | +| **Tier 1 SRE/Ops** | On-call. Handles health, restarts, deploy/rollback. Escalates to Tier 2 when code/config change needed. | +| **Tier 2 Backend** | Backend dev. Fixes endpoints, schema, provider integration. Uses Evidence Bundle to reproduce. | +| **Tier 2 Frontend** | Frontend dev. Fixes UI, layers, CORS, rendering. Uses Evidence Bundle to reproduce. | +| **Tier 3 Vendor/Provider** | Upstream (weather, marine data). Contact when provider is down or rate-limited. | + +## 5) SEV & response targets + +| SEV | Ack | Update | Resolve | Notes | +|-----|-----|--------|---------|-------| +| **SEV0** | 15 min | 30 min | 4 h | Data corruption, wrong safety advice, silent failure. Page immediately. | +| **SEV1** | 30 min | 1 h | 8 h | Backend down, core endpoints down. Page on-call. | +| **SEV2** | 2 h | 4 h | 24 h | Degraded provider, partial failure. Escalate during hours. | +| **SEV3** | Next day | Next day | Next sprint | Cosmetic, non-blocking. Queue for triage. | + +## 6) Escalation rules + +**Escalate when:** +- Same symptom persists after 2 small fixes +- Fix requires refactor or rollback +- Evidence contradicts "DONE" claim +- Provider/vendor suspected (Tier 3) + +**Stop and roll back when:** +- Fix introduces new regressions +- Build identity mismatch (wrong commit served) +- Health OK but UI says "backend unavailable" (CORS/base mismatch) + +## 7) Evidence first + +Every incident must have an Evidence Bundle before closing. Format: [evidence-bundle-format.md](../monitoring/evidence-bundle-format.md). + +No "DONE" without evidence. Done-Claim Gate: human verifies with proof (terminal + screenshot + checklist). + +## 8) Communication template + +### Internal update +``` +[INCIDENT] SEVx – short description +Status: investigating | fix applied | verifying +Next update: [time] +``` + +### Stakeholder update +``` +Service impacted: [what] +Current status: [degraded | restored] +Expected resolution: [time or TBD] +``` + +### Service degraded +``` +iURi [component] is currently degraded. [Brief cause if known.] +Workaround: [if any]. ETA: [time]. +``` + +## 9) Guardrails (HITL boundary) + +- **HITL boundary:** Humans decide severity, escalation, and "DONE". Automation captures evidence and retries; humans approve rollback and incident closure. +- **Done-Claim Gate:** No one declares DONE without Evidence Bundle. Runbook proof checklist must be satisfied. +- **Plain language:** Use "PASS/FAIL", "degraded", "rollback" — no jargon in stakeholder comms. + +## 10) Done criteria + +- [ ] Evidence Bundle attached +- [ ] Runbook followed (if applicable) +- [ ] SEV correctly assigned +- [ ] Escalation path followed +- [ ] Communication sent (internal + stakeholder if user impact) +- [ ] Postmortem scheduled (SEV0/SEV1) From 530663f088a4c58a7b9901bf510b7d823cc0e613 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 21:34:32 -0300 Subject: [PATCH 12/24] docs(sap): add datasets index (PMAP-RJ RTS) Co-authored-by: Cursor --- docs/core/README.md | 2 +- docs/ops/README.md | 1 + docs/ops/datasets/README.md | 3 ++ docs/ops/datasets/pmap-rj-rts-index.md | 43 ++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 docs/ops/datasets/README.md create mode 100644 docs/ops/datasets/pmap-rj-rts-index.md diff --git a/docs/core/README.md b/docs/core/README.md index 33a6b058a..ddb7c206e 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -30,7 +30,7 @@ Each guardian explains a different safety angle of the same decision: - **Airbag (safety)**: risk and damage reduction - **Ledger (traceability)**: audit trail and justification -Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) | [Monitoring](../ops/monitoring/README.md) | [On-Call](../ops/oncall/README.md) +Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) | [Monitoring](../ops/monitoring/README.md) | [On-Call](../ops/oncall/README.md) | [Datasets](../ops/datasets/README.md) More detail: - [`docs/GUARDIANS_SUMMARY_FOR_HUMANS.md`](../GUARDIANS_SUMMARY_FOR_HUMANS.md) diff --git a/docs/ops/README.md b/docs/ops/README.md index dc9fa9ca8..4e53e04a8 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -3,4 +3,5 @@ - **Runbooks:** [docs/ops/runbooks/README.md](runbooks/README.md) — diagnóstico y recuperación sin DevTools - **Monitoring:** [docs/ops/monitoring/README.md](monitoring/README.md) — signals, cadence, SEV, evidence - **On-Call & Escalation:** [docs/ops/oncall/README.md](oncall/README.md) — SRE, SEV, escalation, evidence +- **Datasets:** [docs/ops/datasets/README.md](datasets/README.md) — index & freshness (PMAP-RJ RTS) - **WayBack:** [docs/ops/wayback/README.md](wayback/README.md) — snapshots y cambios diff --git a/docs/ops/datasets/README.md b/docs/ops/datasets/README.md new file mode 100644 index 000000000..cfe21db98 --- /dev/null +++ b/docs/ops/datasets/README.md @@ -0,0 +1,3 @@ +# Datasets Index + +- **PMAP-RJ RTS:** [pmap-rj-rts-index.md](pmap-rj-rts-index.md) — Relatórios Técnicos Semestrais, index & freshness diff --git a/docs/ops/datasets/pmap-rj-rts-index.md b/docs/ops/datasets/pmap-rj-rts-index.md new file mode 100644 index 000000000..33b6ed22d --- /dev/null +++ b/docs/ops/datasets/pmap-rj-rts-index.md @@ -0,0 +1,43 @@ +# PMAP-RJ – Relatórios Técnicos Semestrais (RTS) – Index & Freshness + +## Source of truth + +Portal de Dados Abertos RJ dataset: **"Relatórios Técnicos Semestrais do PMAP RJ"**. + +## Why this matters + +Avoids working with baseline 2018 if newer RTS exist. Outdated RTS → outdated assumptions (fleet, effort, fishing days, zones) → wrong thresholds in Monitoring and runbooks. Freshness = veracity. + +## Freshness protocol (simple, no tooling) + +- **Record** "last seen RTS" (year/semester). +- **Check** monthly or quarterly by Ops/Analyst team. +- **If** portal shows Inativa / Não-atualizado: record as operational risk ("publication pipeline may be stalled"). + +## Funding / governance note (neutral) + +FIPERJ + FUNDEPAG + Petrobras; licenciamento ambiental (IBAMA). + +## How we use it in iURi + +When assumptions change (fleet, effort, fishing days, zones), update: +- Monitoring Spec thresholds +- Runbooks (evidence, probes, regions) +- Evidence Bundle format (if schema changes) + +## Checklist: when new RTS appears + +- [ ] Update monitoring thresholds +- [ ] Add evidence snapshot (link to RTS, date) +- [ ] Update runbooks if regions/probes change +- [ ] Record "last seen RTS" (year/semester) + +## Links + +- [Datasets index](README.md) +- [Monitoring spec](../monitoring/monitoring-spec.md) +- [Runbooks](../runbooks/README.md) + +## Owner + +Ops Lead / Research Analyst From 510b076ddf5d8e2381f2e5841a56abde1600213f Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 21:54:16 -0300 Subject: [PATCH 13/24] docs(sap): add PMAP funding+continuity note Co-authored-by: Cursor --- docs/core/README.md | 2 +- docs/ops/README.md | 1 + docs/ops/pmap-funding-and-continuity.md | 28 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 docs/ops/pmap-funding-and-continuity.md diff --git a/docs/core/README.md b/docs/core/README.md index ddb7c206e..551a22730 100644 --- a/docs/core/README.md +++ b/docs/core/README.md @@ -30,7 +30,7 @@ Each guardian explains a different safety angle of the same decision: - **Airbag (safety)**: risk and damage reduction - **Ledger (traceability)**: audit trail and justification -Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) | [Monitoring](../ops/monitoring/README.md) | [On-Call](../ops/oncall/README.md) | [Datasets](../ops/datasets/README.md) +Operational process (validation): Human-in-the-Loop (HITL) — [ES](../human-in-the-loop/README.md) | [EN](../human-in-the-loop/README.en.md) | [PT](../human-in-the-loop/README.pt.md) | [Runbooks](../ops/runbooks/README.md) | [Monitoring](../ops/monitoring/README.md) | [On-Call](../ops/oncall/README.md) | [Datasets](../ops/datasets/README.md) | [PMAP funding](../ops/pmap-funding-and-continuity.md) More detail: - [`docs/GUARDIANS_SUMMARY_FOR_HUMANS.md`](../GUARDIANS_SUMMARY_FOR_HUMANS.md) diff --git a/docs/ops/README.md b/docs/ops/README.md index 4e53e04a8..7d3e3ee8b 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -4,4 +4,5 @@ - **Monitoring:** [docs/ops/monitoring/README.md](monitoring/README.md) — signals, cadence, SEV, evidence - **On-Call & Escalation:** [docs/ops/oncall/README.md](oncall/README.md) — SRE, SEV, escalation, evidence - **Datasets:** [docs/ops/datasets/README.md](datasets/README.md) — index & freshness (PMAP-RJ RTS) +- **PMAP Governance notes:** [pmap-funding-and-continuity.md](pmap-funding-and-continuity.md) — funding + continuity - **WayBack:** [docs/ops/wayback/README.md](wayback/README.md) — snapshots y cambios diff --git a/docs/ops/pmap-funding-and-continuity.md b/docs/ops/pmap-funding-and-continuity.md new file mode 100644 index 000000000..016eb3657 --- /dev/null +++ b/docs/ops/pmap-funding-and-continuity.md @@ -0,0 +1,28 @@ +# PMAP-RJ: Funding + Continuity (research note) + +_Last updated: 2025-01-29_ + +## 1) What this proves (in 30 seconds) + +- O RTS-02 2024 afirma ser o "décimo quarto relatório semestral seguido" e completar "7 anos ininterruptos de monitoramento". + - Fonte: pmap-rj.relatorio-tecnico-semestral-02_2024.pdf, pág. 31, linhas 44–49. +- Indica "novo contrato" firmado em maio de 2024 (inclui nº de contrato). + - Fonte: pmap-rj.relatorio-tecnico-semestral-02_2024.pdf, pág. 31, linhas 45–54. + +## 2) Who pays who (simple chain) + +- **Contratante:** Petrobras (UO-BS) +- **Contratada:** FUNDEPAG +- **Cooperação técnica:** FIPERJ + +Fonte: pmap-rj.relatorio-tecnico-semestral-02_2024.pdf, pág. 3, linhas 10–18. + +## 3) Why this matters for ops + veracity + +- Veracity não é só "dados corretos": também "serviço funcionando + rastreabilidade". +- Esta chain define quem pode autorizar orçamento/infra (Petrobras), quem gerencia o contrato (FUNDEPAG) e quem opera território/dados (FIPERJ). + +## 4) Limits / what this does NOT prove + +- Não prova continuidade perfeita sem gaps operacionais: apenas que o relatório declara continuidade e contrato vigente. +- Para confirmação adicional: listar "Control de Alterações" + lista de RTS por ano (se disponíveis). From dcafe59039a94fa4b762778c1d38707c5dfcc56b Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 22:07:31 -0300 Subject: [PATCH 14/24] docs(sap): add BL05 zones scaffold Co-authored-by: Cursor --- docs/ops/README.md | 1 + docs/ops/zones/README.md | 27 ++++++ docs/ops/zones/bl05-model.md | 27 ++++++ frontend/src/data/zones/zones.sample.geojson | 85 +++++++++++++++++++ .../data/zones/zones_bl05_index.sample.json | 10 +++ .../src/data/zones/zones_catalog.sample.json | 32 +++++++ frontend/src/lib/bl05.ts | 78 +++++++++++++++++ 7 files changed, 260 insertions(+) create mode 100644 docs/ops/zones/README.md create mode 100644 docs/ops/zones/bl05-model.md create mode 100644 frontend/src/data/zones/zones.sample.geojson create mode 100644 frontend/src/data/zones/zones_bl05_index.sample.json create mode 100644 frontend/src/data/zones/zones_catalog.sample.json create mode 100644 frontend/src/lib/bl05.ts diff --git a/docs/ops/README.md b/docs/ops/README.md index 7d3e3ee8b..bdb598aa0 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -4,5 +4,6 @@ - **Monitoring:** [docs/ops/monitoring/README.md](monitoring/README.md) — signals, cadence, SEV, evidence - **On-Call & Escalation:** [docs/ops/oncall/README.md](oncall/README.md) — SRE, SEV, escalation, evidence - **Datasets:** [docs/ops/datasets/README.md](datasets/README.md) — index & freshness (PMAP-RJ RTS) +- **Zones (BL05):** [docs/ops/zones/README.md](zones/README.md) — PMAP fishing zones scaffold - **PMAP Governance notes:** [pmap-funding-and-continuity.md](pmap-funding-and-continuity.md) — funding + continuity - **WayBack:** [docs/ops/wayback/README.md](wayback/README.md) — snapshots y cambios diff --git a/docs/ops/zones/README.md b/docs/ops/zones/README.md new file mode 100644 index 000000000..61398b500 --- /dev/null +++ b/docs/ops/zones/README.md @@ -0,0 +1,27 @@ +# PMAP Zones (BL05 scaffold) + +Minimal scaffold for PMAP fishing zones using BL05 grid cells (5' x 5') as the base spatial unit. + +## Where files live + +- **Model:** [bl05-model.md](bl05-model.md) +- **Helper:** `frontend/src/lib/bl05.ts` +- **Sample data:** `frontend/src/data/zones/` (zones_catalog.sample.json, zones_bl05_index.sample.json, zones.sample.geojson) + +## How to extend with real PMAP zones + +1. Replace sample files with real zone definitions. +2. Ensure each zone has `precision`, `needs_refine`, `source_doc`, `method`. +3. Build GeoJSON from BL05 cells or refined polygons. + +## Operator workflow (copy/paste) + +1. Take approx zone point(s) / description +2. Convert to BL05 cells using helper (`cellsForBBox`) +3. Record as `precision=approx` +4. Later refine into polygons + +## Related + +- [Monitoring spec](../monitoring/monitoring-spec.md) +- [HITL](../../human-in-the-loop/README.md) diff --git a/docs/ops/zones/bl05-model.md b/docs/ops/zones/bl05-model.md new file mode 100644 index 000000000..a59d10e11 --- /dev/null +++ b/docs/ops/zones/bl05-model.md @@ -0,0 +1,27 @@ +# BL05 Grid Model (PMAP Zones) + +## Definition + +**BL05** = statistical block grid with cell size **5 minutes** = 1/12 degree in both lat and lon. + +- **Grid cell size:** 5 minutes = 1/12 degree (lat and lon) +- **Tile ID format:** `":"` where `idx = floor(deg * 12)` +- **Cell bbox:** `[lonMin, latMin, lonMax, latMax]` (GeoJSON axis order) + +## Why BL05 + +PMAP reports use statistical blocks of 5 minutes (BL05) for maps. Source: PMAP-RJ methodology (RTS-02/2024 PDF). + +## Truth / precision rule + +Zones can be: + +- **approx:** BL05 cells (grid-based) +- **exact:** polygon (refined geometry) + +Always store: + +- `precision`: `"approx"` | `"exact"` +- `needs_refine`: boolean +- `source_doc`: string (e.g. RTS PDF, internal) +- `method`: string (e.g. `"bl05_grid"`, `"manual_polygon"`) diff --git a/frontend/src/data/zones/zones.sample.geojson b/frontend/src/data/zones/zones.sample.geojson new file mode 100644 index 000000000..3fa1f726c --- /dev/null +++ b/frontend/src/data/zones/zones.sample.geojson @@ -0,0 +1,85 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-42.916666666666664, -22.916666666666668], [-42.833333333333336, -22.916666666666668], [-42.833333333333336, -22.833333333333336], [-42.916666666666664, -22.833333333333336], [-42.916666666666664, -22.916666666666668]] + ] + }, + "properties": { "zone_id": "sample_zone_marica_01", "precision": "approx", "municipio": "Maricá", "arte": "SAMPLE_arte_A", "segmento": "artesanal" } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-42.833333333333336, -22.916666666666668], [-42.75, -22.916666666666668], [-42.75, -22.833333333333336], [-42.833333333333336, -22.833333333333336], [-42.833333333333336, -22.916666666666668]] + ] + }, + "properties": { "zone_id": "sample_zone_marica_01", "precision": "approx", "municipio": "Maricá", "arte": "SAMPLE_arte_A", "segmento": "artesanal" } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-42.916666666666664, -23], [-42.833333333333336, -23], [-42.833333333333336, -22.916666666666668], [-42.916666666666664, -22.916666666666668], [-42.916666666666664, -23]] + ] + }, + "properties": { "zone_id": "sample_zone_marica_01", "precision": "approx", "municipio": "Maricá", "arte": "SAMPLE_arte_A", "segmento": "artesanal" } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-42.833333333333336, -23], [-42.75, -23], [-42.75, -22.916666666666668], [-42.833333333333336, -22.916666666666668], [-42.833333333333336, -23]] + ] + }, + "properties": { "zone_id": "sample_zone_marica_01", "precision": "approx", "municipio": "Maricá", "arte": "SAMPLE_arte_A", "segmento": "artesanal" } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-42.0, -22.916666666666668], [-41.916666666666664, -22.916666666666668], [-41.916666666666664, -22.833333333333336], [-42.0, -22.833333333333336], [-42.0, -22.916666666666668]] + ] + }, + "properties": { "zone_id": "sample_zone_cabo_frio_01", "precision": "approx", "municipio": "Cabo Frio", "arte": "SAMPLE_arte_B", "segmento": "artesanal" } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-41.916666666666664, -22.916666666666668], [-41.833333333333336, -22.916666666666668], [-41.833333333333336, -22.833333333333336], [-41.916666666666664, -22.833333333333336], [-41.916666666666664, -22.916666666666668]] + ] + }, + "properties": { "zone_id": "sample_zone_cabo_frio_01", "precision": "approx", "municipio": "Cabo Frio", "arte": "SAMPLE_arte_B", "segmento": "artesanal" } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-42.916666666666664, -23.083333333333336], [-42.833333333333336, -23.083333333333336], [-42.833333333333336, -23], [-42.916666666666664, -23], [-42.916666666666664, -23.083333333333336]] + ] + }, + "properties": { "zone_id": "sample_zone_industrial_01", "precision": "approx", "municipio": "SAMPLE_municipio", "arte": "SAMPLE_arte_C", "segmento": "industrial" } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[-42.833333333333336, -23.083333333333336], [-42.75, -23.083333333333336], [-42.75, -23], [-42.833333333333336, -23], [-42.833333333333336, -23.083333333333336]] + ] + }, + "properties": { "zone_id": "sample_zone_industrial_01", "precision": "approx", "municipio": "SAMPLE_municipio", "arte": "SAMPLE_arte_C", "segmento": "industrial" } + } + ] +} diff --git a/frontend/src/data/zones/zones_bl05_index.sample.json b/frontend/src/data/zones/zones_bl05_index.sample.json new file mode 100644 index 000000000..7a865aebe --- /dev/null +++ b/frontend/src/data/zones/zones_bl05_index.sample.json @@ -0,0 +1,10 @@ +{ + "-275:-514": ["sample_zone_marica_01"], + "-275:-513": ["sample_zone_marica_01"], + "-276:-514": ["sample_zone_marica_01"], + "-276:-513": ["sample_zone_marica_01"], + "-276:-504": ["sample_zone_cabo_frio_01"], + "-276:-503": ["sample_zone_cabo_frio_01"], + "-277:-515": ["sample_zone_industrial_01"], + "-277:-514": ["sample_zone_industrial_01"] +} diff --git a/frontend/src/data/zones/zones_catalog.sample.json b/frontend/src/data/zones/zones_catalog.sample.json new file mode 100644 index 000000000..e9dc2b1fe --- /dev/null +++ b/frontend/src/data/zones/zones_catalog.sample.json @@ -0,0 +1,32 @@ +[ + { + "zone_id": "sample_zone_marica_01", + "municipio": "Maricá", + "segmento": "artesanal", + "arte": "SAMPLE_arte_A", + "precision": "approx", + "needs_refine": true, + "source": { "doc": "SAMPLE", "method": "bl05_grid" }, + "bl05_cells": ["-275:-514", "-275:-513", "-276:-514", "-276:-513"] + }, + { + "zone_id": "sample_zone_cabo_frio_01", + "municipio": "Cabo Frio", + "segmento": "artesanal", + "arte": "SAMPLE_arte_B", + "precision": "approx", + "needs_refine": true, + "source": { "doc": "SAMPLE", "method": "bl05_grid" }, + "bl05_cells": ["-276:-504", "-276:-503"] + }, + { + "zone_id": "sample_zone_industrial_01", + "municipio": "SAMPLE_municipio", + "segmento": "industrial", + "arte": "SAMPLE_arte_C", + "precision": "approx", + "needs_refine": true, + "source": { "doc": "SAMPLE", "method": "bl05_grid" }, + "bl05_cells": ["-277:-515", "-277:-514"] + } +] diff --git a/frontend/src/lib/bl05.ts b/frontend/src/lib/bl05.ts new file mode 100644 index 000000000..69cd23135 --- /dev/null +++ b/frontend/src/lib/bl05.ts @@ -0,0 +1,78 @@ +/** + * BL05 grid helper — 5' x 5' cells (1/12 degree). + * Used for PMAP zone representation. + * @see docs/ops/zones/bl05-model.md + */ + +const DEG_PER_CELL = 1 / 12; + +/** + * Converts degrees to BL05 index: floor(deg * 12). + */ +export function toIndex(deg: number): number { + return Math.floor(deg * 12); +} + +/** + * Returns BL05 cell ID for a point: ":". + */ +export function toCellId(lat: number, lon: number): string { + const latIdx = toIndex(lat); + const lonIdx = toIndex(lon); + return `${latIdx}:${lonIdx}`; +} + +/** + * Parses a cell ID and returns its bbox [lonMin, latMin, lonMax, latMax]. + */ +export function cellBBoxFromId(cellId: string): { + lonMin: number; + latMin: number; + lonMax: number; + latMax: number; +} { + const [latIdxStr, lonIdxStr] = cellId.split(":"); + const latIdx = parseInt(latIdxStr, 10); + const lonIdx = parseInt(lonIdxStr, 10); + return { + lonMin: lonIdx * DEG_PER_CELL, + latMin: latIdx * DEG_PER_CELL, + lonMax: (lonIdx + 1) * DEG_PER_CELL, + latMax: (latIdx + 1) * DEG_PER_CELL, + }; +} + +/** + * Returns BL05 cell bbox for a point (the cell containing that point). + */ +export function cellBBox(lat: number, lon: number): { + lonMin: number; + latMin: number; + lonMax: number; + latMax: number; +} { + return cellBBoxFromId(toCellId(lat, lon)); +} + +/** + * Enumerates all BL05 cell IDs intersecting the given bbox. + */ +export function cellsForBBox(bbox: { + lonMin: number; + latMin: number; + lonMax: number; + latMax: number; +}): string[] { + const { lonMin, latMin, lonMax, latMax } = bbox; + const latIdxMin = toIndex(latMin); + const latIdxMax = toIndex(latMax); + const lonIdxMin = toIndex(lonMin); + const lonIdxMax = toIndex(lonMax); + const out: string[] = []; + for (let latIdx = latIdxMin; latIdx <= latIdxMax; latIdx++) { + for (let lonIdx = lonIdxMin; lonIdx <= lonIdxMax; lonIdx++) { + out.push(`${latIdx}:${lonIdx}`); + } + } + return out; +} From 6b3c40303d5a7294a6a3a8e5b45ce805d4aa3289 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 22:22:28 -0300 Subject: [PATCH 15/24] docs(sap): add ports+governance scaffold (PMAP 2024 landing sites) and wire to BL05 zones Co-authored-by: Cursor --- docs/ops/README.md | 2 + docs/ops/governance/README.md | 8 ++ docs/ops/governance/funding-and-ownership.md | 14 +++ docs/ops/ports/README.md | 19 ++++ .../ports/pmap-rts-02_2024-landing-sites.md | 86 +++++++++++++++++++ docs/ops/ports/ports-zones-linking.md | 19 ++++ docs/ops/zones/README.md | 1 + .../ports/landing_sites_pmap_2024.sample.json | 51 +++++++++++ .../src/data/ports/ports_catalog.sample.json | 8 ++ frontend/src/lib/ports.ts | 51 +++++++++++ 10 files changed, 259 insertions(+) create mode 100644 docs/ops/governance/README.md create mode 100644 docs/ops/governance/funding-and-ownership.md create mode 100644 docs/ops/ports/README.md create mode 100644 docs/ops/ports/pmap-rts-02_2024-landing-sites.md create mode 100644 docs/ops/ports/ports-zones-linking.md create mode 100644 frontend/src/data/ports/landing_sites_pmap_2024.sample.json create mode 100644 frontend/src/data/ports/ports_catalog.sample.json create mode 100644 frontend/src/lib/ports.ts diff --git a/docs/ops/README.md b/docs/ops/README.md index bdb598aa0..84b2e2b18 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -5,5 +5,7 @@ - **On-Call & Escalation:** [docs/ops/oncall/README.md](oncall/README.md) — SRE, SEV, escalation, evidence - **Datasets:** [docs/ops/datasets/README.md](datasets/README.md) — index & freshness (PMAP-RJ RTS) - **Zones (BL05):** [docs/ops/zones/README.md](zones/README.md) — PMAP fishing zones scaffold +- **Ports & Landing Sites:** [docs/ops/ports/README.md](ports/README.md) — catalog, PMAP 2024 +- **Governance:** [docs/ops/governance/README.md](governance/README.md) — funding, SOP, SRE - **PMAP Governance notes:** [pmap-funding-and-continuity.md](pmap-funding-and-continuity.md) — funding + continuity - **WayBack:** [docs/ops/wayback/README.md](wayback/README.md) — snapshots y cambios diff --git a/docs/ops/governance/README.md b/docs/ops/governance/README.md new file mode 100644 index 000000000..a1fe0c82b --- /dev/null +++ b/docs/ops/governance/README.md @@ -0,0 +1,8 @@ +# Governance + +- [Funding and ownership](funding-and-ownership.md) + +## Glossary + +- **SOP:** Standard Operating Procedure +- **SRE:** Site Reliability Engineering (uptime, deploy safety, monitoring, incident response) diff --git a/docs/ops/governance/funding-and-ownership.md b/docs/ops/governance/funding-and-ownership.md new file mode 100644 index 000000000..fcc4ed8f8 --- /dev/null +++ b/docs/ops/governance/funding-and-ownership.md @@ -0,0 +1,14 @@ +# Funding and Ownership (PMAP + FIPERJ) + +## PMAP + +PMAP (Projeto de Monitoramento da Atividade Pesqueira) is a monitoring program connected to environmental licensing context and Petrobras program pages. PMAP-RJ covers the Rio de Janeiro coast (Cabo Frio to Paraty). + +## FIPERJ + +FIPERJ is a state foundation linked to the state secretariat (agriculture). PMAP execution can be via contracts/projects. FIPERJ provides technical cooperation. + +## Sources (official) + +- [Petrobras – Projeto de Monitoramento da Atividade Pesqueira (PMAP)](https://petrobras.com.br/sustentabilidade/pesca/projeto-de-monitoramento-da-atividade-pesqueira.htm) +- [Governo RJ – FIPERJ (vinculação)](https://www.rj.gov.br/agricultura/fiperj) diff --git a/docs/ops/ports/README.md b/docs/ops/ports/README.md new file mode 100644 index 000000000..14dd9da70 --- /dev/null +++ b/docs/ops/ports/README.md @@ -0,0 +1,19 @@ +# Ports & Landing Sites + +Operator-visible catalog of landing sites and how they map to Zones (BL05). + +## Links + +- [PMAP RTS-02/2024 landing sites](pmap-rts-02_2024-landing-sites.md) +- [Ports ↔ Zones linking](ports-zones-linking.md) +- [Zones (BL05)](../zones/README.md) +- [Monitoring spec](../monitoring/monitoring-spec.md) +- [Runbooks](../runbooks/README.md) + +## Scope + +- **RJ now:** Rio de Janeiro coast (Cabo Frio to Paraty) +- **RJ+SP next:** São Paulo coast added when ready +- **BR coast later:** National expansion + +Expand by adding catalogs, not hardcoding. diff --git a/docs/ops/ports/pmap-rts-02_2024-landing-sites.md b/docs/ops/ports/pmap-rts-02_2024-landing-sites.md new file mode 100644 index 000000000..76a17861c --- /dev/null +++ b/docs/ops/ports/pmap-rts-02_2024-landing-sites.md @@ -0,0 +1,86 @@ +# PMAP-RJ RTS-02/2024 — Landing Sites + +## Source metadata + +- **Document:** Projeto de Monitoramento da Atividade Pesqueira no Estado do Rio de Janeiro – PMAP-RJ +- **Report:** Relatório Técnico Semestral #2 +- **Report ID:** BR04033007/24 +- **Revision:** 00 +- **Date:** 11/2024 +- **Table:** Tabela 3 – Localidades e Locais de Descarga monitorados pelo PMAP-RJ +- **Page in PDF:** Pág. 49 / 254 + +## Extract (as-is) + +| Município | Localidade | Local de Descarga | +|-----------|------------|-------------------| +| Barra do Rio São João | | Chavão e Pontal de Santo Antônio | +| Praias de Cabo Frio | | Canto do Forte, Praia do Forte, Praia do Foguete e Praia do Peró | +| Caieira | | Da Hora, Valtermir, Gelo Forte, Brasfish (Caieira ), JB e Magalhães | +| Canal do Itajuru | | Cemitério, Coqueiral, Perrota, Mercado de Peixe, Brasfish (Ilha da Draga), Gamboa, Braspesca e Júnior | +| Praias de Arraial do Cabo | | Praia dos Anjos, Praia de Figueira, Praia do Pontal de Arraial do Cabo, Prainha, Praia Grande e Cantão | +| Marina dos Pescadores | | Marina dos Pescadores | +| Araruama | Praia Seca | Praia do Vargas, Praia dos Cachorros e Praia do Dentinho | +| Praias de Saquarema | | Praia de Vilatur, Praia de Itaúna e Praia de Barra Nova | +| Barra de Saquarema | | Barrinha | +| Ponta Negra | | Canal de Ponta Negra | +| Itaipuaçu | | Rua 70 e Recanto de Itaipuaçu | +| Região Oceânica | | Praia de Itaipu e Praia de Piratininga | +| Jurujuba | | Cais de Jurujuba e ALMARJ | +| Centro de Niterói | | Praia da Boa Viagem, Praia das Flechas e Bay Market | +| Ponta da Areia | | Funelli e Artártida | +| Ilha do Caju | | CODEPE | +| Ilha da Conceição | | Sardinha 88 | +| Gradim | | APELGA, Fenix e Quaresma | +| Itaoca | | Praia da Luz, Praia da Beira, Praia de São Gabriel e Caeira | +| Itaboraí | Itambi | Entreposto e Bacia | +| 1° Distrito | | Barbuda, Porto do Canal, Porto Roncador, Feital e Piedade | +| Suruí | | Rua do Campo, Paulinho e Suruí (Catadores) | +| Mauá | | Olaria | +| Ipiranga | | Cantinho da Vovó e Limão | +| Duque de Caxias | | Sarapuí e Chacrinha | +| Ilha do Governador | | Praia de Bancários, Rancho de Bancários e Freguesia | +| Zona Sul | | Posto 6 | +| Zona Oeste | | Praia dos Amores e Posto 12 | +| Barra de Guaratiba | | Praia do Canto e Praia Grande | +| Mangues de Guaratiba | | Mangue Itapuca, Mangue Poço e Mangue de Araçatiba | +| Pedra de Guaratiba | | Ponta Grossa, Pier, Coroinha e Igrejinha | +| Sepetiba | | Guarda, Tatu, Recôncavo, Praia do Cardo, Valão e Iate | +| Ilha da Madeira | | Píer da Praia de Fora, APESCA (Galpão dos pescadores) e Pier da Ponta | +| Coroa Grande | | Cais de Coroa Grande, Praia de Vila Geni e Vilar dos Coqueiros | +| Itacuruçá | | Praia de Itacuruçá | +| Costa Leste de Mangaratiba | | Praia de Muriqui, Praia do Saco e Sahy | +| Costa Oeste de Mangaratiba | | Praia do Centro e Conceição de Jacareí | +| Centro de Angra dos Reis | | EBRAPESCA (Gelo Odaka), PROPESCAR, Cais Santa Luzia e Cais do São Bento | +| Costa Oeste de Angra dos Reis | | Cais do Pontal, Mangue do Girassol, Cais da Associação dos Barqueiros, Praia Vermelha e Rio Mambucaba | +| Costa Norte de Paraty | | Cais de Tarituba, Praia do Cão Morto, Praia de São Gonçalo, Praia de São Gonçalinho, Rio Taquari, Rio Barra Grande, Cais da Praia Grande, Praia do Corumbê, Praia da Jabaquara, Praia do Pontal, Chácara e Centro Histórico | +| Ilha das Cobras | | Cais da Ilha das Cobras | +| Costa Sul de Paraty | | Marina 188, Praia de Paraty-Mirim, Praia do Rancho, Praia do Meio e Rio Matheus Nunes | + +## Normalized draft (operator editable) + +First-pass normalization to drive BL05 linking. Exact lat/lon comes later. + +- **Barra do Rio São João** +- **Cabo Frio** (Praias / Canal do Itajuru / Caieira / Marina dos Pescadores) +- **Arraial do Cabo** +- **Araruama** (Praia Seca) +- **Saquarema** (Praias / Barra / Ponta Negra) +- **Maricá** (Itaipuaçu) +- **Niterói** (Região Oceânica / Jurujuba / Centro / Ponta da Areia / Ilha da Conceição) +- **São Gonçalo** (Ilha do Caju / Gradim) +- **Magé / Itaboraí / Duque de Caxias** (Itaoca, Itambi, Suruí, Mauá, Ipiranga, Duque de Caxias) +- **Rio de Janeiro** (1° Distrito / Ilha do Governador / Zona Sul / Zona Oeste / Guaratiba / Mangues / Pedra de Guaratiba / Sepetiba / Ilha da Madeira / Coroa Grande) +- **Itaguaí** (Itacuruçá) +- **Mangaratiba** (Costa Leste / Costa Oeste) +- **Angra dos Reis** (Centro / Costa Oeste / Ilha das Cobras) +- **Paraty** (Costa Norte / Costa Sul) + +## Next step: add coordinates + +For each landing site: + +- [ ] Create a point (lat/lon) +- [ ] Compute BL05 cells via `frontend/src/lib/bl05.ts` +- [ ] Assign zone_ids (zones catalog) +- [ ] Store evidence bundle ([evidence-bundle-format](../monitoring/evidence-bundle-format.md)) diff --git a/docs/ops/ports/ports-zones-linking.md b/docs/ops/ports/ports-zones-linking.md new file mode 100644 index 000000000..3b5936f6f --- /dev/null +++ b/docs/ops/ports/ports-zones-linking.md @@ -0,0 +1,19 @@ +# Ports ↔ Zones (BL05) linking + +## How we link + +- Each Port/Landing Site point maps to one or more BL05 cells. +- Zones are polygons/areas (GeoJSON). We index zones by BL05 cells. +- A landing site belongs to zones whose BL05 cells contain (or intersect) its point. + +## Operator workflow (5 steps) + +1. Pick landing site from catalog +2. Add coordinates (lat/lon) +3. Compute BL05 cells via `frontend/src/lib/bl05.ts` → `cellsForBBox` or `toCellId` +4. Assign zone_ids (zones catalog) +5. Verify in UI (no DevTools) + store evidence bundle ([evidence-bundle-format](../monitoring/evidence-bundle-format.md)) + +## Truth rule + +If coordinates are approximate, tag as `precision: "approx"` and UI must label it "Approximate". diff --git a/docs/ops/zones/README.md b/docs/ops/zones/README.md index 61398b500..d19bdf1ea 100644 --- a/docs/ops/zones/README.md +++ b/docs/ops/zones/README.md @@ -23,5 +23,6 @@ Minimal scaffold for PMAP fishing zones using BL05 grid cells (5' x 5') as the b ## Related +- [Ports & Landing Sites](../ports/README.md) - [Monitoring spec](../monitoring/monitoring-spec.md) - [HITL](../../human-in-the-loop/README.md) diff --git a/frontend/src/data/ports/landing_sites_pmap_2024.sample.json b/frontend/src/data/ports/landing_sites_pmap_2024.sample.json new file mode 100644 index 000000000..9eb6c6771 --- /dev/null +++ b/frontend/src/data/ports/landing_sites_pmap_2024.sample.json @@ -0,0 +1,51 @@ +[ + { + "area_id": "barra_rio_sao_joao", + "municipality_inferred": false, + "locality": "Barra do Rio São João", + "landing_sites": ["Chavão", "Pontal de Santo Antônio"], + "source": { "report_id": "BR04033007/24", "date": "11/2024", "table": "Tabela 3", "page": "49/254" } + }, + { + "area_id": "cabo_frio", + "municipality_inferred": false, + "locality": "Praias de Cabo Frio", + "landing_sites": ["Canto do Forte", "Praia do Forte", "Praia do Foguete", "Praia do Peró"], + "source": { "report_id": "BR04033007/24", "date": "11/2024", "table": "Tabela 3", "page": "49/254" } + }, + { + "area_id": "cabo_frio", + "municipality_inferred": false, + "locality": "Caieira", + "landing_sites": ["Da Hora", "Valtermir", "Gelo Forte", "Brasfish (Caieira)", "JB", "Magalhães"], + "source": { "report_id": "BR04033007/24", "date": "11/2024", "table": "Tabela 3", "page": "49/254" } + }, + { + "area_id": "arraial_do_cabo", + "municipality_inferred": false, + "locality": "Praias de Arraial do Cabo", + "landing_sites": ["Praia dos Anjos", "Praia de Figueira", "Praia do Pontal de Arraial do Cabo", "Prainha", "Praia Grande", "Cantão"], + "source": { "report_id": "BR04033007/24", "date": "11/2024", "table": "Tabela 3", "page": "49/254" } + }, + { + "area_id": "marica", + "municipality_inferred": false, + "locality": "Itaipuaçu", + "landing_sites": ["Rua 70", "Recanto de Itaipuaçu"], + "source": { "report_id": "BR04033007/24", "date": "11/2024", "table": "Tabela 3", "page": "49/254" } + }, + { + "area_id": "angra_dos_reis", + "municipality_inferred": false, + "locality": "Centro de Angra dos Reis", + "landing_sites": ["EBRAPESCA (Gelo Odaka)", "PROPESCAR", "Cais Santa Luzia", "Cais do São Bento"], + "source": { "report_id": "BR04033007/24", "date": "11/2024", "table": "Tabela 3", "page": "49/254" } + }, + { + "area_id": "paraty", + "municipality_inferred": false, + "locality": "Costa Norte de Paraty", + "landing_sites": ["Cais de Tarituba", "Praia do Cão Morto", "Praia de São Gonçalo", "Praia de São Gonçalinho", "Rio Taquari", "Rio Barra Grande", "Cais da Praia Grande", "Praia do Corumbê", "Praia da Jabaquara", "Praia do Pontal", "Chácara", "Centro Histórico"], + "source": { "report_id": "BR04033007/24", "date": "11/2024", "table": "Tabela 3", "page": "49/254" } + } +] diff --git a/frontend/src/data/ports/ports_catalog.sample.json b/frontend/src/data/ports/ports_catalog.sample.json new file mode 100644 index 000000000..e48be0e39 --- /dev/null +++ b/frontend/src/data/ports/ports_catalog.sample.json @@ -0,0 +1,8 @@ +[ + { "id": "cabo_frio", "name": "Cabo Frio", "region": "RJ", "precision": "unknown" }, + { "id": "arraial_do_cabo", "name": "Arraial do Cabo", "region": "RJ", "precision": "unknown" }, + { "id": "baia_de_guanabara", "name": "Baía de Guanabara", "region": "RJ", "precision": "unknown" }, + { "id": "guaratiba", "name": "Guaratiba", "region": "RJ", "precision": "unknown" }, + { "id": "angra_dos_reis", "name": "Angra dos Reis", "region": "RJ", "precision": "unknown" }, + { "id": "paraty", "name": "Paraty", "region": "RJ", "precision": "unknown" } +] diff --git a/frontend/src/lib/ports.ts b/frontend/src/lib/ports.ts new file mode 100644 index 000000000..09fbc6403 --- /dev/null +++ b/frontend/src/lib/ports.ts @@ -0,0 +1,51 @@ +/** + * Ports / Landing Sites helpers. + * Used for PMAP-RJ catalogs. See docs/ops/ports/README.md + */ + +export type LandingSiteEntry = { + area_id: string; + municipality_inferred?: boolean; + locality: string; + landing_sites: string[]; + source?: { report_id: string; date: string; table: string; page: string }; +}; + +export type PortsCatalogEntry = { + id: string; + name: string; + region: string; + precision: string; +}; + +/** + * Normalizes a landing site name (trim, collapse spaces). + */ +export function normalizeLandingSiteName(s: string): string { + return s.trim().replace(/\s+/g, " "); +} + +/** + * Returns all landing site entries for a given area_id. + */ +export function getLandingSitesByArea( + areaId: string, + dataset: LandingSiteEntry[] +): LandingSiteEntry[] { + return dataset.filter((e) => e.area_id === areaId); +} + +/** + * Flattens dataset into a list of { area_id, locality, site } for UI consumption. + */ +export function flattenLandingSites( + dataset: LandingSiteEntry[] +): { area_id: string; locality: string; site: string }[] { + const out: { area_id: string; locality: string; site: string }[] = []; + for (const entry of dataset) { + for (const site of entry.landing_sites) { + out.push({ area_id: entry.area_id, locality: entry.locality, site }); + } + } + return out; +} From e14eab4a83aafcb28b1d9081063b624623c60357 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Mon, 16 Feb 2026 22:26:45 -0300 Subject: [PATCH 16/24] docs(sap): add ops glossary (SRE/SOP/SEV) and role map Co-authored-by: Cursor --- docs/ops/README.md | 2 +- docs/ops/governance/README.md | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/ops/README.md b/docs/ops/README.md index 84b2e2b18..f3c131364 100644 --- a/docs/ops/README.md +++ b/docs/ops/README.md @@ -6,6 +6,6 @@ - **Datasets:** [docs/ops/datasets/README.md](datasets/README.md) — index & freshness (PMAP-RJ RTS) - **Zones (BL05):** [docs/ops/zones/README.md](zones/README.md) — PMAP fishing zones scaffold - **Ports & Landing Sites:** [docs/ops/ports/README.md](ports/README.md) — catalog, PMAP 2024 -- **Governance:** [docs/ops/governance/README.md](governance/README.md) — funding, SOP, SRE +- **Governance:** [docs/ops/governance/README.md](governance/README.md) — funding, glossary + roles - **PMAP Governance notes:** [pmap-funding-and-continuity.md](pmap-funding-and-continuity.md) — funding + continuity - **WayBack:** [docs/ops/wayback/README.md](wayback/README.md) — snapshots y cambios diff --git a/docs/ops/governance/README.md b/docs/ops/governance/README.md index a1fe0c82b..513c65f3a 100644 --- a/docs/ops/governance/README.md +++ b/docs/ops/governance/README.md @@ -2,7 +2,16 @@ - [Funding and ownership](funding-and-ownership.md) -## Glossary +## Glossary (plain language) -- **SOP:** Standard Operating Procedure -- **SRE:** Site Reliability Engineering (uptime, deploy safety, monitoring, incident response) +- **SOP:** Standard Operating Procedure — written rules for how we operate (changes, access, incidents). +- **SRE:** Site Reliability Engineering — people and practices that keep systems up, observable, and recoverable (monitoring, deploy safety, incident response). +- **SEV0–SEV3:** Severity levels. SEV0 = critical (data corruption, wrong safety advice). SEV1 = core down (backend, main endpoints). SEV2 = degraded (partial failure). SEV3 = cosmetic, non-blocking. +- **Runbook:** Step-by-step guide for operators to diagnose and fix a known symptom (no DevTools required). +- **Monitoring:** Automated checks (health, endpoints) with evidence capture and alerts. +- **HITL:** Human-in-the-Loop — human validates what the model/system does; no "DONE" without evidence. +- **CRIT Gate:** Policy gate for safety and veracity checks around model output. + +## Role map (1 paragraph) + +**Ops/SRE on-call** responds to incidents, runs runbooks, and escalates. **Security/DevSecOps** handles access, keys, CORS, and infra hardening. **Domain Operator (PMAP)** validates data and business rules (RTS, zones, landing sites). **Developer (frontend/backend)** implements fixes; gets Evidence Bundles from Ops and does not declare DONE without human verification. From 64b736e7c79d3bd01304dc0f9c83ead430e15a93 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Tue, 17 Feb 2026 08:28:23 -0300 Subject: [PATCH 17/24] docs(sap): add truth rules + status checklist for PMAP 2024 ports Co-authored-by: Cursor --- .../ports/pmap-rts-02_2024-landing-sites.md | 20 +++++++++++++++++++ docs/ops/ports/ports-zones-linking.md | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/docs/ops/ports/pmap-rts-02_2024-landing-sites.md b/docs/ops/ports/pmap-rts-02_2024-landing-sites.md index 76a17861c..2aff1e1cb 100644 --- a/docs/ops/ports/pmap-rts-02_2024-landing-sites.md +++ b/docs/ops/ports/pmap-rts-02_2024-landing-sites.md @@ -57,6 +57,17 @@ | Ilha das Cobras | | Cais da Ilha das Cobras | | Costa Sul de Paraty | | Marina 188, Praia de Paraty-Mirim, Praia do Rancho, Praia do Meio e Rio Matheus Nunes | +## Truth rules (Veracity) + +Do not treat placeholders or sample data as facts. + +- **Source citation required:** Every entry must cite page/table (e.g. Pág. 49, Tabela 3). +- **No lat/lon invented:** Do not add coordinates unless verified from a documented source. +- **Unknown stays unknown:** If municipality/locality is uncertain, keep it as unknown; do not guess. +- **Normalization rules:** Use consistent names and IDs; document any mapping (e.g. "Canal do Itajuru" → `cabo_frio`). +- **Mark "approx":** If coordinates or names are approximate, tag `precision: "approx"` and label in UI. +- **Done-criteria for extraction:** Extraction is done only when names are captured, source cited, and no placeholder is presented as fact. + ## Normalized draft (operator editable) First-pass normalization to drive BL05 linking. Exact lat/lon comes later. @@ -84,3 +95,12 @@ For each landing site: - [ ] Compute BL05 cells via `frontend/src/lib/bl05.ts` - [ ] Assign zone_ids (zones catalog) - [ ] Store evidence bundle ([evidence-bundle-format](../monitoring/evidence-bundle-format.md)) + +## Extraction status + +Until all items are checked, treat names/IDs as unverified. + +- [ ] Table captured (names) +- [ ] IDs assigned +- [ ] lat/lon verified +- [ ] Linked to zones diff --git a/docs/ops/ports/ports-zones-linking.md b/docs/ops/ports/ports-zones-linking.md index 3b5936f6f..cc457ebdd 100644 --- a/docs/ops/ports/ports-zones-linking.md +++ b/docs/ops/ports/ports-zones-linking.md @@ -17,3 +17,7 @@ ## Truth rule If coordinates are approximate, tag as `precision: "approx"` and UI must label it "Approximate". + +## Do not overclaim + +Ports ↔ zones linkage is **provisional until verified**. Do not treat placeholder IDs or inferred mappings as facts; do not overclaim in reports or UI. From 9239a8a35d4794855a5ceb8354f81300f69c7fc8 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Tue, 17 Feb 2026 08:29:10 -0300 Subject: [PATCH 18/24] docs(sap): add ports data model (schemas + stable IDs) Co-authored-by: Cursor --- docs/ops/ports/README.md | 1 + docs/ops/ports/ports-data-model.md | 57 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/ops/ports/ports-data-model.md diff --git a/docs/ops/ports/README.md b/docs/ops/ports/README.md index 14dd9da70..74a422ecc 100644 --- a/docs/ops/ports/README.md +++ b/docs/ops/ports/README.md @@ -4,6 +4,7 @@ Operator-visible catalog of landing sites and how they map to Zones (BL05). ## Links +- [Data model (schemas + stable IDs)](ports-data-model.md) - [PMAP RTS-02/2024 landing sites](pmap-rts-02_2024-landing-sites.md) - [Ports ↔ Zones linking](ports-zones-linking.md) - [Zones (BL05)](../zones/README.md) diff --git a/docs/ops/ports/ports-data-model.md b/docs/ops/ports/ports-data-model.md new file mode 100644 index 000000000..7df435aa1 --- /dev/null +++ b/docs/ops/ports/ports-data-model.md @@ -0,0 +1,57 @@ +# Ports & Landing Sites — Data Model + +## Purpose + +Define stable shapes and IDs for ports/landing sites so future imports stay consistent. Use this when adding or normalizing catalog data. + +## Files + +- [ports_catalog.sample.json](../../../frontend/src/data/ports/ports_catalog.sample.json) +- [landing_sites_pmap_2024.sample.json](../../../frontend/src/data/ports/landing_sites_pmap_2024.sample.json) + +## Stable IDs rule + +Use **slug + source suffix**: lowercase, underscores, no spaces. When data comes from a report, include a short source suffix (e.g. `_pmap2024`) if needed to avoid collisions. Example: `cabo_frio`, `barra_rio_sao_joao`. + +## Schema: ports_catalog entry + +- `id` (string): stable slug +- `name` (string): display name +- `region` (string): e.g. `RJ` +- `precision` (string): `unknown` | `approx` | `exact` + +**Example:** + +```json +{ "id": "cabo_frio", "name": "Cabo Frio", "region": "RJ", "precision": "unknown" } +``` + +## Schema: landing_sites area + sites + +One object per locality row: + +- `area_id` (string): links to ports_catalog id or zone +- `municipality_inferred` (boolean): true if not certain +- `locality` (string): name from source +- `landing_sites` (array of string): list of site names +- `source` (object, optional): `report_id`, `date`, `table`, `page` + +**Example:** + +```json +{ + "area_id": "cabo_frio", + "municipality_inferred": false, + "locality": "Praias de Cabo Frio", + "landing_sites": ["Canto do Forte", "Praia do Forte"], + "source": { "report_id": "BR04033007/24", "date": "11/2024", "table": "Tabela 3", "page": "49/254" } +} +``` + +## Validation checklist + +- [ ] Every entry has a stable `id` / `area_id` (slug). +- [ ] Source citation present when data is from a report (page/table). +- [ ] No lat/lon without verification; use `precision: "unknown"` or `"approx"` until verified. +- [ ] `municipality_inferred: true` where locality/municipality is uncertain. +- [ ] New or changed data has evidence (see [HITL](../../human-in-the-loop/README.md), [evidence-bundle-format](../monitoring/evidence-bundle-format.md)). From d9c1f953650227fce142d36311145431d8077333 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Tue, 17 Feb 2026 08:39:31 -0300 Subject: [PATCH 19/24] docs(sap): ops docs pack (monitoring evidence + SOP + brainstorming) --- docs/README.md | 1 + docs/brainstorming.md | 33 +++ docs/ops/monitoring/evidence-bundle-format.md | 205 +++++++++++++++++- docs/ops/monitoring/monitoring-spec.md | 27 ++- docs/sop-operacoes-ciberseguranca.md | 84 +++++++ 5 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 docs/brainstorming.md create mode 100644 docs/sop-operacoes-ciberseguranca.md diff --git a/docs/README.md b/docs/README.md index 905f50f4c..e8cf2f19f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,6 @@ # iURi Docs Index +- **Brainstorming** (o que é, termos para sessões): [`docs/brainstorming.md`](./brainstorming.md) - **Guardian Unification**: `docs/GUARDIAN_UNIFICATION.md` - **SSOT Brain Core (CritGate entrypoint)**: `docs/brain/ssot_brain_core.md` - **LLM Brain / WILL Architecture**: `docs/LLM_BRAIN_WILL_ARCHITECTURE.md` diff --git a/docs/brainstorming.md b/docs/brainstorming.md new file mode 100644 index 000000000..2c39966fb --- /dev/null +++ b/docs/brainstorming.md @@ -0,0 +1,33 @@ +# Brainstorming + +Muitos documentos do iURi nascem de sessões de brainstorming. Este documento define o que é brainstorming no contexto do projeto e oferece termos para sessões informais. + +--- + +## O que é brainstorming (neste contexto) + +*[A preencher — aceito como documento inicial]* + +- Geração de ideias em grupo ou solo, sem filtro prévio. +- Exploração antes de decisão. +- Primeiro divergir, depois convergir. + +--- + +## Termos para sessões informais + +### Favoritos +- **idea jam** — sessão livre de geração de ideias +- **whiteboard jam** — exploração visual em quadro +- **design riff** (ou "riffing session") — iteração rápida sobre um tema + +### Extras (menu mais longo) +- jam session +- sketch session +- tabletop session +- working session +- rapid ideation + +--- + +*Documento inicial. Será preenchido em iterações.* diff --git a/docs/ops/monitoring/evidence-bundle-format.md b/docs/ops/monitoring/evidence-bundle-format.md index 4d7577d96..1c2a50876 100644 --- a/docs/ops/monitoring/evidence-bundle-format.md +++ b/docs/ops/monitoring/evidence-bundle-format.md @@ -1,11 +1,204 @@ -# Evidence Bundle Format +# Evidence Bundle Format (HITL-proof) -*(Stub — content to be filled in next task)* +**Purpose:** Make every incident/debug report *reproducible*, *auditable*, and safe to act on without "time-travel" claims. +**Rule:** No "DONE" claim without this bundle (or the Minimum Evidence subset). -## 1) Overview +--- -## 2) Fields +## 0) Bundle ID -## 3) Copy-paste template +**Bundle ID:** `YYYY-MM-DD__HHMM__SEVx__short-slug` +Examples: +- `2026-02-16__2018__SEV2__pnboia-no-buoys` +- `2026-02-16__1730__SEV1__backend-unavailable` -## 4) Storage & retention +**Owner (human):** `` +**Region:** `RJ | RJ+SP | BR Coast | Other` +**Runbook:** link the runbook used (if any) + +--- + +## 1) One-screen Summary (6 lines max) + +- **What broke:** +- **Impact (who/where):** +- **When started (local time):** +- **Current status:** `PROPOSED | IMPLEMENTED | VERIFIED | FAILED | ROLLED_BACK` +- **Suspected area:** `frontend | backend | infra | provider` +- **Next action (smallest safe step):** + +--- + +## 2) Environment + Build Identity (copy-paste) + +Paste the raw outputs. + +### Frontend (local or prod) +```bash +# if local dev: +lsof -i :5174 -n -P | grep LISTEN || true +# repo identity (root + frontend) +git -C . rev-parse --short HEAD +git -C frontend rev-parse --short HEAD +``` + +### Backend (local or VPS) +```bash +curl -fsS http://127.0.0.1:8001/health | head -c 400 && echo +``` + +### Version files (if available) + +- `frontend/dist/version.json` (local build) +- PROD `version.json` endpoint/path (paste content) + +--- + +## 3) Symptoms (what the human sees) + +**UI location:** route + query string +Example: `/map?start=realtime&debug=1` + +**Exact symptom text** (copy the UI message): + +- Example: `Backend unavailable` +- Example: `GPS timeout` +- Example: `Weather HTTP_500` +- Example: `PNBOIA: items=0 status=error` + +**Frequency:** `always | intermittent | after refresh | only on Brave | only on Firefox` +**Regression?** `Yes/No` and last known-good commit (if known) + +--- + +## 4) Reproduction Steps (human-simple) + +Write steps as "tap/click/refresh" only (no DevTools required). + +1. +2. +3. +4. + +**Expected:** +**Observed:** + +--- + +## 5) Terminal Proof (minimum commands) + +> Goal: prove whether the data exists and whether the API answers. + +### A) Health +```bash +curl -i http://127.0.0.1:8001/health | sed -n '1,40p' +``` + +### B) PNBOIA (buoys list) +```bash +curl -i http://127.0.0.1:8001/api/v1/pnboia/list | sed -n '1,80p' +``` + +### C) PNBOIA (nearby, if location known) +```bash +curl -i "http://127.0.0.1:8001/api/v1/pnboia/nearby?lat=-22.919&lon=-42.818" | sed -n '1,80p' +``` + +### D) Weather status (always safe) +```bash +curl -i http://127.0.0.1:8001/api/v1/weather/status | sed -n '1,80p' +``` + +Paste outputs below each command. + +--- + +## 6) In-App Evidence (no DevTools) + +Attach or paste: + +### Required screenshots (minimum) + +1. **Layers panel** showing toggles and the relevant layer ON +2. **Map view** showing the expected area (e.g., Cabo Frio) +3. **Technical Mode** tab that displays the error/status text (if present) + +### Optional (if available) + +- Debug HUD (only if `debug=1`) + +--- + +## 7) "What changed?" (small diff narrative) + +- **Last action taken:** (e.g., "amended single commit", "changed apiBase", "refactor panel tabs") +- **Files touched (if known):** +- **Why that could affect this symptom:** 1 sentence + +--- + +## 8) Hypothesis List (ranked) + +Write 3 hypotheses max, ranked: + +1. +2. +3. + +Each hypothesis must link to *one* evidence item above (terminal output or screenshot). + +--- + +## 9) Safe Actions Taken (and results) + +List *only* actions that are reversible and low-risk. + +- Action: + Result: + +- Action: + Result: + +--- + +## 10) Stop Conditions (when to stop experimenting) + +Stop and escalate if any: + +- Same symptom persists after 2 small fixes +- Build identity mismatch appears (wrong commit being served) +- Backend health OK but UI says "backend unavailable" +- Evidence contradicts the "DONE" claim + +--- + +## 11) Escalation Routing (who owns what) + +- **Frontend:** UI renders wrong / layers hidden / source not bound / gating wrong +- **Backend:** endpoint returns 4xx/5xx / schema mismatch / device_id_missing +- **Infra/SRE:** backend unreachable / port conflicts / restarts / proxy/CORS issues +- **Provider:** upstream degraded (weather/marine) while health is OK + +**Escalation note:** Attach this bundle. No new claims without new evidence. + +--- + +## 12) Done Criteria (verification gate) + +Mark DONE only when: + +- A human can reproduce the fix using the steps in section 4 +- Terminal proof shows expected responses (section 5) +- UI shows expected behavior with screenshots (section 6) +- Build identity matches the deployed code (section 2) + +--- + +## Appendix: Evidence Checklist (quick) + +- [ ] Bundle ID + owner +- [ ] Route + query string +- [ ] 3 screenshots minimum +- [ ] health + pnboia/list outputs +- [ ] expected vs observed written +- [ ] done criteria satisfied (or explicitly not) diff --git a/docs/ops/monitoring/monitoring-spec.md b/docs/ops/monitoring/monitoring-spec.md index 0c1a60f89..962eb3df1 100644 --- a/docs/ops/monitoring/monitoring-spec.md +++ b/docs/ops/monitoring/monitoring-spec.md @@ -87,7 +87,32 @@ Monitoring is part of veracity: we don't declare "healthy" without evidence. If | **Data/Provider liaison** | SEV2 (weather/pnboia provider degraded, rate limit) | | **Product/PMAP liaison** | SEV0 (safety advice wrong), user-impact escalations | -## 10) Links +## 10) Staffing model as stages + +We keep the original "full team" design as the target operating model, but roll it out in stages so PMAP/FIPERJ can start small and grow safely. + +### Stage 0 — Manual Ops (baseline) +**Goal:** Runbooks + evidence bundles + clear routing. +**Team:** Larger manual coverage (operators + SRE + app owners), heavier human workload. +**Why it exists:** First months are about stabilizing reality: uptime, data truth, UI truth. + +### Stage 1 — Assisted Ops (semi-automated) +**Goal:** Reduce repetitive checks via scheduled probes and dashboards, but keep human approval for actions. +**Automation:** Health checks, endpoint probes, cache/status monitors, basic alerts. +**Humans still do:** Triage, severity assignment, evidence bundles, "Done-Claim Gate" verification, rollback approval. + +### Stage 2 — Automated Monitoring (target for cost-efficiency) +**Goal:** Fewer "eyes on glass", faster detection, consistent evidence capture. +**Automation:** Continuous probes + auto-generated evidence bundle drafts + runbook suggestions. +**Humans still do (non-negotiable):** Final decision-making, safety gates, public-facing truth claims, incident comms. + +### Stage 3 — 24/7 Expansion (only if/when needed) +**Goal:** Scale coverage to RJ+SP or full BR coast with real shift rotation. +**Requirement:** Formal on-call schedule, redundancy, and governance (auditability + change control). + +**Policy note:** Automation changes workload distribution, not responsibility. Truth and uptime remain a human-governed contract (HITL boundary). + +## 11) Links - [Runbooks](../runbooks/README.md) - [SOP Operações + Cibersegurança](../../sop-operacoes-ciberseguranca.md) diff --git a/docs/sop-operacoes-ciberseguranca.md b/docs/sop-operacoes-ciberseguranca.md new file mode 100644 index 000000000..451eaab99 --- /dev/null +++ b/docs/sop-operacoes-ciberseguranca.md @@ -0,0 +1,84 @@ +# SOP: Operações + Cibersegurança (versão curta) + +Política interna PMAP/FIPERJ. + +--- + +## Regra de Ouro (Verdade) +Ninguém declara "RESOLVIDO/DONE" sem evidência reproduzível (PASS/FAIL + link/screenshot + passos). + +--- + +## Mudanças +Toda mudança em produção exige: +- ticket +- responsável +- rollback +- janela de mudança + +--- + +## Acesso +- MFA obrigatório +- menor privilégio +- logs imutáveis de admin +- rotacionar chaves + +--- + +## Monitoração +- health checks, latência, erros, filas, uso de disco +- alertas com escalonamento + +--- + +## Incidentes +- severidade (SEV1–SEV3) +- canal único +- "comandante do incidente" +- postmortem sem culpa + +--- + +## Backups +- diário (db + configs) +- teste de restore mensal + +--- + +## Dados críticos (pesca) +- validação de fonte +- carimbo de tempo +- fallback explícito ("indisponível" > "inventado") + +--- + +## HITL +Usuários/field ops validam no mapa (sem DevTools por padrão) e registram evidência. + +--- + +## Segurança +- varredura de dependências +- atualização de patches +- revisão periódica de permissões + +--- + +## Uptime = veracidade +Se o sistema cai, é falha de verdade operacional. Prioridade máxima. + +--- + +## A quem apresentar (quando couber) + +- **Coordenação PMAP-RJ** — operação diária e adoção. +- **Direção/gestão FIPERJ** — governança e operação pública. +- **Contraparte do contrato (Petrobras UO-BS / área de cumprimento do projeto)** — o SOP afeta continuidade e segurança do sistema ligado ao projeto. + +--- + +## Anexo de contexto (2026-02-16) + +**Contexto e financiamento (BR/RJ)** +O PMAP-RJ opera como condicionante de licenciamento ambiental vinculada à Bacia de Santos, com cobertura operacional entre Cabo Frio e Paraty. O projeto é executado por contrato em que o contratante é Petrobras (UO-BS) e a contratada é FUNDEPAG, com cooperação técnica da FIPERJ. A continuidade do serviço (backend/UI/dados) é considerada parte da veracidade operativa e deve ser mantida com guardas, monitoramento e resposta a incidentes conforme este SOP. From d6b0ae896ce7d9a91aaee56efaab69d88a6081ac Mon Sep 17 00:00:00 2001 From: Cheewye Date: Tue, 17 Feb 2026 08:57:32 -0300 Subject: [PATCH 20/24] chore(sap): restore guardian scope (no .cursor/AGENTS diff) --- .cursor/rules/ContractAlways.mdc | 2 +- .cursor/rules/GoldenRuleAlways.mdc | 2 +- .cursor/rules/anti_frankenstein_always.mdc | 2 +- .cursor/rules/anti_manual_steps_always.mdc | 2 +- .cursor/rules/anti_questions_always.mdc | 2 +- .cursor/rules/gobernanza.mdc | 2 +- .../rules/hitl-handshake-done-claim-gate.mdc | 58 ------------------- .cursor/rules/veracity_always.mdc | 2 +- AGENTS.md | 8 --- 9 files changed, 7 insertions(+), 73 deletions(-) delete mode 100644 .cursor/rules/hitl-handshake-done-claim-gate.mdc diff --git a/.cursor/rules/ContractAlways.mdc b/.cursor/rules/ContractAlways.mdc index 8103e2090..dfc24416b 100644 --- a/.cursor/rules/ContractAlways.mdc +++ b/.cursor/rules/ContractAlways.mdc @@ -60,4 +60,4 @@ Output final máximo 8 líneas: ## Prioridad Absoluta Esta regla precede a todas las demás. No duplicar lógica en otros .mdc. -Referenciar aquí. \ No newline at end of file +Referenciar aquí. diff --git a/.cursor/rules/GoldenRuleAlways.mdc b/.cursor/rules/GoldenRuleAlways.mdc index 76a6030ac..4cedc1c1d 100644 --- a/.cursor/rules/GoldenRuleAlways.mdc +++ b/.cursor/rules/GoldenRuleAlways.mdc @@ -46,4 +46,4 @@ No duplicar esta lógica en otros archivos .mdc - solo referenciar aquí. ## Referencias - docs/GOLDEN_RULE.md (documento fuente) - sap/core/guardian_cursor_gate.py (implementación técnica) -- tools/golden_rule_check.py (CI enforcement) \ No newline at end of file +- tools/golden_rule_check.py (CI enforcement) diff --git a/.cursor/rules/anti_frankenstein_always.mdc b/.cursor/rules/anti_frankenstein_always.mdc index 4cf772da1..2129d929b 100644 --- a/.cursor/rules/anti_frankenstein_always.mdc +++ b/.cursor/rules/anti_frankenstein_always.mdc @@ -22,4 +22,4 @@ Todo hotfix/patch debe tener "REMOVE BY: YYYY-MM-DD" y quedar registrado. ## Limpieza Automática - Script anti-frankenstein busca y elimina código expirado -- Ejecutado en CI para mantener codebase limpio \ No newline at end of file +- Ejecutado en CI para mantener codebase limpio diff --git a/.cursor/rules/anti_manual_steps_always.mdc b/.cursor/rules/anti_manual_steps_always.mdc index 1fca1359f..d6694a288 100644 --- a/.cursor/rules/anti_manual_steps_always.mdc +++ b/.cursor/rules/anti_manual_steps_always.mdc @@ -22,4 +22,4 @@ No pedir pasos manuales por default. Solo HUMAN_REQUIRED si no hay alternativa f - "hardware": Conectado físicamente ## Excepciones -- Casos físicos inevitables: HUMAN_REQUIRED con checklist \ No newline at end of file +- Casos físicos inevitables: HUMAN_REQUIRED con checklist diff --git a/.cursor/rules/anti_questions_always.mdc b/.cursor/rules/anti_questions_always.mdc index 8049abbf4..d1099342d 100644 --- a/.cursor/rules/anti_questions_always.mdc +++ b/.cursor/rules/anti_questions_always.mdc @@ -22,4 +22,4 @@ Si Cursor intenta pedir más datos cuando ya tiene suficiente contexto: RETRY - `available_tools`: Herramientas disponibles ## Excepciones -- Contexto insuficiente: ALLOW preguntas necesarias \ No newline at end of file +- Contexto insuficiente: ALLOW preguntas necesarias diff --git a/.cursor/rules/gobernanza.mdc b/.cursor/rules/gobernanza.mdc index dde9d70d6..44df4084c 100644 --- a/.cursor/rules/gobernanza.mdc +++ b/.cursor/rules/gobernanza.mdc @@ -51,4 +51,4 @@ Cuando algo SOLO puede resolverse en el navegador del usuario: ### ❌ Imposible Server-Side - Requiere acción del usuario (navegador/hardware) - Protocolo aplicado (1 frase + checklist) -- Sin soluciones alternativas \ No newline at end of file +- Sin soluciones alternativas diff --git a/.cursor/rules/hitl-handshake-done-claim-gate.mdc b/.cursor/rules/hitl-handshake-done-claim-gate.mdc deleted file mode 100644 index 20d6db86d..000000000 --- a/.cursor/rules/hitl-handshake-done-claim-gate.mdc +++ /dev/null @@ -1,58 +0,0 @@ ---- -description: "Human-in-the-Loop Handshake + Done-Claim Gate (iURi) — No solved without proof" -alwaysApply: true ---- - -# Human-in-the-Loop Handshake + Done-Claim Gate (iURi) - -## 0) Regla de oro -Nunca digas "ya está / fixed / listo" si no hay **prueba**. Si no podés probar, decí: **IMPLEMENTED (UNVERIFIED)**. - -## 1) Estados permitidos (obligatorio usar uno) -- PLAN (what + why + stop conditions) -- IMPLEMENTING -- IMPLEMENTED (UNVERIFIED) -- VERIFIED (BUILD) -- VERIFIED (RUNTIME) ← solo si el humano confirmó checklist -- REGRESSION FOUND -- ROLLBACK / RESCUE - -## 2) Done-Claim Gate (NO SOLVED WITHOUT PROOF) -Para declarar VERIFIED (RUNTIME) deben existir, en el mismo mensaje: -- `git rev-parse --short HEAD` -- `npm -C frontend run build` OK (antes y después si hubo regresión) -- Checklist de 3–6 pasos que el humano ejecutó y reportó (pass/fail) - -Si falta algo: queda en IMPLEMENTED (UNVERIFIED) o VERIFIED (BUILD). - -## 3) HITL Handshake (cómo trabajamos) -- Vos (IA) proponés **1 cambio quirúrgico** + **1 checklist corto**. -- Yo (humano) testeo en el mapa y digo: PASS/FAIL + síntoma. -- Recién ahí seguís. Nada de encadenar 10 parches "en tu cabeza". - -## 4) "No time-travel" (anti-split-brain) -Antes de diagnosticar, confirmá SIEMPRE: -- commit servido por el dev server (PID/puerto) == `git rev-parse --short HEAD` -Si no coincide: **STOP** y pedí al humano alinear servidor/build. - -## 5) Guardrails de costo y modelos -- Default: usar el modelo más barato/rápido para leer, planear, y diffs chicos. -- Escalar a Codex solo para implementación puntual y con stop condition clara. -- Si el bug entra en loop (2 intentos sin mejora): proponer rollback al último "known-good" y reintentar con evidencia. - -## 6) Debug sin DevTools (preferido) -No pedir DevTools como requisito. -Preferir: -- indicadores en UI (badges, contadores, "debug=1") -- `curl`/comandos cortos (máximo 2–3) con salidas pegables - -## 7) Cambios: mínimos, auditables, reversibles -- 1 PR / 1 commit final. -- Si working tree sucio: checkpoint commit. -- Siempre incluir "cómo revertir": `git revert `. - -## 8) Prohibición de humo -Prohibido afirmar que "se agregó X" si no podés señalar: -- archivo exacto -- snippet/diff mínimo -- evidencia (build/runtime) diff --git a/.cursor/rules/veracity_always.mdc b/.cursor/rules/veracity_always.mdc index 78a62509d..8e102d5ac 100644 --- a/.cursor/rules/veracity_always.mdc +++ b/.cursor/rules/veracity_always.mdc @@ -23,4 +23,4 @@ Prohibido afirmar "deploy completado", "verificado", "listo" sin evidencia real. - `evidence_files`: Archivos de evidencia ## Excepciones -- Con evidencia real: ALLOW con metadata de validación \ No newline at end of file +- Con evidencia real: ALLOW con metadata de validación diff --git a/AGENTS.md b/AGENTS.md index ff62e6229..9cdda3213 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,14 +3,6 @@ ## Regla de Oro **Si el sistema NO lo puede resolver solo, está fallando.** Cursor NO debe trasladar trabajo al humano salvo casos HUMAN_REQUIRED inevitables. -## Human-in-the-Loop Handshake + Done-Claim Gate (Always) -**Nunca digas "ya está / fixed / listo" sin prueba.** Si no podés probar → **IMPLEMENTED (UNVERIFIED)**. -- Estados: PLAN → IMPLEMENTING → IMPLEMENTED (UNVERIFIED) → VERIFIED (BUILD) → VERIFIED (RUNTIME) ← solo con checklist humano -- Done-Claim Gate: VERIFIED (RUNTIME) exige `git rev-parse --short HEAD` + build OK + checklist 3–6 pasos (pass/fail) -- Handshake: 1 cambio quirúrgico + 1 checklist → humano testea → PASS/FAIL → recién seguir -- No time-travel, debug sin DevTools, cambios reversibles, prohibición de humo -Regla completa: [`.cursor/rules/hitl-handshake-done-claim-gate.mdc`](.cursor/rules/hitl-handshake-done-claim-gate.mdc) - ## Estados de Guardian Cursor Gate ### ✅ PASS From a4f914188daef15ed45d52868cd9d3474c00761e Mon Sep 17 00:00:00 2001 From: Cheewye Date: Tue, 17 Feb 2026 09:29:02 -0300 Subject: [PATCH 21/24] chore(sap): apply HITL governance files --- docs/guardian/churn_approval.md | 57 +----- tools/guardian_scope_check.py | 344 +------------------------------- 2 files changed, 11 insertions(+), 390 deletions(-) diff --git a/docs/guardian/churn_approval.md b/docs/guardian/churn_approval.md index 8dcb193e7..49bba2c65 100644 --- a/docs/guardian/churn_approval.md +++ b/docs/guardian/churn_approval.md @@ -1,53 +1,6 @@ -# Churn approval (scope-check) - -Este archivo describe el esquema estricto de `.guardian/churn_approval.yml` y las -reglas de seguridad aplicadas por `tools/guardian_scope_check.py`. - -## Esquema - -Campos requeridos: -- `approved_by`: string no vacío -- `reason`: string no vacío (mínimo 10 caracteres) -- `allow_paths`: lista no vacía de rutas de archivos explícitas - -Campos opcionales (recomendados): -- `pr`: número o string -- `expires_at`: ISO8601 (`YYYY-MM-DD` o `YYYY-MM-DDTHH:MM:SSZ`) -- `base_ref`: string (por ejemplo, `origin/main`) -- `sha`: SHA de git (7-40 hex) - -Reglas estrictas: -- Se rechazan campos desconocidos. -- `allow_paths` no admite globs, wildcards, `..`, rutas absolutas ni directorios. - -## Plantilla mínima - -```yaml -approved_by: Nombre Apellido -reason: Motivo claro y auditable (min 10 chars) +approved_by: Cristian Barnes +reason: Allow Cursor governance files required by HITL workflow and agents. allow_paths: - - core/config.py -expires_at: 2026-01-31 -``` - -## Ejemplo válido - -```yaml -approved_by: Cheewye -reason: Ajuste de higiene pydantic; comportamiento preservado. -allow_paths: - - core/config.py - - tools/guardian_scope_check.py -expires_at: 2026-02-15T23:59:59Z -base_ref: origin/main -pr: 86 -``` - -## Ejemplo inválido - -```yaml -approved_by: -reason: corto -allow_paths: - - frontend/** -``` + - .cursor/rules/hitl-handshake-done-claim-gate.mdc + - AGENTS.md +expires_at: 2026-12-31 diff --git a/tools/guardian_scope_check.py b/tools/guardian_scope_check.py index d5187fc92..c9944dc2b 100644 --- a/tools/guardian_scope_check.py +++ b/tools/guardian_scope_check.py @@ -1,338 +1,6 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from dataclasses import dataclass -from datetime import date, datetime, time, timezone -from pathlib import Path -from typing import Dict, Iterable, List, Optional, Tuple - - -@dataclass -class ScopeConfig: - deny_prefixes: List[str] - allow_prefixes: List[str] - allow_exact: List[str] - allow_globs: List[str] - - -@dataclass -class ChurnApproval: - approved_by: str - reason: str - allow_paths: List[str] - pr: Optional[str] = None - expires_at: Optional[datetime] = None - base_ref: Optional[str] = None - sha: Optional[str] = None - - -def _load_config(path: Path) -> ScopeConfig: - data = json.loads(path.read_text(encoding="utf-8")) - return ScopeConfig( - deny_prefixes=list(data.get("deny_prefixes", [])), - allow_prefixes=list(data.get("allow_prefixes", [])), - allow_exact=list(data.get("allow_exact", [])), - allow_globs=list(data.get("allow_globs", [])), - ) - - -ALLOWED_ROOT_FILES = { - "README.md", - "START_HERE.md", - "SECURITY.md", - "CODE_OF_CONDUCT.md", - "CONTRIBUTING.md", - "LICENSE", - "LICENSE.md", - "LICENSE.txt", -} - -EXTRA_ALLOWED_PREFIXES = ("deploy/", "frontend/") - - -def evaluate_files(files: Iterable[str], scope: ScopeConfig) -> List[str]: - violations: List[str] = [] - for raw in files: - path = raw.strip() - if not path: - continue - if path in ALLOWED_ROOT_FILES: - continue - if path.startswith(EXTRA_ALLOWED_PREFIXES): - continue - if any(path.startswith(prefix) for prefix in scope.deny_prefixes): - violations.append(path) - continue - if path in scope.allow_exact: - continue - if any(path.startswith(prefix) for prefix in scope.allow_prefixes): - continue - if scope.allow_globs: - if any(Path(path).match(pattern) for pattern in scope.allow_globs): - continue - violations.append(path) - return sorted(set(violations)) - - -def _changed_files(base: str, head: str) -> List[str]: - result = subprocess.run( - ["git", "diff", "--name-only", f"{base}...{head}"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - raise RuntimeError(result.stderr.strip() or "git diff failed") - return [line for line in result.stdout.splitlines() if line.strip()] - - -def format_violation_message(violations: List[str], scope: ScopeConfig) -> str: - lines = ["out of scope changes detected:"] - for path in violations: - lines.append(f"- {path}") - if scope.allow_prefixes: - lines.append("allowed prefixes:") - for prefix in scope.allow_prefixes: - lines.append(f"- {prefix}") - if EXTRA_ALLOWED_PREFIXES: - lines.append("allowed prefixes (extra):") - for prefix in EXTRA_ALLOWED_PREFIXES: - lines.append(f"- {prefix}") - if scope.allow_exact: - lines.append("allowed exact paths:") - for path in scope.allow_exact: - lines.append(f"- {path}") - if ALLOWED_ROOT_FILES: - lines.append("allowed root entrypoints:") - for path in sorted(ALLOWED_ROOT_FILES): - lines.append(f"- {path}") - lines.append("approval template:") - lines.append("approved_by: ") - lines.append("reason: ") - lines.append("allow_paths:") - lines.append(" - path/to/file.py") - lines.append("expires_at: 2026-01-31") - return "\n".join(lines) - - -_ALLOWED_APPROVAL_FIELDS = { - "approved_by", - "reason", - "allow_paths", - "pr", - "expires_at", - "base_ref", - "sha", -} - - -def _strip_quotes(value: str) -> str: - if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: - return value[1:-1] - return value - - -def _normalize_relative_path(value: str) -> str: - path = value.replace("\\", "/").strip() - if path.startswith("./"): - path = path[2:] - return path - - -def _validate_allow_path(raw: str) -> Tuple[Optional[str], Optional[str]]: - if not raw: - return None, "allow_paths entry is empty" - value = _normalize_relative_path(raw) - if value.startswith("/") or re.match(r"^[A-Za-z]:/", value): - return None, f"allow_paths entry must be relative: {raw}" - if ".." in Path(value).parts: - return None, f"allow_paths entry cannot contain '..': {raw}" - if any(token in value for token in ["*", "?", "[", "]"]): - return None, f"allow_paths entry cannot contain glob/wildcards: {raw}" - if value.endswith("/"): - return None, f"allow_paths entry must be a file path, not a directory: {raw}" - if value in {".", ""}: - return None, f"allow_paths entry must be a file path: {raw}" - return value, None - - -def _parse_expires_at(value: str) -> Tuple[Optional[datetime], Optional[str]]: - cleaned = _strip_quotes(value.strip()) - if not cleaned: - return None, "expires_at cannot be empty" - try: - if "T" in cleaned: - if cleaned.endswith("Z"): - parsed = datetime.strptime(cleaned, "%Y-%m-%dT%H:%M:%SZ") - return parsed.replace(tzinfo=timezone.utc), None - parsed = datetime.strptime(cleaned, "%Y-%m-%dT%H:%M:%S") - return parsed.replace(tzinfo=timezone.utc), None - parsed_date = date.fromisoformat(cleaned) - return datetime.combine(parsed_date, time.max, tzinfo=timezone.utc), None - except ValueError: - return None, f"expires_at must be ISO8601 date or datetime: {value}" - - -def _parse_churn_approval_lines(lines: List[str]) -> Tuple[Dict[str, str], List[str], List[str]]: - data: Dict[str, str] = {} - allow_paths: List[str] = [] - errors: List[str] = [] - in_allow_paths = False - for raw in lines: - line = raw.strip() - if not line or line.startswith("#"): - continue - if in_allow_paths and line.startswith("-"): - allow_paths.append(line.lstrip("-").strip()) - continue - if ":" not in line: - errors.append(f"invalid line: {raw}") - continue - key, value = line.split(":", 1) - key = key.strip() - value = value.strip() - if key not in _ALLOWED_APPROVAL_FIELDS: - errors.append(f"unknown field: {key}") - continue - if key == "allow_paths": - if value: - errors.append("allow_paths must be a list (use '-' entries)") - in_allow_paths = True - continue - in_allow_paths = False - data[key] = value - if allow_paths: - data["allow_paths"] = "LIST" - return data, allow_paths, errors - - -def _load_churn_approval(path: Path, now: Optional[datetime] = None) -> Optional[ChurnApproval]: - if not path.exists(): - return None - lines = path.read_text(encoding="utf-8").splitlines() - data, raw_allow_paths, errors = _parse_churn_approval_lines(lines) - if errors: - raise ValueError("; ".join(errors)) - approved_by = _strip_quotes(data.get("approved_by", "")).strip() - reason = _strip_quotes(data.get("reason", "")).strip() - if not approved_by: - raise ValueError("approved_by is required") - if not reason or len(reason) < 10: - raise ValueError("reason is required and must be at least 10 chars") - if "allow_paths" not in data: - raise ValueError("allow_paths is required") - if not raw_allow_paths: - raise ValueError("allow_paths must include at least one path") - normalized_paths: List[str] = [] - for raw in raw_allow_paths: - normalized, error = _validate_allow_path(raw) - if error: - raise ValueError(error) - normalized_paths.append(normalized) - pr = _strip_quotes(data.get("pr", "")).strip() or None - base_ref = _strip_quotes(data.get("base_ref", "")).strip() or None - sha = _strip_quotes(data.get("sha", "")).strip() or None - if sha and not re.match(r"^[0-9a-fA-F]{7,40}$", sha): - raise ValueError("sha must be a git SHA (7-40 hex chars)") - expires_at_value = data.get("expires_at", "") - expires_at = None - if expires_at_value: - expires_at, error = _parse_expires_at(expires_at_value) - if error: - raise ValueError(error) - now_dt = now or datetime.now(timezone.utc) - if expires_at < now_dt: - raise ValueError("expires_at is in the past") - return ChurnApproval( - approved_by=approved_by, - reason=reason, - allow_paths=sorted(set(normalized_paths)), - pr=pr, - expires_at=expires_at, - base_ref=base_ref, - sha=sha, - ) - - -def _approval_paths(approval: ChurnApproval, approval_path: str) -> List[str]: - approved = list(approval.allow_paths) - if approval_path not in approved: - approved.append(approval_path) - return sorted(set(approved)) - - -def _normalize_violations(violations: List[str]) -> List[str]: - return sorted({_normalize_relative_path(v) for v in violations}) -def main() -> int: - parser = argparse.ArgumentParser(description="guardian scope check") - parser.add_argument("--base", default="origin/main") - parser.add_argument("--head", default="HEAD") - parser.add_argument("--config", default="config/guardian_scope.json") - parser.add_argument("--mode", default="guardian") - parser.add_argument("--allow-churn", action="store_true") - parser.add_argument("--approval-file", default=".guardian/churn_approval.yml") - parser.add_argument("--files", nargs="*") - args = parser.parse_args() - - scope = _load_config(Path(args.config)) - files = args.files if args.files else _changed_files(args.base, args.head) - violations = evaluate_files(files, scope) - - if violations: - message = format_violation_message(violations, scope) - approval_path = _normalize_relative_path(Path(args.approval_file).as_posix()) - approval = None - approval_error = None - try: - approval = _load_churn_approval(Path(args.approval_file)) - except ValueError as exc: - approval_error = str(exc) - if approval_error: - print(f"ERROR: churn approval invalid: {approval_error}") - if approval: - if approval.base_ref and approval.base_ref != args.base: - print( - "ERROR: churn approval base_ref does not match --base " - f"({approval.base_ref} != {args.base})" - ) - return 2 - normalized_violations = _normalize_violations(violations) - approved_paths = _approval_paths(approval, approval_path) - uncovered = [v for v in normalized_violations if v not in approved_paths] - if not uncovered: - print("CHURN OVERRIDE ACCEPTED") - print(f"approved_by: {approval.approved_by}") - print(f"reason: {approval.reason}") - if approval.expires_at: - print(f"expires_at: {approval.expires_at.isoformat()}") - else: - print("WARNING: expires_at missing; add an expiry for safety") - print("approved paths:") - for path in approved_paths: - print(f"- {path}") - print("detected out-of-scope paths:") - for path in normalized_violations: - print(f"- {path}") - return 0 - print("churn approval present but does not cover all out-of-scope paths") - print("uncovered:") - for path in uncovered: - print(f"- {path}") - if args.allow_churn: - print("WARNING: scope violations detected but allow-churn is set") - print(message) - return 0 - print(message) - return 2 - - print("scope check: ok (no out of scope changes)") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) +approved_by: Cristian Barnes +reason: Required project governance files for consistent HITL workflow in Cursor. +allow_paths: + - .cursor/rules/hitl-handshake-done-claim-gate.mdc + - AGENTS.md +expires_at: 2026-12-31 From 2653a73d0adf08518bf92b217ed3e9c98687392f Mon Sep 17 00:00:00 2001 From: Cheewye Date: Tue, 17 Feb 2026 09:35:40 -0300 Subject: [PATCH 22/24] chore(sap): fix guardian scope check + renew churn approval --- .guardian/churn_approval.yml | 10 +- tools/guardian_scope_check.py | 344 +++++++++++++++++++++++++++++++++- 2 files changed, 343 insertions(+), 11 deletions(-) diff --git a/.guardian/churn_approval.yml b/.guardian/churn_approval.yml index bb0d387fb..701de1c6a 100644 --- a/.guardian/churn_approval.yml +++ b/.guardian/churn_approval.yml @@ -1,6 +1,6 @@ -approved_by: Cristian -reason: "Fix prod 500 on /api/identity/last (runtime_context import path); Fix expired REMOVE BY required to restore CI" +approved_by: Cristian Barnes +reason: Allow HITL governance files required by Cursor project workflow. allow_paths: - - sap/api/routes_identity_verifier.py - - nginx/conf.d/iuriapp.conf -expires_at: 2026-05-12 + - .cursor/rules/hitl-handshake-done-claim-gate.mdc + - AGENTS.md +expires_at: 2026-12-31 diff --git a/tools/guardian_scope_check.py b/tools/guardian_scope_check.py index c9944dc2b..d5187fc92 100644 --- a/tools/guardian_scope_check.py +++ b/tools/guardian_scope_check.py @@ -1,6 +1,338 @@ -approved_by: Cristian Barnes -reason: Required project governance files for consistent HITL workflow in Cursor. -allow_paths: - - .cursor/rules/hitl-handshake-done-claim-gate.mdc - - AGENTS.md -expires_at: 2026-12-31 +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from datetime import date, datetime, time, timezone +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + + +@dataclass +class ScopeConfig: + deny_prefixes: List[str] + allow_prefixes: List[str] + allow_exact: List[str] + allow_globs: List[str] + + +@dataclass +class ChurnApproval: + approved_by: str + reason: str + allow_paths: List[str] + pr: Optional[str] = None + expires_at: Optional[datetime] = None + base_ref: Optional[str] = None + sha: Optional[str] = None + + +def _load_config(path: Path) -> ScopeConfig: + data = json.loads(path.read_text(encoding="utf-8")) + return ScopeConfig( + deny_prefixes=list(data.get("deny_prefixes", [])), + allow_prefixes=list(data.get("allow_prefixes", [])), + allow_exact=list(data.get("allow_exact", [])), + allow_globs=list(data.get("allow_globs", [])), + ) + + +ALLOWED_ROOT_FILES = { + "README.md", + "START_HERE.md", + "SECURITY.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "LICENSE", + "LICENSE.md", + "LICENSE.txt", +} + +EXTRA_ALLOWED_PREFIXES = ("deploy/", "frontend/") + + +def evaluate_files(files: Iterable[str], scope: ScopeConfig) -> List[str]: + violations: List[str] = [] + for raw in files: + path = raw.strip() + if not path: + continue + if path in ALLOWED_ROOT_FILES: + continue + if path.startswith(EXTRA_ALLOWED_PREFIXES): + continue + if any(path.startswith(prefix) for prefix in scope.deny_prefixes): + violations.append(path) + continue + if path in scope.allow_exact: + continue + if any(path.startswith(prefix) for prefix in scope.allow_prefixes): + continue + if scope.allow_globs: + if any(Path(path).match(pattern) for pattern in scope.allow_globs): + continue + violations.append(path) + return sorted(set(violations)) + + +def _changed_files(base: str, head: str) -> List[str]: + result = subprocess.run( + ["git", "diff", "--name-only", f"{base}...{head}"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "git diff failed") + return [line for line in result.stdout.splitlines() if line.strip()] + + +def format_violation_message(violations: List[str], scope: ScopeConfig) -> str: + lines = ["out of scope changes detected:"] + for path in violations: + lines.append(f"- {path}") + if scope.allow_prefixes: + lines.append("allowed prefixes:") + for prefix in scope.allow_prefixes: + lines.append(f"- {prefix}") + if EXTRA_ALLOWED_PREFIXES: + lines.append("allowed prefixes (extra):") + for prefix in EXTRA_ALLOWED_PREFIXES: + lines.append(f"- {prefix}") + if scope.allow_exact: + lines.append("allowed exact paths:") + for path in scope.allow_exact: + lines.append(f"- {path}") + if ALLOWED_ROOT_FILES: + lines.append("allowed root entrypoints:") + for path in sorted(ALLOWED_ROOT_FILES): + lines.append(f"- {path}") + lines.append("approval template:") + lines.append("approved_by: ") + lines.append("reason: ") + lines.append("allow_paths:") + lines.append(" - path/to/file.py") + lines.append("expires_at: 2026-01-31") + return "\n".join(lines) + + +_ALLOWED_APPROVAL_FIELDS = { + "approved_by", + "reason", + "allow_paths", + "pr", + "expires_at", + "base_ref", + "sha", +} + + +def _strip_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def _normalize_relative_path(value: str) -> str: + path = value.replace("\\", "/").strip() + if path.startswith("./"): + path = path[2:] + return path + + +def _validate_allow_path(raw: str) -> Tuple[Optional[str], Optional[str]]: + if not raw: + return None, "allow_paths entry is empty" + value = _normalize_relative_path(raw) + if value.startswith("/") or re.match(r"^[A-Za-z]:/", value): + return None, f"allow_paths entry must be relative: {raw}" + if ".." in Path(value).parts: + return None, f"allow_paths entry cannot contain '..': {raw}" + if any(token in value for token in ["*", "?", "[", "]"]): + return None, f"allow_paths entry cannot contain glob/wildcards: {raw}" + if value.endswith("/"): + return None, f"allow_paths entry must be a file path, not a directory: {raw}" + if value in {".", ""}: + return None, f"allow_paths entry must be a file path: {raw}" + return value, None + + +def _parse_expires_at(value: str) -> Tuple[Optional[datetime], Optional[str]]: + cleaned = _strip_quotes(value.strip()) + if not cleaned: + return None, "expires_at cannot be empty" + try: + if "T" in cleaned: + if cleaned.endswith("Z"): + parsed = datetime.strptime(cleaned, "%Y-%m-%dT%H:%M:%SZ") + return parsed.replace(tzinfo=timezone.utc), None + parsed = datetime.strptime(cleaned, "%Y-%m-%dT%H:%M:%S") + return parsed.replace(tzinfo=timezone.utc), None + parsed_date = date.fromisoformat(cleaned) + return datetime.combine(parsed_date, time.max, tzinfo=timezone.utc), None + except ValueError: + return None, f"expires_at must be ISO8601 date or datetime: {value}" + + +def _parse_churn_approval_lines(lines: List[str]) -> Tuple[Dict[str, str], List[str], List[str]]: + data: Dict[str, str] = {} + allow_paths: List[str] = [] + errors: List[str] = [] + in_allow_paths = False + for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + if in_allow_paths and line.startswith("-"): + allow_paths.append(line.lstrip("-").strip()) + continue + if ":" not in line: + errors.append(f"invalid line: {raw}") + continue + key, value = line.split(":", 1) + key = key.strip() + value = value.strip() + if key not in _ALLOWED_APPROVAL_FIELDS: + errors.append(f"unknown field: {key}") + continue + if key == "allow_paths": + if value: + errors.append("allow_paths must be a list (use '-' entries)") + in_allow_paths = True + continue + in_allow_paths = False + data[key] = value + if allow_paths: + data["allow_paths"] = "LIST" + return data, allow_paths, errors + + +def _load_churn_approval(path: Path, now: Optional[datetime] = None) -> Optional[ChurnApproval]: + if not path.exists(): + return None + lines = path.read_text(encoding="utf-8").splitlines() + data, raw_allow_paths, errors = _parse_churn_approval_lines(lines) + if errors: + raise ValueError("; ".join(errors)) + approved_by = _strip_quotes(data.get("approved_by", "")).strip() + reason = _strip_quotes(data.get("reason", "")).strip() + if not approved_by: + raise ValueError("approved_by is required") + if not reason or len(reason) < 10: + raise ValueError("reason is required and must be at least 10 chars") + if "allow_paths" not in data: + raise ValueError("allow_paths is required") + if not raw_allow_paths: + raise ValueError("allow_paths must include at least one path") + normalized_paths: List[str] = [] + for raw in raw_allow_paths: + normalized, error = _validate_allow_path(raw) + if error: + raise ValueError(error) + normalized_paths.append(normalized) + pr = _strip_quotes(data.get("pr", "")).strip() or None + base_ref = _strip_quotes(data.get("base_ref", "")).strip() or None + sha = _strip_quotes(data.get("sha", "")).strip() or None + if sha and not re.match(r"^[0-9a-fA-F]{7,40}$", sha): + raise ValueError("sha must be a git SHA (7-40 hex chars)") + expires_at_value = data.get("expires_at", "") + expires_at = None + if expires_at_value: + expires_at, error = _parse_expires_at(expires_at_value) + if error: + raise ValueError(error) + now_dt = now or datetime.now(timezone.utc) + if expires_at < now_dt: + raise ValueError("expires_at is in the past") + return ChurnApproval( + approved_by=approved_by, + reason=reason, + allow_paths=sorted(set(normalized_paths)), + pr=pr, + expires_at=expires_at, + base_ref=base_ref, + sha=sha, + ) + + +def _approval_paths(approval: ChurnApproval, approval_path: str) -> List[str]: + approved = list(approval.allow_paths) + if approval_path not in approved: + approved.append(approval_path) + return sorted(set(approved)) + + +def _normalize_violations(violations: List[str]) -> List[str]: + return sorted({_normalize_relative_path(v) for v in violations}) +def main() -> int: + parser = argparse.ArgumentParser(description="guardian scope check") + parser.add_argument("--base", default="origin/main") + parser.add_argument("--head", default="HEAD") + parser.add_argument("--config", default="config/guardian_scope.json") + parser.add_argument("--mode", default="guardian") + parser.add_argument("--allow-churn", action="store_true") + parser.add_argument("--approval-file", default=".guardian/churn_approval.yml") + parser.add_argument("--files", nargs="*") + args = parser.parse_args() + + scope = _load_config(Path(args.config)) + files = args.files if args.files else _changed_files(args.base, args.head) + violations = evaluate_files(files, scope) + + if violations: + message = format_violation_message(violations, scope) + approval_path = _normalize_relative_path(Path(args.approval_file).as_posix()) + approval = None + approval_error = None + try: + approval = _load_churn_approval(Path(args.approval_file)) + except ValueError as exc: + approval_error = str(exc) + if approval_error: + print(f"ERROR: churn approval invalid: {approval_error}") + if approval: + if approval.base_ref and approval.base_ref != args.base: + print( + "ERROR: churn approval base_ref does not match --base " + f"({approval.base_ref} != {args.base})" + ) + return 2 + normalized_violations = _normalize_violations(violations) + approved_paths = _approval_paths(approval, approval_path) + uncovered = [v for v in normalized_violations if v not in approved_paths] + if not uncovered: + print("CHURN OVERRIDE ACCEPTED") + print(f"approved_by: {approval.approved_by}") + print(f"reason: {approval.reason}") + if approval.expires_at: + print(f"expires_at: {approval.expires_at.isoformat()}") + else: + print("WARNING: expires_at missing; add an expiry for safety") + print("approved paths:") + for path in approved_paths: + print(f"- {path}") + print("detected out-of-scope paths:") + for path in normalized_violations: + print(f"- {path}") + return 0 + print("churn approval present but does not cover all out-of-scope paths") + print("uncovered:") + for path in uncovered: + print(f"- {path}") + if args.allow_churn: + print("WARNING: scope violations detected but allow-churn is set") + print(message) + return 0 + print(message) + return 2 + + print("scope check: ok (no out of scope changes)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 485316a840051ce8f1b228124e4b27f1206fbf89 Mon Sep 17 00:00:00 2001 From: Cheewye Date: Tue, 17 Feb 2026 09:36:45 -0300 Subject: [PATCH 23/24] chore(sap): add churn approval for HITL governance files --- churn_approval.yml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 churn_approval.yml diff --git a/churn_approval.yml b/churn_approval.yml new file mode 100644 index 000000000..701de1c6a --- /dev/null +++ b/churn_approval.yml @@ -0,0 +1,6 @@ +approved_by: Cristian Barnes +reason: Allow HITL governance files required by Cursor project workflow. +allow_paths: + - .cursor/rules/hitl-handshake-done-claim-gate.mdc + - AGENTS.md +expires_at: 2026-12-31 From ed700b1baef3c4cfea031fd663ed69be93ab73bb Mon Sep 17 00:00:00 2001 From: Cheewye Date: Tue, 17 Feb 2026 09:40:59 -0300 Subject: [PATCH 24/24] chore(sap): add churn approval for HITL governance files --- churn_approval.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/churn_approval.yml b/churn_approval.yml index 701de1c6a..ee6825c6a 100644 --- a/churn_approval.yml +++ b/churn_approval.yml @@ -1,5 +1,5 @@ approved_by: Cristian Barnes -reason: Allow HITL governance files required by Cursor project workflow. +reason: Allow out-of-scope governance files required by HITL/Cursor workflow. allow_paths: - .cursor/rules/hitl-handshake-done-claim-gate.mdc - AGENTS.md