From ee12e3fddefec1c4038e6da333019e3455e6a50f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 4 Oct 2026 16:54:32 +0900 Subject: [PATCH 1/7] feat: add deterministic local review summary Implement PRD US-REVIEW-01 with canonical findings, minimal TXT disclosure, guarded downloads and native browser acceptance. Preserve layered agent decisions, source-bound verification and unresolved protected integration gates. --- ARCHITECTURE.md | 4 + CHANGELOG.md | 1 + README.md | 7 + docs/ADR-0006-local-review-summary.md | 40 ++ docs/PRD.md | 18 +- docs/TRD.md | 10 + docs/evidence/mixed-agents-review-summary.md | 79 ++++ .../review-summary-successor-20261004.md | 34 ++ docs/index.md | 3 + docs/product-technical-gap-baseline.md | 13 + docs/research-traceability.md | 8 + src/App.tsx | 26 +- src/policy-review-report.test.ts | 203 +++++++++ src/policy-review-report.ts | 49 ++ src/policy-review-ui.test.tsx | 81 ++++ src/styles.css | 2 + tests/e2e/review-summary-boundaries.spec.ts | 431 ++++++++++++++++++ tests/e2e/review-summary.spec.ts | 40 ++ tests/review_summary_guard_contract.mjs | 249 ++++++++++ 19 files changed, 1295 insertions(+), 3 deletions(-) create mode 100644 docs/ADR-0006-local-review-summary.md create mode 100644 docs/evidence/mixed-agents-review-summary.md create mode 100644 docs/evidence/review-summary-successor-20261004.md create mode 100644 src/policy-review-report.test.ts create mode 100644 src/policy-review-report.ts create mode 100644 src/policy-review-ui.test.tsx create mode 100644 tests/e2e/review-summary-boundaries.spec.ts create mode 100644 tests/e2e/review-summary.spec.ts create mode 100644 tests/review_summary_guard_contract.mjs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd8904c..3708ea4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,6 +33,10 @@ Core invariants: ## Current implementation The active MVP is a React/Vite browser workspace. State is in memory and there is no production persistence or publication backend. The browser can download and restore the exact deterministic schema-v1 JSON draft containing normalized operator-authored facts and readiness finding codes. Restore treats the local file as untrusted input, admits only the closed schema and catalog, and recomputes derived readiness evidence before atomically replacing workspace state. This local portability boundary is not publication, persistence, backup, or legal approval. The seven PRD steps are routed to distinct editing surfaces. The collection taxonomy is metadata only. `src/policy.ts` owns deterministic collection-selection/no-collection/mode/purpose/path, non-collection authoring-completeness findings, and schema-v1 validation/reconstruction; `src/App.tsx` owns browser orchestration, bounded local file selection, explicit no-collection and transfer-status capture, warning-to-source navigation, stale dependent-fact invalidation, and deterministic preview rendering. `src/AuthoringFocusController.tsx` is a browser interaction adapter: after explicit rail, previous/next, or review-warning navigation changes the active editing surface, it moves programmatic focus to that surface's heading without changing domain state, intercepting ordinary field interaction, or overriding the separate preview-focus shortcut. +The separate `src/policy-review-report.ts` read projection creates the local minimal TXT review summary via `createPolicyReviewText`. It consumes `createPolicyExport` for canonical service identity/state/ordered codes, `getCompletedSteps` for seven responsibility states, and `getReview` for recommendation labels; it is not a second readiness engine or aggregate. Each code keeps one row, with `단계 미상` fallback instead of data loss. Detailed path/purpose, retention, recipient/country and contact values are excluded; service identity remains disclosure-bearing. JSON quoting plus visible Unicode line/direction-control escaping applies at this TXT boundary, without claiming HTML/Markdown sanitization. + +`App.tsx` owns the fixed-name `policyweave-review.txt` browser download, pending-import disable/handler guard, bounded failure feedback and next-task object-URL cleanup after allocation. Download initiation changes feedback only, not authored facts, navigation or readiness. Presentation `report_format: v1` is independent of fact `schema_version: 1` and neither is a publication revision. [ADR-0006](docs/ADR-0006-local-review-summary.md) records the choice; [local layered proposal/review evidence](docs/evidence/mixed-agents-review-summary.md) records the development workflow, not runtime product AI, heterogeneous-model verification or an approval receipt. No new network, storage, dependency or legal-rule boundary is introduced. + Authoring completeness is deliberately separate from legal sufficiency. Current readiness rules prove that product-defined fact responsibilities were explicitly addressed; they do not assert that a policy complies with law. Source/effective-date-bound legal validation belongs to the Legal Source Registry -> Review & Publication boundary. ## Persistence boundary (Proposed schema; CI-only runtime) diff --git a/CHANGELOG.md b/CHANGELOG.md index 512f36f..878a1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri ## Unreleased ### Added +- Local minimal TXT review summary on the candidate branch: `createPolicyReviewText` reuses canonical export state/service identity/ordered finding codes, existing seven-step completion and recommendation derivation; `검토 요약 다운로드` starts `policyweave-review.txt` (`text/plain;charset=utf-8`) without network or source-state changes. It omits detailed operational/contact facts, distinguishes report-format v1 from facts-schema v1, retains unknown findings and escapes dynamic line/direction controls. Import-time disable/handler guard, contained preparation/activation failures and deferred object-URL reclamation preserve the local boundary. ADR-0006 and the layered MoA-inspired decision record record completed local full-suite/browser successor evidence separately from unresolved visual inspection, exact-head hosted CI and qualifying approval; this entry is not a release or passing receipt. - Fail-closed local schema-v1 draft restore with exact object-shape, canonical-string, collection-catalog, closed-status, contradiction, and derived-evidence validation. Files above 1 MiB are rejected before parsing, invalid files preserve the current workspace, and accepted facts are reconstructed without trusting file-supplied readiness or finding claims. The import and authoring controls are disabled only while a selected file is read and validated, then re-enabled on success or failure so accepted restore cannot overwrite concurrent edits; the visible import control exposes its disabled state and the existing live status region announces `JSON 초안 확인 중` during that interval. The native authoring `fieldset` remains a semantic grid item rather than using `display: contents`. Its local-file control retains a visible high-contrast keyboard focus indicator and 44 px target. The return path performs no network request and does not claim cross-version migration, persistence, backup, publication, or legal approval. - Runtime categorical-status admission regression matrix: 64 invalid-input cases, all 16 valid collection/retention/transfer combinations and disabled-item isolation. The matrix verifies stable owning findings, incomplete readiness, null export of unsupported statuses, source non-mutation and deterministic valid projections; ADR-0004 binds its hosted RED and bounded local verification without claiming a released external interoperability or cross-version migration contract. - Executable npm manifest/lock/license contracts and an exact-head CycloneDX SBOM artifact. Every direct declaration must equal its reviewed lock resolution, the lock root must match the manifest, and every locked package must retain machine-readable license metadata. diff --git a/README.md b/README.md index 24dccec..63299ca 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ PolicyWeave는 범용 법률 문구를 임의로 채우는 생성기가 아닙 선택한 수집 항목에는 수집 경로와 처리 목적을 별도로 기록할 수 있습니다. 필수 사실이 없거나 처리 목적이 비어 있으면 검토본이 이를 숨기지 않고 차단 또는 검토 경고로 드러내며, 경고에서 원인이 있는 입력 단계로 돌아갈 수 있습니다. 작성 내용은 실시간 검토본에 반영되고 모바일·키보드 사용도 고려합니다. 현재 schema-v1 JSON 초안은 로컬로 내보내고, 정확한 검증과 준비 상태 재계산을 통과한 경우에만 다시 열 수 있습니다. +### 로컬 최소 검토 요약 + +미리보기의 `검토 요약 다운로드`는 미완료 초안에서도 `policyweave-review.txt`를 로컬로 내려받기 시작합니다. TXT에는 정규화된 서비스 이름/허용 URL, 제품 정의 준비 상태, 7단계 완료 상태, 순서를 보존한 필수 확인 코드와 권장 항목이 담깁니다. 처리 목적·수집 경로 값, 보유 기간, 제공/이전 수령자·국가, 담당자 연락처와 전체 사실은 담지 않습니다. 서비스 식별정보는 남을 수 있으므로 파일 보관·전달 범위를 직접 확인하세요. + +같은 입력은 같은 TXT를 만들며 타임스탬프를 추가하지 않습니다. 검토 요약 형식 v1과 사실 스키마 v1은 발행 버전이나 승인 표시가 아닙니다. TXT는 JSON 초안 복원 파일·공개 정책·법률 자문·저장 완료 증거를 대체하지 않습니다. JSON 확인 중에는 버튼이 잠기며, 실패하면 현재 작성 상태를 유지하고 재시도를 안내합니다. 현재 후보의 전체 검증과 독립 승인 상태는 [로컬 증거 기록](docs/evidence/mixed-agents-review-summary.md)에서 별도로 구분합니다. + ## 빠른 시작 현재 제품은 소스로 평가하는 초기 개발 버전입니다. 패키지 메타데이터 `0.1.0`은 게시된 릴리스를 뜻하지 않습니다. 아직 GitHub 릴리스가 없으므로 검토 중인 소스와 배포 가능한 제품을 구분해 사용하세요. @@ -111,6 +117,7 @@ PolicyWeave는 개인정보처리방침을 만들기 위해 불필요한 실제 - [아키텍처](ARCHITECTURE.md) — 제품 책임과 기술 경계 - [ADR-0001: Policy as Data](docs/ADR-0001-policy-as-data.md) — 핵심 설계 결정 - [ADR-0005: 로컬 schema-v1 초안 복원](docs/ADR-0005-local-draft-restore.md) — 신뢰하지 않는 로컬 파일의 fail-closed 복원 결정 +- [ADR-0006: 로컬 최소 TXT 검토 요약](docs/ADR-0006-local-review-summary.md) — 출력 최소화와 다운로드 실패 경계 - [제품·기술 Gap baseline](docs/product-technical-gap-baseline.md) — 아직 닫히지 않은 상용화 Gap과 완료 증거 - [공개 문서 홈](docs/index.md) — 저장소 문서 탐색 시작점 - [변경 이력](CHANGELOG.md) diff --git a/docs/ADR-0006-local-review-summary.md b/docs/ADR-0006-local-review-summary.md new file mode 100644 index 0000000..b47a238 --- /dev/null +++ b/docs/ADR-0006-local-review-summary.md @@ -0,0 +1,40 @@ +# ADR-0006: Local minimal TXT review summary + +- Status: Proposed +- Date: 2026-10-03 +- Owner: Review & Publication / Policy Fact Authoring +- Scope: `src/policy-review-report.ts`, `src/App.tsx`, PRD `US-REVIEW-01` +- Candidate base: `60fd7fb`, branch `feat-mixed-agents-prd`; no implementation commit or exact-head hosted PASS receipt is asserted here. +- Evidence: [local layered proposal, cross-review and verification record](evidence/mixed-agents-review-summary.md) + +## Problem and alternatives + +Operators need a portable view of missing responsibilities and current product-defined readiness, but exporting another full-facts document expands disclosure and duplicates the existing JSON portability boundary. A summary must not invent facts, claim legal approval, or diverge from the existing domain findings. + +1. **Full-facts Markdown**: the independent product proposal included normalized seven-step facts and findings. Deferred for this slice because it duplicates JSON, exposes contact and operational details, and adds Markdown/HTML output obligations. +2. **Minimal TXT**: the independent technical proposal limits output to canonical service identity, existing readiness/completion, blocker codes and recommendation labels. Selected after a second cross-review layer and parent aggregation, subject to all P1 controls below. +3. **PDF/HTML/hosted report or AI-generated assessment**: out of scope; no renderer dependency, new network surface, model runtime or legal rule is justified for a local deterministic summary. + +## Decision + +A separate pure `createPolicyReviewText` read projection consumes `createPolicyExport` for schema version, `document_state`, normalized service identity and ordered `review_finding_codes`; `getCompletedSteps` supplies seven-step completion and `getReview(...).recommended` supplies recommendation labels. No new readiness rule or source-of-truth store is introduced. + +- Emit one blocker row for each exported code, in exact order, without deduplication or suppression. `formatReviewFinding` decorates known codes with existing owning-step/label semantics; an unknown code stays visible as a quoted `단계 미상` row. +- Preserve collection contradiction's incomplete steps 2 and 3 even when the selected item has otherwise complete details. Recommendations never become blockers or proof of legal sufficiency. +- Include canonical service name/allowed URL only as authored identity values. Do not expose raw rejected credential/query/fragment URLs. Exclude path/purpose values, retention periods, recipient/country values, contact values and full fact objects. Identity and catalog/finding labels can still disclose context; the result is not guaranteed anonymous. +- JSON quote every dynamic string and visibly escape C1 controls (`U+0080–U+009F`), `U+061C`, `U+200E–U+200F`, `U+2028–U+202E` and `U+2066–U+2069`. This is line/direction-control handling for TXT, not universal HTML/Markdown sanitization. +- Emit deterministic text without timestamps or nonces. `report_format: v1` is presentation format, `schema_version: 1` is the existing fact contract, and neither is a publication or approval version. + +`DocumentPreview` exposes `검토 요약 다운로드`. Browser orchestration uses `policyweave-review.txt`, MIME `text/plain;charset=utf-8`, Blob and an object URL. The button is disabled during pending JSON import and `exportReview` independently guards that interval. Blob, URL allocation, anchor construction/configuration and click failures are contained with generic retry feedback, without exception text. An allocated URL is reclaimed on the next task after either success or activation failure, not synchronously before deferred browser consumption. + +Success announces download initiation, not file storage completion. Success and failure may change feedback only; items, facts, attestations, active step, readiness and completion remain unchanged. Browser cancellation, persistence, publication and approval are not established by starting a download. + +## Verification and acceptance + +PRD `US-REVIEW-01` defines user acceptance. `src/policy-review-report.test.ts` traces initial/incomplete and complete projections, exact ordered finding identity/cardinality, owning labels, recommendation separation, contradictory completion, hostile string encoding, detail omission and unknown fallback. `src/policy-review-ui.test.tsx` traces browser-adapter behavior. Required domain cases also include unsupported categorical values and unsafe URL non-disclosure; required UI/browser cases include full workspace preservation, import lock, fixed filename/MIME, deterministic bytes, mouse/keyboard/touch activation, preparation/activation exceptions and delayed cleanup. + +These are requirements/assertion traces, not blanket passing receipts. The evidence document preserves initial failures, bounded local successor passes and toolchain caveats separately. The parent completed local lint/tests/build and browser regressions; visual screenshot inspection remains unresolved. Exact-current-head organization workflows, resolved threads and qualifying independent approval remain unverified gates. No hosted release is claimed. + +## Consequences + +TXT aids review without duplicating the entire fact export; JSON remains the current-version restore artifact. Future summary format changes require reviewed presentation compatibility, while fact-schema migration remains ADR-0005's separate contract. Hosted review/publication still requires tenant authorization, immutable revision/audit, encryption and source/rule evidence. The MoA-inspired development workflow does not add product AI or satisfy GitHub independent approval. diff --git a/docs/PRD.md b/docs/PRD.md index ec84015..c4dacb7 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -17,7 +17,7 @@ PolicyWeave는 법률 문장을 임의로 창작하는 도구가 아니다. 운 - 개인정보를 수집하지 않는 경우의 명시적 운영자 확인과 수집 항목 사실의 상호배타성 - 개인정보 보유 여부의 명시적 `보유함`/`보유하지 않음` 확인; `보유함`일 때만 보유 기간 요구 - 제3자 제공과 국외 이전의 명시적 `있음`/`없음` 확인; `있음`일 때만 종속 상세 사실 요구 -- 공개 전 검토 요약과 버전 정보 +- 공개 전 로컬 최소 TXT 검토 요약과 별도 형식/사실 스키마 버전 정보(공개·승인·저장 완료의 증거 아님) - 정적 공개 URL 발행 계약(후속 백엔드에서 구현) - 버전이 명시된 JSON으로 현재 정책 사실과 검토 상태를 로컬 내보내기 @@ -28,6 +28,22 @@ PolicyWeave는 법률 문장을 임의로 창작하는 도구가 아니다. 운 - 검증이 실패하면 현재 작업공간을 보존하며, 1 MiB를 초과한 파일은 파싱 전에 거부한다. - 복원 경로는 브라우저 로컬에 한정하고 네트워크 전송·공개·호스팅 영속화를 주장하지 않는다. +## US-REVIEW-01: 로컬 최소 검토 요약 + +운영자로서 현재 작성 상태와 다음 확인 책임을 책임자와 검토하기 위해, 전체 처리 상세를 다시 복제하지 않는 로컬 TXT 요약을 다운로드하고 싶다. + +### 수락조건 + +1. 미완료 상태에서도 미리보기의 `검토 요약 다운로드`로 `policyweave-review.txt`를 내려받기 시작할 수 있다. MIME은 `text/plain;charset=utf-8`이며 네트워크 요청·새 저장소·외부 렌더러를 추가하지 않는다. +2. 요약은 기존 JSON 내보내기의 `document_state`, 정규화된 서비스 이름/허용 URL과 순서가 같은 차단 코드, 기존 완료 판정의 7단계 상태, 기존 권장 검토 항목을 보여 준다. 차단 코드마다 정확히 한 행을 유지하며 알 수 없는 코드는 버리지 않고 `단계 미상`으로 남긴다. 수집 모순은 수집 항목과 처리 목적 단계의 미완료 판정을 그대로 보존한다. +3. 수집 경로·처리 목적 값, 보유 기간, 제공/이전 수령자·국가, 담당자 이름·이메일 및 전체 사실 객체는 포함하지 않는다. 서비스 식별정보는 포함될 수 있으므로 보관·전달 범위를 운영자가 확인한다. 이는 익명화된 파일이라는 보장이 아니다. +4. 같은 입력은 같은 TXT를 만든다. 타임스탬프·nonce·발행 버전을 생성하지 않으며 `report_format: v1`과 `schema_version: 1`을 구분한다. 동적 문자열은 JSON 인용하고 줄/문단 구분자와 bidi 제어문자를 가시적으로 이스케이프한다. HTML/Markdown 안전성을 주장하지 않는다. +5. JSON 읽기/검증 중에는 버튼을 비활성화하고 핸들러에서도 거부한다. 다운로드 준비·활성화 실패는 일반적인 재시도 안내로 처리하며 예외 원문을 표시하지 않는다. URL 할당에 성공하면 성공/실패 모두에서 다음 task에 회수한다. +6. 성공 안내는 `다운로드를 시작했습니다`이며 파일 저장 완료를 주장하지 않는다. 성공/실패 모두 작성 사실·선택 항목·명시적 확인·현재 단계·완료 판정을 변경하지 않는다. +7. 요약은 제품 정의 입력 완결성의 표현일 뿐 법률 자문·준법 보장·승인·발행·자동 저장의 증거가 아니다. TXT는 JSON 복원 파일이나 공개 개인정보처리방침을 대체하지 않는다. + +결정과 검증 범위: [ADR-0006](ADR-0006-local-review-summary.md), [로컬 MoA/검증 기록](evidence/mixed-agents-review-summary.md). 현재 후보의 실제 브라우저·전체 suite·exact-head CI·독립 승인 증거는 별도 게이트이며 이 수락조건의 기재만으로 통과하지 않는다. + ## 비목표 - 법률 자문 또는 준법 보장 diff --git a/docs/TRD.md b/docs/TRD.md index 802a72c..7d8e668 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -41,6 +41,16 @@ The separation between collection and retention follows the PIPC Standard Person - Hosted web endpoints, when introduced, use non-blocking/asynchronous handling and require realistic k6 tests before a p95 <=20 ms page/API claim is recorded. - Production does not depend on synthetic demo data. +## Local minimal TXT review summary + +`src/policy-review-report.ts` owns the pure `createPolicyReviewText` projection; it does not own readiness rules. `createPolicyExport` supplies canonical service identity, `document_state`, schema version and ordered finding codes; `getCompletedSteps` supplies all seven responsibility states; `getReview(...).recommended` supplies recommendation labels. `formatReviewFinding` maps existing codes to owning steps/labels without dropping or reordering codes. Unknown codes retain one quoted row with `단계 미상`. Collection contradiction must retain the domain's incomplete steps 2 and 3, even when item details are otherwise complete. + +The output is a local review aid, not the full JSON portability payload. It excludes collection-path and purpose values, retention periods, recipient/country details, contact values and the full facts object. Service identity can still identify an operator; omission of details is not anonymization. Unsafe credential/query/fragment URLs stay withheld by the existing export admission contract, never printed raw. Dynamic strings are JSON quoted with visible escaping for C1 controls, Unicode line/paragraph separators and bidi controls; this is a TXT boundary, not an HTML/Markdown encoder. No time, nonce, publication revision or runtime LLM is introduced. `report_format: v1` versions presentation independently of `schema_version: 1` facts. + +`DocumentPreview` exposes `검토 요약 다운로드`; `exportReview` is both visibly disabled and handler-guarded during import. It uses fixed filename `policyweave-review.txt` and MIME `text/plain;charset=utf-8`, contains Blob/object-URL/anchor/activation exceptions with generic retry copy, and schedules object-URL reclamation on the next task whenever allocation succeeded. Successful activation announces download initiation, not completed storage. Neither outcome changes authoring facts, selected items, attestations, current step or derived completion/readiness. No network, renderer dependency, persistence, legal rule or cancellation contract is added. + +Acceptance: PRD `US-REVIEW-01`; decision: [ADR-0006](ADR-0006-local-review-summary.md). Domain/UI/browser assertions must cover ordered blocker cardinality, unknown fallback, recommendation separation, contradiction ownership, hostile strings, unsafe URL non-disclosure, deterministic bytes, pending-import lock, complete workspace preservation and preparation/activation failure cleanup. [The local evidence record](evidence/mixed-agents-review-summary.md) preserves earlier bounded observations, completed local full-suite/browser successor evidence, and unresolved visual inspection, exact-head hosted and qualifying-approval gates. + ## Local draft portability The JSON file is a draft portability artifact, not a publication receipt, immutable revision, legal approval, or persistence backup. It may be exported while incomplete so operators can inspect and transfer their authored work without converting blanks into `none`. The schema-v1 return path reconstructs facts from admitted fields and recomputes readiness/findings rather than trusting file claims. Invalid files leave current browser state unchanged; authoring is locked only for the bounded read/validation interval so accepted restore cannot discard edits made during that interval. Contract changes require a new schema version, explicit migration/loss behavior, and compatibility evidence; the current fixed filename avoids using customer-controlled text as a filesystem name. Preparation/activation-error recovery does not establish cancellation of an in-progress browser transfer. diff --git a/docs/evidence/mixed-agents-review-summary.md b/docs/evidence/mixed-agents-review-summary.md new file mode 100644 index 0000000..06f690b --- /dev/null +++ b/docs/evidence/mixed-agents-review-summary.md @@ -0,0 +1,79 @@ +# Local minimal review summary — layered-agent and evidence record + +Date: 2026-10-03 (KST). Repository: PolicyWeave. Candidate base: `60fd7fb`; branch: `feat-mixed-agents-prd`. This record describes a branch-local development decision and bounded observations, not a release, hosted approval or final-head passing certificate. + +## Methodology source + +The verified primary source is Wang, J., Wang, J., Athiwaratkun, B., Zhang, C., & Zou, J. (2024, June 7), *Mixture-of-Agents Enhances Large Language Model Capabilities*, arXiv:2406.04692v1, DOI `10.48550/arXiv.2406.04692`; arXiv metadata/abstract retrieved 2026-10-03. Its layered pattern passes prior-layer agent outputs to the next layer as auxiliary information.[1] The source is methodology metadata only: no benchmark score, performance benefit or reproduction claim is adopted for PolicyWeave. + +This task used a **MoA-inspired development workflow**: independent proposals → both reviewers receive prior proposals plus the parent aggregate → final parent aggregation. It was not merely two parallel implementation assignments. Role independence is not verified model heterogeneity; it is not a runtime product-AI feature. + +## Local receipt chain + +These local receipts were inspected for manifest status and task/log metadata. They are execution-provenance pointers, not portable CI artifacts or qualifying GitHub approvals. Do not copy raw transcripts into the repository: they can contain private local context. Only bounded summaries and identifiers are recorded here. + +| Stage | Local receipt | Observed decision/input | +| --- | --- | --- | +| Layer 1, independent proposals | `~/.hermes/cache/delegation/live/deleg_6398a30f/manifest.json`, `task-0.log`, `task-1.log`; two tasks completed | Product: whole-plan full-facts Markdown with seven-step facts/findings/version. Technical: whole-plan minimal TXT with canonical readiness/identity and no detailed operational/contact values. Both were read-only proposal tasks. | +| Layer 2, cross-review | `~/.hermes/cache/delegation/live/deleg_2296ff9a/manifest.json`, `task-0.log`, `task-1.log`; two tasks completed | Both kickoff contexts supplied both Layer-1 plans plus parent integrated TXT plan. Both returned conditional acceptance with output-encoding, cardinality/ownership, failure/lifetime and state-preservation controls rather than unconditional implementation approval. | +| Parent aggregation | This bounded decision record and [ADR-0006](../ADR-0006-local-review-summary.md) | Minimal TXT selected; full-facts Markdown deferred; all P1 conditions retained as implementation/test contracts. Parent synthesis is not an additional independent approval receipt. | + +Both inspected manifests have `model: null` and `provider: null`. Delegation used same/inherited model configuration; returned metadata does not establish distinct resolved model identities. **Heterogeneous model/provider execution is unverified.** Do not describe role-separated fanout as a verified mixed-model ensemble, claim the paper's benchmark benefits, or count model review as protected-branch independent approval. + +## Aggregated controls and implementation trace + +| Control from cross-review | Contract/trace | Acceptance boundary | +| --- | --- | --- | +| TXT minimum rather than full-facts duplication | `createPolicyReviewText`, PRD `US-REVIEW-01`, ADR-0006 | Identity, state, seven-step status, codes/labels and recommendations only; detailed path/purpose/contact/retention/recipient/country values omitted. Identity remains disclosure-bearing. | +| Every exported blocker has one row in order | `createPolicyExport(...).review_finding_codes` → `formatReviewFinding` | No filtering/deduplication; quoted unknown-code `단계 미상` fallback; count must equal row cardinality. | +| Preserve code ownership and contradiction semantics | `getDraftReview`, collection-code mapping, `getCompletedSteps` | Collection mode/path map to step 2, purpose to step 3; collection contradiction keeps both steps incomplete. | +| Visible control escaping, not plain stringify alone | TXT quoting boundary and hostile-string domain assertions | JSON quoting plus C1/Unicode line/paragraph/bidi escaping for all dynamic strings, including labels/codes/unknown fallback. No HTML/Markdown-safety claim. | +| Do not leak invalid raw URL | Canonical `createPolicyExport` service profile | Credentials/query/fragment stay withheld; error feedback does not include exception/raw fact text. | +| Bounded browser lifetime/failures | `DocumentPreview`, `exportReview`, UI/browser contracts | Fixed filename/MIME; import disable plus handler guard; Blob/URL/anchor/click exception containment; next-task reclamation after successful allocation on success or activation failure. | +| Preserve the complete workspace | Pure domain projection; download feedback only | Facts/items/attestations/current step/readiness/completion unchanged after success/failure; no automatic save or hidden restore. | +| Honest version and success wording | `report_format: v1`, `schema_version: 1`, initiated-download feedback | No timestamp/nonce/publication version; initiation is not completed storage, legal review, approval, persistence or publication. | + +No new network, renderer dependency, legal source/rule or hosted product runtime was introduced by this choice. The historic source/legal/gap ledger is preserved and not promoted to current-head evidence. + +## Earlier documentation-slice observations — historical local status + +This documentation slice directly inspected the implementation/test source and the two proposal-layer manifests/log metadata. It did not run the parent's feature suite or remote GitHub gates. The following test/toolchain observations were supplied by the parent handoff and are labeled as bounded reports, not independently re-executed results: + +- Initial domain RED: the focused test failed because `createPolicyReviewText` did not exist. An initial focused GREEN was then reported. It does not certify the later expanded domain matrix or final tree. +- Initial UI RED: the download button was absent. An initial focused UI pass was reported; a later local run timed out. Timeout is **unknown**, not PASS, and no full-suite count is asserted. +- The first `npm ci` installed production dependencies only; a later development install stalled. The parent copied identical-lock-version dependencies from an existing sibling tree into this owned local tree via `ditto`, and the local UI harness needed `NODE_ENV=test`. This is a task-specific executor caveat, not a universal README setup requirement or clean-install attestation; no dependency/workflow edit is authorized by it. + +| Gate | Status at the earlier documentation slice | Needed evidence | +| --- | --- | --- | +| Expanded current-tree domain/UI regressions | Unverified here; assertion source is not a runner receipt | Actual completed output bound to final candidate source/test tree, including hostile controls, fallback/cardinality, unsupported statuses, unsafe URLs and full-state preservation | +| Full lint/test/build | Pending/unverified | Completed real commands on the final candidate; no fabricated totals or timeout-as-pass | +| Native-browser TXT workflow | Forthcoming/pending | Real download events/bytes/MIME, repeated bytes, activation modes, import lock and failure cleanup; jsdom is supporting evidence only | +| Exact-head hosted verification/security | Unverified | Fresh source/head/base/checkout/run/artifact identity and terminal required checks; no stale, cancelled, skipped or predecessor receipt | +| Independent approval/resolved threads | Unresolved | Qualifying current approval and zero unresolved required threads under live protections; no administrative bypass | +| Issue #12 dependency review | Unresolved, parent reports HTTP 403 | Canonical owner-side repair/terminal workflow and distribution/license acceptance; summary feature does not close the issue | +| Hosted publication/release | Not implemented/claimed | Separate security, immutable review/publication and release contracts; TXT is not publication | + +Docs-slice execution on the local working tree: `git diff --check` completed without whitespace errors; `node --test tests/local_preview_contract.mjs` passed its six existing documentation/local-preview configuration contracts; relative-link checking found no missing targets across the ten owned documentation files; strict citation-ledger verification accepted the methodology source reference. These are bounded documentation/configuration checks, not feature-suite, browser, hosted CI or approval evidence. + +Parent-reported remote observations: Draft PR #1 at `60fd7fb5c3177984a993102742bb16e36a909e2d`; separate Draft PR #25 at `af8c0da17cdfb4786867f4e85401dbbb811b581e`, owning import cancellation/stream work. This feature does not duplicate that work. No workflow-run cancellation was performed or requested. These observations require fresh live inspection before commit/push/integration. This docs task performs no commit or push. + +## Parent-executed successor verification + +The following supersedes the pending **local execution** observations above, not hosted or approval gates. The parent executed these commands on the current uncommitted candidate and will bind final file hashes in a separate local verification receipt: + +- Full Vitest: 193/193 across 18 files, exit 0, with `NODE_ENV=test`, Node 26.7.0 and one worker. This includes 11 report-domain and seven report-UI cases. +- ESLint: exit 0. Existing documentation/local-preview contracts: 6/6, exit 0. Production TypeScript/Vite build: exit 0. +- New browser journey: initial desktop/tablet/mobile runs failed the 48 px touch-height assertion (actual 38 px); `.review-download` was repaired to 48 px. The same three cases then passed on rebuilt product bytes. +- Full Playwright: 39 collected, 27 passed, 12 existing profile-scoped skips, exit 0. This is not 39 executed passes or a full accessibility audit. +- The initial TS2571 in the test's anchor instance access was corrected with an explicit `HTMLAnchorElement` cast; the failed build was not cleared by the succeeding lint command's exit 0. +- Visual screenshot inspection remains unresolved: the first analysis call ended with an upstream stream error and the second with a quota 429. Screenshots exist; DOM/touch/no-overflow checks do not replace their visual inspection. + +Exact-current-head hosted checks, qualifying approval, protected integration, clean-install acceptance, legal/distribution obligations and publication remain unresolved. Independent whole-delta local review subsequently found no blocking security or logic defect and independently repeated 193 unit/UI, lint/build, six pretest and three new browser passes against byte-matching source. It suggested documentation-status reconciliation and stronger handler/native-browser instrumentation. This is not a counted GitHub approval. + +## Successor boundary verification + +[The 2026-10-04 successor](review-summary-successor-20261004.md) records clean-install recovery, another actual proposal/cross-review layer, native browser boundary coverage and independent handler-guard mutation evidence. Original failures and prior totals above remain historical; they are not replaced with successor results. + +## Sources + +[1] https://arxiv.org/abs/2406.04692 — *Mixture-of-Agents Enhances Large Language Model Capabilities*; primary arXiv metadata/abstract, retrieved 2026-10-03. diff --git a/docs/evidence/review-summary-successor-20261004.md b/docs/evidence/review-summary-successor-20261004.md new file mode 100644 index 0000000..28aa5b6 --- /dev/null +++ b/docs/evidence/review-summary-successor-20261004.md @@ -0,0 +1,34 @@ +# Review summary verification and delivery successor — 2026-10-04 + +Repository: PolicyWeave. Source base: `60fd7fb5c3177984a993102742bb16e36a909e2d`. Branch: `feat-mixed-agents-prd`. This is a successor to [the original layered-agent evidence](mixed-agents-review-summary.md), not retrospective replacement of its failures or receipts. + +## Existing work preserved and current authority + +The parent verified that all 16 original candidate files still match the previous patch receipt. A fresh read on 2026-10-04 finds parent Draft PR #1 (`develop` → `main`) unchanged at the source base above, and separate Draft PR #25 (`agent/import-cancellation` → `develop`) at `af8c0da17cdfb4786867f4e85401dbbb811b581e`. The summary work does not duplicate import cancellation/stream ownership. Issue #12 is open; the root candidate's Dependency Review failure, missing OpenCode approval evidence and cancelled Noema verdict remain non-passing. No required workflow rerun, run cancellation, protection change, synthetic status or Issue closure was performed. + +## Second layered development decision + +The primary methodology reference remains Wang et al., *Mixture-of-Agents Enhances Large Language Model Capabilities*, arXiv:2406.04692 (2024-06-07). Its prior-layer-output pattern informed the development workflow; no benchmark benefit is claimed. + +- Layer 1 (`deleg_773be568`): two independent whole plans proposed finishing `US-REVIEW-01` rather than inventing another feature. The product plan prioritized handler guard, native download boundaries, import recovery and observable state preservation. The technical plan distinguished immediately passing existing-behavior tests from production RED→GREEN and proposed isolated mutation evidence for the handler guard. +- Layer 2 (`deleg_7e249da4`): both reviewers received both plans and the parent aggregate. They retained the minimum scope, required original native-method calls and explicit fixture labels, rejected JSON-only claims of full raw-state preservation, and required fresh owned-server/build identity rather than stale preview reuse. +- Parent aggregate: add acceptance contracts and isolated guard controls, then exercise the final candidate and submit an ordinary Draft PR targeting `develop`. No new product API, React internal access, legal rule, hosted store, dependency or runtime model is authorized by this decision. + +Delegated model/provider identities are not verified heterogeneous. Role separation and shared prior-layer proposals are development provenance, not GitHub approval. + +## Clean installation recovery + +A previously dependency-empty owned directory at `/Users/seonghobae/.hermes/cache/scratch/policyweave-clean-install-20261004` ran `NODE_ENV=development npm ci --include=dev --no-audit --no-fund --prefer-offline --fetch-retries=0 --fetch-timeout=20000`: exit 0, 243 packages added. Lock bytes match the candidate. npm reported an allow-scripts warning for `fsevents@2.3.3`; no install-script approval or policy change was performed. This is installed-toolchain evidence, not a fresh vulnerability scan or cross-platform guarantee. + +The preserved candidate was materialized with its verified patch and exercised using those clean-installed dependencies: six pretests, 193 Vitest cases across 18 files, lint and production build completed with exit 0 (`proc_d1d5cc33cf18`). Those totals describe the predecessor source snapshot only; later contract additions require fresh final-candidate execution. + +## Visual observation, not whole-product acceptance + +The original mobile screenshot was successfully inspected on 2026-10-04 after the prior upstream-drop and quota-429 failures. The summary-download label/border is fully shown and readable without neighboring overlap. The header title and part of the horizontal step list are visually cut off at the right. This is a bounded historical screenshot observation, not a current-successor visual audit, full-screen completeness, native zoom, screen-reader or WCAG conformance. Those remaining visual conditions are not dismissed by document-level no-overflow assertions. + +## Executed acceptance contracts — completed parent full gate + +- `tests/e2e/review-summary-boundaries.spec.ts`: native Blob MIME, original URL allocation/activation/deferred-reclaim observation, real downloaded bytes, fixture-owned pending reads and read/validation failure recovery, plus observable state preservation. Positive/negative instrumentation is explicit; passing newly added assertions characterize existing behavior unless a genuine source defect is reproduced. +- `tests/review_summary_guard_contract.mjs`: isolated source copy with only button disabling removed must reject pending-import downloads through real React UI events; an additional guard-removal mutation must fail the intended allocation assertion. The live source remains unchanged. Fixture collection errors or timeouts are not accepted as mutation detection. + +The browser-contract owner completed 24/24 cases across desktop/tablet/mobile, zero skips, failures, flakes or retries. The spec uses a disclosed 60-second local survey budget without changing global config/retries; earlier descriptor/locator errors and default-30-second survey failures are retained as harness observations, not product RED. Original browser primitives are called by the positive observers. The guard owner completed one outer Node contract: enabled-button positive exit 0 and guard-removal negative exit 1 specifically at `HANDLER_GUARD_PENDING_IMPORT_ALLOCATION`, with live hashes unchanged and owned groups settled. This standalone guard contract is not wired into npm test or CI. Parent final-candidate verification completed as `proc_b1bea28186dc`: six pretests; 193/193 Vitest cases across 18 files; lint and production build; the standalone guard contract (positive 0, intended mutation negative 1); and full Chromium suite 63 collected, 51 passed, 12 existing profile-scoped skips, exit 0. `CI=1` forbids reuse of a pre-existing preview server; rebuilt product bytes were exercised. The 24 new boundary cases retain their disclosed 60-second survey budget. Unmounted/discarded stale facts not exposed by public controls/export remain outside observable preservation proof. Shell initialization reported `can't change option: zle`; it did not prevent the commands, and the warning is not suppressed. A new Draft PR is delivery/review admission, not parent integration, current-head CI success, qualifying approval, publication or release. Issue #12 and PR #25 keep their own exit contracts. diff --git a/docs/index.md b/docs/index.md index 8e52c95..55ea859 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,9 @@ PolicyWeave is a local-first privacy-policy fact-authoring workspace for web and - [ADR 0003](ADR-0003-policy-revision-persistence.md) — the Proposed PostgreSQL revision identity, 3NF fact, consistency, and item-level UPSERT contract. - [ADR 0004](ADR-0004-runtime-status-admission.md) — the Proposed closed-vocabulary runtime admission decision for categorical policy facts. - [ADR 0005](ADR-0005-local-draft-restore.md) — the Proposed fail-closed schema-v1 local draft restore decision. +- [ADR 0006](ADR-0006-local-review-summary.md) — the Proposed local minimal TXT review-summary decision, distinct from JSON portability and publication. +- [Local layered-agent evidence](evidence/mixed-agents-review-summary.md) — MoA-inspired proposals/cross-review/aggregation, model-provenance limits and unverified integration gates. +- [Review-summary verification successor](evidence/review-summary-successor-20261004.md) — clean-install recovery, second proposal/cross-review layer, native-boundary and isolated-guard receipts, with local/hosted limits. - [Proposed policy revision ERD](ERD.md) — the normalized persistence relationships and transaction invariants represented by migration `0001`. - [Research and legal traceability](research-traceability.md) — authoritative-source, effective-date, and implementation/test traceability for legal and policy decisions. - [Product and technical gap baseline](product-technical-gap-baseline.md) — current commercialization gaps and evidence status. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 95b108c..a52dd61 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -10,6 +10,19 @@ Fresh inspection still finds protected `main@52f4fd6bb68f870d0519cf11dd471573a2f A check receipt must bind repository, PR, source head, base, actual checkout SHA, run/attempt/job and artifact identity. An associated PR run that checks out a synthetic merge commit is integration evidence, not automatically a literal head checkout. Re-fetch final candidate evidence after every head or base movement. No queued, skipped, cancelled, predecessor or comment-only result constitutes approval or passing required Checks. Ready is review admission, not merge authorization. +## Local minimal review-summary candidate — 2026-10-03 + +The branch-local delta based on `60fd7fb` implements PRD `US-REVIEW-01`, not hosted approval/publication. [ADR-0006](ADR-0006-local-review-summary.md) chooses minimal TXT over full-facts Markdown. [The local evidence record](evidence/mixed-agents-review-summary.md) preserves two independent proposals, a second layer where both receive both proposals and the parent aggregate, and final conditional aggregation. Role-separated same/inherited-model delegation is not verified heterogeneous-model execution. + +This bounded section does not supersede the dated historical receipts below. Parent-observed live state places Draft PR #1 at `60fd7fb5c3177984a993102742bb16e36a909e2d` and separate Draft PR #25 at `af8c0da17cdfb4786867f4e85401dbbb811b581e`, owning import cancellation/stream work. This summary feature neither duplicates that work nor cancels workflow runs. Issue #12 remains open: Dependency Review HTTP 403 and qualifying independent approval remain unresolved. Re-fetch remote head/base/checks before integration; no new exact-head hosted receipt is asserted here. + +| Gap | Candidate delta | Evidence still required | +| --- | --- | --- | +| Portable review aid | Minimal deterministic `policyweave-review.txt`; canonical identity/state/ordered blocker rows, seven-step completion, separate recommendations; no detailed operational/contact facts | Clean-installed baseline test/lint/build recovered. Parent successor gate: 193 unit/UI passes, six pretests, lint/build, isolated guard positive/negative and 51 browser passes with 12 existing scoped skips; 24 new native-boundary cases are included. Historical mobile summary control visually readable; header/step clipping and broader visual/AT acceptance remain open | +| Legal/publication authority | Existing product-readiness expressions only; report-format v1 and facts-schema v1 are not an approval/publication version | Versioned legal-source/rule mappings, authenticated review and immutable publication remain open | +| Integration assurance | Original independent whole-delta local review found no blocking defect. Successor parent gate completed lint/build, 193 Vitest and 51 browser passes with 12 scoped skips; successor independent review pending | Current exact-head hosted workflows, resolved threads and qualifying GitHub approval remain open. Empty-directory npm ci added 243 packages and exercised the predecessor snapshot; fsevents allow-scripts warning is retained, not silently approved. | +| Mixed-agent provenance | Layered proposals → shared prior outputs/cross-review → parent aggregation receipts exist locally | Heterogeneous model/provider identity is unverified; no benchmark or runtime product-AI claim | + ## Runtime status admission repair [ADR-0004](ADR-0004-runtime-status-admission.md) implements the existing PRD/TRD/ADR-0002 explicit-fact requirement in `src/policy.ts`. Review, completed-step derivation and schema-v1 export no longer treat truthy unknown collection/retention/transfer status values as operator confirmations. Only exact existing members are accepted; unsupported statuses retain their owning finding and export as `null`, without coercion, inferred `no`/`none` or source mutation. diff --git a/docs/research-traceability.md b/docs/research-traceability.md index fefba7f..a388cd3 100644 --- a/docs/research-traceability.md +++ b/docs/research-traceability.md @@ -53,5 +53,13 @@ World Wide Web Consortium. (2025). *Understanding Success Criterion 2.4.7: Focus 5. LLM output, if later used to explain or propose wording, is never an authoritative legal source and cannot change review/publication state. 6. Collection absence is not evidence of absence of other processing acts such as storage or retention; applicability decisions require their own explicit source fact unless an authoritative rule proves a dependency. +## Local review-summary trace — 2026-10-03 + +PRD `US-REVIEW-01` and [ADR-0006](ADR-0006-local-review-summary.md) add only a deterministic local TXT expression of the existing product-readiness contracts. `createPolicyReviewText` consumes `createPolicyExport`, `getCompletedSteps` and `getReview`; the report/domain and UI regression files trace ordered blocker identity, recommendations, seven-step ownership, text escaping, identity admission and download failure/state-preservation boundaries. Test source is not passing execution evidence; [the local evidence record](evidence/mixed-agents-review-summary.md) labels earlier bounded observations separately from completed local full-suite/browser successor evidence and unresolved visual inspection, exact-head hosted and qualifying-approval gates. + +No legal source, effective-date snapshot, legal rule, template authority or legal-sufficiency conclusion is changed by this feature. The legal register and dated retrievals above remain historical evidence, not revalidated current law. TXT omission of contact and detailed operational facts is a product disclosure-minimization choice, not a legal anonymization decision. + +The development workflow is MoA-inspired: independent proposals, a second layer receiving both prior proposals plus the parent aggregate, then parent aggregation. The methodology source metadata is Wang et al. (2024), *Mixture-of-Agents Enhances Large Language Model Capabilities*, arXiv:2406.04692 v1 (June 7, 2024); its retrieved source citation and local delegation receipt identifiers are recorded in the evidence document. This is not a runtime LLM feature, verified heterogeneous-model ensemble, benchmark reproduction or substitute for independent GitHub approval. + ## Current gap The seven-step workspace now captures the product's intended fact categories, including independent explicit retention applicability, but retention-period/legal-basis detail, third-party provision, international transfer, contact/controller information, and legal-basis review still need requirement-level mappings to the authoritative register, deterministic validation, and regression fixtures before PolicyWeave can claim those steps are legally complete. CSS-level focus contrast and deterministic step-focus transfer now have executable regression contracts, but real-browser accessibility evidence remains required before claiming WCAG conformance. diff --git a/src/App.tsx b/src/App.tsx index 1cb28a3..7939efd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { ChangeEvent, useMemo, useState } from 'react' import { AlertTriangle, Check, ChevronDown, ExternalLink, FileText, Link, Save, Upload } from 'lucide-react' +import { createPolicyReviewText } from './policy-review-report' import { createPolicyExport, DraftFacts, getCompletedSteps, getDraftReview, getReview, initialFacts, initialItems, isWebServiceUrl, PolicyItem, restorePolicyExport, steps } from './policy' type FactField = { @@ -177,12 +178,13 @@ function EditingPanel({ current, items, setItems, noCollectionAttested, setNoCol } /** Projects verified authoring facts and deterministic readiness findings into the review draft. */ -function DocumentPreview({ items, noCollectionAttested, facts, setCurrent }: { items: PolicyItem[]; noCollectionAttested: boolean; facts: DraftFacts; setCurrent: (step: number) => void }) { +function DocumentPreview({ items, noCollectionAttested, facts, setCurrent, exportReview, isImporting }: { items: PolicyItem[]; noCollectionAttested: boolean; facts: DraftFacts; setCurrent: (step: number) => void; exportReview: () => void; isImporting: boolean }) { const review = useMemo(() => getReview(items, noCollectionAttested), [items, noCollectionAttested]) const draftFindings = useMemo(() => getDraftReview(facts, noCollectionAttested), [facts, noCollectionAttested]) const blockingCount = review.blockingCount + draftFindings.length return

