Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f97ab69
test: require PostgreSQL restore evidence
seonghobae Sep 8, 2026
2e0774e
test(db): verify policy revision dump restore
seonghobae Sep 8, 2026
e9f833a
docs: record restart and restore evidence
seonghobae Sep 8, 2026
c031570
merge: preserve complete concurrent UPSERT evidence
seonghobae Sep 8, 2026
e9d5631
fix: seed restore facts atomically
seonghobae Sep 8, 2026
9219b06
merge: restack restore evidence on NULL-safe concurrency
seonghobae Sep 8, 2026
ce0f316
test: require complete restored collection-item values
seonghobae Sep 8, 2026
0430469
fix: verify complete restored collection-item values
seonghobae Sep 8, 2026
a0a26c9
docs: record complete restore evidence
seonghobae Sep 8, 2026
991ecaa
docs: bind complete restore assertions
seonghobae Sep 8, 2026
e28415c
docs: reconcile complete restore gap
seonghobae Sep 8, 2026
d8fdd01
merge: restack restore evidence on reconciled architecture
seonghobae Sep 8, 2026
5950a74
test: require transactional applies retention seed
seonghobae Sep 8, 2026
45fc63d
fix(db): seed applies retention in one transaction
seonghobae Sep 8, 2026
d2eea3c
merge: restack restore evidence on concurrent UPSERT values
seonghobae Sep 8, 2026
a5342b1
docs: record deferred applies seed contract
seonghobae Sep 8, 2026
a1539c5
merge: integrate concurrent restore restack
seonghobae Sep 8, 2026
5e54834
test: require independent retention state after restore
seonghobae Sep 8, 2026
202e69d
test: prove restored policy fact independence
seonghobae Sep 8, 2026
2b1205f
docs: record complete restore invariants
seonghobae Sep 8, 2026
92a1e11
docs: bind restored fact independence evidence
seonghobae Sep 8, 2026
e1b94ee
docs: reconcile restore RCA and evidence
seonghobae Sep 8, 2026
aaef3b5
test: detect restored service URL loss
seonghobae Sep 8, 2026
57732c6
test: preserve restored service URL evidence
seonghobae Sep 8, 2026
bfdaee2
docs: record restored service URL RCA
seonghobae Sep 8, 2026
ea9f186
docs: separate CI restore from operations
seonghobae Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ jobs:
PGDATABASE: policyweave_test
PGPASSWORD: policyweave_ci_password
run: sh db/tests/policy_revision_concurrency.sh
- name: Verify PostgreSQL policy revision restore
env:
PGHOST: 127.0.0.1
PGPORT: 5432
PGUSER: policyweave_ci
PGDATABASE: policyweave_test
PGPASSWORD: policyweave_ci_password
run: sh db/tests/policy_revision_restore.sh
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- name: Upload exact-head browser evidence
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ coverage
playwright-report
test-results/
*.tsbuildinfo
.codegraph/
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ PolicyWeave is a local-first privacy-policy fact-authoring workspace. It structu
- Do not encode legal conclusions from memory. Every legal/rule/template decision needs an authoritative source, effective date, source revision, and implementation/test trace.
- Do not commit identifying customer, individual, or real operational-organization data in tests, examples, fixtures, or product documentation. Publicly documented legal authorities, official document titles, standards bodies, source publishers, and the repository owner may be named when required for accurate provenance and citation. Production must not consume synthetic demo data.
- Keep persistence objects semantically named with at least two words and `snake_case` unless a framework contract requires another convention. Avoid generic named persistence objects such as a standalone `id` table/collection.
- Hosted persistence/publication must be introduced only behind explicit tenant, authorization, audit, encryption, immutable revision, and supersession contracts.
- Hosted persistence/publication must be introduced only behind explicit tenant, authorization, audit, encryption, immutable revision, and supersession contracts. CI PostgreSQL restart and dump/restore evidence lives in `db/tests/policy_revision_restore.sh` and does not enable a hosted adapter. Seed `retention_status = applies` only in the same transaction as its `retention_rule`; autocommit fails the deferred fact contract.
- GitHub Actions dependencies stay SHA pinned and checkout credentials must not persist.

