8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
276 lines
11 KiB
Python
276 lines
11 KiB
Python
"""Fail-closed census for every provenance-bearing table in the local database.
|
|
|
|
The report contains counts and identifiers only. It never emits connection
|
|
strings, free-text evidence, assessment payloads, or transcript content.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import asyncpg
|
|
|
|
|
|
PROVENANCE_COLUMNS = {
|
|
"source_kind",
|
|
"perspective",
|
|
"instrument_id",
|
|
"model_run_id",
|
|
"evidence_turn_ids",
|
|
}
|
|
KNOWN_PRODUCER_TABLES = {
|
|
"app.calibration_assessment_snapshot",
|
|
"app.calibration_metacognitive_prescription",
|
|
"app.calibration_performance_observation",
|
|
"app.calibration_prediction_revision",
|
|
"app.calibration_subgroup_drift_report",
|
|
"app.calibration_teacher_review_event",
|
|
"app.calibration_transfer_assessment",
|
|
"app.calibration_transfer_execution_event",
|
|
"app.calibration_transfer_suite",
|
|
"app.calibration_transfer_trial",
|
|
"app.competency_graph_snapshot",
|
|
"app.measurement_event",
|
|
"app.measurement_instrument",
|
|
"app.multimodal_axis_measurement",
|
|
"app.outcome_trajectory_observation",
|
|
"app.practice_attempt_evidence",
|
|
"app.practice_coaching_card",
|
|
"app.practice_episode_submission",
|
|
"app.practice_prescription",
|
|
"app.practice_teacher_correction",
|
|
"app.relationship_memory_event",
|
|
"app.rupture_observation_event",
|
|
"app.rupture_reconciliation_revision",
|
|
"app.self_assessment",
|
|
"app.supervision_evaluation_batch",
|
|
"app.supervision_teacher_ai_disagreement",
|
|
"audit.model_run",
|
|
"ds.benchmark_case",
|
|
"ds.benchmark_observation",
|
|
}
|
|
SOURCE_KINDS = {
|
|
"learner_reported",
|
|
"agent_reported",
|
|
"model_inferred",
|
|
"human_rated",
|
|
"observed_runtime",
|
|
"simulated_state",
|
|
}
|
|
PERSPECTIVES = {
|
|
"learner_self_report",
|
|
"client_agent_report",
|
|
"independent_observer",
|
|
"supervisor_human",
|
|
"runtime_observation",
|
|
"client_simulation",
|
|
}
|
|
SOURCE_PERSPECTIVE = {
|
|
"learner_reported": {"learner_self_report"},
|
|
"agent_reported": {"client_agent_report"},
|
|
"model_inferred": {"independent_observer"},
|
|
"human_rated": {"supervisor_human"},
|
|
"observed_runtime": {"runtime_observation"},
|
|
"simulated_state": {"client_simulation"},
|
|
}
|
|
TABLE_SOURCE_KIND_OVERRIDES = {
|
|
"app.multimodal_axis_measurement": {
|
|
"model_inferred_text",
|
|
"model_inferred_voice",
|
|
},
|
|
}
|
|
# G7 records provider-specific instrument/model identifiers under its own
|
|
# constrained ledger. They are deliberately not foreign keys into the G0
|
|
# instrument/model-run tables, so central orphan checks would be category errors.
|
|
TABLE_LOCAL_PROVENANCE = {"app.multimodal_axis_measurement"}
|
|
|
|
|
|
class CensusError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _load_api_env() -> None:
|
|
env_path = Path("apps/api/.env")
|
|
if not env_path.exists():
|
|
return
|
|
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
|
|
|
|
|
def _identifier(value: str) -> str:
|
|
if not value.replace("_", "").isalnum():
|
|
raise CensusError(f"unsafe SQL identifier discovered: {value!r}")
|
|
return '"' + value + '"'
|
|
|
|
|
|
async def _scalar(conn: asyncpg.Connection[Any], sql: str) -> int:
|
|
return int(await conn.fetchval(sql) or 0)
|
|
|
|
|
|
async def census(dsn: str) -> dict[str, Any]:
|
|
conn = await asyncpg.connect(dsn)
|
|
try:
|
|
# The application role is intentionally fail-closed under RLS. The
|
|
# census is an evaluator-plane integrity job, so establish the same
|
|
# explicit AI view used by runtime provenance workers before counting.
|
|
await conn.execute("SELECT set_config('app.ai_context', '1', false)")
|
|
await conn.execute("SELECT set_config('app.current_ai_view', 'evaluator', false)")
|
|
columns = await conn.fetch(
|
|
"""
|
|
SELECT table_schema, table_name, column_name, data_type, udt_name
|
|
FROM information_schema.columns
|
|
WHERE table_schema IN ('app','audit','ds')
|
|
ORDER BY table_schema, table_name, ordinal_position
|
|
"""
|
|
)
|
|
discovered: dict[str, dict[str, tuple[str, str]]] = {}
|
|
for row in columns:
|
|
key = f"{row['table_schema']}.{row['table_name']}"
|
|
discovered.setdefault(key, {})[str(row["column_name"])] = (
|
|
str(row["data_type"]),
|
|
str(row["udt_name"]),
|
|
)
|
|
discovered = {
|
|
table: table_columns
|
|
for table, table_columns in discovered.items()
|
|
if PROVENANCE_COLUMNS & set(table_columns)
|
|
}
|
|
unknown_tables = sorted(set(discovered) - KNOWN_PRODUCER_TABLES)
|
|
missing_tables = sorted(KNOWN_PRODUCER_TABLES - set(discovered))
|
|
violations: dict[str, int] = {
|
|
"unknown_producer_table": len(unknown_tables),
|
|
"missing_registered_producer_table": len(missing_tables),
|
|
}
|
|
table_reports: list[dict[str, Any]] = []
|
|
for table in sorted(discovered):
|
|
schema, name = table.split(".", 1)
|
|
qualified = f"{_identifier(schema)}.{_identifier(name)}"
|
|
table_columns = discovered[table]
|
|
checks: dict[str, int] = {"row_count": await _scalar(conn, f"SELECT count(*) FROM {qualified}")}
|
|
for column in ("source_kind", "perspective", "instrument_id", "instrument_version"):
|
|
if column in table_columns:
|
|
checks[f"null_or_blank_{column}"] = await _scalar(
|
|
conn,
|
|
f"SELECT count(*) FROM {qualified} WHERE {_identifier(column)} IS NULL OR btrim({_identifier(column)}::text) = ''",
|
|
)
|
|
if "source_kind" in table_columns:
|
|
allowed_source_kinds = TABLE_SOURCE_KIND_OVERRIDES.get(
|
|
table, SOURCE_KINDS
|
|
)
|
|
allowed = ",".join(
|
|
"'" + item + "'" for item in sorted(allowed_source_kinds)
|
|
)
|
|
checks["unknown_source_kind"] = await _scalar(
|
|
conn,
|
|
f"SELECT count(*) FROM {qualified} WHERE source_kind IS NOT NULL AND source_kind NOT IN ({allowed})",
|
|
)
|
|
if "perspective" in table_columns:
|
|
allowed = ",".join("'" + item + "'" for item in sorted(PERSPECTIVES))
|
|
checks["unknown_perspective"] = await _scalar(
|
|
conn,
|
|
f"SELECT count(*) FROM {qualified} WHERE perspective IS NOT NULL AND perspective NOT IN ({allowed})",
|
|
)
|
|
if (
|
|
{"source_kind", "perspective"} <= set(table_columns)
|
|
and table not in TABLE_SOURCE_KIND_OVERRIDES
|
|
):
|
|
valid_pairs = " OR ".join(
|
|
f"(source_kind='{source}' AND perspective='{perspective}')"
|
|
for source, perspectives in SOURCE_PERSPECTIVE.items()
|
|
for perspective in perspectives
|
|
)
|
|
checks["incompatible_source_perspective"] = await _scalar(
|
|
conn,
|
|
f"SELECT count(*) FROM {qualified} WHERE source_kind IS NOT NULL AND perspective IS NOT NULL AND NOT ({valid_pairs})",
|
|
)
|
|
if (
|
|
{"instrument_id", "instrument_version"} <= set(table_columns)
|
|
and table != "app.measurement_instrument"
|
|
and table not in TABLE_LOCAL_PROVENANCE
|
|
):
|
|
checks["orphan_instrument"] = await _scalar(
|
|
conn,
|
|
f"SELECT count(*) FROM {qualified} p LEFT JOIN app.measurement_instrument i ON i.instrument_id=p.instrument_id AND i.instrument_version=p.instrument_version WHERE p.instrument_id IS NOT NULL AND i.instrument_id IS NULL",
|
|
)
|
|
if (
|
|
"model_run_id" in table_columns
|
|
and table != "audit.model_run"
|
|
and table not in TABLE_LOCAL_PROVENANCE
|
|
):
|
|
checks["orphan_model_run"] = await _scalar(
|
|
conn,
|
|
f"SELECT count(*) FROM {qualified} p LEFT JOIN audit.model_run m ON m.model_run_id=p.model_run_id WHERE p.model_run_id IS NOT NULL AND m.model_run_id IS NULL",
|
|
)
|
|
if "source_kind" in table_columns:
|
|
checks["missing_required_model_run"] = await _scalar(
|
|
conn,
|
|
f"SELECT count(*) FROM {qualified} WHERE source_kind IN ('model_inferred','agent_reported') AND model_run_id IS NULL",
|
|
)
|
|
if "evidence_turn_ids" in table_columns and table_columns["evidence_turn_ids"][1] == "_uuid":
|
|
checks["orphan_evidence_turn"] = await _scalar(
|
|
conn,
|
|
f"SELECT count(*) FROM {qualified} p CROSS JOIN LATERAL unnest(COALESCE(p.evidence_turn_ids, ARRAY[]::uuid[])) evidence(turn_id) LEFT JOIN app.turns t ON t.id=evidence.turn_id WHERE t.id IS NULL",
|
|
)
|
|
for check, count in checks.items():
|
|
if check != "row_count":
|
|
violations[check] = violations.get(check, 0) + count
|
|
table_reports.append(
|
|
{
|
|
"producer_table": table,
|
|
"provenance_contract": (
|
|
"table_local" if table in TABLE_LOCAL_PROVENANCE else "g0_central"
|
|
),
|
|
**checks,
|
|
}
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
failed = {name: count for name, count in violations.items() if count}
|
|
return {
|
|
"ok": not failed,
|
|
"policy": "fail_closed_on_unknown_producer_or_null_or_orphan_provenance",
|
|
"registered_producer_table_count": len(KNOWN_PRODUCER_TABLES),
|
|
"discovered_producer_table_count": len(discovered),
|
|
"unknown_producer_tables": unknown_tables,
|
|
"missing_registered_producer_tables": missing_tables,
|
|
"violations": violations,
|
|
"failed_checks": failed,
|
|
"tables": table_reports,
|
|
}
|
|
|
|
|
|
async def _run(args: argparse.Namespace) -> dict[str, Any]:
|
|
_load_api_env()
|
|
dsn = args.database_url or os.environ.get("DATABASE_URL")
|
|
if not dsn:
|
|
raise CensusError("DATABASE_URL is required via --database-url or apps/api/.env")
|
|
return await census(dsn)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--database-url", default="")
|
|
parser.add_argument("--out", default="")
|
|
args = parser.parse_args()
|
|
result = asyncio.run(_run(args))
|
|
text = json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
|
if args.out:
|
|
path = Path(args.out)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text + "\n", encoding="utf-8")
|
|
print(text)
|
|
if not result["ok"]:
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|