Add game data capture vertical slice

This commit is contained in:
jb
2026-07-31 14:34:24 -04:00
parent 5859f84034
commit fee4dc3a78
75 changed files with 12654 additions and 5 deletions
@@ -0,0 +1,6 @@
"""Contracts for the local SHD.build game-data capture utility.
Importing this package does not inspect the environment or initialize runtime resources.
"""
__version__ = "0.1.0"
@@ -0,0 +1,6 @@
"""Module entry point for ``python -m game_data_capture``."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,163 @@
"""CLI contracts for the capture utility."""
from __future__ import annotations
import argparse
import json
import sys
from collections.abc import Sequence
from dataclasses import replace
from pathlib import Path
from game_data_capture.config import AppConfig, ConfigError, ServerConfig, load_config
from game_data_capture.doctor import doctor_as_dict, run_doctor
from game_data_capture.paths import PathConfigurationError, RuntimePaths, resolve_runtime_paths
from game_data_capture.runtime import CaptureRuntime, RuntimeFailure
EXIT_OK = 0
EXIT_CONFIG_ERROR = 2
EXIT_UNIMPLEMENTED = 3
EXIT_RUNTIME_ERROR = 4
def _add_config_argument(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--config", type=Path, help="absolute path to a TOML configuration file")
def _port_argument(raw_value: str) -> int:
try:
port = int(raw_value)
except ValueError as error:
raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535") from error
if not 1024 <= port <= 65535:
raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535")
return port
def build_parser() -> argparse.ArgumentParser:
"""Build the stable command surface without initializing runtime resources."""
parser = argparse.ArgumentParser(
prog="shd-capture", description="Local SHD.build game-data capture"
)
subcommands = parser.add_subparsers(dest="command", required=True)
doctor = subcommands.add_parser("doctor", help="report local prerequisite status")
_add_config_argument(doctor)
doctor.add_argument("--json", action="store_true", help="emit JSON diagnostics")
serve = subcommands.add_parser("serve", help="start the localhost review service")
_add_config_argument(serve)
serve.add_argument("--host", help="loopback IP literal override")
serve.add_argument("--port", type=_port_argument, help="TCP port override")
watch = subcommands.add_parser("watch", help="watch the Wayland clipboard for images")
_add_config_argument(watch)
watch_modes = watch.add_mutually_exclusive_group()
watch_modes.add_argument("--arm", action="store_true", help="arm live clipboard capture")
watch_modes.add_argument(
"--dry-run", action="store_true", help="validate the capture runtime plan"
)
ingest_image = subcommands.add_parser("ingest-image", help="stage one image for capture")
_add_config_argument(ingest_image)
ingest_image.add_argument("image", type=Path)
import_video = subcommands.add_parser("import-video", help="stage a video for frame extraction")
_add_config_argument(import_video)
import_video.add_argument("video", type=Path)
export = subcommands.add_parser("export", help="write a versioned ISAC handoff")
_add_config_argument(export)
export.add_argument("destination", type=Path)
verify = subcommands.add_parser("verify", help="verify an evidence archive")
_add_config_argument(verify)
verify.add_argument("archive", type=Path)
evaluate = subcommands.add_parser("eval", help="evaluate local OCR and classification")
_add_config_argument(evaluate)
evaluate.add_argument("corpus", type=Path)
purge = subcommands.add_parser("purge", help="purge retained local data")
_add_config_argument(purge)
purge.add_argument("--confirm", action="store_true", help="confirm a future destructive purge")
return parser
def _load_cli_config(config_path: Path | None) -> tuple[AppConfig, RuntimePaths]:
paths = resolve_runtime_paths()
return load_config(config_path=config_path, paths=paths), paths
def _doctor(arguments: argparse.Namespace) -> int:
config, paths = _load_cli_config(arguments.config)
diagnostics = doctor_as_dict(run_doctor(config, paths))
if arguments.json:
print(json.dumps(diagnostics, separators=(",", ":"), sort_keys=True))
else:
for diagnostic in diagnostics:
print(f"{diagnostic['name']}: {diagnostic['status']} ({diagnostic['detail']})")
return EXIT_OK
def _serve_contract(arguments: argparse.Namespace) -> int:
config, paths = _load_cli_config(arguments.config)
host = arguments.host if arguments.host is not None else config.server.host
port = arguments.port if arguments.port is not None else config.server.port
configured = replace(config, server=ServerConfig(host=host, port=port))
CaptureRuntime(configured, paths).serve()
return EXIT_OK
def _unimplemented(command: str) -> int:
print(
f"{command}: subsystem is not implemented; requires gdc-runtime-integration",
file=sys.stderr,
)
return EXIT_UNIMPLEMENTED
def _watch(arguments: argparse.Namespace) -> int:
config, paths = _load_cli_config(arguments.config)
if not arguments.arm and not arguments.dry_run:
return _unimplemented("watch")
runtime = CaptureRuntime(config, paths)
if arguments.dry_run:
runtime.validate_plan()
print("watch dry-run: capture runtime plan is valid")
return EXIT_OK
runtime.watch()
return EXIT_OK
def _ingest_image(arguments: argparse.Namespace) -> int:
config, paths = _load_cli_config(arguments.config)
CaptureRuntime(config, paths).ingest_image(arguments.image)
print("image capture stored")
return EXIT_OK
def main(argv: Sequence[str] | None = None) -> int:
"""Run one CLI command and return a stable process exit status."""
parser = build_parser()
arguments = parser.parse_args(argv)
try:
if arguments.command == "doctor":
return _doctor(arguments)
if arguments.command == "serve":
return _serve_contract(arguments)
if arguments.command == "watch":
return _watch(arguments)
if arguments.command == "ingest-image":
return _ingest_image(arguments)
_load_cli_config(arguments.config)
return _unimplemented(arguments.command)
except (ConfigError, PathConfigurationError) as error:
print(f"configuration error: {error}", file=sys.stderr)
return EXIT_CONFIG_ERROR
except RuntimeFailure:
print("capture runtime failed", file=sys.stderr)
return EXIT_RUNTIME_ERROR
@@ -0,0 +1,216 @@
"""Typed, bounded configuration for the capture runtime.
Configuration parsing reads an explicit TOML file only when a caller invokes
``load_config``. Importing this module has no filesystem or environment effects.
"""
from __future__ import annotations
import ipaddress
import os
import re
import tomllib
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from game_data_capture.paths import RuntimePaths, resolve_runtime_paths
_ENVIRONMENT_NAME = re.compile(r"^[A-Z_][A-Z0-9_]{0,63}$")
class ConfigError(ValueError):
"""A bounded, safe-to-display configuration error."""
def _error(field_name: str, constraint: str) -> ConfigError:
return ConfigError(f"Invalid configuration: {field_name} {constraint}.")
def _bounded_int(value: object, field_name: str, minimum: int, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
raise _error(field_name, f"must be an integer from {minimum} to {maximum}")
return value
def _mapping(value: object, field_name: str) -> Mapping[str, object]:
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
raise _error(field_name, "must be a table")
return value
def _only_keys(section: Mapping[str, object], section_name: str, allowed: set[str]) -> None:
if set(section) - allowed:
raise _error(section_name, "contains an unsupported setting")
def _string(value: object, field_name: str, maximum: int) -> str:
if not isinstance(value, str) or not value or len(value) > maximum:
raise _error(field_name, f"must be a non-empty string up to {maximum} characters")
return value
@dataclass(frozen=True, slots=True)
class ServerConfig:
host: str = "127.0.0.1"
port: int = 8765
def __post_init__(self) -> None:
try:
address = ipaddress.ip_address(self.host)
except ValueError as error:
raise _error("server.host", "must be a loopback IP literal") from error
if not address.is_loopback:
raise _error("server.host", "must be a loopback IP literal")
_bounded_int(self.port, "server.port", 1024, 65535)
@dataclass(frozen=True, slots=True)
class LifecycleConfig:
"""One-instance settings consumed later by the runtime integration layer."""
instance_name: str = "shd-capture"
lock_timeout_seconds: int = 5
shutdown_timeout_seconds: int = 30
def __post_init__(self) -> None:
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", self.instance_name):
raise _error(
"lifecycle.instance_name", "must use lowercase letters, digits, and hyphens"
)
_bounded_int(self.lock_timeout_seconds, "lifecycle.lock_timeout_seconds", 0, 60)
_bounded_int(self.shutdown_timeout_seconds, "lifecycle.shutdown_timeout_seconds", 1, 300)
@dataclass(frozen=True, slots=True)
class CaptureLimits:
max_image_bytes: int = 32 * 1024 * 1024
max_video_bytes: int = 4 * 1024 * 1024 * 1024
max_queue_items: int = 64
max_ocr_text_characters: int = 20_000
def __post_init__(self) -> None:
_bounded_int(self.max_image_bytes, "limits.max_image_bytes", 1, 64 * 1024 * 1024)
_bounded_int(self.max_video_bytes, "limits.max_video_bytes", 1, 16 * 1024 * 1024 * 1024)
_bounded_int(self.max_queue_items, "limits.max_queue_items", 1, 256)
_bounded_int(self.max_ocr_text_characters, "limits.max_ocr_text_characters", 1, 100_000)
@dataclass(frozen=True, slots=True)
class OpenAIConfig:
api_key_env: str = "OPENAI_API_KEY"
model: str = "gpt-4.1-mini"
def __post_init__(self) -> None:
if not _ENVIRONMENT_NAME.fullmatch(self.api_key_env):
raise _error("openai.api_key_env", "must be an environment variable name")
_string(self.model, "openai.model", 128)
def is_configured(self, environment: Mapping[str, str] | None = None) -> bool:
env = os.environ if environment is None else environment
return bool(env.get(self.api_key_env))
@dataclass(frozen=True, slots=True)
class AppConfig:
server: ServerConfig = field(default_factory=ServerConfig)
lifecycle: LifecycleConfig = field(default_factory=LifecycleConfig)
limits: CaptureLimits = field(default_factory=CaptureLimits)
openai: OpenAIConfig = field(default_factory=OpenAIConfig)
def _section(document: Mapping[str, object], name: str, allowed: set[str]) -> Mapping[str, object]:
value = document.get(name, {})
section = _mapping(value, name)
_only_keys(section, name, allowed)
return section
def parse_config(document: Mapping[str, object]) -> AppConfig:
"""Parse a TOML-shaped mapping with an allowlist of configuration keys."""
_only_keys(document, "root", {"server", "lifecycle", "limits", "openai"})
server = _section(document, "server", {"host", "port"})
lifecycle = _section(
document, "lifecycle", {"instance_name", "lock_timeout_seconds", "shutdown_timeout_seconds"}
)
limits = _section(
document,
"limits",
{"max_image_bytes", "max_video_bytes", "max_queue_items", "max_ocr_text_characters"},
)
openai = _section(document, "openai", {"api_key_env", "model"})
return AppConfig(
server=ServerConfig(
host=_string(server.get("host", "127.0.0.1"), "server.host", 64),
port=_bounded_int(server.get("port", 8765), "server.port", 1024, 65535),
),
lifecycle=LifecycleConfig(
instance_name=_string(
lifecycle.get("instance_name", "shd-capture"), "lifecycle.instance_name", 32
),
lock_timeout_seconds=_bounded_int(
lifecycle.get("lock_timeout_seconds", 5), "lifecycle.lock_timeout_seconds", 0, 60
),
shutdown_timeout_seconds=_bounded_int(
lifecycle.get("shutdown_timeout_seconds", 30),
"lifecycle.shutdown_timeout_seconds",
1,
300,
),
),
limits=CaptureLimits(
max_image_bytes=_bounded_int(
limits.get("max_image_bytes", 32 * 1024 * 1024),
"limits.max_image_bytes",
1,
64 * 1024 * 1024,
),
max_video_bytes=_bounded_int(
limits.get("max_video_bytes", 4 * 1024 * 1024 * 1024),
"limits.max_video_bytes",
1,
16 * 1024 * 1024 * 1024,
),
max_queue_items=_bounded_int(
limits.get("max_queue_items", 64), "limits.max_queue_items", 1, 256
),
max_ocr_text_characters=_bounded_int(
limits.get("max_ocr_text_characters", 20_000),
"limits.max_ocr_text_characters",
1,
100_000,
),
),
openai=OpenAIConfig(
api_key_env=_string(
openai.get("api_key_env", "OPENAI_API_KEY"), "openai.api_key_env", 64
),
model=_string(openai.get("model", "gpt-4.1-mini"), "openai.model", 128),
),
)
def load_config(config_path: Path | None = None, paths: RuntimePaths | None = None) -> AppConfig:
"""Read optional TOML configuration from the XDG config location.
An absent file selects safe defaults. Parse failures intentionally omit source values and paths.
"""
selected_path = (
config_path if config_path is not None else (paths or resolve_runtime_paths()).config_file
)
if not selected_path.is_absolute() or len(str(selected_path)) > 240:
raise _error("config path", "must be an absolute path within 240 characters")
if not selected_path.exists():
return AppConfig()
try:
with selected_path.open("rb") as handle:
document: Any = tomllib.load(handle)
except (OSError, tomllib.TOMLDecodeError) as error:
raise _error("config file", "could not be read") from error
if not isinstance(document, dict):
raise _error("root", "must be a table")
return parse_config(document)
@@ -0,0 +1,84 @@
"""Side-effect-limited local diagnostics for capture prerequisites."""
from __future__ import annotations
import importlib.util
import shutil
from collections.abc import Mapping
from dataclasses import asdict, dataclass
from os import environ
from game_data_capture.config import AppConfig
from game_data_capture.paths import (
PathConfigurationError,
RuntimePaths,
prepare_private_directories,
)
@dataclass(frozen=True, slots=True)
class Diagnostic:
name: str
status: str
detail: str
def _executable_diagnostic(name: str) -> Diagnostic:
return Diagnostic(
name=name,
status="available" if shutil.which(name) else "unavailable",
detail="executable found on PATH" if shutil.which(name) else "executable not found on PATH",
)
def _gpu_diagnostic(environment: Mapping[str, str]) -> Diagnostic:
configured_provider = environment.get("GDC_OCR_PROVIDER")
if configured_provider:
return Diagnostic(
"gpu_provider", "unknown", "provider configured; runtime probe is deferred"
)
if importlib.util.find_spec("torch") is not None:
return Diagnostic(
"gpu_provider", "unknown", "torch is installed; runtime probe is deferred"
)
return Diagnostic("gpu_provider", "unknown", "no provider selected; runtime probe is deferred")
def _storage_diagnostic(paths: RuntimePaths) -> Diagnostic:
try:
prepare_private_directories(paths)
except (OSError, PathConfigurationError):
return Diagnostic(
"storage_permissions", "unavailable", "private XDG directories are not usable"
)
return Diagnostic(
"storage_permissions", "available", "private XDG directories use owner-only access"
)
def run_doctor(
config: AppConfig, paths: RuntimePaths, environment: Mapping[str, str] | None = None
) -> tuple[Diagnostic, ...]:
"""Inspect local prerequisites without launching processes or making network calls."""
env = environ if environment is None else environment
return (
_executable_diagnostic("niri"),
_executable_diagnostic("wl-paste"),
_executable_diagnostic("ffmpeg"),
_gpu_diagnostic(env),
_storage_diagnostic(paths),
Diagnostic(
"openai",
"configured" if config.openai.is_configured(env) else "missing",
"environment variable is present"
if config.openai.is_configured(env)
else "environment variable is absent",
),
)
def doctor_as_dict(diagnostics: tuple[Diagnostic, ...]) -> list[dict[str, str]]:
"""Return diagnostics in a serialization-ready form without paths or secret values."""
return [asdict(diagnostic) for diagnostic in diagnostics]
@@ -0,0 +1,43 @@
"""Side-effect-free domain contracts for the game data capture utility."""
from .state_machines import InvalidStateTransition, StateMachine, require_transition
from .taxonomy import (
ABSTENTION_REASONS,
GEAR_SLOTS,
RECORD_TYPES_BY_VIEW,
SCREEN_VIEWS,
AbstentionReason,
Family,
GearSlot,
RecordType,
)
from .validation import (
ContractValidationError,
ensure_supported_version,
validate_archive_document,
validate_archive_references,
validate_classification,
validate_handoff_document,
validate_handoff_references,
)
__all__ = [
"ABSTENTION_REASONS",
"GEAR_SLOTS",
"RECORD_TYPES_BY_VIEW",
"SCREEN_VIEWS",
"AbstentionReason",
"ContractValidationError",
"Family",
"GearSlot",
"InvalidStateTransition",
"RecordType",
"StateMachine",
"ensure_supported_version",
"require_transition",
"validate_archive_document",
"validate_archive_references",
"validate_classification",
"validate_handoff_document",
"validate_handoff_references",
]
@@ -0,0 +1,118 @@
"""Typed Python views of the portable contract structures."""
import typing
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
type MissingReason = typing.Literal[
"not_visible",
"occluded",
"unreadable",
"not_present_on_screen",
"unsupported",
"ambiguous",
"extraction_failed",
"not_applicable",
"redacted",
]
class SourceEngine(typing.TypedDict):
kind: typing.Literal["local_ocr", "openai_vision", "classifier", "manual", "video_decoder"]
engine_id: str
engine_version: str
class NormalizedRegion(typing.TypedDict):
coordinate_space: typing.Literal["normalized"]
x: float
y: float
width: float
height: float
class PixelRegion(typing.TypedDict):
coordinate_space: typing.Literal["pixels"]
x: int
y: int
width: int
height: int
type BoundingRegion = NormalizedRegion | PixelRegion
class ValueState(typing.TypedDict):
raw_text: str | None
normalized_value: JsonValue
missing_reason: MissingReason | None
class Correction(typing.TypedDict):
correction_id: str
corrected_at: str
actor: typing.Literal["local_user", "system_reprocess"]
reason: str
before: ValueState
after: ValueState
class EvidenceValue(ValueState):
evidence_id: str
field_path: str
confidence: float
source_engine: SourceEngine
image_artifact_id: str
image_sha256: str
region: BoundingRegion
correction_history: list[Correction]
class FieldClaim(typing.TypedDict):
path: str
value: JsonValue
missing_reason: MissingReason | None
evidence_refs: list[str]
uncertainty_note: str | None
class ScreenshotClassification(typing.TypedDict):
family: typing.Literal[
"weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"
]
view: str
gear_slot: (
typing.Literal["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"] | None
)
record_types: list[str]
confidence: float
abstention_reason: str | None
class ObservedContext(typing.TypedDict):
level: FieldClaim
quality: FieldClaim
tier: FieldClaim
upgrade_state: FieldClaim
class ObservedItemInstance(typing.TypedDict):
"""Values shown for one configured item; they are not reusable definitions."""
instance_id: str
family: str
source_screen_ids: list[str]
observed_context: ObservedContext
fields: list[FieldClaim]
class CanonicalDefinitionCandidate(typing.TypedDict):
"""Evidence-backed reusable claims, kept separate from the observed item."""
candidate_id: str
record_type: str
source_instance_ids: list[str]
evidence_refs: list[str]
fields: list[FieldClaim]
completeness: typing.Literal["complete", "incomplete", "unsupported"]
relationship_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
mechanic_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
@@ -0,0 +1,214 @@
"""Trust-boundary ports. Implementations belong to downstream tasks."""
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from math import isfinite
from typing import Protocol
@dataclass(frozen=True, slots=True)
class ResourceBudget:
deadline_monotonic: float | None
max_input_bytes: int
max_output_bytes: int
max_items: int
def __post_init__(self) -> None:
limits: tuple[tuple[str, object], ...] = (
("max_input_bytes", self.max_input_bytes),
("max_output_bytes", self.max_output_bytes),
("max_items", self.max_items),
)
for name, value in limits:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{name} must be a non-negative integer")
deadline: object = self.deadline_monotonic
if deadline is not None:
if isinstance(deadline, bool) or not isinstance(deadline, int | float):
raise ValueError("deadline_monotonic must be a finite number or null")
if not isfinite(deadline):
raise ValueError("deadline_monotonic must be finite or null")
class CancellationToken(Protocol):
def is_cancelled(self) -> bool: ...
def raise_if_cancelled(self) -> None: ...
class Clock(Protocol):
def now_utc(self) -> datetime: ...
def monotonic(self) -> float: ...
class IdGenerator(Protocol):
def new_id(self) -> str: ...
@dataclass(frozen=True, slots=True)
class BlobReceipt:
sha256: str
byte_size: int
media_type: str
class BlobStorage(Protocol):
"""Stores immutable bytes by digest without exposing filesystem paths."""
def put(
self,
chunks: Iterable[bytes],
*,
expected_sha256: str | None,
media_type: str,
budget: ResourceBudget,
cancellation: CancellationToken,
) -> BlobReceipt: ...
def read(
self,
sha256: str,
*,
budget: ResourceBudget,
cancellation: CancellationToken,
) -> Iterable[bytes]: ...
def exists(self, sha256: str) -> bool: ...
class Repository[RecordT](Protocol):
def get(self, record_id: str) -> RecordT | None: ...
def add(self, record: RecordT) -> None: ...
def replace(self, record: RecordT, *, expected_revision: int) -> None: ...
type CaptureRepository = Repository[object]
type JobRepository = Repository[object]
type ExtractionRepository = Repository[object]
type GroupingRepository = Repository[object]
type ReviewRepository = Repository[object]
type ExportRepository = Repository[object]
type ErrorRepository = Repository[object]
class UnitOfWork(Protocol):
captures: CaptureRepository
jobs: JobRepository
extractions: ExtractionRepository
groupings: GroupingRepository
reviews: ReviewRepository
exports: ExportRepository
errors: ErrorRepository
def commit(self) -> None: ...
def rollback(self) -> None: ...
@dataclass(frozen=True, slots=True)
class ImageInput:
artifact_id: str
sha256: str
media_type: str
byte_size: int
@dataclass(frozen=True, slots=True)
class EngineResult:
engine_id: str
engine_version: str
payload: Mapping[str, object]
class OcrEngine(Protocol):
def extract(
self,
image: ImageInput,
*,
regions: Sequence[Mapping[str, int | float | str]],
budget: ResourceBudget,
cancellation: CancellationToken,
) -> EngineResult: ...
class VisionFallback(Protocol):
"""Accepts bounded artifacts and never receives credentials in a request."""
def extract(
self,
images: Sequence[ImageInput],
*,
response_schema: Mapping[str, object],
request_fingerprint: str,
budget: ResourceBudget,
cancellation: CancellationToken,
) -> EngineResult: ...
@dataclass(frozen=True, slots=True)
class SubprocessRequest:
tool: SubprocessTool
arguments: tuple[str, ...]
environment: Mapping[SubprocessEnvironmentKey, str]
stdin: bytes | None
budget: ResourceBudget
class SubprocessTool(StrEnum):
WL_PASTE = "wl-paste"
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"
class SubprocessEnvironmentKey(StrEnum):
WAYLAND_DISPLAY = "WAYLAND_DISPLAY"
XDG_RUNTIME_DIR = "XDG_RUNTIME_DIR"
PATH = "PATH"
HOME = "HOME"
LANG = "LANG"
LC_ALL = "LC_ALL"
@dataclass(frozen=True, slots=True)
class SubprocessResult:
return_code: int
stdout: bytes
stderr: bytes
timed_out: bool
cancelled: bool
class SubprocessRunner(Protocol):
"""Runs an argument array without a shell and with an explicit environment."""
def run(
self,
request: SubprocessRequest,
*,
cancellation: CancellationToken,
) -> SubprocessResult: ...
@dataclass(frozen=True, slots=True)
class FrameScore:
timestamp_ms: int
stability: float
sharpness: float
ui_coverage: float
class FrameScorer(Protocol):
def score(
self,
frames: Sequence[ImageInput],
*,
budget: ResourceBudget,
cancellation: CancellationToken,
) -> Sequence[FrameScore]: ...
@@ -0,0 +1,211 @@
"""State vocabularies and allowed transitions for recoverable workflows."""
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
from types import MappingProxyType
from typing import Final
class CaptureState(StrEnum):
RECEIVED = "received"
SPOOLING = "spooling"
STORED = "stored"
DEDUPLICATED = "deduplicated"
QUEUED = "queued"
FAILED = "failed"
CANCELLED = "cancelled"
class JobState(StrEnum):
QUEUED = "queued"
RUNNING = "running"
RETRY_WAIT = "retry_wait"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
class ExtractionState(StrEnum):
PENDING = "pending"
LOCAL_PROCESSING = "local_processing"
FALLBACK_PENDING = "fallback_pending"
FALLBACK_PROCESSING = "fallback_processing"
CANDIDATE_READY = "candidate_ready"
ABSTAINED = "abstained"
FAILED = "failed"
CANCELLED = "cancelled"
class GroupingState(StrEnum):
UNGROUPED = "ungrouped"
PROPOSED = "proposed"
GROUPED = "grouped"
SPLIT = "split"
SUPERSEDED = "superseded"
REJECTED = "rejected"
CANCELLED = "cancelled"
class ReviewState(StrEnum):
PENDING = "pending"
IN_REVIEW = "in_review"
CHANGES_REQUESTED = "changes_requested"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
class ExportState(StrEnum):
NOT_REQUESTED = "not_requested"
QUEUED = "queued"
BUILDING = "building"
READY = "ready"
FAILED = "failed"
CANCELLED = "cancelled"
EXPIRED = "expired"
class ErrorState(StrEnum):
OPEN = "open"
RETRYABLE = "retryable"
RETRYING = "retrying"
RECOVERED = "recovered"
TERMINAL = "terminal"
DISMISSED = "dismissed"
@dataclass(frozen=True, slots=True)
class StateMachine:
name: str
transitions: Mapping[str, frozenset[str]]
def allows(self, current: str, target: str) -> bool:
return target in self.transitions.get(current, frozenset())
def _machine(name: str, transitions: dict[str, set[str]]) -> StateMachine:
return StateMachine(
name=name,
transitions=MappingProxyType(
{state: frozenset(targets) for state, targets in transitions.items()}
),
)
STATE_MACHINES: Final[Mapping[str, StateMachine]] = MappingProxyType(
{
"capture": _machine(
"capture",
{
"received": {"spooling", "deduplicated", "failed", "cancelled"},
"spooling": {"stored", "failed", "cancelled"},
"stored": {"queued", "failed"},
"deduplicated": set(),
"queued": set(),
"failed": {"spooling", "cancelled"},
"cancelled": set(),
},
),
"job": _machine(
"job",
{
"queued": {"running", "cancelled"},
"running": {"succeeded", "retry_wait", "failed", "cancelled"},
"retry_wait": {"queued", "failed", "cancelled"},
"succeeded": set(),
"failed": {"queued"},
"cancelled": set(),
},
),
"extraction": _machine(
"extraction",
{
"pending": {"local_processing", "cancelled"},
"local_processing": {
"candidate_ready",
"fallback_pending",
"abstained",
"failed",
"cancelled",
},
"fallback_pending": {"fallback_processing", "cancelled"},
"fallback_processing": {
"candidate_ready",
"abstained",
"failed",
"cancelled",
},
"candidate_ready": set(),
"abstained": set(),
"failed": {"pending"},
"cancelled": set(),
},
),
"grouping": _machine(
"grouping",
{
"ungrouped": {"proposed", "cancelled"},
"proposed": {"grouped", "rejected", "cancelled"},
"grouped": {"split", "superseded"},
"split": {"superseded"},
"superseded": set(),
"rejected": {"ungrouped"},
"cancelled": set(),
},
),
"review": _machine(
"review",
{
"pending": {"in_review", "cancelled"},
"in_review": {
"changes_requested",
"approved",
"rejected",
"cancelled",
},
"changes_requested": {"in_review", "rejected", "cancelled"},
"approved": {"in_review"},
"rejected": {"in_review"},
"cancelled": set(),
},
),
"export": _machine(
"export",
{
"not_requested": {"queued"},
"queued": {"building", "cancelled"},
"building": {"ready", "failed", "cancelled"},
"ready": {"expired"},
"failed": {"queued", "cancelled"},
"cancelled": set(),
"expired": {"queued"},
},
),
"error": _machine(
"error",
{
"open": {"retryable", "terminal", "dismissed"},
"retryable": {"retrying", "terminal", "dismissed"},
"retrying": {"recovered", "retryable", "terminal"},
"recovered": set(),
"terminal": {"dismissed"},
"dismissed": set(),
},
),
}
)
class InvalidStateTransition(ValueError):
"""Raised when a caller attempts a transition absent from the contract."""
def require_transition(machine_name: str, current: str, target: str) -> None:
machine = STATE_MACHINES.get(machine_name)
if machine is None:
raise InvalidStateTransition(f"Unknown state machine: {machine_name}")
if current not in machine.transitions:
raise InvalidStateTransition(f"Unknown {machine_name} state: {current}")
if not machine.allows(current, target):
raise InvalidStateTransition(f"Invalid {machine_name} transition: {current} -> {target}")
@@ -0,0 +1,122 @@
"""Frozen screenshot taxonomy and canonical record vocabulary for contract v1."""
from collections.abc import Mapping
from enum import StrEnum
from types import MappingProxyType
from typing import Final
class Family(StrEnum):
WEAPON = "weapon"
GEAR = "gear"
SKILL_CHIP = "skill_chip"
OS_PROTOCOL = "os_protocol"
SPECIALIZATION = "specialization"
UNKNOWN = "unknown"
class GearSlot(StrEnum):
MASK = "Mask"
GLOVES = "Gloves"
BACKPACK = "Backpack"
BODY_ARMOR = "Body Armor"
HOLSTER = "Holster"
KNEE_PADS = "Knee Pads"
class AbstentionReason(StrEnum):
LOW_CONFIDENCE = "low_confidence"
AMBIGUOUS_FAMILY = "ambiguous_family"
AMBIGUOUS_VIEW = "ambiguous_view"
UNSUPPORTED_FAMILY = "unsupported_family"
UNSUPPORTED_VIEW = "unsupported_view"
INSUFFICIENT_VISUAL_EVIDENCE = "insufficient_visual_evidence"
OCCLUDED = "occluded"
CORRUPT_IMAGE = "corrupt_image"
RESOURCE_LIMIT = "resource_limit"
CANCELLED = "cancelled"
class RecordType(StrEnum):
WEAPON = "weapon"
WEAPON_TALENT = "weapon_talent"
WEAPON_AUGMENTATION_PROFILE = "weapon_augmentation_profile"
GEAR = "gear"
BRAND_SET = "brand_set"
GEAR_TALENT = "gear_talent"
GEAR_AUGMENTATION_PROFILE = "gear_augmentation_profile"
SKILL_CHIP_SET = "skill_chip_set"
SKILL_CHIP_SET_BONUS = "skill_chip_set_bonus"
SKILL_CHIP_ROLL_POOL = "skill_chip_roll_pool"
SKILL_CHIP_TALENT = "skill_chip_talent"
OS_PROTOCOL = "os_protocol"
OS_PROTOCOL_SECONDARY_PROFILE = "os_protocol_secondary_profile"
OS_PROTOCOL_TALENT = "os_protocol_talent"
SPECIALIZATION = "specialization"
SPECIALIZATION_FOCUS = "specialization_focus"
SPECIALIZATION_ABILITY = "specialization_ability"
SPECIALIZATION_TREE_NODE = "specialization_tree_node"
SPECIALIZATION_TREE_RANK = "specialization_tree_rank"
SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType(
{
Family.WEAPON: (
"detail",
"blueprint",
"crafting",
"stat_range",
"talent_grid",
"augmentation",
),
Family.GEAR: ("detail", "blueprint", "talent", "augmentation"),
Family.SKILL_CHIP: ("detail", "set_bonus", "talent"),
Family.OS_PROTOCOL: ("detail", "secondary_profile", "talent"),
Family.SPECIALIZATION: (
"overview",
"details",
"ability",
"focus_details",
"focus_tree",
"rank_details",
),
Family.UNKNOWN: ("unknown",),
}
)
GEAR_SLOTS: Final[tuple[str, ...]] = tuple(slot.value for slot in GearSlot)
ABSTENTION_REASONS: Final[tuple[str, ...]] = tuple(reason.value for reason in AbstentionReason)
RECORD_TYPES_BY_VIEW: Final[Mapping[tuple[Family, str], tuple[RecordType, ...]]] = MappingProxyType(
{
(Family.WEAPON, "detail"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
(Family.WEAPON, "blueprint"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
(Family.WEAPON, "crafting"): (RecordType.WEAPON,),
(Family.WEAPON, "stat_range"): (RecordType.WEAPON,),
(Family.WEAPON, "talent_grid"): (RecordType.WEAPON_TALENT,),
(Family.WEAPON, "augmentation"): (RecordType.WEAPON_AUGMENTATION_PROFILE,),
(Family.GEAR, "detail"): (RecordType.GEAR, RecordType.BRAND_SET),
(Family.GEAR, "blueprint"): (RecordType.GEAR, RecordType.BRAND_SET),
(Family.GEAR, "talent"): (RecordType.GEAR_TALENT,),
(Family.GEAR, "augmentation"): (RecordType.GEAR_AUGMENTATION_PROFILE,),
(Family.SKILL_CHIP, "detail"): (
RecordType.SKILL_CHIP_SET,
RecordType.SKILL_CHIP_ROLL_POOL,
),
(Family.SKILL_CHIP, "set_bonus"): (RecordType.SKILL_CHIP_SET_BONUS,),
(Family.SKILL_CHIP, "talent"): (RecordType.SKILL_CHIP_TALENT,),
(Family.OS_PROTOCOL, "detail"): (RecordType.OS_PROTOCOL,),
(Family.OS_PROTOCOL, "secondary_profile"): (RecordType.OS_PROTOCOL_SECONDARY_PROFILE,),
(Family.OS_PROTOCOL, "talent"): (RecordType.OS_PROTOCOL_TALENT,),
(Family.SPECIALIZATION, "overview"): (RecordType.SPECIALIZATION,),
(Family.SPECIALIZATION, "details"): (RecordType.SPECIALIZATION,),
(Family.SPECIALIZATION, "ability"): (RecordType.SPECIALIZATION_ABILITY,),
(Family.SPECIALIZATION, "focus_details"): (RecordType.SPECIALIZATION_FOCUS,),
(Family.SPECIALIZATION, "focus_tree"): (
RecordType.SPECIALIZATION_FOCUS,
RecordType.SPECIALIZATION_TREE_NODE,
),
(Family.SPECIALIZATION, "rank_details"): (RecordType.SPECIALIZATION_TREE_RANK,),
(Family.UNKNOWN, "unknown"): (),
}
)
@@ -0,0 +1,384 @@
"""Semantic checks that portable JSON Schema cannot express."""
from collections.abc import Iterable, Mapping
from datetime import datetime
from . import taxonomy
SUPPORTED_ARCHIVE_SCHEMA_VERSION = 1
SUPPORTED_HANDOFF_SCHEMA_VERSION = 1
class ContractValidationError(ValueError):
"""Raised when a document violates a version or referential contract."""
def ensure_supported_version(document: Mapping[str, object], expected: int) -> None:
version = document.get("schema_version")
if type(version) is not int or version != expected:
raise ContractValidationError(
f"Unsupported schema_version {version!r}; expected {expected}"
)
def _objects(value: object, field: str) -> Iterable[Mapping[str, object]]:
if not isinstance(value, list):
raise ContractValidationError(f"{field} must be an array")
for index, item in enumerate(value):
if not isinstance(item, Mapping):
raise ContractValidationError(f"{field}[{index}] must be an object")
yield item
def _ids(items: Iterable[Mapping[str, object]], field: str) -> set[str]:
result: set[str] = set()
for item in items:
value = item.get(field)
if not isinstance(value, str):
raise ContractValidationError(f"{field} must be a string")
if value in result:
raise ContractValidationError(f"Duplicate {field}: {value}")
result.add(value)
return result
def _references(item: Mapping[str, object], field: str) -> set[str]:
raw = item.get(field, [])
if not isinstance(raw, list) or any(not isinstance(value, str) for value in raw):
raise ContractValidationError(f"{field} must contain string IDs")
return set(raw)
def _require_subset(values: set[str], allowed: set[str], context: str) -> None:
dangling = values - allowed
if dangling:
raise ContractValidationError(f"{context} contains dangling references: {sorted(dangling)}")
def validate_classification(classification: Mapping[str, object]) -> None:
family_raw = classification.get("family")
if not isinstance(family_raw, str):
raise ContractValidationError("Unknown classification family")
try:
family = taxonomy.Family(family_raw)
except ValueError as error:
raise ContractValidationError("Unknown classification family") from error
view = classification.get("view")
if not isinstance(view, str) or view not in taxonomy.SCREEN_VIEWS[family]:
raise ContractValidationError(f"Unknown {family.value} view: {view!r}")
confidence = classification.get("confidence")
if (
isinstance(confidence, bool)
or not isinstance(confidence, int | float)
or not 0 <= confidence <= 1
):
raise ContractValidationError("Classification confidence must be between 0 and 1")
gear_slot = classification.get("gear_slot")
if family is taxonomy.Family.GEAR:
if gear_slot not in taxonomy.GEAR_SLOTS:
raise ContractValidationError(f"Unknown Gear slot: {gear_slot!r}")
elif gear_slot is not None:
raise ContractValidationError("Only Gear classifications may set gear_slot")
record_types = classification.get("record_types")
if not isinstance(record_types, list) or any(
not isinstance(record_type, str) for record_type in record_types
):
raise ContractValidationError("record_types must contain strings")
if len(record_types) != len(set(record_types)):
raise ContractValidationError("record_types must be unique")
allowed = {record_type.value for record_type in taxonomy.RECORD_TYPES_BY_VIEW[(family, view)]}
if family is taxonomy.Family.UNKNOWN:
if record_types:
raise ContractValidationError("Unknown screens cannot claim canonical record types")
reason = classification.get("abstention_reason")
if reason not in taxonomy.ABSTENTION_REASONS:
raise ContractValidationError("Unknown screens require an abstention reason")
else:
if not record_types or not set(record_types).issubset(allowed):
raise ContractValidationError(
f"record_types are incompatible with {family.value}/{view}"
)
if classification.get("abstention_reason") is not None:
raise ContractValidationError("Known screens cannot set abstention_reason")
def _validate_evidence(value: Mapping[str, object]) -> None:
_validate_value_state(value, "evidence")
confidence = value.get("confidence")
if (
isinstance(confidence, bool)
or not isinstance(confidence, int | float)
or not 0 <= confidence <= 1
):
raise ContractValidationError("Evidence confidence must be between 0 and 1")
region = value.get("region")
if not isinstance(region, Mapping):
raise ContractValidationError("Evidence region must be an object")
coordinate_space = region.get("coordinate_space")
numeric_coordinates: list[int | float] = []
for name in ("x", "y", "width", "height"):
number = region.get(name)
if isinstance(number, bool) or not isinstance(number, int | float):
raise ContractValidationError("Evidence bounds must be numeric")
numeric_coordinates.append(number)
x, y, width, height = numeric_coordinates
if coordinate_space == "normalized":
if x < 0 or y < 0 or width <= 0 or height <= 0 or x + width > 1 or y + height > 1:
raise ContractValidationError("Normalized evidence bounds must fit within the image")
elif coordinate_space == "pixels":
if any(not isinstance(number, int) for number in numeric_coordinates):
raise ContractValidationError("Pixel evidence bounds must be integers")
if x < 0 or y < 0 or width <= 0 or height <= 0:
raise ContractValidationError("Pixel evidence bounds must be positive and non-negative")
else:
raise ContractValidationError(f"Unknown coordinate space: {coordinate_space!r}")
def _validate_value_state(value: Mapping[str, object], context: str) -> None:
raw_text = value.get("raw_text")
normalized = value.get("normalized_value")
missing_reason = value.get("missing_reason")
if raw_text is not None and not isinstance(raw_text, str):
raise ContractValidationError(f"{context} raw_text must be a string or null")
if normalized is None and not isinstance(missing_reason, str):
raise ContractValidationError(f"Null {context} requires missing_reason")
if normalized is not None and missing_reason is not None:
raise ContractValidationError(f"Present {context} cannot set missing_reason")
def _value_state(value: Mapping[str, object], context: str) -> tuple[object, object, object]:
_validate_value_state(value, context)
return value.get("raw_text"), value.get("normalized_value"), value.get("missing_reason")
def _parse_correction_time(value: object) -> datetime:
if not isinstance(value, str):
raise ContractValidationError("Correction corrected_at must be a timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise ContractValidationError("Correction corrected_at must be a timestamp") from error
if parsed.tzinfo is None:
raise ContractValidationError("Correction corrected_at must include a UTC offset")
return parsed
def _validate_correction_history(
evidence: Mapping[str, object], seen_correction_ids: set[str]
) -> None:
history = evidence.get("correction_history")
if not isinstance(history, list):
raise ContractValidationError("correction_history must be an array")
previous_after: tuple[object, object, object] | None = None
previous_time: datetime | None = None
for index, correction in enumerate(history):
if not isinstance(correction, Mapping):
raise ContractValidationError(f"correction_history[{index}] must be an object")
correction_id = correction.get("correction_id")
if not isinstance(correction_id, str):
raise ContractValidationError("correction_id must be a string")
if correction_id in seen_correction_ids:
raise ContractValidationError(f"Duplicate correction_id: {correction_id}")
seen_correction_ids.add(correction_id)
corrected_at = _parse_correction_time(correction.get("corrected_at"))
if previous_time is not None and corrected_at <= previous_time:
raise ContractValidationError("Correction history timestamps must be strictly ordered")
before = correction.get("before")
after = correction.get("after")
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
raise ContractValidationError("Correction before and after must be value states")
before_state = _value_state(before, "correction before")
after_state = _value_state(after, "correction after")
if previous_after is not None and before_state != previous_after:
raise ContractValidationError("Correction history contains a disconnected value state")
previous_after = after_state
previous_time = corrected_at
if previous_after is not None and previous_after != _value_state(evidence, "evidence"):
raise ContractValidationError("Correction history final state does not match evidence")
def validate_archive_document(document: Mapping[str, object]) -> None:
validate_archive_references(document)
for screen in _objects(document.get("screens"), "screens"):
classification = screen.get("classification")
if not isinstance(classification, Mapping):
raise ContractValidationError("screen.classification must be an object")
validate_classification(classification)
seen_correction_ids: set[str] = set()
for value in _objects(document.get("evidence_values"), "evidence_values"):
_validate_evidence(value)
_validate_correction_history(value, seen_correction_ids)
def validate_handoff_document(document: Mapping[str, object]) -> None:
validate_handoff_references(document)
expected_boundary: dict[str, object] = {
"intended_destination": "authenticated_isac_intake",
"mutation_authority": "none",
"requires_authenticated_action": True,
"preserves_moderation": True,
"creates_audit_events": False,
}
if document.get("trust_boundary") != expected_boundary:
raise ContractValidationError("The handoff trust boundary is invalid")
seen_correction_ids: set[str] = set()
for value in _objects(document.get("evidence_values"), "evidence_values"):
_validate_evidence(value)
_validate_correction_history(value, seen_correction_ids)
def validate_archive_references(document: Mapping[str, object]) -> None:
ensure_supported_version(document, SUPPORTED_ARCHIVE_SCHEMA_VERSION)
artifacts = list(_objects(document.get("artifacts"), "artifacts"))
captures = list(_objects(document.get("captures"), "captures"))
screens = list(_objects(document.get("screens"), "screens"))
jobs = list(_objects(document.get("jobs"), "jobs"))
extractions = list(_objects(document.get("extractions"), "extractions"))
groups = list(_objects(document.get("groups"), "groups"))
reviews = list(_objects(document.get("reviews"), "reviews"))
exports = list(_objects(document.get("exports"), "exports"))
errors = list(_objects(document.get("errors"), "errors"))
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
instances = list(_objects(document.get("observed_instances"), "observed_instances"))
candidates = list(_objects(document.get("canonical_candidates"), "canonical_candidates"))
artifact_ids = _ids(artifacts, "artifact_id")
_ids(captures, "capture_id")
screen_ids = _ids(screens, "screen_id")
_ids(jobs, "job_id")
_ids(extractions, "extraction_id")
group_ids = _ids(groups, "group_id")
_ids(reviews, "review_id")
_ids(exports, "export_id")
error_ids = _ids(errors, "error_id")
evidence_ids = _ids(evidence, "evidence_id")
instance_ids = _ids(instances, "instance_id")
candidate_ids = _ids(candidates, "candidate_id")
artifact_hashes = {
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
}
for capture in captures:
_require_subset({str(capture.get("artifact_id"))}, artifact_ids, "capture.artifact_id")
for artifact in artifacts:
parent_id = artifact.get("parent_artifact_id")
if parent_id is not None:
if str(parent_id) == str(artifact.get("artifact_id")):
raise ContractValidationError("artifact.parent_artifact_id cannot reference itself")
_require_subset({str(parent_id)}, artifact_ids, "artifact.parent_artifact_id")
for screen in screens:
_require_subset({str(screen.get("artifact_id"))}, artifact_ids, "screen.artifact_id")
for value in evidence:
artifact_id = str(value.get("image_artifact_id"))
_require_subset(
{artifact_id},
artifact_ids,
"evidence.image_artifact_id",
)
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
for job in jobs:
last_error_id = job.get("last_error_id")
if last_error_id is not None:
_require_subset({str(last_error_id)}, error_ids, "job.last_error_id")
for extraction in extractions:
_require_subset({str(extraction.get("screen_id"))}, screen_ids, "extraction.screen_id")
_require_subset(
_references(extraction, "evidence_refs"),
evidence_ids,
"extraction.evidence_refs",
)
for group in groups:
_require_subset(_references(group, "screen_ids"), screen_ids, "group.screen_ids")
supersedes_group_ids = _references(group, "supersedes_group_ids")
if str(group.get("group_id")) in supersedes_group_ids:
raise ContractValidationError("group.supersedes_group_ids cannot reference itself")
_require_subset(
supersedes_group_ids,
group_ids,
"group.supersedes_group_ids",
)
for instance in instances:
_require_subset(
_references(instance, "source_screen_ids"),
screen_ids,
"observed_instance.source_screen_ids",
)
for field in _objects(instance.get("fields"), "observed_instance.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"observed_instance.fields.evidence_refs",
)
context = instance.get("observed_context")
if not isinstance(context, Mapping):
raise ContractValidationError("observed_instance.observed_context must be an object")
for context_name in ("level", "quality", "tier", "upgrade_state"):
context_field = context.get(context_name)
if not isinstance(context_field, Mapping):
raise ContractValidationError(f"observed_context.{context_name} must be an object")
_require_subset(
_references(context_field, "evidence_refs"),
evidence_ids,
f"observed_context.{context_name}.evidence_refs",
)
for candidate in candidates:
_require_subset(
_references(candidate, "source_instance_ids"),
instance_ids,
"canonical_candidate.source_instance_ids",
)
_require_subset(
_references(candidate, "evidence_refs"),
evidence_ids,
"canonical_candidate.evidence_refs",
)
for field in _objects(candidate.get("fields"), "canonical_candidate.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"canonical_candidate.fields.evidence_refs",
)
for review in reviews:
_require_subset({str(review.get("candidate_id"))}, candidate_ids, "review.candidate_id")
for export in exports:
export_artifact_id = export.get("artifact_id")
if export_artifact_id is not None:
_require_subset({str(export_artifact_id)}, artifact_ids, "export.artifact_id")
def validate_handoff_references(document: Mapping[str, object]) -> None:
ensure_supported_version(document, SUPPORTED_HANDOFF_SCHEMA_VERSION)
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
records = list(_objects(document.get("records"), "records"))
evidence_ids = _ids(evidence, "evidence_id")
artifacts = list(_objects(document.get("source_artifacts"), "source_artifacts"))
artifact_ids = _ids(artifacts, "artifact_id")
artifact_hashes = {
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
}
_ids(records, "handoff_record_id")
for value in evidence:
artifact_id = str(value.get("image_artifact_id"))
_require_subset(
{artifact_id},
artifact_ids,
"evidence.image_artifact_id",
)
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
for record in records:
for field in _objects(record.get("fields"), "record.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"record.fields.evidence_refs",
)
for section_name in ("relationships", "mechanics"):
section = record.get(section_name)
if not isinstance(section, Mapping):
raise ContractValidationError(f"record.{section_name} must be an object")
_require_subset(
_references(section, "evidence_refs"),
evidence_ids,
f"record.{section_name}.evidence_refs",
)
@@ -0,0 +1,4 @@
"""Wayland clipboard intake and private spool boundaries.
Submodules stay unimported so the stdin helper can start independently.
"""
@@ -0,0 +1,510 @@
"""Bounded, shell-free supervision for Wayland PNG clipboard intake."""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import signal
import stat
import subprocess
import sys
import threading
import time
from collections import deque
from collections.abc import Iterator, Mapping
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from game_data_capture.ingest.spool_helper import (
PNG_SIGNATURE,
acknowledge_receipt,
durable_receipts,
reconcile_spool,
)
_SAFE_ENV: Final = frozenset(
{"PATH", "HOME", "LANG", "LC_ALL", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"}
)
_DENIED_ENV = re.compile(
r"(?:openai|authorization|token|secret|python(?:home|path|startup|userbase))", re.I
)
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_MAX_METADATA = 16 * 1024
_MAX_RECEIPT = 1024
def _integer(value: object, minimum: int, maximum: int) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum
def _finite(value: object, minimum: float, maximum: float) -> bool:
return (
isinstance(value, int | float)
and not isinstance(value, bool)
and math.isfinite(value)
and minimum < value <= maximum
)
@dataclass(frozen=True, slots=True)
class WatchConfig:
spool: Path
max_image_bytes: int
max_queue_items: int
policy_version: str
max_restarts: int = 3
restart_backoff_seconds: float = 0.25
shutdown_timeout_seconds: float = 2.0
def __post_init__(self) -> None:
if not self.spool.is_absolute() or len(str(self.spool)) > 240:
raise ValueError("spool must be a bounded absolute path")
if not _integer(self.max_image_bytes, 8, 64 * 1024 * 1024):
raise ValueError("invalid image limit")
if not _integer(self.max_queue_items, 1, 256) or not _integer(self.max_restarts, 0, 10):
raise ValueError("invalid queue or restart limit")
if not _finite(self.restart_backoff_seconds, 0, 60) or not _finite(
self.shutdown_timeout_seconds, 0, 30
):
raise ValueError("invalid timeout")
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", self.policy_version):
raise ValueError("invalid policy version")
@dataclass(frozen=True, slots=True)
class FocusedWindowMetadata:
app_id: str | None
title: str | None
window_id: int | None
@dataclass(frozen=True, slots=True)
class IntakeReceipt:
occurrence_id: str
receipt_token: str
sha256: str
byte_size: int
media_type: str
spool_path: Path
policy_version: str
processing_version: str
focused_window: FocusedWindowMetadata
class VerifiedSpoolReader:
"""Descriptor-verified stream for handing one receipt to GDC-003 safely."""
def __init__(self, receipt: IntakeReceipt) -> None:
self.receipt = receipt
self._fd: int | None = None
def __enter__(self) -> VerifiedSpoolReader:
self._fd = _verify_spool_file(self.receipt)
return self
def __exit__(self, *_: object) -> None:
self.close()
def __iter__(self) -> Iterator[bytes]:
if self._fd is None:
raise RuntimeError("verified reader is not open")
while chunk := os.read(self._fd, 64 * 1024):
yield chunk
def close(self) -> None:
if self._fd is not None:
os.close(self._fd)
self._fd = None
def restricted_environment(environment: Mapping[str, str] | None = None) -> dict[str, str]:
source = os.environ if environment is None else environment
return {
key: value[:4096]
for key, value in source.items()
if key in _SAFE_ENV
and not _DENIED_ENV.search(key)
and isinstance(value, str)
and "\x00" not in value
}
def _bounded_text(value: object) -> str | None:
return " ".join(value.split())[:256] or None if isinstance(value, str) else None
def _bounded_command_output(
arguments: list[str], environment: Mapping[str, str], timeout: float
) -> bytes | None:
try:
process = subprocess.Popen(
arguments,
shell=False,
env=environment,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError:
return None
output = bytearray()
done = threading.Event()
def collect() -> None:
if process.stdout is not None:
while len(output) <= _MAX_METADATA:
chunk = process.stdout.read(min(4096, _MAX_METADATA + 1 - len(output)))
if not chunk:
break
output.extend(chunk)
done.set()
reader = threading.Thread(target=collect, daemon=True)
reader.start()
if not done.wait(timeout):
with suppress(OSError):
os.killpg(process.pid, signal.SIGKILL)
with suppress(subprocess.TimeoutExpired):
process.wait(timeout=timeout)
if process.poll() is None:
with suppress(OSError):
os.killpg(process.pid, signal.SIGKILL)
process.wait()
if process.stdout is not None:
process.stdout.close()
reader.join(timeout)
return (
bytes(output)
if done.is_set() and process.returncode == 0 and len(output) <= _MAX_METADATA
else None
)
def focused_window(
*, environment: Mapping[str, str] | None = None, timeout_seconds: float = 0.5
) -> FocusedWindowMetadata:
if not _finite(timeout_seconds, 0, 30):
return FocusedWindowMetadata(None, None, None)
raw = _bounded_command_output(
["niri", "msg", "-j", "focused-window"],
restricted_environment(environment),
float(timeout_seconds),
)
if raw is None:
return FocusedWindowMetadata(None, None, None)
try:
payload = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError):
return FocusedWindowMetadata(None, None, None)
if not isinstance(payload, dict):
return FocusedWindowMetadata(None, None, None)
identifier = payload.get("id")
return FocusedWindowMetadata(
_bounded_text(payload.get("app_id")),
_bounded_text(payload.get("title")),
identifier if _integer(identifier, 0, 2**63 - 1) else None,
)
def _verify_spool_file(receipt: IntakeReceipt) -> int:
spool = receipt.spool_path.parent
if spool.is_symlink() or not spool.is_dir() or (spool.stat().st_mode & 0o777) != 0o700:
raise ValueError("unsafe spool")
fd = os.open(receipt.spool_path, os.O_RDONLY | os.O_NOFOLLOW)
try:
info = os.fstat(fd)
if (
not stat.S_ISREG(info.st_mode)
or (info.st_mode & 0o777) != 0o600
or info.st_size != receipt.byte_size
):
raise ValueError("invalid spool file")
first = os.read(fd, len(PNG_SIGNATURE))
if first != PNG_SIGNATURE:
raise ValueError("invalid spool png")
hasher = hashlib.sha256(first)
while chunk := os.read(fd, 64 * 1024):
hasher.update(chunk)
if hasher.hexdigest() != receipt.sha256:
raise ValueError("spool digest mismatch")
os.lseek(fd, 0, os.SEEK_SET)
return fd
except Exception:
os.close(fd)
raise
class ClipboardIntake:
def __init__(
self, config: WatchConfig, *, environment: Mapping[str, str] | None = None
) -> None:
self.config, self.environment = config, restricted_environment(environment)
self.armed = self.paused = False
self._watcher: subprocess.Popen[bytes] | None = None
self._reader: threading.Thread | None = None
self._queue: deque[IntakeReceipt] = deque()
self._helper_payloads: deque[dict[str, object]] = deque()
self._seen_tokens: set[str] = set()
self._seen_order: deque[str] = deque()
self._recovered_tokens: set[str] = set()
self._restart_count = 0
self._restart_after = 0.0
self._restarts_exhausted = False
@property
def queue_depth(self) -> int:
return len(self._queue)
@property
def helper_queue_depth(self) -> int:
return len(self._helper_payloads)
def arm(self) -> None:
self.armed, self.paused = True, False
self._start_if_due(time.monotonic())
def pause(self) -> None:
self.paused = True
self._stop_watcher()
def resume(self) -> None:
if self.armed:
self.paused = False
self._start_if_due(time.monotonic())
def disarm(self) -> None:
self.armed = self.paused = False
self._stop_watcher()
def cancel(self) -> None:
self.shutdown()
def shutdown(self) -> None:
self.disarm()
self._queue.clear()
self._helper_payloads.clear()
self._seen_tokens.clear()
self._seen_order.clear()
self._recovered_tokens.clear()
def tick(self, now: float | None = None) -> None:
current = time.monotonic() if now is None else now
if self._watcher is not None and self._watcher.poll() is not None:
self._stop_watcher()
self._crashed(current)
self._start_if_due(current)
def take(self) -> IntakeReceipt | None:
return self._queue.popleft() if self._queue else None
def take_helper_receipt(self) -> dict[str, object] | None:
return self._helper_payloads.popleft() if self._helper_payloads else None
def unreferenced_spool_candidates(self) -> list[Path]:
queued = {item.spool_path.name for item in self._queue}
return [
path
for path in self.config.spool.glob("*.png")
if not path.is_symlink() and path.name not in queued
]
def open_verified(self, receipt: IntakeReceipt) -> VerifiedSpoolReader:
return VerifiedSpoolReader(receipt)
def accept_helper_receipt(
self,
payload: Mapping[str, object],
*,
occurrence_id: str,
processing_version: str,
metadata: FocusedWindowMetadata | None = None,
) -> bool:
token, digest, size, name = (
payload.get("receipt_token"),
payload.get("sha256"),
payload.get("byte_size"),
payload.get("spool_name"),
)
if (
not self.armed
or self.paused
or len(self._queue) >= self.config.max_queue_items
or payload.get("receipt_version") != 1
or not isinstance(token, str)
or not _ID.fullmatch(token)
or token in self._seen_tokens
or not isinstance(digest, str)
or not _SHA256.fullmatch(digest)
or not _integer(size, 8, self.config.max_image_bytes)
or payload.get("media_type") != "image/png"
or payload.get("policy_version") != self.config.policy_version
or not isinstance(name, str)
or name != f"{digest}.png"
or not isinstance(occurrence_id, str)
or not _ID.fullmatch(occurrence_id)
or not isinstance(processing_version, str)
or not _ID.fullmatch(processing_version)
):
return False
recovered = token in self._recovered_tokens
receipt = IntakeReceipt(
occurrence_id,
token,
digest,
cast(int, size),
"image/png",
self.config.spool / name,
self.config.policy_version,
processing_version,
FocusedWindowMetadata(None, None, None)
if recovered
else focused_window()
if metadata is None
else metadata,
)
try:
verified_fd = _verify_spool_file(receipt)
except (OSError, ValueError):
return False
os.close(verified_fd)
self._seen_tokens.add(token)
self._seen_order.append(token)
if len(self._seen_order) > self.config.max_queue_items:
self._seen_tokens.remove(self._seen_order.popleft())
self._queue.append(receipt)
return True
def reconcile(self) -> list[str]:
removed = reconcile_spool(self.config.spool)
capacity = self.config.max_queue_items - len(self._helper_payloads)
if capacity <= 0:
return removed
for payload in durable_receipts(self.config.spool, max_items=256):
if len(self._helper_payloads) >= self.config.max_queue_items:
break
token = payload["receipt_token"]
if isinstance(token, str) and token not in self._seen_tokens:
self._helper_payloads.append(payload)
self._recovered_tokens.add(token)
return removed
def acknowledge(self, receipt: IntakeReceipt) -> bool:
"""Delete this occurrence's durable receipt after repository confirmation only."""
if (
receipt.spool_path.parent != self.config.spool
or receipt.policy_version != self.config.policy_version
):
return False
payload: dict[str, object] = {
"receipt_version": 1,
"receipt_token": receipt.receipt_token,
"sha256": receipt.sha256,
"byte_size": receipt.byte_size,
"media_type": receipt.media_type,
"spool_name": receipt.spool_path.name,
"policy_version": receipt.policy_version,
}
acknowledged = acknowledge_receipt(self.config.spool, payload)
if acknowledged:
self._recovered_tokens.discard(receipt.receipt_token)
return acknowledged
def _crashed(self, now: float) -> None:
if self.armed and not self.paused and self._restart_count < self.config.max_restarts:
self._restart_count += 1
self._restart_after = now + float(self.config.restart_backoff_seconds) * 2 ** (
self._restart_count - 1
)
else:
self._restarts_exhausted = True
def _start_if_due(self, now: float) -> None:
if (
not self.armed
or self.paused
or self._watcher is not None
or self._restarts_exhausted
or now < self._restart_after
):
return
try:
self._watcher = subprocess.Popen(
[
"wl-paste",
"--type",
"image/png",
"--watch",
sys.executable,
"-m",
"game_data_capture.ingest.spool_helper",
"--spool",
str(self.config.spool),
"--max-bytes",
str(self.config.max_image_bytes),
"--policy-version",
self.config.policy_version,
"--media-type",
"image/png",
],
shell=False,
env=self.environment,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError:
self._crashed(now)
return
self._reader = threading.Thread(
target=self._read_receipts, args=(self._watcher,), daemon=True
)
self._reader.start()
def _read_receipts(self, process: subprocess.Popen[bytes]) -> None:
if process.stdout is None:
return
while True:
line = process.stdout.readline(_MAX_RECEIPT + 1)
if not line:
return
if len(line) > _MAX_RECEIPT and not line.endswith(b"\n"):
while line and not line.endswith(b"\n"):
line = process.stdout.readline(_MAX_RECEIPT + 1)
continue
try:
payload = json.loads(line)
except (json.JSONDecodeError, UnicodeDecodeError):
continue
if (
isinstance(payload, dict)
and all(isinstance(key, str) for key in payload)
and len(self._helper_payloads) < self.config.max_queue_items
):
self._helper_payloads.append(payload)
def _stop_watcher(self) -> None:
process, self._watcher = self._watcher, None
if process is None:
return
if process.poll() is None:
with suppress(OSError):
os.killpg(process.pid, signal.SIGTERM)
try:
process.wait(timeout=float(self.config.shutdown_timeout_seconds))
except subprocess.TimeoutExpired:
with suppress(OSError):
os.killpg(process.pid, signal.SIGKILL)
process.wait()
if process.stdout is not None:
process.stdout.close()
if self._reader is not None:
self._reader.join(timeout=float(self.config.shutdown_timeout_seconds))
self._reader = None
@@ -0,0 +1,486 @@
"""Small stdin-only PNG spool helper; intentionally independent of application services."""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
import re
import secrets
import sys
import time
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from pathlib import Path
from stat import S_ISDIR, S_ISREG
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
RECEIPT_VERSION = 1
_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_CHUNK_SIZE = 64 * 1024
_MAX_RECEIPT_BYTES = 1024
_MAX_SIDECARS = 256
_MAX_RECONCILE_ENTRIES = 1024
_LOCK_NAME = ".spool.lock"
_LOCK_TIMEOUT_SECONDS = 1.0
_LOCK_RETRY_SECONDS = 0.01
class SpoolError(ValueError):
"""The supplied image or spool boundary is invalid."""
def _private_spool(path: Path) -> None:
if not path.is_absolute() or len(str(path)) > 240:
raise SpoolError("invalid spool")
if path.is_symlink():
raise SpoolError("unsafe spool")
path.mkdir(mode=0o700, parents=True, exist_ok=True)
mode = path.lstat().st_mode
if path.is_symlink() or not S_ISDIR(mode):
raise SpoolError("unsafe spool")
path.chmod(0o700)
def _write_all(fd: int, data: bytes) -> None:
view = memoryview(data)
while view:
written = os.write(fd, view)
if written <= 0:
raise OSError("short spool write")
view = view[written:]
def _fsync_directory(path: Path) -> None:
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
@contextmanager
def _spool_lock(spool: Path) -> Iterator[None]:
"""Hold the private spool lock for one bounded receipt installation."""
try:
fd = os.open(spool / _LOCK_NAME, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
except OSError as error:
raise SpoolError("unsafe spool lock") from error
try:
info = os.fstat(fd)
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600:
raise SpoolError("unsafe spool lock")
deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise SpoolError("spool busy") from None
time.sleep(_LOCK_RETRY_SECONDS)
yield
finally:
with suppress(OSError):
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def _verify_existing(path: Path, digest: str, size: int) -> bool:
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError:
return False
try:
info = os.fstat(fd)
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600 or info.st_size != size:
return False
hasher = hashlib.sha256()
prefix = os.read(fd, len(PNG_SIGNATURE))
if prefix != PNG_SIGNATURE:
return False
hasher.update(prefix)
while chunk := os.read(fd, _CHUNK_SIZE):
hasher.update(chunk)
return hasher.hexdigest() == digest
finally:
os.close(fd)
def receipt_name(token: str) -> str:
if not _TOKEN.fullmatch(token):
raise SpoolError("invalid receipt identity")
return f"{token}.receipt.json"
def _pending_name(token: str) -> str:
return f".{token}.receipt.pending"
def _receipt_bytes(receipt: dict[str, object]) -> bytes:
encoded = json.dumps(receipt, separators=(",", ":"), sort_keys=True).encode("ascii")
if len(encoded) > _MAX_RECEIPT_BYTES:
raise SpoolError("receipt exceeds limit")
return encoded
def _valid_receipt(payload: object, *, spool: Path, verify_png: bool) -> dict[str, object] | None:
if not isinstance(payload, dict) or set(payload) != {
"byte_size",
"media_type",
"policy_version",
"receipt_token",
"receipt_version",
"sha256",
"spool_name",
}:
return None
token, digest, size, name = (
payload.get("receipt_token"),
payload.get("sha256"),
payload.get("byte_size"),
payload.get("spool_name"),
)
if (
payload.get("receipt_version") != RECEIPT_VERSION
or not isinstance(token, str)
or not _TOKEN.fullmatch(token)
or not isinstance(digest, str)
or not _SHA256.fullmatch(digest)
or isinstance(size, bool)
or not isinstance(size, int)
or size < len(PNG_SIGNATURE)
or payload.get("media_type") != "image/png"
or not isinstance(payload.get("policy_version"), str)
or not _TOKEN.fullmatch(payload["policy_version"])
or not isinstance(name, str)
or name != f"{digest}.png"
):
return None
if verify_png and not _verify_existing(spool / name, digest, size):
return None
return payload
def _read_sidecar(path: Path, *, spool: Path, verify_png: bool) -> dict[str, object] | None:
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError:
return None
try:
info = os.fstat(fd)
if (
not S_ISREG(info.st_mode)
or (info.st_mode & 0o777) != 0o600
or info.st_size > _MAX_RECEIPT_BYTES
):
return None
raw = os.read(fd, _MAX_RECEIPT_BYTES + 1)
if len(raw) != info.st_size or len(raw) > _MAX_RECEIPT_BYTES:
return None
try:
return _valid_receipt(json.loads(raw), spool=spool, verify_png=verify_png)
except (UnicodeDecodeError, json.JSONDecodeError):
return None
finally:
os.close(fd)
def _install_pending_sidecar(spool: Path, receipt: dict[str, object]) -> Path:
token = receipt["receipt_token"]
assert isinstance(token, str)
pending = spool / _pending_name(token)
encoded = _receipt_bytes(receipt)
fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
_write_all(fd, encoded)
os.fsync(fd)
finally:
os.close(fd)
# Persist the recoverable pending name before promotion. A crash after this
# point cannot claim recovery until its PNG is immutably installed.
_fsync_directory(spool)
return pending
def _same_receipt(left: dict[str, object], right: dict[str, object]) -> bool:
return _receipt_bytes(left) == _receipt_bytes(right)
def _final_sidecar(spool: Path, receipt: dict[str, object]) -> dict[str, object] | None:
token = receipt["receipt_token"]
assert isinstance(token, str)
final = spool / receipt_name(token)
if not final.exists() and not final.is_symlink():
return None
stored = _read_sidecar(final, spool=spool, verify_png=True)
if stored is None or not _same_receipt(stored, receipt):
raise SpoolError("receipt token conflict")
return stored
def _receipt_capacity_available(spool: Path) -> bool:
"""Count bounded valid receipt sidecars while removing malformed regular entries."""
valid = 0
candidates = 0
for entry in spool.iterdir():
if entry.is_symlink() or not entry.is_file():
continue
pending = entry.name.startswith(".") and entry.name.endswith(".receipt.pending")
final = entry.name.endswith(".receipt.json")
if not pending and not final:
continue
candidates += 1
if candidates > _MAX_RECONCILE_ENTRIES:
return False
token = (
entry.name[1 : -len(".receipt.pending")]
if pending
else entry.name[: -len(".receipt.json")]
)
receipt = _read_sidecar(entry, spool=spool, verify_png=final)
if receipt is None or receipt.get("receipt_token") != token:
entry.unlink()
_fsync_directory(spool)
continue
valid += 1
if valid >= _MAX_SIDECARS:
return False
return True
def _promote_sidecar(spool: Path, receipt: dict[str, object], pending: Path) -> None:
token = receipt["receipt_token"]
assert isinstance(token, str)
final = spool / receipt_name(token)
try:
os.link(pending, final, follow_symlinks=False)
except FileExistsError:
try:
_final_sidecar(spool, receipt)
except SpoolError:
pending.unlink()
_fsync_directory(spool)
raise
else:
_fsync_directory(spool)
pending.unlink()
_fsync_directory(spool)
def reconcile_spool(spool: Path) -> list[str]:
"""Remove incomplete files and promote only receipt sidecars backed by immutable PNGs."""
_private_spool(spool)
removed: list[str] = []
promoted = 0
candidates = 0
for entry in spool.iterdir():
if entry.is_symlink() or not entry.is_file():
continue
if entry.name.endswith(".partial"):
candidates += 1
if candidates > _MAX_RECONCILE_ENTRIES:
break
entry.unlink()
removed.append(entry.name)
elif entry.name.startswith(".") and entry.name.endswith(".receipt.pending"):
candidates += 1
if candidates > _MAX_RECONCILE_ENTRIES:
break
receipt = _read_sidecar(entry, spool=spool, verify_png=True)
token = entry.name[1 : -len(".receipt.pending")]
if receipt is not None and receipt.get("receipt_token") == token:
final = spool / receipt_name(token)
if final.exists() or final.is_symlink():
with suppress(SpoolError):
_final_sidecar(spool, receipt)
entry.unlink()
removed.append(entry.name)
elif promoted >= _MAX_SIDECARS:
continue
else:
_promote_sidecar(spool, receipt, entry)
promoted += 1
continue
entry.unlink()
removed.append(entry.name)
return removed
def durable_receipts(spool: Path, *, max_items: int) -> Iterator[dict[str, object]]:
"""Yield at most ``max_items`` validated final receipt sidecars without following links."""
if (
isinstance(max_items, bool)
or not isinstance(max_items, int)
or not 1 <= max_items <= _MAX_SIDECARS
):
raise SpoolError("invalid receipt limit")
_private_spool(spool)
yielded = 0
valid = 0
candidates = 0
for entry in spool.iterdir():
if entry.is_symlink() or not entry.is_file() or not entry.name.endswith(".receipt.json"):
continue
candidates += 1
if candidates > _MAX_RECONCILE_ENTRIES:
break
token = entry.name[: -len(".receipt.json")]
receipt = _read_sidecar(entry, spool=spool, verify_png=True)
if receipt is not None and receipt.get("receipt_token") == token:
valid += 1
if valid > _MAX_SIDECARS:
break
if yielded < max_items:
yielded += 1
yield receipt
else:
try:
info = entry.lstat()
if S_ISREG(info.st_mode):
entry.unlink()
_fsync_directory(spool)
except OSError:
continue
def acknowledge_receipt(spool: Path, receipt: dict[str, object]) -> bool:
"""Remove exactly one verified durable sidecar; immutable PNG data is never removed here."""
_private_spool(spool)
token = receipt.get("receipt_token")
if not isinstance(token, str) or not _TOKEN.fullmatch(token):
return False
path = spool / receipt_name(token)
stored = _read_sidecar(path, spool=spool, verify_png=True)
if stored is None:
return False
if _receipt_bytes(stored) != _receipt_bytes(receipt):
return False
try:
info = path.lstat()
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600:
return False
path.unlink()
_fsync_directory(spool)
except OSError:
return False
return True
def spool_png(
input_stream: object,
*,
spool: Path,
max_bytes: int,
receipt_token: str,
policy_version: str,
media_type: str = "image/png",
) -> dict[str, object]:
"""Install immutable PNG bytes, then fsync a durable occurrence receipt before stdout."""
if (
isinstance(max_bytes, bool)
or not isinstance(max_bytes, int)
or not 8 <= max_bytes <= 64 * 1024 * 1024
):
raise SpoolError("invalid byte limit")
if (
media_type != "image/png"
or not _TOKEN.fullmatch(receipt_token)
or not _TOKEN.fullmatch(policy_version)
):
raise SpoolError("invalid receipt identity")
_private_spool(spool)
temporary = spool / f".{secrets.token_hex(16)}.partial"
pending: Path | None = None
immutable_installed = False
hasher = hashlib.sha256()
size = 0
try:
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
prefix = bytearray()
while True:
chunk = input_stream.read(_CHUNK_SIZE) # type: ignore[attr-defined]
if not isinstance(chunk, bytes):
raise SpoolError("invalid input stream")
if not chunk:
break
prefix.extend(chunk[: max(0, len(PNG_SIGNATURE) - len(prefix))])
size += len(chunk)
if size > max_bytes:
raise SpoolError("image exceeds byte limit")
hasher.update(chunk)
_write_all(fd, chunk)
if len(prefix) < len(PNG_SIGNATURE) or bytes(prefix) != PNG_SIGNATURE:
raise SpoolError("invalid png")
os.fsync(fd)
finally:
os.close(fd)
digest = hasher.hexdigest()
name = f"{digest}.png"
receipt: dict[str, object] = {
"receipt_version": RECEIPT_VERSION,
"receipt_token": receipt_token,
"sha256": digest,
"byte_size": size,
"media_type": "image/png",
"spool_name": name,
"policy_version": policy_version,
}
with _spool_lock(spool):
if _final_sidecar(spool, receipt) is not None:
return receipt
if not _receipt_capacity_available(spool):
raise SpoolError("spool receipt capacity reached")
pending = _install_pending_sidecar(spool, receipt)
destination = spool / name
if destination.exists():
if not _verify_existing(destination, digest, size):
raise SpoolError("spool collision")
else:
os.replace(temporary, destination)
os.chmod(destination, 0o600)
_fsync_directory(spool)
immutable_installed = True
_promote_sidecar(spool, receipt, pending)
return receipt
finally:
if temporary.exists() and not temporary.is_symlink():
temporary.unlink()
if (
pending is not None
and not immutable_installed
and pending.exists()
and not pending.is_symlink()
):
pending.unlink()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--spool", required=True)
parser.add_argument("--max-bytes", required=True, type=int)
parser.add_argument("--receipt-token", default=secrets.token_hex(16))
parser.add_argument("--policy-version", required=True)
parser.add_argument("--media-type", default="image/png")
args = parser.parse_args(argv)
try:
receipt = spool_png(
sys.stdin.buffer,
spool=Path(args.spool),
max_bytes=args.max_bytes,
receipt_token=args.receipt_token,
policy_version=args.policy_version,
media_type=args.media_type,
)
except (OSError, SpoolError):
return 2
sys.stdout.write(json.dumps(receipt, separators=(",", ":")) + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,95 @@
"""Structured logging helpers that remove sensitive capture material."""
from __future__ import annotations
import logging
import re
from collections.abc import Mapping
from typing import Any
_SENSITIVE_FIELD = re.compile(
r"(?:api[_-]?key|authorization|auth[_-]?header|token|secret|password|"
r"image|payload|base64|data[_-]?url|ocr[_-]?text|recognized[_-]?text)",
re.IGNORECASE,
)
_AUTHORIZATION_VALUE = re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?\S+")
_KEY_VALUE = re.compile(r"(?i)((?:api[_-]?key|token|secret|password)\s*[:=]\s*)\S+")
_OPENAI_KEY = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
def redact_text(value: str) -> str:
"""Remove common secret-bearing fragments from a log message."""
value = _AUTHORIZATION_VALUE.sub(r"\1[REDACTED]", value)
value = _KEY_VALUE.sub(r"\1[REDACTED]", value)
return _OPENAI_KEY.sub("[REDACTED]", value)
def redact_log_data(value: object, field_name: str | None = None) -> object:
"""Redact secrets, image data, and unbounded OCR text from structured fields."""
if field_name is not None and _SENSITIVE_FIELD.search(field_name):
return "[REDACTED]"
if isinstance(value, Mapping):
return {str(key): redact_log_data(item, str(key)) for key, item in value.items()}
if isinstance(value, list | tuple | set | frozenset):
return [redact_log_data(item) for item in value]
if isinstance(value, str):
return redact_text(value[:512])
return value
class RedactingFormatter(logging.Formatter):
"""Formatter for static messages plus redacted structured event data."""
def format(self, record: logging.LogRecord) -> str:
original_message = record.msg
original_args = record.args
original_exception = record.exc_info
original_exception_text = record.exc_text
original_stack = record.stack_info
try:
if isinstance(record.msg, str):
record.msg = redact_text(record.msg)
if isinstance(record.args, Mapping):
record.args = {
str(key): redact_log_data(value, str(key)) for key, value in record.args.items()
}
elif record.args:
record.args = tuple(redact_log_data(item) for item in record.args)
record.exc_info = None
record.exc_text = None
record.stack_info = None
output = redact_text(super().format(record))
if original_exception is not None or original_exception_text is not None:
return f"{output}\n[REDACTED EXCEPTION]"
return output
finally:
record.msg = original_message
record.args = original_args
record.exc_info = original_exception
record.exc_text = original_exception_text
record.stack_info = original_stack
def configure_logging(level: int = logging.INFO) -> logging.Logger:
"""Create an isolated logger that writes redacted records to stderr."""
logger = logging.getLogger("game_data_capture")
logger.setLevel(level)
logger.propagate = False
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler = logging.StreamHandler()
handler.setFormatter(RedactingFormatter("%(levelname)s %(message)s"))
logger.addHandler(handler)
return logger
def log_event(logger: logging.Logger, event: str, **fields: Any) -> None:
"""Log a stable event name and scrubbed structured fields.
Callers must keep event names static and place external text in named fields.
"""
logger.info("%s %s", event, redact_log_data(fields))
@@ -0,0 +1,108 @@
"""XDG path resolution and private-directory preparation.
Path resolution is pure. Call :func:`prepare_private_directories` only at a runtime
boundary that is allowed to create the utility's private directories.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from os import environ
from pathlib import Path
from stat import S_ISDIR, S_ISLNK, S_ISREG
class PathConfigurationError(ValueError):
"""A configured path violates the local runtime contract."""
def _bounded_path_error(kind: str) -> PathConfigurationError:
return PathConfigurationError(f"{kind} path must be an absolute path within 240 characters")
def _read_xdg_path(environment: Mapping[str, str], key: str, fallback: Path, kind: str) -> Path:
raw_value = environment.get(key)
candidate = fallback if raw_value is None or raw_value == "" else Path(raw_value).expanduser()
if not candidate.is_absolute() or len(str(candidate)) > 240:
raise _bounded_path_error(kind)
return candidate
@dataclass(frozen=True, slots=True)
class RuntimePaths:
"""Private filesystem locations owned by one local capture installation."""
config_dir: Path
data_dir: Path
cache_dir: Path
runtime_dir: Path
def __post_init__(self) -> None:
for path in (self.config_dir, self.data_dir, self.cache_dir, self.runtime_dir):
if not path.is_absolute() or len(str(path)) > 240:
raise _bounded_path_error("runtime")
@property
def config_file(self) -> Path:
return self.config_dir / "config.toml"
@property
def instance_lock_file(self) -> Path:
return self.runtime_dir / "instance.lock"
def resolve_runtime_paths(environment: Mapping[str, str] | None = None) -> RuntimePaths:
"""Resolve XDG paths without creating or inspecting filesystem entries."""
env = environ if environment is None else environment
home = Path(env.get("HOME", "~")).expanduser()
if not home.is_absolute() or len(str(home)) > 200:
raise _bounded_path_error("home")
config_root = _read_xdg_path(env, "XDG_CONFIG_HOME", home / ".config", "XDG config")
data_root = _read_xdg_path(env, "XDG_DATA_HOME", home / ".local" / "share", "XDG data")
cache_root = _read_xdg_path(env, "XDG_CACHE_HOME", home / ".cache", "XDG cache")
runtime_root = _read_xdg_path(env, "XDG_RUNTIME_DIR", data_root / "runtime", "XDG runtime")
return RuntimePaths(
config_dir=config_root / "shd-capture",
data_dir=data_root / "shd-capture",
cache_dir=cache_root / "shd-capture",
runtime_dir=runtime_root / "shd-capture",
)
def _ensure_private_directory(path: Path) -> None:
path.mkdir(mode=0o700, parents=True, exist_ok=True)
mode = path.lstat().st_mode
if S_ISLNK(mode) or not S_ISDIR(mode):
raise PathConfigurationError("runtime path must name a directory")
path.chmod(0o700)
def prepare_private_directories(paths: RuntimePaths) -> None:
"""Create utility directories with owner-only access at an explicit runtime boundary."""
for path in (paths.config_dir, paths.data_dir, paths.cache_dir, paths.runtime_dir):
_ensure_private_directory(path)
def prepare_private_file(path: Path) -> None:
"""Create a runtime-owned regular file with owner-only access.
The lifecycle layer uses this contract for its instance lock and any future
state files. Callers must prepare the parent directory first.
"""
if not path.is_absolute() or len(str(path)) > 240:
raise _bounded_path_error("runtime")
if path.is_symlink():
raise PathConfigurationError("runtime file must be a regular file")
if path.exists():
mode = path.lstat().st_mode
if S_ISLNK(mode) or not S_ISREG(mode):
raise PathConfigurationError("runtime file must be a regular file")
else:
path.touch(mode=0o600, exist_ok=False)
path.chmod(0o600)
@@ -0,0 +1,157 @@
"""Bounded durable leases that follow the frozen job state machine."""
from __future__ import annotations
import sqlite3
from datetime import UTC, datetime, timedelta
from game_data_capture.domain.state_machines import require_transition
from game_data_capture.storage.database import StorageDatabase
from game_data_capture.storage.repository import StorageError, _id
_KINDS = {"classify", "ocr", "vision_fallback", "frame_extract", "export"}
def _now() -> datetime:
return datetime.now(UTC)
def _lease(seconds: int) -> None:
if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= 300:
raise ValueError("lease duration must be from 1 to 300 seconds")
class JobLeases:
def __init__(self, database: StorageDatabase) -> None:
self.database = database
def create(
self, job_id: str, kind: str, capture_id: str | None = None, max_attempts: int = 3
) -> None:
_id(job_id)
if capture_id is not None:
_id(capture_id)
if (
kind not in _KINDS
or isinstance(max_attempts, bool)
or not isinstance(max_attempts, int)
or not 1 <= max_attempts <= 20
):
raise ValueError("invalid job definition")
with self.database.connect() as connection:
connection.execute(
"INSERT INTO jobs(job_id, capture_id, kind, state, max_attempts) "
"VALUES (?, ?, ?, 'queued', ?)",
(job_id, capture_id, kind, max_attempts),
)
def _recover_expired(self, connection: sqlite3.Connection, now: datetime) -> None:
# State changes use the contract path: running -> retry_wait -> queued/failed.
rows = list(
connection.execute(
"SELECT job_id, attempt, max_attempts FROM jobs "
"WHERE state = 'running' AND lease_expires_at < ?",
(now.isoformat(),),
)
)
for row in rows:
require_transition("job", "running", "retry_wait")
changed = connection.execute(
"UPDATE jobs SET state = 'retry_wait', lease_owner = NULL, "
"lease_expires_at = NULL, revision = revision + 1 "
"WHERE job_id = ? AND state = 'running'",
(row["job_id"],),
).rowcount
if changed != 1:
raise StorageError("expired job changed concurrently")
target = "failed" if row["attempt"] >= row["max_attempts"] else "queued"
require_transition("job", "retry_wait", target)
changed = connection.execute(
"UPDATE jobs SET state = ?, revision = revision + 1 "
"WHERE job_id = ? AND state = 'retry_wait'",
(target, row["job_id"]),
).rowcount
if changed != 1:
raise StorageError("expired job changed concurrently")
def acquire(self, owner: str, lease_seconds: int) -> str | None:
_id(owner)
_lease(lease_seconds)
now = _now()
with self.database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
self._recover_expired(connection, now)
row = connection.execute(
"SELECT job_id FROM jobs WHERE state = 'queued' "
"AND attempt < max_attempts ORDER BY job_id LIMIT 1"
).fetchone()
if row is None:
connection.execute("COMMIT")
return None
result = connection.execute(
"UPDATE jobs SET state = 'running', lease_owner = ?, lease_expires_at = ?, "
"attempt = attempt + 1, revision = revision + 1 WHERE job_id = ? "
"AND state = 'queued' AND attempt < max_attempts",
(owner, (now + timedelta(seconds=lease_seconds)).isoformat(), row["job_id"]),
)
connection.execute("COMMIT")
return str(row["job_id"]) if result.rowcount == 1 else None
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
def renew(self, job_id: str, owner: str, lease_seconds: int) -> None:
_id(job_id)
_id(owner)
_lease(lease_seconds)
now = _now()
with self.database.connect() as connection:
result = connection.execute(
"UPDATE jobs SET lease_expires_at = ?, revision = revision + 1 "
"WHERE job_id = ? AND state = 'running' AND lease_owner = ? "
"AND lease_expires_at >= ?",
(
(now + timedelta(seconds=lease_seconds)).isoformat(),
job_id,
owner,
now.isoformat(),
),
)
if result.rowcount != 1:
raise StorageError("lease is not owned or has expired")
def finish(self, job_id: str, owner: str, target: str) -> None:
_id(job_id)
_id(owner)
if target not in {"succeeded", "failed", "cancelled"}:
raise ValueError("job finish target must be terminal")
require_transition("job", "running", target)
now = _now()
with self.database.connect() as connection:
result = connection.execute(
"UPDATE jobs SET state = ?, lease_owner = NULL, lease_expires_at = NULL, "
"revision = revision + 1 WHERE job_id = ? AND state = 'running' "
"AND lease_owner = ? AND lease_expires_at >= ?",
(target, job_id, owner, now.isoformat()),
)
if result.rowcount != 1:
raise StorageError("lease is not owned or has expired")
def recover(self) -> int:
now = _now()
with self.database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
before = connection.execute(
"SELECT count(*) FROM jobs WHERE state = 'running' AND lease_expires_at < ?",
(now.isoformat(),),
).fetchone()[0]
self._recover_expired(connection, now)
connection.execute("COMMIT")
return int(before)
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
@@ -0,0 +1,545 @@
"""Bounded lifecycle wiring for local capture intake and utility storage."""
from __future__ import annotations
import fcntl
import os
import secrets
import signal
import stat
import time
from collections.abc import Callable, Iterable, Iterator, Mapping
from contextlib import AbstractContextManager, contextmanager, suppress
from dataclasses import dataclass
from pathlib import Path
from threading import Event, RLock, Thread, current_thread, main_thread
from typing import Protocol, cast
import uvicorn
from game_data_capture.config import AppConfig
from game_data_capture.ingest.clipboard import ClipboardIntake, IntakeReceipt, WatchConfig
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE
from game_data_capture.paths import RuntimePaths, prepare_private_directories, prepare_private_file
from game_data_capture.pipeline.jobs import JobLeases
from game_data_capture.storage import (
CaptureCorpus,
CaptureRepository,
ContentAddressedBlobStore,
StorageDatabase,
)
from game_data_capture.web.app import (
CaptureControlPort,
CaptureSessionStatus,
ControlOutcome,
WebConfig,
create_app,
)
from game_data_capture.web.security import new_bootstrap_token
_POLICY_VERSION = "capture-v1"
_PROCESSING_VERSION = "capture-v1"
_POLL_SECONDS = 0.1
class RuntimeFailure(RuntimeError):
"""A safe-to-display runtime failure that does not disclose local inputs."""
class IntakePort(Protocol):
"""The accepted media descriptor boundary consumed by this runtime."""
def reconcile(self) -> list[str]: ...
def arm(self) -> None: ...
def tick(self) -> None: ...
def take_helper_receipt(self) -> dict[str, object] | None: ...
def accept_helper_receipt(
self,
payload: Mapping[str, object],
*,
occurrence_id: str,
processing_version: str,
) -> bool: ...
def take(self) -> IntakeReceipt | None: ...
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]: ...
def acknowledge(self, receipt: IntakeReceipt) -> bool: ...
def shutdown(self) -> None: ...
class InstanceLock:
"""Descriptor-held ownership that safely reuses an unlocked stale lock file."""
def __init__(self, file_descriptor: int) -> None:
self._file_descriptor: int | None = file_descriptor
@classmethod
def acquire(cls, path: Path, timeout_seconds: int) -> InstanceLock:
try:
prepare_private_file(path)
descriptor = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError as error:
raise RuntimeFailure("capture instance lock is unavailable") from error
try:
info = os.fstat(descriptor)
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600:
raise RuntimeFailure("capture instance lock is unsafe")
deadline = time.monotonic() + timeout_seconds
while True:
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
return cls(descriptor)
except BlockingIOError:
if time.monotonic() >= deadline:
raise RuntimeFailure("another capture instance is active") from None
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
except Exception:
os.close(descriptor)
raise
def release(self) -> None:
if self._file_descriptor is None:
return
descriptor, self._file_descriptor = self._file_descriptor, None
with suppress(OSError):
fcntl.flock(descriptor, fcntl.LOCK_UN)
with suppress(OSError):
os.close(descriptor)
@dataclass(slots=True)
class _Resources:
repository: CaptureRepository
corpus: CaptureCorpus
lock: InstanceLock
def close(self) -> None:
self.lock.release()
class _CaptureControl(CaptureControlPort):
"""Serialize one optional armed intake beneath a running local service."""
def __init__(
self,
runtime: CaptureRuntime,
repository: CaptureRepository,
) -> None:
self._runtime = runtime
self._repository = repository
self._lock = RLock()
self._cancelled: Event | None = None
self._intake: IntakePort | None = None
self._worker: Thread | None = None
self._state = "disarmed"
def status(self) -> CaptureSessionStatus:
with self._lock:
self._reap_finished_worker()
intake = self._intake
depth = self._queue_depth(intake)
return CaptureSessionStatus(
armed=self._state in {"armed", "stopping"},
queue_depth=depth,
queue_capacity=self._runtime.config.limits.max_queue_items,
)
def start_session(self) -> ControlOutcome:
with self._lock:
self._reap_finished_worker()
if self._state == "armed":
return "already_armed"
if self._state == "stopping":
return "conflict"
if self._worker is not None:
return "conflict"
intake: IntakePort | None = None
try:
intake = self._runtime.intake_factory(self._runtime._watch_config())
# Durable media always becomes eligible before the watcher is armed.
intake.reconcile()
intake.arm()
except Exception:
if intake is not None:
with suppress(Exception):
intake.shutdown()
self._state = "disarmed"
return "conflict"
assert intake is not None
cancelled = Event()
worker = Thread(
target=self._drain_loop,
args=(intake, cancelled),
name="shd-capture-drain",
daemon=True,
)
self._intake = intake
self._cancelled = cancelled
self._worker = worker
self._state = "armed"
try:
worker.start()
except Exception:
self._state = "disarmed"
self._intake = self._cancelled = self._worker = None
with suppress(Exception):
intake.shutdown()
return "conflict"
return "started"
def stop_session(self) -> ControlOutcome:
with self._lock:
self._reap_finished_worker()
worker = self._worker
if worker is None:
return "already_stopped"
if self._state == "stopping":
return "conflict"
self._state = "stopping"
cancelled = self._cancelled
intake = self._intake
return "stopped" if self._stop_worker(worker, intake, cancelled) else "conflict"
def shutdown(self) -> bool:
"""Request child shutdown before the service gives up its instance lock."""
with self._lock:
self._reap_finished_worker()
worker = self._worker
if worker is None:
return True
self._state = "stopping"
intake = self._intake
cancelled = self._cancelled
return self._stop_worker(worker, intake, cancelled)
def defer_resource_close(self, close: Callable[[], None]) -> None:
"""Release the service lock only after a timed-out drain worker exits."""
with self._lock:
self._reap_finished_worker()
worker = self._worker
if worker is None:
close()
return
def wait_for_worker() -> None:
worker.join()
close()
Thread(target=wait_for_worker, name="shd-capture-release", daemon=True).start()
def _stop_worker(
self,
worker: Thread,
intake: IntakePort | None,
cancelled: Event | None,
) -> bool:
if cancelled is not None:
cancelled.set()
timeout = float(self._runtime.config.lifecycle.shutdown_timeout_seconds)
initial_timeout = timeout / 2
# Never hold the control lock while waiting for a drain or child cleanup.
worker.join(timeout=initial_timeout)
if worker.is_alive():
# A receipt in flight remains durable because cancellation prevents its
# acknowledgement. Stopping intake unblocks watcher/helper cleanup.
if intake is not None:
with suppress(Exception):
intake.shutdown()
worker.join(timeout=timeout - initial_timeout)
with self._lock:
self._reap_finished_worker()
return not worker.is_alive()
def _drain_loop(self, intake: IntakePort, cancelled: Event) -> None:
try:
while not cancelled.is_set():
intake.tick()
self._runtime._drain(intake, self._repository, cancelled.is_set)
cancelled.wait(_POLL_SECONDS)
except Exception:
# The HTTP boundary exposes only bounded armed/queue state, not an
# intake, storage, helper, or media exception.
pass
finally:
with suppress(Exception):
intake.shutdown()
with self._lock:
if self._intake is intake:
self._state = "disarmed"
def _reap_finished_worker(self) -> None:
if self._worker is not None and not self._worker.is_alive():
self._worker = self._cancelled = self._intake = None
self._state = "disarmed"
def _queue_depth(self, intake: IntakePort | None) -> int:
if intake is None:
return 0
depths: list[int] = []
for name in ("queue_depth", "helper_queue_depth"):
try:
value = getattr(intake, name, 0)
except Exception:
value = 0
if not isinstance(value, bool) and isinstance(value, int) and value >= 0:
depths.append(value)
return min(sum(depths), self._runtime.config.limits.max_queue_items)
@contextmanager
def _verified_png_file(path: Path, max_bytes: int) -> Iterator[Iterable[bytes]]:
"""Yield a one-descriptor PNG stream and reject unsafe input without path disclosure."""
candidate = path.absolute()
if len(str(candidate)) > 240:
raise RuntimeFailure("input image is not a safe PNG")
try:
descriptor = os.open(candidate, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError as error:
raise RuntimeFailure("input image is not a safe PNG") from error
try:
info = os.fstat(descriptor)
if (
not stat.S_ISREG(info.st_mode)
or stat.S_IMODE(info.st_mode) & 0o022
or not 8 <= info.st_size <= max_bytes
):
raise RuntimeFailure("input image is not a safe PNG")
def chunks() -> Iterator[bytes]:
first = os.read(descriptor, len(PNG_SIGNATURE))
if first != PNG_SIGNATURE:
raise RuntimeFailure("input image is not a safe PNG")
total = len(first)
yield first
while chunk := os.read(descriptor, 64 * 1024):
total += len(chunk)
if total > max_bytes:
raise RuntimeFailure("input image is not a safe PNG")
yield chunk
if total != info.st_size:
raise RuntimeFailure("input image is not a safe PNG")
yield chunks()
finally:
os.close(descriptor)
class CaptureRuntime:
"""Own utility-local storage, then stream verified media receipts into it."""
def __init__(
self,
config: AppConfig,
paths: RuntimePaths,
*,
migrations_dir: Path | None = None,
intake_factory: Callable[[WatchConfig], IntakePort] = ClipboardIntake,
) -> None:
self.config = config
self.paths = paths
self.migrations_dir = (
Path(__file__).resolve().parents[2] / "migrations"
if migrations_dir is None
else migrations_dir
)
self.intake_factory = intake_factory
def _watch_config(self) -> WatchConfig:
return WatchConfig(
spool=self.paths.cache_dir / "spool",
max_image_bytes=self.config.limits.max_image_bytes,
max_queue_items=self.config.limits.max_queue_items,
policy_version=_POLICY_VERSION,
shutdown_timeout_seconds=float(min(30, self.config.lifecycle.shutdown_timeout_seconds)),
)
def validate_plan(self) -> None:
"""Validate static configuration and paths without creating runtime resources."""
self._watch_config()
if not self.migrations_dir.is_absolute() or not self.migrations_dir.is_dir():
raise RuntimeFailure("capture runtime resources are unavailable")
StorageDatabase(self.paths.data_dir, self.migrations_dir)
ContentAddressedBlobStore(self.paths.data_dir)
def _start(self) -> _Resources:
self.validate_plan()
prepare_private_directories(self.paths)
lock = InstanceLock.acquire(
self.paths.instance_lock_file, self.config.lifecycle.lock_timeout_seconds
)
try:
database = StorageDatabase(self.paths.data_dir, self.migrations_dir)
database.migrate()
blobs = ContentAddressedBlobStore(self.paths.data_dir)
blobs.reconcile()
JobLeases(database).recover()
corpus = CaptureCorpus(
database,
blobs,
max_media_bytes=self.config.limits.max_image_bytes,
)
corpus.reconcile_deletions()
return _Resources(CaptureRepository(database, blobs), corpus, lock)
except RuntimeFailure:
lock.release()
raise
except Exception as error:
lock.release()
raise RuntimeFailure("capture runtime initialization failed") from error
def ingest_image(self, image: Path) -> str:
"""Persist one descriptor-verified PNG as a unique capture occurrence."""
resources = self._start()
token = secrets.token_hex(16)
try:
with _verified_png_file(image, self.config.limits.max_image_bytes) as stream:
return resources.repository.receive(
f"capture-{token}",
f"image-{token}",
stream,
media_type="image/png",
max_bytes=self.config.limits.max_image_bytes,
)
except RuntimeFailure:
raise
except Exception as error:
raise RuntimeFailure("image capture could not be stored") from error
finally:
resources.close()
def _drain(
self,
intake: IntakePort,
repository: CaptureRepository,
cancellation_requested: Callable[[], bool],
) -> None:
while (
not cancellation_requested() and (payload := intake.take_helper_receipt()) is not None
):
intake.accept_helper_receipt(
payload,
occurrence_id=f"capture-{secrets.token_hex(16)}",
processing_version=_PROCESSING_VERSION,
)
while not cancellation_requested() and (receipt := intake.take()) is not None:
with intake.open_verified(receipt) as reader:
repository.receive(
receipt.occurrence_id,
receipt.receipt_token,
reader,
media_type="image/png",
max_bytes=self.config.limits.max_image_bytes,
)
if cancellation_requested():
return
if not intake.acknowledge(receipt):
raise RuntimeFailure("capture receipt acknowledgement failed")
def serve(self) -> None:
"""Run one local web service with unarmed, durable capture control."""
bootstrap_token = new_bootstrap_token()
resources = self._start()
control = _CaptureControl(self, resources.repository)
authority = (
f"[{self.config.server.host}]"
if ":" in self.config.server.host
else self.config.server.host
)
try:
app = create_app(
config=WebConfig(
server=self.config.server,
max_capture_items=min(64, self.config.limits.max_queue_items),
max_image_bytes=self.config.limits.max_image_bytes,
),
capture_control=control,
corpus=resources.corpus,
bootstrap_token=bootstrap_token,
)
print(
f"Open local review service: http://{authority}:{self.config.server.port}/"
f"#bootstrap={bootstrap_token}"
)
print("H1: capture is disarmed; use Start Session before taking a screenshot.")
try:
uvicorn.run(
app,
host=self.config.server.host,
port=self.config.server.port,
reload=False,
workers=1,
proxy_headers=False,
)
except SystemExit as error:
if error.code not in (None, 0):
raise RuntimeFailure("capture service failed") from error
raise
except KeyboardInterrupt:
return
except RuntimeFailure:
raise
except Exception as error:
raise RuntimeFailure("capture service failed") from error
finally:
if control.shutdown():
resources.close()
else:
control.defer_resource_close(resources.close)
raise RuntimeFailure("capture service shutdown failed")
def watch(self, *, cancelled: Event | None = None, poll_seconds: float = _POLL_SECONDS) -> None:
"""Run the armed intake until cancellation, signal, or keyboard interruption."""
if not 0 < poll_seconds <= 1:
raise ValueError("invalid runtime poll interval")
resources = self._start()
intake: IntakePort | None = None
stop_requested = Event()
handlers: dict[signal.Signals, object] = {}
def request_stop(_signal_number: int, _frame: object) -> None:
stop_requested.set()
try:
if current_thread() is main_thread():
for configured_signal in (signal.SIGINT, signal.SIGTERM):
handlers[configured_signal] = signal.signal(configured_signal, request_stop)
intake = self.intake_factory(self._watch_config())
intake.reconcile()
intake.arm()
while not stop_requested.is_set() and (cancelled is None or not cancelled.is_set()):
intake.tick()
self._drain(
intake,
resources.repository,
lambda: (
stop_requested.is_set() or (cancelled is not None and cancelled.is_set())
),
)
time.sleep(poll_seconds)
except KeyboardInterrupt:
return
except RuntimeFailure:
raise
except Exception as error:
raise RuntimeFailure("capture watch failed") from error
finally:
if intake is not None:
with suppress(OSError, RuntimeError):
intake.shutdown()
for restored_signal, handler in handlers.items():
signal.signal(restored_signal, cast(signal.Handlers, handler))
resources.close()
@@ -0,0 +1,15 @@
"""Utility-local durable storage; it never opens the SHD.build application database."""
from game_data_capture.storage.blobs import BlobCorruptionError, ContentAddressedBlobStore
from game_data_capture.storage.corpus import CaptureCorpus
from game_data_capture.storage.database import StorageDatabase
from game_data_capture.storage.repository import CaptureRepository, StorageError
__all__ = [
"BlobCorruptionError",
"CaptureCorpus",
"CaptureRepository",
"ContentAddressedBlobStore",
"StorageDatabase",
"StorageError",
]
@@ -0,0 +1,264 @@
"""Verified immutable blobs with no symlink traversal at storage boundaries."""
from __future__ import annotations
import hashlib
import os
import secrets
import time
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from pathlib import Path
from stat import S_IMODE, S_ISDIR, S_ISREG
class BlobCorruptionError(RuntimeError):
pass
class BlobLimitError(ValueError):
pass
class BlobLockError(RuntimeError):
pass
def _digest(value: str) -> None:
if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
raise ValueError("invalid SHA-256 digest")
class ContentAddressedBlobStore:
def __init__(self, root: Path) -> None:
if not root.is_absolute():
raise ValueError("blob root must be absolute")
self.root = root
self.blobs = root / "blobs"
self.temporary = root / "tmp"
self.lock_path = root / ".blob-operation.lock"
@contextmanager
def operation_lock(self, *, timeout_seconds: float = 1.0) -> Iterator[None]:
"""Hold the private cross-process lock for receipt finalization or deletion."""
if (
isinstance(timeout_seconds, bool)
or not isinstance(timeout_seconds, int | float)
or not 0 < timeout_seconds <= 5
):
raise ValueError("blob lock timeout is invalid")
self._directory(self.root, create=True)
try:
fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
except OSError as error:
raise BlobLockError("blob operation lock is unavailable") from error
try:
info = os.fstat(fd)
if not S_ISREG(info.st_mode) or S_IMODE(info.st_mode) != 0o600:
raise BlobLockError("blob operation lock is unsafe")
import fcntl
deadline = time.monotonic() + timeout_seconds
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise BlobLockError("blob operation lock is unavailable") from None
remaining = deadline - time.monotonic()
if remaining <= 0:
raise BlobLockError("blob operation lock is unavailable") from None
time.sleep(min(0.02, remaining))
yield
finally:
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)
def _directory(self, path: Path, *, create: bool) -> None:
if path.is_symlink():
raise BlobCorruptionError("storage directory is unsafe")
if create:
path.mkdir(mode=0o700, parents=True, exist_ok=True)
if not path.exists() or path.is_symlink() or not S_ISDIR(path.lstat().st_mode):
raise BlobCorruptionError("storage directory is unsafe")
path.chmod(0o700)
def _path(self, digest: str) -> Path:
_digest(digest)
return self.blobs / digest[:2] / digest[2:]
def _fsync_directory(self, path: Path) -> None:
if path.is_symlink():
raise BlobCorruptionError("storage directory is unsafe")
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
os.fsync(fd)
finally:
os.close(fd)
def _verify_fd(self, fd: int, digest: str, expected_size: int | None) -> int:
stat = os.fstat(fd)
if not S_ISREG(stat.st_mode):
raise BlobCorruptionError("blob is not a regular file")
if expected_size is not None and stat.st_size != expected_size:
raise BlobCorruptionError("blob content does not match receipt")
os.lseek(fd, 0, os.SEEK_SET)
hasher = hashlib.sha256()
size = 0
while chunk := os.read(fd, 1024 * 1024):
size += len(chunk)
hasher.update(chunk)
if hasher.hexdigest() != digest or (expected_size is not None and size != expected_size):
raise BlobCorruptionError("blob content does not match receipt")
os.lseek(fd, 0, os.SEEK_SET)
return size
def put(
self, chunks: Iterable[bytes], *, max_bytes: int, expected_sha256: str | None = None
) -> tuple[str, int]:
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
raise BlobLimitError("blob limit must be positive")
if expected_sha256 is not None:
_digest(expected_sha256)
self._directory(self.root, create=True)
self._directory(self.blobs, create=True)
self._directory(self.temporary, create=True)
temporary = self.temporary / f".{secrets.token_hex(16)}.partial"
hasher = hashlib.sha256()
size = 0
try:
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
for chunk in chunks:
if not isinstance(chunk, bytes) or not chunk:
raise BlobLimitError("blob chunks must be non-empty bytes")
size += len(chunk)
if size > max_bytes:
raise BlobLimitError("blob exceeds configured size limit")
hasher.update(chunk)
view = memoryview(chunk)
while view:
written = os.write(fd, view)
if written <= 0:
raise OSError("short blob write")
view = view[written:]
os.fsync(fd)
finally:
os.close(fd)
digest = hasher.hexdigest()
if size == 0 or (expected_sha256 is not None and digest != expected_sha256):
raise BlobCorruptionError("blob digest verification failed")
with self.operation_lock():
destination = self._path(digest)
new_prefix = not destination.parent.exists()
self._directory(destination.parent, create=True)
if new_prefix:
self._fsync_directory(self.blobs)
directory_fd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
if destination.exists():
self.verify(digest, expected_size=size)
else:
os.replace(temporary, destination)
os.chmod(destination, 0o600)
os.fsync(directory_fd)
finally:
os.close(directory_fd)
self.verify(digest, expected_size=size)
return digest, size
finally:
if temporary.exists() and not temporary.is_symlink():
temporary.unlink()
def _open_verified(self, digest: str, expected_size: int | None = None) -> tuple[int, int]:
path = self._path(digest)
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as error:
raise BlobCorruptionError("blob is missing or unsafe") from error
try:
return fd, self._verify_fd(fd, digest, expected_size)
except Exception:
os.close(fd)
raise
def verify(self, digest: str, *, expected_size: int | None = None) -> int:
fd, size = self._open_verified(digest, expected_size)
os.close(fd)
return size
def read(
self, digest: str, *, max_bytes: int, expected_size: int | None = None
) -> Iterator[bytes]:
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
raise BlobLimitError("blob limit must be positive")
fd, size = self._open_verified(digest, expected_size)
if size > max_bytes:
os.close(fd)
raise BlobLimitError("blob exceeds read limit")
try:
while chunk := os.read(fd, 1024 * 1024):
yield chunk
finally:
os.close(fd)
def delete_verified(self, digest: str, *, expected_size: int) -> bool:
"""Unlink one verified immutable blob without following a replacement path."""
if (
isinstance(expected_size, bool)
or not isinstance(expected_size, int)
or expected_size <= 0
):
raise BlobLimitError("blob size is invalid")
fd, _ = self._open_verified(digest, expected_size)
path = self._path(digest)
try:
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
current = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False)
opened = os.fstat(fd)
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
raise BlobCorruptionError("blob changed during deletion")
os.unlink(path.name, dir_fd=parent_fd)
os.fsync(parent_fd)
return True
finally:
os.close(parent_fd)
except OSError as error:
raise BlobCorruptionError("blob is missing or unsafe") from error
finally:
os.close(fd)
def reconcile(self) -> list[str]:
if not self.temporary.exists() or self.temporary.is_symlink():
return []
self._directory(self.temporary, create=False)
removed = []
for entry in self.temporary.iterdir():
if not entry.is_symlink() and entry.is_file() and entry.name.endswith(".partial"):
entry.unlink()
removed.append(entry.name)
return removed
def purge_dry_run(self, digests: set[str]) -> list[Path]:
if not self.blobs.exists() or self.blobs.is_symlink():
return []
self._directory(self.blobs, create=False)
candidates = []
for prefix in self.blobs.iterdir():
if prefix.is_symlink() or not prefix.is_dir():
continue
for entry in prefix.iterdir():
if (
not entry.is_symlink()
and entry.is_file()
and prefix.name + entry.name not in digests
):
candidates.append(entry.relative_to(self.root))
return candidates
@@ -0,0 +1,499 @@
"""Durable, safe projections for the reviewed local capture corpus port."""
from __future__ import annotations
import json
import re
import sqlite3
from collections.abc import Mapping, Sequence
from game_data_capture.domain.validation import (
ContractValidationError,
validate_classification,
)
from game_data_capture.storage.blobs import (
BlobCorruptionError,
BlobLockError,
ContentAddressedBlobStore,
)
from game_data_capture.storage.database import StorageDatabase
from game_data_capture.storage.repository import StorageError, _now
from game_data_capture.web.app import CaptureRecord, MediaDescriptor, MutationOutcome
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
_CLASSIFICATION_KEYS = frozenset(
{"family", "view", "gear_slot", "record_types", "confidence", "abstention_reason"}
)
_MAX_MEDIA_BYTES = 64 * 1024 * 1024
def _capture_id(value: str) -> bool:
return isinstance(value, str) and bool(_CAPTURE_ID.fullmatch(value))
def _classification(value: Mapping[str, object]) -> str:
if not isinstance(value, Mapping) or set(value) != _CLASSIFICATION_KEYS:
raise ValueError("classification does not match the frozen taxonomy shape")
payload = dict(value)
try:
validate_classification(payload)
confidence = payload["confidence"]
record_types = payload["record_types"]
if isinstance(confidence, bool) or not isinstance(confidence, int | float):
raise ValueError("classification confidence is invalid")
if not isinstance(record_types, list) or any(
not isinstance(record_type, str) for record_type in record_types
):
raise ValueError("classification record types are invalid")
canonical_confidence = 0.0 if confidence == 0 else float(confidence)
canonical = {
"family": payload["family"],
"view": payload["view"],
"gear_slot": payload["gear_slot"],
"record_types": sorted(record_types),
"confidence": canonical_confidence,
"abstention_reason": payload["abstention_reason"],
}
encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
except (ContractValidationError, TypeError, ValueError) as error:
raise ValueError("classification does not match the frozen taxonomy shape") from error
if not 2 <= len(encoded) <= 4096:
raise ValueError("classification does not match the frozen taxonomy shape")
return encoded
def _stored_classification(value: object) -> Mapping[str, object]:
if not isinstance(value, str) or not 2 <= len(value) <= 4096:
raise StorageError("stored corpus record is invalid")
try:
decoded = json.loads(value)
except (TypeError, ValueError) as error:
raise StorageError("stored corpus record is invalid") from error
if not isinstance(decoded, dict) or set(decoded) != _CLASSIFICATION_KEYS:
raise StorageError("stored corpus record is invalid")
try:
validate_classification(decoded)
except ContractValidationError as error:
raise StorageError("stored corpus record is invalid") from error
return decoded
class CaptureCorpus:
"""SQLite-backed implementation of the reviewed ``CaptureCorpusPort``.
The corpus exposes only opaque capture identifiers. It serializes writes with
``BEGIN IMMEDIATE`` so repeated labels and rejections have deterministic
idempotence, while differing labels preserve the replaced value as evidence.
"""
def __init__(
self,
database: StorageDatabase,
blobs: ContentAddressedBlobStore,
*,
max_media_bytes: int = _MAX_MEDIA_BYTES,
) -> None:
if (
isinstance(max_media_bytes, bool)
or not isinstance(max_media_bytes, int)
or not 1 <= max_media_bytes <= _MAX_MEDIA_BYTES
):
raise ValueError("corpus media limit is invalid")
self.database = database
self.blobs = blobs
self.max_media_bytes = max_media_bytes
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 256:
raise ValueError("capture list limit is invalid")
with self.database.connect() as connection:
rows = connection.execute(
"SELECT c.capture_id, c.state, c.received_at, l.classification_json "
"FROM captures AS c LEFT JOIN capture_labels AS l USING(capture_id) "
"ORDER BY c.received_at DESC, c.capture_id DESC LIMIT ?",
(limit,),
)
return [self._record(row) for row in rows]
def get_capture(self, capture_id: str) -> CaptureRecord | None:
if not _capture_id(capture_id):
return None
with self.database.connect() as connection:
row = connection.execute(
"SELECT c.capture_id, c.state, c.received_at, l.classification_json "
"FROM captures AS c LEFT JOIN capture_labels AS l USING(capture_id) "
"WHERE c.capture_id = ?",
(capture_id,),
).fetchone()
return None if row is None else self._record(row)
def open_media(self, capture_id: str) -> MediaDescriptor | None:
if not _capture_id(capture_id):
return None
with self.database.connect() as connection:
row = connection.execute(
"SELECT b.sha256, b.byte_size, b.media_type FROM captures AS c "
"JOIN blobs AS b ON b.sha256 = c.blob_sha256 WHERE c.capture_id = ?",
(capture_id,),
).fetchone()
if row is None:
return None
digest, size, media_type = row["sha256"], row["byte_size"], row["media_type"]
if (
not isinstance(digest, str)
or isinstance(size, bool)
or not isinstance(size, int)
or not 1 <= size <= self.max_media_bytes
or not isinstance(media_type, str)
or media_type not in _MEDIA_TYPES
):
raise StorageError("stored corpus media is invalid")
try:
chunks = self.blobs.read(
digest,
max_bytes=self.max_media_bytes,
expected_size=size,
)
except (BlobCorruptionError, ValueError) as error:
raise StorageError("stored corpus media is unavailable") from error
return MediaDescriptor(media_type=media_type, byte_size=size, chunks=chunks)
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
if not _capture_id(capture_id):
return "not_found"
encoded = _classification(classification)
with self.database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
if not self._exists(connection, capture_id):
connection.execute("COMMIT")
return "not_found"
if self._has_rejection(connection, capture_id):
connection.execute("COMMIT")
return "conflict"
current = connection.execute(
"SELECT classification_json, revision FROM capture_labels WHERE capture_id = ?",
(capture_id,),
).fetchone()
if current is None:
connection.execute(
"INSERT INTO capture_labels(capture_id, classification_json, revision, "
"labeled_at) VALUES (?, ?, 0, ?)",
(capture_id, encoded, _now()),
)
else:
current_json = current["classification_json"]
_stored_classification(current_json)
if current_json == encoded:
connection.execute("COMMIT")
return "updated"
revision = current["revision"]
revision_is_valid = (
not isinstance(revision, bool)
and isinstance(revision, int)
and revision >= 0
)
if not revision_is_valid:
raise StorageError("stored corpus label is invalid")
assert isinstance(revision, int) and not isinstance(revision, bool)
connection.execute(
"INSERT INTO capture_label_evidence(capture_id, replaced_revision, "
"classification_json, replaced_at) VALUES (?, ?, ?, ?)",
(capture_id, revision, current_json, _now()),
)
result = connection.execute(
"UPDATE capture_labels SET classification_json = ?, "
"revision = revision + 1, labeled_at = ? "
"WHERE capture_id = ? AND revision = ?",
(encoded, _now(), capture_id, revision),
)
if result.rowcount != 1:
connection.execute("ROLLBACK")
return "conflict"
connection.execute("COMMIT")
return "updated"
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
if not _capture_id(capture_id):
return "not_found"
with self.database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
if not self._exists(connection, capture_id):
connection.execute("COMMIT")
return "not_found"
if not self._has_rejection(connection, capture_id):
connection.execute(
"INSERT INTO capture_rejections(capture_id, rejected_at) VALUES (?, ?)",
(capture_id, _now()),
)
connection.execute("COMMIT")
return "updated"
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
if not _capture_id(capture_id):
return "not_found"
try:
with self.blobs.operation_lock():
return self._delete_locked(capture_id)
except BlobLockError as error:
raise StorageError("blob operation is unavailable") from error
def _delete_locked(self, capture_id: str) -> MutationOutcome:
with self.database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
row = connection.execute(
"SELECT blob_sha256, state, received_at, receipt_token, "
"failure_reason, revision "
"FROM captures WHERE capture_id = ?",
(capture_id,),
).fetchone()
if row is None:
connection.execute("COMMIT")
return "not_found"
if self._retained(connection, capture_id) or not self._deletable_capture(row):
connection.execute("COMMIT")
return "retained"
digest = row["blob_sha256"]
blob = connection.execute(
"SELECT byte_size FROM blobs WHERE sha256 = ?", (digest,)
).fetchone()
if (
not isinstance(digest, str)
or blob is None
or isinstance(blob["byte_size"], bool)
or not isinstance(blob["byte_size"], int)
or not 1 <= blob["byte_size"] <= self.max_media_bytes
):
connection.execute("COMMIT")
return "retained"
try:
self.blobs.verify(digest, expected_size=blob["byte_size"])
except (BlobCorruptionError, ValueError):
connection.execute("COMMIT")
return "retained"
references = connection.execute(
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest,)
).fetchone()[0]
if not isinstance(references, int) or references < 1:
connection.execute("ROLLBACK")
return "conflict"
if references == 1:
connection.execute(
"INSERT INTO capture_deletion_intents(blob_sha256, capture_id, byte_size, "
"capture_state, received_at, receipt_token, failure_reason, revision, "
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
digest,
capture_id,
blob["byte_size"],
row["state"],
row["received_at"],
row["receipt_token"],
row["failure_reason"],
row["revision"],
_now(),
),
)
result = connection.execute(
"DELETE FROM captures WHERE capture_id = ?", (capture_id,)
)
if result.rowcount != 1:
connection.execute("ROLLBACK")
return "conflict"
connection.execute("COMMIT")
if references > 1:
return "updated"
return (
"updated"
if self._reconcile_locked(limit=1, restore_on_failure=True)
else "retained"
)
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
@staticmethod
def _exists(connection: sqlite3.Connection, capture_id: str) -> bool:
return (
connection.execute(
"SELECT 1 FROM captures WHERE capture_id = ?", (capture_id,)
).fetchone()
is not None
)
@staticmethod
def _has_rejection(connection: sqlite3.Connection, capture_id: str) -> bool:
return (
connection.execute(
"SELECT 1 FROM capture_rejections WHERE capture_id = ?", (capture_id,)
).fetchone()
is not None
)
@staticmethod
def _retained(connection: sqlite3.Connection, capture_id: str) -> bool:
tables = (
"jobs",
"ocr_runs",
"capture_labels",
"capture_label_evidence",
"capture_rejections",
)
if any(
connection.execute(
f"SELECT 1 FROM {table} WHERE capture_id = ? LIMIT 1", (capture_id,)
).fetchone()
is not None
for table in tables
):
return True
if (
connection.execute(
"SELECT 1 FROM runtime_failures WHERE subject_type = 'capture' "
"AND subject_id = ? LIMIT 1",
(capture_id,),
).fetchone()
is not None
):
return True
return (
connection.execute(
"SELECT 1 FROM lineage WHERE child_id = ? OR parent_id = ? LIMIT 1",
(capture_id, capture_id),
).fetchone()
is not None
)
@staticmethod
def _deletable_capture(row: sqlite3.Row) -> bool:
return row["state"] in {"stored", "deduplicated"} and row["failure_reason"] is None
def reconcile_deletions(self, *, limit: int = 64) -> int:
"""Finish bounded committed deletion intents after a crash or unlink failure."""
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
raise ValueError("deletion reconciliation limit is invalid")
try:
with self.blobs.operation_lock():
return self._reconcile_locked(limit=limit, restore_on_failure=False)
except BlobLockError as error:
raise StorageError("blob operation is unavailable") from error
def _reconcile_locked(self, *, limit: int, restore_on_failure: bool) -> int:
with self.database.connect() as connection:
intents = list(
connection.execute(
"SELECT blob_sha256, byte_size, capture_id, capture_state, received_at, "
"receipt_token, failure_reason, revision FROM capture_deletion_intents "
"ORDER BY created_at, capture_id LIMIT ?",
(limit,),
)
)
completed = 0
for intent in intents:
digest, size = intent["blob_sha256"], intent["byte_size"]
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
continue
try:
self.blobs.delete_verified(digest, expected_size=size)
except BlobCorruptionError:
try:
self.blobs._path(digest).lstat()
except FileNotFoundError:
pass
else:
if restore_on_failure and self._restore_intent(intent):
continue
if restore_on_failure:
raise StorageError("blob deletion could not be completed") from None
continue
with self.database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
active = connection.execute(
"SELECT 1 FROM captures WHERE blob_sha256 = ?", (digest,)
).fetchone()
if active is not None:
connection.execute("ROLLBACK")
continue
result = connection.execute(
"DELETE FROM capture_deletion_intents WHERE blob_sha256 = ?", (digest,)
)
connection.execute("DELETE FROM blobs WHERE sha256 = ?", (digest,))
connection.execute("COMMIT")
completed += result.rowcount
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
return completed
def _restore_intent(self, intent: sqlite3.Row) -> bool:
digest = intent["blob_sha256"]
size = intent["byte_size"]
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
return False
try:
self.blobs.verify(digest, expected_size=size)
except (BlobCorruptionError, ValueError):
return False
with self.database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
connection.execute(
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, "
"receipt_token, failure_reason, revision) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
intent["capture_id"],
digest,
intent["capture_state"],
intent["received_at"],
intent["receipt_token"],
intent["failure_reason"],
intent["revision"],
),
)
result = connection.execute(
"DELETE FROM capture_deletion_intents WHERE blob_sha256 = ?", (digest,)
)
connection.execute("COMMIT")
return result.rowcount == 1
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
@staticmethod
def _record(row: sqlite3.Row) -> CaptureRecord:
capture_id, state, received_at = (
row["capture_id"],
row["state"],
row["received_at"],
)
if (
not _capture_id(capture_id)
or not isinstance(state, str)
or not isinstance(received_at, str)
or not 1 <= len(received_at) <= 40
or "\r" in received_at
or "\n" in received_at
):
raise StorageError("stored corpus record is invalid")
raw_label = row["classification_json"]
return CaptureRecord(
capture_id=capture_id,
state=state,
received_at=received_at,
classification=None if raw_label is None else _stored_classification(raw_label),
)
@@ -0,0 +1,124 @@
"""Private SQLite setup and immutable ordered utility migrations."""
from __future__ import annotations
import hashlib
import os
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from stat import S_ISDIR, S_ISLNK
class MigrationError(RuntimeError):
"""The local migration history cannot safely be applied."""
def _private_directory(path: Path) -> None:
if path.is_symlink():
raise MigrationError("utility root must not be a symlink")
path.mkdir(mode=0o700, parents=True, exist_ok=True)
mode = path.lstat().st_mode
if S_ISLNK(mode) or not S_ISDIR(mode):
raise MigrationError("utility root must be a directory")
path.chmod(0o700)
class StorageDatabase:
def __init__(self, root: Path, migrations_dir: Path) -> None:
if not root.is_absolute() or not migrations_dir.is_absolute():
raise ValueError("storage paths must be absolute")
self.root = root
self.migrations_dir = migrations_dir
self.path = root / "capture.sqlite3"
def connect(self) -> sqlite3.Connection:
_private_directory(self.root)
if self.path.is_symlink():
raise MigrationError("database file must not be a symlink")
connection: sqlite3.Connection | None = None
try:
connection = sqlite3.connect(self.path, isolation_level=None, timeout=5.0)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
journal = connection.execute("PRAGMA journal_mode = WAL").fetchone()[0]
connection.execute("PRAGMA busy_timeout = 5000")
connection.execute("PRAGMA synchronous = FULL")
checks = (
connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1,
journal == "wal",
connection.execute("PRAGMA busy_timeout").fetchone()[0] == 5000,
connection.execute("PRAGMA synchronous").fetchone()[0] == 2,
)
if not all(checks):
raise MigrationError("SQLite connection invariants were not enabled")
for candidate in (
self.path,
self.path.with_name(self.path.name + "-wal"),
self.path.with_name(self.path.name + "-shm"),
):
if candidate.exists() and not candidate.is_symlink():
candidate.chmod(0o600)
return connection
except Exception:
if connection is not None:
connection.close()
raise
def _migrations(self) -> list[tuple[int, Path, str]]:
found: list[tuple[int, Path, str]] = []
versions: set[int] = set()
for path in self.migrations_dir.glob("*.sql"):
if len(path.name) < 5 or not path.name[:3].isdigit() or path.name[3] != "_":
raise MigrationError("migration filename is invalid")
version = int(path.name[:3])
if version in versions:
raise MigrationError("migration versions must be unique")
versions.add(version)
checksum = hashlib.sha256(path.read_bytes()).hexdigest()
found.append((version, path, checksum))
found.sort(key=lambda item: item[0])
if not found:
raise MigrationError("no storage migrations found")
return found
def migrate(self) -> None:
migrations = self._migrations()
with self.connect() as connection:
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations ("
"version INTEGER PRIMARY KEY, filename TEXT NOT NULL UNIQUE, "
"checksum TEXT NOT NULL, applied_at TEXT NOT NULL)"
)
applied = list(
connection.execute(
"SELECT version, filename, checksum FROM schema_migrations ORDER BY version"
)
)
if len(applied) > len(migrations):
raise MigrationError("applied migration is no longer present")
expected_prefix = migrations[: len(applied)]
for row, expected in zip(applied, expected_prefix, strict=True):
version, path, checksum = expected
if (row["version"], row["filename"], row["checksum"]) != (
version,
path.name,
checksum,
):
raise MigrationError("migration history is not an exact ordered prefix")
for version, path, checksum in migrations[len(applied) :]:
sql = path.read_text(encoding="utf-8")
try:
applied_at = datetime.now(UTC).isoformat().replace("'", "''")
connection.executescript(
"BEGIN IMMEDIATE;\n"
+ sql
+ "\nINSERT INTO schema_migrations VALUES "
+ f"({version}, '{path.name}', '{checksum}', '{applied_at}');"
+ "\nCOMMIT;"
)
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
os.chmod(self.path, 0o600)
@@ -0,0 +1,266 @@
"""Utility-local capture receipts and bounded insert-only provenance."""
from __future__ import annotations
import json
import re
import sqlite3
from collections.abc import Iterable
from datetime import UTC, datetime
from game_data_capture.domain.state_machines import require_transition
from game_data_capture.storage.blobs import (
BlobCorruptionError,
BlobLockError,
ContentAddressedBlobStore,
)
from game_data_capture.storage.database import StorageDatabase
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_MEDIA = re.compile(r"^(?:image/(?:png|webp|avif)|video/(?:mp4|webm|x-matroska))$")
_SECRET = re.compile(
r"(?i)(?:sk-[a-z0-9_-]{8,}|authorization\s*[:=]\s*bearer\s+\S+|"
r"(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+|bearer\s+\S+|/[^\s]+)"
)
class StorageError(RuntimeError):
pass
def _now() -> str:
return datetime.now(UTC).isoformat()
def _id(value: str) -> str:
if not isinstance(value, str) or not _ID.fullmatch(value):
raise ValueError("invalid local identifier")
return value
def _json(value: object, maximum: int) -> str:
encoded = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
if len(encoded) > maximum:
raise ValueError("provenance payload exceeds its bound")
return encoded
def sanitize_failure(code: str, message: str) -> tuple[str, str]:
safe_code = re.sub(r"[^a-z0-9_]", "_", code.lower())[:64] or "storage_failure"
compact = " ".join(message.split())
compact = _SECRET.sub("[REDACTED]", compact)
if len(compact) > 512 or (len(compact) > 160 and " " not in compact[:160]):
compact = "operation failed [payload redacted]"
return safe_code, compact[:512] or "operation failed"
class CaptureRepository:
def __init__(self, database: StorageDatabase, blobs: ContentAddressedBlobStore) -> None:
self.database = database
self.blobs = blobs
def receive(
self,
capture_id: str,
receipt_token: str,
chunks: Iterable[bytes],
*,
media_type: str,
max_bytes: int,
) -> str:
_id(capture_id)
_id(receipt_token)
if not _MEDIA.fullmatch(media_type):
raise ValueError("unsupported media type")
try:
with self.blobs.operation_lock(), self.database.connect() as connection:
pending = connection.execute(
"SELECT 1 FROM capture_deletion_intents WHERE receipt_token = ?",
(receipt_token,),
).fetchone()
if pending is not None:
raise StorageError("blob deletion is pending")
existing = connection.execute(
"SELECT capture_id FROM captures WHERE receipt_token = ?", (receipt_token,)
).fetchone()
if existing is not None:
return str(existing["capture_id"])
except BlobLockError as error:
raise StorageError("blob operation is unavailable") from error
try:
digest, size = self.blobs.put(chunks, max_bytes=max_bytes)
except BlobLockError as error:
raise StorageError("blob operation is unavailable") from error
try:
with self.blobs.operation_lock():
self.blobs.verify(digest, expected_size=size)
with self.database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
pending = connection.execute(
"SELECT 1 FROM capture_deletion_intents WHERE blob_sha256 = ?",
(digest,),
).fetchone()
if pending is not None:
raise StorageError("blob deletion is pending")
existing = connection.execute(
"SELECT capture_id FROM captures WHERE receipt_token = ?",
(receipt_token,),
).fetchone()
if existing is not None:
connection.execute("COMMIT")
return str(existing["capture_id"])
connection.execute(
"INSERT OR IGNORE INTO blobs(sha256, byte_size, media_type, "
"created_at) "
"VALUES (?, ?, ?, ?)",
(digest, size, media_type, _now()),
)
duplicate = connection.execute(
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest,)
).fetchone()[0]
connection.execute(
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, "
"receipt_token) VALUES (?, ?, ?, ?, ?)",
(
capture_id,
digest,
"deduplicated" if duplicate else "stored",
_now(),
receipt_token,
),
)
connection.execute("COMMIT")
return capture_id
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
except BlobLockError as error:
raise StorageError("blob operation is unavailable") from error
def transition(self, capture_id: str, target: str, expected_revision: int) -> int:
_id(capture_id)
with self.database.connect() as connection:
row = connection.execute(
"SELECT state FROM captures WHERE capture_id = ? AND revision = ?",
(capture_id, expected_revision),
).fetchone()
if row is None:
raise StorageError("stale or missing capture")
require_transition("capture", str(row["state"]), target)
result = connection.execute(
"UPDATE captures SET state = ?, revision = revision + 1 "
"WHERE capture_id = ? AND revision = ?",
(target, capture_id, expected_revision),
)
if result.rowcount != 1:
raise StorageError("stale capture transition")
return expected_revision + 1
def append_ocr_run(
self, record_id: str, capture_id: str, engine_id: str, engine_version: str, payload: object
) -> None:
if not 0 < len(engine_id) <= 100 or not 0 < len(engine_version) <= 100:
raise ValueError("OCR engine identity is invalid")
self._insert(
"ocr_runs",
(
record_id,
capture_id,
engine_id,
engine_version,
_now(),
"completed",
_json(payload, 1_000_000),
),
)
def append_region(self, record_id: str, run_id: str, payload: object) -> None:
self._insert("regions", (record_id, run_id, _json(payload, 10_000), _now()))
def append_classification(self, record_id: str, run_id: str, payload: object) -> None:
self._insert("classifications", (record_id, run_id, _json(payload, 10_000), _now()))
def append_candidate(self, record_id: str, run_id: str, payload: object) -> None:
self._insert(
"extraction_candidates", (record_id, run_id, _json(payload, 1_000_000), _now())
)
def append_routing(self, record_id: str, candidate_id: str, payload: object) -> None:
self._insert("routing_decisions", (record_id, candidate_id, _json(payload, 10_000), _now()))
def append_review(self, record_id: str, candidate_id: str, payload: object) -> None:
self._insert("review_events", (record_id, candidate_id, _json(payload, 10_000), _now()))
def append_lineage(self, record_id: str, child_id: str, parent_id: str, relation: str) -> None:
if not 0 < len(relation) <= 100:
raise ValueError("lineage relation is invalid")
self._insert("lineage", (record_id, child_id, parent_id, relation, _now()))
def append_export(self, record_id: str, state: str, manifest: object) -> None:
if not state or len(state) > 100:
raise ValueError("export state is invalid")
self._insert("exports", (record_id, state, _json(manifest, 1_000_000), _now()))
def _insert(self, table: str, values: tuple[object, ...]) -> None:
specs = {
"ocr_runs": (
"run_id,capture_id,engine_id,engine_version,created_at,state,payload_json",
(0, 1),
),
"regions": ("region_id,run_id,region_json,created_at", (0, 1)),
"classifications": ("classification_id,run_id,classification_json,created_at", (0, 1)),
"extraction_candidates": ("candidate_id,run_id,candidate_json,created_at", (0, 1)),
"routing_decisions": ("decision_id,candidate_id,decision_json,created_at", (0, 1)),
"review_events": ("event_id,candidate_id,event_json,created_at", (0, 1)),
"lineage": ("lineage_id,child_id,parent_id,relation,created_at", (0, 1, 2)),
"exports": ("export_id,state,manifest_json,created_at", (0,)),
}
columns, positions = specs[table]
if len(values) != len(columns.split(",")):
raise ValueError("provenance record arity is invalid")
for position in positions:
_id(str(values[position]))
placeholders = ", ".join("?" for _ in values)
with self.database.connect() as connection:
connection.execute(f"INSERT INTO {table} ({columns}) VALUES ({placeholders})", values)
def report(self) -> dict[str, int]:
tables = ("blobs", "captures", "jobs", "ocr_runs", "runtime_failures")
with self.database.connect() as connection:
return {
table: int(connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0])
for table in tables
}
def verify(self) -> list[str]:
with self.database.connect() as connection:
return [
str(row["sha256"])
for row in connection.execute("SELECT sha256, byte_size FROM blobs")
if self._invalid_blob(row)
]
def _invalid_blob(self, row: sqlite3.Row) -> bool:
try:
digest = row["sha256"]
size = row["byte_size"]
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
return True
self.blobs.verify(digest, expected_size=size)
except BlobCorruptionError:
return True
return False
def record_failure(
self, failure_id: str, subject_type: str, subject_id: str, code: str, message: str
) -> None:
if not re.fullmatch(r"[a-z_]{1,64}", subject_type):
raise ValueError("failure subject type is invalid")
code, message = sanitize_failure(code, message)
with self.database.connect() as connection:
connection.execute(
"INSERT INTO runtime_failures VALUES (?, ?, ?, ?, ?, ?)",
(_id(failure_id), subject_type, _id(subject_id), code, message, _now()),
)
@@ -0,0 +1,567 @@
"""FastAPI factory for the local capture-and-labeling boundary.
Construction only retains injected ports and process-local session state. It does
not bind a listener, initialize runtime paths, open SQLite, arm capture, read a
clipboard, or create a child process.
"""
from __future__ import annotations
import json
import re
import time
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal, Protocol, TypeVar
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response
from fastapi.templating import Jinja2Templates
from starlette.staticfiles import StaticFiles
from game_data_capture.config import ServerConfig
from game_data_capture.domain.state_machines import CaptureState
from game_data_capture.domain.validation import ContractValidationError, validate_classification
from game_data_capture.web.security import (
AuthenticatedSession,
AuthenticationError,
BodyLimitExceeded,
ClientDisconnected,
LocalAuthority,
LocalRequestLimitsMiddleware,
RequestLimits,
SecurityError,
SessionStore,
response_security_headers,
set_session_cookie,
single_header,
validate_origin,
)
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
_STATE_VALUES = frozenset(state.value for state in CaptureState)
_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
_OUTCOME_VALUES = frozenset({"updated", "not_found", "conflict", "retained"})
_PortResult = TypeVar("_PortResult")
class ServiceUnavailable(RuntimeError):
"""An injected port cannot safely complete this local request."""
@dataclass(frozen=True, slots=True)
class WebConfig:
"""Static local service settings supplied by runtime integration."""
server: ServerConfig
request_limits: RequestLimits = field(default_factory=RequestLimits)
max_capture_items: int = 64
max_image_bytes: int = 64 * 1024 * 1024
bootstrap_ttl_seconds: int = 600
session_ttl_seconds: int = 900
def __post_init__(self) -> None:
for value, maximum in (
(self.max_capture_items, 256),
(self.max_image_bytes, 64 * 1024 * 1024),
(self.bootstrap_ttl_seconds, 3600),
(self.session_ttl_seconds, 3600),
):
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
raise ValueError("web service configuration is invalid")
@property
def authority(self) -> LocalAuthority:
return LocalAuthority(self.server.host, self.server.port)
@dataclass(frozen=True, slots=True)
class CaptureSessionStatus:
armed: bool
queue_depth: int
queue_capacity: int
@dataclass(frozen=True, slots=True)
class CaptureRecord:
"""A safe browser projection; ports must never place paths or OCR text here."""
capture_id: str
state: str
received_at: str
classification: Mapping[str, object] | None
@dataclass(frozen=True, slots=True)
class MediaDescriptor:
"""An opaque capture-media stream supplied by a corpus port, never a path."""
media_type: str
byte_size: int
chunks: Iterable[bytes]
MutationOutcome = Literal["updated", "not_found", "conflict", "retained"]
ControlOutcome = Literal["started", "already_armed", "stopped", "already_stopped", "conflict"]
class CaptureControlPort(Protocol):
"""Narrow control boundary implemented by later runtime integration."""
def status(self) -> CaptureSessionStatus: ...
def start_session(self) -> ControlOutcome: ...
def stop_session(self) -> ControlOutcome: ...
class CaptureCorpusPort(Protocol):
"""Narrow durable corpus boundary; routes never access SQLite or paths."""
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]: ...
def get_capture(self, capture_id: str) -> CaptureRecord | None: ...
def open_media(self, capture_id: str) -> MediaDescriptor | None: ...
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome: ...
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome: ...
def delete_with_retention(self, capture_id: str) -> MutationOutcome: ...
def _response(payload: Mapping[str, object], *, status_code: int = 200) -> JSONResponse:
return JSONResponse(
payload,
status_code=status_code,
headers={"Cache-Control": "no-store", **response_security_headers()},
)
def _error(status_code: int, code: str) -> JSONResponse:
return _response({"error": code}, status_code=status_code)
def _call_port(operation: Callable[[], _PortResult]) -> _PortResult:
"""Translate every injected-port failure without retaining its raw message."""
try:
return operation()
except Exception as error:
raise ServiceUnavailable("local service operation failed") from error
def _valid_capture_id(capture_id: str) -> bool:
return bool(_CAPTURE_ID.fullmatch(capture_id))
def _status_projection(status: CaptureSessionStatus) -> dict[str, object]:
if (
not isinstance(status.armed, bool)
or isinstance(status.queue_depth, bool)
or isinstance(status.queue_capacity, bool)
or not isinstance(status.queue_depth, int)
or not isinstance(status.queue_capacity, int)
or not 0 <= status.queue_depth <= status.queue_capacity <= 256
):
raise ServiceUnavailable("capture control returned an invalid status")
return {
"armed": status.armed,
"queue_depth": status.queue_depth,
"queue_capacity": status.queue_capacity,
}
def _classification_projection(classification: Mapping[str, object]) -> dict[str, object]:
# Reconstruct only the frozen contract keys before returning port data to a browser.
if not isinstance(classification, Mapping) or set(classification) != {
"family",
"view",
"gear_slot",
"record_types",
"confidence",
"abstention_reason",
}:
raise ServiceUnavailable("corpus returned an invalid classification")
payload = dict(classification)
try:
validate_classification(payload)
except ContractValidationError as error:
raise ServiceUnavailable("corpus returned an invalid classification") from error
family = payload["family"]
view = payload["view"]
gear_slot = payload["gear_slot"]
record_types = payload["record_types"]
confidence = payload["confidence"]
reason = payload["abstention_reason"]
if (
not isinstance(family, str)
or not isinstance(view, str)
or (gear_slot is not None and not isinstance(gear_slot, str))
or not isinstance(record_types, list)
or any(not isinstance(record_type, str) for record_type in record_types)
or isinstance(confidence, bool)
or not isinstance(confidence, int | float)
or (reason is not None and not isinstance(reason, str))
):
raise ServiceUnavailable("corpus returned an invalid classification")
return {
"family": family,
"view": view,
"gear_slot": gear_slot,
"record_types": record_types,
"confidence": confidence,
"abstention_reason": reason,
}
def _record_projection(record: CaptureRecord) -> dict[str, object]:
if (
not isinstance(record.capture_id, str)
or not _valid_capture_id(record.capture_id)
or not isinstance(record.state, str)
or record.state not in _STATE_VALUES
or not isinstance(record.received_at, str)
or not 1 <= len(record.received_at) <= 40
or any(character in record.received_at for character in "\r\n")
):
raise ServiceUnavailable("corpus returned an invalid capture")
payload: dict[str, object] = {
"capture_id": record.capture_id,
"state": record.state,
"received_at": record.received_at,
"classification": None,
}
if record.classification is not None:
payload["classification"] = _classification_projection(record.classification)
return payload
def _json_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def _reject_json_constant(value: str) -> object:
raise ValueError(f"unsupported JSON constant {value}")
async def _read_json(request: Request, *, maximum: int) -> dict[str, object]:
content_type = single_header(request, b"content-type", required=True)
if content_type is None:
raise SecurityError("unsupported request content type")
try:
media_type = content_type.decode("ascii")
except UnicodeDecodeError as error:
raise SecurityError("unsupported request content type") from error
if media_type.split(";", 1)[0].strip().lower() != "application/json":
raise SecurityError("unsupported request content type")
body = await request.body()
if not 2 <= len(body) <= maximum:
raise SecurityError("request JSON size is invalid")
try:
value = json.loads(
body.decode("utf-8"),
object_pairs_hook=_json_pairs,
parse_constant=_reject_json_constant,
)
except ValueError as error:
raise SecurityError("request JSON is invalid") from error
if not isinstance(value, dict):
raise SecurityError("request JSON must be an object")
return value
def _bootstrap_payload(value: Mapping[str, object]) -> str:
if set(value) != {"token"}:
raise SecurityError("bootstrap payload is invalid")
token = value.get("token")
if not isinstance(token, str) or not 32 <= len(token) <= 128:
raise SecurityError("bootstrap payload is invalid")
return token
def _confirmation_payload(value: Mapping[str, object], confirmation: str) -> None:
if set(value) != {"confirmation"} or value.get("confirmation") != confirmation:
raise SecurityError("confirmation payload is invalid")
def _label_payload(value: Mapping[str, object]) -> dict[str, object]:
expected = {
"family",
"view",
"gear_slot",
"record_types",
"confidence",
"abstention_reason",
}
if set(value) != expected:
raise SecurityError("label payload is invalid")
family = value.get("family")
view = value.get("view")
record_types = value.get("record_types")
reason = value.get("abstention_reason")
if (
not isinstance(family, str)
or not 1 <= len(family) <= 32
or not isinstance(view, str)
or not 1 <= len(view) <= 32
or not isinstance(record_types, list)
or not 0 <= len(record_types) <= 8
or any(
not isinstance(record_type, str) or not 1 <= len(record_type) <= 64
for record_type in record_types
)
or (reason is not None and (not isinstance(reason, str) or not 1 <= len(reason) <= 64))
):
raise SecurityError("label payload is invalid")
payload = dict(value)
try:
validate_classification(payload)
except ContractValidationError as error:
raise SecurityError("label payload is invalid") from error
return payload
def _outcome_response(outcome: MutationOutcome) -> tuple[int, str]:
if outcome not in _OUTCOME_VALUES:
raise ServiceUnavailable("corpus returned an invalid mutation outcome")
if outcome == "updated":
return 200, "updated"
if outcome == "not_found":
return 404, "not_found"
if outcome == "conflict":
return 409, "conflict"
return 409, "retained"
def _media_bytes(media: MediaDescriptor, maximum: int) -> tuple[str, bytes]:
"""Materialize and validate bounded port media before response headers exist."""
try:
if (
media.media_type not in _IMAGE_MEDIA_TYPES
or isinstance(media.byte_size, bool)
or not isinstance(media.byte_size, int)
or not 1 <= media.byte_size <= maximum
):
raise ServiceUnavailable("corpus returned unsafe media")
materialized = bytearray()
for chunk in media.chunks:
if not isinstance(chunk, bytes) or not chunk:
raise ServiceUnavailable("corpus returned unsafe media")
if (
len(materialized) + len(chunk) > media.byte_size
or len(materialized) + len(chunk) > maximum
):
raise ServiceUnavailable("corpus returned unsafe media")
materialized.extend(chunk)
if len(materialized) != media.byte_size:
raise ServiceUnavailable("corpus returned unsafe media")
return media.media_type, bytes(materialized)
except ServiceUnavailable:
raise
except Exception as error:
raise ServiceUnavailable("corpus media stream failed") from error
def _session_response(
payload: dict[str, object],
session_store: SessionStore,
session: AuthenticatedSession,
config: WebConfig,
) -> JSONResponse:
rotated = session_store.rotate(session)
payload["csrf_token"] = rotated.csrf_token
response = _response(payload)
set_session_cookie(response, rotated, max_age=config.session_ttl_seconds)
return response
def create_app(
*,
config: WebConfig,
capture_control: CaptureControlPort,
corpus: CaptureCorpusPort,
bootstrap_token: str,
now: Callable[[], float] = time.monotonic,
) -> FastAPI:
"""Create an unbound app using only narrow, caller-injected local ports."""
authority = config.authority
sessions = SessionStore(
bootstrap_token,
now=now,
bootstrap_ttl_seconds=config.bootstrap_ttl_seconds,
session_ttl_seconds=config.session_ttl_seconds,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
app.add_middleware(
LocalRequestLimitsMiddleware, authority=authority, limits=config.request_limits
)
app.mount(
"/static",
StaticFiles(
directory=Path(__file__).with_name("static"),
check_dir=False,
follow_symlink=False,
),
name="static",
)
templates = Jinja2Templates(directory=str(Path(__file__).with_name("templates")))
@app.exception_handler(AuthenticationError)
async def authentication_error(_: Request, __: AuthenticationError) -> JSONResponse:
return _error(401, "authentication_required")
@app.exception_handler(BodyLimitExceeded)
async def body_limit_error(_: Request, __: BodyLimitExceeded) -> JSONResponse:
return _error(413, "request_too_large")
@app.exception_handler(ClientDisconnected)
async def disconnect_error(_: Request, __: ClientDisconnected) -> JSONResponse:
return _error(400, "request_rejected")
@app.exception_handler(SecurityError)
async def security_error(_: Request, __: SecurityError) -> JSONResponse:
return _error(403, "request_forbidden")
@app.exception_handler(ServiceUnavailable)
async def service_error(_: Request, __: ServiceUnavailable) -> JSONResponse:
return _error(503, "service_unavailable")
@app.exception_handler(Exception)
async def unexpected_error(_: Request, __: Exception) -> JSONResponse:
return _error(503, "service_unavailable")
@app.get("/health")
async def health() -> JSONResponse:
return _response({"status": "ok"})
@app.get("/", response_class=Response)
async def review_page(request: Request) -> Response:
return templates.TemplateResponse(request=request, name="index.html", context={})
@app.post("/api/bootstrap")
async def bootstrap(request: Request) -> JSONResponse:
validate_origin(request, authority)
session = sessions.bootstrap(_bootstrap_payload(await _read_json(request, maximum=256)))
response = _response({"csrf_token": session.csrf_token})
set_session_cookie(response, session, max_age=config.session_ttl_seconds)
return response
@app.get("/api/session")
async def session_status(request: Request) -> JSONResponse:
session = sessions.authenticate(request)
payload = _status_projection(_call_port(capture_control.status))
payload["csrf_token"] = session.csrf_token
return _response(payload)
@app.post("/api/session/start")
async def start_session(request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
_confirmation_payload(await _read_json(request, maximum=64), "START")
outcome = _call_port(capture_control.start_session)
if outcome not in {"started", "already_armed", "conflict"}:
raise ServiceUnavailable("capture control returned an invalid start outcome")
if outcome == "conflict":
return _error(409, "conflict")
payload = _status_projection(_call_port(capture_control.status))
payload["result"] = outcome
return _session_response(payload, sessions, session, config)
@app.post("/api/session/stop")
async def stop_session(request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
_confirmation_payload(await _read_json(request, maximum=64), "STOP")
outcome = _call_port(capture_control.stop_session)
if outcome not in {"stopped", "already_stopped", "conflict"}:
raise ServiceUnavailable("capture control returned an invalid stop outcome")
if outcome == "conflict":
return _error(409, "conflict")
payload = _status_projection(_call_port(capture_control.status))
payload["result"] = outcome
return _session_response(payload, sessions, session, config)
@app.get("/api/captures")
async def list_captures(request: Request) -> JSONResponse:
sessions.authenticate(request)
def list_projection() -> list[dict[str, object]]:
records = corpus.list_captures(limit=config.max_capture_items)
if len(records) > config.max_capture_items:
raise ValueError("corpus returned too many captures")
return [_record_projection(record) for record in records]
return _response({"captures": _call_port(list_projection)})
@app.get("/api/captures/{capture_id}")
async def get_capture(capture_id: str, request: Request) -> JSONResponse:
sessions.authenticate(request)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
record = _call_port(lambda: corpus.get_capture(capture_id))
if record is None:
return _error(404, "not_found")
return _response({"capture": _call_port(lambda: _record_projection(record))})
@app.get("/api/captures/{capture_id}/media")
async def capture_media(capture_id: str, request: Request) -> Response:
sessions.authenticate(request)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
media = _call_port(lambda: corpus.open_media(capture_id))
if media is None:
return _error(404, "not_found")
media_type, content = _call_port(lambda: _media_bytes(media, config.max_image_bytes))
return Response(
content=content,
media_type=media_type,
headers={
"Cache-Control": "private, no-store",
},
)
@app.post("/api/captures/{capture_id}/label")
async def label_capture(capture_id: str, request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
classification = _label_payload(await _read_json(request, maximum=4096))
outcome = _call_port(lambda: corpus.label(capture_id, classification))
status, result = _outcome_response(outcome)
if status != 200:
return _error(status, result)
return _session_response({"result": result}, sessions, session, config)
@app.post("/api/captures/{capture_id}/reject")
async def reject_capture(capture_id: str, request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
_confirmation_payload(await _read_json(request, maximum=64), "REJECT")
outcome = _call_port(lambda: corpus.reject_preserving_evidence(capture_id))
status, result = _outcome_response(outcome)
if status != 200:
return _error(status, result)
return _session_response({"result": result}, sessions, session, config)
@app.post("/api/captures/{capture_id}/delete")
async def delete_capture(capture_id: str, request: Request) -> JSONResponse:
session = sessions.require_mutation(request, authority)
if not _valid_capture_id(capture_id):
return _error(404, "not_found")
_confirmation_payload(await _read_json(request, maximum=64), "DELETE")
outcome = _call_port(lambda: corpus.delete_with_retention(capture_id))
status, result = _outcome_response(outcome)
if status != 200:
return _error(status, result)
return _session_response({"result": result}, sessions, session, config)
return app
@@ -0,0 +1,352 @@
"""Local-only request guards and process-local session state.
This module intentionally has no runtime, storage, filesystem, or server imports.
The runtime integration layer owns listener construction and must disable Uvicorn
proxy headers so forwarded request headers never affect this boundary.
"""
from __future__ import annotations
import ipaddress
import json
import re
import secrets
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from fastapi import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
_SECRET = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
_COOKIE_NAME = "capture_session"
_RESPONSE_SECURITY_HEADERS: tuple[tuple[bytes, bytes], ...] = (
(
b"content-security-policy",
b"default-src 'self'; base-uri 'none'; object-src 'none'; frame-src 'none'; "
b"frame-ancestors 'none'; "
b"script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; "
b"media-src 'self'; connect-src 'self'; form-action 'self'",
),
(b"referrer-policy", b"no-referrer"),
(b"x-content-type-options", b"nosniff"),
(b"x-frame-options", b"DENY"),
)
def response_security_headers() -> dict[str, str]:
"""Return response headers for errors generated outside the ASGI wrapper."""
return {
name.decode("ascii"): value.decode("ascii") for name, value in _RESPONSE_SECURITY_HEADERS
}
class SecurityError(ValueError):
"""A request failed a security boundary without exposing its input."""
class AuthenticationError(SecurityError):
"""The process-local authenticated session is absent or expired."""
class BodyLimitExceeded(SecurityError):
"""The declared or streamed request body exceeded the configured limit."""
class ClientDisconnected(SecurityError):
"""The client disconnected before a route obtained a complete request body."""
@dataclass(frozen=True, slots=True)
class LocalAuthority:
"""The single exact loopback authority accepted by the HTTP boundary."""
host: str
port: int
def __post_init__(self) -> None:
try:
address = ipaddress.ip_address(self.host)
except ValueError as error:
raise ValueError("web host must be a loopback IP literal") from error
if not address.is_loopback or not 1024 <= self.port <= 65535:
raise ValueError("web authority is invalid")
@property
def authority(self) -> str:
return f"[{self.host}]:{self.port}" if ":" in self.host else f"{self.host}:{self.port}"
@property
def origin(self) -> str:
return f"http://{self.authority}"
@dataclass(frozen=True, slots=True)
class RequestLimits:
max_header_bytes: int = 16 * 1024
max_headers: int = 64
max_body_bytes: int = 16 * 1024
def __post_init__(self) -> None:
for value in (self.max_header_bytes, self.max_headers, self.max_body_bytes):
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError("request limits must be positive integers")
def new_bootstrap_token() -> str:
"""Create a fragment-safe, one-time token for the local bootstrap mutation."""
return secrets.token_urlsafe(32)
def _header_values(scope: Scope, name: bytes) -> list[bytes]:
return [value for key, value in scope.get("headers", []) if key.lower() == name]
def _single_header(scope: Scope, name: bytes, *, required: bool) -> bytes | None:
values = _header_values(scope, name)
if len(values) != 1:
if not values and not required:
return None
raise SecurityError("ambiguous request header")
return values[0]
def single_header(request: Request, name: bytes, *, required: bool) -> bytes | None:
"""Return one unambiguous request header without trusting framework merging."""
return _single_header(request.scope, name, required=required)
def _ascii(value: bytes) -> str:
try:
return value.decode("ascii")
except UnicodeDecodeError as error:
raise SecurityError("invalid request header") from error
def validate_host(scope: Scope, authority: LocalAuthority) -> None:
value = _single_header(scope, b"host", required=True)
assert value is not None
if not secrets.compare_digest(_ascii(value), authority.authority):
raise SecurityError("unexpected request authority")
def validate_origin(request: Request, authority: LocalAuthority) -> None:
value = _single_header(request.scope, b"origin", required=True)
assert value is not None
if not secrets.compare_digest(_ascii(value), authority.origin):
raise SecurityError("unexpected request origin")
def _validate_content_length(scope: Scope, maximum: int) -> None:
value = _single_header(scope, b"content-length", required=False)
if value is None:
return
raw = _ascii(value)
if not raw.isascii() or not raw.isdecimal():
raise SecurityError("invalid content length")
if int(raw) > maximum:
raise BodyLimitExceeded("request body is too large")
async def _send_rejection(send: Send, status: int, code: str) -> None:
body = json.dumps({"error": code}, separators=(",", ":")).encode("ascii")
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(body)).encode("ascii")),
],
}
)
await send({"type": "http.response.body", "body": body})
class LocalRequestLimitsMiddleware:
"""Validate headers and cap all ASGI body chunks before application parsing."""
def __init__(self, app: ASGIApp, *, authority: LocalAuthority, limits: RequestLimits) -> None:
self.app = app
self.authority = authority
self.limits = limits
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def secured_send(message: Message) -> None:
if message["type"] != "http.response.start":
await send(message)
return
headers = list(message.get("headers", []))
present = {name.lower() for name, _ in headers}
headers.extend(
(name, value) for name, value in _RESPONSE_SECURITY_HEADERS if name not in present
)
protected = dict(message)
protected["headers"] = headers
await send(protected)
headers = scope.get("headers", [])
if len(headers) > self.limits.max_headers or any(
len(name) + len(value) > self.limits.max_header_bytes for name, value in headers
):
await _send_rejection(secured_send, 431, "request_rejected")
return
if sum(len(name) + len(value) for name, value in headers) > self.limits.max_header_bytes:
await _send_rejection(secured_send, 431, "request_rejected")
return
try:
validate_host(scope, self.authority)
_validate_content_length(scope, self.limits.max_body_bytes)
except BodyLimitExceeded:
await _send_rejection(secured_send, 413, "request_too_large")
return
except SecurityError:
await _send_rejection(secured_send, 400, "request_rejected")
return
received = 0
request_complete = False
async def limited_receive() -> Message:
nonlocal received, request_complete
message = await receive()
if message["type"] == "http.disconnect":
if not request_complete:
raise ClientDisconnected("client disconnected")
return message
if message["type"] != "http.request":
return message
body = message.get("body", b"")
if not isinstance(body, bytes):
raise SecurityError("invalid request body")
received += len(body)
if received > self.limits.max_body_bytes:
raise BodyLimitExceeded("request body is too large")
if not message.get("more_body", False):
request_complete = True
return message
await self.app(scope, limited_receive, secured_send)
@dataclass(frozen=True, slots=True)
class AuthenticatedSession:
token: str
csrf_token: str
expires_at: float
class SessionStore:
"""An intentionally non-durable session store invalidated on process restart."""
def __init__(
self,
bootstrap_token: str,
*,
now: Callable[[], float] = time.monotonic,
bootstrap_ttl_seconds: int = 600,
session_ttl_seconds: int = 900,
) -> None:
if not isinstance(bootstrap_token, str) or not _SECRET.fullmatch(bootstrap_token):
raise ValueError("bootstrap token is invalid")
if (
isinstance(bootstrap_ttl_seconds, bool)
or not isinstance(bootstrap_ttl_seconds, int)
or isinstance(session_ttl_seconds, bool)
or not isinstance(session_ttl_seconds, int)
or not 1 <= bootstrap_ttl_seconds <= 3600
or not 1 <= session_ttl_seconds <= 3600
):
raise ValueError("session lifetime is invalid")
self._bootstrap_token = bootstrap_token.encode("ascii")
self._bootstrap_expires_at = now() + bootstrap_ttl_seconds
self._bootstrap_used = False
self._now = now
self._session_ttl_seconds = session_ttl_seconds
self._sessions: dict[str, AuthenticatedSession] = {}
def bootstrap(self, submitted: str) -> AuthenticatedSession:
if (
self._bootstrap_used
or self._now() > self._bootstrap_expires_at
or not _SECRET.fullmatch(submitted)
or not secrets.compare_digest(submitted.encode("ascii"), self._bootstrap_token)
):
raise AuthenticationError("bootstrap authentication failed")
self._bootstrap_used = True
return self._new_session()
def authenticate(self, request: Request) -> AuthenticatedSession:
token = _session_cookie(request)
session = self._sessions.get(token)
if session is None or self._now() > session.expires_at:
self._sessions.pop(token, None)
raise AuthenticationError("session authentication failed")
return session
def require_mutation(self, request: Request, authority: LocalAuthority) -> AuthenticatedSession:
session = self.authenticate(request)
validate_origin(request, authority)
csrf = _single_header(request.scope, b"x-csrf-token", required=True)
assert csrf is not None
submitted = _ascii(csrf)
if not _SECRET.fullmatch(submitted) or not secrets.compare_digest(
submitted.encode("ascii"), session.csrf_token.encode("ascii")
):
raise SecurityError("csrf validation failed")
return session
def rotate(self, session: AuthenticatedSession) -> AuthenticatedSession:
current = self._sessions.get(session.token)
if current != session or self._now() > session.expires_at:
self._sessions.pop(session.token, None)
raise AuthenticationError("session authentication failed")
del self._sessions[session.token]
return self._new_session()
def _new_session(self) -> AuthenticatedSession:
session = AuthenticatedSession(
token=secrets.token_urlsafe(32),
csrf_token=secrets.token_urlsafe(32),
expires_at=self._now() + self._session_ttl_seconds,
)
self._sessions[session.token] = session
return session
def _session_cookie(request: Request) -> str:
raw = _single_header(request.scope, b"cookie", required=True)
assert raw is not None
pairs: dict[str, str] = {}
for segment in _ascii(raw).split(";"):
name, separator, value = segment.strip().partition("=")
if not separator or not name or name in pairs:
raise AuthenticationError("session authentication failed")
pairs[name] = value
token = pairs.get(_COOKIE_NAME)
if token is None or not _SECRET.fullmatch(token):
raise AuthenticationError("session authentication failed")
return token
def set_session_cookie(response: Any, session: AuthenticatedSession, *, max_age: int) -> None:
"""Set a host-only HTTP-only local session cookie without a Domain attribute."""
response.set_cookie(
key=_COOKIE_NAME,
value=session.token,
max_age=max_age,
path="/",
secure=False,
httponly=True,
samesite="strict",
)
@@ -0,0 +1,124 @@
:root {
--canvas: #0a0c0d;
--surface: #111415;
--surface-raised: #141718;
--well: #0d0f10;
--line: #303536;
--line-strong: #50595b;
--text: #ecebe5;
--muted: #a8afb1;
--dim: #7e8789;
--orange: #f6a821;
--orange-hover: #ffc45d;
--success: #8bd2a0;
--danger: #f09a86;
--info: #8cc8df;
--focus: #ffc45d;
--space: 1rem;
font-family: Arial, Helvetica, sans-serif;
color: var(--text);
background: var(--canvas);
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 20rem; background: var(--canvas); color: var(--text); }
button, input { font: inherit; }
button, input[type="radio"], input[type="checkbox"] { accent-color: var(--orange); }
button { min-height: 44px; border: 1px solid var(--line-strong); border-radius: 0; background: var(--surface); color: var(--text); cursor: pointer; padding: .65rem .9rem; font-weight: 700; }
button:hover:not(:disabled) { background: #1b2021; border-color: var(--text); }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { color: var(--dim); cursor: not-allowed; border-color: var(--line); background: #101213; }
.button-primary { background: var(--orange); color: #12100b; border-color: var(--orange); }
.button-primary:hover:not(:disabled) { background: var(--orange-hover); border-color: var(--orange-hover); }
.button-danger { color: var(--danger); }
.quiet-danger { border-color: #6f4941; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.skip-link { position: fixed; z-index: 2; top: 0; left: .5rem; transform: translateY(-120%); background: var(--orange); color: #12100b; padding: .65rem; font-weight: 700; }
.skip-link:focus { transform: translateY(0); }
.shell { width: min(100% - 2rem, 100rem); margin: 0 auto; padding: 1.5rem 0 3rem; }
.command-header, .control-strip, .panel-heading { display: flex; gap: var(--space); justify-content: space-between; align-items: center; }
.command-header { border-bottom: 1px solid var(--line); padding: 0 0 1.25rem; }
.kicker { margin: 0 0 .35rem; color: var(--muted); font-size: .74rem; font-weight: 800; letter-spacing: .08em; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(1.65rem, 3vw, 2.35rem); letter-spacing: -.025em; }
h2 { margin-bottom: 0; font-size: 1.15rem; }
.header-state { text-align: right; }
.header-state strong, #capture-state, #review-state { display: block; font-size: .88rem; font-weight: 800; letter-spacing: .04em; }
.header-state span, #queue-state, #capture-count, .field-help, .action-help, figcaption { color: var(--muted); font-size: .88rem; }
.notice { display: flex; gap: .75rem; border: 1px solid var(--line); border-left: 2px solid var(--info); margin: 1rem 0; padding: .8rem 1rem; background: var(--well); }
.notice strong { color: var(--info); font-size: .78rem; letter-spacing: .06em; }
.notice[data-kind="success"] { border-left-color: var(--success); }
.notice[data-kind="success"] strong { color: var(--success); }
.notice[data-kind="error"], .notice[data-kind="conflict"] { border-left-color: var(--danger); }
.notice[data-kind="error"] strong, .notice[data-kind="conflict"] strong { color: var(--danger); }
.notice[data-kind="retained"] { border-left-color: var(--info); }
.notice[data-kind="retained"] strong { color: var(--info); }
.control-strip { border: 1px solid var(--line); background: var(--surface-raised); padding: 1rem; margin-bottom: 1rem; }
.control-strip p:last-child { margin: 0; }
.control-actions, .form-actions, .dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; }
.workspace-grid { display: grid; grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr); border: 1px solid var(--line); background: var(--surface); }
.capture-panel { border-right: 1px solid var(--line); min-height: 42rem; }
.capture-panel, .review-panel { padding: 1rem; }
.panel-heading { border-bottom: 1px solid var(--line); padding-bottom: .85rem; }
.empty-copy, .review-empty { color: var(--muted); padding: 1rem 0; line-height: 1.5; }
.capture-list { margin: 0; padding: 0; list-style: none; }
.capture-list button { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .25rem .6rem; margin-top: .55rem; text-align: left; background: var(--well); }
.capture-list button[aria-current="true"] { border-color: var(--orange); color: var(--text); }
.capture-list .capture-id { overflow-wrap: anywhere; }
.capture-list .capture-meta { color: var(--muted); font-size: .8rem; }
.review-content { padding-top: 1rem; }
.evidence-frame { margin: 0 0 1rem; border: 1px solid var(--line); background: var(--well); padding: .65rem; }
.evidence-frame img { display: block; width: 100%; max-height: 31rem; object-fit: contain; background: #000; }
.evidence-frame figcaption { padding-top: .65rem; overflow-wrap: anywhere; }
fieldset { margin: 0 0 1rem; padding: .75rem; border: 1px solid var(--line); min-width: 0; }
fieldset:disabled { opacity: .65; }
legend { padding: 0 .35rem; font-weight: 700; }
.choice-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
.choice { position: relative; display: inline-flex; min-height: 44px; align-items: center; border: 1px solid var(--line-strong); background: var(--well); cursor: pointer; }
.choice:hover { border-color: var(--text); }
.choice input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.choice span { padding: .65rem .75rem; }
.choice input:checked + span { background: #3c2a0c; color: var(--text); box-shadow: inset 0 -2px var(--orange); }
.choice input:focus-visible + span { outline: 3px solid var(--focus); outline-offset: 2px; }
.validation-message { min-height: 1.5rem; margin: -.35rem 0 .75rem; color: var(--danger); }
.action-help { max-width: 70ch; margin: .8rem 0 0; line-height: 1.45; }
dialog { width: min(calc(100% - 2rem), 34rem); border: 1px solid var(--orange); border-radius: 0; padding: 1.3rem; color: var(--text); background: var(--surface-raised); box-shadow: 0 18px 60px #000; }
dialog::backdrop { background: rgb(0 0 0 / 72%); }
dialog p { line-height: 1.5; }
@media (max-width: 52rem) {
.shell { width: min(100% - 1rem, 100rem); padding-top: 1rem; }
.command-header, .control-strip { align-items: flex-start; flex-direction: column; }
.header-state { text-align: left; }
.workspace-grid { grid-template-columns: 1fr; }
.capture-panel { min-height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
.capture-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: .55rem; }
.capture-list button { margin-top: 0; }
}
@media (max-width: 33rem) {
.control-actions, .form-actions, .dialog-actions { display: grid; grid-template-columns: 1fr; width: 100%; }
.control-actions button, .form-actions button, .dialog-actions button { width: 100%; }
.notice { align-items: flex-start; flex-direction: column; gap: .25rem; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
@media (forced-colors: active) {
button, fieldset, .choice, .notice, .workspace-grid, .evidence-frame { border: 1px solid CanvasText; }
.button-primary, .choice input:checked + span { forced-color-adjust: none; background: Highlight; color: HighlightText; }
}
@@ -0,0 +1,286 @@
const taxonomy = Object.freeze({
weapon: { views: ["detail", "blueprint", "crafting", "stat_range", "talent_grid", "augmentation"], records: { detail: ["weapon", "weapon_talent"], blueprint: ["weapon", "weapon_talent"], crafting: ["weapon"], stat_range: ["weapon"], talent_grid: ["weapon_talent"], augmentation: ["weapon_augmentation_profile"] } },
gear: { views: ["detail", "blueprint", "talent", "augmentation"], records: { detail: ["gear", "brand_set"], blueprint: ["gear", "brand_set"], talent: ["gear_talent"], augmentation: ["gear_augmentation_profile"] } },
skill_chip: { views: ["detail", "set_bonus", "talent"], records: { detail: ["skill_chip_set", "skill_chip_roll_pool"], set_bonus: ["skill_chip_set_bonus"], talent: ["skill_chip_talent"] } },
os_protocol: { views: ["detail", "secondary_profile", "talent"], records: { detail: ["os_protocol"], secondary_profile: ["os_protocol_secondary_profile"], talent: ["os_protocol_talent"] } },
specialization: { views: ["overview", "details", "ability", "focus_details", "focus_tree", "rank_details"], records: { overview: ["specialization"], details: ["specialization"], ability: ["specialization_ability"], focus_details: ["specialization_focus"], focus_tree: ["specialization_focus", "specialization_tree_node"], rank_details: ["specialization_tree_rank"] } },
unknown: { views: ["unknown"], records: { unknown: [] } }
});
const families = Object.keys(taxonomy);
const gearSlots = ["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"];
const abstentionReasons = ["low_confidence", "ambiguous_family", "ambiguous_view", "unsupported_family", "unsupported_view", "insufficient_visual_evidence", "occluded", "corrupt_image", "resource_limit", "cancelled"];
const csrfPattern = /^[A-Za-z0-9_-]{32,128}$/;
const $ = (id) => document.getElementById(id);
const els = Object.freeze({
sessionState: $("session-state"), queueState: $("queue-state"), captureState: $("capture-state"), notice: $("notice"), noticeTitle: $("notice-title"), noticeCopy: $("notice-copy"), start: $("start-button"), stop: $("stop-button"), refresh: $("refresh-button"), list: $("capture-list"), listEmpty: $("capture-empty"), count: $("capture-count"), reviewTitle: $("review-title"), reviewState: $("review-state"), reviewEmpty: $("review-empty"), reviewContent: $("review-content"), media: $("capture-media"), meta: $("capture-meta"), labelForm: $("label-form"), validation: $("label-validation"), save: $("save-label-button"), reject: $("reject-button"), remove: $("delete-button"), family: $("family-options"), view: $("view-options"), gear: $("gear-slot-options"), records: $("record-type-options"), abstention: $("abstention-options"), viewFieldset: $("view-fieldset"), gearFieldset: $("gear-slot-fieldset"), recordFieldset: $("record-types-fieldset"), abstentionFieldset: $("abstention-fieldset"), dialog: $("confirmation-dialog"), dialogKicker: $("confirmation-kicker"), dialogTitle: $("confirmation-title"), dialogCopy: $("confirmation-copy"), dialogSubmit: $("confirmation-submit")
});
let csrfToken = null;
let activeCapture = null;
let captures = [];
let armed = null;
let pending = false;
let returnFocus = null;
function setText(element, value) { element.textContent = value; }
function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }
function label(value) { return value.replaceAll("_", " "); }
function notice(kind, title, copy) { els.notice.dataset.kind = kind; setText(els.noticeTitle, title); setText(els.noticeCopy, copy); }
function hasValidCsrf(value) { return typeof value === "string" && csrfPattern.test(value); }
function selected(name) { return document.querySelector(`input[name="${name}"]:checked`)?.value || null; }
function selectedAll(name) { return [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((input) => input.value); }
function currentFamily() { return selected("family"); }
function currentView() { return selected("view"); }
function syncControls() {
const locked = !csrfToken;
els.start.disabled = pending || locked || armed === true;
els.stop.disabled = pending || locked || armed !== true;
els.refresh.disabled = pending || locked;
els.save.disabled = pending || locked || !activeCapture || !isValidLabel();
els.reject.disabled = pending || locked || !activeCapture;
els.remove.disabled = pending || locked || !activeCapture;
document.querySelectorAll("#capture-list button").forEach((button) => {
button.disabled = pending || locked;
});
document.querySelectorAll("#label-form input").forEach((input) => {
const fieldset = input.closest("fieldset");
input.disabled = pending || locked || !activeCapture || Boolean(fieldset?.disabled || fieldset?.hidden);
});
els.labelForm.setAttribute("aria-busy", String(pending));
}
function setBusy(value) { pending = value; syncControls(); }
function clearActiveDetail() {
activeCapture = null;
els.media.removeAttribute("src");
els.reviewContent.hidden = true;
els.reviewEmpty.hidden = false;
els.reviewTitle.textContent = "No capture selected";
els.reviewState.textContent = csrfToken ? "READY" : "LOCKED";
renderLabelForm(null);
}
function lock(message) {
csrfToken = null;
armed = null;
captures = [];
clearActiveDetail();
els.sessionState.textContent = "LOCKED";
els.captureState.textContent = "DISARMED";
els.queueState.textContent = "Queue unavailable";
els.listEmpty.hidden = false;
setText(els.listEmpty, message);
clearNode(els.list);
els.count.textContent = "0 items";
setBusy(false);
}
function validSession(payload) {
return Boolean(payload) && hasValidCsrf(payload.csrf_token) && typeof payload.armed === "boolean" && Number.isInteger(payload.queue_depth) && Number.isInteger(payload.queue_capacity) && payload.queue_depth >= 0 && payload.queue_depth <= payload.queue_capacity && payload.queue_capacity <= 256;
}
function updateSession(payload) {
armed = payload.armed;
els.sessionState.textContent = "UNLOCKED";
els.captureState.textContent = armed ? "ARMED" : "DISARMED";
els.queueState.textContent = `Queue ${payload.queue_depth} / ${payload.queue_capacity}`;
if (!activeCapture) els.reviewState.textContent = "READY";
syncControls();
}
function safeJson(response) { return response.json().catch(() => null); }
async function request(path, options = {}, mutation = false) {
const headers = new Headers(options.headers || {});
if (mutation) { headers.set("Content-Type", "application/json"); headers.set("X-CSRF-Token", csrfToken || ""); }
let response;
try { response = await fetch(path, { ...options, headers, credentials: "same-origin" }); } catch { notice("error", "NETWORK ERROR", "The local service did not respond. Your current review remains unchanged."); return null; }
if (response.status === 401 || response.status === 403) { lock("Local session expired or restarted. Open the provided local address again."); notice("error", "SESSION LOCKED", "The local session is no longer available. No action was retried."); return null; }
const payload = await safeJson(response);
if (payload === null) { notice("error", "RESPONSE ERROR", "The local service returned an unusable response. Your current review remains unchanged."); return null; }
if (!response.ok) {
const retained = response.status === 409 && payload.error === "retained";
notice(retained ? "retained" : response.status === 409 ? "conflict" : "error", retained ? "RETAINED" : response.status === 409 ? "CONFLICT" : "ACTION FAILED", retained ? "Retention policy kept this evidence. No deletion occurred." : "The local service could not complete that action.");
return null;
}
return payload;
}
function acceptMutationCsrf(payload) {
if (hasValidCsrf(payload?.csrf_token)) { csrfToken = payload.csrf_token; return true; }
lock("The local service returned an invalid session update. Open the provided local address again.");
notice("error", "SESSION LOCKED", "The confirmed action was not retried.");
return false;
}
function addChoice(container, type, name, value, checked, onChange) {
const wrapper = document.createElement("label"); wrapper.className = "choice";
const input = document.createElement("input"); input.type = type; input.name = name; input.value = value; input.checked = checked; input.addEventListener("change", onChange);
const text = document.createElement("span"); text.textContent = label(value);
wrapper.append(input, text); container.append(wrapper);
}
function setChecked(name, value) {
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => { input.checked = input.value === value; });
}
function setCheckedMembers(name, values) {
const selectedValues = new Set(values);
document.querySelectorAll(`input[name="${name}"]`).forEach((input) => {
input.checked = selectedValues.has(input.value);
});
}
function renderFamilies() {
clearNode(els.family);
families.forEach((family) => addChoice(els.family, "radio", "family", family, false, () => { renderViews(); renderDependentFields(); }));
}
function renderViews() {
const family = currentFamily();
clearNode(els.view);
els.viewFieldset.disabled = !family;
if (!family) return;
taxonomy[family].views.forEach((view) => addChoice(els.view, "radio", "view", view, false, renderDependentFields));
}
function renderDependentFields() {
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown"; const isGear = family === "gear";
els.gearFieldset.hidden = !isGear; els.gearFieldset.disabled = !isGear;
els.abstentionFieldset.hidden = !unknown; els.abstentionFieldset.disabled = !unknown;
els.recordFieldset.disabled = !family || !view || unknown;
clearNode(els.gear); clearNode(els.records); clearNode(els.abstention);
if (isGear) gearSlots.forEach((slot) => addChoice(els.gear, "radio", "gear_slot", slot, false, validateLabel));
if (unknown) abstentionReasons.forEach((reason) => addChoice(els.abstention, "radio", "abstention_reason", reason, false, validateLabel));
if (!unknown && family && view) (taxonomy[family].records[view] || []).forEach((record) => addChoice(els.records, "checkbox", "record_type", record, false, validateLabel));
validateLabel();
}
function labelPayload() {
const family = currentFamily(); const view = currentView(); const unknown = family === "unknown";
return { family, view, gear_slot: family === "gear" ? selected("gear_slot") : null, record_types: unknown ? [] : selectedAll("record_type"), confidence: 1.0, abstention_reason: unknown ? selected("abstention_reason") : null };
}
function isValidLabel() {
const payload = labelPayload();
if (!payload.family || !payload.view) return false;
if (payload.family === "unknown") return payload.view === "unknown" && !payload.gear_slot && payload.record_types.length === 0 && Boolean(payload.abstention_reason);
return payload.record_types.length > 0 && (payload.family !== "gear" || Boolean(payload.gear_slot));
}
function validateLabel() {
const payload = labelPayload(); let message = "";
if (!payload.family) message = "Select the evidence family.";
else if (!payload.view) message = "Select the screen view.";
else if (payload.family === "unknown" && !payload.abstention_reason) message = "Select an explicit abstention reason for an unknown screen.";
else if (payload.family === "gear" && !payload.gear_slot) message = "Select the Gear slot shown by this evidence.";
else if (payload.family !== "unknown" && payload.record_types.length === 0) message = "Select at least one compatible canonical record type.";
setText(els.validation, message);
if (!pending) syncControls();
return !message;
}
function renderLabelForm(classification) {
renderFamilies();
if (!classification) { renderViews(); renderDependentFields(); return; }
setChecked("family", classification.family);
renderViews();
setChecked("view", classification.view);
renderDependentFields();
if (classification.gear_slot) setChecked("gear_slot", classification.gear_slot);
setCheckedMembers("record_type", classification.record_types || []);
if (classification.abstention_reason) setChecked("abstention_reason", classification.abstention_reason);
validateLabel();
}
function renderCaptures() {
clearNode(els.list); els.count.textContent = `${captures.length} ${captures.length === 1 ? "item" : "items"}`; els.listEmpty.hidden = captures.length > 0;
if (!captures.length) { setText(els.listEmpty, "EMPTY QUEUE: capture evidence will appear after a manual refresh."); return; }
captures.forEach((capture) => {
const item = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-current", String(activeCapture?.capture_id === capture.capture_id));
const id = document.createElement("span"); id.className = "capture-id"; id.textContent = capture.capture_id;
const meta = document.createElement("span"); meta.className = "capture-meta"; meta.textContent = `${capture.state} · ${capture.received_at}`;
button.append(id, meta); button.addEventListener("click", () => selectCapture(capture.capture_id)); item.append(button); els.list.append(item);
});
syncControls();
}
function focusIfAvailable(control) { if (control && !control.disabled) control.focus(); }
async function refreshCaptures({ announce = true, fallbackFocus = null } = {}) {
if (!csrfToken || pending) return false;
setBusy(true); if (announce) notice("info", "LOADING", "Refreshing the local capture queue.");
const payload = await request("/api/captures");
if (!payload || !Array.isArray(payload.captures)) { setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return false; }
captures = payload.captures;
if (activeCapture && !captures.some((capture) => capture.capture_id === activeCapture.capture_id)) clearActiveDetail();
renderCaptures(); if (announce) notice("success", "QUEUE REFRESHED", "Local capture list updated."); setBusy(false); if (announce) focusIfAvailable(fallbackFocus); return true;
}
async function selectCapture(captureId, { announce = true } = {}) {
if (!csrfToken || pending) return;
setBusy(true); if (announce) notice("info", "LOADING", "Loading selected private evidence.");
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}`);
if (!payload?.capture) { setBusy(false); return; }
activeCapture = payload.capture; els.reviewContent.hidden = false; els.reviewEmpty.hidden = true; els.reviewTitle.textContent = activeCapture.capture_id; els.reviewState.textContent = activeCapture.state;
els.meta.textContent = `State: ${activeCapture.state} · Received: ${activeCapture.received_at}`;
els.media.removeAttribute("src"); els.media.src = `/api/captures/${encodeURIComponent(activeCapture.capture_id)}/media`;
renderCaptures(); renderLabelForm(activeCapture.classification); if (announce) notice("success", "CAPTURE READY", "Review the evidence, then save a manual label or choose a separate evidence action."); setBusy(false);
}
function routeForAction(action) {
if (action === "start") return ["/api/session/start", "START"];
if (action === "stop") return ["/api/session/stop", "STOP"];
if ((action === "reject" || action === "delete") && activeCapture) return [`/api/captures/${encodeURIComponent(activeCapture.capture_id)}/${action}`, action === "reject" ? "REJECT" : "DELETE"];
return null;
}
function showConfirmation(action, source) {
if ((action === "reject" || action === "delete") && !activeCapture) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); return; }
const messages = { start: ["START SESSION", "Start capture now? This is the only action that can arm capture.", "Start Session"], stop: ["STOP SESSION", "Stop capture now? New screenshots will not enter the queue after the session stops.", "Stop Session"], reject: ["REJECT EVIDENCE", "Reject this evidence? The evidence remains retained for review and will not be deleted.", "Reject evidence"], delete: ["DELETE CAPTURE", "Request deletion of this capture? Retention policy may refuse deletion. Rejection does not delete evidence.", "Delete capture"] };
const message = messages[action]; if (!message) return;
returnFocus = source; els.dialog.returnValue = "cancel"; els.dialog.dataset.action = action;
els.dialogKicker.textContent = "EXPLICIT CONFIRMATION"; els.dialogTitle.textContent = message[0]; els.dialogCopy.textContent = message[1]; els.dialogSubmit.textContent = message[2]; els.dialogSubmit.className = action === "delete" || action === "reject" ? "button button-danger" : "button button-primary";
els.dialog.showModal(); els.dialogSubmit.focus();
}
async function confirmed(action, fallbackFocus) {
if (pending) return;
const route = routeForAction(action);
if (!route) { notice("error", "ACTION UNAVAILABLE", "Select a capture before confirming this evidence action."); fallbackFocus?.focus(); return; }
setBusy(true); notice("info", "PENDING", "The local service is processing your confirmed action.");
const [path, confirmation] = route; const payload = await request(path, { method: "POST", body: JSON.stringify({ confirmation }) }, true);
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); fallbackFocus?.focus(); return; }
if ((action === "start" || action === "stop") && !validSession(payload)) { lock("The local service returned an invalid session update. Open the provided local address again."); setBusy(false); return; }
if (action === "delete") clearActiveDetail();
if (action === "start" || action === "stop") updateSession(payload);
const successFocus = action === "start" ? els.stop : action === "stop" ? els.start : action === "delete" ? els.refresh : fallbackFocus;
setBusy(false); if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(successFocus); return; }
notice("success", "SUCCESS", action === "reject" ? "Evidence rejected and retained." : action === "delete" ? "Deletion request completed." : `Capture session ${action === "start" ? "armed" : "disarmed"}.`);
focusIfAvailable(successFocus);
}
async function initialize() {
renderLabelForm(null);
let fragment = window.location.hash; let token = null;
if (fragment.length > 1 && fragment.length <= 256) { const params = new URLSearchParams(fragment.slice(1)); const candidate = params.get("bootstrap"); if (candidate && csrfPattern.test(candidate)) token = candidate; }
if (fragment) history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
fragment = "";
if (token) {
const bootstrap = await request("/api/bootstrap", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }) }); token = null;
if (!hasValidCsrf(bootstrap?.csrf_token)) { lock("The local service could not establish a safe session. Open the provided local address again."); return; }
csrfToken = bootstrap.csrf_token;
}
const session = await request("/api/session");
if (!validSession(session)) { lock("LOCKED: start the local service with its provided bootstrap address. This browser has no process-local session."); return; }
csrfToken = session.csrf_token; updateSession(session); if (!(await refreshCaptures({ announce: false }))) return;
const startupCopy = session.armed ? "Capture is ARMED. Stop Session requires explicit confirmation." : "Capture is DISARMED. Start Session requires explicit confirmation.";
notice("success", "LOCAL SESSION READY", startupCopy);
}
els.start.addEventListener("click", () => showConfirmation("start", els.start));
els.stop.addEventListener("click", () => showConfirmation("stop", els.stop));
els.refresh.addEventListener("click", () => refreshCaptures({ fallbackFocus: els.refresh }));
els.labelForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeCapture || !validateLabel() || pending) return;
const captureId = activeCapture.capture_id;
setBusy(true); notice("info", "PENDING", "Saving the manual reviewed label.");
const payload = await request(`/api/captures/${encodeURIComponent(captureId)}/label`, { method: "POST", body: JSON.stringify(labelPayload()) }, true);
if (!payload || !acceptMutationCsrf(payload)) { setBusy(false); return; }
setBusy(false);
if (!(await refreshCaptures({ announce: false }))) { focusIfAvailable(els.save); return; }
if (captures.some((capture) => capture.capture_id === captureId)) await selectCapture(captureId, { announce: false });
notice("success", "LABEL SAVED", "Manual reviewed label saved with confidence 1.0.");
focusIfAvailable(els.save);
});
els.reject.addEventListener("click", () => showConfirmation("reject", els.reject));
els.remove.addEventListener("click", () => showConfirmation("delete", els.remove));
els.dialog.addEventListener("close", () => {
const action = els.dialog.dataset.action; const shouldConfirm = els.dialog.returnValue === "confirm";
els.dialog.dataset.action = "";
const focusTarget = returnFocus;
if (focusTarget) focusTarget.focus();
returnFocus = null;
if (shouldConfirm && action) confirmed(action, focusTarget);
});
els.dialog.addEventListener("cancel", () => { els.dialog.returnValue = "cancel"; });
els.media.addEventListener("error", () => notice("error", "MEDIA UNAVAILABLE", "The private evidence image could not be loaded. Refresh captures before retrying."));
initialize();
@@ -0,0 +1,126 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Local Capture Review</title>
<link rel="stylesheet" href="/static/review.css" />
<script type="module" src="/static/review.js"></script>
</head>
<body>
<a class="skip-link" href="#workspace">Skip to review workspace</a>
<main class="shell" id="workspace">
<header class="command-header">
<div>
<p class="kicker">LOCAL OPERATIONS / CAPTURE REVIEW</p>
<h1>Evidence intake</h1>
</div>
<div class="header-state" aria-label="Session status">
<strong id="session-state">LOCKED</strong>
<span id="queue-state">Queue unavailable</span>
</div>
</header>
<section class="notice" id="notice" aria-live="polite" aria-atomic="true">
<strong id="notice-title">STARTUP</strong>
<span id="notice-copy">Checking this local session.</span>
</section>
<section class="control-strip" aria-label="Capture controls">
<div>
<p class="kicker">CAPTURE SESSION</p>
<p id="capture-state">DISARMED</p>
</div>
<div class="control-actions">
<button class="button button-primary" id="start-button" type="button" disabled>Start Session</button>
<button class="button" id="stop-button" type="button" disabled>Stop Session</button>
<button class="button" id="refresh-button" type="button" disabled>Refresh captures</button>
</div>
</section>
<section class="workspace-grid" aria-label="Capture review workspace">
<aside class="capture-panel" aria-labelledby="capture-list-title">
<div class="panel-heading">
<div>
<p class="kicker">PRIVATE QUEUE</p>
<h2 id="capture-list-title">Captures</h2>
</div>
<span id="capture-count">0 items</span>
</div>
<p class="empty-copy" id="capture-empty">Authenticate to inspect the local capture queue.</p>
<ol class="capture-list" id="capture-list" aria-label="Available captures"></ol>
</aside>
<section class="review-panel" aria-labelledby="review-title">
<div class="panel-heading">
<div>
<p class="kicker">MANUAL REVIEW</p>
<h2 id="review-title">No capture selected</h2>
</div>
<span id="review-state">LOCKED</span>
</div>
<div class="review-empty" id="review-empty">
<p>Select a capture after the local session is unlocked. Media remains private to this browser session.</p>
</div>
<div class="review-content" id="review-content" hidden>
<figure class="evidence-frame">
<img id="capture-media" alt="Private captured game screen" />
<figcaption id="capture-meta"></figcaption>
</figure>
<form id="label-form" novalidate>
<fieldset id="family-fieldset">
<legend>Evidence family</legend>
<div class="choice-grid" id="family-options"></div>
</fieldset>
<fieldset id="view-fieldset" disabled>
<legend>Screen view</legend>
<div class="choice-grid" id="view-options"></div>
</fieldset>
<fieldset id="gear-slot-fieldset" hidden disabled>
<legend>Gear slot</legend>
<div class="choice-grid" id="gear-slot-options"></div>
</fieldset>
<fieldset id="record-types-fieldset" disabled>
<legend>Canonical records evidenced</legend>
<p class="field-help" id="record-types-help">Choose every record type shown by this screen.</p>
<div class="choice-grid" id="record-type-options"></div>
</fieldset>
<fieldset id="abstention-fieldset" hidden disabled>
<legend>Unknown screen reason</legend>
<div class="choice-grid" id="abstention-options"></div>
</fieldset>
<p class="validation-message" id="label-validation" role="status"></p>
<div class="form-actions">
<button class="button button-primary" id="save-label-button" type="submit" disabled>Save reviewed label</button>
<button class="button button-danger" id="reject-button" type="button" disabled>Reject evidence</button>
<button class="button button-danger quiet-danger" id="delete-button" type="button" disabled>Delete capture</button>
</div>
<p class="action-help">Rejecting retains evidence. Deletion requires separate confirmation and may be retained by policy.</p>
</form>
</div>
</section>
</section>
</main>
<dialog class="confirmation-dialog" id="confirmation-dialog" aria-labelledby="confirmation-title">
<form method="dialog" id="confirmation-form">
<p class="kicker" id="confirmation-kicker">CONFIRMATION REQUIRED</p>
<h2 id="confirmation-title">Confirm action</h2>
<p id="confirmation-copy"></p>
<div class="dialog-actions">
<button class="button" id="confirmation-cancel" value="cancel" type="submit">Cancel</button>
<button class="button button-primary" id="confirmation-submit" value="confirm" type="submit">Confirm</button>
</div>
</form>
</dialog>
</body>
</html>