## Verification
Expand Down
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ The active MVP is a React/Vite browser workspace. State is in memory and there i

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; bounded CI runtime only)
## Persistence boundary (Proposed schema; CI-only runtime)
ADR-0003 and `db/migrations/0001_policy_revision.sql` propose the first PostgreSQL contract. The 3NF write model uses `policy_revision` as aggregate root; `service_profile`, `collection_item`, `processing_purpose`, and `retention_rule` are revision-owned facts. `(tenant_account_id, revision_number)` identifies a version, while `(policy_revision_id, collection_item_key)` is the item-level UPSERT/idempotency key. Deferred database constraints lock the owning revision row, reject collection items under explicit no-collection, and reject retention-rule/status contradictions at transaction commit.

This contract is not an active datastore or released API. Exact-head CI executes PostgreSQL 18 migration apply/down cycles and bounded two-session lock/UPSERT cases, including NULL-safe complete-value assertions. Hosted tenant authorization, immutable audit events, encryption, process restart, backup/restore, and production-scale contention remain open. Publication remains append-only/immutable with explicit supersession; writes across unrelated aggregates must not share a transaction merely for convenience. Named database/schema/persistence objects use at least two semantic words and `snake_case`, for example `policy_revision`, `collection_item`, `processing_purpose`, `review_finding`, `publication_revision`, and `legal_source_revision`.
Exact-head CI executes that migration against digest-pinned PostgreSQL 18, including rollback, two-session locks with NULL-safe complete-value assertions, process restart, and custom-format dump/restore of complete collection-item plus independent collection and retention facts. This is not an active datastore, released API, or hosted adapter. Hosted tenant authorization, immutable audit events, encryption, deletion, operational backup/restore, and production-scale contention remain open. Publication remains append-only/immutable with explicit supersession; writes across unrelated aggregates must not share a transaction merely for convenience. Named database/schema/persistence objects use at least two semantic words and `snake_case`, for example `policy_revision`, `collection_item`, `processing_purpose`, `review_finding`, `publication_revision`, and `legal_source_revision`.

Separate write-side draft commands from read-side rendered/review projections once hosted traffic justifies it. Account for revision hot spots and optimistic/constrained writes before adding collaborative editing. Keep source/customer integrations behind ACLs; do not form a shared kernel with unrelated ContextualWisdomLab products without demonstrated reuse.

Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri
## Unreleased

### Added
- PostgreSQL restart and custom-format dump/restore evidence that preserves NULL-safe complete service/collection-item values, a collecting-without-retention cross-state fixture, and independent no-collection and applies-retention facts, then re-executes no-collection plus both retention-status/rule contradictions against the restored database.
- PostgreSQL two-session concurrency evidence that observes real lock waits, rejects a collection-item writer racing with a no-collection update, and proves competing same-item UPSERTs converge to one row carrying the second writer's label, mode, and path with NULL-safe complete-value assertions and without timing-based transaction sleeps.
- PostgreSQL 18 runtime contract coverage for migration apply/down/apply cycles, item-key UPSERT idempotency, and deferred rejection of no-collection, missing-retention-rule, and revision-owner contradictions. The database remains CI-only and is not a hosted product backend.
- Proposed PostgreSQL `policy_revision` 3NF migration contract with tenant-scoped version identity, normalized revision-owned facts, owner-key immutability, parent-row-serialized deferred no-collection/retention consistency checks, and item-level natural-key UPSERT. It is source-validated only and does not claim a deployed database or hosted persistence.
Expand Down Expand Up @@ -54,6 +55,6 @@ All notable product changes are recorded here. PolicyWeave is pre-release; entri
### Not yet shipped
- Authoritative legal-rule snapshots that can determine legal sufficiency beyond product-defined fact completeness.
- Manual zoom and screen-reader evidence beyond the automated desktop/tablet/mobile Chromium accessibility and focus checks.
- A product persistence adapter, durable hosted storage, tenant authorization, immutable audit history, restart, encryption, backup/restore, and production-scale contention evidence. Bounded CI database execution does not constitute a hosted runtime.
- A product persistence adapter, durable hosted storage, tenant authorization, immutable audit history, encryption, operational backup/restore, and production-scale contention evidence. Bounded CI database execution, including process restart and dump/restore, does not constitute a hosted runtime.
- Authenticated immutable publication revisions and public URL lifecycle.
- Hosted tenant/security/operability evidence and endpoint load testing.
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ Follow `AGENTS.md` as the repository-wide operating contract.
Before changing code, read `docs/PRD.md`, `ARCHITECTURE.md`, the applicable ADRs, `docs/TRD.md`, `docs/research-traceability.md`, and `docs/product-technical-gap-baseline.md`. Preserve PolicyWeave's boundary: verified operator facts in, deterministic reviewable policy projection out; no legal-advice or compliance-guarantee claims.

