feat(lab): publish M4.7 Worker graph evidence
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
"""Publish an immutable visual LAB binding for the accepted M4.7 graph shadow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from k1link.perception.reference_graph_result import read_reference_graph_result
|
||||
from k1link.perception.threat_replay import read_threat_replay_result
|
||||
|
||||
M47_REFERENCE_GRAPH_LAB_SCHEMA: Final = "missioncore.reference-perception-graph-lab/v1"
|
||||
M47_REFERENCE_GRAPH_LAB_REPORT_SCHEMA: Final = (
|
||||
"missioncore.reference-perception-graph-lab-report/v1"
|
||||
)
|
||||
M47_REFERENCE_GRAPH_LAB_PREFIX: Final = "m47-reference-graph-lab-"
|
||||
|
||||
|
||||
class M47ReferenceGraphLabError(RuntimeError):
|
||||
"""The M4.7 graph and visual replay cannot be bound without exact proof."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class M47ReferenceGraphLab:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
manifest: dict[str, object]
|
||||
report: dict[str, object]
|
||||
|
||||
|
||||
def publish_m47_reference_graph_lab(
|
||||
*,
|
||||
graph_result_root: Path,
|
||||
visual_result_root: Path,
|
||||
output_root: Path,
|
||||
) -> M47ReferenceGraphLab:
|
||||
graph = read_reference_graph_result(graph_result_root)
|
||||
visual = read_threat_replay_result(visual_result_root)
|
||||
if not graph.accepted or not visual.accepted:
|
||||
raise M47ReferenceGraphLabError("both graph and visual replay must be accepted")
|
||||
graph_report = graph.report
|
||||
graph_manifest = graph.manifest
|
||||
graph_runtime = _object(graph_manifest.get("runtime"), "graph runtime")
|
||||
parity = _object(graph_report.get("accepted_parity"), "graph parity")
|
||||
mismatches = _object(parity.get("mismatch_counts"), "graph parity mismatches")
|
||||
visual_identity = _object(visual.manifest.get("identity"), "visual identity")
|
||||
if (
|
||||
parity.get("accepted") is not True
|
||||
or parity.get("expected_frames") != 4489
|
||||
or parity.get("compared_frames") != 4489
|
||||
or set(mismatches)
|
||||
!= {
|
||||
"source_binding",
|
||||
"current",
|
||||
"rolling_retained",
|
||||
"held",
|
||||
"expired",
|
||||
"camera_uncertainty",
|
||||
"threat_assessments",
|
||||
}
|
||||
or any(value != 0 for value in mismatches.values())
|
||||
or parity.get("threat_frames_sha256") != visual_identity.get("frames_sha256")
|
||||
or parity.get("temporal_frames_sha256") != visual_identity.get("temporal_frames_sha256")
|
||||
):
|
||||
raise M47ReferenceGraphLabError("graph-to-visual parity proof changed")
|
||||
terminal = _object(graph_report.get("terminal_outcomes"), "terminal outcomes")
|
||||
queues = _object(graph_report.get("queue_high_watermarks"), "queue high watermarks")
|
||||
execution = _object(graph_report.get("execution"), "graph execution")
|
||||
authority = {
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"ground_truth": False,
|
||||
"mode": "replay-simulated",
|
||||
}
|
||||
visual_evidence = {
|
||||
"linked_result_id": visual.result_id,
|
||||
"binding": "exact-threat-and-temporal-ledger-parity",
|
||||
"timeline_frames": 4489,
|
||||
"shared_recorded_clock": True,
|
||||
"video_camera_3d_plan_available": True,
|
||||
"regression_sequences": [138, 274, 1880, 2584],
|
||||
"independent_ground_truth": False,
|
||||
}
|
||||
identity: dict[str, object] = {
|
||||
"schema_version": M47_REFERENCE_GRAPH_LAB_SCHEMA,
|
||||
"binding_id": "m47-reference-graph-visual-binding/v1",
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"source_session_id": visual_identity.get("source_session_id"),
|
||||
"graph_result_id": graph.result_id,
|
||||
"visual_result_id": visual.result_id,
|
||||
"temporal_frames_sha256": parity.get("temporal_frames_sha256"),
|
||||
"threat_frames_sha256": parity.get("threat_frames_sha256"),
|
||||
},
|
||||
"method": {
|
||||
"graph_id": graph_manifest.get("graph_id"),
|
||||
"run_mode": graph_manifest.get("run_mode"),
|
||||
"source_profile_id": graph_manifest.get("source_profile_id"),
|
||||
"canonical_payload_sha256": graph_manifest.get("canonical_payload_sha256"),
|
||||
"parity_schema_version": parity.get("schema_version"),
|
||||
},
|
||||
"execution": graph_runtime,
|
||||
"acceptance": {
|
||||
"accepted": True,
|
||||
"expected_frames": 4489,
|
||||
"admitted_frames": graph_report.get("admitted_frames"),
|
||||
"delivered_frames": terminal.get("delivered"),
|
||||
"parity_mismatch_counts": mismatches,
|
||||
},
|
||||
"visual_evidence": visual_evidence,
|
||||
"authority": authority,
|
||||
}
|
||||
identity_sha256 = _canonical_sha256(identity)
|
||||
result_id = f"{M47_REFERENCE_GRAPH_LAB_PREFIX}{identity_sha256}"
|
||||
elapsed_seconds = execution.get("elapsed_seconds")
|
||||
report: dict[str, object] = {
|
||||
"schema_version": M47_REFERENCE_GRAPH_LAB_REPORT_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"source": identity["source"],
|
||||
"method": identity["method"],
|
||||
"execution": {
|
||||
**graph_runtime,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
},
|
||||
"metrics": {
|
||||
"frames": {
|
||||
"expected": 4489,
|
||||
"admitted": graph_report.get("admitted_frames"),
|
||||
"delivered": terminal.get("delivered"),
|
||||
"failed": terminal.get("failed", 0),
|
||||
"stale": terminal.get("stale", 0),
|
||||
"superseded": terminal.get("superseded", 0),
|
||||
"rejected": terminal.get("rejected", 0),
|
||||
"unavailable": terminal.get("unavailable", 0),
|
||||
},
|
||||
"queue_high_watermarks": queues,
|
||||
"parity_mismatch_counts": mismatches,
|
||||
"canonical_payload_sha256": graph_manifest.get("canonical_payload_sha256"),
|
||||
},
|
||||
"acceptance": {
|
||||
"accepted": True,
|
||||
"gates": graph_report.get("gates"),
|
||||
"parity": parity,
|
||||
},
|
||||
"decision": {
|
||||
"state": "accepted-reference-graph-replay",
|
||||
"next_gate": "independent-object-centric-detection-quality",
|
||||
"summary": (
|
||||
"Canonical source→detector→geometry→temporal/motion→rolling→threat "
|
||||
"graph preserves the accepted M4.5R/M4.6 payload for every frame."
|
||||
),
|
||||
},
|
||||
"limitations": [
|
||||
"This result proves recorded lossless replay parity, not physical live operation.",
|
||||
"The linked M4.6 visual replay is engineering evidence, not independent object truth.",
|
||||
"No navigation, safety, command or actuation authority is granted.",
|
||||
"Independent object-centric detection quality remains the next acceptance gate.",
|
||||
],
|
||||
"authority": authority,
|
||||
"visual_evidence": visual_evidence,
|
||||
}
|
||||
output = output_root.expanduser().resolve()
|
||||
output.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if output.is_symlink() or not output.is_dir():
|
||||
raise M47ReferenceGraphLabError("LAB output root must be a real directory")
|
||||
staging = output / f".m47-reference-graph-lab.{uuid.uuid4().hex}.tmp"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
try:
|
||||
sources = {
|
||||
"graph-manifest.json": graph.result_root / "manifest.json",
|
||||
"graph-report.json": graph.result_root / "report.json",
|
||||
"graph-runtime.json": graph.result_root / "runtime.json",
|
||||
"visual-manifest.json": visual.result_root / "manifest.json",
|
||||
"visual-report.json": visual.result_root / "report.json",
|
||||
}
|
||||
for name, source in sources.items():
|
||||
shutil.copyfile(source, staging / name)
|
||||
_write_json(staging / "report.json", report)
|
||||
roles = {
|
||||
"report.json": "m47-lab-report",
|
||||
"graph-manifest.json": "m47-graph-manifest",
|
||||
"graph-report.json": "m47-graph-report",
|
||||
"graph-runtime.json": "m47-runtime",
|
||||
"visual-manifest.json": "linked-visual-manifest",
|
||||
"visual-report.json": "linked-visual-report",
|
||||
}
|
||||
schemas = {
|
||||
"graph-manifest.json": graph_manifest.get("schema_version"),
|
||||
"graph-report.json": graph_report.get("schema_version"),
|
||||
"graph-runtime.json": graph_runtime.get("schema_version"),
|
||||
"visual-manifest.json": visual.manifest.get("schema_version"),
|
||||
"visual-report.json": visual.report.get("schema_version"),
|
||||
"report.json": M47_REFERENCE_GRAPH_LAB_REPORT_SCHEMA,
|
||||
}
|
||||
artifacts = [_artifact(staging / name, role, schemas[name]) for name, role in roles.items()]
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": M47_REFERENCE_GRAPH_LAB_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": graph_runtime.get("started_at_utc"),
|
||||
"accepted": True,
|
||||
"ground_truth": False,
|
||||
"authority": authority,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
_write_json(staging / "manifest.json", manifest)
|
||||
target = output / result_id
|
||||
_publish(staging, target)
|
||||
except Exception:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
return read_m47_reference_graph_lab(target)
|
||||
|
||||
|
||||
def read_m47_reference_graph_lab(result_root: Path) -> M47ReferenceGraphLab:
|
||||
candidate = result_root.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB result root is invalid")
|
||||
resolved = candidate.resolve(strict=True)
|
||||
if (
|
||||
resolved.is_symlink()
|
||||
or not resolved.is_dir()
|
||||
or not resolved.name.startswith(M47_REFERENCE_GRAPH_LAB_PREFIX)
|
||||
):
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB result root is invalid")
|
||||
manifest = _read_json(resolved / "manifest.json")
|
||||
if (
|
||||
manifest.get("schema_version") != M47_REFERENCE_GRAPH_LAB_SCHEMA
|
||||
or manifest.get("result_id") != resolved.name
|
||||
or manifest.get("accepted") is not True
|
||||
or manifest.get("ground_truth") is not False
|
||||
):
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB manifest changed")
|
||||
identity = _object(manifest.get("identity"), "LAB identity")
|
||||
identity_sha256 = _canonical_sha256(identity)
|
||||
if (
|
||||
manifest.get("identity_sha256") != identity_sha256
|
||||
or resolved.name != f"{M47_REFERENCE_GRAPH_LAB_PREFIX}{identity_sha256}"
|
||||
):
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB identity changed")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or len(artifacts) != 6:
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB artifact inventory changed")
|
||||
for value in artifacts:
|
||||
descriptor = _object(value, "LAB artifact")
|
||||
path_value = descriptor.get("path")
|
||||
if not isinstance(path_value, str) or "/" in path_value or "\\" in path_value:
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB artifact path changed")
|
||||
path = resolved / path_value
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_file()
|
||||
or path.stat().st_size != descriptor.get("byte_length")
|
||||
or _file_sha256(path) != descriptor.get("sha256")
|
||||
):
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB artifact proof changed")
|
||||
report = _read_json(resolved / "report.json")
|
||||
if (
|
||||
report.get("schema_version") != M47_REFERENCE_GRAPH_LAB_REPORT_SCHEMA
|
||||
or report.get("result_id") != resolved.name
|
||||
or _object(report.get("acceptance"), "LAB acceptance").get("accepted") is not True
|
||||
or report.get("authority") != identity.get("authority")
|
||||
):
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB report changed")
|
||||
return M47ReferenceGraphLab(
|
||||
result_id=resolved.name,
|
||||
result_root=resolved,
|
||||
manifest=manifest,
|
||||
report=report,
|
||||
)
|
||||
|
||||
|
||||
def _artifact(path: Path, role: str, schema: object) -> dict[str, object]:
|
||||
return {
|
||||
"role": role,
|
||||
"path": path.name,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _file_sha256(path),
|
||||
"schema_version": schema,
|
||||
"media_type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _publish(staging: Path, target: Path) -> None:
|
||||
if target.exists():
|
||||
existing = {path.name: _file_sha256(path) for path in target.iterdir() if path.is_file()}
|
||||
proposed = {path.name: _file_sha256(path) for path in staging.iterdir() if path.is_file()}
|
||||
if existing != proposed:
|
||||
raise M47ReferenceGraphLabError("immutable M4.7 LAB identity collision")
|
||||
shutil.rmtree(staging)
|
||||
return
|
||||
os.replace(staging, target)
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise M47ReferenceGraphLabError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _write_json(path: Path, document: dict[str, object]) -> None:
|
||||
with path.open("wb") as stream:
|
||||
stream.write(_canonical_json(document) + b"\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 1024 * 1024:
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB JSON artifact is invalid")
|
||||
try:
|
||||
return _object(json.loads(path.read_text("utf-8")), "M4.7 LAB JSON artifact")
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise M47ReferenceGraphLabError("M4.7 LAB JSON artifact is unreadable") from error
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"M47_REFERENCE_GRAPH_LAB_PREFIX",
|
||||
"M47_REFERENCE_GRAPH_LAB_REPORT_SCHEMA",
|
||||
"M47_REFERENCE_GRAPH_LAB_SCHEMA",
|
||||
"M47ReferenceGraphLab",
|
||||
"M47ReferenceGraphLabError",
|
||||
"publish_m47_reference_graph_lab",
|
||||
"read_m47_reference_graph_lab",
|
||||
]
|
||||
@@ -153,6 +153,103 @@ def seal_reference_graph_result(
|
||||
)
|
||||
|
||||
|
||||
def read_reference_graph_result(result_root: Path) -> SealedReferenceGraphResult:
|
||||
candidate = result_root.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise ReferenceGraphResultError("reference graph result root is invalid")
|
||||
resolved = candidate.resolve(strict=True)
|
||||
if (
|
||||
resolved.is_symlink()
|
||||
or not resolved.is_dir()
|
||||
or not resolved.name.startswith(REFERENCE_GRAPH_RESULT_PREFIX)
|
||||
):
|
||||
raise ReferenceGraphResultError("reference graph result root is invalid")
|
||||
manifest = _read_json(resolved / "manifest.json")
|
||||
expected_manifest_keys = {
|
||||
"schema_version",
|
||||
"result_id",
|
||||
"identity_sha256",
|
||||
"graph_id",
|
||||
"source_profile_id",
|
||||
"run_mode",
|
||||
"canonical_payload_sha256",
|
||||
"runtime",
|
||||
"files",
|
||||
"accepted",
|
||||
}
|
||||
if set(manifest) != expected_manifest_keys:
|
||||
raise ReferenceGraphResultError("reference graph manifest fields changed")
|
||||
if manifest.get("schema_version") != REFERENCE_GRAPH_MANIFEST_SCHEMA:
|
||||
raise ReferenceGraphResultError("reference graph manifest schema changed")
|
||||
if not isinstance(manifest.get("accepted"), bool):
|
||||
raise ReferenceGraphResultError("reference graph acceptance type changed")
|
||||
identity = {
|
||||
key: manifest[key]
|
||||
for key in (
|
||||
"graph_id",
|
||||
"source_profile_id",
|
||||
"run_mode",
|
||||
"canonical_payload_sha256",
|
||||
"runtime",
|
||||
"files",
|
||||
)
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
if (
|
||||
manifest.get("result_id") != resolved.name
|
||||
or manifest.get("identity_sha256") != identity_sha256
|
||||
or resolved.name != f"{REFERENCE_GRAPH_RESULT_PREFIX}{identity_sha256}"
|
||||
):
|
||||
raise ReferenceGraphResultError("reference graph identity changed")
|
||||
runtime = ReferenceGraphRuntimeIdentity.from_dict(manifest.get("runtime"))
|
||||
files = manifest.get("files")
|
||||
if not isinstance(files, dict) or set(files) != {
|
||||
"frames.jsonl",
|
||||
"outcomes.jsonl",
|
||||
"report.json",
|
||||
"runtime.json",
|
||||
}:
|
||||
raise ReferenceGraphResultError("reference graph artifact inventory changed")
|
||||
for name, descriptor_value in files.items():
|
||||
if not isinstance(descriptor_value, dict) or set(descriptor_value) != {
|
||||
"sha256",
|
||||
"bytes",
|
||||
}:
|
||||
raise ReferenceGraphResultError("reference graph artifact proof changed")
|
||||
path = _safe_result_file(resolved, name)
|
||||
if descriptor_value.get("bytes") != path.stat().st_size or descriptor_value.get(
|
||||
"sha256"
|
||||
) != _sha256_file(path):
|
||||
raise ReferenceGraphResultError("reference graph artifact proof does not match")
|
||||
runtime_document = _read_json(resolved / "runtime.json")
|
||||
if runtime_document != runtime.to_dict():
|
||||
raise ReferenceGraphResultError("reference graph runtime artifact changed")
|
||||
report = _read_json(resolved / "report.json")
|
||||
if (
|
||||
report.get("schema_version") != REFERENCE_GRAPH_REPORT_SCHEMA
|
||||
or report.get("graph_id") != manifest.get("graph_id")
|
||||
or report.get("canonical_payload_sha256") != manifest.get("canonical_payload_sha256")
|
||||
or not isinstance(report.get("execution"), dict)
|
||||
or report["execution"].get("runtime_identity") != runtime_document
|
||||
or report.get("accepted") is not manifest.get("accepted")
|
||||
):
|
||||
raise ReferenceGraphResultError("reference graph report changed")
|
||||
gates = report.get("gates")
|
||||
if (
|
||||
not isinstance(gates, dict)
|
||||
or not gates
|
||||
or manifest.get("accepted") is not all(value is True for value in gates.values())
|
||||
):
|
||||
raise ReferenceGraphResultError("reference graph acceptance proof changed")
|
||||
return SealedReferenceGraphResult(
|
||||
result_id=resolved.name,
|
||||
result_root=resolved,
|
||||
accepted=bool(manifest["accepted"]),
|
||||
report=report,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
|
||||
def _write_json_lines(path: Path, rows: tuple[dict[str, object], ...]) -> None:
|
||||
with path.open("wb") as handle:
|
||||
for row in rows:
|
||||
@@ -168,6 +265,28 @@ def _write_json(path: Path, document: dict[str, object]) -> None:
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 1024 * 1024:
|
||||
raise ReferenceGraphResultError("reference graph JSON artifact is invalid")
|
||||
try:
|
||||
document = json.loads(path.read_text("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ReferenceGraphResultError("reference graph JSON artifact is unreadable") from error
|
||||
if not isinstance(document, dict) or any(not isinstance(key, str) for key in document):
|
||||
raise ReferenceGraphResultError("reference graph JSON artifact must be an object")
|
||||
return document
|
||||
|
||||
|
||||
def _safe_result_file(root: Path, name: str) -> Path:
|
||||
path = root / name
|
||||
if path.is_symlink():
|
||||
raise ReferenceGraphResultError("reference graph artifact must not be a symlink")
|
||||
resolved = path.resolve(strict=True)
|
||||
if resolved.parent != root or not resolved.is_file():
|
||||
raise ReferenceGraphResultError("reference graph artifact escaped its result")
|
||||
return resolved
|
||||
|
||||
|
||||
def _publish_immutable(staging: Path, target: Path) -> None:
|
||||
if target.exists():
|
||||
if target.is_symlink() or not target.is_dir():
|
||||
@@ -199,5 +318,6 @@ __all__ = [
|
||||
"REFERENCE_GRAPH_RESULT_PREFIX",
|
||||
"ReferenceGraphResultError",
|
||||
"SealedReferenceGraphResult",
|
||||
"read_reference_graph_result",
|
||||
"seal_reference_graph_result",
|
||||
]
|
||||
|
||||
@@ -98,6 +98,10 @@ def build_m4_threat_replay_router(
|
||||
"access": "read-only-replay-simulated",
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}")
|
||||
def get_result(result_id: str) -> dict[str, object]:
|
||||
return _project_result(result(result_id))
|
||||
|
||||
@router.get("/results/{result_id}/visuals")
|
||||
def list_visuals(result_id: str) -> dict[str, object]:
|
||||
frozen = result(result_id)
|
||||
|
||||
Reference in New Issue
Block a user