From e112680ad815b5159bc368ed44876aa9bd69ae8c Mon Sep 17 00:00:00 2001 From: SayQuan1 <15246906705@163.com> Date: Mon, 28 Sep 2026 19:22:13 +0800 Subject: [PATCH 1/3] feat(i-01a): add isolated Python 3.11 development skeleton Unify the business and AI interpreter baseline on Python 3.11 while preserving separate dependencies and processes. Replace framework fallbacks, align the authenticated fixture with generated AI contracts, reject production, and document ADR-PY-01 and I-01A acceptance. Validation: 25 application/protocol tests; clean AI-only environment; Ruff check and format; npm ci and dev build; production rejection; wheel/resource smoke; 228 synthetic readiness/release/D-FINE checks; generated artifacts and staged whitespace checks. Scope: development skeleton only. MySQL, durable jobs, real spawn computation, D-FINE/OCR/GPU inference, and production deployment remain later milestones. Runtime and auth changes require independent review. Rollback: revert this commit. No database migration or production data changes. Do not rewrite main history. --- .env.example | 15 + .github/workflows/ci.yml | 89 + .gitignore | 7 + DEVELOPMENT.md | 151 ++ apps/__init__.py | 1 + apps/ai_inference/__init__.py | 1 + apps/ai_inference/app/__init__.py | 1 + apps/ai_inference/app/fixture.py | 125 ++ apps/ai_inference/app/main.py | 136 ++ apps/ai_inference/app/settings.py | 95 + apps/ai_inference/fixtures/dictionary.json | 5 + apps/ai_inference/fixtures/model.json | 7 + apps/ai_inference/fixtures/runtime-lock.json | 14 + apps/ai_inference/run.py | 19 + apps/api/__init__.py | 1 + apps/api/app/__init__.py | 1 + apps/api/app/main.py | 25 + apps/api/run.py | 19 + apps/web/index.html | 12 + apps/web/package-lock.json | 1718 ++++++++++++++++ apps/web/package.json | 24 + apps/web/src/App.vue | 11 + apps/web/src/main.ts | 5 + apps/web/tsconfig.json | 16 + apps/web/vite.config.ts | 14 + apps/worker/__init__.py | 1 + apps/worker/app/__init__.py | 1 + apps/worker/app/main.py | 28 + apps/worker/run.py | 20 + docs/00-document-control.md | 4 +- .../03-ai-inference-process.md | 4 +- docs/02-architecture/04-coding-baseline.md | 8 +- docs/02-architecture/07-python-runtime.md | 38 + docs/02-architecture/README.md | 1 + docs/04-ai-rules/02-model-data-plan.md | 18 +- .../02-deployment-operations.md | 2 + docs/08-delivery/01-implementation-plan.md | 6 +- docs/08-delivery/04-remediation-plan.md | 2 +- docs/08-delivery/07-i01a-acceptance.md | 57 + docs/08-delivery/README.md | 1 + .../design-validation-results.json | 4 +- packages/__init__.py | 1 + packages/application/__init__.py | 1 + packages/domain/__init__.py | 1 + packages/inference_protocol/__init__.py | 1 + packages/inference_protocol/contract.json | 1730 +++++++++++++++++ packages/inference_protocol/contract.py | 36 + packages/inference_protocol/hashing.py | 34 + packages/persistence/__init__.py | 1 + packages/rules/__init__.py | 1 + packages/shared/__init__.py | 1 + packages/shared/environment.py | 13 + packages/shared/json_hash.py | 5 + pyproject.toml | 60 + requirements/common.txt | 7 + requirements/dev-ai.txt | 2 + requirements/dev.txt | 2 + requirements/py311-ai.txt | 2 + requirements/py311-business.txt | 7 + requirements/test.txt | 3 + tests/__init__.py | 1 + tests/ai/__init__.py | 1 + tests/ai/test_fixture.py | 320 +++ tests/business/__init__.py | 1 + tests/business/test_bootstrap.py | 77 + tests/helpers.py | 119 ++ tests/protocol/__init__.py | 1 + tests/protocol/test_contract.py | 39 + tools/design/README.md | 2 +- tools/design/build_specs.py | 5 + 70 files changed, 5160 insertions(+), 21 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 DEVELOPMENT.md create mode 100644 apps/__init__.py create mode 100644 apps/ai_inference/__init__.py create mode 100644 apps/ai_inference/app/__init__.py create mode 100644 apps/ai_inference/app/fixture.py create mode 100644 apps/ai_inference/app/main.py create mode 100644 apps/ai_inference/app/settings.py create mode 100644 apps/ai_inference/fixtures/dictionary.json create mode 100644 apps/ai_inference/fixtures/model.json create mode 100644 apps/ai_inference/fixtures/runtime-lock.json create mode 100644 apps/ai_inference/run.py create mode 100644 apps/api/__init__.py create mode 100644 apps/api/app/__init__.py create mode 100644 apps/api/app/main.py create mode 100644 apps/api/run.py create mode 100644 apps/web/index.html create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.vue create mode 100644 apps/web/src/main.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 apps/worker/__init__.py create mode 100644 apps/worker/app/__init__.py create mode 100644 apps/worker/app/main.py create mode 100644 apps/worker/run.py create mode 100644 docs/02-architecture/07-python-runtime.md create mode 100644 docs/08-delivery/07-i01a-acceptance.md create mode 100644 packages/__init__.py create mode 100644 packages/application/__init__.py create mode 100644 packages/domain/__init__.py create mode 100644 packages/inference_protocol/__init__.py create mode 100644 packages/inference_protocol/contract.json create mode 100644 packages/inference_protocol/contract.py create mode 100644 packages/inference_protocol/hashing.py create mode 100644 packages/persistence/__init__.py create mode 100644 packages/rules/__init__.py create mode 100644 packages/shared/__init__.py create mode 100644 packages/shared/environment.py create mode 100644 packages/shared/json_hash.py create mode 100644 pyproject.toml create mode 100644 requirements/common.txt create mode 100644 requirements/dev-ai.txt create mode 100644 requirements/dev.txt create mode 100644 requirements/py311-ai.txt create mode 100644 requirements/py311-business.txt create mode 100644 requirements/test.txt create mode 100644 tests/__init__.py create mode 100644 tests/ai/__init__.py create mode 100644 tests/ai/test_fixture.py create mode 100644 tests/business/__init__.py create mode 100644 tests/business/test_bootstrap.py create mode 100644 tests/helpers.py create mode 100644 tests/protocol/__init__.py create mode 100644 tests/protocol/test_contract.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e1091c --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +APP_ENV=dev +API_HOST=127.0.0.1 +API_PORT=8000 +AI_INFERENCE_HOST=127.0.0.1 +AI_INFERENCE_PORT=8001 +REDIS_URL=redis://localhost:6379/0 +DATABASE_URL=mysql+pymysql://labsafe:change-me@localhost:3306/labsafe +AI_MODE=mock +AI_TOKEN_FILE=.local-secrets/ai-token +AI_ALLOWED_TENANTS=11111111-1111-4111-8111-111111111111 +AI_MAX_INFLIGHT=1 +AI_FIXTURE_SCENARIO=no_targets +AI_FIXTURE_DELAY_MS=0 +SERVICE_COMMIT=local-development +VITE_APP_ENV=dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb7f638 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: ci + +on: + pull_request: + push: + branches: [main, 'wsq/**'] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHONDONTWRITEBYTECODE: '1' + PYTHONUTF8: '1' + +jobs: + business-python311: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements/*.txt + - run: python -m pip install -r requirements/dev.txt + - run: python -m pip check + - run: python -m unittest discover -s tests/business -t . -v + - run: python -m unittest discover -s tests/protocol -t . -v + - run: ruff check apps packages tests + - run: ruff format --check apps packages tests + + ai-python311: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements/*.txt + - run: python -m pip install -r requirements/dev-ai.txt + - run: python -m pip check + - name: AI environment must not install business or model dependencies + run: | + python -c "import importlib.util; names=['celery','redis','sqlalchemy','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)" + - run: python -m unittest discover -s tests/ai -t . -v + - run: python -m unittest discover -s tests/protocol -t . -v + + design-contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: python -m pip install -r tools/design/requirements.txt + - run: python -B tools/design/build_specs.py --check + - run: python -B tools/design/test_readiness_design.py + - run: python -B tools/design/validate_specs.py + - run: git diff --exit-code + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web + env: + APP_ENV: dev + VITE_APP_ENV: dev + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: apps/web/package-lock.json + - run: npm ci + - run: npm run build + - name: Reject production fixture builds + run: | + if APP_ENV=production VITE_APP_ENV=production npm run build; then + echo "ERROR: I-01A must reject production builds" + exit 1 + fi + - run: git diff --exit-code -- package.json package-lock.json diff --git a/.gitignore b/.gitignore index 264d825..d167b71 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,10 @@ node_modules/ dist/ build/ coverage/ +.venv/ +.venv-*/ +.ruff_cache/ +.mypy_cache/ +*.egg-info/ +.local-secrets/ +test-results/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..0d8b5d0 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,151 @@ +# LabSafe 开发指南(I-01A) + +## 1. 本阶段边界 + +所有 Python 服务统一使用 Python 3.11.x;业务与 AI 使用独立虚拟环境和独立进程。 +依据见 [运行时决策](docs/02-architecture/07-python-runtime.md)。前端 CI 使用 Node.js 20。 + +本阶段提供真实 FastAPI/Celery 入口、同协议开发 fixture、配置保护和 CI。 +不包含数据库迁移、用户会话、业务 API、持久任务、真实图像处理、训练权重或生产部署。 +API 的 /ready 只检查 I-01A 进程配置,不代表数据库或完整系统已就绪。 +所有后端进程和前端构建均拒绝 production;AI_MODE 只接受 mock。 + +## 2. 安装依赖 + +在仓库根目录执行。Windows 示例: + +~~~powershell +py -3.11 -m venv .venv-business +.\.venv-business\Scripts\python.exe -m pip install -r requirements/dev.txt + +py -3.11 -m venv .venv-ai +.\.venv-ai\Scripts\python.exe -m pip install -r requirements/dev-ai.txt +~~~ + +Linux 使用 python3.11 创建环境,并将 Scripts/python.exe 替换为 bin/python。 +不需安装模型、CUDA、数据库驱动到 AI 环境;AI 只安装 common.txt 与测试依赖。 +requirements 是顶层依赖约束,不是完整传递依赖锁或 GPU 兼容性证明。 + +## 3. 配置和启动 + +.env.example 是变量清单,当前程序不会自动读取 .env。请显式设置环境变量。 +以下每个终端均从仓库根目录启动,不能把三个前台进程串在同一个终端等待。 + +### 3.1 API 终端 + +~~~powershell +$env:APP_ENV = 'dev' +.\.venv-business\Scripts\python.exe -m apps.api.run +~~~ + +API 监听 127.0.0.1:8000;GET /health 为存活,GET /ready 返回开发骨架就绪范围。 +不提供假的登录、业务成功或数据库就绪结果。 + +### 3.2 AI 终端 + +首次创建本地随机令牌;文件在 .local-secrets/ 下,受 Git 忽略: + +~~~powershell +.\.venv-ai\Scripts\python.exe -c "import pathlib,secrets; p=pathlib.Path('.local-secrets/ai-token'); p.parent.mkdir(exist_ok=True); p.write_text(secrets.token_urlsafe(32),encoding='ascii')" +$env:APP_ENV = 'dev' +$env:AI_MODE = 'mock' +$env:AI_TOKEN_FILE = (Resolve-Path .local-secrets/ai-token).Path +$env:AI_ALLOWED_TENANTS = '11111111-1111-4111-8111-111111111111' +$env:AI_FIXTURE_SCENARIO = 'no_targets' +.\.venv-ai\Scripts\python.exe -m apps.ai_inference.run +~~~ + +AI 监听 127.0.0.1:8001。五个接口均使用 /internal/inference/v1 前缀,并要求 Bearer 服务令牌: + +| 方法 | 路径 | 开发行为 | +|---|---|---| +| GET | /health | supervisor HTTP 进程存活与当前 attempt | +| GET | /ready | 生命周期启动成功才 200;忙碌不等于未就绪 | +| GET | /version | development、is_simulated、fixture 身份与真实文件摘要 | +| POST | /quality | InferenceRequest → InferenceResult;通过或重拍分支 | +| POST | /runs | 同协议;无目标返回 needs_review,不冒充安全通过 | + +不再提供 /fixture、裸 /version 或未鉴权的 AI /healthz。 +请求和响应按生成的契约校验,不给 InferenceResult 私加 is_simulated 字段;模拟身份来自 /version,后续由 Worker 固化到业务 run。 + +AI_ALLOWED_TENANTS 必须是显式 UUID 清单;令牌为至少 32 字节的随机 ASCII 字符串。 +请求模型/词典版本、实际 fixture 文件 SHA、设备 cpu、request_hash、图片关联与 analysis key 必须匹配。 +fixture 不下载对象或读取图片;质量分数、空事实与阶段耗时均是合成值,不是性能或识别证据。 + +AI_FIXTURE_SCENARIO 可设 no_targets、needs_retake、error、timeout;AI_FIXTURE_DELAY_MS 范围 0–10000。 +这些只能由开发者在服务启动环境配置,不能通过请求选择。 +同一实例只接收一个在途请求;重复活动 attempt 返回 409,其他请求返回 429;超时返回 504 并释放容量。 +当前模拟延迟可协作取消;真实 spawn 计算子进程及 kill/join 恢复在 I-03 实现,不能把本次 fixture 当成已完成的生产 supervisor。 + +开发 fixture 位于 apps/ai_inference/fixtures/:model.json、dictionary.json 均为小型合成文件;runtime-lock.json 校验 Python minor 及直接依赖版本。 +该锁的 scope 明确为 fixture-direct-constraints-only,不声称传递依赖可复现,也不能用于真实模型激活。 + +### 3.3 Worker 终端 + +无需 Redis 的入口检查: + +~~~powershell +$env:APP_ENV = 'dev' +.\.venv-business\Scripts\python.exe -m apps.worker.run --check +~~~ + +实际 Celery 队列运行按设计使用 Linux/WSL 的独立业务环境及 Redis: + +~~~bash +APP_ENV=dev REDIS_URL=redis://localhost:6379/0 .venv-business/bin/python -m apps.worker.run +~~~ + +--check 只验证真实 Celery 配置与任务注册,不证明 Redis 连通或任务消费。 +labsafe.fixture.echo 只是本地 smoke 任务,不是推理流程,不能用它绕过持久任务或写入成功状态。 +完整 Worker→AI→数据库链路及 Outbox/inbox/fencing 在 I-03 落地。 + +### 3.4 Web 终端 + +~~~powershell +$env:APP_ENV = 'dev' +$env:VITE_APP_ENV = 'dev' +Set-Location apps/web +npm ci +npm run dev +~~~ + +两个环境变量必须相等且为 dev/test。开发页面始终显示“开发环境,结果不用于安全判断”。 +构建命令是 npm run build;production 或不一致环境必须失败。 + +## 4. 验证 + +根目录、业务环境执行: + +~~~powershell +.\.venv-business\Scripts\python.exe -m unittest discover -s tests/business -t . -v +.\.venv-business\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v +.\.venv-business\Scripts\python.exe -m ruff check apps packages tests +.\.venv-business\Scripts\python.exe -m ruff format --check apps packages tests +~~~ + +AI 环境执行: + +~~~powershell +.\.venv-ai\Scripts\python.exe -m unittest discover -s tests/ai -t . -v +.\.venv-ai\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v +~~~ + +测试自行在临时目录生成令牌和合成请求,不要求预设 APP_ENV,也不连接 Redis/MySQL/对象存储。 +包含实际 API/AI 子进程 HTTP smoke,不只是 import 非空检查。 + +设计校验使用独立工具环境,安装 tools/design/requirements.txt 后执行: + +~~~powershell +python -B tools/design/build_specs.py --check +python -B tools/design/test_readiness_design.py +python -B tools/design/validate_specs.py +~~~ + +修改契约时只改 tools/design 的生成源,再运行 build_specs.py;同一生成器输出 contracts 和运行时 packages/inference_protocol/contract.json。 +CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用、模型或部署验收。 + +## 5. 提交和下一阶段 + +遵循 [Git 协作规范](CONTRIBUTING.md)。提交特性分支、创建 PR;不直接推送 main,不自行合并或代替独立审查。 +不提交虚拟环境、.local-secrets、真实图片、权重或 .env。 +下一阶段从 I-01B 专用空库迁移开始;真实模型主线可按 I-ML-01 并行,但不扩大本 PR 验收范围。 diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..af6e9fa --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1 @@ +"""LabSafe process entrypoints.""" diff --git a/apps/ai_inference/__init__.py b/apps/ai_inference/__init__.py new file mode 100644 index 0000000..aa8dbb4 --- /dev/null +++ b/apps/ai_inference/__init__.py @@ -0,0 +1 @@ +"""Isolated AI inference process.""" diff --git a/apps/ai_inference/app/__init__.py b/apps/ai_inference/app/__init__.py new file mode 100644 index 0000000..498905e --- /dev/null +++ b/apps/ai_inference/app/__init__.py @@ -0,0 +1 @@ +"""AI supervisor application package.""" diff --git a/apps/ai_inference/app/fixture.py b/apps/ai_inference/app/fixture.py new file mode 100644 index 0000000..4be6721 --- /dev/null +++ b/apps/ai_inference/app/fixture.py @@ -0,0 +1,125 @@ +"""Synthetic contract fixture. No image download, inference or business writes.""" + +import asyncio +from datetime import datetime, timezone +from typing import Any + +from packages.inference_protocol.contract import DOCUMENT +from packages.inference_protocol.hashing import request_hash + +from .settings import Settings + + +class ProtocolError(Exception): + def __init__(self, code: str, message: str) -> None: + self.code = code + self.message = message + self.status = DOCUMENT["x-error-codes"][code]["http_status"] + self.retryable = DOCUMENT["x-error-codes"][code]["retryable"] + super().__init__(message) + + +def verify_request(payload: dict[str, Any], settings: Settings) -> None: + if payload["tenant_id"] not in settings.allowed_tenants: + raise ProtocolError("FORBIDDEN", "Tenant is not allowed on this fixture instance") + for key in ("model_bundle_id", "dictionary_version_id", "pipeline_version", "device_profile"): + if payload[key] != settings.identity[key]: + raise ProtocolError( + "MODEL_VERSION_UNAVAILABLE", "Request does not match loaded fixture" + ) + if ( + payload["model_checksum"] != settings.model_checksum + or payload["dictionary_sha256"] != settings.dictionary_sha256 + ): + raise ProtocolError("HASH_MISMATCH", "Fixture artifact hash mismatch") + if payload["request_hash"] != request_hash(payload): + raise ProtocolError("HASH_MISMATCH", "Request content hash mismatch") + refs = payload["image_refs"] + overview = refs[0] + if overview["role"] != "overview" or overview["parent_image_id"] is not None: + raise ProtocolError("VALIDATION_ERROR", "First image must be the unique overview") + if len({image["image_id"] for image in refs}) != len(refs): + raise ProtocolError("VALIDATION_ERROR", "Image IDs must be unique") + for index, image in enumerate(refs): + expected_key = ( + f"tenant/{payload['tenant_id']}/lab/{payload['laboratory_id']}/analysis/" + f"{image['image_id']}/{image['sha256']}.png" + ) + if image["object_key"] != expected_key: + raise ProtocolError("UNAUTHORIZED_REF", "Only exact analysis object keys are accepted") + if image["mime_type"] != "image/png": + raise ProtocolError("VALIDATION_ERROR", "Analysis images must be normalized PNG") + if image["location_id"] != overview["location_id"]: + raise ProtocolError("VALIDATION_ERROR", "Images must share a location") + if index and ( + image["role"] != "detail" or image["parent_image_id"] != overview["image_id"] + ): + raise ProtocolError("VALIDATION_ERROR", "Detail must refer to the overview") + + +async def compute(payload: dict[str, Any], settings: Settings, stage: str) -> dict[str, Any]: + remaining = ( + datetime.fromisoformat(payload["deadline_at"].upper().replace("Z", "+00:00")) + - datetime.now(timezone.utc) + ).total_seconds() + if remaining <= 0: + raise ProtocolError("AI_TIMEOUT", "Request deadline has expired") + budget = min(remaining, 10 if stage == "quality" else 180) + delay = budget + 1 if settings.scenario == "timeout" else settings.delay_ms / 1000 + try: + # Cooperative synthetic delay only. Real compute cancellation belongs to I-03. + await asyncio.wait_for(asyncio.sleep(delay), timeout=budget) + except TimeoutError as exc: + raise ProtocolError("AI_TIMEOUT", "Fixture deadline exceeded") from exc + if settings.scenario == "error": + raise ProtocolError("MODEL_ERROR", "Configured synthetic fixture failure") + retake = settings.scenario == "needs_retake" + echoed = ( + "run_id", + "attempt_id", + "fencing_token", + "tenant_id", + "request_hash", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + ) + return { + **{key: payload[key] for key in echoed}, + "input_hashes": [ + {"image_id": image["image_id"], "sha256": image["sha256"]} + for image in payload["image_refs"] + ], + "outcome": "needs_retake" + if retake + else ("facts_ready" if stage == "quality" else "needs_review"), + "quality": [ + { + "image_id": image["image_id"], + "status": "needs_retake" if retake else "pass", + "reasons": ["blur"] if retake else [], + "blur_score": 0 if retake else 100, + "brightness": 0.5, + "glare_ratio": 0, + } + for image in payload["image_refs"] + ], + "detections": [], + "crops": [], + "ocr_fields": [], + "entities": [], + "relations": [], + "timing_ms": { + key: 0 + for key in ( + "download_ms", + "quality_ms", + "detection_ms", + "ocr_ms", + "normalization_ms", + "total_ms", + ) + }, + } diff --git a/apps/ai_inference/app/main.py b/apps/ai_inference/app/main.py new file mode 100644 index 0000000..507a576 --- /dev/null +++ b/apps/ai_inference/app/main.py @@ -0,0 +1,136 @@ +"""Authenticated, same-protocol development fixture; no production inference.""" + +import asyncio +import copy +import hmac +from contextlib import asynccontextmanager +from typing import Annotated, Any +from uuid import uuid4 + +from fastapi import APIRouter, Depends, FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from packages.inference_protocol.contract import DOCUMENT, InferenceRequest, validate + +from .fixture import ProtocolError, compute, verify_request +from .settings import Settings + + +def create_app(settings: Settings | None = None) -> FastAPI: + settings = settings or Settings.from_env() + if settings.environment not in {"dev", "test"}: + raise RuntimeError("Production is disabled in I-01A") + validate("Version", settings.identity) + gate = asyncio.Lock() + ready = False + active_attempt: str | None = None + + @asynccontextmanager + async def lifespan(app: FastAPI): + nonlocal ready + ready = True + try: + yield + finally: + ready = False + + app = FastAPI( + title="LabSafe AI development fixture", + lifespan=lifespan, + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + # Exact checked-in wire schema, packaged by the existing design generator. + app.openapi = lambda: copy.deepcopy(DOCUMENT) + bearer = HTTPBearer(auto_error=False) + + async def authenticate( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + ) -> None: + if credentials is None or not hmac.compare_digest( + credentials.credentials.encode("utf-8"), settings.token.encode("ascii") + ): + raise ProtocolError("UNAUTHENTICATED", "Valid service bearer token required") + + def checked(name: str, payload: dict[str, Any]) -> dict[str, Any]: + validate(name, payload) + return payload + + @app.exception_handler(ProtocolError) + async def protocol_error(request: Request, exc: ProtocolError) -> JSONResponse: + payload = checked( + "Error", + { + "request_id": str(uuid4()), + "error": { + "code": exc.code, + "message": exc.message, + "retryable": exc.retryable, + "details": [], + }, + }, + ) + headers = {"WWW-Authenticate": "Bearer"} if exc.status == 401 else None + return JSONResponse(payload, status_code=exc.status, headers=headers) + + @app.exception_handler(RequestValidationError) + async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + return await protocol_error( + request, ProtocolError("VALIDATION_ERROR", "Request violates the inference contract") + ) + + router = APIRouter(prefix=DOCUMENT["servers"][0]["url"], dependencies=[Depends(authenticate)]) + + def health_payload(status: str) -> dict[str, Any]: + return checked( + "Health", + { + "status": status, + "model_bundle_id": settings.identity["model_bundle_id"], + "active_attempt_id": active_attempt, + }, + ) + + @router.get("/health", operation_id="aiHealth") + async def health() -> dict[str, Any]: + return health_payload("alive") + + @router.get("/ready", operation_id="aiReady") + async def readiness() -> dict[str, Any]: + if not ready: + raise ProtocolError("MODEL_NOT_READY", "Fixture lifespan has not started") + return health_payload("ready") + + @router.get("/version", operation_id="aiVersion") + async def version() -> dict[str, Any]: + return checked("Version", dict(settings.identity)) + + async def infer(payload: InferenceRequest, stage: str) -> dict[str, Any]: + nonlocal active_attempt + if not ready: + raise ProtocolError("MODEL_NOT_READY", "Fixture is not ready") + body = payload.root + verify_request(body, settings) + if gate.locked(): + code = "RUN_IN_PROGRESS" if active_attempt == body["attempt_id"] else "AI_BUSY" + raise ProtocolError(code, "This instance already has an active attempt") + async with gate: + active_attempt = body["attempt_id"] + try: + return checked("InferenceResult", await compute(body, settings, stage)) + finally: + active_attempt = None + + @router.post("/quality", operation_id="aiQuality") + async def quality(payload: InferenceRequest) -> dict[str, Any]: + return await infer(payload, "quality") + + @router.post("/runs", operation_id="aiRuns") + async def runs(payload: InferenceRequest) -> dict[str, Any]: + return await infer(payload, "runs") + + app.include_router(router) + return app diff --git a/apps/ai_inference/app/settings.py b/apps/ai_inference/app/settings.py new file mode 100644 index 0000000..1b873fb --- /dev/null +++ b/apps/ai_inference/app/settings.py @@ -0,0 +1,95 @@ +"""Explicit, development-only AI startup settings and verified fixture identity.""" + +import hashlib +import json +import os +import sys +from dataclasses import dataclass, field +from importlib.metadata import version +from importlib.resources import files +from pathlib import Path +from typing import Any +from uuid import UUID + + +@dataclass(frozen=True) +class Settings: + environment: str + token: str = field(repr=False) + allowed_tenants: frozenset[str] + scenario: str + delay_ms: int + identity: dict[str, Any] + model_checksum: str + dictionary_sha256: str + + @classmethod + def from_env(cls) -> "Settings": + if sys.version_info[:2] != (3, 11): + raise RuntimeError("LabSafe requires Python 3.11") + environment = os.environ.get("APP_ENV") + if environment not in {"dev", "test"}: + raise RuntimeError("I-01A requires APP_ENV=dev or test; production is disabled") + if os.environ.get("AI_MODE") != "mock": + raise RuntimeError("I-01A implements AI_MODE=mock only; real inference is not ready") + if os.environ.get("AI_MAX_INFLIGHT", "1") != "1": + raise RuntimeError("AI_MAX_INFLIGHT must be 1") + token_path = os.environ.get("AI_TOKEN_FILE") + if not token_path: + raise RuntimeError("AI_TOKEN_FILE is required") + try: + token = Path(token_path).read_text(encoding="utf-8").strip() + except (OSError, UnicodeError) as exc: + raise RuntimeError("Cannot read AI_TOKEN_FILE") from exc + if not token.isascii() or len(token) < 32 or any(c.isspace() for c in token): + raise RuntimeError("AI_TOKEN_FILE must contain an ASCII token of at least 32 bytes") + raw_tenants = os.environ.get("AI_ALLOWED_TENANTS", "") + try: + allowed_tenants = frozenset(str(UUID(v.strip())) for v in raw_tenants.split(",")) + except ValueError as exc: + raise RuntimeError("AI_ALLOWED_TENANTS requires explicit tenant UUIDs") from exc + scenario = os.environ.get("AI_FIXTURE_SCENARIO", "no_targets") + if scenario not in {"no_targets", "needs_retake", "error", "timeout"}: + raise RuntimeError("Unknown AI_FIXTURE_SCENARIO") + try: + delay_ms = int(os.environ.get("AI_FIXTURE_DELAY_MS", "0")) + except ValueError as exc: + raise RuntimeError("AI_FIXTURE_DELAY_MS must be an integer") from exc + if not 0 <= delay_ms <= 10000: + raise RuntimeError("AI_FIXTURE_DELAY_MS must be between 0 and 10000") + commit = os.environ.get("SERVICE_COMMIT", "local-development") + if not 1 <= len(commit) <= 40: + raise RuntimeError("SERVICE_COMMIT must contain 1 to 40 characters") + fixture_dir = files("apps.ai_inference").joinpath("fixtures") + model_bytes = fixture_dir.joinpath("model.json").read_bytes() + dictionary_bytes = fixture_dir.joinpath("dictionary.json").read_bytes() + lock_bytes = fixture_dir.joinpath("runtime-lock.json").read_bytes() + runtime_lock = json.loads(lock_bytes) + for name, expected in runtime_lock["packages"].items(): + if version(name) != expected: + raise RuntimeError(f"Fixture dependency mismatch: {name} must be {expected}") + model, dictionary = json.loads(model_bytes), json.loads(dictionary_bytes) + identity = { + "service_commit": commit, + "pipeline_version": "vision-v1", + "model_bundle_id": model["model_bundle_id"], + "dictionary_version_id": dictionary["dictionary_version_id"], + "device_profile": "cpu", + "purpose": "development", + "is_simulated": True, + "adapter_id": "fixture-v1", + "runtime_profile": "fixture-v1", + "runtime_lock_sha256": hashlib.sha256(lock_bytes).hexdigest(), + "detector_device": "mock", + "ocr_device": "mock", + } + return cls( + environment, + token, + allowed_tenants, + scenario, + delay_ms, + identity, + hashlib.sha256(model_bytes).hexdigest(), + hashlib.sha256(dictionary_bytes).hexdigest(), + ) diff --git a/apps/ai_inference/fixtures/dictionary.json b/apps/ai_inference/fixtures/dictionary.json new file mode 100644 index 0000000..0ad2cfb --- /dev/null +++ b/apps/ai_inference/fixtures/dictionary.json @@ -0,0 +1,5 @@ +{ + "purpose": "development", + "dictionary_version_id": "33333333-3333-4333-8333-333333333333", + "entries": [] +} diff --git a/apps/ai_inference/fixtures/model.json b/apps/ai_inference/fixtures/model.json new file mode 100644 index 0000000..10427d4 --- /dev/null +++ b/apps/ai_inference/fixtures/model.json @@ -0,0 +1,7 @@ +{ + "purpose": "development", + "adapter_id": "fixture-v1", + "model_family": "mock_fixture", + "model_bundle_id": "22222222-2222-4222-8222-222222222222", + "description": "Synthetic no-target fixture. This is not a trained model." +} diff --git a/apps/ai_inference/fixtures/runtime-lock.json b/apps/ai_inference/fixtures/runtime-lock.json new file mode 100644 index 0000000..8662527 --- /dev/null +++ b/apps/ai_inference/fixtures/runtime-lock.json @@ -0,0 +1,14 @@ +{ + "purpose": "development", + "scope": "fixture-direct-constraints-only", + "python": "3.11", + "packages": { + "fastapi": "0.115.6", + "pydantic": "2.10.5", + "uvicorn": "0.34.0", + "PyYAML": "6.0.2", + "jsonschema": "4.23.0", + "rfc3339-validator": "0.1.4" + }, + "limitations": "Checked direct fixture dependencies only; not a production transitive/model runtime lock." +} diff --git a/apps/ai_inference/run.py b/apps/ai_inference/run.py new file mode 100644 index 0000000..4b5888a --- /dev/null +++ b/apps/ai_inference/run.py @@ -0,0 +1,19 @@ +"""One HTTP process; the real computation child is an I-03 deliverable.""" + +import os + +import uvicorn + + +def main() -> None: + uvicorn.run( + "apps.ai_inference.app.main:create_app", + factory=True, + host=os.getenv("AI_INFERENCE_HOST", "127.0.0.1"), + port=int(os.getenv("AI_INFERENCE_PORT", "8001")), + workers=1, + ) + + +if __name__ == "__main__": + main() diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..b3518a5 --- /dev/null +++ b/apps/api/__init__.py @@ -0,0 +1 @@ +"""Public API process.""" diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..d9c5b70 --- /dev/null +++ b/apps/api/app/__init__.py @@ -0,0 +1 @@ +"""FastAPI application package.""" diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..d93df13 --- /dev/null +++ b/apps/api/app/main.py @@ -0,0 +1,25 @@ +"""Development-only API shell. Business routes arrive in I-01C/I-02.""" + +from fastapi import FastAPI + +from packages.shared.environment import development_environment + + +def create_app() -> FastAPI: + environment = development_environment() + app = FastAPI(title="LabSafe API", version="0.1.0-dev") + + @app.get("/health") + def health() -> dict[str, str | bool]: + return {"status": "alive", "environment": environment, "is_simulated": True} + + @app.get("/ready") + def ready() -> dict[str, str | bool]: + return { + "status": "ready", + "environment": environment, + "is_simulated": True, + "scope": "I-01A process only; database and business readiness not implemented", + } + + return app diff --git a/apps/api/run.py b/apps/api/run.py new file mode 100644 index 0000000..b9f44e8 --- /dev/null +++ b/apps/api/run.py @@ -0,0 +1,19 @@ +"""Run the development API with actual FastAPI/Uvicorn dependencies.""" + +import os + +import uvicorn + + +def main() -> None: + uvicorn.run( + "apps.api.app.main:create_app", + factory=True, + host=os.getenv("API_HOST", "127.0.0.1"), + port=int(os.getenv("API_PORT", "8000")), + workers=1, + ) + + +if __name__ == "__main__": + main() diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..0e88a22 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + LabSafe 开发环境 + + +
+ + + diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..e5e5d41 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,1718 @@ +{ + "name": "@labsafe/web", + "version": "0.1.0-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@labsafe/web", + "version": "0.1.0-dev", + "dependencies": { + "@tanstack/vue-query": "^5.66.8", + "element-plus": "^2.9.4", + "pinia": "^2.3.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.3", + "vite": "^6.0.11", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.1.tgz", + "integrity": "sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-9.1.2.tgz", + "integrity": "sha512-aPkUQctDzpXLSWhlx7jUDi3mInsI18xcnrK1hvIyIa88ab1ax88cACr7xU+BKMCGB4NZYNwh4SoNSEbv3RY2NA==", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.0.tgz", + "integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-query": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/vue-query/-/vue-query-5.104.0.tgz", + "integrity": "sha512-Nxqsl0YsXrHZQAq9rNQWvVR+elPENBg+YXkQHnuDn7/YM48vts4aaKZYBkRzaU24ckEGLxC13o3GiM3kYUKfdw==", + "dependencies": { + "@tanstack/match-sorter-utils": "^9.1.2", + "@tanstack/query-core": "5.104.0", + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@vue/composition-api": "^1.1.2", + "vue": "^2.6.0 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==" + }, + "node_modules/@vueuse/core": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-15.0.0.tgz", + "integrity": "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "15.0.0", + "@vueuse/shared": "15.0.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-15.0.0.tgz", + "integrity": "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-15.0.0.tgz", + "integrity": "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "node_modules/element-plus": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.6.tgz", + "integrity": "sha512-GH0fMoMgVEKkHimoaKvg5B00NmfPbQ+qxX6/JrO7F+dLAC3atrZr2Y2MoccHMxS22M7QE12/s0DG0Zs49iwJ/A==", + "dependencies": { + "@ctrl/tinycolor": "^4.2.1", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "15.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.11" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==" + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..23a8b4c --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@labsafe/web", + "private": true, + "version": "0.1.0-dev", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/vue-query": "^5.66.8", + "element-plus": "^2.9.4", + "pinia": "^2.3.1", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.3", + "vite": "^6.0.11", + "vue-tsc": "^2.2.0" + } +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue new file mode 100644 index 0000000..ff50476 --- /dev/null +++ b/apps/web/src/App.vue @@ -0,0 +1,11 @@ + + + diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..1aa6519 --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +createApp(App).use(createPinia()).mount('#app') diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..6d01b47 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..eeb3945 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig(({ mode }) => { + const variables = { ...loadEnv(mode, process.cwd(), 'VITE_'), ...process.env } + const environment = variables.VITE_APP_ENV + if (!['dev', 'test'].includes(environment ?? '') || variables.APP_ENV !== environment) { + throw new Error('I-01A requires matching APP_ENV/VITE_APP_ENV=dev or test; production is disabled') + } + return { + plugins: [vue()], + server: { host: '127.0.0.1', port: 5173 } + } +}) diff --git a/apps/worker/__init__.py b/apps/worker/__init__.py new file mode 100644 index 0000000..ca60bca --- /dev/null +++ b/apps/worker/__init__.py @@ -0,0 +1 @@ +"""Durable task worker process.""" diff --git a/apps/worker/app/__init__.py b/apps/worker/app/__init__.py new file mode 100644 index 0000000..b85d90f --- /dev/null +++ b/apps/worker/app/__init__.py @@ -0,0 +1 @@ +"""Worker application package.""" diff --git a/apps/worker/app/main.py b/apps/worker/app/main.py new file mode 100644 index 0000000..8f4abbf --- /dev/null +++ b/apps/worker/app/main.py @@ -0,0 +1,28 @@ +"""Real Celery entrypoint; durable routing and execution belong to I-03.""" + +import os +from typing import Any + +from celery import Celery + +from packages.shared.environment import development_environment + + +def fixture_echo(payload: dict[str, Any]) -> dict[str, Any]: + """Local smoke task only, NOT an inference or persistence bypass.""" + return {"fixture": True, "payload": payload} + + +def create_celery_app() -> Celery: + development_environment() + celery = Celery("labsafe-worker", broker=os.getenv("REDIS_URL", "redis://localhost:6379/0")) + celery.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + task_acks_late=True, + task_reject_on_worker_lost=True, + worker_prefetch_multiplier=1, + ) + celery.task(name="labsafe.fixture.echo")(fixture_echo) + return celery diff --git a/apps/worker/run.py b/apps/worker/run.py new file mode 100644 index 0000000..7a71388 --- /dev/null +++ b/apps/worker/run.py @@ -0,0 +1,20 @@ +"""Worker startup; --check validates configuration without connecting to Redis.""" + +import argparse + +from .app.main import create_celery_app + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + app = create_celery_app() + if args.check: + print("Worker configuration OK (broker connectivity not tested)") + return + app.worker_main(["worker", "--loglevel=INFO"]) + + +if __name__ == "__main__": + main() diff --git a/docs/00-document-control.md b/docs/00-document-control.md index 3cbfd3a..4c07a1b 100644 --- a/docs/00-document-control.md +++ b/docs/00-document-control.md @@ -1,6 +1,6 @@ # 00 文档控制 -状态:2026-09-27 修订候选,版本 1.1.0;尚未人工签署。 +状态:设计版本1.1.0;2026-09-28进入I-01A实施。文档提交、实现验证和独立人工批准分别记录,不将代码自审等同于批准。 ## 1. 规范边界 @@ -12,6 +12,8 @@ - ML-BASE-02将检测器固定为D-FINE-N;更新ModelManifest来源/运行配置和16字段content hash,内部/version增加适配器与实际运行信息;vision-v1事实语义和消息schema_version保持不变。旧未部署模型清单不兼容本次门禁,禁止改名复用;外部既有消费者另行迁移。 - IRR-01至06补齐跨模块实现边界:RelationFact.same_location改为必填boolean|null;重试、容量、对象身份/入口及确定性事实算法同步。仍属未部署1.1.0候选,消息schema_version=1.1不变;客户端重新生成nullable类型,禁止旧客户端把null当false。没有已部署消费者兼容性承诺,若外部已使用须先另行迁移。关闭证据见实现就绪清单,不代表人工批准或实际服务测试已完成。 +- ADR-PY-01统一业务/AI/训练的Python3.11基线;独立进程、依赖环境、GPU和协议边界不变。I-01A仅为开发骨架,真实模型与生产能力尚未验收。 + ## 2. 状态与审批 “结构校验通过”“设计可编码”“协作者批准”“系统可上线”分别判断。无签署记录不得称已批准。实际模型、依赖兼容性、数据许可、专家规则和性能成绩不可虚构。 diff --git a/docs/02-architecture/03-ai-inference-process.md b/docs/02-architecture/03-ai-inference-process.md index bcb6dcf..60cf88a 100644 --- a/docs/02-architecture/03-ai-inference-process.md +++ b/docs/02-architecture/03-ai-inference-process.md @@ -10,7 +10,9 @@ AI 是单 Uvicorn worker supervisor + 一个 multiprocessing spawn 计算子进 AI 只能读授权 analysis/ 对象和只读模型目录,无 MySQL/Redis/业务 ORM/规则依赖。Worker 根据裁剪配方写证据对象。AI 不返回可写 URL、不持有写权限。 -模型和数据路线以[固定模型方案](../04-ai-rules/02-model-data-plan.md)为准:AI运行时Python3.11、D-FINE-N四类ONNX,业务仍Python3.12;RGB直接resize640并除255,qmax/无NMS,不套用其他模型的letterbox。建议生产检测CUDA FP32;OCR保持PP-OCRv4 CPU。APP_ENV/AI_MODE在启动校验;production禁止fixture和未批准bundle,dev/test可用同协议mock进程。/version除purpose/is_simulated外回显adapter_id、runtime_profile、runtime_lock_sha256、detector_device和ocr_device;这些值取实际通过启动校验的加载状态,Worker与受控manifest逐项核对。 +模型和数据路线以[固定模型方案](../04-ai-rules/02-model-data-plan.md)为准:AI运行时Python3.11、D-FINE-N四类ONNX,业务同为Python3.11但保留独立依赖环境;RGB直接resize640并除255,qmax/无NMS,不套用其他模型的letterbox。建议生产检测CUDA FP32;OCR保持PP-OCRv4 CPU。APP_ENV/AI_MODE在启动校验;production禁止fixture和未批准bundle,dev/test可用同协议mock进程。/version除purpose/is_simulated外回显adapter_id、runtime_profile、runtime_lock_sha256、detector_device和ocr_device;这些值取实际通过启动校验的加载状态,Worker与受控manifest逐项核对。 + +I-01A只交付同协议开发fixture和HTTP进程入口;下面的真实spawn计算子进程、模型加载与故障恢复仍按I-03/I-ML实施,不能将fixture冒充完整supervisor。运行和验收入口见[开发指南](../../DEVELOPMENT.md)。 ## 2. 生命周期 diff --git a/docs/02-architecture/04-coding-baseline.md b/docs/02-architecture/04-coding-baseline.md index 05e68cb..a2fa77a 100644 --- a/docs/02-architecture/04-coding-baseline.md +++ b/docs/02-architecture/04-coding-baseline.md @@ -11,9 +11,9 @@ | 目录 | 实现职责 | 禁止事项 | |---|---|---| | apps/web | Vue 3、TypeScript、Vite、Element Plus、Vue Router、TanStack Query;Pinia 只管会话/UI | 复制状态机、长期对象凭据 | -| apps/api | Python 3.12、FastAPI、Pydantic v2;路由调用命令/查询 | 加载模型,外部调用持有 DB 事务 | +| apps/api | Python 3.11、FastAPI、Pydantic v2;路由调用命令/查询 | 加载模型,外部调用持有 DB 事务 | | apps/worker | Celery、Redis、持久任务、AI client、规则、裁剪、导出 | 覆盖历史结果 | -| apps/ai_inference | Python3.11独立FastAPI supervisor + spawn计算子进程;D-FINE-N四类ONNX/PP-OCRv4 CPU;建议检测CUDA FP32,CPU功能保留;业务API/Worker仍Python3.12 | 业务ORM、数据库、Redis、最终风险 | +| apps/ai_inference | Python3.11独立FastAPI supervisor + spawn计算子进程;D-FINE-N四类ONNX/PP-OCRv4 CPU;建议检测CUDA FP32,CPU功能保留;业务API/Worker同为Python3.11,依赖环境仍隔离 | 业务ORM、数据库、Redis、最终风险 | | packages/domain | 状态、guards、权限动作、不变事实类型 | 通用 status setter | | packages/application | 命令处理器、查询、unit_of_work;每命令唯一入口 | 路由另写业务逻辑 | | packages/persistence | SQLAlchemy 2、MySQL 8.0.16+、Alembic、Outbox repository | 省略租户条件 | @@ -22,6 +22,8 @@ 依赖:HTTP/任务入口 → application → domain;persistence 实现仓储接口。AI 只共享协议和图像变换包。应用依赖锁文件和镜像 digest 在实施环境验证后提交;没有完成依赖兼容性验证前不得宣称环境可复现。 +统一运行时依据见[ADR-PY-01](07-python-runtime.md);I-01A开发入口与测试见[开发指南](../../DEVELOPMENT.md)。统一minor不合并AI与业务依赖,也不改变进程/权限边界。 + ## 3. 事务模板 1. 会话给出租户,服务端授权;不接受客户端 tenant_id 作为权限依据。 @@ -36,7 +38,7 @@ - 资源 ID 默认 UUID v4 小写;确定性检测/裁剪 ID 按 AI 文档使用 UUIDv5。时间为 UTC RFC3339 毫秒,DB DATETIME(3)。reference_date 在提交时按 tenant.timezone 生成并冻结。 - 通用字符串 NFC 去首尾空白;密码和 OCR raw_text 不处理;账号 NFKC+casefold 后唯一。 -- JSON哈希:UTF-8、键字典序、无空格、禁止NaN、保留数组顺序;共享规范函数为json.dumps(sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False)。Python3.11/3.12生产者使用同一函数和黄金样本测试;不是任意语言默认JSON字符串。原图/制品哈希直接对字节计算。 +- JSON哈希:UTF-8、键字典序、无空格、禁止NaN、保留数组顺序;共享规范函数为json.dumps(sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False)。Python3.11生产者使用同一函数和黄金样本测试;不是任意语言默认JSON字符串。原图/制品哈希直接对字节计算。 - trace_id 为 32 位小写十六进制;request_id/attempt_id 是 UUID。 - 原图、分析图、裁剪图各自保留对象 key、version、SHA,禁止混用。 - 终态结果、事实正文、发布规则/词典正文不可覆盖;元数据只能通过规定命令变化;activation 只影响新 run。 diff --git a/docs/02-architecture/07-python-runtime.md b/docs/02-architecture/07-python-runtime.md new file mode 100644 index 0000000..d8731e8 --- /dev/null +++ b/docs/02-architecture/07-python-runtime.md @@ -0,0 +1,38 @@ +# 02.7 Python 运行时决策(ADR-PY-01) + +日期:2026-09-28。依据:用户授权在双版本收益不足时统一 Python 3.11。 +本决策随 I-01A 特性分支提交,仍需按协作规范独立评审;不是生产上线批准。 + +## 1. 决定 + +业务 API、Worker、AI 推理及离线训练均以 Python 3.11.x 为唯一 minor 基线。 +替代原来的“业务 3.12、AI/训练 3.11”版本分离;不改变独立进程、HTTP 契约、服务权限或 GPU 边界。 +业务与 AI 继续使用不同虚拟环境、不同依赖集合和后续独立镜像;统一解释器版本不等于合并环境。 + +## 2. 判断依据 + +1. 当前骨架没有依赖 Python 3.12 专属语法或 API。 +2. 本次业务与 AI 顶层依赖已在本机 Python 3.11 验证安装和运行;CI 按 Python 3.11 分服务验收。 +3. 现阶段双 minor 版本没有对应的已验证性能或功能收益,却增加环境说明、测试矩阵与共享序列化核对工作。 +4. AI 原有 3.11 基线保持不变,不据此声称 D-FINE、CUDA、Paddle 的真实运行时已经验证。 + +上述为当前工程的取舍,不是 Python 3.11 比 3.12 更快的结论。完整传递依赖锁、镜像 digest 与真实硬件验证仍归 I-ML/I-05。 + +## 3. 实现约束 + +| 边界 | 约束 | +|---|---| +| 业务 | requirements/py311-business.txt;FastAPI/Celery/SQLAlchemy/Alembic | +| AI | requirements/py311-ai.txt;I-01A 无 Celery、Redis、业务 ORM 或真实模型包 | +| 共用部分 | common.txt 固定当前直接依赖;inference_protocol 只包含 wire schema、DTO 校验和规范哈希 | +| Python 版本 | pyproject 要求 >=3.11,<3.12;运行时显式校验 minor;CI 使用 3.11 | +| 模型 | D-FINE-N、检测 CUDA FP32 建议、PP-OCRv4 CPU 及真实 CPU profile 均不改变 | +| 环境安全 | I-01A 仅 dev/test,AI_MODE=mock;生产与 real 启动拒绝 | + +FastAPI/Celery 是必需运行依赖,缺失时必须失败,不在应用入口构造兼容外观的假框架。 + +## 4. 后续变更与回滚 + +后续若需要升级 Python,先在业务/AI 各自环境验证并提交新的运行时决策、依赖锁和测试结果,不能只修改镜像标签。 +I-01A 没有数据库迁移和生产部署,回滚通过 revert 对应实现提交恢复文档基线;不删除审计记录、不改写 main 历史。 +旧 3.12 文字仅保留在明确归档或本决策的历史说明中,现行操作命令不再引用 py312.txt。 diff --git a/docs/02-architecture/README.md b/docs/02-architecture/README.md index 5b40847..c2b17a1 100644 --- a/docs/02-architecture/README.md +++ b/docs/02-architecture/README.md @@ -6,5 +6,6 @@ 4. [独立AI](03-ai-inference-process.md)。 5. [持久任务](05-durable-jobs.md)。 6. [数据约束](06-data-persistence.md)。 +7. [Python运行时决策](07-python-runtime.md)。 本页仅导航;现行设计状态见 [文档控制](../00-document-control.md),不把候选设计视为已经批准。 diff --git a/docs/04-ai-rules/02-model-data-plan.md b/docs/04-ai-rules/02-model-data-plan.md index 2b6e2e5..eac4529 100644 --- a/docs/04-ai-rules/02-model-data-plan.md +++ b/docs/04-ai-rules/02-model-data-plan.md @@ -4,14 +4,14 @@ ## 1. 固定路线与变更边界 -| 能力 | 本期决定 | 不允许的替代 | -|---|---|---| -| 检测 | D-FINE-N,四类微调,640×640,300 queries | 把COCO权重直接当四类模型;未经确认换S或扩大输入 | -| 适配器 | dfine-n4-rgb-stretch-v1;RGB、直接resize、qmax、无NMS | 旧BGR/0–255/letterbox适配器;混用上游两个推理示例 | -| 生产建议 | ONNX Runtime CUDA、FP32、batch=1、并发1 | 默认TensorRT/FP16、跨主机GPU调度或静默CPU fallback | -| OCR | 保留PaddleOCR2.10.0、PP-OCRv4及CPU | 本次不隐含批准PP-OCRv6、OCR上GPU或大模型 | -| 开发 | fixture-v1联调;真实CPU profile做功能/导出检查 | 把mock、CPU结果当CUDA生产性能证明 | -| 业务 | AI只出事实;Worker写证据并执行确定性规则;仍需人工复核 | 用模型置信度代替安全判断 | +| 能力 | 本期决定 | 不允许的替代 | +| ---- | ---------------------------------------------- | --------------------------------------- | +| 检测 | D-FINE-N,四类微调,640×640,300 queries | 把COCO权重直接当四类模型;未经确认换S或扩大输入 | +| 适配器 | dfine-n4-rgb-stretch-v1;RGB、直接resize、qmax、无NMS | 旧BGR/0–255/letterbox适配器;混用上游两个推理示例 | +| 生产建议 | ONNX Runtime CUDA、FP32、batch=1、并发1 | 默认TensorRT/FP16、跨主机GPU调度或静默CPU fallback | +| OCR | 保留PaddleOCR2.10.0、PP-OCRv4及CPU | 本次不隐含批准PP-OCRv6、OCR上GPU或大模型 | +| 开发 | fixture-v1联调;真实CPU profile做功能/导出检查 | 把mock、CPU结果当CUDA生产性能证明 | +| 业务 | AI只出事实;Worker写证据并执行确定性规则;仍需人工复核 | 用模型置信度代替安全判断 | 上游仓库为Peterande/D-FINE,固定提交956d1709314c2c6a4df6f34de232054578a7449f;模型结构从该提交configs/dfine/dfine_hgnetv2_n_coco.yml及其include展开。source_ref固定为https://github.com/Peterande/D-FINE,source_commit为上述40位提交;不在构建时追踪master。项目包装器和安全加载改动由项目git_commit及补丁摘要追踪,不伪装成上游原样实现。 @@ -19,7 +19,7 @@ ## 2. 运行时和版本约束 -业务API/Worker保持Python3.12;AI和离线训练使用Python3.11、Linux x86_64。Windows开发通过相同Linux容器联调。训练框架不进入业务API,也不要求训练与推理安装在同一环境。 +依据[ADR-PY-01](../02-architecture/07-python-runtime.md),业务API/Worker、AI和离线训练统一Python3.11;各服务依赖环境仍独立,真实训练/推理以Linux x86_64为目标。Windows开发通过相同Linux容器联调。训练框架不进入业务API,也不要求训练与推理安装在同一环境。 | 环境 | 顶层约束 | |---|---| diff --git a/docs/07-quality-operations/02-deployment-operations.md b/docs/07-quality-operations/02-deployment-operations.md index 40798fb..8059346 100644 --- a/docs/07-quality-operations/02-deployment-operations.md +++ b/docs/07-quality-operations/02-deployment-operations.md @@ -42,6 +42,8 @@ AI_DEVICE、路由device_profile、manifest.runtime_profile/runtime设备和/ver 单卡无双份显存余量时,停止领取新CUDA任务→排空在途任务→not_ready→卸载旧子进程→加载新bundle/lock→smoke→激活新route。新版本失败则恢复旧镜像/bundle/route并smoke,不改旧run。记录GPU已用/峰值显存、显存余量、重载耗时、CUDA EP失败次数及现有分阶段/队列指标;高基数GPU标识留环境快照,不按run生成监控标签。 +I-01A统一Python3.11,服务依赖环境仍隔离;开发骨架尚不实现本节生产拓扑,生产启动被明确拒绝。见[运行时决策](../02-architecture/07-python-runtime.md)。 + ## 2. 配置表 | 变量/secret | 默认/要求 | 使用者 | diff --git a/docs/08-delivery/01-implementation-plan.md b/docs/08-delivery/01-implementation-plan.md index 6796765..59e11c1 100644 --- a/docs/08-delivery/01-implementation-plan.md +++ b/docs/08-delivery/01-implementation-plan.md @@ -1,6 +1,6 @@ # 08.1 实施计划与首批任务 -状态:设计已给出执行顺序;下列均为未来业务开发任务,不能写成已实现。阶段标准见[阶段门禁](05-stage-gates.md)。 +状态:已进入I-01A骨架实现与验收;其余任务仍未实现,不能以骨架通过代替业务/模型/发布验收。阶段标准见[阶段门禁](05-stage-gates.md),当前入口见[开发指南](../../DEVELOPMENT.md)。 ## 1. 从“只有文档”开始 @@ -8,7 +8,7 @@ | 编号 | 任务与输入 | 必须交付 | 完成检查 / 失败处理 | |---|---|---|---| -| I-01A | 由编码基线创建apps/api、worker、ai_inference、web和共享包;固定APP_ENV=dev | 项目骨架、各进程入口、环境变量样例、顶层依赖约束、CI入口 | 空环境import/build通过;不装真实模型也能启动mock;失败先修环境不改业务协议 | +| I-01A | 由编码基线创建apps/api、worker、ai_inference、web和共享包;固定APP_ENV=dev | 项目骨架、各进程入口、环境变量样例、顶层依赖约束、CI入口 | 按声明依赖在干净Python3.11环境安装后import/HTTP smoke/build通过;缺框架须失败、不装真实模型可启动同协议mock;失败先修环境不改业务协议 | | I-01B | 设计DDL与字典;专用空MySQL数据库 | 初始Alembic迁移、bootstrap-tenant/roles CLI、DB结构核对测试 | 空库upgrade到head;43表/列/FK/索引与字典一致,实际版本记日志;失败在可丢弃测试库重建,不触碰用户已有库 | | I-01C | 数据约束和安全规范 | 仓储、session/RBAC、幂等、跨租户拒绝测试 | DB-01/02、API-02/03、SEC-01/02;失败事务回滚且无旁路权限 | | I-02 | 状态机与公共契约 | 巡检、上传、事实、人工复核、整改等命令/查询 | 每个guard有正/反例;相同payload重发无重复副作用 | @@ -40,4 +40,4 @@ IRR六项设计前置按[关闭清单](06-implementation-readiness.md)执行。 ## 5. 协作和Push -当前保留原分支和用户已有暂存改动;本轮只改设计、契约和设计校验工具,不创建业务工程,不commit/push。设计文档可以先形成独立docs PR,不应等待完整系统上线。推送时再依CONTRIBUTING检查远程基线、变更范围、生成一致性和保密要求;数据库/权限/模型变更须双人评审,不能把本次自审当批准。 +设计基线已通过文档PR形成提交。I-01A按用户授权在特性分支完成修正、验证后Push并提交PR;不直接推送main,不自动合并或替代独立审查。推送前依CONTRIBUTING核对远程基线、变更范围、生成一致性和保密要求;数据库/权限/模型变更仍须双人评审。 diff --git a/docs/08-delivery/04-remediation-plan.md b/docs/08-delivery/04-remediation-plan.md index 16ec709..5158973 100644 --- a/docs/08-delivery/04-remediation-plan.md +++ b/docs/08-delivery/04-remediation-plan.md @@ -8,7 +8,7 @@ - ML-BASE-02选择D-FINE-N四类微调+PP-OCRv4 CPU,固定上游提交、初始化来源、数据/类别双向映射/微调/导出路线,不能拿COCO类别权重冒充项目模型。 - adapter明确640、RGB/0–1、PIL直接resize、orig_target_sizes=[W,H]、labels/boxes/scores、qmax/无NMS;全请求100结果上限与原图裁剪规则保持,源实现/参考语义/测试同步。 -- 独立AI/训练Python3.11,业务Python3.12,通过既有HTTP边界隔离;顶层版本基线已选择,实际lock和硬件证明由I-ML-01生成。 +- 依据ADR-PY-01,业务、独立AI/训练统一Python3.11,通过独立依赖环境与既有HTTP边界隔离;顶层版本基线已选择,实际lock和硬件证明由I-ML-01生成。 - CUDA FP32为建议生产profile,CPU功能仍保留;两者独立bundle/lock/报告。OCR不暗换模型或GPU化。先用同协议fixture和初始化checkpoint开发,production拒绝mock/未校准/未批准bundle。 - 清单新增来源提交和runtime;16字段内容摘要绑定代码/环境,/version回显实际设备/适配器。生成器与门禁不再仅接受旧模型,新负例覆盖错设备、精度和报告复用。 - 数据不足和目标不达标有继续采集/微调与ADR替换路径,不让开发者自行猜模型、输出字段或错误行为。 diff --git a/docs/08-delivery/07-i01a-acceptance.md b/docs/08-delivery/07-i01a-acceptance.md new file mode 100644 index 0000000..aebae41 --- /dev/null +++ b/docs/08-delivery/07-i01a-acceptance.md @@ -0,0 +1,57 @@ +# 08.7 I-01A 工程骨架验收记录 + +日期:2026-09-28。范围:本次特性分支中的开发骨架,不是完整业务系统或生产发布验收。 +运行时决策见 [ADR-PY-01](../02-architecture/07-python-runtime.md),操作入口见 [开发指南](../../DEVELOPMENT.md)。 + +## 1. 六项修正关闭证据 + +| 问题 | 修正 | 可重验依据 | +|---|---|---| +| CI 静态检查失败 | 修正超长行,统一 Ruff 格式 | ruff check / ruff format --check;没有关闭原 lint 规则 | +| fixture 协议漂移 | 既有五个 AI 路由、请求/响应/错误 Schema;契约由同一生成器打包 | test_authenticated_health_ready_version_follow_contract、test_quality_and_runs_echo_identity_without_false_safe_result;build_specs --check | +| production/mock 与鉴权缺失 | 后端仅 dev/test、AI_MODE=mock;随机 token 文件、租户白名单;Web 拒绝生产配置 | test_invalid_configuration_is_rejected、test_all_contract_routes_require_token、test_production_cli_fails;前端生产构建反例 | +| 双 Python 环境不落实 | 统一 Python 3.11;保留业务与 AI 的依赖隔离;CI 分服务安装 | 干净 AI 环境不含 Celery/Redis/SQLAlchemy/真实模型包;独立 AI 套件通过 | +| 假框架掩盖运行错误 | 移除框架 fallback;使用真实 FastAPI/Celery;实际 API/AI HTTP 子进程 smoke | test_missing_frameworks_do_not_fall_back、test_api_starts_as_an_independent_http_process、test_standalone_ai_http_process | +| 虚拟环境未忽略 | 忽略 .venv*、工具缓存、.local-secrets、egg-info | git check-ignore 对虚拟环境、Ruff 缓存、令牌文件均匹配 | + +另有并发、相同 attempt、超时释放、版本/哈希/对象引用、JSON规范化及运行依赖漂移检查。 +日期校验显式安装 rfc3339-validator;缺少该能力立即失败,不能让 jsonschema 跳过 date-time 格式校验。 + +## 2. 本机实际执行结果 + +环境:Windows、Python 3.11.4、Node 24.21.0、npm 9.6.7;依赖安装在临时虚拟环境。 +远程 CI 使用 Linux/Python 3.11、Node 20;其成绩以本 PR 的对应提交检查为准,不用本机成绩代替。 + +| 检查 | 本次结果 | +|---|---| +| python -m unittest discover -s tests -t . -v | 25 项通过;覆盖真实框架、HTTP 子进程、协议、启动保护和异常分支 | +| ruff check apps packages tests | 通过 | +| ruff format --check apps packages tests | 通过,34个Python文件 | +| 独立 AI 环境安装 requirements/dev-ai.txt,pip check | 通过;未安装 Celery、Redis、SQLAlchemy、Torch、Paddle、ORT | +| AI 单独套件 + 共用协议套件 | 干净环境15项AI测试和4项协议测试通过 | +| Python wheel构建与已安装包资源检查 | 通过;隔离导入可找到运行时契约和fixture资源,无需源码目录 | +| npm ci、npm run build(dev) | 通过;npm 安装时审计报告 0 vulnerabilities,不代表持续安全保证 | +| production Web 构建 | 按预期失败,没有生成可用生产模式 | +| build_specs.py --check | 通过,16 个生成物;其中运行时包契约来自同一源 | +| test_readiness_design.py / validate_specs.py | 通过;148 IRR + 41 发布 + 39 D-FINE 合成设计检查 | + +设计检查输出只描述该工具调用的证据范围;其中“No application runtime tests”不能被读成仓库没有应用测试。 +本页的应用验证与设计合成验证分别记录,不能相互替代。 + +## 3. 明确未完成的后续阶段 + +- I-01B/C:专用 MySQL、43 表 Alembic 迁移、仓储、会话、RBAC、幂等与租户事务。 +- I-02/I-03:实际巡检命令、Outbox/inbox、Worker→AI→DB、租约/fencing/恢复。 +- I-03:真实 spawn 计算子进程、硬超时 kill/join 与故障重建;I-01A仅有可取消的合成延迟。 +- I-ML:D-FINE-N、PP-OCRv4 CPU、真实图像/数据/权重、CUDA 推理和完整运行时锁。 +- I-04/I-05/R:业务前端、真实对象存储权限/签名、Compose、备份恢复和人工批准。 + +当前 fixture 不下载或验证图片字节,不执行真实质量算法;其分数、空事实和耗时都是合成值。 +API /ready 只表明骨架进程配置有效;worker --check 不验证 broker 连通,也不能视为完整异步链路。 +不得据此激活生产模型、宣称准确率/GPU性能达标,或把本阶段自审等同于协作者批准。 + +## 4. 提交与回滚 + +本次提交特性分支并创建 PR,base=main;不直接 push main、不自动合并。 +ADR、启动保护和开发 token 边界变更由协作者按 CONTRIBUTING 评审,未代替任何人签署。 +没有生产数据迁移;如需回滚,revert 本 PR 的实现提交即可,不改写已发布历史。 diff --git a/docs/08-delivery/README.md b/docs/08-delivery/README.md index 13c45c7..f55f47f 100644 --- a/docs/08-delivery/README.md +++ b/docs/08-delivery/README.md @@ -6,5 +6,6 @@ 4. [需求追踪](02-traceability-matrix.md)。 5. [设计、实施与发布门禁](05-stage-gates.md)。 6. [实现就绪评审六项关闭清单](06-implementation-readiness.md)。 +7. [I-01A工程骨架验收](07-i01a-acceptance.md)。 本页仅导航;现行设计状态见 [文档控制](../00-document-control.md),不把候选设计视为已经批准。 diff --git a/docs/08-delivery/design-validation-results.json b/docs/08-delivery/design-validation-results.json index 6febfed..30bc889 100644 --- a/docs/08-delivery/design-validation-results.json +++ b/docs/08-delivery/design-validation-results.json @@ -1,6 +1,6 @@ { "status": "PASS", - "executed_at_utc": "2026-09-27T10:31:32.254226+00:00", + "executed_at_utc": "2026-09-28T11:19:31.719885+00:00", "python_version": "3.11.4", "counts": { "openapi_specs": 2, @@ -20,7 +20,7 @@ "truth_table_cases": 9, "rule_semantic_negative_cases": 1, "rule_scope_cases": 2, - "doc_links": 140, + "doc_links": 150, "public_operations": 94, "traceable_test_ids": 42, "synthetic_release_gate_checks": 41, diff --git a/packages/__init__.py b/packages/__init__.py new file mode 100644 index 0000000..e18386f --- /dev/null +++ b/packages/__init__.py @@ -0,0 +1 @@ +"""Shared LabSafe packages.""" diff --git a/packages/application/__init__.py b/packages/application/__init__.py new file mode 100644 index 0000000..cbf7c70 --- /dev/null +++ b/packages/application/__init__.py @@ -0,0 +1 @@ +"""Application command/query package placeholder.""" diff --git a/packages/domain/__init__.py b/packages/domain/__init__.py new file mode 100644 index 0000000..d96aeeb --- /dev/null +++ b/packages/domain/__init__.py @@ -0,0 +1 @@ +"""Domain package placeholder for immutable facts, guards and state transitions.""" diff --git a/packages/inference_protocol/__init__.py b/packages/inference_protocol/__init__.py new file mode 100644 index 0000000..34e6376 --- /dev/null +++ b/packages/inference_protocol/__init__.py @@ -0,0 +1 @@ +"""Shared inference wire contract only; no domain, ORM, Redis or model imports.""" diff --git a/packages/inference_protocol/contract.json b/packages/inference_protocol/contract.json new file mode 100644 index 0000000..0e49fa9 --- /dev/null +++ b/packages/inference_protocol/contract.json @@ -0,0 +1,1730 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "LabSafe 独立 AI 事实 API", + "version": "1.1.0", + "description": "设计修订版,由 tools/design/build_specs.py 生成;未声明已部署。" + }, + "servers": [ + { + "url": "/internal/inference/v1" + } + ], + "security": [ + { + "serviceToken": [] + } + ], + "paths": { + "/quality": { + "post": { + "operationId": "aiQuality", + "responses": { + "200": { + "description": "结构化事实或健康状态", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InferenceResult" + } + } + } + }, + "401": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "504": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InferenceRequest" + } + } + } + } + } + }, + "/runs": { + "post": { + "operationId": "aiRuns", + "responses": { + "200": { + "description": "结构化事实或健康状态", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InferenceResult" + } + } + } + }, + "401": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "504": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InferenceRequest" + } + } + } + } + } + }, + "/health": { + "get": { + "operationId": "aiHealth", + "responses": { + "200": { + "description": "结构化事实或健康状态", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "401": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "504": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/ready": { + "get": { + "operationId": "aiReady", + "responses": { + "200": { + "description": "结构化事实或健康状态", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "401": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "504": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/version": { + "get": { + "operationId": "aiVersion", + "responses": { + "200": { + "description": "结构化事实或健康状态", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Version" + } + } + } + }, + "401": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "504": { + "description": "统一错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "serviceToken": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "request_id": { + "type": "string", + "format": "uuid" + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "VALIDATION_ERROR", + "UNAUTHENTICATED", + "FORBIDDEN", + "NOT_FOUND", + "STATE_CONFLICT", + "VERSION_CONFLICT", + "IDEMPOTENCY_CONFLICT", + "REQUEST_IN_PROGRESS", + "RATE_LIMITED", + "IMAGE_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "IMAGE_INVALID", + "UNAUTHORIZED_REF", + "OBJECT_NOT_FOUND", + "HASH_MISMATCH", + "MODEL_NOT_READY", + "MODEL_VERSION_UNAVAILABLE", + "AI_BUSY", + "RUN_IN_PROGRESS", + "AI_TIMEOUT", + "STAGE_TIMEOUT", + "MODEL_OOM", + "DEPENDENCY_UNAVAILABLE", + "MODEL_ERROR", + "INTERNAL_ERROR", + "SCHEMA_MISMATCH", + "RULESET_INVALID", + "LEASE_LOST" + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "retryable": { + "type": "boolean" + }, + "details": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ErrorDetail" + }, + "minItems": 0, + "maxItems": 30 + } + }, + "required": [ + "code", + "message", + "retryable", + "details" + ], + "additionalProperties": false + } + }, + "required": [ + "request_id", + "error" + ], + "additionalProperties": false + }, + "ErrorDetail": { + "type": "object", + "properties": { + "field": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + }, + "required": [ + "field", + "reason" + ], + "additionalProperties": false + }, + "ImageRef": { + "type": "object", + "properties": { + "image_id": { + "type": "string", + "format": "uuid" + }, + "object_key": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mime_type": { + "type": "string", + "enum": [ + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "role": { + "type": "string", + "enum": [ + "overview", + "detail" + ] + }, + "location_id": { + "type": "string", + "format": "uuid" + }, + "parent_image_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "image_id", + "object_key", + "sha256", + "mime_type", + "role", + "location_id", + "parent_image_id" + ], + "additionalProperties": false + }, + "InferenceRequest": { + "type": "object", + "properties": { + "run_id": { + "type": "string", + "format": "uuid" + }, + "attempt_id": { + "type": "string", + "format": "uuid" + }, + "fencing_token": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "laboratory_id": { + "type": "string", + "format": "uuid" + }, + "item_id": { + "type": "string", + "format": "uuid" + }, + "submission_revision": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "model_bundle_id": { + "type": "string", + "format": "uuid" + }, + "model_checksum": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "dictionary_version_id": { + "type": "string", + "format": "uuid" + }, + "dictionary_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "pipeline_version": { + "type": "string", + "enum": [ + "vision-v1" + ] + }, + "device_profile": { + "type": "string", + "enum": [ + "cpu", + "cuda" + ] + }, + "deadline_at": { + "type": "string", + "format": "date-time" + }, + "request_hash": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "image_refs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageRef" + }, + "minItems": 1, + "maxItems": 3 + } + }, + "required": [ + "run_id", + "attempt_id", + "fencing_token", + "tenant_id", + "laboratory_id", + "item_id", + "submission_revision", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + "device_profile", + "deadline_at", + "request_hash", + "image_refs" + ], + "additionalProperties": false + }, + "Point": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false + }, + "Quality": { + "type": "object", + "properties": { + "image_id": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": [ + "pass", + "needs_retake" + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "blur", + "dark", + "glare", + "occluded", + "unreadable" + ] + }, + "minItems": 0, + "maxItems": 5 + }, + "blur_score": { + "type": "number", + "minimum": 0 + }, + "brightness": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "glare_ratio": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "image_id", + "status", + "reasons", + "blur_score", + "brightness", + "glare_ratio" + ], + "additionalProperties": false + }, + "Detection": { + "type": "object", + "properties": { + "detection_id": { + "type": "string", + "format": "uuid" + }, + "image_id": { + "type": "string", + "format": "uuid" + }, + "parent_detection_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "bottle", + "label", + "shelf", + "cabinet" + ] + }, + "bbox": { + "type": "array", + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "minItems": 4, + "maxItems": 4 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "detection_id", + "image_id", + "parent_detection_id", + "type", + "bbox", + "confidence" + ], + "additionalProperties": false + }, + "CropRecipe": { + "type": "object", + "properties": { + "crop_id": { + "type": "string", + "format": "uuid" + }, + "image_id": { + "type": "string", + "format": "uuid" + }, + "detection_id": { + "type": "string", + "format": "uuid" + }, + "quad": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Point" + }, + "minItems": 4, + "maxItems": 4 + }, + "output_width": { + "type": "integer", + "minimum": 1, + "maximum": 2048 + }, + "output_height": { + "type": "integer", + "minimum": 1, + "maximum": 2048 + }, + "transform_version": { + "type": "string", + "enum": [ + "perspective-rgb-v1" + ] + } + }, + "required": [ + "crop_id", + "image_id", + "detection_id", + "quad", + "output_width", + "output_height", + "transform_version" + ], + "additionalProperties": false + }, + "OCRField": { + "type": "object", + "properties": { + "image_id": { + "type": "string", + "format": "uuid" + }, + "detection_id": { + "type": "string", + "format": "uuid" + }, + "crop_id": { + "type": "string", + "format": "uuid" + }, + "field": { + "type": "string", + "enum": [ + "name", + "expiry", + "production", + "opened", + "date_unknown", + "concentration", + "hazard_mark" + ] + }, + "raw_text": { + "type": "string", + "minLength": 0, + "maxLength": 2000 + }, + "normalized_text": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + { + "type": "null" + } + ] + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "image_id", + "detection_id", + "crop_id", + "field", + "raw_text", + "normalized_text", + "confidence" + ], + "additionalProperties": false + }, + "Candidate": { + "type": "object", + "properties": { + "entity_id": { + "type": "string", + "format": "uuid" + }, + "canonical_name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "match_method": { + "type": "string", + "enum": [ + "cas_exact", + "alias_exact", + "fuzzy" + ] + } + }, + "required": [ + "entity_id", + "canonical_name", + "confidence", + "match_method" + ], + "additionalProperties": false + }, + "EntityCandidates": { + "type": "object", + "properties": { + "image_id": { + "type": "string", + "format": "uuid" + }, + "detection_id": { + "type": "string", + "format": "uuid" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Candidate" + }, + "minItems": 0, + "maxItems": 5 + }, + "resolution": { + "type": "string", + "enum": [ + "resolved", + "candidate", + "unknown" + ] + } + }, + "required": [ + "image_id", + "detection_id", + "candidates", + "resolution" + ], + "additionalProperties": false + }, + "Relation": { + "type": "object", + "properties": { + "image_id": { + "type": "string", + "format": "uuid" + }, + "source_detection_id": { + "type": "string", + "format": "uuid" + }, + "target_detection_id": { + "type": "string", + "format": "uuid" + }, + "relation": { + "type": "string", + "enum": [ + "adjacent", + "not_adjacent", + "unknown" + ] + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": [ + "image_id", + "source_detection_id", + "target_detection_id", + "relation", + "confidence" + ], + "additionalProperties": false + }, + "Timing": { + "type": "object", + "properties": { + "download_ms": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "quality_ms": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "detection_ms": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "ocr_ms": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "normalization_ms": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + }, + "total_ms": { + "type": "integer", + "minimum": 0, + "maximum": 2147483647 + } + }, + "required": [ + "download_ms", + "quality_ms", + "detection_ms", + "ocr_ms", + "normalization_ms", + "total_ms" + ], + "additionalProperties": false + }, + "InferenceResult": { + "type": "object", + "properties": { + "run_id": { + "type": "string", + "format": "uuid" + }, + "attempt_id": { + "type": "string", + "format": "uuid" + }, + "fencing_token": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "tenant_id": { + "type": "string", + "format": "uuid" + }, + "request_hash": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "input_hashes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "image_id": { + "type": "string", + "format": "uuid" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "image_id", + "sha256" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 3 + }, + "model_bundle_id": { + "type": "string", + "format": "uuid" + }, + "model_checksum": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "dictionary_version_id": { + "type": "string", + "format": "uuid" + }, + "dictionary_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "pipeline_version": { + "type": "string", + "enum": [ + "vision-v1" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "facts_ready", + "needs_retake", + "needs_review" + ] + }, + "quality": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Quality" + }, + "minItems": 1, + "maxItems": 3 + }, + "detections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Detection" + }, + "minItems": 0, + "maxItems": 100 + }, + "crops": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CropRecipe" + }, + "minItems": 0, + "maxItems": 100 + }, + "ocr_fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OCRField" + }, + "minItems": 0, + "maxItems": 500 + }, + "entities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityCandidates" + }, + "minItems": 0, + "maxItems": 100 + }, + "relations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Relation" + }, + "minItems": 0, + "maxItems": 200 + }, + "timing_ms": { + "$ref": "#/components/schemas/Timing" + } + }, + "required": [ + "run_id", + "attempt_id", + "fencing_token", + "tenant_id", + "request_hash", + "input_hashes", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + "outcome", + "quality", + "detections", + "crops", + "ocr_fields", + "entities", + "relations", + "timing_ms" + ], + "additionalProperties": false + }, + "Health": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "alive", + "ready", + "not_ready" + ] + }, + "model_bundle_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "active_attempt_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "model_bundle_id", + "active_attempt_id" + ], + "additionalProperties": false + }, + "Version": { + "type": "object", + "properties": { + "service_commit": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "pipeline_version": { + "type": "string", + "enum": [ + "vision-v1" + ] + }, + "model_bundle_id": { + "type": "string", + "format": "uuid" + }, + "dictionary_version_id": { + "type": "string", + "format": "uuid" + }, + "device_profile": { + "type": "string", + "enum": [ + "cpu", + "cuda" + ] + }, + "purpose": { + "type": "string", + "enum": [ + "development", + "evaluation", + "production" + ] + }, + "is_simulated": { + "type": "boolean" + }, + "adapter_id": { + "type": "string", + "enum": [ + "dfine-n4-rgb-stretch-v1", + "fixture-v1" + ] + }, + "runtime_profile": { + "type": "string", + "enum": [ + "dfine-cpu-fp32-ocrv4cpu-v1", + "dfine-cuda-fp32-ocrv4cpu-v1", + "fixture-v1" + ] + }, + "runtime_lock_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "detector_device": { + "type": "string", + "enum": [ + "cpu", + "cuda", + "mock" + ] + }, + "ocr_device": { + "type": "string", + "enum": [ + "cpu", + "mock" + ] + } + }, + "required": [ + "service_commit", + "pipeline_version", + "model_bundle_id", + "dictionary_version_id", + "device_profile", + "purpose", + "is_simulated", + "adapter_id", + "runtime_profile", + "runtime_lock_sha256", + "detector_device", + "ocr_device" + ], + "additionalProperties": false + } + } + }, + "x-error-codes": { + "VALIDATION_ERROR": { + "http_status": 422, + "retryable": false + }, + "UNAUTHENTICATED": { + "http_status": 401, + "retryable": false + }, + "FORBIDDEN": { + "http_status": 403, + "retryable": false + }, + "NOT_FOUND": { + "http_status": 404, + "retryable": false + }, + "STATE_CONFLICT": { + "http_status": 409, + "retryable": false + }, + "VERSION_CONFLICT": { + "http_status": 409, + "retryable": false + }, + "IDEMPOTENCY_CONFLICT": { + "http_status": 409, + "retryable": false + }, + "REQUEST_IN_PROGRESS": { + "http_status": 409, + "retryable": true + }, + "RATE_LIMITED": { + "http_status": 429, + "retryable": true + }, + "IMAGE_TOO_LARGE": { + "http_status": 413, + "retryable": false + }, + "UNSUPPORTED_MEDIA_TYPE": { + "http_status": 415, + "retryable": false + }, + "IMAGE_INVALID": { + "http_status": 422, + "retryable": false + }, + "UNAUTHORIZED_REF": { + "http_status": 403, + "retryable": false + }, + "OBJECT_NOT_FOUND": { + "http_status": 404, + "retryable": false + }, + "HASH_MISMATCH": { + "http_status": 422, + "retryable": false + }, + "MODEL_NOT_READY": { + "http_status": 503, + "retryable": true + }, + "MODEL_VERSION_UNAVAILABLE": { + "http_status": 409, + "retryable": false + }, + "AI_BUSY": { + "http_status": 429, + "retryable": true + }, + "RUN_IN_PROGRESS": { + "http_status": 409, + "retryable": true + }, + "AI_TIMEOUT": { + "http_status": 504, + "retryable": true + }, + "STAGE_TIMEOUT": { + "http_status": 504, + "retryable": true + }, + "MODEL_OOM": { + "http_status": 503, + "retryable": true + }, + "DEPENDENCY_UNAVAILABLE": { + "http_status": 503, + "retryable": true + }, + "MODEL_ERROR": { + "http_status": 500, + "retryable": false + }, + "INTERNAL_ERROR": { + "http_status": 500, + "retryable": false + }, + "SCHEMA_MISMATCH": { + "http_status": 502, + "retryable": false + }, + "RULESET_INVALID": { + "http_status": 422, + "retryable": false + }, + "LEASE_LOST": { + "http_status": 409, + "retryable": false + } + } +} diff --git a/packages/inference_protocol/contract.py b/packages/inference_protocol/contract.py new file mode 100644 index 0000000..09d5ef5 --- /dev/null +++ b/packages/inference_protocol/contract.py @@ -0,0 +1,36 @@ +"""Validate directly against generated schemas; never duplicate wire fields.""" + +import json +from functools import lru_cache +from importlib.resources import files +from typing import Any, Self + +from jsonschema import Draft202012Validator, FormatChecker +from pydantic import RootModel, model_validator + +DOCUMENT = json.loads(files(__package__).joinpath("contract.json").read_text(encoding="utf-8")) +FORMAT_CHECKER = FormatChecker() +if "date-time" not in FORMAT_CHECKER.checkers: + raise RuntimeError("Install rfc3339-validator: timestamp validation must not be skipped") + + +@lru_cache +def validator(name: str) -> Draft202012Validator: + schema = { + "$ref": f"#/components/schemas/{name}", + "components": DOCUMENT["components"], + } + return Draft202012Validator(schema, format_checker=FORMAT_CHECKER) + + +def validate(name: str, value: Any) -> None: + # Avoid leaking image refs, tenant IDs or raw request content in error messages. + if next(validator(name).iter_errors(value), None) is not None: + raise ValueError(f"Payload does not satisfy {name}") + + +class InferenceRequest(RootModel[dict[str, Any]]): + @model_validator(mode="after") + def validate_contract(self) -> Self: + validate("InferenceRequest", self.root) + return self diff --git a/packages/inference_protocol/hashing.py b/packages/inference_protocol/hashing.py new file mode 100644 index 0000000..8f39667 --- /dev/null +++ b/packages/inference_protocol/hashing.py @@ -0,0 +1,34 @@ +"""Canonical JSON and request identity shared by producer and consumer.""" + +import hashlib +import json +from typing import Any + +REQUEST_HASH_FIELDS = ( + "tenant_id", + "laboratory_id", + "item_id", + "run_id", + "submission_revision", + "model_bundle_id", + "model_checksum", + "dictionary_version_id", + "dictionary_sha256", + "pipeline_version", + "device_profile", + "image_refs", +) + + +def canonical_json(value: Any) -> str: + return json.dumps( + value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":") + ) + + +def sha256_json(value: Any) -> str: + return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest() + + +def request_hash(payload: dict[str, Any]) -> str: + return sha256_json({name: payload[name] for name in REQUEST_HASH_FIELDS}) diff --git a/packages/persistence/__init__.py b/packages/persistence/__init__.py new file mode 100644 index 0000000..536ba8f --- /dev/null +++ b/packages/persistence/__init__.py @@ -0,0 +1 @@ +"""Persistence adapter package placeholder.""" diff --git a/packages/rules/__init__.py b/packages/rules/__init__.py new file mode 100644 index 0000000..e032c24 --- /dev/null +++ b/packages/rules/__init__.py @@ -0,0 +1 @@ +"""Pure rule evaluation package placeholder.""" diff --git a/packages/shared/__init__.py b/packages/shared/__init__.py new file mode 100644 index 0000000..d17fb4a --- /dev/null +++ b/packages/shared/__init__.py @@ -0,0 +1 @@ +"""Cross-process utilities that do not contain business state.""" diff --git a/packages/shared/environment.py b/packages/shared/environment.py new file mode 100644 index 0000000..3de4954 --- /dev/null +++ b/packages/shared/environment.py @@ -0,0 +1,13 @@ +"""Business-process startup guard; this milestone never enables production.""" + +import os +import sys + + +def development_environment() -> str: + if sys.version_info[:2] != (3, 11): + raise RuntimeError("LabSafe I-01A requires Python 3.11") + environment = os.environ.get("APP_ENV") + if environment not in {"dev", "test"}: + raise RuntimeError("I-01A requires explicit APP_ENV=dev or test; production is disabled") + return environment diff --git a/packages/shared/json_hash.py b/packages/shared/json_hash.py new file mode 100644 index 0000000..548e52d --- /dev/null +++ b/packages/shared/json_hash.py @@ -0,0 +1,5 @@ +"""Compatibility imports; canonical serialization has one implementation.""" + +from packages.inference_protocol.hashing import canonical_json, sha256_json + +__all__ = ["canonical_json", "sha256_json"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5245c8b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "labsafe" +version = "0.1.0.dev0" +description = "LabSafe inspection and evidence workflow" +requires-python = ">=3.11,<3.12" +readme = "DEVELOPMENT.md" +dependencies = [] + +[project.optional-dependencies] +ai = [ + "fastapi==0.115.6", + "pydantic==2.10.5", + "uvicorn[standard]==0.34.0", + "PyYAML==6.0.2", + "jsonschema==4.23.0", + "rfc3339-validator==0.1.4", +] +api = [ + "fastapi==0.115.6", + "pydantic==2.10.5", + "uvicorn[standard]==0.34.0", +] +worker = [ + "celery==5.4.0", + "redis==5.2.1", +] +persistence = [ + "alembic==1.14.1", + "sqlalchemy==2.0.37", + "pymysql==1.1.1", +] +dev = [ + "pytest==8.3.4", + "ruff==0.9.3", + "mypy==1.14.1", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["apps*", "packages*"] +namespaces = false + +[tool.setuptools.package-data] +"packages.inference_protocol" = ["contract.json"] +"apps.ai_inference" = ["fixtures/*.json"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I"] diff --git a/requirements/common.txt b/requirements/common.txt new file mode 100644 index 0000000..9cfb5da --- /dev/null +++ b/requirements/common.txt @@ -0,0 +1,7 @@ +# Direct development constraints, not a production transitive runtime lock. +fastapi==0.115.6 +pydantic==2.10.5 +uvicorn[standard]==0.34.0 +PyYAML==6.0.2 +jsonschema==4.23.0 +rfc3339-validator==0.1.4 diff --git a/requirements/dev-ai.txt b/requirements/dev-ai.txt new file mode 100644 index 0000000..3a92659 --- /dev/null +++ b/requirements/dev-ai.txt @@ -0,0 +1,2 @@ +-r py311-ai.txt +-r test.txt diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..2049333 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,2 @@ +-r py311-business.txt +-r test.txt diff --git a/requirements/py311-ai.txt b/requirements/py311-ai.txt new file mode 100644 index 0000000..1f93377 --- /dev/null +++ b/requirements/py311-ai.txt @@ -0,0 +1,2 @@ +# Isolated Python 3.11 AI service; no Celery, Redis or business ORM. +-r common.txt diff --git a/requirements/py311-business.txt b/requirements/py311-business.txt new file mode 100644 index 0000000..7200597 --- /dev/null +++ b/requirements/py311-business.txt @@ -0,0 +1,7 @@ +# API, worker and persistence constraints. All services use Python 3.11. +-r common.txt +celery==5.4.0 +redis==5.2.1 +alembic==1.14.1 +sqlalchemy==2.0.37 +pymysql==1.1.1 diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000..9f62f98 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,3 @@ +pytest==8.3.4 +ruff==0.9.3 +httpx==0.28.1 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fe24853 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""I-01A smoke tests.""" diff --git a/tests/ai/__init__.py b/tests/ai/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/ai/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/ai/test_fixture.py b/tests/ai/test_fixture.py new file mode 100644 index 0000000..139da86 --- /dev/null +++ b/tests/ai/test_fixture.py @@ -0,0 +1,320 @@ +import asyncio +import copy +import hashlib +import os +import subprocess +import sys +import unittest +from importlib.resources import files +from pathlib import Path +from unittest.mock import patch +from uuid import uuid4 + +import httpx +from fastapi.testclient import TestClient + +from apps.ai_inference.app.main import create_app +from apps.ai_inference.app.settings import Settings +from packages.inference_protocol.contract import DOCUMENT, validate +from packages.inference_protocol.hashing import request_hash +from tests.helpers import configured, http_process, make_request + +BASE = "/internal/inference/v1" + + +class FixtureTests(unittest.TestCase): + def test_authenticated_health_ready_version_follow_contract(self): + with configured() as token: + settings = Settings.from_env() + app = create_app(settings) + self.assertEqual(app.openapi(), DOCUMENT) + with TestClient(app) as client: + for route, schema in ( + ("health", "Health"), + ("ready", "Health"), + ("version", "Version"), + ): + response = client.get( + f"{BASE}/{route}", headers={"Authorization": f"Bearer {token}"} + ) + self.assertEqual(response.status_code, 200) + validate(schema, response.json()) + identity = client.get( + f"{BASE}/version", headers={"Authorization": f"Bearer {token}"} + ).json() + self.assertTrue(identity["is_simulated"]) + self.assertEqual(identity["purpose"], "development") + self.assertEqual(identity["detector_device"], "mock") + lock = ( + files("apps.ai_inference").joinpath("fixtures/runtime-lock.json").read_bytes() + ) + self.assertEqual(identity["runtime_lock_sha256"], hashlib.sha256(lock).hexdigest()) + + def test_all_contract_routes_require_token(self): + with configured(): + with TestClient(create_app()) as client: + for path, operation in DOCUMENT["paths"].items(): + for method in operation: + for headers in ({}, {"Authorization": "Bearer incorrect"}): + response = client.request(method, BASE + path, headers=headers, json={}) + self.assertEqual(response.status_code, 401, response.text) + validate("Error", response.json()) + self.assertNotIn("incorrect", response.text) + self.assertFalse(response.json()["error"]["retryable"]) + self.assertEqual(client.post(f"{BASE}/fixture", json={}).status_code, 404) + self.assertEqual(client.get("/docs").status_code, 404) + + def test_missing_lifespan_is_not_ready(self): + with configured() as token: + # Without context manager, TestClient does not enter ASGI lifespan. + client = TestClient(create_app()) + try: + response = client.get(f"{BASE}/ready", headers={"Authorization": f"Bearer {token}"}) + self.assertEqual(response.status_code, 503) + validate("Error", response.json()) + finally: + client.close() + + def test_quality_and_runs_echo_identity_without_false_safe_result(self): + with configured() as token: + settings = Settings.from_env() + payload = make_request(settings) + with TestClient(create_app(settings)) as client: + for stage, expected in (("quality", "facts_ready"), ("runs", "needs_review")): + response = client.post( + f"{BASE}/{stage}", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(response.status_code, 200, response.text) + result = response.json() + validate("InferenceResult", result) + self.assertEqual(result["outcome"], expected) + for key in ("run_id", "attempt_id", "fencing_token", "request_hash"): + self.assertEqual(result[key], payload[key]) + self.assertEqual(result["detections"], []) + self.assertEqual( + result["quality"][0]["image_id"], payload["image_refs"][0]["image_id"] + ) + + def test_retake_and_model_error_branches(self): + for scenario, status in (("needs_retake", 200), ("error", 500)): + with self.subTest(scenario=scenario), configured(scenario) as token: + settings = Settings.from_env() + with TestClient(create_app(settings)) as client: + for stage in ("quality", "runs"): + response = client.post( + f"{BASE}/{stage}", + json=make_request(settings), + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(response.status_code, status) + validate("InferenceResult" if status == 200 else "Error", response.json()) + if status == 200: + self.assertEqual(response.json()["outcome"], "needs_retake") + self.assertEqual(response.json()["quality"][0]["reasons"], ["blur"]) + else: + self.assertEqual(response.json()["error"]["code"], "MODEL_ERROR") + + def test_schema_errors_do_not_echo_input(self): + with configured() as token: + settings = Settings.from_env() + valid = make_request(settings) + invalid = [ + ({}, None), + ({**valid, "unknown": "sensitive-value"}, None), + ({**valid, "fencing_token": True}, None), + ({**valid, "run_id": "not-a-uuid"}, None), + ({**valid, "deadline_at": "not-a-date"}, None), + ] + with TestClient(create_app(settings)) as client: + for payload, _ in invalid: + response = client.post( + f"{BASE}/runs", json=payload, headers={"Authorization": f"Bearer {token}"} + ) + self.assertEqual(response.status_code, 422) + validate("Error", response.json()) + self.assertNotIn("sensitive-value", response.text) + response = client.post( + f"{BASE}/runs", + content="{broken", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + self.assertEqual(response.status_code, 422) + validate("Error", response.json()) + + def test_semantic_validation(self): + with configured() as token: + settings = Settings.from_env() + mutations = [ + ("tenant_id", str(uuid4()), "FORBIDDEN"), + ("model_bundle_id", str(uuid4()), "MODEL_VERSION_UNAVAILABLE"), + ("dictionary_version_id", str(uuid4()), "MODEL_VERSION_UNAVAILABLE"), + ("device_profile", "cuda", "MODEL_VERSION_UNAVAILABLE"), + ("model_checksum", "0" * 64, "HASH_MISMATCH"), + ("dictionary_sha256", "0" * 64, "HASH_MISMATCH"), + ("request_hash", "0" * 64, "HASH_MISMATCH"), + ] + with TestClient(create_app(settings)) as client: + for key, value, expected in mutations: + payload = make_request(settings) + payload[key] = value + response = client.post( + f"{BASE}/runs", json=payload, headers={"Authorization": f"Bearer {token}"} + ) + self.assertEqual(response.json()["error"]["code"], expected) + validate("Error", response.json()) + for field, value, expected in ( + ("object_key", "https://example.invalid/remote", "UNAUTHORIZED_REF"), + ("parent_image_id", str(uuid4()), "VALIDATION_ERROR"), + ("role", "detail", "VALIDATION_ERROR"), + ("mime_type", "image/jpeg", "VALIDATION_ERROR"), + ): + payload = make_request(settings) + payload["image_refs"][0][field] = value + payload["request_hash"] = request_hash(payload) + response = client.post( + f"{BASE}/runs", json=payload, headers={"Authorization": f"Bearer {token}"} + ) + self.assertEqual(response.json()["error"]["code"], expected) + + def test_timeout_releases_capacity(self): + with configured(delay=100) as token: + settings = Settings.from_env() + with TestClient(create_app(settings)) as client: + for seconds in (-1, 0.03): + response = client.post( + f"{BASE}/runs", + json=make_request(settings, seconds=seconds), + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(response.status_code, 504) + self.assertTrue(response.json()["error"]["retryable"]) + response = client.post( + f"{BASE}/runs", + json=make_request(settings), + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(response.status_code, 200) + status = client.get(f"{BASE}/health", headers={"Authorization": f"Bearer {token}"}) + self.assertIsNone(status.json()["active_attempt_id"]) + + def test_single_inflight_and_duplicate_attempt(self): + async def check(): + with configured(delay=400) as token: + settings = Settings.from_env() + app = create_app(settings) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), + base_url="http://fixture", + headers={"Authorization": f"Bearer {token}"}, + ) as client: + payload = make_request(settings) + first = asyncio.create_task(client.post(f"{BASE}/runs", json=payload)) + try: + for _ in range(100): + health = await client.get(f"{BASE}/health") + if health.json()["active_attempt_id"] == payload["attempt_id"]: + break + await asyncio.sleep(0.002) + else: + self.fail("First attempt did not enter the gate") + duplicate = await client.post(f"{BASE}/quality", json=payload) + self.assertEqual(duplicate.status_code, 409) + self.assertEqual(duplicate.json()["error"]["code"], "RUN_IN_PROGRESS") + other = copy.deepcopy(payload) + other["attempt_id"] = str(uuid4()) + busy = await client.post(f"{BASE}/runs", json=other) + self.assertEqual(busy.status_code, 429) + self.assertEqual(busy.json()["error"]["code"], "AI_BUSY") + self.assertEqual((await client.get(f"{BASE}/ready")).status_code, 200) + self.assertEqual((await first).status_code, 200) + finally: + if not first.done(): + first.cancel() + await asyncio.gather(first, return_exceptions=True) + + asyncio.run(check()) + + def test_invalid_configuration_is_rejected(self): + for name, value in ( + ("APP_ENV", "production"), + ("APP_ENV", ""), + ("APP_ENV", "staging"), + ("AI_MODE", "real"), + ("AI_MODE", "fixture"), + ("AI_MODE", ""), + ("AI_ALLOWED_TENANTS", "*"), + ("AI_ALLOWED_TENANTS", ""), + ("AI_MAX_INFLIGHT", "2"), + ("AI_FIXTURE_DELAY_MS", "NaN"), + ("AI_FIXTURE_DELAY_MS", "-1"), + ("AI_FIXTURE_DELAY_MS", "10001"), + ("AI_FIXTURE_SCENARIO", "unknown"), + ("AI_TOKEN_FILE", ""), + ): + with self.subTest(name=name, value=value), configured(): + with patch.dict(os.environ, {name: value}): + with self.assertRaises(RuntimeError): + create_app() + with configured(): + Path(os.environ["AI_TOKEN_FILE"]).write_text("too-short", encoding="ascii") + with self.assertRaises(RuntimeError): + create_app() + + def test_runtime_drift_is_rejected(self): + with configured(), patch("apps.ai_inference.app.settings.version", return_value="0.0"): + with self.assertRaisesRegex(RuntimeError, "dependency mismatch"): + create_app() + + def test_rfc3339_lowercase_timestamp_and_timeout_scenario(self): + for scenario in ("no_targets", "timeout"): + with self.subTest(scenario=scenario), configured(scenario) as token: + settings = Settings.from_env() + payload = make_request(settings, seconds=0.2 if scenario == "timeout" else 10) + payload["deadline_at"] = payload["deadline_at"].replace("+00:00", "z").lower() + with TestClient(create_app(settings)) as client: + response = client.post( + f"{BASE}/runs", + json=payload, + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(response.status_code, 504 if scenario == "timeout" else 200) + + def test_standalone_ai_http_process(self): + with configured() as token: + settings = Settings.from_env() + headers = {"Authorization": f"Bearer {token}"} + with http_process( + "apps.ai_inference.run", "AI_INFERENCE_PORT", f"{BASE}/ready", headers=headers + ) as client: + result = client.post(f"{BASE}/runs", json=make_request(settings), headers=headers) + self.assertEqual(result.status_code, 200) + validate("InferenceResult", result.json()) + + def test_production_cli_fails(self): + with configured(), patch.dict(os.environ, {"APP_ENV": "production"}): + result = subprocess.run( + [sys.executable, "-B", "-m", "apps.ai_inference.run"], + capture_output=True, + text=True, + timeout=15, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("production is disabled", result.stderr) + + def test_ai_import_does_not_load_business_or_model_dependencies(self): + code = ( + "import sys; import apps.ai_inference.app.main; " + "names=('celery','redis','sqlalchemy','torch','paddle','onnxruntime'," + "'packages.domain','packages.application','packages.persistence','packages.rules'); " + "assert not any(n in sys.modules for n in names)" + ) + result = subprocess.run( + [sys.executable, "-B", "-c", code], capture_output=True, text=True, timeout=15 + ) + self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tests/business/__init__.py b/tests/business/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/business/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/business/test_bootstrap.py b/tests/business/test_bootstrap.py new file mode 100644 index 0000000..f020546 --- /dev/null +++ b/tests/business/test_bootstrap.py @@ -0,0 +1,77 @@ +import os +import subprocess +import sys +import unittest +from unittest.mock import patch + +from celery import Celery +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apps.api.app.main import create_app +from apps.worker.app.main import create_celery_app, fixture_echo +from tests.helpers import configured, http_process + + +class BootstrapTests(unittest.TestCase): + def test_actual_api_and_development_readiness(self): + with configured(): + app = create_app() + self.assertIsInstance(app, FastAPI) + with TestClient(app) as client: + self.assertEqual(client.get("/health").json()["status"], "alive") + result = client.get("/ready") + self.assertEqual(result.status_code, 200) + self.assertEqual(result.json()["environment"], "test") + self.assertIn("process only", result.json()["scope"]) + + def test_actual_celery_and_registered_smoke_task(self): + with configured(): + app = create_celery_app() + self.assertIsInstance(app, Celery) + self.assertIn("labsafe.fixture.echo", app.tasks) + self.assertTrue(app.conf.task_acks_late) + self.assertEqual(app.conf.worker_prefetch_multiplier, 1) + self.assertEqual( + fixture_echo({"task": "smoke"}), {"fixture": True, "payload": {"task": "smoke"}} + ) + + def test_invalid_environments_are_rejected(self): + for environment in ("production", "staging", ""): + with self.subTest(environment=environment), configured(): + with patch.dict(os.environ, {"APP_ENV": environment}): + for factory in (create_app, create_celery_app): + with self.assertRaises(RuntimeError): + factory() + with configured(): + os.environ.pop("APP_ENV") + with self.assertRaises(RuntimeError): + create_app() + + def test_missing_frameworks_do_not_fall_back(self): + for module in ("apps.api.app.main", "apps.worker.app.main", "apps.ai_inference.app.main"): + with self.subTest(module=module): + code = f"import sys; sys.path.insert(0, {os.getcwd()!r}); import {module}" + result = subprocess.run( + [sys.executable, "-I", "-S", "-B", "-c", code], + capture_output=True, + text=True, + timeout=10, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("ModuleNotFoundError", result.stderr) + + def test_api_starts_as_an_independent_http_process(self): + with configured(), http_process("apps.api.run", "API_PORT", "/health") as client: + self.assertEqual(client.get("/ready").status_code, 200) + + def test_worker_configuration_command(self): + with configured(): + result = subprocess.run( + [sys.executable, "-B", "-m", "apps.worker.run", "--check"], + capture_output=True, + text=True, + timeout=15, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("broker connectivity not tested", result.stdout) diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..f6a5a18 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,119 @@ +"""Synthetic data and temporary secrets only; no external services.""" + +import base64 +import hashlib +import os +import secrets +import socket +import subprocess +import sys +import tempfile +import time +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch +from uuid import uuid4 + +import httpx + +from packages.inference_protocol.hashing import request_hash + +TENANT = "11111111-1111-4111-8111-111111111111" + + +@contextmanager +def configured(scenario="no_targets", delay=0): + with tempfile.TemporaryDirectory(prefix="labsafe-test-") as directory: + token = secrets.token_urlsafe(32) + token_file = Path(directory) / "token" + token_file.write_text(token, encoding="ascii") + values = { + "APP_ENV": "test", + "AI_MODE": "mock", + "AI_TOKEN_FILE": str(token_file), + "AI_ALLOWED_TENANTS": TENANT, + "AI_MAX_INFLIGHT": "1", + "AI_FIXTURE_SCENARIO": scenario, + "AI_FIXTURE_DELAY_MS": str(delay), + "SERVICE_COMMIT": "synthetic-test", + "PYTHONDONTWRITEBYTECODE": "1", + } + with patch.dict(os.environ, values): + yield token + + +def make_request(settings, *, seconds=10): + image_id, lab_id, location_id = (str(uuid4()) for _ in range(3)) + # A synthetic 1x1 PNG, not a laboratory image or a quality-evaluation sample. + png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jWZkAAAAASUVORK5CYII=" + ) + image_hash = hashlib.sha256(png).hexdigest() + payload = { + "run_id": str(uuid4()), + "attempt_id": str(uuid4()), + "fencing_token": 1, + "tenant_id": TENANT, + "laboratory_id": lab_id, + "item_id": str(uuid4()), + "submission_revision": 1, + "model_bundle_id": settings.identity["model_bundle_id"], + "model_checksum": settings.model_checksum, + "dictionary_version_id": settings.identity["dictionary_version_id"], + "dictionary_sha256": settings.dictionary_sha256, + "pipeline_version": "vision-v1", + "device_profile": "cpu", + "deadline_at": (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat(), + "image_refs": [ + { + "image_id": image_id, + "object_key": f"tenant/{TENANT}/lab/{lab_id}/analysis/{image_id}/{image_hash}.png", + "sha256": image_hash, + "mime_type": "image/png", + "role": "overview", + "location_id": location_id, + "parent_image_id": None, + } + ], + } + payload["request_hash"] = request_hash(payload) + return payload + + +@contextmanager +def http_process(module, port_variable, probe, *, headers=None): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + environment = {**os.environ, port_variable: str(port), "PYTHONDONTWRITEBYTECODE": "1"} + process = subprocess.Popen( + [sys.executable, "-B", "-m", module], + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, + ) + try: + with httpx.Client(base_url=f"http://127.0.0.1:{port}", trust_env=False) as client: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if process.poll() is not None: + raise AssertionError(f"Service exited: {process.communicate()[0]}") + try: + if client.get(probe, headers=headers, timeout=0.5).status_code == 200: + yield client + return + except httpx.TransportError: + pass + time.sleep(0.05) + raise AssertionError("HTTP process did not become ready") + finally: + if process.poll() is None: + process.terminate() + try: + process.communicate(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=5) diff --git a/tests/protocol/__init__.py b/tests/protocol/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/protocol/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/protocol/test_contract.py b/tests/protocol/test_contract.py new file mode 100644 index 0000000..e6a215f --- /dev/null +++ b/tests/protocol/test_contract.py @@ -0,0 +1,39 @@ +import json +import math +import unittest +from importlib.resources import files + +from packages.inference_protocol.contract import DOCUMENT, InferenceRequest, validate +from packages.inference_protocol.hashing import canonical_json, request_hash + + +class ContractTests(unittest.TestCase): + def test_canonical_serialization(self): + self.assertEqual(canonical_json({"b": 1, "a": [True]}), '{"a":[true],"b":1}') + self.assertEqual(canonical_json({"x": "中文"}), '{"x":"中文"}') + with self.assertRaises(ValueError): + canonical_json({"x": math.nan}) + + def test_retry_identity_excludes_attempt_fencing_and_deadline(self): + from packages.inference_protocol.hashing import REQUEST_HASH_FIELDS + + payload = {key: key for key in REQUEST_HASH_FIELDS} + original = request_hash(payload) + payload.update(attempt_id="retry", fencing_token=9, deadline_at="later") + self.assertEqual(original, request_hash(payload)) + payload["submission_revision"] = "new" + self.assertNotEqual(original, request_hash(payload)) + + def test_generated_package_is_available_as_a_resource(self): + content = files("packages.inference_protocol").joinpath("contract.json").read_text("utf-8") + self.assertEqual(json.loads(content), DOCUMENT) + self.assertEqual(DOCUMENT["servers"][0]["url"], "/internal/inference/v1") + self.assertEqual( + set(DOCUMENT["paths"]), {"/health", "/ready", "/version", "/quality", "/runs"} + ) + + def test_empty_request_is_not_a_dto(self): + with self.assertRaises(ValueError): + InferenceRequest.model_validate({}) + with self.assertRaises(ValueError): + validate("InferenceResult", {"facts": []}) diff --git a/tools/design/README.md b/tools/design/README.md index c3cf9d9..efba874 100644 --- a/tools/design/README.md +++ b/tools/design/README.md @@ -25,7 +25,7 @@ python -m venv .venv-design .\.venv-design\Scripts\python tools/design/validate_specs.py --report ~~~ -建议Python3.11/3.12独立环境。CI先安装依赖,再运行--check和validate;不要先自动生成再检查,否则掩盖未提交生成物漂移。虚拟环境、__pycache__和运行缓存不得提交。所有fixtures均模拟,无真实实验室图像或个人信息。 +使用Python3.11独立工具环境;应用与AI也统一3.11,但不与设计工具共用依赖环境。CI先安装依赖,再运行--check和validate;不要先自动生成再检查,否则掩盖未提交生成物漂移。虚拟环境、__pycache__和运行缓存不得提交。所有fixtures均模拟,无真实实验室图像或个人信息。 ## 验证范围 diff --git a/tools/design/build_specs.py b/tools/design/build_specs.py index 1ee1468..f561f93 100644 --- a/tools/design/build_specs.py +++ b/tools/design/build_specs.py @@ -208,6 +208,11 @@ def visit(v): OUT['contracts/public-api-v1.yaml']=document('LabSafe 公共 API',PATHS,S,'/api/v1','cookieAuth') OUT['contracts/operation-catalog.json']={'version':'1.1.0','operations':OPS} OUT['contracts/error-codes.json']={k:{'http_status':v[0],'retryable':v[1]} for k,v in ERRORS.items()} + +# Package the exact inference contract for runtime validation, without runtime YAML/paths. +runtime_contract=copy.deepcopy(OUT['contracts/inference-v1.yaml']) +runtime_contract['x-error-codes']=copy.deepcopy(OUT['contracts/error-codes.json']) +OUT['packages/inference_protocol/contract.json']=runtime_contract def serialize(path,value): return yaml.safe_dump(value,allow_unicode=True,sort_keys=False,width=110) if path.endswith('.yaml') else json.dumps(value,ensure_ascii=False,indent=2)+'\n' def main(): From 4ab0a6879a3ebd36d0371f2ec229137b7cc5bd62 Mon Sep 17 00:00:00 2001 From: SayQuan1 <15246906705@163.com> Date: Wed, 30 Sep 2026 10:13:49 +0800 Subject: [PATCH 2/3] feat(i-01b): add database foundation and bootstrap CLI Add the frozen initial Alembic schema, tenant and administrator bootstrap, metadata verification, and isolated MySQL integration tests. Document the disposable database workflow, CI service, rollback guard, and I-01B evidence. --- .env.example | 3 +- .github/workflows/ci.yml | 35 +- DEVELOPMENT.md | 66 +- alembic.ini | 3 + contracts/database-design.sql | 2 +- docs/02-architecture/06-data-persistence.md | 4 + docs/08-delivery/07-i01a-acceptance.md | 2 + docs/08-delivery/08-i01b-acceptance.md | 71 + docs/README.md | 3 + packages/persistence/bootstrap.py | 167 + packages/persistence/cli.py | 91 + packages/persistence/database.py | 75 + packages/persistence/migrations/__init__.py | 1 + packages/persistence/migrations/env.py | 26 + .../migrations/snapshots/0001_initial.json | 6108 +++++++++++++++++ .../migrations/snapshots/0001_initial.sql | 1022 +++ .../migrations/versions/0001_initial.py | 46 + .../migrations/versions/__init__.py | 1 + packages/persistence/schema.py | 250 + pyproject.toml | 5 +- requirements/py311-business.txt | 4 +- tests/persistence/__init__.py | 1 + tests/persistence/conftest.py | 61 + tests/persistence/factories.py | 119 + tests/persistence/test_bootstrap.py | 149 + tests/persistence/test_constraints.py | 163 + tests/persistence/test_schema.py | 58 + tests/persistence/test_unit.py | 117 + tools/database/run_mysql_tests.py | 165 + tools/design/data_model.py | 4 +- 30 files changed, 8807 insertions(+), 15 deletions(-) create mode 100644 alembic.ini create mode 100644 docs/08-delivery/08-i01b-acceptance.md create mode 100644 packages/persistence/bootstrap.py create mode 100644 packages/persistence/cli.py create mode 100644 packages/persistence/database.py create mode 100644 packages/persistence/migrations/__init__.py create mode 100644 packages/persistence/migrations/env.py create mode 100644 packages/persistence/migrations/snapshots/0001_initial.json create mode 100644 packages/persistence/migrations/snapshots/0001_initial.sql create mode 100644 packages/persistence/migrations/versions/0001_initial.py create mode 100644 packages/persistence/migrations/versions/__init__.py create mode 100644 packages/persistence/schema.py create mode 100644 tests/persistence/__init__.py create mode 100644 tests/persistence/conftest.py create mode 100644 tests/persistence/factories.py create mode 100644 tests/persistence/test_bootstrap.py create mode 100644 tests/persistence/test_constraints.py create mode 100644 tests/persistence/test_schema.py create mode 100644 tests/persistence/test_unit.py create mode 100644 tools/database/run_mysql_tests.py diff --git a/.env.example b/.env.example index 2e1091c..14c6782 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,8 @@ API_PORT=8000 AI_INFERENCE_HOST=127.0.0.1 AI_INFERENCE_PORT=8001 REDIS_URL=redis://localhost:6379/0 -DATABASE_URL=mysql+pymysql://labsafe:change-me@localhost:3306/labsafe +DATABASE_URL_FILE=.local-secrets/database-url +DATABASE_SCHEMA=labsafe_dev_local AI_MODE=mock AI_TOKEN_FILE=.local-secrets/ai-token AI_ALLOWED_TENANTS=11111111-1111-4111-8111-111111111111 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb7f638..ac12fa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,8 @@ jobs: - run: python -m pip check - run: python -m unittest discover -s tests/business -t . -v - run: python -m unittest discover -s tests/protocol -t . -v - - run: ruff check apps packages tests - - run: ruff format --check apps packages tests + - run: ruff check apps packages tests tools/database + - run: ruff format --check apps packages tests tools/database ai-python311: runs-on: ubuntu-latest @@ -46,7 +46,7 @@ jobs: - run: python -m pip check - name: AI environment must not install business or model dependencies run: | - python -c "import importlib.util; names=['celery','redis','sqlalchemy','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)" + python -c "import importlib.util; names=['celery','redis','sqlalchemy','alembic','pymysql','argon2','torch','paddle','onnxruntime']; assert all(importlib.util.find_spec(n) is None for n in names)" - run: python -m unittest discover -s tests/ai -t . -v - run: python -m unittest discover -s tests/protocol -t . -v @@ -63,6 +63,35 @@ jobs: - run: python -B tools/design/validate_specs.py - run: git diff --exit-code + persistence-mysql: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0.33 + env: + # Disposable CI instance only; never use this test credential for deployments. + MYSQL_ROOT_PASSWORD: ${{ github.run_id }}-${{ github.run_attempt }}-mysql-test-only + MYSQL_ROOT_HOST: '%' + ports: + - 3306/tcp + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 --silent" + --health-interval=5s --health-timeout=5s --health-retries=30 + env: + APP_ENV: test + CI: 'true' + LABSAFE_MYSQL_INTEGRATION: '1' + LABSAFE_CI_MYSQL_PASSWORD: ${{ github.run_id }}-${{ github.run_attempt }}-mysql-test-only + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: python -m pip install -r requirements/dev.txt + - run: python tools/database/run_mysql_tests.py --ci + env: + LABSAFE_CI_MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} + web: runs-on: ubuntu-latest defaults: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0d8b5d0..93f4466 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,12 +1,12 @@ -# LabSafe 开发指南(I-01A) +# LabSafe 开发指南(I-01A / I-01B) ## 1. 本阶段边界 所有 Python 服务统一使用 Python 3.11.x;业务与 AI 使用独立虚拟环境和独立进程。 依据见 [运行时决策](docs/02-architecture/07-python-runtime.md)。前端 CI 使用 Node.js 20。 -本阶段提供真实 FastAPI/Celery 入口、同协议开发 fixture、配置保护和 CI。 -不包含数据库迁移、用户会话、业务 API、持久任务、真实图像处理、训练权重或生产部署。 +已提供真实 FastAPI/Celery 入口、同协议开发 fixture、配置保护,以及 I-01B 初始数据库迁移、租户初始化和真实 MySQL 测试。 +不包含用户会话、RBAC/仓储、业务 API、持久任务、真实图像处理、训练权重或生产部署。 API 的 /ready 只检查 I-01A 进程配置,不代表数据库或完整系统已就绪。 所有后端进程和前端构建均拒绝 production;AI_MODE 只接受 mock。 @@ -112,6 +112,53 @@ npm run dev 两个环境变量必须相等且为 dev/test。开发页面始终显示“开发环境,结果不用于安全判断”。 构建命令是 npm run build;production 或不一致环境必须失败。 +### 3.5 数据库迁移与初始化(I-01B) + +先在独立的开发 MySQL 8.0.16+ 实例创建专用空库 labsafe_dev_local。 +由数据库管理员提供仅对该库具有 SELECT/INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/INDEX/REFERENCES 权限的迁移/初始化账户;不要给 API/Worker 或 AI 使用该管理账户。 +本阶段只允许本机回环连接;远端 TLS、应用最小权限账户和生产发布不在 I-01B 范围内。 + +将管理员提供的 mysql+pymysql 连接 URL 保存到 .local-secrets/database-url,包含用户名、URL 编码后的密码、回环地址、端口和准确的库名。 +URL 不允许 query 参数;程序只读取 DATABASE_URL_FILE,不读取明文 DATABASE_URL,也不自动加载 .env。 +该文件必须仅当前用户可读;不要把真实连接串放在命令行、终端历史、日志、PR 或文档中。 + +~~~powershell +$env:APP_ENV = 'dev' +$env:DATABASE_SCHEMA = 'labsafe_dev_local' +$env:DATABASE_URL_FILE = (Resolve-Path .local-secrets/database-url).Path +.\.venv-business\Scripts\python.exe -m packages.persistence.cli upgrade +.\.venv-business\Scripts\python.exe -m packages.persistence.cli verify +.\.venv-business\Scripts\python.exe -m packages.persistence.cli bootstrap-tenant --code my_org --name '机构名称' --timezone Asia/Shanghai --username admin --display-name '初始管理员' --reason '首次初始化' +~~~ + +最后一步交互式输入并确认 12–128 字符密码,不提供项目默认密码。 +非交互环境使用 --password-file 指向一次性 UTF-8 秘密文件:内容就是密码,不要额外添加 BOM 或结尾换行,程序不会 trim/规范化密码;成功后由提供该文件的操作者安全移除。 +用户名执行 NFKC+casefold;租户、六角色、租户级 safety_admin 授权与脱敏审计在一个事务中提交。 +重复初始化同一租户返回错误,绝不重置已有密码;本命令不是登录接口,也不是管理员重置工具。 +upgrade 成功不等于结构正确,必须继续 verify;JSON 中 errors 应为空,退出码为 0。 +CLI 退出码:0=成功,2=输入/保护检查失败,1=数据库/迁移/结构检查失败;错误不输出连接串或密码。 + +初始迁移仅允许空库(可以已有 Alembic 版本表);不使用 stamp 绕过保护。 +MySQL DDL 非整体事务:中途失败可能遗留部分表,不能承诺自动回滚。 +此时保留失败证据,并由操作者确认后丢弃整个专用测试库重建;不要对已有业务库执行重建。 +降级会删除全部 43 张业务表,只能在 APP_ENV=test 且 DATABASE_SCHEMA 为 labsafe_test_ 前缀的可丢弃库执行: + +~~~powershell +# 仅在已确认的专用测试库配置中执行,绝不能指向业务/开发数据库。 +$env:LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE = $env:DATABASE_SCHEMA +.\.venv-business\Scripts\python.exe -m packages.persistence.cli downgrade +Remove-Item Env:LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE +~~~ + +本机自动验证不需要配置或连接已有库: + +~~~powershell +.\.venv-business\Scripts\python.exe tools/database/run_mysql_tests.py --mysqld 'D:/SQL/MySQL/MySQL Server 8.0/bin/mysqld.exe' +~~~ + +把路径替换为实际 mysqld 可执行文件。脚本创建临时 data directory、随机回环端口和随机凭据,核对实例身份后才建库;结束后只关闭本次子进程并清理临时数据。 +不注册或修改 Windows 服务。Linux 本机模式需已安装 mysqld,并以允许运行 mysqld 的非 root 用户执行;CI 使用隔离 MySQL 容器模式,不依赖本机服务。 + ## 4. 验证 根目录、业务环境执行: @@ -119,8 +166,9 @@ npm run dev ~~~powershell .\.venv-business\Scripts\python.exe -m unittest discover -s tests/business -t . -v .\.venv-business\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v -.\.venv-business\Scripts\python.exe -m ruff check apps packages tests -.\.venv-business\Scripts\python.exe -m ruff format --check apps packages tests +.\.venv-business\Scripts\python.exe -m pytest tests/persistence/test_unit.py --tb=short +.\.venv-business\Scripts\python.exe -m ruff check apps packages tests tools/database +.\.venv-business\Scripts\python.exe -m ruff format --check apps packages tests tools/database ~~~ AI 环境执行: @@ -130,8 +178,9 @@ AI 环境执行: .\.venv-ai\Scripts\python.exe -m unittest discover -s tests/protocol -t . -v ~~~ -测试自行在临时目录生成令牌和合成请求,不要求预设 APP_ENV,也不连接 Redis/MySQL/对象存储。 +I-01A 的业务/AI/协议测试自行在临时目录生成令牌和合成请求,不要求预设 APP_ENV,也不连接 Redis/MySQL/对象存储。 包含实际 API/AI 子进程 HTTP smoke,不只是 import 非空检查。 +I-01B 的真实数据库测试必须运行 3.5 的隔离启动器;直接 pytest tests/persistence 会跳过未配置的 MySQL 用例,不能把跳过算作数据库通过。 设计校验使用独立工具环境,安装 tools/design/requirements.txt 后执行: @@ -148,4 +197,7 @@ CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用 遵循 [Git 协作规范](CONTRIBUTING.md)。提交特性分支、创建 PR;不直接推送 main,不自行合并或代替独立审查。 不提交虚拟环境、.local-secrets、真实图片、权重或 .env。 -下一阶段从 I-01B 专用空库迁移开始;真实模型主线可按 I-ML-01 并行,但不扩大本 PR 验收范围。 +I-01B 的实际结果与后续边界见 [验收记录](docs/08-delivery/08-i01b-acceptance.md)。 +本地 I-01B 分支继承尚未评审的 PR #5,属于依赖分支,不代表前置代码已获批准;未自动创建新 PR 或推送。 +后续提交时按 CONTRIBUTING 独立评审,先完成 I-01A 合并,再基于最新 origin/main 整理仅 I-01B 的提交,重新跑 CI;不直接推送 main。 +下一阶段 I-01C 是仓储、会话/RBAC、幂等与租户事务;真实模型主线可按 I-ML-01 推进,但不以数据库通过替代模型或生产验收。 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..d2cc3b8 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,3 @@ +[alembic] +script_location = %(here)s/packages/persistence/migrations +prepend_sys_path = . diff --git a/contracts/database-design.sql b/contracts/database-design.sql index d88d8bf..0f5c257 100644 --- a/contracts/database-design.sql +++ b/contracts/database-design.sql @@ -83,7 +83,7 @@ CREATE TABLE `user_roles` ( `role_id` CHAR(36) NOT NULL, `laboratory_id` CHAR(36) NULL, `scope_kind` ENUM('tenant','laboratory') NOT NULL DEFAULT 'tenant', - `scope_key` CHAR(36) GENERATED ALWAYS AS (COALESCE(laboratory_id,'00000000-0000-0000-0000-000000000000')) STORED, + `scope_key` CHAR(36) GENERATED ALWAYS AS (COALESCE(laboratory_id,'00000000-0000-0000-0000-000000000000')) STORED NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uq_user_roles_0` (`tenant_id`, `id`), UNIQUE KEY `uq_user_roles_1` (`tenant_id`, `user_id`, `role_id`, `scope_key`), diff --git a/docs/02-architecture/06-data-persistence.md b/docs/02-architecture/06-data-persistence.md index 128c56a..a385bc2 100644 --- a/docs/02-architecture/06-data-persistence.md +++ b/docs/02-architecture/06-data-persistence.md @@ -60,3 +60,7 @@ IRR-04:completeUpload用API内部HEAD取得准确versionId/大小/MIME,HEAD ## 6. 初始化和迁移门禁 先 tenants/roles/users,再组织/模板/词典/模型/规则/activation;循环current pointers初始NULL。角色固定六种;bootstrap管理员由一次性CLI输入密码,不将演示账号写进DDL。数据库管理账户只用于migration;API/worker按表权限分配,AI无账户。DDL脚本不能直接在非空生产schema执行;迁移必须可备份、演练且有前向修复方案。 + +2026-09-29 实现说明:I-01B 已提供 packages/persistence 的 Alembic 0001_initial、只读结构核对和原子租户/角色/管理员初始化。操作与失败恢复见 [开发指南](../../DEVELOPMENT.md),真实 MySQL 证据见 [I-01B 验收](../08-delivery/08-i01b-acceptance.md)。本阶段迁移/初始化凭据只供离线管理 CLI;不代表 API/Worker 已获得业务数据库访问能力。 + +真实库核对发现 DDL 生成器遗漏了 user_roles.scope_key 的 NOT NULL;生成源与未发布的初始迁移快照已补齐,以保持原数据字典定义,不改变角色 scope 规则。迁移发布后不得修改历史快照;后续设计变更必须新增 revision,并更新当前 head 的核对基准。 diff --git a/docs/08-delivery/07-i01a-acceptance.md b/docs/08-delivery/07-i01a-acceptance.md index aebae41..7e8af11 100644 --- a/docs/08-delivery/07-i01a-acceptance.md +++ b/docs/08-delivery/07-i01a-acceptance.md @@ -40,6 +40,8 @@ ## 3. 明确未完成的后续阶段 +以下保留 2026-09-28 的历史边界;2026-09-29 后续实现状态见 [I-01B 验收](08-i01b-acceptance.md),不回写当时的验收结论。 + - I-01B/C:专用 MySQL、43 表 Alembic 迁移、仓储、会话、RBAC、幂等与租户事务。 - I-02/I-03:实际巡检命令、Outbox/inbox、Worker→AI→DB、租约/fencing/恢复。 - I-03:真实 spawn 计算子进程、硬超时 kill/join 与故障重建;I-01A仅有可取消的合成延迟。 diff --git a/docs/08-delivery/08-i01b-acceptance.md b/docs/08-delivery/08-i01b-acceptance.md new file mode 100644 index 0000000..ac524b2 --- /dev/null +++ b/docs/08-delivery/08-i01b-acceptance.md @@ -0,0 +1,71 @@ +# 08.8 I-01B 数据库基础验收记录 + +日期:2026-09-29。范围:初始迁移、租户/角色/初始管理员 CLI、真实 MySQL 结构与约束核对。 +本机验证通过不等于协作者批准、远程 CI 通过或生产可用。 + +## 1. 分支与前置依赖 + +本地分支 wsq/i-01b-database-foundation,继承 PR #5 的 I-01A 提交 e112680。 +PR #5 尚未完成评审时只进行依赖开发,不自动合并、推送 main 或修改该 PR。 +本次没有提交/推送 I-01B,也未创建新的 PR;远程 CI 尚未验证本次增量。 +后续按 CONTRIBUTING 整理仅 I-01B 的提交、重跑 CI、说明数据库/安全风险,并由两名协作者共同确认。 + +## 2. 已交付 + +| 交付项 | 代码入口与约束 | +|---|---| +| 初始 Alembic 迁移 | packages/persistence/migrations/versions/0001_initial.py;43 张业务表、115 个 FK,另有 Alembic 版本表 | +| 不可变迁移输入 | migrations/snapshots/0001_initial.sql/json;执行时不读取可变的 contracts;wheel 包含快照和 revision | +| 连接保护 | database.py;Python 3.11、显式 dev/test、同环境前缀 schema、秘密文件、回环地址、MySQL 8.x 且不低于 8.0.16 | +| 迁移保护 | 非空初始库/视图和 offline 模式拒绝;迁移持有 schema 级命名锁;downgrade 仅允许显式确认的可丢弃 test 库 | +| 初始化 | cli.py / bootstrap.py;租户、固定六角色、租户级 safety_admin、脱敏审计同事务提交;并发初始化串行化,重复租户不重置密码 | +| 密码与账号 | 交互 getpass 或秘密文件;密码 12–128 字符,不 trim/规范化;Argon2id m=65536 KiB、t=3、p=1、salt=16 bytes、hash=32 bytes;用户名 NFKC+casefold | +| 结构核对 | schema.py;表/列顺序、类型、nullability、默认值、生成列、PK/unique/index/FK/check、collation、UTC/strict/FK/unique session 设置 | +| 测试与 CI | tests/persistence、tools/database/run_mysql_tests.py;新增独立 MySQL CI job,不给 AI 安装数据库或密码散列依赖 | + +结构比较只规范化 MySQL 的表示差异,不忽略 NOT NULL、CHECK 优先级或 ENUM 字符串的大小写差异。 +六角色是 safety_admin、lab_manager、inspector、remediator、viewer、rule_expert;只为初始管理员创建 safety_admin 授权,不自动授予其他角色。 +初始化 CLI 不是登录、会话或密码重置实现。 + +## 3. 本机实际验证 + +Windows、Python 3.11.4、MySQL Community Server 8.0.33。数据库测试使用新的临时目录、随机回环端口、随机凭据与专用随机 schema。 +启动器使用 --no-defaults,并在任何写操作前核对该实例的 datadir/port;没有使用、注册、修改或停止已有 MySQL 服务。 +测试结束已删除本次临时 schema/data directory 并关闭本次子进程。 + +| 检查 | 实际结果 | +|---|---| +| I-01B 测试套件 | 47 项通过:26 项真实 MySQL 用例 + 21 项配置/安全/规范化/快照单元用例 | +| 迁移周期 | upgrade → 核对 → downgrade → re-upgrade → 再核对;43 表、115 FK、head=0001_initial、差异为空 | +| 保护反例 | 非空库 sentinel 保留;未确认 downgrade 拒绝且原结构不变;生产/远端连接/offline/错误 schema/缺密码/额外 URL 参数拒绝 | +| 数据约束 | 跨 tenant、错 item current 指针、重复 scope、owner 同空/同非空、重复 crop、错 run/parent/evidence、非法 attempt/state、受引用父项删除均拒绝;合法路径通过 | +| 拒绝错误码 | FK=1452;父记录 RESTRICT=1451;唯一键=1062;CHECK=3819;严格模式非法 ENUM=1265 | +| 结构校验反例 | 实际修改列长度、默认值、唯一索引、FK、CHECK enforcement、额外索引六类漂移均被发现,恢复后再次无差异 | +| 初始化事务 | 注入 audit 写入失败后所有记录回滚;并发同租户只生成一个管理员;重复调用不改变密码;密码/hash 不进入输出或审计 changes | +| I-01A 回归 | 6 项业务 + 4 项协议 + 15 项 AI 测试通过;协议在独立 AI 环境另行复验通过,不重复计数 | +| 环境与静态检查 | 两环境 pip check 通过;AI 无 SQLAlchemy/Alembic/PyMySQL/Argon2/Celery/Redis/模型依赖;Ruff check/format 通过(50 个 Python 文件) | +| 发布包 | 使用声明的隔离构建依赖构建 wheel,安装到新临时目录后可找到两份快照和 Alembic head,不依赖源码目录 | +| 设计检查 | build_specs --check、148 项 IRR 合成检查、validate_specs(含 41 发布/39 D-FINE 合成检查)通过 | +| CI 配置 | YAML 结构校验通过;新 MySQL job 待本次分支提交远程后执行,不能复用 PR #5 的成绩 | + +MySQL 8.0.33 只是本次本机/CI 兼容性测试基线,不是生产镜像或安全补丁选型结论。 +设计工具仍声明未运行应用/数据库测试,是该工具自身的证据范围;本页单独记录真实数据库结果,二者不能互相替代。 + +## 4. 本轮发现并关闭的差异 + +真实 introspection 发现设计字典要求 user_roles.scope_key 非空,但 DDL 生成器遗漏 NOT NULL。 +已修复 tools/design/data_model.py、重新生成 contracts/database-design.sql,并同步尚未发布的 0001_initial 快照;数据字典和角色规则没有变更。 +MySQL 8 的默认认证所需 RSA 依赖通过 PyMySQL[rsa] 补齐;Argon2id 和时区依赖仅加入业务侧。 + +## 5. 操作、回滚与后续边界 + +完整环境准备、upgrade/verify/bootstrap-tenant、秘密文件、退出码、失败处理和命令见 [开发指南](../../DEVELOPMENT.md) 第 3.5 节。 +正常代码回滚不授权删除业务库。首迁移 DDL 中途失败可能留下部分表;保留失败证据,仅在操作者确认的可丢弃库重建。 +downgrade 删除 43 张业务表,必须 APP_ENV=test 且 LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE 与 DATABASE_SCHEMA 完全一致;不是生产恢复方案。 +历史迁移发布后不得就地修改快照;新增 revision 并维护对应 head 的核对基准。 + +- I-01C:业务仓储、session/RBAC、幂等、租户事务、最后管理员保护及角色/实验室授权守卫。 +- I-02/I-03:跨资源/JSON 语义一致性、状态机、持久任务、旧租约 fencing 回写拒绝、Worker→AI→DB。 +- I-ML/I-05/R:真实 D-FINE-N/CUDA/图片评测、远端 TLS 与生产账户最小权限、备份恢复、部署和人工批准。 + +以上后续事项未在 I-01B 实现,不能用 FK 检查、schema 通过、开发 fixture 或既有 CI 结果代替验收。 diff --git a/docs/README.md b/docs/README.md index 550313d..5b1b9d7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,9 @@ ## 阅读顺序 +实现进度:I-01A 工程骨架与 I-01B 数据库基础已在特性分支实现;审批状态与本机测试不能互相替代。 +运行入口见 [开发指南](../DEVELOPMENT.md),本轮证据见 [I-01B 验收](08-delivery/08-i01b-acceptance.md)。 + 1. [文档控制](00-document-control.md):规范边界和变更要求。 2. [产品](01-requirements/01-product-requirements.md)与[范围](01-requirements/01-scope-and-acceptance.md)。 3. [编码入口](02-architecture/04-coding-baseline.md)→[状态机](02-architecture/02-domain-model.md)→[AI进程](02-architecture/03-ai-inference-process.md)→[持久任务](02-architecture/05-durable-jobs.md)→[数据库](02-architecture/06-data-persistence.md)。 diff --git a/packages/persistence/bootstrap.py b/packages/persistence/bootstrap.py new file mode 100644 index 0000000..d9dd3b2 --- /dev/null +++ b/packages/persistence/bootstrap.py @@ -0,0 +1,167 @@ +"""One-time tenant/admin creation; all records and audit commit atomically.""" + +import hashlib +import json +import re +import unicodedata +from dataclasses import dataclass +from uuid import NAMESPACE_URL, uuid4, uuid5 +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from argon2 import PasswordHasher, Type +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from packages.persistence.database import DatabaseSafetyError +from packages.persistence.schema import verify_schema + +ROLES = { + "safety_admin": "安全管理员", + "lab_manager": "实验室负责人", + "inspector": "巡检员", + "remediator": "整改员", + "viewer": "只读用户", + "rule_expert": "规则专家", +} +PASSWORD_HASHER = PasswordHasher( + time_cost=3, memory_cost=65536, parallelism=1, hash_len=32, salt_len=16, type=Type.ID +) + + +def normalized(value: str, limit: int, *, account: bool = False) -> str: + value = unicodedata.normalize("NFKC" if account else "NFC", value).strip() + if account: + value = value.casefold() + if not 1 <= len(value) <= limit or any(unicodedata.category(c).startswith("C") for c in value): + raise DatabaseSafetyError("Invalid text length or control characters in bootstrap input") + return value + + +@dataclass(frozen=True) +class TenantInput: + code: str + name: str + timezone: str + username: str + display_name: str + reason: str + + def validated(self): + code = normalized(self.code, 64) + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", code): + raise DatabaseSafetyError( + "Tenant code must use lowercase ASCII letters, digits, _ or -" + ) + timezone = normalized(self.timezone, 64) + try: + ZoneInfo(timezone) + except (ZoneInfoNotFoundError, ValueError): + raise DatabaseSafetyError("Unknown IANA timezone") from None + return TenantInput( + code, + normalized(self.name, 200), + timezone, + normalized(self.username, 128, account=True), + normalized(self.display_name, 100), + normalized(self.reason, 2000), + ) + + +def bootstrap_tenant(engine: Engine, inputs: TenantInput, password: str) -> dict[str, str]: + inputs = inputs.validated() + # Passwords are never normalized, stripped, truncated, logged or returned. + if not 12 <= len(password) <= 128: + raise DatabaseSafetyError("Password must contain 12 to 128 characters") + password_hash = PASSWORD_HASHER.hash(password) + tenant_id, user_id = str(uuid4()), str(uuid4()) + lock = hashlib.sha256(f"labsafe:bootstrap:{engine.url.database}".encode()).hexdigest() + with engine.connect() as connection: + if connection.scalar(text("SELECT GET_LOCK(:name, 10)"), {"name": lock}) != 1: + raise DatabaseSafetyError("Another bootstrap owns the database lock") + connection.commit() + try: + with connection.begin(): + if verify_schema(connection)["errors"]: + raise DatabaseSafetyError("Bootstrap requires verified migration 0001_initial") + if connection.scalar( + text("SELECT id FROM tenants WHERE code=:code"), {"code": inputs.code} + ): + raise DatabaseSafetyError( + "Tenant already exists; bootstrap never resets accounts" + ) + existing = dict(connection.execute(text("SELECT code,id FROM roles")).all()) + if set(existing) - set(ROLES): + raise DatabaseSafetyError( + "Unknown roles found; resolve schema/seed drift first" + ) + for code, name in ROLES.items(): + if code not in existing: + role_id = str(uuid5(NAMESPACE_URL, f"urn:labsafe:role:{code}")) + connection.execute( + text("INSERT INTO roles (id,code,name) VALUES (:id,:code,:name)"), + {"id": role_id, "code": code, "name": name}, + ) + existing[code] = role_id + connection.execute( + text( + "INSERT INTO tenants (id,code,name,timezone) " + "VALUES (:id,:code,:name,:timezone)" + ), + { + "id": tenant_id, + "code": inputs.code, + "name": inputs.name, + "timezone": inputs.timezone, + }, + ) + connection.execute( + text( + "INSERT INTO users (id,tenant_id,username,display_name,password_hash) " + "VALUES (:id,:tenant,:username,:display_name,:password_hash)" + ), + { + "id": user_id, + "tenant": tenant_id, + "username": inputs.username, + "display_name": inputs.display_name, + "password_hash": password_hash, + }, + ) + connection.execute( + text( + "INSERT INTO user_roles (id,tenant_id,user_id,role_id,scope_kind) " + "VALUES (:id,:tenant,:user,:role,'tenant')" + ), + { + "id": str(uuid4()), + "tenant": tenant_id, + "user": user_id, + "role": existing["safety_admin"], + }, + ) + connection.execute( + text( + "INSERT INTO audit_events " + "(id,tenant_id,actor_id,action,resource_type,resource_id," + "changes,reason,request_id) " + "VALUES (:id,:tenant,:actor,'tenant.bootstrap','tenant',:tenant," + ":changes,:reason,:request)" + ), + { + "id": str(uuid4()), + "tenant": tenant_id, + "actor": user_id, + "changes": json.dumps( + { + "initial_admin_id": user_id, + "role_codes": list(ROLES), + "scope_kind": "tenant", + } + ), + "reason": inputs.reason, + "request": str(uuid4()), + }, + ) + finally: + connection.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": lock}) + return {"tenant_id": tenant_id, "admin_id": user_id, "role": "safety_admin"} diff --git a/packages/persistence/cli.py b/packages/persistence/cli.py new file mode 100644 index 0000000..e98906a --- /dev/null +++ b/packages/persistence/cli.py @@ -0,0 +1,91 @@ +"""Development database CLI; connection/password secrets never appear in arguments.""" + +import argparse +import getpass +import json +import sys +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy.exc import SQLAlchemyError + +from packages.persistence.database import DatabaseSafetyError, database_engine +from packages.persistence.schema import verify_schema + + +def migration_config(): + config = Config() + config.set_main_option("script_location", str(Path(__file__).parent / "migrations")) + return config + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("upgrade", help="Apply head to a dedicated dev/test schema") + commands.add_parser("downgrade", help="Destroy tables only in explicitly confirmed test schema") + commands.add_parser("verify", help="Compare actual schema to frozen baseline; exit 1 on drift") + bootstrap = commands.add_parser("bootstrap-tenant", help="Create tenant, six roles and admin") + for field in ("code", "name", "timezone", "username", "display-name", "reason"): + bootstrap.add_argument("--" + field, required=True) + bootstrap.add_argument( + "--password-file", + type=Path, + help="Read exact UTF-8 password bytes; no stripping or normalization", + ) + arguments = parser.parse_args(argv) + engine = None + try: + if arguments.command in {"upgrade", "downgrade"}: + if arguments.command == "upgrade": + command.upgrade(migration_config(), "head") + else: + command.downgrade(migration_config(), "base") + print(json.dumps({"command": arguments.command, "status": "pass"})) + return 0 + engine = database_engine() + if arguments.command == "verify": + with engine.connect() as connection: + report = verify_schema(connection) + print(json.dumps(report, indent=2, ensure_ascii=False)) + return 1 if report["errors"] else 0 + from packages.persistence.bootstrap import TenantInput, bootstrap_tenant + + if arguments.password_file: + # read_bytes avoids newline translation; passwords must remain unchanged. + password = arguments.password_file.read_bytes().decode("utf-8") + elif sys.stdin.isatty(): + password = getpass.getpass("Initial administrator password: ") + if password != getpass.getpass("Confirm password: "): + raise DatabaseSafetyError("Password confirmation does not match") + else: + raise DatabaseSafetyError("Interactive terminal or --password-file is required") + inputs = TenantInput( + arguments.code, + arguments.name, + arguments.timezone, + arguments.username, + arguments.display_name, + arguments.reason, + ) + print(json.dumps(bootstrap_tenant(engine, inputs, password))) + return 0 + except DatabaseSafetyError as error: + print(f"Database safety check failed: {error}", file=sys.stderr) + return 2 + except (SQLAlchemyError, OSError, ValueError, RuntimeError): + # Driver exceptions may carry connection credentials or bound values. + print( + "Database command failed. Check configuration/schema and private server logs; " + "no partial DDL recovery or automatic account reset was attempted.", + file=sys.stderr, + ) + return 1 + finally: + if engine is not None: + engine.dispose() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/persistence/database.py b/packages/persistence/database.py new file mode 100644 index 0000000..5913929 --- /dev/null +++ b/packages/persistence/database.py @@ -0,0 +1,75 @@ +"""Explicit, secret-file based access to disposable I-01B databases only.""" + +import os +import re +from pathlib import Path + +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine, make_url +from sqlalchemy.exc import ArgumentError +from sqlalchemy.pool import NullPool + +from packages.shared.environment import development_environment + +STRICT_SQL_MODE = ( + "STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO," + "NO_ENGINE_SUBSTITUTION,ONLY_FULL_GROUP_BY" +) + + +class DatabaseSafetyError(ValueError): + """Safe-to-display configuration error; never include the connection URL.""" + + +def database_engine() -> Engine: + environment = development_environment() + secret = os.environ.get("DATABASE_URL_FILE") + schema = os.environ.get("DATABASE_SCHEMA", "") + if not secret or not re.fullmatch(rf"labsafe_{environment}_[a-z0-9_]+", schema): + raise DatabaseSafetyError( + "Set DATABASE_URL_FILE and DATABASE_SCHEMA=labsafe__" + ) + if len(schema) > 64: + raise DatabaseSafetyError("DATABASE_SCHEMA must not exceed 64 characters") + try: + raw = Path(secret).read_text(encoding="utf-8").strip() + url = make_url(raw) + except (OSError, ValueError, UnicodeError, ArgumentError): + raise DatabaseSafetyError("Cannot read a valid database URL from secret file") from None + if ( + url.drivername != "mysql+pymysql" + or url.database != schema + or not url.username + or not url.password + or url.host not in {"localhost", "127.0.0.1", "::1"} + or url.query + ): + raise DatabaseSafetyError( + "Require authenticated loopback mysql+pymysql URL, confirmed schema, no query options" + ) + engine = create_engine( + url, + poolclass=NullPool, + hide_parameters=True, + connect_args={"charset": "utf8mb4", "connect_timeout": 10}, + ) + + @event.listens_for(engine, "connect") + def configure_session(connection, _record): + with connection.cursor() as cursor: + cursor.execute("SET SESSION time_zone = '+00:00'") + cursor.execute("SET SESSION sql_mode = %s", (STRICT_SQL_MODE,)) + cursor.execute("SET SESSION foreign_key_checks = 1") + cursor.execute("SET SESSION unique_checks = 1") + cursor.execute("SELECT VERSION()") + version = cursor.fetchone()[0] + match = re.match(r"(\d+)\.(\d+)\.(\d+)", version) + if ( + "mariadb" in version.lower() + or not match + or tuple(map(int, match.groups())) < (8, 0, 16) + or int(match.group(1)) != 8 + ): + raise DatabaseSafetyError("I-01B requires MySQL 8.x, version >= 8.0.16") + + return engine diff --git a/packages/persistence/migrations/__init__.py b/packages/persistence/migrations/__init__.py new file mode 100644 index 0000000..de96aed --- /dev/null +++ b/packages/persistence/migrations/__init__.py @@ -0,0 +1 @@ +"""Immutable migration revisions and their packaged schema snapshots.""" diff --git a/packages/persistence/migrations/env.py b/packages/persistence/migrations/env.py new file mode 100644 index 0000000..a0bd5ce --- /dev/null +++ b/packages/persistence/migrations/env.py @@ -0,0 +1,26 @@ +"""Online-only migration environment with a schema-specific advisory lock.""" + +import hashlib + +from alembic import context +from sqlalchemy import text + +from packages.persistence.database import database_engine + +if context.is_offline_mode(): + raise RuntimeError("Offline migration is disabled; use an explicit disposable database") + +engine = database_engine() +with engine.connect() as connection: + lock = hashlib.sha256(f"labsafe:migrate:{engine.url.database}".encode()).hexdigest() + if connection.scalar(text("SELECT GET_LOCK(:name, 10)"), {"name": lock}) != 1: + raise RuntimeError("Another migration owns the database lock") + connection.commit() + try: + context.configure(connection=connection, target_metadata=None) + with context.begin_transaction(): + context.run_migrations() + connection.commit() + finally: + connection.execute(text("SELECT RELEASE_LOCK(:name)"), {"name": lock}) +engine.dispose() diff --git a/packages/persistence/migrations/snapshots/0001_initial.json b/packages/persistence/migrations/snapshots/0001_initial.json new file mode 100644 index 0000000..de6f9d8 --- /dev/null +++ b/packages/persistence/migrations/snapshots/0001_initial.json @@ -0,0 +1,6108 @@ +{ + "version": "1.1.0", + "engine": "MySQL 8.0.16+ / InnoDB", + "timezone": "UTC", + "tables": { + "tenants": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "timezone": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','suspended')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "code" + ] + ], + "indexes": [], + "foreign_keys": [], + "checks": [], + "append_only": false + }, + "roles": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(40)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "code" + ] + ], + "indexes": [], + "foreign_keys": [], + "checks": [], + "append_only": false + }, + "colleges": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','archived')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "code" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "laboratories": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "college_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','archived')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "code" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "college_id" + ], + "target": "colleges", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "users": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "username": { + "type": "VARCHAR(128)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "display_name": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "password_hash": { + "type": "VARCHAR(255)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','disabled')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + }, + "session_epoch": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "username" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "user_roles": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "user_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "role_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "scope_kind": { + "type": "ENUM('tenant','laboratory')", + "nullable": false, + "default_sql": "'tenant'", + "description": "", + "generated_sql": null + }, + "scope_key": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": "COALESCE(laboratory_id,'00000000-0000-0000-0000-000000000000')" + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "user_id", + "role_id", + "scope_key" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "user_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "role_id" + ], + "target": "roles", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [ + "(scope_kind='tenant' AND laboratory_id IS NULL) OR (scope_kind='laboratory' AND laboratory_id IS NOT NULL)" + ], + "append_only": false + }, + "sessions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "user_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "token_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "csrf_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "session_epoch": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "expires_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "idle_expires_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "revoked_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "token_hash" + ] + ], + "indexes": [ + [ + "expires_at" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "user_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "locations": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "parent_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "type": { + "type": "ENUM('room','area','shelf','cabinet')", + "nullable": false, + "default_sql": "'room'", + "description": "", + "generated_sql": null + }, + "label": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('active','archived')", + "nullable": false, + "default_sql": "'active'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "laboratory_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "parent_id" + ], + "target": "locations", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "inspection_templates": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "family_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','published','retired')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "family_id", + "revision" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "template_items": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "template_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "code": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "title": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "capture_hint": { + "type": "VARCHAR(1000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "sort_order": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "required": { + "type": "BOOLEAN", + "nullable": false, + "default_sql": "TRUE", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "template_id", + "code" + ], + [ + "tenant_id", + "template_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "template_id" + ], + "target": "inspection_templates", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "inspections": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "template_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "inspector_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','in_progress','completed','cancelled')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + }, + "completed_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "laboratory_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "template_id" + ], + "target": "inspection_templates", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "inspector_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "inspection_items": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "inspection_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "template_item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "location_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','uploaded','queued','quality_checking','needs_retake','processing','needs_review','completed','failed')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + }, + "submission_revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "current_run_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "current_fact_revision_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "current_evaluation_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "review_outcome": { + "type": "ENUM('no_issue','issues_confirmed','cannot_determine')", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reviewed_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reviewed_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "laboratory_id", + "id" + ], + [ + "tenant_id", + "inspection_id", + "template_item_id", + "location_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "inspection_id" + ], + "target": "inspections", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "template_item_id" + ], + "target": "template_items", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "location_id" + ], + "target": "locations", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "reviewed_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "id", + "current_run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "id", + "current_fact_revision_id" + ], + "target": "fact_revisions", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "id", + "current_evaluation_id" + ], + "target": "rule_evaluations", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "dictionary_versions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "version_label": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "object_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "source": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','published','retired')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "version_label" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "chemical_entities": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "canonical_name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "cas_number": { + "type": "VARCHAR(32)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "hazard_class": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "storage_class": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "dictionary_version_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id" + ], + "target": "dictionary_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "chemical_aliases": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "entity_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "normalized_alias": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "dictionary_version_id", + "entity_id", + "normalized_alias" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id", + "entity_id" + ], + "target": "chemical_entities", + "references": [ + "tenant_id", + "dictionary_version_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "model_versions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "bundle_version": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "manifest": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('registered','validated','published','retired')", + "nullable": false, + "default_sql": "'registered'", + "description": "", + "generated_sql": null + }, + "evaluation_report_key": { + "type": "VARCHAR(1024)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "bundle_version" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id" + ], + "target": "dictionary_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "rule_sets": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "description": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "name" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "rule_versions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_set_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "definition": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','submitted','approved','published','retired')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + }, + "submitted_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "approved_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "approved_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "rule_set_id", + "revision" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_set_id" + ], + "target": "rule_sets", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "submitted_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "approved_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "rule_bundles": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "evaluator_version": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "checksum" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "rule_bundle_members": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "bundle_id", + "rule_version_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "bundle_id" + ], + "target": "rule_bundles", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_version_id" + ], + "target": "rule_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "activations": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "model_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "pipeline_version": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "device_profile": { + "type": "ENUM('cpu','cuda')", + "nullable": false, + "default_sql": "'cpu'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "laboratory_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "model_bundle_id" + ], + "target": "model_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id" + ], + "target": "dictionary_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_bundle_id" + ], + "target": "rule_bundles", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "inference_runs": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "submission_revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "replay_of": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "input_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "model_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dictionary_version_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "pipeline_version": { + "type": "VARCHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "device_profile": { + "type": "ENUM('cpu','cuda')", + "nullable": false, + "default_sql": "'cpu'", + "description": "", + "generated_sql": null + }, + "reference_date": { + "type": "DATE", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('queued','processing','retrying','completed','needs_retake','needs_review','failed','superseded')", + "nullable": false, + "default_sql": "'queued'", + "description": "", + "generated_sql": null + }, + "stage": { + "type": "ENUM('queued','quality','facts','rules','done')", + "nullable": false, + "default_sql": "'queued'", + "description": "", + "generated_sql": null + }, + "result": { + "type": "JSON", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "result_hash": { + "type": "CHAR(64)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "error_code": { + "type": "VARCHAR(80)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "started_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finished_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "item_id", + "id" + ], + [ + "tenant_id", + "item_id", + "submission_revision" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "item_id" + ], + "target": "inspection_items", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "replay_of" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "model_bundle_id" + ], + "target": "model_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dictionary_version_id" + ], + "target": "dictionary_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_bundle_id" + ], + "target": "rule_bundles", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "fact_revisions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "revision": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "entities": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "relations": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dates": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reason": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "item_id", + "id" + ], + [ + "tenant_id", + "run_id", + "revision" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "created_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "rule_evaluations": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "fact_revision_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_bundle_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reference_date": { + "type": "DATE", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('queued','running','completed','failed','superseded')", + "nullable": false, + "default_sql": "'queued'", + "description": "", + "generated_sql": null + }, + "result": { + "type": "JSON", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "result_hash": { + "type": "CHAR(64)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finished_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "item_id", + "id" + ], + [ + "tenant_id", + "fact_revision_id", + "rule_bundle_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "fact_revision_id" + ], + "target": "fact_revisions", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "rule_bundle_id" + ], + "target": "rule_bundles", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "findings": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "fact_revision_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_evaluation_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "rule_id": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "fingerprint": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "severity": { + "type": "ENUM('low','medium','high','critical')", + "nullable": false, + "default_sql": "'low'", + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('needs_review','confirmed','rejected','cannot_determine','dispatched','closed')", + "nullable": false, + "default_sql": "'needs_review'", + "description": "", + "generated_sql": null + }, + "explanation": { + "type": "VARCHAR(4000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "evidence": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "superseded_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "confirmed_by": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "rule_evaluation_id", + "fingerprint" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "fact_revision_id" + ], + "target": "fact_revisions", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "rule_evaluation_id" + ], + "target": "rule_evaluations", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "confirmed_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "review_actions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "actor_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "action": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reason": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "before_version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "after_version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "actor_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "remediation_tasks": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finding_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "assignee_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('pending_dispatch','in_progress','pending_recheck','rejected','cannot_remediate','closed')", + "nullable": false, + "default_sql": "'pending_dispatch'", + "description": "", + "generated_sql": null + }, + "priority": { + "type": "ENUM('low','medium','high','critical')", + "nullable": false, + "default_sql": "'low'", + "description": "", + "generated_sql": null + }, + "due_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "description": { + "type": "VARCHAR(4000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "latest_evidence_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "closed_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "finding_id" + ], + [ + "tenant_id", + "laboratory_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "finding_id" + ], + "target": "findings", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "assignee_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "id", + "latest_evidence_id" + ], + "target": "remediation_evidence", + "references": [ + "tenant_id", + "task_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "remediation_evidence": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "task_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "description": { + "type": "VARCHAR(4000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "submitted_by": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "task_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "task_id" + ], + "target": "remediation_tasks", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "submitted_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "uploads": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "inspection_item_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "remediation_task_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "requested_by": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "object_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "expected_sha256": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "mime_type": { + "type": "VARCHAR(40)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "size_bytes": { + "type": "BIGINT UNSIGNED", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "captured_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "expires_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('granted','validating','ready','rejected','expired')", + "nullable": false, + "default_sql": "'granted'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "requested_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "inspection_item_id" + ], + "target": "inspection_items", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "remediation_task_id" + ], + "target": "remediation_tasks", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [ + "(inspection_item_id IS NULL) <> (remediation_task_id IS NULL)" + ], + "append_only": false + }, + "asset_images": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "laboratory_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "inspection_item_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "remediation_task_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "upload_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('validating','ready','rejected','deleted')", + "nullable": false, + "default_sql": "'validating'", + "description": "", + "generated_sql": null + }, + "original_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "original_object_version": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "original_sha256": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "analysis_key": { + "type": "VARCHAR(1024)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "analysis_object_version": { + "type": "VARCHAR(200)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "analysis_sha256": { + "type": "CHAR(64)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "width": { + "type": "INT UNSIGNED", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "height": { + "type": "INT UNSIGNED", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "mime_type": { + "type": "VARCHAR(40)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "captured_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "legal_hold": { + "type": "BOOLEAN", + "nullable": false, + "default_sql": "FALSE", + "description": "", + "generated_sql": null + }, + "deleted_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "upload_id" + ], + [ + "tenant_id", + "inspection_item_id", + "id" + ], + [ + "tenant_id", + "remediation_task_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "upload_id" + ], + "target": "uploads", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id" + ], + "target": "laboratories", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "inspection_item_id" + ], + "target": "inspection_items", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "laboratory_id", + "remediation_task_id" + ], + "target": "remediation_tasks", + "references": [ + "tenant_id", + "laboratory_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [ + "(inspection_item_id IS NULL) <> (remediation_task_id IS NULL)" + ], + "append_only": false + }, + "evidence_images": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "task_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "evidence_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "image_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "ordinal": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "evidence_id", + "image_id" + ], + [ + "tenant_id", + "evidence_id", + "ordinal" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "task_id", + "evidence_id" + ], + "target": "remediation_evidence", + "references": [ + "tenant_id", + "task_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "task_id", + "image_id" + ], + "target": "asset_images", + "references": [ + "tenant_id", + "remediation_task_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "run_images": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "item_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "image_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "ordinal": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "role": { + "type": "ENUM('overview','detail')", + "nullable": false, + "default_sql": "'overview'", + "description": "", + "generated_sql": null + }, + "parent_image_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "analysis_sha256": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "run_id", + "image_id" + ], + [ + "tenant_id", + "run_id", + "ordinal" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "run_id" + ], + "target": "inference_runs", + "references": [ + "tenant_id", + "item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "item_id", + "image_id" + ], + "target": "asset_images", + "references": [ + "tenant_id", + "inspection_item_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "run_id", + "parent_image_id" + ], + "target": "run_images", + "references": [ + "tenant_id", + "run_id", + "image_id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "image_derivatives": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "run_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "image_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "crop_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "detection_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "kind": { + "type": "ENUM('crop')", + "nullable": false, + "default_sql": "'crop'", + "description": "", + "generated_sql": null + }, + "recipe": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "object_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "object_version": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "sha256": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "run_id", + "crop_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "run_id", + "image_id" + ], + "target": "run_images", + "references": [ + "tenant_id", + "run_id", + "image_id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "api_idempotency": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "actor_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "method": { + "type": "VARCHAR(10)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "path_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "key_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "request_hash": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "state": { + "type": "ENUM('pending','completed')", + "nullable": false, + "default_sql": "'pending'", + "description": "", + "generated_sql": null + }, + "response_status": { + "type": "SMALLINT UNSIGNED", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "response": { + "type": "JSON", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_owner": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_until": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "expires_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "actor_id", + "method", + "path_hash", + "key_hash" + ] + ], + "indexes": [ + [ + "expires_at" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "actor_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "outbox_events": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "event_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "aggregate_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "aggregate_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "aggregate_version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "payload": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "state": { + "type": "ENUM('pending','leased','published')", + "nullable": false, + "default_sql": "'pending'", + "description": "", + "generated_sql": null + }, + "lease_owner": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_until": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "attempts": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "available_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "published_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [ + [ + "state", + "available_at", + "id" + ], + [ + "state", + "lease_until" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "event_inbox": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "event_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "consumer": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "processed_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "event_id", + "consumer" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "event_id" + ], + "target": "outbox_events", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "task_runs": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "task_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "logical_key": { + "type": "VARCHAR(200)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "payload": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "state": { + "type": "ENUM('ready','leased','retry_wait','succeeded','failed','dead_letter')", + "nullable": false, + "default_sql": "'ready'", + "description": "", + "generated_sql": null + }, + "attempt": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "replay_generation": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "dispatch_sequence": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "lease_owner": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_until": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "fencing_token": { + "type": "BIGINT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "heartbeat_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "available_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "last_dispatched_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "last_error_code": { + "type": "VARCHAR(80)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "started_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finished_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "logical_key" + ] + ], + "indexes": [ + [ + "state", + "available_at", + "id" + ], + [ + "state", + "lease_until" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [ + "attempt <= 4" + ], + "append_only": false + }, + "task_attempts": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "task_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "replay_generation": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "0", + "description": "", + "generated_sql": null + }, + "attempt": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "fencing_token": { + "type": "BIGINT UNSIGNED", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "lease_owner": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "started_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "finished_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('running','succeeded','failed','abandoned')", + "nullable": false, + "default_sql": "'running'", + "description": "", + "generated_sql": null + }, + "error_code": { + "type": "VARCHAR(80)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "task_id", + "replay_generation", + "attempt" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "task_id" + ], + "target": "task_runs", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "notifications": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "event_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "recipient_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "read_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "event_id", + "recipient_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "event_id" + ], + "target": "outbox_events", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "recipient_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "audit_events": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "actor_id": { + "type": "CHAR(36)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "action": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_type": { + "type": "VARCHAR(80)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "resource_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "changes": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "reason": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "request_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [ + [ + "tenant_id", + "resource_type", + "resource_id", + "created_at" + ] + ], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "actor_id" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "report_exports": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "requested_by": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "format": { + "type": "ENUM('csv','pdf')", + "nullable": false, + "default_sql": "'csv'", + "description": "", + "generated_sql": null + }, + "filters": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "snapshot_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "snapshot": { + "type": "JSON", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('queued','running','ready','failed','expired')", + "nullable": false, + "default_sql": "'queued'", + "description": "", + "generated_sql": null + }, + "object_key": { + "type": "VARCHAR(1024)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "expires_at": { + "type": "DATETIME(3)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "last_error_code": { + "type": "VARCHAR(80)", + "nullable": true, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "requested_by" + ], + "target": "users", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": false + }, + "dataset_versions": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "name": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "checksum": { + "type": "CHAR(64)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "manifest_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "authorization_ref": { + "type": "VARCHAR(1000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "status": { + "type": "ENUM('draft','frozen','retired')", + "nullable": false, + "default_sql": "'draft'", + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "name" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + }, + "dataset_images": { + "columns": { + "id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "created_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "updated_at": { + "type": "DATETIME(3)", + "nullable": false, + "default_sql": "CURRENT_TIMESTAMP(3)", + "description": "", + "generated_sql": null + }, + "version": { + "type": "INT UNSIGNED", + "nullable": false, + "default_sql": "1", + "description": "", + "generated_sql": null + }, + "tenant_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "dataset_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "image_id": { + "type": "CHAR(36)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "scene_id": { + "type": "VARCHAR(100)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "split": { + "type": "ENUM('train','validation','test')", + "nullable": false, + "default_sql": "'train'", + "description": "", + "generated_sql": null + }, + "annotation_key": { + "type": "VARCHAR(1024)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + }, + "provenance": { + "type": "VARCHAR(2000)", + "nullable": false, + "default_sql": null, + "description": "", + "generated_sql": null + } + }, + "primary_key": [ + "id" + ], + "unique": [ + [ + "tenant_id", + "id" + ], + [ + "tenant_id", + "dataset_id", + "image_id" + ] + ], + "indexes": [], + "foreign_keys": [ + { + "columns": [ + "tenant_id" + ], + "target": "tenants", + "references": [ + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "dataset_id" + ], + "target": "dataset_versions", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + }, + { + "columns": [ + "tenant_id", + "image_id" + ], + "target": "asset_images", + "references": [ + "tenant_id", + "id" + ], + "on_delete": "RESTRICT", + "on_update": "RESTRICT" + } + ], + "checks": [], + "append_only": true + } + } +} diff --git a/packages/persistence/migrations/snapshots/0001_initial.sql b/packages/persistence/migrations/snapshots/0001_initial.sql new file mode 100644 index 0000000..0f5c257 --- /dev/null +++ b/packages/persistence/migrations/snapshots/0001_initial.sql @@ -0,0 +1,1022 @@ +-- Design-only MySQL 8.0.16+ / InnoDB DDL. Run in a new empty schema. + +-- Application transactions enforce documented cross-resource and JSON invariants. + +SET time_zone = '+00:00'; + +CREATE TABLE `tenants` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `timezone` VARCHAR(64) NOT NULL, + `status` ENUM('active','suspended') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_tenants_0` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `roles` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `code` VARCHAR(40) NOT NULL, + `name` VARCHAR(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_roles_0` (`code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `colleges` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `status` ENUM('active','archived') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_colleges_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_colleges_1` (`tenant_id`, `code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `laboratories` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `college_id` CHAR(36) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `status` ENUM('active','archived') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_laboratories_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_laboratories_1` (`tenant_id`, `code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `users` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `username` VARCHAR(128) NOT NULL, + `display_name` VARCHAR(100) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `status` ENUM('active','disabled') NOT NULL DEFAULT 'active', + `session_epoch` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_users_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_users_1` (`tenant_id`, `username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `user_roles` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `user_id` CHAR(36) NOT NULL, + `role_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NULL, + `scope_kind` ENUM('tenant','laboratory') NOT NULL DEFAULT 'tenant', + `scope_key` CHAR(36) GENERATED ALWAYS AS (COALESCE(laboratory_id,'00000000-0000-0000-0000-000000000000')) STORED NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user_roles_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_user_roles_1` (`tenant_id`, `user_id`, `role_id`, `scope_key`), + CONSTRAINT `ck_user_roles_0` CHECK ((scope_kind='tenant' AND laboratory_id IS NULL) OR (scope_kind='laboratory' AND laboratory_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `sessions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `user_id` CHAR(36) NOT NULL, + `token_hash` CHAR(64) NOT NULL, + `csrf_hash` CHAR(64) NOT NULL, + `session_epoch` INT UNSIGNED NOT NULL DEFAULT 0, + `expires_at` DATETIME(3) NOT NULL, + `idle_expires_at` DATETIME(3) NOT NULL, + `revoked_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_sessions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_sessions_1` (`token_hash`), + KEY `ix_sessions_0` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `locations` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `parent_id` CHAR(36) NULL, + `type` ENUM('room','area','shelf','cabinet') NOT NULL DEFAULT 'room', + `label` VARCHAR(200) NOT NULL, + `status` ENUM('active','archived') NOT NULL DEFAULT 'active', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_locations_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_locations_1` (`tenant_id`, `laboratory_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `inspection_templates` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `family_id` CHAR(36) NOT NULL, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `name` VARCHAR(200) NOT NULL, + `status` ENUM('draft','published','retired') NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_inspection_templates_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_inspection_templates_1` (`tenant_id`, `family_id`, `revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `template_items` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `template_id` CHAR(36) NOT NULL, + `code` VARCHAR(64) NOT NULL, + `title` VARCHAR(200) NOT NULL, + `capture_hint` VARCHAR(1000) NOT NULL, + `sort_order` INT UNSIGNED NOT NULL DEFAULT 0, + `required` BOOLEAN NOT NULL DEFAULT TRUE, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_template_items_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_template_items_1` (`tenant_id`, `template_id`, `code`), + UNIQUE KEY `uq_template_items_2` (`tenant_id`, `template_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `inspections` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `template_id` CHAR(36) NOT NULL, + `inspector_id` CHAR(36) NOT NULL, + `status` ENUM('draft','in_progress','completed','cancelled') NOT NULL DEFAULT 'draft', + `completed_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_inspections_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_inspections_1` (`tenant_id`, `laboratory_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `inspection_items` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `inspection_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `template_item_id` CHAR(36) NOT NULL, + `location_id` CHAR(36) NOT NULL, + `status` ENUM('draft','uploaded','queued','quality_checking','needs_retake','processing','needs_review','completed','failed') NOT NULL DEFAULT 'draft', + `submission_revision` INT UNSIGNED NOT NULL DEFAULT 0, + `current_run_id` CHAR(36) NULL, + `current_fact_revision_id` CHAR(36) NULL, + `current_evaluation_id` CHAR(36) NULL, + `review_outcome` ENUM('no_issue','issues_confirmed','cannot_determine') NULL, + `reviewed_by` CHAR(36) NULL, + `reviewed_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_inspection_items_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_inspection_items_1` (`tenant_id`, `laboratory_id`, `id`), + UNIQUE KEY `uq_inspection_items_2` (`tenant_id`, `inspection_id`, `template_item_id`, `location_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `dictionary_versions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `version_label` VARCHAR(64) NOT NULL, + `checksum` CHAR(64) NOT NULL, + `object_key` VARCHAR(1024) NOT NULL, + `source` VARCHAR(2000) NOT NULL, + `status` ENUM('draft','published','retired') NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_dictionary_versions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_dictionary_versions_1` (`tenant_id`, `version_label`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `chemical_entities` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `canonical_name` VARCHAR(200) NOT NULL, + `cas_number` VARCHAR(32) NULL, + `hazard_class` VARCHAR(80) NOT NULL, + `storage_class` VARCHAR(80) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_chemical_entities_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_chemical_entities_1` (`tenant_id`, `dictionary_version_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `chemical_aliases` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `entity_id` CHAR(36) NOT NULL, + `normalized_alias` VARCHAR(200) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_chemical_aliases_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_chemical_aliases_1` (`tenant_id`, `dictionary_version_id`, `entity_id`, `normalized_alias`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `model_versions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `bundle_version` VARCHAR(64) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `checksum` CHAR(64) NOT NULL, + `manifest` JSON NOT NULL, + `status` ENUM('registered','validated','published','retired') NOT NULL DEFAULT 'registered', + `evaluation_report_key` VARCHAR(1024) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_model_versions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_model_versions_1` (`tenant_id`, `bundle_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_sets` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `description` VARCHAR(2000) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_sets_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_sets_1` (`tenant_id`, `name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_versions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `rule_set_id` CHAR(36) NOT NULL, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `checksum` CHAR(64) NOT NULL, + `definition` JSON NOT NULL, + `status` ENUM('draft','submitted','approved','published','retired') NOT NULL DEFAULT 'draft', + `submitted_by` CHAR(36) NULL, + `approved_by` CHAR(36) NULL, + `approved_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_versions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_versions_1` (`tenant_id`, `rule_set_id`, `revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_bundles` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `checksum` CHAR(64) NOT NULL, + `evaluator_version` VARCHAR(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_bundles_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_bundles_1` (`tenant_id`, `checksum`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_bundle_members` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `bundle_id` CHAR(36) NOT NULL, + `rule_version_id` CHAR(36) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_bundle_members_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_bundle_members_1` (`tenant_id`, `bundle_id`, `rule_version_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `activations` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `model_bundle_id` CHAR(36) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `rule_bundle_id` CHAR(36) NOT NULL, + `pipeline_version` VARCHAR(64) NOT NULL, + `device_profile` ENUM('cpu','cuda') NOT NULL DEFAULT 'cpu', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_activations_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_activations_1` (`tenant_id`, `laboratory_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `inference_runs` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `submission_revision` INT UNSIGNED NOT NULL DEFAULT 1, + `replay_of` CHAR(36) NULL, + `input_hash` CHAR(64) NOT NULL, + `model_bundle_id` CHAR(36) NOT NULL, + `dictionary_version_id` CHAR(36) NOT NULL, + `rule_bundle_id` CHAR(36) NOT NULL, + `pipeline_version` VARCHAR(64) NOT NULL, + `device_profile` ENUM('cpu','cuda') NOT NULL DEFAULT 'cpu', + `reference_date` DATE NOT NULL, + `status` ENUM('queued','processing','retrying','completed','needs_retake','needs_review','failed','superseded') NOT NULL DEFAULT 'queued', + `stage` ENUM('queued','quality','facts','rules','done') NOT NULL DEFAULT 'queued', + `result` JSON NULL, + `result_hash` CHAR(64) NULL, + `error_code` VARCHAR(80) NULL, + `started_at` DATETIME(3) NULL, + `finished_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_inference_runs_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_inference_runs_1` (`tenant_id`, `item_id`, `id`), + UNIQUE KEY `uq_inference_runs_2` (`tenant_id`, `item_id`, `submission_revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `fact_revisions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `revision` INT UNSIGNED NOT NULL DEFAULT 1, + `entities` JSON NOT NULL, + `relations` JSON NOT NULL, + `dates` JSON NOT NULL, + `reason` VARCHAR(2000) NOT NULL, + `created_by` CHAR(36) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_fact_revisions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_fact_revisions_1` (`tenant_id`, `item_id`, `id`), + UNIQUE KEY `uq_fact_revisions_2` (`tenant_id`, `run_id`, `revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `rule_evaluations` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `fact_revision_id` CHAR(36) NOT NULL, + `rule_bundle_id` CHAR(36) NOT NULL, + `reference_date` DATE NOT NULL, + `status` ENUM('queued','running','completed','failed','superseded') NOT NULL DEFAULT 'queued', + `result` JSON NULL, + `result_hash` CHAR(64) NULL, + `finished_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_rule_evaluations_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_rule_evaluations_1` (`tenant_id`, `item_id`, `id`), + UNIQUE KEY `uq_rule_evaluations_2` (`tenant_id`, `fact_revision_id`, `rule_bundle_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `findings` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `fact_revision_id` CHAR(36) NOT NULL, + `rule_evaluation_id` CHAR(36) NOT NULL, + `rule_id` VARCHAR(80) NOT NULL, + `fingerprint` CHAR(64) NOT NULL, + `type` VARCHAR(80) NOT NULL, + `severity` ENUM('low','medium','high','critical') NOT NULL DEFAULT 'low', + `status` ENUM('needs_review','confirmed','rejected','cannot_determine','dispatched','closed') NOT NULL DEFAULT 'needs_review', + `explanation` VARCHAR(4000) NOT NULL, + `evidence` JSON NOT NULL, + `superseded_at` DATETIME(3) NULL, + `confirmed_by` CHAR(36) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_findings_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_findings_1` (`tenant_id`, `rule_evaluation_id`, `fingerprint`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `review_actions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `actor_id` CHAR(36) NOT NULL, + `resource_type` VARCHAR(80) NOT NULL, + `resource_id` CHAR(36) NOT NULL, + `action` VARCHAR(80) NOT NULL, + `reason` VARCHAR(2000) NOT NULL, + `before_version` INT UNSIGNED NOT NULL DEFAULT 1, + `after_version` INT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_review_actions_0` (`tenant_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `remediation_tasks` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `finding_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `assignee_id` CHAR(36) NOT NULL, + `status` ENUM('pending_dispatch','in_progress','pending_recheck','rejected','cannot_remediate','closed') NOT NULL DEFAULT 'pending_dispatch', + `priority` ENUM('low','medium','high','critical') NOT NULL DEFAULT 'low', + `due_at` DATETIME(3) NOT NULL, + `description` VARCHAR(4000) NOT NULL, + `latest_evidence_id` CHAR(36) NULL, + `closed_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_remediation_tasks_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_remediation_tasks_1` (`tenant_id`, `finding_id`), + UNIQUE KEY `uq_remediation_tasks_2` (`tenant_id`, `laboratory_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `remediation_evidence` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `task_id` CHAR(36) NOT NULL, + `description` VARCHAR(4000) NOT NULL, + `submitted_by` CHAR(36) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_remediation_evidence_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_remediation_evidence_1` (`tenant_id`, `task_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `uploads` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `inspection_item_id` CHAR(36) NULL, + `remediation_task_id` CHAR(36) NULL, + `requested_by` CHAR(36) NOT NULL, + `object_key` VARCHAR(1024) NOT NULL, + `expected_sha256` CHAR(64) NOT NULL, + `mime_type` VARCHAR(40) NOT NULL, + `size_bytes` BIGINT UNSIGNED NOT NULL, + `captured_at` DATETIME(3) NOT NULL, + `expires_at` DATETIME(3) NOT NULL, + `status` ENUM('granted','validating','ready','rejected','expired') NOT NULL DEFAULT 'granted', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_uploads_0` (`tenant_id`, `id`), + CONSTRAINT `ck_uploads_0` CHECK ((inspection_item_id IS NULL) <> (remediation_task_id IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `asset_images` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `laboratory_id` CHAR(36) NOT NULL, + `inspection_item_id` CHAR(36) NULL, + `remediation_task_id` CHAR(36) NULL, + `upload_id` CHAR(36) NOT NULL, + `status` ENUM('validating','ready','rejected','deleted') NOT NULL DEFAULT 'validating', + `original_key` VARCHAR(1024) NOT NULL, + `original_object_version` VARCHAR(200) NOT NULL, + `original_sha256` CHAR(64) NOT NULL, + `analysis_key` VARCHAR(1024) NULL, + `analysis_object_version` VARCHAR(200) NULL, + `analysis_sha256` CHAR(64) NULL, + `width` INT UNSIGNED NULL, + `height` INT UNSIGNED NULL, + `mime_type` VARCHAR(40) NOT NULL, + `captured_at` DATETIME(3) NOT NULL, + `legal_hold` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_asset_images_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_asset_images_1` (`tenant_id`, `upload_id`), + UNIQUE KEY `uq_asset_images_2` (`tenant_id`, `inspection_item_id`, `id`), + UNIQUE KEY `uq_asset_images_3` (`tenant_id`, `remediation_task_id`, `id`), + CONSTRAINT `ck_asset_images_0` CHECK ((inspection_item_id IS NULL) <> (remediation_task_id IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `evidence_images` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `task_id` CHAR(36) NOT NULL, + `evidence_id` CHAR(36) NOT NULL, + `image_id` CHAR(36) NOT NULL, + `ordinal` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_evidence_images_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_evidence_images_1` (`tenant_id`, `evidence_id`, `image_id`), + UNIQUE KEY `uq_evidence_images_2` (`tenant_id`, `evidence_id`, `ordinal`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `run_images` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `item_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `image_id` CHAR(36) NOT NULL, + `ordinal` INT UNSIGNED NOT NULL DEFAULT 0, + `role` ENUM('overview','detail') NOT NULL DEFAULT 'overview', + `parent_image_id` CHAR(36) NULL, + `analysis_sha256` CHAR(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_run_images_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_run_images_1` (`tenant_id`, `run_id`, `image_id`), + UNIQUE KEY `uq_run_images_2` (`tenant_id`, `run_id`, `ordinal`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `image_derivatives` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `run_id` CHAR(36) NOT NULL, + `image_id` CHAR(36) NOT NULL, + `crop_id` CHAR(36) NOT NULL, + `detection_id` CHAR(36) NOT NULL, + `kind` ENUM('crop') NOT NULL DEFAULT 'crop', + `recipe` JSON NOT NULL, + `object_key` VARCHAR(1024) NOT NULL, + `object_version` VARCHAR(200) NOT NULL, + `sha256` CHAR(64) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_image_derivatives_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_image_derivatives_1` (`tenant_id`, `run_id`, `crop_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `api_idempotency` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `actor_id` CHAR(36) NOT NULL, + `method` VARCHAR(10) NOT NULL, + `path_hash` CHAR(64) NOT NULL, + `key_hash` CHAR(64) NOT NULL, + `request_hash` CHAR(64) NOT NULL, + `state` ENUM('pending','completed') NOT NULL DEFAULT 'pending', + `response_status` SMALLINT UNSIGNED NULL, + `response` JSON NULL, + `lease_owner` CHAR(36) NULL, + `lease_until` DATETIME(3) NULL, + `expires_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_api_idempotency_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_api_idempotency_1` (`tenant_id`, `actor_id`, `method`, `path_hash`, `key_hash`), + KEY `ix_api_idempotency_0` (`expires_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `outbox_events` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `event_type` VARCHAR(80) NOT NULL, + `aggregate_type` VARCHAR(80) NOT NULL, + `aggregate_id` CHAR(36) NOT NULL, + `aggregate_version` INT UNSIGNED NOT NULL DEFAULT 1, + `payload` JSON NOT NULL, + `state` ENUM('pending','leased','published') NOT NULL DEFAULT 'pending', + `lease_owner` CHAR(36) NULL, + `lease_until` DATETIME(3) NULL, + `attempts` INT UNSIGNED NOT NULL DEFAULT 0, + `available_at` DATETIME(3) NOT NULL, + `published_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_outbox_events_0` (`tenant_id`, `id`), + KEY `ix_outbox_events_0` (`state`, `available_at`, `id`), + KEY `ix_outbox_events_1` (`state`, `lease_until`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `event_inbox` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `event_id` CHAR(36) NOT NULL, + `consumer` VARCHAR(80) NOT NULL, + `processed_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_event_inbox_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_event_inbox_1` (`tenant_id`, `event_id`, `consumer`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `task_runs` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `task_type` VARCHAR(80) NOT NULL, + `resource_id` CHAR(36) NOT NULL, + `logical_key` VARCHAR(200) NOT NULL, + `payload` JSON NOT NULL, + `state` ENUM('ready','leased','retry_wait','succeeded','failed','dead_letter') NOT NULL DEFAULT 'ready', + `attempt` INT UNSIGNED NOT NULL DEFAULT 0, + `replay_generation` INT UNSIGNED NOT NULL DEFAULT 0, + `dispatch_sequence` INT UNSIGNED NOT NULL DEFAULT 1, + `lease_owner` CHAR(36) NULL, + `lease_until` DATETIME(3) NULL, + `fencing_token` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `heartbeat_at` DATETIME(3) NULL, + `available_at` DATETIME(3) NOT NULL, + `last_dispatched_at` DATETIME(3) NULL, + `last_error_code` VARCHAR(80) NULL, + `started_at` DATETIME(3) NULL, + `finished_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_task_runs_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_task_runs_1` (`tenant_id`, `logical_key`), + KEY `ix_task_runs_0` (`state`, `available_at`, `id`), + KEY `ix_task_runs_1` (`state`, `lease_until`), + CONSTRAINT `ck_task_runs_0` CHECK (attempt <= 4) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `task_attempts` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `task_id` CHAR(36) NOT NULL, + `replay_generation` INT UNSIGNED NOT NULL DEFAULT 0, + `attempt` INT UNSIGNED NOT NULL DEFAULT 1, + `fencing_token` BIGINT UNSIGNED NOT NULL, + `lease_owner` CHAR(36) NOT NULL, + `started_at` DATETIME(3) NOT NULL, + `finished_at` DATETIME(3) NULL, + `status` ENUM('running','succeeded','failed','abandoned') NOT NULL DEFAULT 'running', + `error_code` VARCHAR(80) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_task_attempts_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_task_attempts_1` (`tenant_id`, `task_id`, `replay_generation`, `attempt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `notifications` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `event_id` CHAR(36) NOT NULL, + `recipient_id` CHAR(36) NOT NULL, + `type` VARCHAR(80) NOT NULL, + `resource_type` VARCHAR(80) NOT NULL, + `resource_id` CHAR(36) NOT NULL, + `read_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_notifications_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_notifications_1` (`tenant_id`, `event_id`, `recipient_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `audit_events` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `actor_id` CHAR(36) NULL, + `action` VARCHAR(100) NOT NULL, + `resource_type` VARCHAR(80) NOT NULL, + `resource_id` CHAR(36) NOT NULL, + `changes` JSON NOT NULL, + `reason` VARCHAR(2000) NOT NULL, + `request_id` CHAR(36) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_audit_events_0` (`tenant_id`, `id`), + KEY `ix_audit_events_0` (`tenant_id`, `resource_type`, `resource_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `report_exports` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `requested_by` CHAR(36) NOT NULL, + `format` ENUM('csv','pdf') NOT NULL DEFAULT 'csv', + `filters` JSON NOT NULL, + `snapshot_at` DATETIME(3) NOT NULL, + `snapshot` JSON NOT NULL, + `status` ENUM('queued','running','ready','failed','expired') NOT NULL DEFAULT 'queued', + `object_key` VARCHAR(1024) NULL, + `checksum` CHAR(64) NULL, + `expires_at` DATETIME(3) NULL, + `last_error_code` VARCHAR(80) NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_report_exports_0` (`tenant_id`, `id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `dataset_versions` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `checksum` CHAR(64) NOT NULL, + `manifest_key` VARCHAR(1024) NOT NULL, + `authorization_ref` VARCHAR(1000) NOT NULL, + `status` ENUM('draft','frozen','retired') NOT NULL DEFAULT 'draft', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_dataset_versions_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_dataset_versions_1` (`tenant_id`, `name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE `dataset_images` ( + `id` CHAR(36) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `version` INT UNSIGNED NOT NULL DEFAULT 1, + `tenant_id` CHAR(36) NOT NULL, + `dataset_id` CHAR(36) NOT NULL, + `image_id` CHAR(36) NOT NULL, + `scene_id` VARCHAR(100) NOT NULL, + `split` ENUM('train','validation','test') NOT NULL DEFAULT 'train', + `annotation_key` VARCHAR(1024) NOT NULL, + `provenance` VARCHAR(2000) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_dataset_images_0` (`tenant_id`, `id`), + UNIQUE KEY `uq_dataset_images_1` (`tenant_id`, `dataset_id`, `image_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +ALTER TABLE `colleges` ADD CONSTRAINT `fk_colleges_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `laboratories` ADD CONSTRAINT `fk_laboratories_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `laboratories` ADD CONSTRAINT `fk_laboratories_1` FOREIGN KEY (`tenant_id`, `college_id`) REFERENCES `colleges` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `users` ADD CONSTRAINT `fk_users_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `user_roles` ADD CONSTRAINT `fk_user_roles_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `user_roles` ADD CONSTRAINT `fk_user_roles_1` FOREIGN KEY (`tenant_id`, `user_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `user_roles` ADD CONSTRAINT `fk_user_roles_2` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `user_roles` ADD CONSTRAINT `fk_user_roles_3` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `sessions` ADD CONSTRAINT `fk_sessions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `sessions` ADD CONSTRAINT `fk_sessions_1` FOREIGN KEY (`tenant_id`, `user_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `locations` ADD CONSTRAINT `fk_locations_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `locations` ADD CONSTRAINT `fk_locations_1` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `locations` ADD CONSTRAINT `fk_locations_2` FOREIGN KEY (`tenant_id`, `laboratory_id`, `parent_id`) REFERENCES `locations` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_templates` ADD CONSTRAINT `fk_inspection_templates_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `template_items` ADD CONSTRAINT `fk_template_items_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `template_items` ADD CONSTRAINT `fk_template_items_1` FOREIGN KEY (`tenant_id`, `template_id`) REFERENCES `inspection_templates` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspections` ADD CONSTRAINT `fk_inspections_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspections` ADD CONSTRAINT `fk_inspections_1` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspections` ADD CONSTRAINT `fk_inspections_2` FOREIGN KEY (`tenant_id`, `template_id`) REFERENCES `inspection_templates` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspections` ADD CONSTRAINT `fk_inspections_3` FOREIGN KEY (`tenant_id`, `inspector_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_1` FOREIGN KEY (`tenant_id`, `laboratory_id`, `inspection_id`) REFERENCES `inspections` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_2` FOREIGN KEY (`tenant_id`, `template_item_id`) REFERENCES `template_items` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_3` FOREIGN KEY (`tenant_id`, `laboratory_id`, `location_id`) REFERENCES `locations` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_4` FOREIGN KEY (`tenant_id`, `reviewed_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_5` FOREIGN KEY (`tenant_id`, `id`, `current_run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_6` FOREIGN KEY (`tenant_id`, `id`, `current_fact_revision_id`) REFERENCES `fact_revisions` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inspection_items` ADD CONSTRAINT `fk_inspection_items_7` FOREIGN KEY (`tenant_id`, `id`, `current_evaluation_id`) REFERENCES `rule_evaluations` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dictionary_versions` ADD CONSTRAINT `fk_dictionary_versions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `chemical_entities` ADD CONSTRAINT `fk_chemical_entities_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `chemical_entities` ADD CONSTRAINT `fk_chemical_entities_1` FOREIGN KEY (`tenant_id`, `dictionary_version_id`) REFERENCES `dictionary_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `chemical_aliases` ADD CONSTRAINT `fk_chemical_aliases_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `chemical_aliases` ADD CONSTRAINT `fk_chemical_aliases_1` FOREIGN KEY (`tenant_id`, `dictionary_version_id`, `entity_id`) REFERENCES `chemical_entities` (`tenant_id`, `dictionary_version_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `model_versions` ADD CONSTRAINT `fk_model_versions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `model_versions` ADD CONSTRAINT `fk_model_versions_1` FOREIGN KEY (`tenant_id`, `dictionary_version_id`) REFERENCES `dictionary_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_sets` ADD CONSTRAINT `fk_rule_sets_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_versions` ADD CONSTRAINT `fk_rule_versions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_versions` ADD CONSTRAINT `fk_rule_versions_1` FOREIGN KEY (`tenant_id`, `rule_set_id`) REFERENCES `rule_sets` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_versions` ADD CONSTRAINT `fk_rule_versions_2` FOREIGN KEY (`tenant_id`, `submitted_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_versions` ADD CONSTRAINT `fk_rule_versions_3` FOREIGN KEY (`tenant_id`, `approved_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_bundles` ADD CONSTRAINT `fk_rule_bundles_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_bundle_members` ADD CONSTRAINT `fk_rule_bundle_members_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_bundle_members` ADD CONSTRAINT `fk_rule_bundle_members_1` FOREIGN KEY (`tenant_id`, `bundle_id`) REFERENCES `rule_bundles` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_bundle_members` ADD CONSTRAINT `fk_rule_bundle_members_2` FOREIGN KEY (`tenant_id`, `rule_version_id`) REFERENCES `rule_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_1` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_2` FOREIGN KEY (`tenant_id`, `model_bundle_id`) REFERENCES `model_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_3` FOREIGN KEY (`tenant_id`, `dictionary_version_id`) REFERENCES `dictionary_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `activations` ADD CONSTRAINT `fk_activations_4` FOREIGN KEY (`tenant_id`, `rule_bundle_id`) REFERENCES `rule_bundles` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_1` FOREIGN KEY (`tenant_id`, `laboratory_id`, `item_id`) REFERENCES `inspection_items` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_2` FOREIGN KEY (`tenant_id`, `replay_of`) REFERENCES `inference_runs` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_3` FOREIGN KEY (`tenant_id`, `model_bundle_id`) REFERENCES `model_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_4` FOREIGN KEY (`tenant_id`, `dictionary_version_id`) REFERENCES `dictionary_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `inference_runs` ADD CONSTRAINT `fk_inference_runs_5` FOREIGN KEY (`tenant_id`, `rule_bundle_id`) REFERENCES `rule_bundles` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `fact_revisions` ADD CONSTRAINT `fk_fact_revisions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `fact_revisions` ADD CONSTRAINT `fk_fact_revisions_1` FOREIGN KEY (`tenant_id`, `item_id`, `run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `fact_revisions` ADD CONSTRAINT `fk_fact_revisions_2` FOREIGN KEY (`tenant_id`, `created_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_evaluations` ADD CONSTRAINT `fk_rule_evaluations_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_evaluations` ADD CONSTRAINT `fk_rule_evaluations_1` FOREIGN KEY (`tenant_id`, `item_id`, `run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_evaluations` ADD CONSTRAINT `fk_rule_evaluations_2` FOREIGN KEY (`tenant_id`, `item_id`, `fact_revision_id`) REFERENCES `fact_revisions` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `rule_evaluations` ADD CONSTRAINT `fk_rule_evaluations_3` FOREIGN KEY (`tenant_id`, `rule_bundle_id`) REFERENCES `rule_bundles` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_1` FOREIGN KEY (`tenant_id`, `item_id`, `run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_2` FOREIGN KEY (`tenant_id`, `item_id`, `fact_revision_id`) REFERENCES `fact_revisions` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_3` FOREIGN KEY (`tenant_id`, `item_id`, `rule_evaluation_id`) REFERENCES `rule_evaluations` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `findings` ADD CONSTRAINT `fk_findings_4` FOREIGN KEY (`tenant_id`, `confirmed_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `review_actions` ADD CONSTRAINT `fk_review_actions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `review_actions` ADD CONSTRAINT `fk_review_actions_1` FOREIGN KEY (`tenant_id`, `actor_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_1` FOREIGN KEY (`tenant_id`, `finding_id`) REFERENCES `findings` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_2` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_3` FOREIGN KEY (`tenant_id`, `assignee_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_tasks` ADD CONSTRAINT `fk_remediation_tasks_4` FOREIGN KEY (`tenant_id`, `id`, `latest_evidence_id`) REFERENCES `remediation_evidence` (`tenant_id`, `task_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_evidence` ADD CONSTRAINT `fk_remediation_evidence_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_evidence` ADD CONSTRAINT `fk_remediation_evidence_1` FOREIGN KEY (`tenant_id`, `task_id`) REFERENCES `remediation_tasks` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `remediation_evidence` ADD CONSTRAINT `fk_remediation_evidence_2` FOREIGN KEY (`tenant_id`, `submitted_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_1` FOREIGN KEY (`tenant_id`, `requested_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_2` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_3` FOREIGN KEY (`tenant_id`, `laboratory_id`, `inspection_item_id`) REFERENCES `inspection_items` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `uploads` ADD CONSTRAINT `fk_uploads_4` FOREIGN KEY (`tenant_id`, `laboratory_id`, `remediation_task_id`) REFERENCES `remediation_tasks` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_1` FOREIGN KEY (`tenant_id`, `upload_id`) REFERENCES `uploads` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_2` FOREIGN KEY (`tenant_id`, `laboratory_id`) REFERENCES `laboratories` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_3` FOREIGN KEY (`tenant_id`, `laboratory_id`, `inspection_item_id`) REFERENCES `inspection_items` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `asset_images` ADD CONSTRAINT `fk_asset_images_4` FOREIGN KEY (`tenant_id`, `laboratory_id`, `remediation_task_id`) REFERENCES `remediation_tasks` (`tenant_id`, `laboratory_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `evidence_images` ADD CONSTRAINT `fk_evidence_images_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `evidence_images` ADD CONSTRAINT `fk_evidence_images_1` FOREIGN KEY (`tenant_id`, `task_id`, `evidence_id`) REFERENCES `remediation_evidence` (`tenant_id`, `task_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `evidence_images` ADD CONSTRAINT `fk_evidence_images_2` FOREIGN KEY (`tenant_id`, `task_id`, `image_id`) REFERENCES `asset_images` (`tenant_id`, `remediation_task_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `run_images` ADD CONSTRAINT `fk_run_images_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `run_images` ADD CONSTRAINT `fk_run_images_1` FOREIGN KEY (`tenant_id`, `item_id`, `run_id`) REFERENCES `inference_runs` (`tenant_id`, `item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `run_images` ADD CONSTRAINT `fk_run_images_2` FOREIGN KEY (`tenant_id`, `item_id`, `image_id`) REFERENCES `asset_images` (`tenant_id`, `inspection_item_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `run_images` ADD CONSTRAINT `fk_run_images_3` FOREIGN KEY (`tenant_id`, `run_id`, `parent_image_id`) REFERENCES `run_images` (`tenant_id`, `run_id`, `image_id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `image_derivatives` ADD CONSTRAINT `fk_image_derivatives_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `image_derivatives` ADD CONSTRAINT `fk_image_derivatives_1` FOREIGN KEY (`tenant_id`, `run_id`, `image_id`) REFERENCES `run_images` (`tenant_id`, `run_id`, `image_id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `api_idempotency` ADD CONSTRAINT `fk_api_idempotency_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `api_idempotency` ADD CONSTRAINT `fk_api_idempotency_1` FOREIGN KEY (`tenant_id`, `actor_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `outbox_events` ADD CONSTRAINT `fk_outbox_events_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `event_inbox` ADD CONSTRAINT `fk_event_inbox_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `event_inbox` ADD CONSTRAINT `fk_event_inbox_1` FOREIGN KEY (`tenant_id`, `event_id`) REFERENCES `outbox_events` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `task_runs` ADD CONSTRAINT `fk_task_runs_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `task_attempts` ADD CONSTRAINT `fk_task_attempts_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `task_attempts` ADD CONSTRAINT `fk_task_attempts_1` FOREIGN KEY (`tenant_id`, `task_id`) REFERENCES `task_runs` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `notifications` ADD CONSTRAINT `fk_notifications_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `notifications` ADD CONSTRAINT `fk_notifications_1` FOREIGN KEY (`tenant_id`, `event_id`) REFERENCES `outbox_events` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `notifications` ADD CONSTRAINT `fk_notifications_2` FOREIGN KEY (`tenant_id`, `recipient_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `audit_events` ADD CONSTRAINT `fk_audit_events_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `audit_events` ADD CONSTRAINT `fk_audit_events_1` FOREIGN KEY (`tenant_id`, `actor_id`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `report_exports` ADD CONSTRAINT `fk_report_exports_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `report_exports` ADD CONSTRAINT `fk_report_exports_1` FOREIGN KEY (`tenant_id`, `requested_by`) REFERENCES `users` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dataset_versions` ADD CONSTRAINT `fk_dataset_versions_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dataset_images` ADD CONSTRAINT `fk_dataset_images_0` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dataset_images` ADD CONSTRAINT `fk_dataset_images_1` FOREIGN KEY (`tenant_id`, `dataset_id`) REFERENCES `dataset_versions` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; + +ALTER TABLE `dataset_images` ADD CONSTRAINT `fk_dataset_images_2` FOREIGN KEY (`tenant_id`, `image_id`) REFERENCES `asset_images` (`tenant_id`, `id`) ON DELETE RESTRICT ON UPDATE RESTRICT; diff --git a/packages/persistence/migrations/versions/0001_initial.py b/packages/persistence/migrations/versions/0001_initial.py new file mode 100644 index 0000000..3e7abc8 --- /dev/null +++ b/packages/persistence/migrations/versions/0001_initial.py @@ -0,0 +1,46 @@ +"""I-01B: frozen 43-table design baseline; no demo accounts or credentials.""" + +import json +import os +from pathlib import Path + +from alembic import op +from sqlalchemy import inspect + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None +SNAPSHOTS = Path(__file__).resolve().parents[1] / "snapshots" + + +def upgrade(): + connection = op.get_bind() + inspector = inspect(connection) + if set(inspector.get_table_names()) - {"alembic_version"} or inspector.get_view_names(): + raise RuntimeError("Initial migration requires an empty disposable database") + ddl = (SNAPSHOTS / "0001_initial.sql").read_text(encoding="utf-8") + # Immutable generated DDL has no procedures or semicolons in literals. + ddl = "\n".join(line for line in ddl.splitlines() if not line.startswith("--")) + for statement in ddl.split(";"): + if statement.strip(): + connection.exec_driver_sql(statement.strip()) + + +def downgrade(): + connection = op.get_bind() + schema = connection.engine.url.database + if ( + os.environ.get("APP_ENV") != "test" + or os.environ.get("LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE") != schema + ): + raise RuntimeError( + "Destructive downgrade requires APP_ENV=test and " + "LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE equal to DATABASE_SCHEMA" + ) + model = json.loads((SNAPSHOTS / "0001_initial.json").read_text(encoding="utf-8")) + for table, definition in model["tables"].items(): + for index, _foreign_key in enumerate(definition["foreign_keys"]): + op.drop_constraint(f"fk_{table}_{index}", table, type_="foreignkey") + for table in reversed(model["tables"]): + op.drop_table(table) diff --git a/packages/persistence/migrations/versions/__init__.py b/packages/persistence/migrations/versions/__init__.py new file mode 100644 index 0000000..e9b79b6 --- /dev/null +++ b/packages/persistence/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Alembic revision history. Never edit a released revision.""" diff --git a/packages/persistence/schema.py b/packages/persistence/schema.py new file mode 100644 index 0000000..643139c --- /dev/null +++ b/packages/persistence/schema.py @@ -0,0 +1,250 @@ +"""Read-only comparison of actual MySQL metadata with the frozen design.""" + +import json +import re +from collections import defaultdict +from pathlib import Path + +from sqlalchemy import text + +from packages.persistence.database import STRICT_SQL_MODE + +MODEL_PATH = Path(__file__).parent / "migrations/snapshots/0001_initial.json" + + +def model(): + return json.loads(MODEL_PATH.read_text(encoding="utf-8")) + + +def expression_tokens(value): + # MySQL adds identifier quotes, charset introducers and redundant parentheses. + value = value.replace(chr(96), "").replace("\\'", "'") + value = re.sub(r"_(?:utf8mb[34]|ascii|latin1)(?=')", "", value, flags=re.I) + tokens = re.findall(r"'(?:''|[^'])*'|[a-zA-Z_][a-zA-Z_0-9]*|\d+|<=|>=|<>|[(),=<>]", value) + if re.sub(r"\s+", "", "".join(tokens)) != re.sub(r"\s+", "", value): + raise ValueError(f"Unsupported SQL schema expression token: {value!r}") + return [t if t.startswith("'") else t.lower() for t in tokens] + + +def check_tree(value): + """Parse this baseline's restricted CHECK grammar without discarding precedence.""" + tokens = expression_tokens(value) + position = 0 + + precedence = {"or": 1, "and": 2, "=": 3, "<=": 3, ">=": 3, "<": 3, ">": 3, "<>": 3, "is": 3} + + def parse(minimum=0): + nonlocal position + if tokens[position] == "(": + position += 1 + node = parse() + if tokens[position] != ")": + raise ValueError("Unbalanced CHECK expression") + position += 1 + else: + node = tokens[position] + position += 1 + while position < len(tokens) and precedence.get(tokens[position], -1) >= minimum: + operator = tokens[position] + priority = precedence[operator] + position += 1 + if operator == "is" and tokens[position] == "not": + operator += " not" + position += 1 + node = (operator, node, parse(priority + 1)) + return node + + result = parse() + if position != len(tokens): + raise ValueError("Unexpected CHECK expression suffix") + return result + + +def column_type(value): + # ENUM values are case-sensitive under utf8mb4_bin; normalize SQL keywords only. + parts = re.split(r"('(?:''|[^'])*')", value) + value = "".join(part if i % 2 else part.lower() for i, part in enumerate(parts)) + if value == "boolean": + return "tinyint(1)" + # MySQL 8.0 omits deprecated integer display widths except BOOLEAN/tinyint(1). + return re.sub(r"\b(int|bigint|smallint|mediumint)\(\d+\)", r"\1", value) + + +def default_value(value): + if value is None: + return None + value = str(value) + if value.startswith("'") and value.endswith("'"): + return value[1:-1].replace("''", "'") + if value.upper() in {"TRUE", "FALSE"}: + return "1" if value.upper() == "TRUE" else "0" + if value.lower().startswith("current_timestamp"): + return value.lower() + return value + + +def verify_schema(connection) -> dict: + expected = model()["tables"] + errors = [] + + def compare(path, actual, wanted): + if actual != wanted: + errors.append(f"{path}: actual={actual!r}; expected={wanted!r}") + + def rows(query): + return connection.execute(text(query)).mappings().all() + + tables = rows( + "SELECT TABLE_NAME,ENGINE,TABLE_COLLATION FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA=DATABASE()" + ) + actual_names = {row["TABLE_NAME"] for row in tables} - {"alembic_version"} + compare("tables", sorted(actual_names), sorted(expected)) + for row in tables: + if row["TABLE_NAME"] in expected: + compare(f"{row['TABLE_NAME']}.engine", row["ENGINE"], "InnoDB") + compare(f"{row['TABLE_NAME']}.collation", row["TABLE_COLLATION"], "utf8mb4_bin") + + columns = defaultdict(dict) + for row in rows( + "SELECT * FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " + "ORDER BY TABLE_NAME,ORDINAL_POSITION" + ): + columns[row["TABLE_NAME"]][row["COLUMN_NAME"]] = row + for table, definition in expected.items(): + compare(f"{table}.columns", list(columns[table]), list(definition["columns"])) + for name, wanted in definition["columns"].items(): + actual = columns[table].get(name) + if actual is None: + continue + path = f"{table}.{name}" + compare(path + ".type", column_type(actual["COLUMN_TYPE"]), column_type(wanted["type"])) + compare(path + ".nullable", actual["IS_NULLABLE"] == "YES", wanted["nullable"]) + compare( + path + ".default", + default_value(actual["COLUMN_DEFAULT"]), + default_value(wanted["default_sql"]), + ) + generated = wanted["generated_sql"] + compare( + path + ".generated", + expression_tokens(actual["GENERATION_EXPRESSION"] or ""), + expression_tokens(generated or ""), + ) + extra = actual["EXTRA"].lower().split() + wanted_extra = ["stored", "generated"] if generated else [] + if (wanted["default_sql"] or "").upper().startswith("CURRENT_TIMESTAMP"): + wanted_extra = ["default_generated"] + compare(path + ".extra", extra, wanted_extra) + if re.match(r"^(char|varchar|enum|text)", actual["DATA_TYPE"]): + compare(path + ".collation", actual["COLLATION_NAME"], "utf8mb4_bin") + + indexes = defaultdict(lambda: defaultdict(list)) + for row in rows( + "SELECT * FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() " + "ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX" + ): + indexes[row["TABLE_NAME"]][row["INDEX_NAME"]].append(row) + for table, definition in expected.items(): + required = {"PRIMARY": (0, definition["primary_key"])} + required.update( + {f"uq_{table}_{i}": (0, fields) for i, fields in enumerate(definition["unique"])} + ) + required.update( + {f"ix_{table}_{i}": (1, fields) for i, fields in enumerate(definition["indexes"])} + ) + implicit = { + f"fk_{table}_{i}": (1, fk["columns"]) for i, fk in enumerate(definition["foreign_keys"]) + } + for name in required.keys() - indexes[table].keys(): + errors.append(f"{table}.index.{name}: missing") + for name, parts in indexes[table].items(): + wanted = required.get(name, implicit.get(name)) + actual = (parts[0]["NON_UNIQUE"], [part["COLUMN_NAME"] for part in parts]) + compare(f"{table}.index.{name}", actual, wanted) + for part in parts: + compare( + f"{table}.index.{name}.kind", + (part["SUB_PART"], part["INDEX_TYPE"], part["IS_VISIBLE"]), + (None, "BTREE", "YES"), + ) + + foreign_keys = defaultdict(lambda: defaultdict(list)) + for row in rows( + "SELECT k.*,r.UPDATE_RULE,r.DELETE_RULE FROM information_schema.KEY_COLUMN_USAGE k " + "JOIN information_schema.REFERENTIAL_CONSTRAINTS r " + "ON k.CONSTRAINT_SCHEMA=r.CONSTRAINT_SCHEMA AND k.TABLE_NAME=r.TABLE_NAME " + "AND k.CONSTRAINT_NAME=r.CONSTRAINT_NAME WHERE k.TABLE_SCHEMA=DATABASE() " + "ORDER BY k.TABLE_NAME,k.CONSTRAINT_NAME,k.ORDINAL_POSITION" + ): + foreign_keys[row["TABLE_NAME"]][row["CONSTRAINT_NAME"]].append(row) + schema = connection.scalar(text("SELECT DATABASE()")) + for table, definition in expected.items(): + wanted_names = [f"fk_{table}_{i}" for i in range(len(definition["foreign_keys"]))] + compare(f"{table}.foreign_keys", sorted(foreign_keys[table]), sorted(wanted_names)) + for i, wanted in enumerate(definition["foreign_keys"]): + name = f"fk_{table}_{i}" + parts = foreign_keys[table].get(name) + if not parts: + continue + compare( + f"{table}.{name}", + ( + [p["COLUMN_NAME"] for p in parts], + parts[0]["REFERENCED_TABLE_NAME"], + [p["REFERENCED_COLUMN_NAME"] for p in parts], + parts[0]["DELETE_RULE"], + parts[0]["UPDATE_RULE"], + parts[0]["REFERENCED_TABLE_SCHEMA"], + ), + ( + wanted["columns"], + wanted["target"], + wanted["references"], + wanted["on_delete"], + wanted["on_update"], + schema, + ), + ) + + checks = defaultdict(dict) + for row in rows( + "SELECT t.TABLE_NAME,t.CONSTRAINT_NAME,c.CHECK_CLAUSE,t.ENFORCED " + "FROM information_schema.TABLE_CONSTRAINTS t JOIN information_schema.CHECK_CONSTRAINTS c " + "ON t.CONSTRAINT_SCHEMA=c.CONSTRAINT_SCHEMA AND t.CONSTRAINT_NAME=c.CONSTRAINT_NAME " + "WHERE t.TABLE_SCHEMA=DATABASE()" + ): + checks[row["TABLE_NAME"]][row["CONSTRAINT_NAME"]] = row + for table, definition in expected.items(): + wanted_names = [f"ck_{table}_{i}" for i in range(len(definition["checks"]))] + compare(f"{table}.checks", sorted(checks[table]), sorted(wanted_names)) + for i, expression in enumerate(definition["checks"]): + name = f"ck_{table}_{i}" + if name in checks[table]: + compare( + f"{table}.{name}", + check_tree(checks[table][name]["CHECK_CLAUSE"]), + check_tree(expression), + ) + compare(f"{table}.{name}.enforced", checks[table][name]["ENFORCED"], "YES") + head = connection.scalar(text("SELECT version_num FROM alembic_version")) + compare("migration_head", head, "0001_initial") + compare("session.time_zone", connection.scalar(text("SELECT @@session.time_zone")), "+00:00") + actual_modes = set(connection.scalar(text("SELECT @@session.sql_mode")).split(",")) + required_modes = set(STRICT_SQL_MODE.split(",")) + compare("session.sql_mode.missing", sorted(required_modes - actual_modes), []) + compare( + "session.foreign_key_checks", + connection.scalar(text("SELECT @@session.foreign_key_checks")), + 1, + ) + compare("session.unique_checks", connection.scalar(text("SELECT @@session.unique_checks")), 1) + return { + "mysql_version": connection.scalar(text("SELECT VERSION()")), + "schema": schema, + "migration_head": head, + "tables": len(actual_names), + "foreign_keys": sum(len(v) for v in foreign_keys.values()), + "errors": errors, + "status": "pass" if not errors else "fail", + } diff --git a/pyproject.toml b/pyproject.toml index 5245c8b..f0bfec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,9 @@ worker = [ persistence = [ "alembic==1.14.1", "sqlalchemy==2.0.37", - "pymysql==1.1.1", + "pymysql[rsa]==1.1.1", + "argon2-cffi==23.1.0", + "tzdata==2026.4", ] dev = [ "pytest==8.3.4", @@ -47,6 +49,7 @@ namespaces = false [tool.setuptools.package-data] "packages.inference_protocol" = ["contract.json"] "apps.ai_inference" = ["fixtures/*.json"] +"packages.persistence.migrations" = ["snapshots/*.json", "snapshots/*.sql"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/requirements/py311-business.txt b/requirements/py311-business.txt index 7200597..7f61214 100644 --- a/requirements/py311-business.txt +++ b/requirements/py311-business.txt @@ -4,4 +4,6 @@ celery==5.4.0 redis==5.2.1 alembic==1.14.1 sqlalchemy==2.0.37 -pymysql==1.1.1 +pymysql[rsa]==1.1.1 +argon2-cffi==23.1.0 +tzdata==2026.4 diff --git a/tests/persistence/__init__.py b/tests/persistence/__init__.py new file mode 100644 index 0000000..908ab9a --- /dev/null +++ b/tests/persistence/__init__.py @@ -0,0 +1 @@ +"""Persistence unit and real MySQL integration tests.""" diff --git a/tests/persistence/conftest.py b/tests/persistence/conftest.py new file mode 100644 index 0000000..19c75d1 --- /dev/null +++ b/tests/persistence/conftest.py @@ -0,0 +1,61 @@ +import json +import os + +import pytest +from alembic import command +from sqlalchemy import inspect, text + +from packages.persistence.cli import migration_config +from packages.persistence.database import database_engine +from packages.persistence.schema import verify_schema + + +@pytest.fixture(scope="session") +def database(): + if os.environ.get("LABSAFE_MYSQL_INTEGRATION") != "1": + pytest.skip("Run tools/database/run_mysql_tests.py for an isolated real MySQL database") + if os.environ.get("LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE") != os.environ.get("DATABASE_SCHEMA"): + pytest.fail("Migration-cycle tests require explicit disposable schema confirmation") + engine = database_engine() + with engine.connect() as connection: + assert inspect(connection).get_table_names() == [], "Integration tests require an empty DB" + config = migration_config() + with engine.begin() as connection: + connection.execute(text("CREATE TABLE user_sentinel (id INT PRIMARY KEY)")) + connection.execute(text("INSERT INTO user_sentinel VALUES (7)")) + with pytest.raises(RuntimeError, match="empty disposable"): + command.upgrade(config, "head") + with engine.begin() as connection: + assert connection.scalar(text("SELECT id FROM user_sentinel")) == 7 + connection.execute(text("DROP TABLE user_sentinel")) + command.upgrade(config, "head") + with engine.connect() as connection: + report = verify_schema(connection) + print(json.dumps(report, indent=2)) + assert report["errors"] == [] + confirmation = os.environ.pop("LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE") + try: + with pytest.raises(RuntimeError, match="Destructive downgrade"): + command.downgrade(config, "base") + finally: + os.environ["LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE"] = confirmation + with engine.connect() as connection: + assert verify_schema(connection)["errors"] == [] + command.downgrade(config, "base") + with engine.connect() as connection: + assert inspect(connection).get_table_names() == ["alembic_version"] + command.upgrade(config, "head") + with engine.connect() as connection: + assert verify_schema(connection)["errors"] == [] + yield engine + engine.dispose() + + +@pytest.fixture +def transaction(database): + with database.connect() as connection: + transaction = connection.begin() + try: + yield connection + finally: + transaction.rollback() diff --git a/tests/persistence/factories.py b/tests/persistence/factories.py new file mode 100644 index 0000000..a52f233 --- /dev/null +++ b/tests/persistence/factories.py @@ -0,0 +1,119 @@ +"""Synthetic DDL-only fixtures; not application repositories or production seed data.""" + +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import text + +from packages.persistence.schema import model + +TABLES = model()["tables"] + + +def insert(connection, table, **values): + definition = TABLES[table] + for name, column in definition["columns"].items(): + if ( + name in values + or column["nullable"] + or column["default_sql"] is not None + or column["generated_sql"] + ): + continue + kind = column["type"] + if kind == "CHAR(36)": + values[name] = str(uuid4()) + elif kind == "CHAR(64)": + values[name] = uuid4().hex * 2 + elif kind.startswith("VARCHAR"): + values[name] = uuid4().hex + elif kind == "JSON": + values[name] = "{}" + elif kind.startswith("DATETIME"): + values[name] = datetime(2026, 9, 29) + elif kind == "DATE": + values[name] = datetime(2026, 9, 29).date() + elif "INT" in kind: + values[name] = 1 + else: + raise AssertionError(f"No fixture value for {table}.{name}") + assert set(values) <= definition["columns"].keys() + quote = chr(96) + fields = ",".join(quote + name + quote for name in values) + parameters = ",".join(":" + name for name in values) + connection.execute( + text(f"INSERT INTO {quote}{table}{quote} ({fields}) VALUES ({parameters})"), values + ) + return values["id"] + + +class Graph: + def __init__(self, connection): + self.connection = connection + self.tenant = insert(connection, "tenants", timezone="UTC") + self.user = self.add("users") + self.college = self.add("colleges") + self.lab = self.add("laboratories", college_id=self.college) + self.location = self.add("locations", laboratory_id=self.lab) + self.template = self.add("inspection_templates") + self.inspection = self.add( + "inspections", laboratory_id=self.lab, template_id=self.template, inspector_id=self.user + ) + self.dictionary = self.add("dictionary_versions") + self.model = self.add("model_versions", dictionary_version_id=self.dictionary) + self.rules = self.add("rule_bundles") + self.item, self.run, self.fact, self.evaluation = self.item_chain() + self.other_item, self.other_run, self.other_fact, self.other_evaluation = self.item_chain() + self.finding = self.add( + "findings", + item_id=self.item, + run_id=self.run, + fact_revision_id=self.fact, + rule_evaluation_id=self.evaluation, + ) + self.task = self.add( + "remediation_tasks", + finding_id=self.finding, + laboratory_id=self.lab, + assignee_id=self.user, + ) + self.upload = self.add( + "uploads", laboratory_id=self.lab, inspection_item_id=self.item, requested_by=self.user + ) + self.image = self.add( + "asset_images", + laboratory_id=self.lab, + inspection_item_id=self.item, + upload_id=self.upload, + ) + self.add("run_images", item_id=self.item, run_id=self.run, image_id=self.image) + + def add(self, table, **values): + return insert(self.connection, table, tenant_id=self.tenant, **values) + + def item_chain(self): + template_item = self.add("template_items", template_id=self.template) + item = self.add( + "inspection_items", + inspection_id=self.inspection, + laboratory_id=self.lab, + template_item_id=template_item, + location_id=self.location, + ) + run = self.add( + "inference_runs", + item_id=item, + laboratory_id=self.lab, + model_bundle_id=self.model, + dictionary_version_id=self.dictionary, + rule_bundle_id=self.rules, + ) + fact = self.add("fact_revisions", item_id=item, run_id=run) + evaluation = self.add( + "rule_evaluations", + item_id=item, + run_id=run, + fact_revision_id=fact, + rule_bundle_id=self.rules, + ) + return item, run, fact, evaluation diff --git a/tests/persistence/test_bootstrap.py b/tests/persistence/test_bootstrap.py new file mode 100644 index 0000000..5f6e8fa --- /dev/null +++ b/tests/persistence/test_bootstrap.py @@ -0,0 +1,149 @@ +import json +from concurrent.futures import ThreadPoolExecutor +from uuid import uuid4 + +import pytest +from sqlalchemy import event, text + +from packages.persistence.bootstrap import PASSWORD_HASHER, ROLES, TenantInput, bootstrap_tenant +from packages.persistence.cli import main +from packages.persistence.database import DatabaseSafetyError + +PASSWORD = " Synthetic-Only-Password-中文 " + + +def inputs(): + return TenantInput( + "test_" + uuid4().hex, + "合成测试机构", + "Asia/Shanghai", + " ADMIN ", + "初始管理员", + "I-01B synthetic verification", + ) + + +def test_bootstrap_hash_roles_and_audit_atomic(database): + data = inputs() + result = bootstrap_tenant(database, data, PASSWORD) + with database.connect() as connection: + user = ( + connection.execute(text("SELECT * FROM users WHERE id=:id"), {"id": result["admin_id"]}) + .mappings() + .one() + ) + assert user["username"] == "admin" + assert PASSWORD_HASHER.verify(user["password_hash"], PASSWORD) + assert not PASSWORD_HASHER.check_needs_rehash(user["password_hash"]) + assert set(connection.execute(text("SELECT code FROM roles")).scalars()) == set(ROLES) + grants = connection.execute( + text("SELECT scope_kind,laboratory_id FROM user_roles WHERE user_id=:id"), + {"id": result["admin_id"]}, + ).all() + assert grants == [("tenant", None)] + audit = connection.execute( + text("SELECT changes FROM audit_events WHERE tenant_id=:id"), + {"id": result["tenant_id"]}, + ).scalar_one() + assert json.loads(audit)["initial_admin_id"] == result["admin_id"] + assert "password" not in audit.lower() and PASSWORD not in audit + with pytest.raises(DatabaseSafetyError, match="already exists"): + bootstrap_tenant(database, data, "Another-Password-Do-Not-Reset") + with database.connect() as connection: + unchanged = connection.execute( + text("SELECT password_hash FROM users WHERE id=:id"), {"id": result["admin_id"]} + ).scalar_one() + assert unchanged == user["password_hash"] + + +def test_bootstrap_audit_failure_rolls_back_every_write(database): + data = inputs() + tables = ("tenants", "users", "roles", "user_roles", "audit_events") + with database.connect() as connection: + before = { + table: connection.scalar(text(f"SELECT COUNT(*) FROM {table}")) for table in tables + } + + def fail_audit(_connection, _cursor, statement, _parameters, _context, _many): + if statement.startswith("INSERT INTO audit_events"): + raise RuntimeError("synthetic audit failure") + + event.listen(database, "before_cursor_execute", fail_audit) + try: + with pytest.raises(RuntimeError, match="synthetic audit failure"): + bootstrap_tenant(database, data, PASSWORD) + finally: + event.remove(database, "before_cursor_execute", fail_audit) + with database.connect() as connection: + after = { + table: connection.scalar(text(f"SELECT COUNT(*) FROM {table}")) for table in tables + } + assert before == after + assert not connection.scalar( + text("SELECT id FROM tenants WHERE code=:code"), {"code": data.code} + ) + + +def test_concurrent_bootstrap_creates_one_admin(database): + data = inputs() + + def attempt(_): + try: + bootstrap_tenant(database, data, PASSWORD) + return "created" + except DatabaseSafetyError as error: + assert "already exists" in str(error) + return "duplicate" + + with ThreadPoolExecutor(max_workers=2) as executor: + assert sorted(executor.map(attempt, range(2))) == ["created", "duplicate"] + with database.connect() as connection: + assert ( + connection.scalar( + text( + "SELECT COUNT(*) FROM users u JOIN tenants t " + "ON u.tenant_id=t.id WHERE t.code=:code" + ), + {"code": data.code}, + ) + == 1 + ) + + +def test_cli_password_file_and_readonly_verify(database, tmp_path, capsys): + data = inputs() + password_file = tmp_path / "one-time-password" + password_file.write_bytes(PASSWORD.encode("utf-8")) + assert ( + main( + [ + "bootstrap-tenant", + "--code", + data.code, + "--name", + data.name, + "--timezone", + data.timezone, + "--username", + data.username, + "--display-name", + data.display_name, + "--reason", + data.reason, + "--password-file", + str(password_file), + ] + ) + == 0 + ) + output = capsys.readouterr() + result = json.loads(output.out) + assert PASSWORD not in output.out + output.err + assert "password" not in output.out.lower() + with database.connect() as connection: + hashed = connection.scalar( + text("SELECT password_hash FROM users WHERE id=:id"), {"id": result["admin_id"]} + ) + assert PASSWORD_HASHER.verify(hashed, PASSWORD) + assert main(["verify"]) == 0 + assert json.loads(capsys.readouterr().out)["errors"] == [] diff --git a/tests/persistence/test_constraints.py b/tests/persistence/test_constraints.py new file mode 100644 index 0000000..c52314e --- /dev/null +++ b/tests/persistence/test_constraints.py @@ -0,0 +1,163 @@ +from uuid import uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from tests.persistence.factories import Graph, insert + + +@pytest.fixture +def graph(transaction): + return Graph(transaction) + + +def rejected(code, operation): + with pytest.raises(DBAPIError) as failure: + operation() + assert failure.value.orig.args[0] == code + + +def test_cross_tenant_foreign_key(transaction, graph): + other_tenant = insert(transaction, "tenants", timezone="UTC") + rejected( + 1452, + lambda: insert( + transaction, "laboratories", tenant_id=other_tenant, college_id=graph.college + ), + ) + + +@pytest.mark.parametrize( + "column,wrong,right", + [ + ("current_run_id", "other_run", "run"), + ("current_fact_revision_id", "other_fact", "fact"), + ("current_evaluation_id", "other_evaluation", "evaluation"), + ], +) +def test_current_pointer_must_belong_to_same_item(transaction, graph, column, wrong, right): + statement = text(f"UPDATE inspection_items SET {column}=:target WHERE id=:item") + rejected( + 1452, + lambda: transaction.execute( + statement, {"target": getattr(graph, wrong), "item": graph.item} + ), + ) + transaction.execute(statement, {"target": getattr(graph, right), "item": graph.item}) + + +def test_nullable_scope_cannot_duplicate_tenant_role(transaction, graph): + role = insert(transaction, "roles") + values = dict(user_id=graph.user, role_id=role, scope_kind="tenant") + graph.add("user_roles", **values) + rejected(1062, lambda: graph.add("user_roles", **values)) + rejected(3819, lambda: graph.add("user_roles", **values, laboratory_id=graph.lab)) + rejected( + 3819, + lambda: graph.add("user_roles", user_id=graph.user, role_id=role, scope_kind="laboratory"), + ) + graph.add( + "user_roles", + user_id=graph.user, + role_id=role, + scope_kind="laboratory", + laboratory_id=graph.lab, + ) + + +@pytest.mark.parametrize("table", ["uploads", "asset_images"]) +@pytest.mark.parametrize("both", [False, True]) +def test_exactly_one_owner_required(graph, table, both): + owners = {"inspection_item_id": graph.item, "remediation_task_id": graph.task} if both else {} + extra = {"requested_by": graph.user} if table == "uploads" else {"upload_id": graph.upload} + rejected(3819, lambda: graph.add(table, laboratory_id=graph.lab, **extra, **owners)) + + +def test_remediation_owner_is_valid(graph): + upload = graph.add( + "uploads", laboratory_id=graph.lab, remediation_task_id=graph.task, requested_by=graph.user + ) + graph.add( + "asset_images", laboratory_id=graph.lab, remediation_task_id=graph.task, upload_id=upload + ) + + +def test_multi_crop_allowed_duplicate_run_crop_rejected(graph): + first = str(uuid4()) + graph.add("image_derivatives", run_id=graph.run, image_id=graph.image, crop_id=first) + graph.add("image_derivatives", run_id=graph.run, image_id=graph.image, crop_id=str(uuid4())) + rejected( + 1062, + lambda: graph.add( + "image_derivatives", run_id=graph.run, image_id=graph.image, crop_id=first + ), + ) + rejected( + 1452, + lambda: graph.add( + "image_derivatives", run_id=graph.other_run, image_id=graph.image, crop_id=str(uuid4()) + ), + ) + + +def test_run_image_and_parent_must_belong_to_same_run_item(graph): + rejected( + 1452, + lambda: graph.add( + "run_images", item_id=graph.other_item, run_id=graph.other_run, image_id=graph.image + ), + ) + upload = graph.add( + "uploads", + laboratory_id=graph.lab, + inspection_item_id=graph.other_item, + requested_by=graph.user, + ) + image = graph.add( + "asset_images", + laboratory_id=graph.lab, + inspection_item_id=graph.other_item, + upload_id=upload, + ) + rejected( + 1452, + lambda: graph.add( + "run_images", + item_id=graph.other_item, + run_id=graph.other_run, + image_id=image, + parent_image_id=graph.image, + ), + ) + graph.add("run_images", item_id=graph.other_item, run_id=graph.other_run, image_id=image) + + +def test_remediation_evidence_pointer_must_belong_to_same_task(transaction, graph): + evidence = graph.add("remediation_evidence", task_id=graph.task, submitted_by=graph.user) + finding = graph.add( + "findings", + item_id=graph.other_item, + run_id=graph.other_run, + fact_revision_id=graph.other_fact, + rule_evaluation_id=graph.other_evaluation, + ) + task = graph.add( + "remediation_tasks", finding_id=finding, laboratory_id=graph.lab, assignee_id=graph.user + ) + statement = text("UPDATE remediation_tasks SET latest_evidence_id=:evidence WHERE id=:task") + rejected(1452, lambda: transaction.execute(statement, {"evidence": evidence, "task": task})) + transaction.execute(statement, {"evidence": evidence, "task": graph.task}) + + +def test_task_attempt_and_enum_guards(graph): + graph.add("task_runs", attempt=4) + rejected(3819, lambda: graph.add("task_runs", attempt=5)) + rejected(1265, lambda: graph.add("task_runs", state="not-a-real-state")) + + +def test_parent_delete_restricted(transaction, graph): + rejected( + 1451, + lambda: transaction.execute(text("DELETE FROM tenants WHERE id=:id"), {"id": graph.tenant}), + ) diff --git a/tests/persistence/test_schema.py b/tests/persistence/test_schema.py new file mode 100644 index 0000000..98bef03 --- /dev/null +++ b/tests/persistence/test_schema.py @@ -0,0 +1,58 @@ +import pytest + +from packages.persistence.schema import verify_schema + + +def test_full_schema(database): + with database.connect() as connection: + report = verify_schema(connection) + assert report["errors"] == [] + assert report["tables"] == 43 + assert report["foreign_keys"] == 115 + + +@pytest.mark.parametrize( + "change,restore,expected_error", + [ + ( + "ALTER TABLE tenants MODIFY name VARCHAR(199) NOT NULL", + "ALTER TABLE tenants MODIFY name VARCHAR(200) NOT NULL", + "tenants.name.type", + ), + ( + "ALTER TABLE tenants ALTER status SET DEFAULT 'suspended'", + "ALTER TABLE tenants ALTER status SET DEFAULT 'active'", + "tenants.status.default", + ), + ( + "ALTER TABLE roles DROP INDEX uq_roles_0", + "ALTER TABLE roles ADD UNIQUE KEY uq_roles_0 (code)", + "roles.index.uq_roles_0", + ), + ( + "ALTER TABLE colleges DROP FOREIGN KEY fk_colleges_0", + "ALTER TABLE colleges ADD CONSTRAINT fk_colleges_0 FOREIGN KEY (tenant_id) " + "REFERENCES tenants (id) ON DELETE RESTRICT ON UPDATE RESTRICT", + "colleges.foreign_keys", + ), + ( + "ALTER TABLE task_runs ALTER CHECK ck_task_runs_0 NOT ENFORCED", + "ALTER TABLE task_runs ALTER CHECK ck_task_runs_0 ENFORCED", + "task_runs.ck_task_runs_0.enforced", + ), + ( + "ALTER TABLE roles ADD INDEX unexpected_index (name)", + "ALTER TABLE roles DROP INDEX unexpected_index", + "roles.index.unexpected_index", + ), + ], +) +def test_checker_detects_real_schema_drift(database, change, restore, expected_error): + with database.connect() as connection: + connection.exec_driver_sql(change) + try: + report = verify_schema(connection) + assert any(expected_error in error for error in report["errors"]) + finally: + connection.exec_driver_sql(restore) + assert verify_schema(connection)["errors"] == [] diff --git a/tests/persistence/test_unit.py b/tests/persistence/test_unit.py new file mode 100644 index 0000000..be355eb --- /dev/null +++ b/tests/persistence/test_unit.py @@ -0,0 +1,117 @@ +import json +from pathlib import Path +from unittest.mock import Mock + +import pytest +from alembic import command +from argon2 import Type, extract_parameters + +from packages.persistence.bootstrap import PASSWORD_HASHER, TenantInput, bootstrap_tenant +from packages.persistence.cli import main, migration_config +from packages.persistence.database import DatabaseSafetyError, database_engine +from packages.persistence.schema import ( + MODEL_PATH, + check_tree, + column_type, + expression_tokens, + model, +) + +ROOT = Path(__file__).resolve().parents[2] + + +def test_frozen_migration_matches_authoritative_design(): + assert model() == json.loads((ROOT / "contracts/data-model.json").read_text(encoding="utf-8")) + snapshot = MODEL_PATH.with_suffix(".sql").read_text(encoding="utf-8").strip() + assert snapshot == (ROOT / "contracts/database-design.sql").read_text(encoding="utf-8").strip() + + +@pytest.mark.parametrize("environment", ["production", "staging", "", "prod"]) +def test_no_production_database_access(monkeypatch, environment): + monkeypatch.setenv("APP_ENV", environment) + with pytest.raises(RuntimeError, match="production"): + database_engine() + + +@pytest.mark.parametrize( + "url,schema", + [ + ("mysql+pymysql://u:p@localhost/mysql", "mysql"), + ("mysql+pymysql://u:p@localhost/labsafe_test_other", "labsafe_test_confirmed"), + ("sqlite:///labsafe_test_confirmed", "labsafe_test_confirmed"), + ("mysql+pymysql://u@localhost/labsafe_test_confirmed", "labsafe_test_confirmed"), + ( + "mysql+pymysql://u:p@localhost/labsafe_test_confirmed?local_infile=1", + "labsafe_test_confirmed", + ), + ("malformed-SECRET", "labsafe_test_confirmed"), + ("mysql+pymysql://u:p@remote.example/labsafe_test_confirmed", "labsafe_test_confirmed"), + ], +) +def test_url_guards_do_not_connect_or_expose_secrets(monkeypatch, tmp_path, url, schema): + secret = tmp_path / "url" + secret.write_text(url, encoding="utf-8") + monkeypatch.setenv("APP_ENV", "test") + monkeypatch.setenv("DATABASE_URL_FILE", str(secret)) + monkeypatch.setenv("DATABASE_SCHEMA", schema) + with pytest.raises(DatabaseSafetyError) as failure: + database_engine() + assert url not in str(failure.value) + + +def test_password_policy_parameters_and_random_salt(): + password = " Synthetic password 中文 " + first = PASSWORD_HASHER.hash(password) + second = PASSWORD_HASHER.hash(password) + assert first != second + parameters = extract_parameters(first) + assert ( + parameters.type, + parameters.time_cost, + parameters.memory_cost, + parameters.parallelism, + parameters.salt_len, + parameters.hash_len, + ) == (Type.ID, 3, 65536, 1, 16, 32) + assert PASSWORD_HASHER.verify(first, password) + + +@pytest.mark.parametrize("password", ["", "x" * 11, "x" * 129]) +def test_bad_password_rejected_before_connection(password): + engine = Mock() + data = TenantInput("synthetic", "Test", "UTC", "admin", "Admin", "Testing") + with pytest.raises(DatabaseSafetyError, match="Password"): + bootstrap_tenant(engine, data, password) + engine.connect.assert_not_called() + + +def test_normalization_and_timezone_validation(): + data = TenantInput("synthetic", "Test", "UTC", " ADMIN ", "Admin", "Testing") + assert data.validated().username == "admin" + with pytest.raises(DatabaseSafetyError, match="timezone"): + TenantInput("synthetic", "Test", "Not/AZone", "admin", "Admin", "Testing").validated() + + +def test_expression_normalization_preserves_logic(): + assert check_tree("(a IS NULL) <> (b IS NULL)") == check_tree("((a is null) <> (b is null))") + assert check_tree("a=1 OR b=2 AND c=3") != check_tree("(a=1 OR b=2) AND c=3") + assert expression_tokens("coalesce(a,_utf8mb4\\'x\\')") == expression_tokens("COALESCE(a,'x')") + + +def test_type_normalization_preserves_case_sensitive_enum_values(): + assert column_type("ENUM('active')") == column_type("enum('active')") + assert column_type("ENUM('ACTIVE')") != column_type("ENUM('active')") + + +def test_offline_migration_cannot_bypass_guards(): + with pytest.raises(RuntimeError, match="Offline"): + command.upgrade(migration_config(), "head", sql=True) + + +def test_cli_sanitizes_driver_failures(monkeypatch, capsys): + def fail(): + raise RuntimeError("mysql://credential-SENTINEL") + + monkeypatch.setattr("packages.persistence.cli.database_engine", fail) + assert main(["verify"]) == 1 + assert "credential-SENTINEL" not in capsys.readouterr().err diff --git a/tools/database/run_mysql_tests.py b/tools/database/run_mysql_tests.py new file mode 100644 index 0000000..4f3c8ae --- /dev/null +++ b/tools/database/run_mysql_tests.py @@ -0,0 +1,165 @@ +"""Run integration tests using a new local mysqld or a dedicated GitHub CI service. + +Never discover, stop, reconfigure or use an existing local MySQL service. +""" + +import argparse +import os +import secrets +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from uuid import uuid4 + +import pymysql +from sqlalchemy.engine import URL + +ROOT = Path(__file__).resolve().parents[2] +NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def run_suite(connection, port, password, folder): + schema = "labsafe_test_" + uuid4().hex + # Identifier is internally generated, never taken from CLI/connection strings. + with connection.cursor() as cursor: + cursor.execute(f"CREATE DATABASE {schema} CHARACTER SET utf8mb4 COLLATE utf8mb4_bin") + url = URL.create( + "mysql+pymysql", + username="root", + password=password, + host="127.0.0.1", + port=port, + database=schema, + ) + secret = folder / "database-url" + secret.write_text(url.render_as_string(hide_password=False), encoding="utf-8") + secret.chmod(0o600) + environment = os.environ.copy() + environment.update( + APP_ENV="test", + DATABASE_SCHEMA=schema, + DATABASE_URL_FILE=str(secret), + LABSAFE_MYSQL_INTEGRATION="1", + PYTHONDONTWRITEBYTECODE="1", + PYTHONUTF8="1", + LABSAFE_ALLOW_DESTRUCTIVE_DOWNGRADE=schema, + ) + try: + return subprocess.run( + [sys.executable, "-m", "pytest", "tests/persistence", "-v", "-s", "--tb=short"], + cwd=ROOT, + env=environment, + check=False, + ).returncode + finally: + with connection.cursor() as cursor: + cursor.execute(f"DROP DATABASE {schema}") + secret.unlink(missing_ok=True) + + +def local_suite(mysqld, folder): + executable = Path(mysqld).resolve(strict=True) + data = folder / "data" + data.mkdir() + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + subprocess.run( + [ + str(executable), + "--no-defaults", + "--initialize-insecure", + f"--datadir={data}", + "--console", + ], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=120, + creationflags=NO_WINDOW, + ) + server = subprocess.Popen( + [ + str(executable), + "--no-defaults", + f"--datadir={data}", + f"--port={port}", + "--bind-address=127.0.0.1", + "--mysqlx=0", + "--skip-log-bin", + f"--log-error={folder / 'server.log'}", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=NO_WINDOW, + ) + connection = None + verified_own_server = False + try: + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if server.poll() is not None: + raise RuntimeError("Disposable mysqld exited during startup") + try: + connection = pymysql.connect( + host="127.0.0.1", port=port, user="root", connect_timeout=2, autocommit=True + ) + break + except pymysql.OperationalError: + time.sleep(0.2) + if connection is None: + raise RuntimeError("Disposable mysqld did not start within 60 seconds") + with connection.cursor() as cursor: + cursor.execute("SELECT @@datadir,@@port,VERSION()") + actual_data, actual_port, version = cursor.fetchone() + if Path(actual_data).resolve() != data.resolve() or actual_port != port: + raise RuntimeError("Refusing connection: not the server created by this process") + verified_own_server = True + print( + f"Isolated MySQL {version}; random loopback port; new temporary data directory", + flush=True, + ) + password = secrets.token_urlsafe(32) + cursor.execute("ALTER USER 'root'@'localhost' IDENTIFIED BY %s", (password,)) + return run_suite(connection, port, password, folder) + finally: + if connection is not None: + if verified_own_server: + try: + with connection.cursor() as cursor: + cursor.execute("SHUTDOWN") + except pymysql.Error: + pass + connection.close() + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + server.terminate() # Only the child process created above, never a service/PID search. + server.wait(timeout=10) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--mysqld", help="Path to installed MySQL server executable, not a data dir") + mode.add_argument("--ci", action="store_true", help="Use this job's disposable CI service") + arguments = parser.parse_args() + with tempfile.TemporaryDirectory(prefix="labsafe-i01b-") as directory: + folder = Path(directory) + if arguments.mysqld: + return local_suite(arguments.mysqld, folder) + if os.environ.get("CI") != "true": + raise RuntimeError("--ci is restricted to a disposable CI job service") + port = int(os.environ["LABSAFE_CI_MYSQL_PORT"]) + password = os.environ["LABSAFE_CI_MYSQL_PASSWORD"] + with pymysql.connect( + host="127.0.0.1", port=port, user="root", password=password, autocommit=True + ) as connection: + return run_suite(connection, port, password, folder) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/design/data_model.py b/tools/design/data_model.py index 2530399..9a13e94 100644 --- a/tools/design/data_model.py +++ b/tools/design/data_model.py @@ -75,7 +75,9 @@ def build_database(): lines=[] for col,v in t['columns'].items(): z=q(col)+' '+v['type'] - if v['generated_sql']:z+=' GENERATED ALWAYS AS ('+v['generated_sql']+') STORED' + if v['generated_sql']: + z+=' GENERATED ALWAYS AS ('+v['generated_sql']+') STORED' + z+=' NULL' if v['nullable'] else ' NOT NULL' else: z+=' NULL' if v['nullable'] else ' NOT NULL' if v['default_sql'] is not None:z+=' DEFAULT '+v['default_sql'] From 631280e8d93017a2a0b2da51571d8a5e24ac6632 Mon Sep 17 00:00:00 2001 From: SayQuan1 <15246906705@163.com> Date: Wed, 30 Sep 2026 10:22:47 +0800 Subject: [PATCH 3/3] docs(i-01b): record merged prerequisite and CI evidence Update the I-01B handoff after I-01A merges and record successful remote checks without treating CI as review approval. --- DEVELOPMENT.md | 4 ++-- docs/08-delivery/08-i01b-acceptance.md | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 93f4466..919200a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -198,6 +198,6 @@ CI 必须先 --check,不能先生成来掩盖漂移。设计校验不是应用 遵循 [Git 协作规范](CONTRIBUTING.md)。提交特性分支、创建 PR;不直接推送 main,不自行合并或代替独立审查。 不提交虚拟环境、.local-secrets、真实图片、权重或 .env。 I-01B 的实际结果与后续边界见 [验收记录](docs/08-delivery/08-i01b-acceptance.md)。 -本地 I-01B 分支继承尚未评审的 PR #5,属于依赖分支,不代表前置代码已获批准;未自动创建新 PR 或推送。 -后续提交时按 CONTRIBUTING 独立评审,先完成 I-01A 合并,再基于最新 origin/main 整理仅 I-01B 的提交,重新跑 CI;不直接推送 main。 +I-01A PR #5 已合并;I-01B 在 wsq/i-01b-database-foundation 上通过 PR #7 提交,目标为最新 main。数据库/安全变更仍需按 CONTRIBUTING 独立评审;不直接推送 main。 +PR #7 的远程 CI 必须运行其自身的 persistence-mysql、业务、AI、设计和 Web 检查,不能用 PR #5 的成绩代替。 下一阶段 I-01C 是仓储、会话/RBAC、幂等与租户事务;真实模型主线可按 I-ML-01 推进,但不以数据库通过替代模型或生产验收。 diff --git a/docs/08-delivery/08-i01b-acceptance.md b/docs/08-delivery/08-i01b-acceptance.md index ac524b2..853dceb 100644 --- a/docs/08-delivery/08-i01b-acceptance.md +++ b/docs/08-delivery/08-i01b-acceptance.md @@ -5,10 +5,9 @@ ## 1. 分支与前置依赖 -本地分支 wsq/i-01b-database-foundation,继承 PR #5 的 I-01A 提交 e112680。 -PR #5 尚未完成评审时只进行依赖开发,不自动合并、推送 main 或修改该 PR。 -本次没有提交/推送 I-01B,也未创建新的 PR;远程 CI 尚未验证本次增量。 -后续按 CONTRIBUTING 整理仅 I-01B 的提交、重跑 CI、说明数据库/安全风险,并由两名协作者共同确认。 +本地分支 wsq/i-01b-database-foundation,基于已合并的 I-01A PR #5(main 提交 3a92e09)。 +I-01B PR #7 已推送并以 main 为目标;本页保留分支依赖关系,数据库/安全变更仍需两名协作者共同确认。 +远程 CI 已运行并通过 business-python311、ai-python311、design-contracts、persistence-mysql 和 web 检查;通过 CI 不替代代码评审。 ## 2. 已交付 @@ -46,7 +45,7 @@ Windows、Python 3.11.4、MySQL Community Server 8.0.33。数据库测试使用 | 环境与静态检查 | 两环境 pip check 通过;AI 无 SQLAlchemy/Alembic/PyMySQL/Argon2/Celery/Redis/模型依赖;Ruff check/format 通过(50 个 Python 文件) | | 发布包 | 使用声明的隔离构建依赖构建 wheel,安装到新临时目录后可找到两份快照和 Alembic head,不依赖源码目录 | | 设计检查 | build_specs --check、148 项 IRR 合成检查、validate_specs(含 41 发布/39 D-FINE 合成检查)通过 | -| CI 配置 | YAML 结构校验通过;新 MySQL job 待本次分支提交远程后执行,不能复用 PR #5 的成绩 | +| CI 配置 | YAML 结构校验通过;PR #7 的新 MySQL job 已在远程运行并通过,未复用 PR #5 的成绩 | MySQL 8.0.33 只是本次本机/CI 兼容性测试基线,不是生产镜像或安全补丁选型结论。 设计工具仍声明未运行应用/数据库测试,是该工具自身的证据范围;本页单独记录真实数据库结果,二者不能互相替代。 @@ -54,7 +53,7 @@ MySQL 8.0.33 只是本次本机/CI 兼容性测试基线,不是生产镜像或 ## 4. 本轮发现并关闭的差异 真实 introspection 发现设计字典要求 user_roles.scope_key 非空,但 DDL 生成器遗漏 NOT NULL。 -已修复 tools/design/data_model.py、重新生成 contracts/database-design.sql,并同步尚未发布的 0001_initial 快照;数据字典和角色规则没有变更。 +已修复 tools/design/data_model.py、重新生成 contracts/database-design.sql,并同步 0001_initial 快照;数据字典和角色规则没有变更。 MySQL 8 的默认认证所需 RSA 依赖通过 PyMySQL[rsa] 补齐;Argon2id 和时区依赖仅加入业务侧。 ## 5. 操作、回滚与后续边界