For behavior changes, add or strengthen tests before production code. Reconcile documentation and the product-gap ledger on the same branch. Use the exact current PR head for reviews, checks, and merge decisions, and never bypass governance to compensate for a failing or unassigned check.

Deferred `policy_revision` fact triggers evaluate the final commit state. CI restore seeds `retention_status = applies` and `retention_rule` in one transaction; the owner runbook is `db/tests/policy_revision_restore.sh`.
311 changes: 311 additions & 0 deletions db/tests/policy_revision_restore.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
#!/bin/sh
set -eu

: "${PGHOST:=127.0.0.1}"
: "${PGPORT:=5432}"
: "${PGUSER:=policyweave_ci}"
: "${PGDATABASE:=policyweave_test}"
: "${PGPASSWORD:=policyweave_ci_password}"
export PGHOST PGPORT PGUSER PGDATABASE PGPASSWORD

test_run_id=$$
dump_file="/tmp/policyweave_restore_${test_run_id}.dump"
list_file="/tmp/policyweave_restore_${test_run_id}.list"
container_dump_file="/tmp/policyweave_restore.dump"
postgres_container_id=

psql_command() {
psql --no-psqlrc --set ON_ERROR_STOP=1 "$@"
}

cleanup_restore_test() {
rm -f "$dump_file" "$list_file"
if [ -n "$postgres_container_id" ]; then
docker exec "$postgres_container_id" rm -f "$container_dump_file" >/dev/null 2>&1 || true
fi
psql --no-psqlrc --command "drop database if exists policyweave_restore" >/dev/null 2>&1 || true
}

wait_for_postgres() {
attempt_count=0
while :; do
if docker exec "$postgres_container_id" pg_isready -U "$PGUSER" -d "$PGDATABASE" >/dev/null 2>&1 \
&& psql_command --command 'select 1' >/dev/null 2>&1; then
return 0
fi
attempt_count=$((attempt_count + 1))
if [ "$attempt_count" -ge 60 ]; then
echo 'PostgreSQL did not accept connections after restart' >&2
return 1
fi
sleep 1
done
}

find_postgres_container() {
container_id=$(docker ps --filter name=postgres --format '{{.ID}}' | head -n 1)
if [ -n "$container_id" ]; then
printf '%s\n' "$container_id"
return 0
fi
docker ps --format '{{.ID}} {{.Ports}}' | awk '/5432/ { print $1; exit }'
}

expect_failure() {
failure_name=$1
expected_message=$2
failure_log="/tmp/${failure_name}.log"
if psql_command >"$failure_log" 2>&1; then
echo "expected PostgreSQL failure: ${failure_name}" >&2
return 1
fi
if ! grep -F -- "$expected_message" "$failure_log"; then
cat "$failure_log" >&2
echo "unexpected PostgreSQL failure: ${failure_name}" >&2
return 1
fi
}

