vignette/scripts/smoke-multimodal-consent-concurrency.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
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 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

257 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Actual-Postgres proof for G7 consent-withdrawal serialization.
The probe creates a unique synthetic learner/session, grants derived-feature
processing, holds the shared consent transaction lock while committing a
withdrawal, and proves a concurrent evaluator timeline write remains blocked
until that withdrawal commits and then fails closed. No microphone, provider,
login, or external service is used. The append-only fixture is intentionally
left in the expendable development database as audit evidence.
"""
from __future__ import annotations
import asyncio
import json
import sys
import time
from pathlib import Path
from uuid import UUID, uuid4
API_ROOT = Path(__file__).resolve().parents[1] / "apps" / "api"
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from app import db # noqa: E402
from app.contracts.multimodal_alliance import AlignedVoiceTimeline # noqa: E402
from app.deps import Principal, Role # noqa: E402
from app.services import multimodal_alliance_store as store # noqa: E402
def learner_principal(learner_id: UUID) -> Principal:
return Principal(
user_id=str(learner_id),
role=Role.LEARNER,
cohort_ids=["g7-consent-concurrency"],
consent_at=1.0,
profile_completed_at=1.0,
)
async def seed_fixture(learner_id: UUID, session_id: UUID) -> None:
pool = db.get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.app_user (
user_id, external_id, email, display_name, role, cohort,
consent_at, profile_completed_at
) VALUES ($1,$2,$3,'G7 Consent Concurrency','learner',
'g7-consent-concurrency',now(),now())
""",
learner_id,
f"g7-consent-concurrency:{learner_id}",
f"{learner_id}@g7-consent-concurrency.invalid",
)
async with db.acquire(
role="learner",
user_id=str(learner_id),
cohort_ids=["g7-consent-concurrency"],
) as conn:
await conn.execute(
"""
INSERT INTO app.sessions (id, learner_id, theory_mode)
VALUES ($1,$2,'integrative')
""",
session_id,
learner_id,
)
async def persist_timeline(session_id: UUID, submission_id: UUID) -> dict:
async with db.acquire(ai_view="evaluator", ai_context=True) as conn:
return await store.append_timeline(
conn=conn,
session_id=session_id,
submission_id=submission_id,
timeline=AlignedVoiceTimeline(
audio_duration_ms=1000,
words=(),
events=(),
),
audio_asset=None,
)
async def append_withdrawal_inside_lock(
*,
conn,
learner_id: UUID,
session_id: UUID,
submission_id: UUID,
) -> UUID:
latest = await store._latest_consent(conn, session_id)
if latest is None or latest["consent_status"] != "granted":
raise AssertionError("concurrency probe requires a current granted consent")
consent_snapshot_id = uuid4()
await store._insert_request(
conn,
submission_id=submission_id,
content_hash=store._canonical_hash(
{
"probe": "concurrent-withdrawal",
"session_id": str(session_id),
}
),
request_kind="consent",
session_id=session_id,
learner_id=learner_id,
result_id=consent_snapshot_id,
created_by_role="learner",
)
await conn.execute(
"""
INSERT INTO app.multimodal_consent_snapshot (
consent_snapshot_id, submission_id, session_id, learner_id,
sequence_no, consent_status, retain_audio, retain_derived_features,
transcript_retained, retention_days, policy_version, reason_code,
created_by_uid
) VALUES ($1,$2,$3,$4,$5,'withdrawn',FALSE,FALSE,TRUE,NULL,
'g7-concurrency-v1','concurrency_probe',$4)
""",
consent_snapshot_id,
submission_id,
session_id,
learner_id,
int(latest["sequence_no"]) + 1,
)
deletion_submission_id, deletion_request_id = store._withdrawal_ids(submission_id)
await store._insert_deletion_request(
conn,
submission_id=deletion_submission_id,
deletion_request_id=deletion_request_id,
session_id=session_id,
learner_id=learner_id,
scopes=("audio", "derived_features"),
request_reason="consent_withdrawal",
requested_by_uid=learner_id,
created_by_role="learner",
)
return consent_snapshot_id
async def main() -> None:
learner_id = uuid4()
session_id = uuid4()
grant_submission_id = uuid4()
withdrawal_submission_id = uuid4()
timeline_submission_id = uuid4()
await db.init_pool()
timeline_task: asyncio.Task[dict] | None = None
try:
await seed_fixture(learner_id, session_id)
learner = learner_principal(learner_id)
await store.append_consent_snapshot(
principal=learner,
session_id=session_id,
submission_id=grant_submission_id,
consent_status="granted",
retain_audio=False,
retain_derived_features=True,
transcript_retained=True,
retention_days=30,
policy_version="g7-concurrency-v1",
reason_code=None,
)
blocked_while_withdrawal_uncommitted = False
started_at = time.monotonic()
async with db.acquire(
role="learner",
user_id=str(learner_id),
cohort_ids=["g7-consent-concurrency"],
) as withdrawal_conn:
await store._lock_consent_processing(withdrawal_conn, session_id)
timeline_task = asyncio.create_task(
persist_timeline(session_id, timeline_submission_id)
)
try:
await asyncio.wait_for(asyncio.shield(timeline_task), timeout=0.25)
except TimeoutError:
blocked_while_withdrawal_uncommitted = True
else:
raise AssertionError(
"timeline write escaped the shared consent transaction lock"
)
withdrawal_snapshot_id = await append_withdrawal_inside_lock(
conn=withdrawal_conn,
learner_id=learner_id,
session_id=session_id,
submission_id=withdrawal_submission_id,
)
write_blocked_after_commit = False
try:
await asyncio.wait_for(timeline_task, timeout=5.0)
except store.MultimodalConsentRequiredError:
write_blocked_after_commit = True
if not write_blocked_after_commit:
raise AssertionError("timeline write was not rejected after withdrawal commit")
async with db.acquire(ai_view="evaluator", ai_context=True) as conn:
timeline_rows = await conn.fetchval(
"""
SELECT count(*) FROM app.multimodal_audio_timeline
WHERE submission_id = $1
""",
timeline_submission_id,
)
latest = await store._latest_consent(conn, session_id)
if timeline_rows != 0:
raise AssertionError("a post-withdrawal timeline row was persisted")
if latest is None or latest["consent_snapshot_id"] != withdrawal_snapshot_id:
raise AssertionError("withdrawal is not the latest consent snapshot")
voice_processing_blocked = False
try:
await store.assert_voice_processing_allowed(
principal=learner,
session_id=str(session_id),
)
except store.MultimodalConsentWithdrawnError:
voice_processing_blocked = True
if not voice_processing_blocked:
raise AssertionError("withdrawal did not block subsequent voice processing")
print(
json.dumps(
{
"schema_version": "vignette.g7-consent-concurrency-proof.v1",
"status": "passed",
"actual_postgres": True,
"synthetic_fixture": True,
"session_id": str(session_id),
"blocked_while_withdrawal_uncommitted": (
blocked_while_withdrawal_uncommitted
),
"write_blocked_after_withdrawal_commit": (
write_blocked_after_commit
),
"timeline_rows_persisted": timeline_rows,
"subsequent_voice_processing_blocked": voice_processing_blocked,
"elapsed_ms": round((time.monotonic() - started_at) * 1000),
"microphone_used": False,
"external_provider_used": False,
},
ensure_ascii=False,
indent=2,
)
)
finally:
if timeline_task is not None and not timeline_task.done():
timeline_task.cancel()
await db.close_pool()
if __name__ == "__main__":
asyncio.run(main())