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 산출물은 커밋에서 제외했다.
257 lines
9.1 KiB
Python
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())
|