assert_restored_facts() {
scene_name=$1
psql_command <<SQL
do \$restore_assertion\$
declare
collection_revision_count integer;
service_profile_count integer;
collection_item_count integer;
collection_purpose_count integer;
collection_rule_count integer;
stored_service_name text;
stored_service_url text;
stored_item_label text;
stored_item_mode text;
stored_item_path text;
stored_purpose text;
stored_period text;
no_collection_revision_count integer;
no_collection_item_count integer;
no_collection_rule_count integer;
collection_without_retention_count integer;
begin
select count(*)
into collection_revision_count
from policy_revision
where policy_revision_id = '60000000-0000-4000-8000-000000000001'
and no_collection_confirmed = false
and retention_status = 'applies';

select count(*), max(service_name), max(service_url)
into service_profile_count, stored_service_name, stored_service_url
from service_profile
where policy_revision_id = '60000000-0000-4000-8000-000000000001';

select count(*),
max(collection_item_label),
max(collection_mode::text),
max(collection_path)
into collection_item_count, stored_item_label, stored_item_mode, stored_item_path
from collection_item
where policy_revision_id = '60000000-0000-4000-8000-000000000001'
and collection_item_key = 'contact_email';

select count(*), max(purpose_text)
into collection_purpose_count, stored_purpose
from processing_purpose
where policy_revision_id = '60000000-0000-4000-8000-000000000001'
and collection_item_key = 'contact_email';

select count(*), max(retention_period)
into collection_rule_count, stored_period
from retention_rule
where policy_revision_id = '60000000-0000-4000-8000-000000000001';

select count(*)
into no_collection_revision_count
from policy_revision
where policy_revision_id = '60000000-0000-4000-8000-000000000002'
and no_collection_confirmed = true
and retention_status = 'none';

select count(*)
into no_collection_item_count
from collection_item
where policy_revision_id = '60000000-0000-4000-8000-000000000002';

select count(*)
into no_collection_rule_count
from retention_rule
where policy_revision_id = '60000000-0000-4000-8000-000000000002';

select count(*)
into collection_without_retention_count
from policy_revision as revision
join collection_item as item using (policy_revision_id)
where revision.policy_revision_id = '60000000-0000-4000-8000-000000000003'
and revision.no_collection_confirmed = false
and revision.retention_status = 'none'
and item.collection_item_key = 'support_email';

if collection_revision_count <> 1
or service_profile_count <> 1
or stored_service_name is distinct from 'Restore Probe Service'
or stored_service_url is distinct from 'https://restore.example.test'
or collection_item_count <> 1
or stored_item_label <> 'Restore contact email'
or stored_item_mode is distinct from 'required'
or stored_item_path is distinct from 'Account registration form'
or collection_purpose_count <> 1
or stored_purpose <> 'Account notices'
or collection_rule_count <> 1
or stored_period <> '1 year after account closure'
or no_collection_revision_count <> 1
or no_collection_item_count <> 0
or no_collection_rule_count <> 0
or collection_without_retention_count <> 1 then
raise exception '${scene_name} did not preserve independent collection and retention facts';
end if;
end;
\$restore_assertion\$;
SQL
}

if ! command -v docker >/dev/null 2>&1; then
echo 'docker is required for PostgreSQL restart evidence' >&2
exit 1
fi

postgres_container_id=$(find_postgres_container)
if [ -z "$postgres_container_id" ]; then
echo 'postgres container not found for restart evidence' >&2
docker ps >&2 || true
exit 1
fi

trap cleanup_restore_test 0 1 2 15

psql_command --file db/migrations/0001_policy_revision.sql