개인정보처리방침 미리보기

+
근거 법령 개인정보 보호법{blockingCount ? `검토 필요 ${blockingCount}` : '필수 확인 완료'}버전 0.1.0

{facts.serviceName || '개인정보처리방침'} (검토본)

@@ -242,6 +244,26 @@ export default function App() { } } } + /** Starts a local TXT review-summary download without changing authoring facts or claiming file storage. */ + function exportReview() { + if (isImporting) return + let fileUrl: string | null = null + try { + fileUrl = URL.createObjectURL(new Blob([createPolicyReviewText(items, noCollectionAttested, facts)], { type: 'text/plain;charset=utf-8' })) + const downloadLink = document.createElement('a') + downloadLink.href = fileUrl + downloadLink.download = 'policyweave-review.txt' + downloadLink.click() + setMessage('검토 요약 다운로드를 시작했습니다. 파일 보관 및 전달 범위를 확인하세요.') + } catch { + setMessage('검토 요약을 내보내지 못했습니다. 다시 시도하세요.') + } finally { + if (fileUrl) { + const disposableFileUrl = fileUrl + setTimeout(() => URL.revokeObjectURL(disposableFileUrl), 0) + } + } + } /** Restores a bounded schema-v1 local draft without trusting embedded readiness evidence. */ async function importDraft(event: ChangeEvent) { const fileInput = event.currentTarget @@ -267,7 +289,7 @@ export default function App() { } return
PolicyWeave{facts.serviceName || '내 서비스'} 개인정보처리방침작성 중버전 0.1.0 (임시저장){isImporting ? : } {isImporting ? 'JSON 초안 확인 중' : '브라우저 작업 중'}
-
+
검토 요약확인을 마친 뒤 공개 준비 상태를 확인하세요.
필수 확인 {blockingCount}건
권장 검토 {collectionReview.recommended.length}건
{message}
} diff --git a/src/policy-review-report.test.ts b/src/policy-review-report.test.ts new file mode 100644 index 0000000..9370a00 --- /dev/null +++ b/src/policy-review-report.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest' +import * as report from './policy-review-report' +import { createPolicyExport, getCompletedSteps, getDraftReview, getReview, initialFacts, initialItems, steps, type DraftFacts, type PolicyItem } from './policy' + +const completeFacts: DraftFacts = { + ...initialFacts, + serviceName: '예시 서비스', + serviceUrl: 'https://example.test', + retentionStatus: 'none', + thirdPartyStatus: 'no', + internationalStatus: 'no', + privacyOfficerName: '예시 담당', + privacyOfficerEmail: 'privacy@example.test', +} + +function findingCodes(text: string): string[] { + return text.split('\n').filter((line) => line.startsWith('필수 항목: ')) + .map((line) => JSON.parse(line.match(/^필수 항목: ("(?:\\.|[^"\\])*")/)![1]) as string) +} + +describe('local review summary', () => { + it('projects the initial incomplete facts into a deterministic seven-step review summary', () => { + expect(report.createPolicyReviewText).toBeTypeOf('function') + const text = report.createPolicyReviewText(initialItems, false, initialFacts) + expect(text).toContain('검토 요약 형식 v1 / 사실 스키마 v1') + expect(text).toContain('상태: 미완료') + expect(text).toContain('필수 확인: 8건') + for (const step of steps) expect(text).toContain(step) + expect(text).toContain('서비스 이름: 미확인') + expect(text).toContain('법률 자문') + expect(text).toContain('공개본 아님') + expect(text).toBe(report.createPolicyReviewText(initialItems, false, initialFacts)) + }) + + it('preserves version metadata and the exact ordered initial export findings with owning labels', () => { + const text = report.createPolicyReviewText(initialItems, false, initialFacts) + expect(text).toContain('report_format: v1') + expect(text).toContain('schema_version: 1') + expect(text).toContain(`document_state: ${createPolicyExport(initialItems, false, initialFacts).document_state}`) + expect(findingCodes(text)).toEqual(createPolicyExport(initialItems, false, initialFacts).review_finding_codes) + expect(text).toContain('필수 항목: "collection_selection" | 2단계 "수집 항목" | "수집 항목 선택 또는 수집하지 않음 확인"') + for (const finding of getDraftReview(initialFacts)) { + expect(text).toContain(`필수 항목: ${JSON.stringify(finding.code)} | ${finding.step}단계 ${JSON.stringify(steps[finding.step - 1])} | ${JSON.stringify(finding.label)}`) + } + }) + it('maps collection blockers while preserving contradictory step completion and exact code order', () => { + const items: PolicyItem[] = [{ ...initialItems[0], enabled: true, mode: '', detail: ' \t ', purpose: ' \n ' }] + const text = report.createPolicyReviewText(items, true, completeFacts) + expect(findingCodes(text)).toEqual(createPolicyExport(items, true, completeFacts).review_finding_codes) + expect(text).toContain('필수 확인: 4건') + expect(text).toContain('필수 항목: "collection_contradiction" | 2단계 "수집 항목" | "수집하지 않음 확인과 수집 항목의 모순"') + for (const [prefix, step, label] of [ + ['collection_mode', 2, '수집 구분'], ['collection_path', 2, '수집 경로'], ['processing_purpose', 3, '처리 목적'], + ] as const) { + expect(text).toContain(`필수 항목: "${prefix}:name" | ${step}단계 ${JSON.stringify(steps[step - 1])} | "이름: ${label}"`) + } + for (const [index, step] of steps.entries()) { + expect(text).toContain(`${index + 1}. ${step}: ${getCompletedSteps(items, true, completeFacts).has(index + 1) ? '제품 정의 입력 확인됨' : '확인 필요'}`) + } + const otherwiseComplete: PolicyItem[] = [{ ...items[0], mode: '필수', detail: '예시 경로', purpose: '예시 목적' }] + const contradiction = report.createPolicyReviewText(otherwiseComplete, true, completeFacts) + expect(findingCodes(contradiction)).toEqual(['collection_contradiction']) + expect(contradiction).toContain('2. 수집 항목: 확인 필요') + expect(contradiction).toContain('3. 처리 목적: 확인 필요') + }) + it('lists recommended item labels without changing complete seven-step readiness', () => { + const items: PolicyItem[] = [ + { ...initialItems[0], enabled: true, mode: '선택', purpose: '예시 목적', detail: '예시 경로' }, + { ...initialItems[3], enabled: true, mode: '선택', purpose: '예시 목적', detail: '예시 경로' }, + ] + const text = report.createPolicyReviewText(items, false, completeFacts) + expect(text).toContain('document_state: review_ready') + expect(text).toContain('필수 확인: 0건') + expect(text).toContain(`권장 검토: ${getReview(items, false).recommended.length}건`) + expect(text.split('\n').filter((line) => line.startsWith('권장 항목: '))).toEqual(['권장 항목: "이름"']) + expect(findingCodes(text)).toEqual([]) + for (const [index, step] of steps.entries()) expect(text).toContain(`${index + 1}. ${step}: 제품 정의 입력 확인됨`) + expect(text).toContain('법률 자문·준법 보장·승인·발행·자동 저장의 증거가 아닙니다.') + expect(text).toContain('서비스 식별정보가 포함될 수 있습니다.') + }) + it('encodes normalized service identity as one-line TXT data and omits detailed operational facts', () => { + const facts: DraftFacts = { + ...completeFacts, serviceName: ' 예시 "서비스"\n필수 확인: 0건 \\ ', serviceUrl: ' HTTPS://EXAMPLE.TEST ', + retentionStatus: 'applies', retentionPeriod: 'SECRET_RETENTION', thirdPartyStatus: 'yes', + thirdPartyRecipient: 'SECRET_THIRD_RECIPIENT', thirdPartyPurpose: 'SECRET_THIRD_PURPOSE', + internationalStatus: 'yes', internationalCountry: 'SECRET_COUNTRY', internationalRecipient: 'SECRET_INTL_RECIPIENT', + privacyOfficerName: 'SECRET_CONTACT', privacyOfficerEmail: 'SECRET_EMAIL@example.test', + } + const items: PolicyItem[] = [ + { ...initialItems[0], enabled: true, mode: '필수', detail: 'SECRET_PATH', purpose: 'SECRET_PURPOSE', description: 'SECRET_DESCRIPTION' }, + { ...initialItems[1], enabled: false, label: 'SECRET_INACTIVE_LABEL', detail: 'SECRET_INACTIVE_PATH', purpose: 'SECRET_INACTIVE_PURPOSE' }, + ] + const text = report.createPolicyReviewText(items, false, facts) + const profile = createPolicyExport(items, false, facts).policy_facts.service_profile + expect(text).toContain(`서비스 이름: ${JSON.stringify(profile.service_name)}`) + expect(text).toContain('서비스 URL: "https://example.test/"') + expect(text).not.toContain('SECRET_') + expect(text.split('\n').filter((line) => line.startsWith('필수 확인: '))).toEqual(['필수 확인: 0건']) + const inactiveFacts = { ...facts, retentionStatus: 'none' as const, thirdPartyStatus: 'no' as const, internationalStatus: 'no' as const } + expect(report.createPolicyReviewText(items, false, inactiveFacts)).not.toContain('SECRET_') + }) + it('renders Unicode directional and line controls visibly in every dynamic TXT string', () => { + const controls = '\u2028\u2029\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069\u0085\u009b' + const encode = (value: string) => JSON.stringify(value).replace(/[\u0080-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g, + (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`) + const items: PolicyItem[] = [{ ...initialItems[0], id: `odd:${controls}\nforged`, label: `예시${controls}\n권장 검토: 0건`, enabled: true, mode: '선택' }] + const facts = { ...completeFacts, serviceName: `서비스${controls}끝` } + const text = report.createPolicyReviewText(items, false, facts) + expect(text).toContain(`서비스 이름: ${encode(facts.serviceName)}`) + expect(text).toContain(`권장 항목: ${encode(items[0].label)}`) + expect(text).toContain(`필수 항목: ${encode(`collection_path:${items[0].id}`)} | 2단계 "수집 항목" | ${encode(`${items[0].label}: 수집 경로`)}`) + expect(text).not.toMatch(/[\u0080-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/) + expect(findingCodes(text)).toEqual(createPolicyExport(items, false, facts).review_finding_codes) + expect(text.split('\n').filter((line) => line.startsWith('권장 검토: '))).toEqual(['권장 검토: 1건']) + const unknownCode = `future:odd${controls}\n필수 확인: 0건"\\` + const fallback = report.formatReviewFinding(unknownCode, items, facts) + expect(fallback).toBe(`필수 항목: ${encode(unknownCode)} | 단계 미상`) + expect(fallback.split('\n')).toHaveLength(1) + expect(findingCodes(fallback)).toEqual([unknownCode]) + }) + it('states the plain-text boundary without promising Markdown or HTML embedding protection', () => { + const text = report.createPolicyReviewText(initialItems, false, initialFacts) + expect(text).toContain('로컬 TXT 검토 요약입니다. HTML·Markdown에 삽입하는 용도의 보호를 제공하지 않습니다.') + }) + it('preserves independent retention readiness in a no-collection draft', () => { + const unconfirmed = { ...completeFacts, retentionStatus: '' as const } + const text = report.createPolicyReviewText(initialItems, true, unconfirmed) + expect(findingCodes(text)).toEqual(['retention_status']) + expect(text).toContain('필수 확인: 1건') + expect(text).toContain('2. 수집 항목: 제품 정의 입력 확인됨') + expect(text).toContain('3. 처리 목적: 제품 정의 입력 확인됨') + expect(text).toContain('4. 보유 기간: 확인 필요') + const retained = report.createPolicyReviewText(initialItems, true, { ...completeFacts, retentionStatus: 'applies', retentionPeriod: '\t ' }) + expect(findingCodes(retained)).toEqual(['retention_period']) + expect(retained).toContain('필수 항목: "retention_period" | 4단계 "보유 기간" | "보유 기간"') + const complete = report.createPolicyReviewText(initialItems, true, completeFacts) + expect(complete).toContain('document_state: review_ready') + expect(findingCodes(complete)).toEqual([]) + for (const [index, step] of steps.entries()) expect(complete).toContain(`${index + 1}. ${step}: 제품 정의 입력 확인됨`) + }) + + it('projects every current raw draft finding including invalid categories and conditional details', () => { + const cases: DraftFacts[] = [ + { ...initialFacts, serviceName: ' \t ', serviceUrl: ' \n ', privacyOfficerName: '\t', privacyOfficerEmail: ' ' }, + { ...completeFacts, retentionStatus: 'invalid', thirdPartyStatus: 'invalid', internationalStatus: 'invalid' } as unknown as DraftFacts, + { ...completeFacts, serviceUrl: 'invalid', retentionStatus: 'applies', retentionPeriod: '\t ', thirdPartyStatus: 'yes', internationalStatus: 'yes', privacyOfficerEmail: 'invalid' }, + ] + for (const facts of cases) { + const text = report.createPolicyReviewText(initialItems, true, facts) + const findings = getDraftReview(facts, true) + expect(findingCodes(text)).toEqual(createPolicyExport(initialItems, true, facts).review_finding_codes) + expect(text).toContain(`필수 확인: ${findings.length}건`) + for (const finding of findings) { + expect(text).toContain(`필수 항목: ${JSON.stringify(finding.code)} | ${finding.step}단계 ${JSON.stringify(steps[finding.step - 1])} | ${JSON.stringify(finding.label)}`) + } + } + const items: PolicyItem[] = [{ ...initialItems[0], enabled: true, mode: 'invalid', detail: '예시 경로', purpose: '예시 목적' } as unknown as PolicyItem] + const invalidMode = report.createPolicyReviewText(items, false, completeFacts) + expect(findingCodes(invalidMode)).toEqual(['collection_mode:name']) + expect(invalidMode).toContain('2. 수집 항목: 확인 필요') + expect(invalidMode).toContain('3. 처리 목적: 제품 정의 입력 확인됨') + }) + + it('uses canonical export URLs and never exposes rejected raw URL data', () => { + for (const serviceUrl of [ + 'https://user:secret@example.test', 'https://example.test?SECRET_QUERY', 'https://example.test#SECRET_FRAGMENT', + 'https://example.test?', 'https://example.test#', 'https://example.test?%3F%23', 'https://example.test#%3F%23', + 'ftp://example.test', '', + ]) { + const facts = { ...completeFacts, serviceUrl } + const exported = createPolicyExport(initialItems, true, facts) + expect(exported.policy_facts.service_profile.service_url).toBeNull() + const text = report.createPolicyReviewText(initialItems, true, facts) + expect(text).toContain('서비스 URL: 미확인') + expect(findingCodes(text)).toEqual(exported.review_finding_codes) + expect(text).not.toContain('secret') + expect(text).not.toContain('SECRET_') + expect(text).not.toContain('%3F%23') + } + const facts = { ...completeFacts, serviceUrl: ' HTTPS://EXAMPLE.TEST:443/예시 ' } + const url = createPolicyExport(initialItems, true, facts).policy_facts.service_profile.service_url + expect(url).toBe('https://example.test/%EC%98%88%EC%8B%9C') + expect(report.createPolicyReviewText(initialItems, true, facts)).toContain(`서비스 URL: ${JSON.stringify(url)}`) + }) + + it('does not mutate deeply frozen inputs or deduplicate repeated exported findings', () => { + const items: PolicyItem[] = [ + { ...initialItems[0], id: 'odd', enabled: true }, { ...initialItems[0], id: 'odd', enabled: true }, + ] + const facts = { ...initialFacts } + for (const item of items) Object.freeze(item) + Object.freeze(items) + Object.freeze(facts) + const before = JSON.stringify({ items, facts }) + const text = report.createPolicyReviewText(items, false, facts) + const codes = createPolicyExport(items, false, facts).review_finding_codes + expect(findingCodes(text)).toEqual(codes) + expect(text).toContain(`필수 확인: ${codes.length}건`) + expect(findingCodes(text).filter((code) => code === 'collection_mode:odd')).toHaveLength(2) + expect(JSON.stringify({ items, facts })).toBe(before) + expect(text).toBe(report.createPolicyReviewText(items, false, facts)) + }) +}) diff --git a/src/policy-review-report.ts b/src/policy-review-report.ts new file mode 100644 index 0000000..f0051e4 --- /dev/null +++ b/src/policy-review-report.ts @@ -0,0 +1,49 @@ +import { createPolicyExport, getCompletedSteps, getDraftReview, getReview, type DraftFacts, type PolicyItem, steps } from './policy' + +/** Quotes TXT data and makes visual line/direction controls explicit; not an HTML/Markdown encoder. */ +function quoteText(value: string): string { + return JSON.stringify(value).replace(/[\u0080-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g, + (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`) +} + +/** Formats one exported blocker without changing its identity or deriving readiness. */ +export function formatReviewFinding(code: string, items: PolicyItem[], facts: DraftFacts): string { + let finding = getDraftReview(facts).find((candidate) => candidate.code === code) + if (code === 'collection_selection') finding = { code, step: 2, label: '수집 항목 선택 또는 수집하지 않음 확인' } + if (code === 'collection_contradiction') finding = { code, step: 2, label: '수집하지 않음 확인과 수집 항목의 모순' } + for (const [prefix, step, label] of [ + ['collection_mode', 2, '수집 구분'], ['collection_path', 2, '수집 경로'], ['processing_purpose', 3, '처리 목적'], + ] as const) { + const item = items.find((candidate) => code === `${prefix}:${candidate.id}`) + if (item) finding = { code, step, label: `${item.label}: ${label}` } + } + return finding + ? `필수 항목: ${quoteText(code)} | ${finding.step}단계 ${quoteText(steps[finding.step - 1])} | ${quoteText(finding.label)}` + : `필수 항목: ${quoteText(code)} | 단계 미상` +} + +/** Projects the current authoring state into a local review summary, never a publication receipt. */ +export function createPolicyReviewText(items: PolicyItem[], noCollectionAttested: boolean, facts: DraftFacts): string { + const exported = createPolicyExport(items, noCollectionAttested, facts) + const completed = getCompletedSteps(items, noCollectionAttested, facts) + const recommended = getReview(items, noCollectionAttested).recommended + return [ + 'PolicyWeave 검토 요약 — 공개본 아님', + '검토 요약 형식 v1 / 사실 스키마 v1', + 'report_format: v1', + `schema_version: ${exported.schema_version}`, + `상태: ${exported.document_state === 'incomplete' ? '미완료' : '제품 정의 필수 사실 입력 확인됨'}`, + `document_state: ${exported.document_state}`, + `서비스 이름: ${exported.policy_facts.service_profile.service_name === null ? '미확인' : quoteText(exported.policy_facts.service_profile.service_name)}`, + `서비스 URL: ${exported.policy_facts.service_profile.service_url === null ? '미확인' : quoteText(exported.policy_facts.service_profile.service_url)}`, + `필수 확인: ${exported.review_finding_codes.length}건`, + ...steps.map((step, index) => `${index + 1}. ${step}: ${completed.has(index + 1) ? '제품 정의 입력 확인됨' : '확인 필요'}`), + ...exported.review_finding_codes.map((code) => formatReviewFinding(code, items, facts)), + `권장 검토: ${recommended.length}건`, + ...recommended.map((item) => `권장 항목: ${quoteText(item.label)}`), + '법률 자문·준법 보장·승인·발행·자동 저장의 증거가 아닙니다. 공개 전 책임자 검토가 필요합니다.', + '서비스 식별정보가 포함될 수 있습니다. 파일 보관 및 전달 범위를 직접 확인하세요.', + '로컬 TXT 검토 요약입니다. HTML·Markdown에 삽입하는 용도의 보호를 제공하지 않습니다.', + '', + ].join('\n') +} diff --git a/src/policy-review-ui.test.tsx b/src/policy-review-ui.test.tsx new file mode 100644 index 0000000..b8903d5 --- /dev/null +++ b/src/policy-review-ui.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { createPolicyExport, initialFacts, initialItems } from './policy' +import { afterEach, describe, expect, it, vi } from 'vitest' +import App from './App' + +let captured: Blob | undefined +const allocate = vi.fn((blob: Blob) => { captured = blob; return 'blob:review-fixture' }) +const revoke = vi.fn() +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); captured = undefined; allocate.mockClear(); revoke.mockClear() }) + +function mount() { + vi.stubGlobal('URL', class extends URL { static createObjectURL = allocate; static revokeObjectURL = revoke }) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const view = render() + return { ...view, click } +} + +describe('local review summary download UI', () => { + it.each(['blob', 'url', 'anchor', 'click'])('preserves the complete workspace and retries after %s failure', (stage) => { + vi.useFakeTimers() + const { getByRole, click, container } = mount() + fireEvent.change(container.querySelector('input[name="serviceName"]')!, { target: { value: 'Review Fixture' } }) + fireEvent.click(container.querySelectorAll('.rail ol button')[1]) + fireEvent.click(container.querySelector('input[name="noCollectionAttested"]')!) + const before = container.querySelector('.workspace')!.innerHTML + const failure = new Error('fixture failure') + if (stage === 'blob') vi.stubGlobal('Blob', class { constructor() { throw failure } }) + if (stage === 'url') allocate.mockImplementationOnce(() => { throw failure }) + if (stage === 'anchor') { + const original = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementationOnce(((tag: string) => { if (tag === 'a') throw failure; return original(tag) }) as typeof document.createElement) + } + if (stage === 'click') click.mockImplementationOnce(() => { throw failure }) + fireEvent.click(getByRole('button', { name: '검토 요약 다운로드' })) + expect(container.querySelector('output')?.textContent).toContain('다시 시도하세요') + expect(container.querySelector('.workspace')!.innerHTML).toBe(before) + expect(revoke).not.toHaveBeenCalled() + vi.runAllTimers() + if (stage === 'anchor' || stage === 'click') expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:review-fixture') + else expect(revoke).not.toHaveBeenCalled() + vi.unstubAllGlobals() + vi.stubGlobal('URL', class extends URL { static createObjectURL = allocate; static revokeObjectURL = revoke }) + fireEvent.click(getByRole('button', { name: '검토 요약 다운로드' })) + expect(container.querySelector('output')?.textContent).toContain('다운로드를 시작했습니다') + expect(container.querySelector('.workspace')!.innerHTML).toBe(before) + vi.runAllTimers() + }) + + it.each(['valid', 'invalid'])('blocks summary allocation during import and recovers after %s completion', async (outcome) => { + const { getByRole, container } = mount() + let finish!: (text: string) => void + const file = new File(['pending'], 'draft.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { value: () => new Promise((resolve) => { finish = resolve }) }) + fireEvent.change(container.querySelector('input[type="file"]')!, { target: { files: [file] } }) + const button = getByRole('button', { name: '검토 요약 다운로드' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + fireEvent.click(button) + expect(allocate).not.toHaveBeenCalled() + expect(container.querySelector('.save-state')?.textContent).toContain('JSON 초안 확인 중') + finish(outcome === 'valid' ? JSON.stringify(createPolicyExport(initialItems, false, initialFacts)) : '{}') + await waitFor(() => expect(button.disabled).toBe(false), { timeout: 60000 }) + fireEvent.click(button) + expect(allocate).toHaveBeenCalledOnce() + }) + + it('starts an incomplete TXT download with a fixed filename and delayed object URL cleanup', () => { + vi.useFakeTimers() + const { getByRole, click, container } = mount() + fireEvent.change(container.querySelector('input[name="serviceName"]')!, { target: { value: 'Review Fixture' } }) + fireEvent.click(getByRole('button', { name: '검토 요약 다운로드' })) + expect(allocate).toHaveBeenCalledOnce() + expect(captured?.type).toBe('text/plain;charset=utf-8') + expect((click.mock.instances[0] as HTMLAnchorElement).download).toBe('policyweave-review.txt') + expect(container.querySelector('output')?.textContent).toContain('다운로드를 시작했습니다') + expect(container.querySelector('input[name="serviceName"]')?.value).toBe('Review Fixture') + expect(revoke).not.toHaveBeenCalled() + vi.runAllTimers() + expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:review-fixture') + }) +}) diff --git a/src/styles.css b/src/styles.css index be13d97..619806e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -41,6 +41,8 @@ button { cursor: pointer; } background: white; color: #29302b; } +.review-download { min-height: 48px; margin-bottom: 12px; } +.review-download:disabled { cursor: not-allowed; color: var(--muted); background: #f4f5f4; } .file-control { min-height: 44px; cursor: pointer; } .file-control[aria-disabled="true"] { background: #f4f5f4; color: var(--muted); cursor: not-allowed; } .file-control:focus-within { outline: 3px solid var(--green); outline-offset: 2px; } diff --git a/tests/e2e/review-summary-boundaries.spec.ts b/tests/e2e/review-summary-boundaries.spec.ts new file mode 100644 index 0000000..5d2c243 --- /dev/null +++ b/tests/e2e/review-summary-boundaries.spec.ts @@ -0,0 +1,431 @@ +import { expect, test, type Page, type TestInfo } from '@playwright/test' +import { readFile, writeFile } from 'node:fs/promises' + +// First-GREEN characterization of existing behavior, not a manufactured RED or +// evidence of browser/OS failures. Only the explicitly named fault cases inject +// failures; positive TXT/JSON downloads use the original browser primitives. +type FailureStage = 'blob' | 'url' | 'anchor' | 'click' +type BoundaryEvent = { + kind: string + url?: string + type?: string + filename?: string + cleanupTask?: boolean + sameTask?: boolean + native?: boolean +} +type BoundaryController = { + events: BoundaryEvent[] + arm: (stage: FailureStage | null) => void + hold: () => void + release: (reject: boolean) => void + fileReads: Array<{ name: string; native: boolean }> + restore: () => boolean +} +declare global { + interface Window { __reviewBoundary: BoundaryController } +} + +/** Transparent observation with descriptor, receiver, return/throw preservation. */ +async function installBoundaryObserver(page: Page) { + await page.addInitScript(() => { + const events: BoundaryEvent[] = [] + const fileReads: Array<{ name: string; native: boolean }> = [] + const saved: Array<{ owner: object; key: string; descriptor: PropertyDescriptor | undefined }> = [] + const nativeBlob = window.Blob + const nativeCreate = URL.createObjectURL + const nativeRevoke = URL.revokeObjectURL + const nativeClick = HTMLAnchorElement.prototype.click + const nativeElement = Document.prototype.createElement + const nativeTimeout = window.setTimeout + const nativeText = File.prototype.text + const pending = new Map() + let failure: FailureStage | null = null + let holdFile = false + let releaseFile: ((reject: boolean) => void) | undefined + let cleanupTask = false + let restored = false + + function replace(owner: object, key: string, value: unknown) { + const descriptor = Object.getOwnPropertyDescriptor(owner, key) + saved.push({ owner, key, descriptor }) + Object.defineProperty(owner, key, descriptor ? { ...descriptor, value } : { value, configurable: true, writable: true }) + } + function inject(stage: FailureStage, applicable: boolean) { + if (failure !== stage || !applicable) return + failure = null + events.push({ kind: `injected-${stage}` }) + throw new Error(`test-fixture injected ${stage} boundary failure`) + } + replace(window, 'Blob', new Proxy(nativeBlob, { + construct(target, args, newTarget) { + inject('blob', args[1]?.type === 'text/plain;charset=utf-8') + return Reflect.construct(target, args, newTarget) + }, + })) + replace(URL, 'createObjectURL', function (this: typeof URL, object: Blob | MediaSource) { + inject('url', object instanceof nativeBlob && object.type === 'text/plain;charset=utf-8') + const url = Reflect.apply(nativeCreate, this, [object]) + const record = { type: object instanceof nativeBlob ? object.type : '', sameTask: true } + pending.set(url, record) + events.push({ kind: 'create', url, type: record.type, native: true }) + queueMicrotask(() => { + record.sameTask = false + events.push({ kind: 'activation-microtask', url }) + }) + return url + }) + replace(URL, 'revokeObjectURL', function (this: typeof URL, url: string) { + const record = pending.get(url) + // Call the original even for duplicate or unrelated revocations. + const result = Reflect.apply(nativeRevoke, this, [url]) + events.push({ kind: 'revoke', url, type: record?.type, sameTask: record?.sameTask, cleanupTask, native: true }) + pending.delete(url) + return result + }) + replace(Document.prototype, 'createElement', function (this: Document, ...args: Parameters) { + inject('anchor', args[0] === 'a' && [...pending.values()].some(({ type }) => type === 'text/plain;charset=utf-8')) + return Reflect.apply(nativeElement, this, args) + }) + replace(HTMLAnchorElement.prototype, 'click', function (this: HTMLAnchorElement, ...args: []) { + events.push({ kind: 'click-enter', url: this.href, filename: this.download }) + inject('click', this.download === 'policyweave-review.txt') + const result = Reflect.apply(nativeClick, this, args) + events.push({ kind: 'click-return', url: this.href, filename: this.download, native: true }) + return result + }) + replace(window, 'setTimeout', function (this: Window, handler: TimerHandler, timeout?: number, ...args: unknown[]) { + if (typeof handler !== 'function' || timeout !== 0 || pending.size === 0) { + return Reflect.apply(nativeTimeout, this, [handler, timeout, ...args]) + } + // Observe an actual scheduled task; never accelerate or suppress cleanup. + events.push({ kind: 'timer-scheduled' }) + return Reflect.apply(nativeTimeout, this, [function (this: Window, ...callbackArgs: unknown[]) { + const previous = cleanupTask + cleanupTask = true + events.push({ kind: 'timer-enter' }) + try { return Reflect.apply(handler, this, callbackArgs) } finally { cleanupTask = previous } + }, timeout, ...args]) + }) + replace(File.prototype, 'text', function (this: File, ...args: []) { + if (!holdFile || !this.name.startsWith('test-fixture')) return Reflect.apply(nativeText, this, args) + holdFile = false + const record = { name: this.name, native: false } + fileReads.push(record) + return new Promise((resolve, reject) => { + releaseFile = (readFailure) => { + releaseFile = undefined + if (readFailure) { + reject(new Error('test-fixture injected File.text rejection')) + } else { + // Valid and invalid JSON both traverse the native File.text method. + record.native = true + try { resolve(Reflect.apply(nativeText, this, args)) } catch (error) { reject(error) } + } + } + }) + }) + const controller: BoundaryController = { + events, fileReads, + arm: (stage) => { failure = stage }, + hold: () => { holdFile = true }, + release: (reject) => { + if (!releaseFile) throw new Error('No owned test-fixture File.text read is pending') + releaseFile(reject) + }, + restore: () => { + if (!restored) { + for (const { owner, key, descriptor } of saved.toReversed()) { + if (descriptor) Object.defineProperty(owner, key, descriptor) + else Reflect.deleteProperty(owner, key) + } + restored = true + window.removeEventListener('pagehide', onPageHide) + } + return saved.every(({ owner, key, descriptor }) => { + const actual = Object.getOwnPropertyDescriptor(owner, key) + if (!descriptor) return actual === undefined + return actual !== undefined && Reflect.ownKeys(descriptor).every((property) => actual[property as keyof PropertyDescriptor] === descriptor[property as keyof PropertyDescriptor]) + }) + }, + } + function onPageHide() { controller.restore() } + Object.defineProperty(window, '__reviewBoundary', { value: controller, configurable: true }) + window.addEventListener('pagehide', onPageHide, { once: true }) + }) +} + +// These cases deliberately read all seven steps repeatedly, not a single page. +// Bound the evidence survey at 60s per case without changing global config, +// retry policy, production timer behavior, or lifecycle assertions. +test.setTimeout(60_000) +test.beforeEach(async ({ page }) => { await installBoundaryObserver(page) }) +test.afterEach(async ({ page }) => { + if (!page.isClosed()) expect(await page.evaluate(() => window.__reviewBoundary?.restore() ?? true)).toBe(true) +}) + +async function actualDownload(page: Page, filename: string, activate: () => Promise) { + const pending = page.waitForEvent('download') + await activate() + const download = await pending + expect(download.suggestedFilename()).toBe(filename) + expect(await download.failure()).toBeNull() + const path = await download.path() + expect(path).not.toBeNull() + return readFile(path!) +} +async function summary(page: Page, keyboard = false) { + const button = page.getByRole('button', { name: '검토 요약 다운로드', exact: true }) + return actualDownload(page, 'policyweave-review.txt', () => keyboard ? button.press('Enter') : button.click()) +} +async function jsonBytes(page: Page) { + return actualDownload(page, 'policyweave-draft.json', () => page.getByRole('button', { name: 'JSON 내보내기', exact: true }).click()) +} +async function step(page: Page, index: number) { + await page.locator('.rail ol button').nth(index - 1).evaluate((button: HTMLButtonElement) => button.click()) + await expect(page.locator('.form-panel h1')).toHaveText(new RegExp(`^${index}\\.`)) +} + +/** Inputs are intentionally raw/untrimmed; JSON alone would lose that evidence. */ +async function seedWorkspace(page: Page, validServiceUrl = false) { + await page.goto('/') + await page.getByLabel('서비스 이름', { exact: true }).fill(' Boundary Fixture 서비스 ') + await page.getByLabel('서비스 URL', { exact: true }).fill(validServiceUrl ? 'https://example.test/privacy' : 'https://example.test/privacy?token=RAW-ONLY-FIXTURE') + await step(page, 2) + await page.getByRole('checkbox', { name: '이메일 주소', exact: true }).check() + await page.getByRole('combobox', { name: '이메일 주소 수집 구분', exact: true }).selectOption('선택') + await page.getByLabel('수집 경로', { exact: true }).fill(' test-fixture signup ') + await step(page, 3) + await page.getByLabel('이메일 주소 처리 목적', { exact: true }).fill(' test-fixture account notices ') + await step(page, 4) + await page.getByRole('combobox', { name: '개인정보 보유 여부', exact: true }).selectOption('applies') + await page.getByLabel('대표 보유 기간 또는 종료 조건', { exact: true }).fill(' test-fixture account closure ') + await step(page, 5) + await page.getByRole('combobox', { name: '제3자 제공 여부', exact: true }).selectOption('yes') + await page.getByLabel('제공받는 자', { exact: true }).fill(' Test Fixture Recipient ') + await page.getByLabel('제공 목적', { exact: true }).fill(' test-fixture delivery ') + await step(page, 6) + await page.getByRole('combobox', { name: '국외 이전 여부', exact: true }).selectOption('yes') + await page.getByLabel('이전 국가', { exact: true }).fill(' Test Fixture Country ') + await page.getByLabel('국외 수령자', { exact: true }).fill(' Test Fixture Overseas ') + await step(page, 7) + await page.getByLabel('담당자 또는 담당 부서', { exact: true }).fill(' Test Fixture Privacy Team ') + await page.getByLabel('연락 이메일', { exact: true }).fill('privacy@example.test') + await step(page, 2) +} + +async function observableWorkspace(page: Page) { + return page.evaluate(() => ({ + heading: document.querySelector('.form-panel h1')?.textContent, + controls: [...document.querySelectorAll('.form-panel input, .form-panel select')].map((control) => ({ + tag: control.tagName, name: control.name, type: control.type, + value: control.value, + checked: control instanceof HTMLInputElement ? control.checked : null, + selected: control instanceof HTMLSelectElement ? [...control.options].map((option) => ({ value: option.value, selected: option.selected })) : null, + disabled: control.matches(':disabled'), readOnly: control instanceof HTMLInputElement ? control.readOnly : null, + })), + navigation: [...document.querySelectorAll('.rail li')].map((item) => ({ text: item.textContent, class: item.className, current: item.querySelector('button')?.getAttribute('aria-current') })), + progress: document.querySelector('.progress-copy')?.textContent, + progressWidth: document.querySelector('.progress i')?.style.width, + preview: document.querySelector('.paper')?.textContent, + previewMeta: document.querySelector('.meta')?.textContent, + readiness: [...document.querySelectorAll('.review-stat')].map((item) => item.textContent), + publishDisabled: document.querySelector('.publish')?.disabled, + navigationDisabled: [...document.querySelectorAll('.form-actions button')].map((button) => button.disabled), + })) +} + +/** + * Normal native JSON bytes plus raw DOM properties at all seven authoring steps. + * This proves observable work, including disabled controls and raw values omitted + * by the JSON projection. It cannot prove discarded/unmounted disabled-item stale + * facts which neither the public export nor a visible control exposes; no React + * internals or production testing API is used to claim that inaccessible evidence. + */ +async function workspaceSnapshot(page: Page) { + const bytes = await jsonBytes(page) + const active = await page.locator('.rail ol button').evaluateAll((buttons) => buttons.findIndex((button) => button.getAttribute('aria-current') === 'step') + 1) + // Native DOM activation avoids auto-scroll/animation waits during a read-only + // survey; the public rail still owns every state transition (no React access). + async function surveyStep(index: number) { + await page.locator('.rail ol button').nth(index - 1).evaluate((button: HTMLButtonElement) => button.click()) + await expect(page.locator('.form-panel h1')).toHaveText(new RegExp(`^${index}\\.`)) + } + const steps = [] + for (let index = 1; index <= 7; index += 1) { + await surveyStep(index) + steps.push(await observableWorkspace(page)) + } + await surveyStep(active) + return { json: bytes.toString('utf8'), active, steps, current: await observableWorkspace(page) } +} + +async function audit(page: Page) { + return page.evaluate(() => window.__reviewBoundary.events) +} +async function clearAudit(page: Page) { + // Wait for native JSON snapshot cleanup before isolating summary evidence. + await expect.poll(async () => { + const events = await audit(page) + return events.filter(({ kind }) => kind === 'create').length - events.filter(({ kind }) => kind === 'revoke').length + }).toBe(0) + await page.evaluate(() => { window.__reviewBoundary.events.length = 0 }) +} +async function assertLifecycle(page: Page, expectedAllocations: number, clicked: boolean) { + await expect.poll(async () => (await audit(page)).filter(({ kind }) => kind === 'revoke').length).toBe(expectedAllocations) + const events = await audit(page) + const created = events.filter(({ kind }) => kind === 'create') + expect(created).toHaveLength(expectedAllocations) + expect(new Set(created.map(({ url }) => url)).size).toBe(expectedAllocations) + for (const creation of created) { + expect(creation.type).toBe('text/plain;charset=utf-8') + expect(creation.native).toBe(true) + expect(creation.url).toMatch(/^blob:http:\/\/127\.0\.0\.1:4173\//) + const revokes = events.filter(({ kind, url }) => kind === 'revoke' && url === creation.url) + expect(revokes).toEqual([{ kind: 'revoke', url: creation.url, type: creation.type, sameTask: false, cleanupTask: true, native: true }]) + const revokeIndex = events.indexOf(revokes[0]) + expect(events.findIndex(({ kind, url }) => kind === 'activation-microtask' && url === creation.url)).toBeLessThan(revokeIndex) + expect(events.slice(0, revokeIndex).some(({ kind }) => kind === 'timer-enter')).toBe(true) + if (clicked) { + const click = events.find(({ kind, url }) => kind === 'click-return' && url === creation.url) + expect(click).toEqual({ kind: 'click-return', url: creation.url, filename: 'policyweave-review.txt', native: true }) + expect(events.indexOf(click!)).toBeLessThan(revokeIndex) + expect(events.findIndex(({ kind, url }) => kind === 'click-enter' && url === creation.url)).toBeLessThan(events.indexOf(click!)) + } + } + // A second real task must not reveal a duplicate cleanup. + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0))) + expect((await audit(page)).filter(({ kind }) => kind === 'revoke')).toHaveLength(expectedAllocations) + return events +} +async function attachReceipt(testInfo: TestInfo, name: string, data: unknown) { + // The configured line reporter does not retain successful body attachments. + // Write the same actual observation under the runner's isolated output path. + const path = testInfo.outputPath(name) + await writeFile(path, JSON.stringify(data, null, 2)) + await testInfo.attach(name, { path, contentType: 'application/json' }) +} + +test('native TXT MIME, original URL/click and next-task cleanup preserve every observable step', async ({ page }, testInfo) => { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await seedWorkspace(page) + const before = await workspaceSnapshot(page) + expect(before.json).not.toContain('RAW-ONLY-FIXTURE') + expect(before.steps[0].controls.map(({ value }) => value)).toContain('https://example.test/privacy?token=RAW-ONLY-FIXTURE') + await clearAudit(page) + const first = await summary(page) + const firstLifecycle = await assertLifecycle(page, 1, true) + expect(first.toString('utf8')).toContain('Boundary Fixture 서비스') + expect(first.toString('utf8')).toContain('공개본 아님') + expect(first.toString('utf8')).not.toContain('RAW-ONLY-FIXTURE') + await expect(page.locator('output')).toContainText('다운로드를 시작했습니다') + expect(await workspaceSnapshot(page)).toEqual(before) + await clearAudit(page) + const second = await summary(page, true) + const secondLifecycle = await assertLifecycle(page, 1, true) + expect(second.equals(first)).toBe(true) + expect(await workspaceSnapshot(page)).toEqual(before) + expect(errors).toEqual([]) + await attachReceipt(testInfo, 'native-summary-lifecycle.json', { firstLifecycle, secondLifecycle, before, errors }) +}) + +for (const stage of ['blob', 'url', 'anchor', 'click'] as const) { + test(`injected ${stage} failure reports generic retry; native retry preserves observable workspace`, async ({ page }, testInfo) => { + const errors: string[] = [] + const downloads: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('download', (download) => downloads.push(download.suggestedFilename())) + await seedWorkspace(page) + const before = await workspaceSnapshot(page) + await clearAudit(page) + downloads.length = 0 + await page.evaluate((failure) => window.__reviewBoundary.arm(failure), stage) + await page.getByRole('button', { name: '검토 요약 다운로드', exact: true }).click() + await expect(page.locator('output')).toHaveText('검토 요약을 내보내지 못했습니다. 다시 시도하세요.') + const failureLifecycle = await assertLifecycle(page, stage === 'anchor' || stage === 'click' ? 1 : 0, false) + expect(failureLifecycle.filter(({ kind }) => kind === `injected-${stage}`)).toHaveLength(1) + expect(failureLifecycle.filter(({ kind }) => kind === 'click-return')).toEqual([]) + expect(downloads).toEqual([]) + expect(await workspaceSnapshot(page)).toEqual(before) + await clearAudit(page) + await page.evaluate(() => window.__reviewBoundary.arm(null)) + const bytes = await summary(page) + const retryLifecycle = await assertLifecycle(page, 1, true) + await expect(page.locator('output')).toContainText('다운로드를 시작했습니다') + expect(bytes.toString('utf8')).toContain('Boundary Fixture 서비스') + expect(await workspaceSnapshot(page)).toEqual(before) + expect(errors).toEqual([]) + await attachReceipt(testInfo, `injected-${stage}-retry.json`, { failureLifecycle, retryLifecycle, before, errors }) + }) +} + +for (const outcome of ['valid', 'invalid', 'read-rejection'] as const) { + test(`pending owned File.text ${outcome}: zero summary allocation, recovery and observable preservation`, async ({ page }, testInfo) => { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + await seedWorkspace(page, true) + // Use an actual native schema-v1 export as the valid import, not invented JSON. + await step(page, 1) + await page.getByLabel('서비스 이름', { exact: true }).fill('Restored Fixture 서비스') + const importBytes = await jsonBytes(page) + await page.getByLabel('서비스 이름', { exact: true }).fill('Prior Fixture 서비스') + await step(page, 2) + const beforeImport = await workspaceSnapshot(page) + await clearAudit(page) + const beforePending = await observableWorkspace(page) + await page.evaluate(() => window.__reviewBoundary.hold()) + await page.getByLabel('JSON 초안 가져오기', { exact: true }).setInputFiles({ + name: `test-fixture-${outcome}.json`, mimeType: 'application/json', + buffer: outcome === 'invalid' ? Buffer.from('{}') : importBytes, + }) + const button = page.getByRole('button', { name: '검토 요약 다운로드', exact: true }) + await expect(button).toBeDisabled() + await expect(page.getByLabel('JSON 초안 가져오기', { exact: true })).toBeDisabled() + await expect(page.locator('.editing-lock')).toHaveAttribute('aria-busy', 'true') + await expect(page.locator('.save-state')).toContainText('JSON 초안 확인 중') + await expect(page.getByRole('checkbox', { name: '이메일 주소', exact: true })).toBeDisabled() + // Native HTMLElement.click on a disabled button is ignored by the browser. + await button.evaluate((element: HTMLButtonElement) => element.click()) + await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0))) + const pendingEvents = await audit(page) + expect(pendingEvents.filter(({ kind }) => kind === 'create')).toEqual([]) + expect(pendingEvents.filter(({ kind }) => kind.startsWith('click-'))).toEqual([]) + const pendingWorkspace = await observableWorkspace(page) + expect(pendingWorkspace.controls.map(({ disabled: _disabled, ...control }) => control)).toEqual(beforePending.controls.map(({ disabled: _disabled, ...control }) => control)) + expect(pendingWorkspace.preview).toBe(beforePending.preview) + expect(pendingWorkspace.navigation).toEqual(beforePending.navigation) + expect(pendingWorkspace.progress).toBe(beforePending.progress) + expect(await page.evaluate(() => window.__reviewBoundary.fileReads)).toEqual([{ name: `test-fixture-${outcome}.json`, native: false }]) + await page.evaluate((reject) => window.__reviewBoundary.release(reject), outcome === 'read-rejection') + await expect(button).toBeEnabled() + await expect(page.getByLabel('JSON 초안 가져오기', { exact: true })).toBeEnabled() + await expect(page.getByLabel('JSON 초안 가져오기', { exact: true })).toHaveValue('') + await expect(page.locator('.editing-lock')).toHaveAttribute('aria-busy', 'false') + await expect(page.locator('.save-state')).toContainText('브라우저 작업 중') + if (outcome === 'valid') { + await expect(page.locator('output')).toContainText('초안을 불러왔습니다') + await expect(page.getByLabel('서비스 이름', { exact: true })).toHaveValue('Restored Fixture 서비스') + await expect(page.locator('.form-panel h1')).toHaveText('1. 서비스 정보') + } else { + await expect(page.locator('output')).toContainText('불러오지 못했습니다') + } + const recovered = await workspaceSnapshot(page) + if (outcome === 'valid') { + expect(recovered.json).toBe(importBytes.toString('utf8')) + expect(recovered.active).toBe(1) + expect(recovered.json).not.toBe(beforeImport.json) + } else { + expect(recovered).toEqual(beforeImport) + } + await clearAudit(page) + const bytes = await summary(page) + const recoveryLifecycle = await assertLifecycle(page, 1, true) + expect(bytes.toString('utf8')).toContain(outcome === 'valid' ? 'Restored Fixture 서비스' : 'Prior Fixture 서비스') + expect(await workspaceSnapshot(page)).toEqual(recovered) + const reads = await page.evaluate(() => window.__reviewBoundary.fileReads) + expect(reads).toEqual([{ name: `test-fixture-${outcome}.json`, native: outcome !== 'read-rejection' }]) + expect(errors).toEqual([]) + await attachReceipt(testInfo, `pending-${outcome}.json`, { pendingEvents, reads, beforeImport, recovered, recoveryLifecycle, errors }) + }) +} diff --git a/tests/e2e/review-summary.spec.ts b/tests/e2e/review-summary.spec.ts new file mode 100644 index 0000000..7a947d0 --- /dev/null +++ b/tests/e2e/review-summary.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test' +import { readFile } from 'node:fs/promises' + +/** Reads an actual browser download without substituting a fixture response. */ +async function downloadSummary(page: import('@playwright/test').Page, activate: () => Promise) { + const pending = page.waitForEvent('download') + await activate() + const download = await pending + expect(download.suggestedFilename()).toBe('policyweave-review.txt') + const path = await download.path() + expect(path).not.toBeNull() + return readFile(path!, 'utf8') +} + +test('actual TXT summary mouse, keyboard or touch download preserves current work and stable bytes', async ({ page }, testInfo) => { + await page.goto('/') + await page.getByRole('textbox', { name: '서비스 이름', exact: true }).fill('Review Fixture') + const button = page.getByRole('button', { name: '검토 요약 다운로드' }) + const first = await downloadSummary(page, () => testInfo.project.name.startsWith('mobile') ? button.tap() : button.click()) + expect(first).toContain('필수 확인: 7건') + expect(first).toContain('Review Fixture') + expect(first).toContain('공개본 아님') + await button.focus() + const second = await downloadSummary(page, () => button.press('Enter')) + expect(second).toBe(first) + await expect(page.getByRole('textbox', { name: '서비스 이름', exact: true })).toHaveValue('Review Fixture') + await page.getByRole('textbox', { name: '서비스 URL', exact: true }).fill('https://example.test/?token=DO-NOT-EXPORT') + const unsafe = await downloadSummary(page, () => button.click()) + expect(unsafe).toContain('service_url_format') + expect(unsafe).not.toContain('DO-NOT-EXPORT') + await page.getByRole('textbox', { name: '서비스 이름', exact: true }).fill('Changed Fixture') + const changed = await downloadSummary(page, () => button.click()) + expect(changed).toContain('Changed Fixture') + expect(changed).not.toBe(first) + const bounds = await button.boundingBox() + expect(bounds?.height).toBeGreaterThanOrEqual(48) + expect(bounds?.width).toBeGreaterThanOrEqual(48) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('review-summary.png'), fullPage: true }) +}) diff --git a/tests/review_summary_guard_contract.mjs b/tests/review_summary_guard_contract.mjs new file mode 100644 index 0000000..34fef6e --- /dev/null +++ b/tests/review_summary_guard_contract.mjs @@ -0,0 +1,249 @@ +/** + * Independent handler-guard mutation proof, not a product-defect RED or browser E2E. + * Run: TMPDIR="$HOME/.hermes/cache/scratch" node --test tests/review_summary_guard_contract.mjs + * Only private App copies are mutated. Both variants remove the summary button's + * disabled prop; the negative also removes the handler guard. Real React DOM + * events must reach the enabled button while a synthetic File.text() is pending. + * Installed dependencies are shared by a symlink (versions checked against the + * manifest and lock), not copied/reinstalled or claimed to be a clean install. + * Fixture source, config and Vite caches stay in scratch. Raw reports/receipts + * remain there; runtime copies are removed after both subprocesses have exited. + * This standalone Node test is NOT automatically admitted by npm test or CI. + */ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const marker = 'HANDLER_GUARD_PENDING_IMPORT_ALLOCATION' +const sourcePaths = ['src/App.tsx', 'src/policy.ts', 'src/policy-review-report.ts', 'package.json', 'package-lock.json', 'vite.config.ts', 'tests/review_summary_guard_contract.mjs'] +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex') + +function replaceExactlyOnce(source, needle, replacement) { + assert.equal(source.split(needle).length - 1, 1, `mutation selector must match once: ${needle}`) + return source.replace(needle, replacement) +} + +const fixtureTest = ` +import React from 'react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import App from './src/App' +import { createPolicyExport, initialFacts, initialItems } from './src/policy' + +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers() }) + +it('real enabled summary click allocates zero URLs while File.text is pending', async () => { + vi.useFakeTimers() + const allocate = vi.fn(() => 'blob:review-guard-fixture') + const revoke = vi.fn() + vi.stubGlobal('URL', class extends URL { static createObjectURL = allocate; static revokeObjectURL = revoke }) + const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const { container, getByRole } = render() + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + let finish!: (text: string) => void + let settled = false + const pendingRead = new Promise((resolve) => { finish = resolve }) + const read = vi.fn(() => pendingRead) + const file = new File(['pending synthetic draft'], 'synthetic-draft.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { value: read }) + const nativeClick = vi.fn() + try { + fireEvent.change(fileInput, { target: { files: [file] } }) + const button = getByRole('button', { name: '검토 요약 다운로드' }) as HTMLButtonElement + expect(read).toHaveBeenCalledOnce() + expect(fileInput.disabled).toBe(true) + expect(container.querySelector('fieldset')?.disabled).toBe(true) + expect(container.querySelector('.save-state')?.textContent).toContain('JSON 초안 확인 중') + expect(button.disabled, 'fixture must remove the button disabled prop').toBe(false) + expect(button.matches(':disabled'), 'no disabled ancestor may swallow activation').toBe(false) + expect(settled).toBe(false) + button.addEventListener('click', nativeClick) + fireEvent.click(button) + expect(nativeClick).toHaveBeenCalledOnce() + expect(settled).toBe(false) + const pendingAllocations = allocate.mock.calls.length + const pendingDownloads = anchorClick.mock.calls.length + + // Settle genuine async import, then prove the SAME public UI path can export. + await act(async () => { + finish(JSON.stringify(createPolicyExport(initialItems, false, initialFacts))) + await pendingRead + settled = true + }) + expect(fileInput.disabled).toBe(false) + expect(container.querySelector('.save-state')?.textContent).not.toContain('JSON 초안 확인 중') + fireEvent.click(button) + expect(nativeClick).toHaveBeenCalledTimes(2) + expect(allocate.mock.calls.length).toBe(pendingAllocations + 1) + expect(anchorClick.mock.calls.length).toBe(pendingDownloads + 1) + expect((anchorClick.mock.instances.at(-1) as HTMLAnchorElement).download).toBe('policyweave-review.txt') + vi.runAllTimers() + expect(revoke.mock.calls.length).toBe(allocate.mock.calls.length) + expect(pendingAllocations, '${marker}').toBe(0) + expect(pendingDownloads).toBe(0) + } finally { + if (!settled) { + await act(async () => { finish('{}'); await pendingRead; settled = true }) + } + vi.runAllTimers() + } +}) +` + +function observeOwnedGroup(pgid) { + const observed = spawnSync('/bin/ps', ['-axo', 'pid=,pgid='], { encoding: 'utf8', timeout: 5000 }) + assert.equal(observed.error, undefined, 'process lookup failed; custody unknown') + assert.equal(observed.status, 0, 'process lookup failed; custody unknown') + return observed.stdout.trim().split('\n').filter(Boolean).map((line) => line.trim().split(/\s+/).map(Number)) + .filter(([, group]) => group === pgid).map(([pid]) => pid) +} + +function runVitest(fixtureRoot, evidenceRoot, name) { + const argv = [join(projectRoot, 'node_modules/vitest/vitest.mjs'), 'run', '--root', fixtureRoot, + '--config', join(fixtureRoot, 'vitest.config.mjs'), '--reporter=json', '--outputFile', join(evidenceRoot, `${name}.report.json`)] + return new Promise((resolveRun, rejectRun) => { + const child = spawn(process.execPath, argv, { + cwd: fixtureRoot, + env: { ...process.env, NODE_ENV: 'test', TMPDIR: fixtureRoot, NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, // Own one POSIX process group, including ordinary Vitest workers. + }) + let stdout = '' + let stderr = '' + let timedOut = false + let launchError + child.stdout.on('data', (chunk) => { stdout += chunk }) + child.stderr.on('data', (chunk) => { stderr += chunk }) + child.on('error', (error) => { launchError = error }) + let deadlineSignalError + const deadline = setTimeout(() => { + timedOut = true + try { process.kill(-child.pid, 'SIGKILL') } catch (error) { deadlineSignalError = error.code } + }, 120000) + child.on('close', (exitCode, signal) => { + clearTimeout(deadline) + let remainingGroupPids = null + let custodyError = null + try { remainingGroupPids = observeOwnedGroup(child.pid) } catch (error) { custodyError = String(error) } + const receipt = { name, executable: process.execPath, nodeVersion: process.version, argv, pid: child.pid, + exitCode, signal, timedOut, launchError: launchError?.message ?? null, deadlineSignalError: deadlineSignalError ?? null, + remainingGroupPids, custodyError, stdout, stderr } + try { + writeFileSync(join(evidenceRoot, `${name}.process.json`), `${JSON.stringify(receipt, null, 2)}\n`) + if (launchError) rejectRun(launchError) + else resolveRun(receipt) + } catch (error) { rejectRun(error) } + }) + }) +} + +function verifyReport(receipt, evidenceRoot, name, shouldFail) { + assert.equal(receipt.timedOut, false, `${name}: timeout is not mutation evidence`) + assert.equal(receipt.signal, null, `${name}: terminated process is not mutation evidence`) + assert.equal(receipt.custodyError, null, `${name}: process-group custody unknown`) + assert.deepEqual(receipt.remainingGroupPids, [], `${name}: owned Vitest workers remain alive`) + assert.equal(receipt.exitCode, shouldFail ? 1 : 0, `${name}: ${receipt.stdout}\n${receipt.stderr}`) + const report = JSON.parse(readFileSync(join(evidenceRoot, `${name}.report.json`), 'utf8')) + assert.equal(report.numTotalTestSuites, 1) + assert.equal(report.numTotalTests, 1) + assert.equal(report.numPendingTestSuites, 0) + assert.equal(report.numFailedTestSuites, shouldFail ? 1 : 0) + assert.equal(report.numPassedTestSuites, shouldFail ? 0 : 1) + assert.equal(report.numPendingTests, 0) + assert.equal(report.numFailedTests, shouldFail ? 1 : 0) + assert.equal(report.numPassedTests, shouldFail ? 0 : 1) + assert.equal(report.success, !shouldFail) + assert.equal(report.testResults.length, 1) + const results = report.testResults[0].assertionResults + assert.equal(results.length, 1) + assert.equal(results[0].status, shouldFail ? 'failed' : 'passed') + if (shouldFail) { + assert.equal(results[0].failureMessages.length, 1) + assert.match(results[0].failureMessages[0], new RegExp(`${marker}: expected 1 to be (?:\\+)?0`)) + } else { + assert.deepEqual(results[0].failureMessages, []) + } + assert.doesNotMatch(`${receipt.stderr}\n${receipt.stdout}`, /Unhandled (?:Error|Rejection)|Failed to (?:load|resolve)|Transform failed|Test timed out/i) + return { exitCode: receipt.exitCode, testStatus: results[0].status, failureMessages: results[0].failureMessages } +} + +test('independent handler guard survives enabled-button positive and rejects guard-removal mutant', async (t) => { + const scratchParent = resolve(process.env.TMPDIR ?? join(homedir(), '.hermes/cache/scratch')) + mkdirSync(scratchParent, { recursive: true }) + const evidenceRoot = mkdtempSync(join(scratchParent, 'policyweave-review-guard-')) + t.diagnostic(`scratch receipts: ${evidenceRoot}`) + const original = Object.fromEntries(sourcePaths.map((path) => [path, readFileSync(join(projectRoot, path))])) + const beforeHashes = Object.fromEntries(sourcePaths.map((path) => [path, digest(original[path])])) + for (const path of sourcePaths) { + const snapshotPath = join(evidenceRoot, 'original-inputs', path) + mkdirSync(dirname(snapshotPath), { recursive: true }) + writeFileSync(snapshotPath, original[path]) + } + writeFileSync(join(evidenceRoot, 'guard.test.tsx'), fixtureTest) + const manifest = JSON.parse(original['package.json']) + const lock = JSON.parse(original['package-lock.json']) + const dependencyVersions = {} + for (const [name, pinned] of Object.entries({ ...manifest.dependencies, ...manifest.devDependencies })) { + const installed = JSON.parse(readFileSync(join(projectRoot, 'node_modules', name, 'package.json'), 'utf8')).version + assert.equal(installed, pinned, `installed direct dependency mismatch: ${name}`) + assert.equal(lock.packages[`node_modules/${name}`].version, pinned, `lock mismatch: ${name}`) + dependencyVersions[name] = installed + } + const button = '' + const enabledButton = button.replace(' disabled={isImporting}', '') + const guarded = ' function exportReview() {\n if (isImporting) return\n' + const unguarded = ' function exportReview() {\n' + const originalApp = original['src/App.tsx'].toString('utf8') + const positiveApp = replaceExactlyOnce(originalApp, button, enabledButton) + assert.equal(positiveApp.split(guarded).length - 1, 1, 'positive must retain the exact handler guard') + const negativeApp = replaceExactlyOnce(positiveApp, guarded, unguarded) + const runtimeRoots = [] + const processReceipts = [] + const summary = { evidenceKind: 'scratch-only UI handler-guard mutation proof, not a reproduced product bug', + dependencySharing: { kind: 'node_modules symlink; direct installed/manifest/lock version parity', + target: realpathSync(join(projectRoot, 'node_modules')), versions: dependencyVersions }, + beforeHashes, variants: {}, afterHashes: {}, runtimeRemoved: false } + let primaryFailure + try { + for (const [name, app] of [['positive', positiveApp], ['negative', negativeApp]]) { + const fixtureRoot = join(evidenceRoot, `${name}-runtime`) + runtimeRoots.push(fixtureRoot) + mkdirSync(join(fixtureRoot, 'src'), { recursive: true }) + writeFileSync(join(fixtureRoot, 'package.json'), '{"private":true,"type":"module"}\n') + symlinkSync(join(projectRoot, 'node_modules'), join(fixtureRoot, 'node_modules'), 'dir') + writeFileSync(join(fixtureRoot, 'src/App.tsx'), app) + for (const path of ['src/policy.ts', 'src/policy-review-report.ts']) writeFileSync(join(fixtureRoot, path), original[path]) + writeFileSync(join(fixtureRoot, 'guard.test.tsx'), fixtureTest) + writeFileSync(join(fixtureRoot, 'vitest.config.mjs'), `import react from '@vitejs/plugin-react'\nimport { defineConfig } from 'vitest/config'\nexport default defineConfig({ plugins: [react()], cacheDir: './.vite-cache', test: { environment: 'jsdom', include: ['guard.test.tsx'], pool: 'forks', maxWorkers: 1, fileParallelism: false, testTimeout: 60000 } })\n`) + // Retain exact mutation inputs separately from disposable runtime/cache trees. + writeFileSync(join(evidenceRoot, `${name}.App.tsx`), app) + const receipt = await runVitest(fixtureRoot, evidenceRoot, name) + processReceipts.push(receipt) + summary.variants[name] = { appHash: digest(app), mutation: name === 'positive' ? ['remove summary-button disabled prop'] : ['remove summary-button disabled prop', 'remove exportReview isImporting guard'], + ...verifyReport(receipt, evidenceRoot, name, name === 'negative') } + t.diagnostic(`${name}: Vitest exit ${receipt.exitCode}; ${summary.variants[name].testStatus}${name === 'negative' ? ` at ${marker} (1 URL allocation vs 0)` : ' (0 pending allocations; fresh post-import UI export works)'}`) + } + } catch (error) { + primaryFailure = error + summary.failure = String(error) + } finally { + const custodySettled = processReceipts.length === runtimeRoots.length && processReceipts.every((receipt) => + receipt.custodyError === null && Array.isArray(receipt.remainingGroupPids) && receipt.remainingGroupPids.length === 0) + // Never delete a runtime still owned by a producer whose settlement is unknown. + if (custodySettled) for (const root of runtimeRoots) rmSync(root, { recursive: true, force: true }) + summary.processCustodySettled = custodySettled + summary.runtimeRemoved = runtimeRoots.every((root) => !existsSync(root)) + summary.afterHashes = Object.fromEntries(sourcePaths.map((path) => [path, digest(readFileSync(join(projectRoot, path)))])) + writeFileSync(join(evidenceRoot, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`) + } + if (primaryFailure) throw primaryFailure + assert.equal(summary.runtimeRemoved, true) + assert.deepEqual(summary.afterHashes, beforeHashes, 'live input source changed during the proof') + t.diagnostic('live source hashes unchanged; both child close events observed; owned runtime/cache trees removed') +}) From 2117734f1956b7200bd8b396066e33f800e18436 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 5 Oct 2026 02:25:54 +0900 Subject: [PATCH 2/7] fix: explain memory-only draft preservation Remove the unsupported temporary-save claim and distinguish app versions. Add static authoring notices across seven steps with native mobile/download/reload/restore acceptance and source-bound layered-review evidence. --- ARCHITECTURE.md | 2 + CHANGELOG.md | 2 + docs/ADR-0005-local-draft-restore.md | 8 + docs/PRD.md | 15 + docs/TRD.md | 8 + docs/evidence/session-notice-20261005.md | 86 ++++++ docs/product-technical-gap-baseline.md | 14 + docs/research-traceability.md | 8 + src/App.tsx | 12 +- src/initial-workspace.test.tsx | 27 ++ src/policy-import-ui.test.tsx | 6 + src/policy-review-ui.test.tsx | 4 + tests/e2e/session-notice.spec.ts | 336 +++++++++++++++++++++++ 13 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/session-notice-20261005.md create mode 100644 tests/e2e/session-notice.spec.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3708ea4..b90c939 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -37,6 +37,8 @@ The separate `src/policy-review-report.ts` read projection creates the local min `App.tsx` owns the fixed-name `policyweave-review.txt` browser download, pending-import disable/handler guard, bounded failure feedback and next-task object-URL cleanup after allocation. Download initiation changes feedback only, not authored facts, navigation or readiness. Presentation `report_format: v1` is independent of fact `schema_version: 1` and neither is a publication revision. [ADR-0006](docs/ADR-0006-local-review-summary.md) records the choice; [local layered proposal/review evidence](docs/evidence/mixed-agents-review-summary.md) records the development workflow, not runtime product AI, heterogeneous-model verification or an approval receipt. No new network, storage, dependency or legal-rule boundary is introduced. +PRD `US-SESSION-01` exposes, rather than changes, that memory boundary. Shared `SessionNotice` is a static paragraph after the section heading and before inputs in all three editor component types, yielding one notice in each active step. It explains no automatic saving, reload/tab-close loss and the existing JSON export path without new live/focus ownership, a global layout row, browser storage, beforeunload or persistence adapter. Header/preview `앱 버전 0.1.0` is separate from fact `schema_version: 1`, report `report_format: v1` and any future publication revision. Import's existing polite pending status and semantic lock retain their own ownership; the notice neither alters facts/readiness nor certifies file storage. JSON carries admitted normalized facts, not a complete raw-input backup; TXT is a review projection, not a restore source. [The session evidence record](docs/evidence/session-notice-20261005.md) separates layered development, bounded TDD observations and unfinished current-candidate gates from historical receipts. + Authoring completeness is deliberately separate from legal sufficiency. Current readiness rules prove that product-defined fact responsibilities were explicitly addressed; they do not assert that a policy complies with law. Source/effective-date-bound legal validation belongs to the Legal Source Registry -> Review & Publication boundary. ## Persistence boundary (Proposed schema; CI-only runtime) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878a1c3..aff2eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri ## Unreleased ### Added +- Candidate `US-SESSION-01` static memory-only notice before input on every authoring step: no automatic saving, reload/tab-close loss, and existing JSON export for portability. One shared paragraph covers all three editor types without live/focus ownership or a global layout change; existing pending-import feedback/locks remain. No autosave, browser storage, beforeunload, network, dependency or schema/legal-rule change. [The 2026-10-05 evidence record](docs/evidence/session-notice-20261005.md) preserves parent-reported focused TDD/build observations separately from historic suite totals and unfinished current-candidate browser/full-suite/hosted/approval gates; this is not a passing or release receipt. - Local minimal TXT review summary on the candidate branch: `createPolicyReviewText` reuses canonical export state/service identity/ordered finding codes, existing seven-step completion and recommendation derivation; `검토 요약 다운로드` starts `policyweave-review.txt` (`text/plain;charset=utf-8`) without network or source-state changes. It omits detailed operational/contact facts, distinguishes report-format v1 from facts-schema v1, retains unknown findings and escapes dynamic line/direction controls. Import-time disable/handler guard, contained preparation/activation failures and deferred object-URL reclamation preserve the local boundary. ADR-0006 and the layered MoA-inspired decision record record completed local full-suite/browser successor evidence separately from unresolved visual inspection, exact-head hosted CI and qualifying approval; this entry is not a release or passing receipt. - Fail-closed local schema-v1 draft restore with exact object-shape, canonical-string, collection-catalog, closed-status, contradiction, and derived-evidence validation. Files above 1 MiB are rejected before parsing, invalid files preserve the current workspace, and accepted facts are reconstructed without trusting file-supplied readiness or finding claims. The import and authoring controls are disabled only while a selected file is read and validated, then re-enabled on success or failure so accepted restore cannot overwrite concurrent edits; the visible import control exposes its disabled state and the existing live status region announces `JSON 초안 확인 중` during that interval. The native authoring `fieldset` remains a semantic grid item rather than using `display: contents`. Its local-file control retains a visible high-contrast keyboard focus indicator and 44 px target. The return path performs no network request and does not claim cross-version migration, persistence, backup, publication, or legal approval. - Runtime categorical-status admission regression matrix: 64 invalid-input cases, all 16 valid collection/retention/transfer combinations and disabled-item isolation. The matrix verifies stable owning findings, incomplete readiness, null export of unsupported statuses, source non-mutation and deterministic valid projections; ADR-0004 binds its hosted RED and bounded local verification without claiming a released external interoperability or cross-version migration contract. @@ -24,6 +25,7 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri - Playwright/axe browser evidence harness covering desktop, tablet, and mobile rendering; horizontal overflow; keyboard activation and focus transfer; explicit no-collection progression; retention-status transitions and stale-period invalidation; effective 200% browser-zoom reflow from the desktop profile; serious/critical automated accessibility findings; real-browser JSON download events with mouse, keyboard, and touch activation; fixed filename; JSON MIME; byte-stable repeated exports; review-ready payload semantics; success and preparation/activation-error object-URL cleanup; and exact-head screenshot artifacts. ### Changed +- Header and preview version labels now identify `앱 버전 0.1.0`, removing the unsupported `임시저장` claim. Application version is not JSON schema v1, TXT report format v1, a publication revision or proof of stored work. Existing JSON restore remains explicit and validated; reload/restore tests characterize that behavior rather than introduce automatic recovery. - Collection mode, retention status and both transfer statuses now require exact confirmed vocabulary at runtime in review and schema-v1 export. Truthy unknown values no longer complete steps or escape as confirmed facts; they preserve the existing owning finding and export as `null`. Valid positive/negative attestations, dependent details and independent completed responsibilities are preserved without coercion. - The active product-gap ledger separates current PR #1 integration truth from the former stack's historical receipts. The preceding ledger is retained byte-for-byte under `docs/evidence/product-gap-history-through-20260909.md`; no valid historical evidence is dropped or reused as current acceptance. - All direct npm packages now use exact reviewed versions. React and Lucide remain runtime dependencies; TypeScript, Vite, and the React Vite plugin are correctly classified with the test/build toolchain in `devDependencies`, and npm regenerated the lock graph so transitive development scope is accurate. diff --git a/docs/ADR-0005-local-draft-restore.md b/docs/ADR-0005-local-draft-restore.md index ca551ed..a53fc30 100644 --- a/docs/ADR-0005-local-draft-restore.md +++ b/docs/ADR-0005-local-draft-restore.md @@ -56,6 +56,14 @@ Pending-feedback test-only commit `3e2eba61e7e4f02c5ac8b3f9ee23895d515a37b3` the Semantic-fieldset test-only commit `52d252a2c0c46b12c6cecdebd3dcc67940322bde` reproduced the remaining accessibility risk by failing while `.editing-lock` used `display: contents`. Implementation `9d540895569ab945a087bed99c7d4906b82ae532` keeps the native disabled/`aria-busy` fieldset as the middle grid item, resets only its user-agent box, and gives the contained editing panel the grid item's height so bounded scrolling remains available. Focused style/import validation passed 10/10 locally; hosted browser and assistive-technology evidence remain separate gates. +## Session-boundary clarification — 2026-10-05 + +PRD `US-SESSION-01` adds truthful notice of this ADR's existing memory-only portability boundary; it does not revise schema admission, persistence or restore authority. The active editor shows one static paragraph after its heading and before inputs, explaining no automatic saving, reload/tab-close loss and existing JSON export. Header/preview `앱 버전 0.1.0` replaces the unsupported temporary-save label and is independent of JSON `schema_version: 1` and TXT `report_format: v1`. The notice does not replace the import live region, become a focus target, add beforeunload/browser storage or change the semantic input lock. + +A valid native JSON download → reload-empty → explicit import journey characterizes existing behavior; it does not establish automatic recovery or a new feature. JSON carries only admitted normalized facts: rejected URLs, inactive/discarded details and every raw input are not guaranteed round-trip backup content. A failed import preserves current state; success retains validated replacement and step-1 navigation. Download initiation is not completed storage, and TXT is not a restore artifact. Existing cancellation/stream work remains separately owned, not implied by this clarification. + +[The session evidence record](evidence/session-notice-20261005.md) retains parent-reported focused TDD/build observations separately from direct source inspection, all historic receipts above and unfinished current full/browser/hosted/independent-approval gates. This ADR remains Proposed; no new CI pass, protected integration, release or legal conclusion is asserted. + ## Consequences and follow-up PolicyWeave now owns a deterministic local export/restore round trip for schema-v1. This closes the missing current-version return path, not version migration. Any schema-v2 work must define explicit migration, loss reporting, compatibility fixtures, and rollback behavior. DB-backed versioned ko/en/ja/zh/vi/es/de/fr resources remain a separate owner contract; schema-v1 catalog-label identity must not be relaxed by embedding a full translation catalog in the browser. diff --git a/docs/PRD.md b/docs/PRD.md index c4dacb7..0dfd164 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -44,6 +44,21 @@ PolicyWeave는 법률 문장을 임의로 창작하는 도구가 아니다. 운 결정과 검증 범위: [ADR-0006](ADR-0006-local-review-summary.md), [로컬 MoA/검증 기록](evidence/mixed-agents-review-summary.md). 현재 후보의 실제 브라우저·전체 suite·exact-head CI·독립 승인 증거는 별도 게이트이며 이 수락조건의 기재만으로 통과하지 않는다. +## US-SESSION-01: 메모리 전용 작성과 보관 경계 안내 + +운영자로서 작성 내용이 자동 저장되지 않는다는 사실을 입력 전에 알고, 보관이 필요하면 기존 JSON 내보내기와 검증된 가져오기 경로를 선택하고 싶다. + +### 수락조건 + +1. 상단과 미리보기는 `앱 버전 0.1.0`으로 표시하고 `임시저장`·자동 저장·저장 완료를 주장하지 않는다. 앱 버전은 JSON `schema_version: 1`, TXT `report_format: v1`, 승인·발행 버전과 별개다. +2. 모든 7단계의 작성면에서 `.section-head` 다음, 첫 입력 전에 동일한 정적 안내를 정확히 하나 제공한다: `자동 저장되지 않습니다. 새로고침하거나 탭을 닫으면 작성 내용이 사라집니다. 보관하려면 JSON 내보내기를 사용하세요.` +3. 안내는 일반 문서 흐름에 남아 320 px 모바일에서도 숨겨지거나 가로로 잘리지 않아야 한다. 상단 `.version`·`.save-state`의 모바일 숨김 여부에 의존하지 않는다. 반복 live 알림, alert/status 역할 또는 별도 포커스 대상으로 만들지 않는다. +4. 편집, 단계 이동, 가져오기 대기·성공·실패와 JSON/TXT 다운로드 성공·실패 후에도 안내를 유지한다. 기존 가져오기 live 상태 `JSON 초안 확인 중` / `브라우저 작업 중`과 입력 잠금은 보존한다. 안내가 사실·선택·명시적 확인·완료/readiness 판정·내보내기 바이트를 바꾸지 않는다. 정상 가져오기의 검증 후 상태 교체와 첫 단계 이동은 기존 동작이다. +5. 유효한 schema-v1 파일의 실제 다운로드 → 새로고침 후 빈 작업공간 → 명시적 파일 가져오기 경로를 브라우저에서 확인한다. 이는 기존 메모리 소실/JSON 복원 동작의 특성화이며 새 자동 복원 기능이 아니다. JSON은 정규화·검증된 사실의 이동성 파일이지 잘못된 URL, 비활성/폐기된 원시 입력 전체를 보존하는 백업이 아니다. TXT는 복원 파일이 아니며 다운로드 시작은 파일 보관 완료를 증명하지 않는다. +6. 자동 저장, localStorage/sessionStorage/IndexedDB, beforeunload, 네트워크 영속화, 새 의존성 또는 스키마/법률 규칙을 추가하지 않는다. 저장·승인·준법·공개 완료를 안내로 추론하지 않는다. + +결정·MoA/TDD 및 검증 범위: [2026-10-05 세션 안내 증거](evidence/session-notice-20261005.md), [ADR-0005](ADR-0005-local-draft-restore.md). 소스 assertion·집중 로컬 실행·선행 suite·현재 후보의 전체/실제 브라우저·hosted CI·독립 승인 증거는 서로 대체하지 않는다. + ## 비목표 - 법률 자문 또는 준법 보장 diff --git a/docs/TRD.md b/docs/TRD.md index 7d8e668..b86227b 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -51,6 +51,14 @@ The output is a local review aid, not the full JSON portability payload. It excl Acceptance: PRD `US-REVIEW-01`; decision: [ADR-0006](ADR-0006-local-review-summary.md). Domain/UI/browser assertions must cover ordered blocker cardinality, unknown fallback, recommendation separation, contradiction ownership, hostile strings, unsafe URL non-disclosure, deterministic bytes, pending-import lock, complete workspace preservation and preparation/activation failure cleanup. [The local evidence record](evidence/mixed-agents-review-summary.md) preserves earlier bounded observations, completed local full-suite/browser successor evidence, and unresolved visual inspection, exact-head hosted and qualifying-approval gates. +## Memory-only authoring notice + +PRD `US-SESSION-01` makes the existing memory-only contract visible before input. `src/App.tsx` renders shared `SessionNotice` as a normal paragraph immediately after the section heading in `FactStep`, `CollectionForm` and `PurposeForm`; the active editor contains exactly one notice across all seven steps. Copy: `자동 저장되지 않습니다. 새로고침하거나 탭을 닫으면 작성 내용이 사라집니다. 보관하려면 JSON 내보내기를 사용하세요.` It is neither a live announcement nor an alert/status/focus target. It does not introduce a global workspace row or change the workspace height calculation; mobile acceptance must prove readable, non-hidden normal-flow text at 320 px rather than rely on header metadata that existing CSS hides. + +Header and preview label `앱 버전 0.1.0` identifies the application only, independently of JSON `schema_version: 1` and TXT `report_format: v1`. The notice has no state or persistence authority. Existing pending-import fieldset/input locks and polite `JSON 초안 확인 중` / `브라우저 작업 중` feedback remain unchanged. Import success retains the existing validated replacement/step-1 transition; failures and export outcomes must not gain fact/readiness mutations or saved-state claims. Normalized JSON portability does not preserve every rejected, inactive or discarded raw value; TXT cannot restore a draft. No autosave, browser storage, beforeunload, network, dependency, schema or legal-rule change is admitted. + +`src/initial-workspace.test.tsx` traces version semantics, one notice per step, input ordering and static semantics; existing import/export regressions support preservation. Real-browser acceptance additionally requires notice persistence across editing/import/export outcomes, narrow-mobile readability and native download → reload-empty → explicit validated restore. The last journey characterizes existing behavior, not a new restore mechanism. [The dated evidence record](evidence/session-notice-20261005.md) distinguishes parent-reported bounded TDD from directly inspected source and pending successor gates; predecessor totals are not this candidate's passing certificate. + ## Local draft portability The JSON file is a draft portability artifact, not a publication receipt, immutable revision, legal approval, or persistence backup. It may be exported while incomplete so operators can inspect and transfer their authored work without converting blanks into `none`. The schema-v1 return path reconstructs facts from admitted fields and recomputes readiness/findings rather than trusting file claims. Invalid files leave current browser state unchanged; authoring is locked only for the bounded read/validation interval so accepted restore cannot discard edits made during that interval. Contract changes require a new schema version, explicit migration/loss behavior, and compatibility evidence; the current fixed filename avoids using customer-controlled text as a filesystem name. Preparation/activation-error recovery does not establish cancellation of an in-progress browser transfer. diff --git a/docs/evidence/session-notice-20261005.md b/docs/evidence/session-notice-20261005.md new file mode 100644 index 0000000..3117920 --- /dev/null +++ b/docs/evidence/session-notice-20261005.md @@ -0,0 +1,86 @@ +# Memory-only editing notice — US-SESSION-01 evidence + +Date: 2026-10-05 (KST). Repository: PolicyWeave. Source base: `ee12e3fddefec1c4038e6da333019e3455e6a50f`; parent identifies this as Draft consumer PR #26. This record documents an uncommitted session-notice candidate, not release, current-head CI success or qualifying approval. This documentation owner edits only the seven assigned existing documents and this new record; no production change, commit, push or workflow operation is performed here. + +## Decision and acceptance boundary + +The existing workspace uses React memory, while the predecessor header claimed `버전 0.1.0 (임시저장)`. Existing mobile CSS hides `.version` and `.save-state`, so changing header copy alone cannot deliver the loss warning. PRD [US-SESSION-01](../PRD.md) selects a shared static paragraph in `FactStep`, `CollectionForm` and `PurposeForm`, after `.section-head` and before the first input, exactly once per active editor across all seven steps: + +> 자동 저장되지 않습니다. 새로고침하거나 탭을 닫으면 작성 내용이 사라집니다. 보관하려면 JSON 내보내기를 사용하세요. + +Header and preview identify `앱 버전 0.1.0`, separate from JSON `schema_version: 1`, TXT `report_format: v1` and future publication revisions. The paragraph owns no state, live announcement, alert/status role or focus target. Existing import's native fieldset/input lock and polite `JSON 초안 확인 중` / `브라우저 작업 중` remain under `isImporting`. It does not add a global row or alter workspace height arithmetic, browser storage, autosave, beforeunload, network, dependencies, schema or legal rules. + +Notice persistence is required during editing, navigation, import pending/success/failure and JSON/TXT export success/failure. Its presence must not change facts, selected items, attestations, completion/readiness or exported bytes. Accepted import still performs its existing validated state replacement and step-1 navigation. Native valid-JSON download → reload-empty → explicit restore characterizes existing memory/portability behavior, not new automatic recovery. JSON is admitted normalized facts, not every rejected/inactive/discarded raw value's backup; TXT is not a restore file. Download initiation is not storage completion. Mobile 320 px readable normal-flow placement and actual browser outcomes require runner evidence, not jsdom or copy inspection alone. + +## Methodology source and actual layered provenance + +Wang, J., Wang, J., Athiwaratkun, B., Zhang, C., & Zou, J. (2024, June 7), *Mixture-of-Agents Enhances Large Language Model Capabilities*, arXiv:2406.04692v1, DOI `10.48550/arXiv.2406.04692`, is the primary methodology source. Version-v1 metadata and abstract were retrieved on 2026-10-05; the abstract specifies prior-layer outputs as auxiliary inputs for each next-layer agent.[1] Only that development pattern is adopted; no benchmark score, speed/quality improvement, runtime product AI or paper reproduction is claimed. + +The documentation owner directly read both local manifests and their four task logs. Raw transcripts are not copied into this repository: local receipts are provenance pointers, not portable CI artifacts or GitHub approvals. + +| Stage | Local receipt | Directly observed scope | +| --- | --- | --- | +| Layer 1, independent whole proposals | `~/.hermes/cache/delegation/live/deleg_3fdbd3a9/manifest.json`, `task-0.log`, `task-1.log`; two tasks completed, 2026-10-05 01:20:33–01:26:07 KST | Product A and Technical B independently chose truthful session/save wording and existing JSON portability. Both identified the hidden mobile metadata gap; neither implemented production changes. | +| Layer 2, cross-review | `~/.hermes/cache/delegation/live/deleg_77762cef/manifest.json`, `task-0.log`, `task-1.log`; two tasks completed, 01:29:53–01:35:21 KST | Kickoffs supplied summaries of both Layer-1 plans and the parent aggregate. Reviewers conditionally retained shared editor-local placement, static semantics, import feedback, version separation and native restore/preservation checks. One reviewer explicitly reported not finding full proposal text. Full verbatim prior-output transfer is therefore not established. | +| Parent synthesis | Parent handoff and current source, traced in PRD/TRD/architecture | Retain warning before inputs in the three editor types, reject header-only/global-banner fixes, retain failure/data/mobile acceptance and no new storage. This is synthesis, not an additional independent approval. | + +Both manifests contain `model: null` and `provider: null`; same/inherited configuration does not establish resolved heterogeneous identities. This is a **MoA-inspired, summary-fed development workflow**, not a verified heterogeneous ensemble or complete reproduction of the paper's all-output transfer. Conditional planning review does not approve the final implementation. + +## Direct source inspection snapshot + +At 2026-10-05 01:51:26 KST, the documentation owner recorded these SHA-256 hashes from actual local bytes. They identify inspection, not the execution bytes of earlier parent tests or a frozen final successor. Other owners are still adding tests. + +| Source | SHA-256 | Observed trace | +| --- | --- | --- | +| `src/App.tsx` | `d846002aea63583d7bec553776ba293ebf843989eb0df4397af2eccd98295068` | Shared `SessionNotice`, three placements, header/preview app labels, unchanged pending-import feedback owner | +| `src/initial-workspace.test.tsx` | `4d0713d41e7f19d63009aa631d54762c415c0fd4db894503c2499f266d107bc2` | One notice across each of seven steps, input ordering, no live/alert/tabindex and unchanged initial zero completion; explicit header/preview labels | +| `src/styles.css` | `b8f434878d5a0b819196c837101a3faac628f2c94096f931b70466a8df955943` | Existing header mobile hiding, not modified by notice implementation | +| `src/policy.ts` | `e351c81b74d97c46f9179997b660dfaf7411c774b3aa1c56e4fbb2c707e7adfd` | Existing fact/readiness/export/restore authority; no notice-owned domain change | +| `src/policy-review-report.ts` | `0e22b3823c63b2c1f8287e7aa884436d76446882e906612ba4e0daf942488f1a` | Existing TXT projection/version, not a restore mechanism | + +Assertion source is not execution evidence. Public control properties plus normalized exports can support observable preservation but cannot certify unmounted/discarded hidden raw facts. Existing unsafe-URL normalization/admission limits are not repaired or waived by the notice. + +## Parent-reported bounded TDD observations + +The parent handoff supplies the following local observations. This documentation owner did not independently re-execute them, inspect their full command logs, or bind their execution to the snapshot hashes above. They must not be promoted to CI/full-suite evidence or summed with later totals. + +| Slice | Reported observation | Evidence boundary | +| --- | --- | --- | +| Temporary-save claim RED | One intended failure / three passes observed, then outer 60-second timeout; rerun completed exit 1 | Original timeout retained as incomplete execution, not PASS; completed rerun is intended RED only | +| Missing notice RED | Initial run reported two failures / three passes, exit 1; null-check improved to identify the intended missing step-1 notice | Harness/null access is not counted as an independent product defect; intended absence is the regression target | +| Header label GREEN | Focused one pass / four selection skips | Selected-case evidence, not four additional passes or full suite | +| Static notice GREEN | Focused five cases passed | Bounded local report only; not native mobile/layout acceptance | +| Preview label RED→GREEN | New intended one failure / four selection skips, then five cases passed and build passed | Version slice and bounded local build; no final-source full gate inferred | + +New existing-behavior reload/restore and failure-preservation characterization may be first-GREEN. Do not manufacture behavioral RED from a locator/setup error, host contention, timeout or previously implemented restore behavior. + +## Historic receipts retained, successor gates not inferred + +[The original summary evidence](mixed-agents-review-summary.md) and [2026-10-04 successor](review-summary-successor-20261004.md) remain unchanged. Their completed 193 unit/UI and 51 browser passes with 12 scoped skips belong to predecessor work, not US-SESSION-01. Earlier harness failures, native-browser survey-budget disclosures, clean-install warning, screenshot limitations and unresolved integration obligations remain intact. + +| Gate | State at this documentation handoff | Required before broader acceptance | +| --- | --- | --- | +| Current full unit/UI, lint and build | Pending here; parent-reported focused/build observations above only | Completed canonical commands with source/test identities and exit status; no stale totals | +| Session native-browser/mobile successor | Underway in another owner lane; no completed result claimed here | 320 px readable/non-hidden notice, edit/import/export persistence, public-state/version preservation, valid native download/reload/restore, actual downloads/failure cleanup and final build identity | +| Visual/assistive-technology acceptance | Not established | Inspect current rendered screenshots separately from no-overflow assertions; native zoom/screen-reader evidence remains separate | +| Exact-head hosted required checks | Unverified here | Fresh repository/PR/source head/base/checkout/run/job/artifact identity and terminal live required workflows | +| Qualifying independent approval and threads | Unresolved here | Final whole-candidate review, current qualifying approval and resolved required threads; no bypass | +| Protected integration/publication/release | Not performed or claimed | Ordinary governed integration and independent hosted/publication/security contracts | + +Parent reports Draft PR #26 as consumer, separate Draft PR #25 owning import-stream/cancellation, and central `.github` migration work (PR #2565, Draft `ab0c8659`) with an older consumer billing failure. These are attributed handoff observations, not remote re-fetches or integrated-ready evidence. No workflow edit/rerun/cancellation, gate relaxation, PR #25 modification or legal-source refresh belongs to this notice/documentation slice. Work can proceed locally while owner-side CI gates remain unresolved. + +## Documentation-slice verification + +Direct execution in this slice: `git diff --check` completed exit 0; `node --test tests/local_preview_contract.mjs` completed six passes, zero failures/skips, exit 0; relative-link checking found zero missing targets in the eight owned documents. Strict citation-ledger verification with evidence passed for the one external methodology reference. Every pre-existing line in the seven edited documents remains present, and the three historical evidence files linked above (including the archived product-gap ledger) are byte-equal to HEAD. These are documentation/configuration and preservation checks, not feature-suite, browser, hosted CI or approval evidence. + +The first arXiv `web_extract` attempt timed out after 120 seconds with no content. A real `web.run` open of the exact v1 page then returned its metadata/abstract, supporting the bounded methodology attribution; no result was inferred from the failed extraction. A broad scratch-file listing also encountered a dangling symlink after the local delegation receipts had been read; narrowing to relevant top-level directories completed. Neither retrieval/listing error is a product-test outcome. + +## Parent-executed successor receipts — 2026-10-05 + +The documentation handoff table above records an earlier state. The parent subsequently verified all 290 retained browser-artifact hashes and the browser owner's end-source hashes against the current candidate. The new spec's completed desktop run passed six cases; its three-profile run passed 18 cases, zero skips/flakes/failures, on the ordinary 30-second case budget. Two earlier 2-pass/4-fail locator-timeout runs remain excluded harness evidence, not product RED. Current 320 px step-1 screenshot inspection found the notice fully readable across three lines without clipping or overlap; that bounded image observation does not establish whole-product visual/AT acceptance. + +Parent final unit/config gate `proc_bdc56c716167` completed exit 0: six pretests, 195 unit/UI cases across 18 files, lint, build and independent handler-guard contract (enabled-button positive exit 0, guard-removal intended assertion exit 1). Parent production/test bytes were frozen before that run. Full accumulated browser suite `proc_2e469ba14328` completed exit 0 against rebuilt bytes and a new owned server (`CI=1`): 81 collected, 69 passed, 12 existing profile-scoped skips. The new session cases are included, not added a second time to this total. The subsequent whole-candidate source review (`deleg_70f729f1`) found no blocking security or logic defect across the complete develop-to-candidate union: 24 paths including both new files. Its evidence snapshot preceded this terminal-browser receipt append; production/test hashes are unchanged, and the append reports only parent-executed results. This local source verdict does not replace runtime gates, hosted checks, qualifying GitHub approval or release. + +## Sources + +[1] https://arxiv.org/abs/2406.04692v1 — *Mixture-of-Agents Enhances Large Language Model Capabilities*; primary version-v1 metadata/abstract, retrieved 2026-10-05. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index a52dd61..b66db8c 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -23,6 +23,20 @@ This bounded section does not supersede the dated historical receipts below. Par | Integration assurance | Original independent whole-delta local review found no blocking defect. Successor parent gate completed lint/build, 193 Vitest and 51 browser passes with 12 scoped skips; successor independent review pending | Current exact-head hosted workflows, resolved threads and qualifying GitHub approval remain open. Empty-directory npm ci added 243 packages and exercised the predecessor snapshot; fsevents allow-scripts warning is retained, not silently approved. | | Mixed-agent provenance | Layered proposals → shared prior outputs/cross-review → parent aggregation receipts exist locally | Heterogeneous model/provider identity is unverified; no benchmark or runtime product-AI claim | +## Memory-only session-notice candidate — 2026-10-05 + +PRD `US-SESSION-01` reconciles unsupported temporary-save wording with the existing React-memory contract. The uncommitted delta is based on `ee12e3fddefec1c4038e6da333019e3455e6a50f`: shared static notice after each section heading and before inputs across all seven steps, plus explicit app-version labels in header/preview. No automatic storage, unload prompt, dependency, schema, readiness or legal rule is added. JSON is normalized fact portability, not every raw input's backup; TXT cannot restore work. Existing pending-import live status and lock remain. + +[The dated evidence record](evidence/session-notice-20261005.md) binds direct source/proposal inspection separately from parent-reported RED→GREEN observations: initial outer timeout is retained as non-passing; completed intended REDs, focused five-case GREEN and local build are bounded reports, not a final-head full gate. Historic 193-unit / 51-browser / 12-scoped-skip totals above remain predecessor receipts and are not promoted to the session candidate. Current full suite and actual browser successor are pending in this documentation handoff; no new CI pass is asserted. + +| Gap | Candidate contract | Remaining evidence | +| --- | --- | --- | +| Truthful session boundary | One static notice per active editor; app version distinct from fact/report versions; no save claim | Current native browser notice persistence across edit/import/export outcomes; readable normal-flow 320 px text and no clipping; reload-empty / explicit validated restore; current lint/test/build | +| Integration and ownership | Parent reports Draft consumer PR #26 and separate import-stream/cancellation PR #25; centralized CI migration remains owner work, not duplicated here | Fresh head/base/checkouts and terminal live required checks, resolved threads and qualifying independent approval. Parent-reported old billing failure/central migration is not integrated-ready evidence; do not bypass gates or alter PR #25 | +| Evidence provenance | Two independent whole proposals followed by summary-fed cross-review and parent synthesis | Same/inherited model identity is unverified heterogeneous; one reviewer explicitly lacked full prior proposal text. Conditional planning agreement is not final implementation approval | + +This dated addition preserves all earlier receipts, including the older PR #1 observations; it does not re-fetch or silently relabel them as current remote truth. Source/test assertion presence and local planning do not close legal, accessibility, hosted persistence/publication or Issue #12 gates. + ## Runtime status admission repair [ADR-0004](ADR-0004-runtime-status-admission.md) implements the existing PRD/TRD/ADR-0002 explicit-fact requirement in `src/policy.ts`. Review, completed-step derivation and schema-v1 export no longer treat truthy unknown collection/retention/transfer status values as operator confirmations. Only exact existing members are accepted; unsupported statuses retain their owning finding and export as `null`, without coercion, inferred `no`/`none` or source mutation. diff --git a/docs/research-traceability.md b/docs/research-traceability.md index a388cd3..991da29 100644 --- a/docs/research-traceability.md +++ b/docs/research-traceability.md @@ -61,5 +61,13 @@ No legal source, effective-date snapshot, legal rule, template authority or lega The development workflow is MoA-inspired: independent proposals, a second layer receiving both prior proposals plus the parent aggregate, then parent aggregation. The methodology source metadata is Wang et al. (2024), *Mixture-of-Agents Enhances Large Language Model Capabilities*, arXiv:2406.04692 v1 (June 7, 2024); its retrieved source citation and local delegation receipt identifiers are recorded in the evidence document. This is not a runtime LLM feature, verified heterogeneous-model ensemble, benchmark reproduction or substitute for independent GitHub approval. +## Memory-only session-notice trace — 2026-10-05 + +PRD `US-SESSION-01` exposes the existing memory-only boundary through `SessionNotice` in `FactStep`, `CollectionForm` and `PurposeForm`; `src/initial-workspace.test.tsx` traces all-step placement/static semantics and explicit app-version wording. Existing import and export contracts retain pending feedback, validation, derived evidence and failure-state ownership. [The dated evidence record](evidence/session-notice-20261005.md) preserves directly inspected sources and local delegation manifests, parent-reported bounded RED→GREEN/build observations, historic suite receipts and pending current browser/full/hosted/approval gates separately. Test source alone is not execution evidence. + +Wang et al. (2024), *Mixture-of-Agents Enhances Large Language Model Capabilities*, arXiv:2406.04692v1 (June 7, 2024), supplies methodology only: prior-layer outputs inform next-layer agents. Version-v1 metadata/abstract was retrieved again on 2026-10-05; source and exact scope are in the evidence record. Here two independent whole proposals were followed by reviewers receiving summaries plus the parent aggregate; one reviewer reported not finding full proposal text. This is MoA-inspired, not verified full-output-transfer reproduction, heterogeneous-model execution, runtime product AI, benchmark benefit or qualifying GitHub approval. + +No law, source effective date, template authority or legal-sufficiency rule is changed or revalidated by session copy. The legal register and dated retrievals above remain historical. Guidance to export JSON is a product portability choice, not a legal retention/backup guarantee. + ## Current gap The seven-step workspace now captures the product's intended fact categories, including independent explicit retention applicability, but retention-period/legal-basis detail, third-party provision, international transfer, contact/controller information, and legal-basis review still need requirement-level mappings to the authoritative register, deterministic validation, and regression fixtures before PolicyWeave can claim those steps are legally complete. CSS-level focus contrast and deterministic step-focus transfer now have executable regression contracts, but real-browser accessibility evidence remains required before claiming WCAG conformance. diff --git a/src/App.tsx b/src/App.tsx index 7939efd..2b832ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,11 @@ function StepActions({ current, setCurrent }: { current: number; setCurrent: (st } +/** Explains the browser-memory boundary before operator input without adding a live announcement. */ +function SessionNotice() { + return

자동 저장되지 않습니다. 새로고침하거나 탭을 닫으면 작성 내용이 사라집니다. 보관하려면 JSON 내보내기를 사용하세요.

+} + /** Renders a scalar-fact authoring step backed by the current draft facts. */ function FactStep({ current, title, description, fields, facts, setFacts, setCurrent }: { current: number @@ -65,6 +70,7 @@ function FactStep({ current, title, description, fields, facts, setFacts, setCur } return

{current}. {title}

{description}

+
사실 기반 입력운영 중인 서비스와 계약·처리 흐름에서 확인한 사실만 입력하세요. 확인되지 않은 내용은 비워 두고 검토 대상으로 남깁니다.

확인 정보

{fields.filter((field) => !field.visibleWhen || facts[field.visibleWhen.key] === field.visibleWhen.equals).map((field) =>