psql_command <<'SQL'
begin;
insert into policy_revision (
policy_revision_id, tenant_account_id, revision_number, retention_status
) values (
'60000000-0000-4000-8000-000000000001', '50000000-0000-4000-8000-000000000001', 1, 'applies'
);
insert into service_profile (policy_revision_id, service_name, service_url)
values (
'60000000-0000-4000-8000-000000000001',
'Restore Probe Service',
'https://restore.example.test'
);
select upsert_collection_item(
'60000000-0000-4000-8000-000000000001',
'contact_email',
'Restore contact email',
'required',
'Account registration form'
);
insert into processing_purpose (policy_revision_id, collection_item_key, purpose_text)
values (
'60000000-0000-4000-8000-000000000001',
'contact_email',
'Account notices'
);
insert into retention_rule (policy_revision_id, retention_period)
values (
'60000000-0000-4000-8000-000000000001',
'1 year after account closure'
);

insert into policy_revision (
policy_revision_id, tenant_account_id, revision_number, no_collection_confirmed, retention_status
) values (
'60000000-0000-4000-8000-000000000002', '50000000-0000-4000-8000-000000000001', 2, true, 'none'
Comment thread
seonghobae marked this conversation as resolved.
);

insert into policy_revision (
policy_revision_id, tenant_account_id, revision_number, retention_status
) values (
'60000000-0000-4000-8000-000000000003', '50000000-0000-4000-8000-000000000001', 3, 'none'
);
select upsert_collection_item(
'60000000-0000-4000-8000-000000000003',
'support_email',
'Support email',
'optional',
'Support request form'
);
commit;
SQL

psql_command --command 'checkpoint'

docker restart "$postgres_container_id" >/dev/null
wait_for_postgres
assert_restored_facts restart

docker exec "$postgres_container_id" pg_dump \
-U "$PGUSER" \
-d "$PGDATABASE" \
--no-owner \
--no-acl \
--format=custom \
--schema=public \
-f "$container_dump_file"

docker cp "$postgres_container_id:$container_dump_file" "$dump_file"
if [ ! -s "$dump_file" ]; then
echo 'pg_dump produced an empty custom archive' >&2
exit 1
fi

psql_command --file db/migrations/0001_policy_revision.down.sql
psql_command <<'SQL'
do $rollback_assertion$
begin
if to_regclass('public.policy_revision') is not null then
raise exception 'rollback left policy_revision behind before restore';
end if;
end;
$rollback_assertion$;
SQL

psql_command --command "drop database if exists policyweave_restore"
psql_command --command "create database policyweave_restore"

docker exec "$postgres_container_id" pg_restore -l "$container_dump_file" \
| grep -v 'SCHEMA - public' >"$list_file"
docker cp "$list_file" "$postgres_container_id:$container_dump_file.list"
docker exec "$postgres_container_id" pg_restore \
-U "$PGUSER" \
-d policyweave_restore \
--no-owner \
--no-acl \
--exit-on-error \
-L "$container_dump_file.list" \
"$container_dump_file"

PGDATABASE=policyweave_restore assert_restored_facts restore
PGDATABASE=policyweave_restore expect_failure restored_no_collection_conflict 'no-collection confirmation conflicts with collection items' <<'SQL'
Comment thread
seonghobae marked this conversation as resolved.
begin;
insert into collection_item (policy_revision_id, collection_item_key, collection_item_label)
values ('60000000-0000-4000-8000-000000000002', 'contact_email', 'Contact email');
commit;
SQL
PGDATABASE=policyweave_restore expect_failure restored_status_without_rule 'retention status applies requires a retention rule' <<'SQL'
begin;
update policy_revision
set retention_status = 'applies'
where policy_revision_id = '60000000-0000-4000-8000-000000000002';
commit;
SQL
PGDATABASE=policyweave_restore expect_failure restored_rule_without_status 'retention rule requires retention status applies' <<'SQL'
begin;
insert into retention_rule (policy_revision_id, retention_period)
values ('60000000-0000-4000-8000-000000000002', '1 year');
commit;
SQL

psql_command --command "drop database policyweave_restore"
cleanup_restore_test
trap - 0 1 2 15
Loading