From 4e9a1c62eee2b10150b5ba369514a58733517c48 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 12:59:46 +0100 Subject: [PATCH 01/39] Publish verified content recovery candidate evidence Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 2 +- ...t-lifecycle-baseline-tests-2026-10-31.json | 25 ++++ ...lifecycle-candidate-2026-10-31.sources.txt | 4 + ...-lifecycle-candidate-build-2026-10-31.json | 119 ++++++++++++++++++ ...cycle-candidate-provenance-2026-10-31.json | 20 +++ ...-lifecycle-candidate-tests-2026-10-31.json | 20 +++ .../CSOC-WP-0013-sharing-content-hardening.md | 15 ++- 7 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 docs/release/content-lifecycle-baseline-tests-2026-10-31.json create mode 100644 docs/release/content-lifecycle-candidate-2026-10-31.sources.txt create mode 100644 docs/release/content-lifecycle-candidate-build-2026-10-31.json create mode 100644 docs/release/content-lifecycle-candidate-provenance-2026-10-31.json create mode 100644 docs/release/content-lifecycle-candidate-tests-2026-10-31.json diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index abdac06..d319abd 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -103,7 +103,7 @@ | task | CSOC-WP-0013-T02 | progress | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | | task | CSOC-WP-0013-T03 | todo | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | | task | CSOC-WP-0013-T04 | done | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | -| task | CSOC-WP-0013-T05 | todo | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | +| task | CSOC-WP-0013-T05 | progress | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | | task | CSOC-WP-0014-T01 | progress | — | workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md | | task | CSOC-WP-0014-T02 | todo | — | workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md | | task | CSOC-WP-0014-T03 | wait | — | workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md | diff --git a/docs/release/content-lifecycle-baseline-tests-2026-10-31.json b/docs/release/content-lifecycle-baseline-tests-2026-10-31.json new file mode 100644 index 0000000..6b3e676 --- /dev/null +++ b/docs/release/content-lifecycle-baseline-tests-2026-10-31.json @@ -0,0 +1,25 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-tests.v1", + "source": "23ffe2cf4b214666754a2420558943438e54251a", + "image_id": "sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "tests": 29, + "failures": 4, + "expected_baseline_regressions": true, + "failed_nodes": [ + "test_process_death_during_copy_or_move_is_recoverable[copy_page-during_first_asset]", + "test_process_death_during_copy_or_move_is_recoverable[copy_page-during_second_asset]", + "test_process_death_during_copy_or_move_is_recoverable[transfer_page-during_first_asset]", + "test_process_death_during_copy_or_move_is_recoverable[transfer_page-during_second_asset]" + ], + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "test_tooling_injected": true, + "application_modules_from_image_verified": true, + "production_access": false, + "junit_sha256": "edc52bd3a321d028d255b3d478020b3383d94747957ffe2985583b1f0741cb85", + "driver_sha256": "85b65114997c7e7c5323cb77a0043e495ec132506eef07dd394c6a6361380d36", + "test_source_sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" +} diff --git a/docs/release/content-lifecycle-candidate-2026-10-31.sources.txt b/docs/release/content-lifecycle-candidate-2026-10-31.sources.txt new file mode 100644 index 0000000..12056be --- /dev/null +++ b/docs/release/content-lifecycle-candidate-2026-10-31.sources.txt @@ -0,0 +1,4 @@ +/home/worsch/coulomb-social/coulomb_social/apps/spaces/pageops.py: 055ef25b5efc75798cf17ffa68739de4d4099d2cab4d041e7c7eacdfaf43803e +/home/worsch/coulomb-social/tests/test_content_recovery.py: 84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449 +/home/worsch/coulomb-social/scripts/verify-packaged-source.py: 99d734f20331e8d699081d3d77e7a0d0b3bc676be69ce101377bc69eae6c70d5 +/tmp/csoc-packaged-lifecycle-tests-20261031.py: 85b65114997c7e7c5323cb77a0043e495ec132506eef07dd394c6a6361380d36 diff --git a/docs/release/content-lifecycle-candidate-build-2026-10-31.json b/docs/release/content-lifecycle-candidate-build-2026-10-31.json new file mode 100644 index 0000000..07207bc --- /dev/null +++ b/docs/release/content-lifecycle-candidate-build-2026-10-31.json @@ -0,0 +1,119 @@ +{ + "schema": "coulomb.content-lifecycle-candidate-build.v1", + "completed_at": "2026-10-31T11:58:26.697591+00:00", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "tag": "forgejo.coulomb.social/coulomb/coulomb-social:f306dbfa2604", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "registry_digest_independently_verified": true, + "source_revision_label_verified": true, + "build_context": "Clean git archive restricted to Dockerfile, dockerignore, lock, project, manage.py, app and entrypoint.", + "build_inputs": [ + { + "path": "Dockerfile", + "sha256": "d0e197a18db8980eb3e51cca842d693e10ab0b4208627bba28691d10719d4a00" + }, + { + "path": ".dockerignore", + "sha256": "901c8b95d95549d0e78c1c3d6d0538bf0252c8a77e9409129e0cadfa55c7c56c" + }, + { + "path": "pyproject.toml", + "sha256": "5758c56fd1be89b973861ccc5ecbadb495e742ee13ebd7e19a221a0f119c54ee" + }, + { + "path": "uv.lock", + "sha256": "e8ebb6fc4b5abd1ac80f1c9012f1983642d0528a8a213cfcc394261606fad887" + }, + { + "path": "scripts/docker-entrypoint.sh", + "sha256": "70100256ca41ae6afe77078c810a8b6012472f0a741919f403f32088f98ab463" + } + ], + "runtime": { + "previous": { + "python": "3.12.16", + "distributions": { + "h11": "0.16.0", + "httpcore": "1.0.9", + "psycopg": "3.3.4", + "joserfc": "1.7.4", + "anyio": "4.14.2", + "cryptography": "50.0.0", + "idna": "3.18", + "certifi": "2026.7.22", + "packaging": "26.3", + "python-decouple": "3.8", + "psycopg-binary": "3.3.4", + "sqlparse": "0.5.5", + "whitenoise": "6.12.0", + "cffi": "2.1.1", + "markdown": "3.10.3", + "webencodings": "0.5.1", + "pycparser": "3.0", + "bleach": "6.4.0", + "authlib": "1.7.2", + "django": "6.1", + "typing_extensions": "4.16.0", + "gunicorn": "26.0.0", + "asgiref": "3.12.1", + "httpx": "0.28.1", + "dj-database-url": "3.1.2" + } + }, + "candidate": { + "python": "3.12.16", + "distributions": { + "h11": "0.16.0", + "httpcore": "1.0.9", + "psycopg": "3.3.4", + "joserfc": "1.7.4", + "anyio": "4.14.2", + "cryptography": "50.0.0", + "idna": "3.18", + "certifi": "2026.7.22", + "packaging": "26.3", + "python-decouple": "3.8", + "psycopg-binary": "3.3.4", + "sqlparse": "0.5.5", + "whitenoise": "6.12.0", + "cffi": "2.1.1", + "markdown": "3.10.3", + "webencodings": "0.5.1", + "pycparser": "3.0", + "bleach": "6.4.0", + "authlib": "1.7.2", + "django": "6.1", + "typing_extensions": "4.16.0", + "gunicorn": "26.0.0", + "asgiref": "3.12.1", + "httpx": "0.28.1", + "dj-database-url": "3.1.2" + } + }, + "runtime_packages_equal": true, + "python_versions_equal": true, + "django_check": { + "returncode": 0, + "output": "System check identified no issues (0 silenced)." + }, + "migration_drift": { + "returncode": 0, + "output": "No changes detected" + } + }, + "source_delta_from_revision_19": [ + "coulomb_social/apps/spaces/pageops.py" + ], + "new_database_migrations": false, + "production_deployed": false, + "live_release_pins_modified": false, + "source_full_gate_tests": 546, + "packaged_lifecycle_tests": 29, + "previous_image_expected_lifecycle_failures": 4, + "owner_release_task": "RAPPS-WP-0015-T07", + "remaining": [ + "Exact current-target release packet, paired rollback evidence and candidate/previous-image restored-store checks.", + "Attended image release and independent native postflight.", + "Productive lifecycle verdict; retention policy already accepted under c3a191f2-ce06-483f-82d7-ef8c54ea0d5e." + ] +} diff --git a/docs/release/content-lifecycle-candidate-provenance-2026-10-31.json b/docs/release/content-lifecycle-candidate-provenance-2026-10-31.json new file mode 100644 index 0000000..60aefca --- /dev/null +++ b/docs/release/content-lifecycle-candidate-provenance-2026-10-31.json @@ -0,0 +1,20 @@ +{ + "schema": "coulomb.packaged-source-provenance.v1", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "network": "none", + "read_only_root": true, + "entrypoint_bypassed": true, + "database_contact": false, + "production_changes": false, + "comparisons": [ + { + "commit": "f306dbfa2604e4815523667740be2925c333b84e", + "packaged_file_count": 80, + "source_file_count": 80, + "different_files": 0, + "all_packaged_source_bytes_match": true + } + ], + "actual_packaged_manifest_sha256": "b5c8a16b4b3d30cc9d8a7a9f3ed61bd8cb0e182589aceb5e4127fff27a8ae482", + "dependency_or_build_reproducibility_proved": false +} diff --git a/docs/release/content-lifecycle-candidate-tests-2026-10-31.json b/docs/release/content-lifecycle-candidate-tests-2026-10-31.json new file mode 100644 index 0000000..a0748e4 --- /dev/null +++ b/docs/release/content-lifecycle-candidate-tests-2026-10-31.json @@ -0,0 +1,20 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-tests.v1", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "image_id": "sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "tests": 29, + "failures": 0, + "expected_baseline_regressions": false, + "failed_nodes": [], + "errors": 0, + "skipped": 0, + "network": "none", + "read_only_root": true, + "application_source_overlay": false, + "test_tooling_injected": true, + "application_modules_from_image_verified": true, + "production_access": false, + "junit_sha256": "f2968ef1048ec9af68a187b967d558db0f6ee76e765a8106fb7fab284953511f", + "driver_sha256": "85b65114997c7e7c5323cb77a0043e495ec132506eef07dd394c6a6361380d36", + "test_source_sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449" +} diff --git a/workplans/CSOC-WP-0013-sharing-content-hardening.md b/workplans/CSOC-WP-0013-sharing-content-hardening.md index 83b915d..a9dad0a 100644 --- a/workplans/CSOC-WP-0013-sharing-content-hardening.md +++ b/workplans/CSOC-WP-0013-sharing-content-hardening.md @@ -18,7 +18,7 @@ Residual acceptance from CSOC-WP-0008, split at the user's request. Existing imp ## Current execution map — 2026-10-31 -T01 storage enforcement is complete. T04 now exposes completed fidelity-review preparation. T02 still owns actual corpus/assets fidelity verdicts and final content acceptance; its revision lifecycle detail is T05. T03 owns four separate UC-003/004/005/006 productive verdicts. No review verdict has been inferred from tests or weekly-call acceptance. +T01 storage enforcement is complete. T04 now exposes completed fidelity-review preparation. T02 still owns actual corpus/assets fidelity verdicts and final content acceptance; its revision lifecycle detail is T05, now in progress with the retention policy accepted and a tested crash-recovery candidate published. T03 owns four separate UC-003/004/005/006 productive verdicts. No review verdict has been inferred from tests or weekly-call acceptance. The [four-workplan assessment](../docs/release/goal-progress-audit-2026-10-31.md) records the scope split and effort estimate. Task counts are not effort percentages. @@ -216,6 +216,19 @@ release delivery and productive review. User accepted the retain-all/manual-cuto production purge, image deployment or user acceptance is inferred. The current verified release remains revision19/image522bcd7f until a new owner return. +The corrected source f306dbf is published as immutable candidate +`sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec`. +All80 packaged source files match; runtime package/Python versions are unchanged, +and no new migration is present. The previous image reproduces four interrupted- +attachment failures; the candidate passes all29 lifecycle checks in a read-only, +network-disabled container without an application overlay. Full make check passes +546 tests, including required browsers, with zero failures/errors/skips. Build, +provenance and paired baseline/candidate test receipts are under +`docs/release/content-lifecycle-*-2026-10-31.json`. RAPPS-WP-0015-T07 now owns the +new exact release preparation/execution return. T05 remains progress until that +return and productive acceptance; policy acceptance is settled and no live purge +is authorized. No production image or release pin changed during this work. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. From a786a8050db92807d0f16e637043fbd191ab9335 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 13:25:36 +0100 Subject: [PATCH 02/39] Record content recovery release preparation and backup ordering Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- ...fecycle-candidate-recovery-2026-10-31.json | 65 ++++++++++++++ ...t-lifecycle-release-2026-10-31.sources.txt | 3 + ...ifecycle-release-preflight-2026-10-31.json | 7 ++ ...ecycle-release-preparation-2026-10-31.json | 79 ++++++++++++++++ ...lifecycle-release-proposal-2026-10-31.json | 90 +++++++++++++++++++ ...ifecycle-release-row-drift-2026-10-31.json | 61 +++++++++++++ ...t-lifecycle-release-target-2026-10-31.json | 62 +++++++++++++ docs/usecases/sharing-portability-review.md | 56 ++++++++++++ ...0012-durable-recovery-service-readiness.md | 21 ++++- .../CSOC-WP-0013-sharing-content-hardening.md | 13 +++ 10 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 docs/release/content-lifecycle-candidate-recovery-2026-10-31.json create mode 100644 docs/release/content-lifecycle-release-2026-10-31.sources.txt create mode 100644 docs/release/content-lifecycle-release-preflight-2026-10-31.json create mode 100644 docs/release/content-lifecycle-release-preparation-2026-10-31.json create mode 100644 docs/release/content-lifecycle-release-proposal-2026-10-31.json create mode 100644 docs/release/content-lifecycle-release-row-drift-2026-10-31.json create mode 100644 docs/release/content-lifecycle-release-target-2026-10-31.json diff --git a/docs/release/content-lifecycle-candidate-recovery-2026-10-31.json b/docs/release/content-lifecycle-candidate-recovery-2026-10-31.json new file mode 100644 index 0000000..72b4365 --- /dev/null +++ b/docs/release/content-lifecycle-candidate-recovery-2026-10-31.json @@ -0,0 +1,65 @@ +{ + "schema": "coulomb.packaged-content-lifecycle-recovery.v1", + "status": "packaged_recovered_client_export_and_views_verified", + "source": "f306dbfa2604e4815523667740be2925c333b84e", + "image_id": "sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "restore_receipt_sha256": "966d6640e42e2f4940c66d1cd5c8a802cd950de0305572f3c8b720ee3d81ae2b", + "client_export": { + "status": "recovered_client_export_verified", + "schema": "coulomb.client-dataset-export.v1", + "content_files": 85, + "records": { + "members.user": 1, + "members.member": 1, + "spaces.space": 1, + "spaces.spacemembership": 1 + }, + "admin_events": 0, + "archive_sha256": "e364a61570e0083e729a3f314eb707c57347200438bd98170c77b8f7fcc2b337", + "offboarding_complete": false, + "all_source_tables_compared": 13, + "all_source_files_compared": 86, + "archive_member_hashes_verified": true, + "selected_client_content_complete_locally": true, + "source_sql_and_content_unchanged": true, + "external_bindings_require_review": 0, + "sessions_and_credentials_excluded": true + }, + "candidate_application": { + "status": "isolated_application_recovery_verified", + "recovered_spaces_with_content": 1, + "actual_pages_rendered_through_view": 40, + "restored_owner_access_verified": true, + "synthetic_viewer_reads": 1, + "synthetic_viewer_mutation_denials": 1, + "same_tenant_ungranted_page_denials": 40, + "fixture_transaction_rolled_back": true, + "production_access": false, + "external_identity_provider_used": false, + "real_browser_journey_verified": false + }, + "previous_image_application": { + "status": "isolated_application_recovery_verified", + "recovered_spaces_with_content": 1, + "actual_pages_rendered_through_view": 40, + "restored_owner_access_verified": true, + "synthetic_viewer_reads": 1, + "synthetic_viewer_mutation_denials": 1, + "same_tenant_ungranted_page_denials": 40, + "fixture_transaction_rolled_back": true, + "production_access": false, + "external_identity_provider_used": false, + "real_browser_journey_verified": false + }, + "source_content_files_unchanged": 86, + "all_tables_unchanged_after_both_view_checks": true, + "application_source_overlay": false, + "export_archive_removed": true, + "network_internal": true, + "published_ports": 0, + "production_access": false, + "production_restore": false, + "post_date_correction_data_claimed": true, + "retained_point_captured_at": "2026-10-31T07:13:52Z", + "driver_sha256": "92b66225e5f9bdf03d0a0999b613370c42341a2ceef468f1743fb4301884cb25" +} diff --git a/docs/release/content-lifecycle-release-2026-10-31.sources.txt b/docs/release/content-lifecycle-release-2026-10-31.sources.txt new file mode 100644 index 0000000..cdb9490 --- /dev/null +++ b/docs/release/content-lifecycle-release-2026-10-31.sources.txt @@ -0,0 +1,3 @@ +/tmp/csoc-content-release-observation-20261031.py: 1065625ee5b0736ae68a5230684f0effcb2c92cf0f814154ee86450b5a8fda4a +/tmp/csoc-content-row-drift-20261031.py: 49c8f0b8a92524205e2b190bbefec76d9cc38d966c4d20ffa7391e85ff17bf0e +/tmp/csoc-packaged-lifecycle-recovery-20261031.py: 92b66225e5f9bdf03d0a0999b613370c42341a2ceef468f1743fb4301884cb25 diff --git a/docs/release/content-lifecycle-release-preflight-2026-10-31.json b/docs/release/content-lifecycle-release-preflight-2026-10-31.json new file mode 100644 index 0000000..e62ada5 --- /dev/null +++ b/docs/release/content-lifecycle-release-preflight-2026-10-31.json @@ -0,0 +1,7 @@ +{ + "proposal_digest": "sha256:c9c3231620721ef3ea9b2bf22fc6c62fe3ed8efb91386cf7ec96acc6959dae08", + "execution_started": false, + "status": "release_refused_or_failed", + "failure_type": "Refused", + "failure_reason": "inactive_backup_installation_required_before_image_release" +} \ No newline at end of file diff --git a/docs/release/content-lifecycle-release-preparation-2026-10-31.json b/docs/release/content-lifecycle-release-preparation-2026-10-31.json new file mode 100644 index 0000000..fd558c3 --- /dev/null +++ b/docs/release/content-lifecycle-release-preparation-2026-10-31.json @@ -0,0 +1,79 @@ +{ + "schema": "coulomb.content-release-preparation.v1", + "prepared_at": "2026-10-31T12:16:52.085864+00:00", + "status": "prepared_waiting_for_inactive_backup_installation", + "proposal_digest": "sha256:c9c3231620721ef3ea9b2bf22fc6c62fe3ed8efb91386cf7ec96acc6959dae08", + "capsule_members": 20, + "owner_task": "RAPPS-WP-0015-T07", + "owner_task_id": "8f448934-bdfe-539f-8cab-a739ec752491", + "consumer_task": "CSOC-WP-0013-T05", + "baseline_revision": 19, + "current_business_rows": 93, + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "prerequisite_installation_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "ordering": [ + "historical custody repair", + "recurring alert route", + "inactive backup-source installation", + "content image preflight and supervised release", + "native recurring proof and schedule activation" + ], + "controller_tests": { + "tests": 40, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "helm_lint": "passed", + "image_only_local_render_difference": true, + "candidate_image_only_server_preview_verified": true, + "native_full_preflight": "refused_expected_missing_installation", + "full_preflight_passed": false, + "historical_custody_packet_digest_unchanged": true, + "inactive_installer_packet_digest_unchanged": true, + "release_executed": false, + "fresh_backup_created_by_preparation": false, + "production_data_restored": false, + "timers_activated": false, + "new_resources_committed": 0, + "cost_risk_review": { + "additional_infrastructure_reservations": 0, + "new_backup_objects": 1, + "encrypted_file_limit_bytes": 1073741824, + "incremental_provider_cost_eur": null, + "autopilot_budget_claimed": false, + "risks": [ + "Brief application pause for paired capture and Recreate image rollout.", + "Rollback restores previous image/helper without rolling back productive data.", + "Uncertain or changed baseline refuses; inspect durable receipt before retry." + ] + }, + "files": [ + { + "repo": "railiance-apps", + "path": "tools/coulomb-image-release.py", + "sha256": "4cefb7b4ce7fb7eece1cd78ce3c1712a2b5cc2f9c81acfed06e3c6ac7c4abad3" + }, + { + "repo": "railiance-apps", + "path": "tools/coulomb_image_release_node.py", + "sha256": "5547b3809f34178e48404cd1590f665729b062b4333f561e2da81b56d435dece" + }, + { + "repo": "railiance-apps", + "path": "tests/test_coulomb_image_release.py", + "sha256": "dc9cd51bd0698b46c5e2a4f2f0fb8f89eceed722a92bd7b03f7278ed77a497d4" + }, + { + "repo": "railiance-apps", + "path": "release/coulomb-content-lifecycle-f306dbf.json", + "sha256": "1bcd840aee2a93130838b7430de279c56dd7aecbce4244765a85be9d0a7130e2" + } + ], + "native_no_attempt_readback": { + "release_journal_exists": false, + "release_receipt_exists": false + }, + "sequencing_decision_id": "de5a4528-0864-4769-b9d0-2f658b0310c6", + "owner_controller_commit": "9c748846413c6db3f13ae3e55627fe3f6ad89194" +} diff --git a/docs/release/content-lifecycle-release-proposal-2026-10-31.json b/docs/release/content-lifecycle-release-proposal-2026-10-31.json new file mode 100644 index 0000000..5f633e1 --- /dev/null +++ b/docs/release/content-lifecycle-release-proposal-2026-10-31.json @@ -0,0 +1,90 @@ +{ + "schema": "coulomb.reviewed-image-release.v1", + "proposal_id": "CSOC-CONTENT-LIFECYCLE-20261031", + "scope": "ADMINISTER @ realm:kubernetes/railiance01; supervised founder execution only", + "operation": "image_only_existing_migration_0003", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "baseline_revision": 19, + "baseline_helm_manifest_sha256": "93cb277abd1aea0657be482e458dcfb268fe0bfbd45d90aa1faedcc7f5bce499", + "candidate_render_sha256": "1606725273089b83689850ad1c25d1834faa15e00c8cf8aa24e1053273af71bc", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "candidate_spec_sha256": "c65cb691bab7054ae25de58cdb75933d383621ec4e049d2daa1b1bf52dce0bba", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "product_source": "f306dbfa2604e4815523667740be2925c333b84e", + "expected_data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "installed_helper_hashes": { + "/opt/coulomb-social-backup/paired-backup.py": "d161c742d1a6a182460a57d0a1719591ab58fc94006f55169ad3d74442a93630", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "/opt/coulomb-social-backup/mirror-paired-backup.py": "9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c", + "/opt/coulomb-social-backup/check-paired-backup-health.py": "2d40ec6e4c59ea7fe1d46065d197b9a87ca6811f023cc6f3e20e4184a3490566", + "/opt/coulomb-social-backup/backup_capacity.py": "cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893", + "/opt/railiance-platform/scripts/application_recovery_identities.py": "20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d", + "/opt/railiance-platform/scripts/application_recovery_mirror.py": "d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178", + "/opt/railiance-platform/scripts/application_recovery_resources.py": "ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d", + "/opt/coulomb-social-backup/backup_alerts.py": "07522437d4276b92f0a44149adc6c9e81bb18dbe59ce0f1f9e58a48347700d0e", + "/opt/coulomb-social-backup/notify-backup-alert.py": "0bd53bc5ad22d8bcb0326fb18a17737744b06416fe785dad077308e45f051d5a", + "/opt/coulomb-social-backup/observe-backup-alerts.py": "17caf4fa65283242f618b039ff1d018801c9890ea2a9178912cbdd4b50ee2664" + }, + "clipboard_static_sha256": "347eabe0c8d93bf439df02b5a08a7ea71004600e6ff4e8a42cecbee52a9f0413", + "fresh_backup_before_helm": true, + "requires_dataset_equality_before_and_after": true, + "node_request_percent_limit": 90, + "helm_atomic_timeout_seconds": 300, + "rollback": "Restore Helm revision19/image522bcd7f and verified snapshot pin; preserve all post-capture data. No production data restore or schema downgrade.", + "approval_status": "prepared_not_admitted", + "recovery_rehearsal_scope": "Retained independently recovered 2026-10-31T07:13:52Z point; candidate and previous image preserve all 13 source tables and 86 files. Live observation now has93 business rows versus92 in that point; exact fresh paired backup/readback is required before Helm. Independent restore of the new point remains consumer follow-through. Read-only drift comparison confines differences to sessions and user/member login timestamps; all content and migration history match the retained point.", + "rehearsal_evidence_sha256": { + "content-lifecycle-candidate-build-2026-10-31.json": "83ad2ec41c85f8bb5087a250fdb91126ed92f12351cdc72b353c6f0e1a26452b", + "content-lifecycle-candidate-provenance-2026-10-31.json": "35ed909e0a2fa5c57cea1fcc4fbebb877eae559ca9dc753c3da00f84806d7b73", + "content-lifecycle-candidate-tests-2026-10-31.json": "27a75cfee6bbda41f377b73d6a6c7ecde0c54806c113f1be5223dd2f2cf39b3b", + "content-lifecycle-baseline-tests-2026-10-31.json": "de14e4e10337990ccec1c483f7d943bdc7e11b03aa813aff47d00a9b38a07b65", + "content-lifecycle-candidate-recovery-2026-10-31.json": "8d15172e5e330d01f153336e67f6bffb4363d571530c31ea05db872ca10807a9", + "content-lifecycle-validation-2026-10-31.json": "cbb093af470588ca6e8eb3ec5bc1ccee0be537ab37a46625b6ac267c17343d99", + "content-lifecycle-release-target-2026-10-31.json": "e9b50be3c8284c5a9ae96d75b800cbc4090580fa811c2ecd098d73ce8cbee74e", + "content-lifecycle-release-row-drift-2026-10-31.json": "b10f65bf55e900199ae6037767f32e8901aaccb6f7f3a86461f13dd02ec8efce" + }, + "new_resources_committed": 0, + "autopilot_or_eur_budget_claimed": false, + "values_file": "release/content-lifecycle-values.yaml", + "snapshot_file": "release/content-lifecycle-snapshot.py", + "evidence_directory": "release/content-lifecycle-evidence", + "prerequisite_installation_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "requires_inactive_backup_units": true, + "files": { + "tools/coulomb-image-release.py": "4cefb7b4ce7fb7eece1cd78ce3c1712a2b5cc2f9c81acfed06e3c6ac7c4abad3", + "tools/coulomb_image_release_node.py": "5547b3809f34178e48404cd1590f665729b062b4333f561e2da81b56d435dece", + "tools/coulomb_image_probe.py": "7d241b288992fbbc961b42a1311f7d0938b0c899c0675b7db92653c5ace6014f", + "release/content-lifecycle-values.yaml": "daa302f96bddb889c7fe4aa95b740e9f5184d2ac463cd2403431b3e94db86528", + "release/content-lifecycle-snapshot.py": "c1508043f473d2762723acda04944a36f5346e7641fbe6fdf21a91acd3921a54", + "charts/coulomb-social/Chart.yaml": "663d6c23c57457a03bc376ebafb8107ee7f150e150f4d4fb6a3a949fb21006aa", + "charts/coulomb-social/templates/_helpers.tpl": "65379818d484959e7221d3f538b59ffd6b8b7d3c9493250e47520576aee1fc68", + "charts/coulomb-social/templates/content-pvc.yaml": "5c4ddcaee2b3dcdf17da061b1a7a2cb1e0398d2f56f4ef31790cfaaf648b0fee", + "charts/coulomb-social/templates/deployment.yaml": "f90b7517959ec8785f578f917fec222287931e4f8a27f6f9e8fc0a28385f7df8", + "charts/coulomb-social/templates/service.yaml": "78ce0ec6990c5ad57703c7a8d5d2c88c8eb9302d325cb2ba9d2df34e5f90ebdd", + "charts/coulomb-social/values.yaml": "af48d133f7fe45f0d762b40878f306ef48262f6cbbca61808ffac5dd9fc9d241", + "release/content-lifecycle-evidence/content-lifecycle-baseline-tests-2026-10-31.json": "de14e4e10337990ccec1c483f7d943bdc7e11b03aa813aff47d00a9b38a07b65", + "release/content-lifecycle-evidence/content-lifecycle-candidate-build-2026-10-31.json": "83ad2ec41c85f8bb5087a250fdb91126ed92f12351cdc72b353c6f0e1a26452b", + "release/content-lifecycle-evidence/content-lifecycle-candidate-provenance-2026-10-31.json": "35ed909e0a2fa5c57cea1fcc4fbebb877eae559ca9dc753c3da00f84806d7b73", + "release/content-lifecycle-evidence/content-lifecycle-candidate-recovery-2026-10-31.json": "8d15172e5e330d01f153336e67f6bffb4363d571530c31ea05db872ca10807a9", + "release/content-lifecycle-evidence/content-lifecycle-candidate-tests-2026-10-31.json": "27a75cfee6bbda41f377b73d6a6c7ecde0c54806c113f1be5223dd2f2cf39b3b", + "release/content-lifecycle-evidence/content-lifecycle-release-row-drift-2026-10-31.json": "b10f65bf55e900199ae6037767f32e8901aaccb6f7f3a86461f13dd02ec8efce", + "release/content-lifecycle-evidence/content-lifecycle-release-target-2026-10-31.json": "e9b50be3c8284c5a9ae96d75b800cbc4090580fa811c2ecd098d73ce8cbee74e", + "release/content-lifecycle-evidence/content-lifecycle-validation-2026-10-31.json": "cbb093af470588ca6e8eb3ec5bc1ccee0be537ab37a46625b6ac267c17343d99" + } +} diff --git a/docs/release/content-lifecycle-release-row-drift-2026-10-31.json b/docs/release/content-lifecycle-release-row-drift-2026-10-31.json new file mode 100644 index 0000000..8c8aeec --- /dev/null +++ b/docs/release/content-lifecycle-release-row-drift-2026-10-31.json @@ -0,0 +1,61 @@ +{ + "changed_columns": { + "django_session": [ + "session_key", + "session_data", + "expire_date" + ], + "members_member": [ + "updated_at", + "last_login_at" + ], + "members_user": [ + "last_login" + ] + }, + "file_changes": [], + "retained_fingerprint": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 92, + "business_rows_sha256": "6243955479f7861fcdcd79b62f7f1e9f9a27c05a17bd2c17b792ebc98b2fa7b6", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0 + }, + "schema": "coulomb.content-release-row-drift.v1", + "changed_tables": [ + { + "table": "django_session", + "retained_rows": 42, + "current_rows": 43, + "retained_sha256": "c958e33ec782f9f2fd1b3cceba4a64da0038fde51050f25905ae42de91ca6dde", + "current_sha256": "594e246ef6297d9be9b6e8d9eda076a5b2ab36afb9263606b719f64f51293ba6" + }, + { + "table": "members_member", + "retained_rows": 1, + "current_rows": 1, + "retained_sha256": "4d465d2b9d1e6311e505f861f2ed4b67763a545a965f9a7d0c586a3d07e3e9a3", + "current_sha256": "ab82b3f2c5e49f8634c598c9642894747f4b6fcff0f5230ac27f41939feb8640" + }, + { + "table": "members_user", + "retained_rows": 1, + "current_rows": 1, + "retained_sha256": "81ccb3a40191e910a68c101896df518a588c00231728934c4c900577f8ae4f12", + "current_sha256": "0fc7a71b4b80a71fbedce527bbd2cae3858cf6a062e5714fc231be13ff987256" + } + ], + "all_other_business_tables_equal": true, + "migration_history_equal": true, + "content_equal": true, + "retained_point": "2026-10-31T07:13:52Z", + "current_business_rows": 93, + "current_business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "private_row_values_printed": false, + "production_changes": false +} diff --git a/docs/release/content-lifecycle-release-target-2026-10-31.json b/docs/release/content-lifecycle-release-target-2026-10-31.json new file mode 100644 index 0000000..a3e6e05 --- /dev/null +++ b/docs/release/content-lifecycle-release-target-2026-10-31.json @@ -0,0 +1,62 @@ +{ + "schema": "coulomb.content-release-target-observation.v1", + "observed_at": "2026-10-31T12:07:24.322343+00:00", + "cluster_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "deployment_uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "pvc_uid": "9888abeb-9d52-4c70-a23d-b5494780d9c3", + "baseline_revision": 19, + "baseline_helm_manifest_sha256": "93cb277abd1aea0657be482e458dcfb268fe0bfbd45d90aa1faedcc7f5bce499", + "baseline_spec_sha256": "42e091e52d5dae3c1f986e4898c62057fa80846adfce049a75d8fbd4681180ea", + "candidate_spec_sha256": "c65cb691bab7054ae25de58cdb75933d383621ec4e049d2daa1b1bf52dce0bba", + "old_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "candidate_image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:d3f2f5bb90f0ba79f4e1d8098d2eb06f8940b8789e5ecb851914bbab364597ec", + "sole_ready_pvc_writer": true, + "existing_unmanaged_metadata_preserved": true, + "candidate_image_only_server_preview_verified": true, + "data": { + "business_tables": 12, + "migration_rows_sha256": "43471516387c0e2a3d35971033ed6af330e2963efe4da93293d97633825d04af", + "business_rows": 93, + "business_rows_sha256": "7953b37b02b80d89ca6869dab6b2b0b996541de20ca0dd1b294a5f25572fb2ca", + "content_files": 86, + "content_files_sha256": "ed535246f4e8f46fcd7ce01f936127996aff873c6936b297f7272194b594aeda", + "global_slug_constraint": true, + "tenant_slug_constraint": true, + "migration_0003_applied": true, + "duplicate_slug_groups": 0, + "actual_owner_pages_rendered": 40, + "unsaved_outsider_page_denials": 40, + "production_identity_or_grant_created": false + }, + "matches_verified_revision19_data": false, + "installed_helper_sha256": { + "/opt/coulomb-social-backup/paired-backup.py": "43e119acb6a4933b4e144c349a091e074e1a91ab61dbe15a65c8264c1d47661a", + "/opt/coulomb-social-backup/snapshot-content-and-db.py": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "/opt/railiance-platform/scripts/primary_application_archive.py": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d" + }, + "timers": { + "coulomb-social-paired-backup.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "UnitFileState": "" + } + }, + "prerequisites": { + "custody": { + "exists": false + }, + "installation": { + "exists": false + }, + "route": { + "exists": false + } + }, + "production_data_modified": false, + "release_executed": false +} diff --git a/docs/usecases/sharing-portability-review.md b/docs/usecases/sharing-portability-review.md index 683d00c..3abaf51 100644 --- a/docs/usecases/sharing-portability-review.md +++ b/docs/usecases/sharing-portability-review.md @@ -80,3 +80,59 @@ symlinks. Existing retention/incoming-address permission tests remain required. Evidence: `../release/content-lifecycle-validation-2026-10-31.json`. This is source and isolated-fixture evidence; the fix still needs release delivery and productive review. Do not kill or mutate the productive application to perform this review. + +## Prepared lifecycle image release — follows inactive backup installation + +The owner packet is `railiance-apps/release/coulomb-content-lifecycle-f306dbf.json`, +proposal `sha256:c9c3231620721ef3ea9b2bf22fc6c62fe3ed8efb91386cf7ec96acc6959dae08`. +It upgrades the verified revision19/image522bcd7f to candidate d3f2f5bb, with no new +migration, resource, authorization grant or content rewrite. The only packaged +application change is attachment journal ordering/durability in pageops.py. + +**Current full preflight refuses the missing inactive-installation receipt.** +The order is: existing historical custody repairfa2763d1 and recurring route4d848106, +then inactive source installation8f442bf0, then this image release, then native +recurring-backup proof and schedule activation. The installer pins the current +snapshot helper; deploying this image first would invalidate its reviewed preimage. +The image controller enforces the completed installer receipt and inactive/disabled +backup units before creating an execution journal. Do not bypass that dependency. + +After the existing prerequisites have their independently verified returns, run +this read-only check from the workstation: + +```bash +python3 /home/worsch/railiance-apps/tools/coulomb-image-release.py \ + --plan /home/worsch/railiance-apps/release/coulomb-content-lifecycle-f306dbf.json \ + --preflight \ + --expect-digest sha256:c9c3231620721ef3ea9b2bf22fc6c62fe3ed8efb91386cf7ec96acc6959dae08 +``` + +Only a `live_release_preflight_verified` return at that exact digest makes the +packet ready for attended execution. A changed baseline needs a revised review. +The founder execution command then replaces `--preflight` with `--execute`; its +terminal prompt requires `DEPLOY` followed by that full digest. The supervision +rule is recorded in the-custodian/docs/agent-autonomy-decision.md: “The supervisor +approves that proposal or performs it through the privileged execution path.” +This release has not been executed or approved through the retention-policy answer. + +Execution creates a fresh immutable paired primary backup/readback before Helm, +checks the current complete data and migration fingerprints, performs an atomic +image-only upgrade, verifies owner/outsider/public endpoints and refreshes the +snapshot image pin. A failure restores revision19 and the recognized previous pin +without restoring old data or downgrading schema. Inspect the durable receipt +before any retry. Expected receipt status is `image_candidate_deployed_verified` +under `/var/lib/coulomb-social-backup/releases/c9c3231620721ef3ea9b2bf22fc6c62fe3ed8efb91386cf7ec96acc6959dae08/receipt.json`. + +The current observation binds93business rows/86files. The retained07:13 recovery +point has92business rows: the differences are sessions and login timestamps, with +identical content and migration history. Candidate/rollback rehearsals preserve +all13tables/86files, verify85export files and40owner/40outsider views on that point. +They do not establish recovery of the newer session; independent retrieval/restore +of the fresh execution point remains part of the release return. + +There are no new infrastructure reservations. The action adds one encrypted +primary backup object and temporary local scratch under the admitted capacity +bounds. Incremental provider cost is unpriced; no EUR autopilot budget is claimed. +Application writes pause during paired capture and the Recreate rollout. Evidence: +`../release/content-lifecycle-release-preparation-2026-10-31.json`, its proposal, +native target/preflight/row-drift receipts, and the candidate recovery receipt. diff --git a/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md b/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md index eb49779..621c3d0 100644 --- a/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md +++ b/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md @@ -647,6 +647,12 @@ Then obtain the attended return and independently verify installed hashes and disabled units. Review: docs/release/backup-source-installation-execution-review-2026-10-31.md; source/gate evidence: backup-supervised-installer-validation-2026-10-31.json. +The later content image release (RAPPS-WP-0015-T07, packetc9c32316) must follow +this installation: it refreshes the snapshot image pin that this reviewed bundle +currently fixes atdcc8dd2a. Both existing packet digests are preserved. Technical +sequencing decision: de5a4528-0864-4769-b9d0-2f658b0310c6; this is not image +execution approval. + Preparation is complete; task status is now wait for the T04 custody/profile return and T07 attended route. The exact eight-entry custody repair has a passing native preflight but has not executed. Installation and independent postflight @@ -659,10 +665,15 @@ neither task represents a currently running native job. id: CSOC-WP-0012-T09 status: todo priority: high +depends_on: +- CSOC-WP-0012-T04 +- CSOC-WP-0012-T07 +- CSOC-WP-0012-T08 +- RAPPS-WP-0015-T07 state_hub_task_id: "c30bc200-6e0c-5651-b53a-be7a6f3dd207" ``` -After T07/T08 and applicable T04 operating admission, prove a native paired service capture, exact primary/mirror receipt, independent retrieval, received failed/pending/stale events in both channels, retry and restart continuity, and outside-producer failure detection. Retain correlated receipts without claiming an Alertmanager handoff is recipient delivery. Simulated source tests and the one-off email cannot close this task. +After T07/T08, applicable T04 operating admission and RAPPS-WP-0015-T07 content image delivery, prove a native paired service capture, exact primary/mirror receipt, independent retrieval, received failed/pending/stale events in both channels, retry and restart continuity, and outside-producer failure detection. Retain correlated receipts without claiming an Alertmanager handoff is recipient delivery. Simulated source tests and the one-off email cannot close this task. ## T10 — Activate and observe the approved recurring schedule @@ -670,10 +681,16 @@ After T07/T08 and applicable T04 operating admission, prove a native paired serv id: CSOC-WP-0012-T10 status: todo priority: high +depends_on: +- CSOC-WP-0012-T04 +- CSOC-WP-0012-T07 +- CSOC-WP-0012-T08 +- RAPPS-WP-0015-T07 +- CSOC-WP-0012-T09 state_hub_task_id: "009c9b51-32d4-5a35-a771-7cff0059c042" ``` -After T04 and T07–T09, activate the already approved 03:15 UTC schedule with five-minute jitter, RPO24h/stale26h and attended RTO60min target. Verify native timer/service state and subsequent scheduled capture/freshness evidence. Record measured recovery limits; cached-image rehearsal is not cold-start recovery. Do not ask the user to approve the schedule again. Close on observed operation, not merely enabling a timer. +After T04, T07–T09 and RAPPS-WP-0015-T07, activate the already approved 03:15 UTC schedule with five-minute jitter, RPO24h/stale26h and attended RTO60min target. Verify native timer/service state and subsequent scheduled capture/freshness evidence. Record measured recovery limits; cached-image rehearsal is not cold-start recovery. Do not ask the user to approve the schedule again. Close on observed operation, not merely enabling a timer. ## T11 — Close service registration, provenance, cost and smoke evidence diff --git a/workplans/CSOC-WP-0013-sharing-content-hardening.md b/workplans/CSOC-WP-0013-sharing-content-hardening.md index a9dad0a..0f5ad3f 100644 --- a/workplans/CSOC-WP-0013-sharing-content-hardening.md +++ b/workplans/CSOC-WP-0013-sharing-content-hardening.md @@ -229,6 +229,19 @@ new exact release preparation/execution return. T05 remains progress until that return and productive acceptance; policy acceptance is settled and no live purge is authorized. No production image or release pin changed during this work. +Exact owner image packetc9c32316 is now prepared and RAPPS-WP-0015-T07 waits on +WP-0012-T08. Its40controller cases pass; the separate values/snapshot paths retain +historical capsules. Current native image preview is image-only at revision19. +Candidate and rollback images preserve the retained13tables/86files and render40 +owner pages/deny40outsiders. Live93-row baseline includes a new session and login +timestamps since the92-row retained point; content/migrations match. The execution +must capture a new exact paired point before Helm. Native full preflight refuses +missing installation8f442bf0 before creating an execution journal. Existing backup +packets retain their digests; image delivery follows inactive installation and +precedes native recurring-service acceptance/activation. Details and later commands +are in docs/usecases/sharing-portability-review.md. No image release or productive +acceptance has occurred; T05 remains progress. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. From 961f310a8eb53f32a3fcbde5779b4da78e0366fe Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 13:29:40 +0100 Subject: [PATCH 03/39] Assess password reset owner return after directory bind split Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- ...native-password-reset-review-2026-10-30.md | 19 +++ ...et-owner-return-assessment-2026-10-31.json | 111 ++++++++++++++++++ ...P-0015-communication-runtime-acceptance.md | 16 ++- 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 docs/release/reset-owner-return-assessment-2026-10-31.json diff --git a/docs/release/native-password-reset-review-2026-10-30.md b/docs/release/native-password-reset-review-2026-10-30.md index f1a7c51..dbcfbf8 100644 --- a/docs/release/native-password-reset-review-2026-10-30.md +++ b/docs/release/native-password-reset-review-2026-10-30.md @@ -95,3 +95,22 @@ expiry/replay, KeyCape, user-engine HTTP and the actual app callback using an explicit synthetic password-manager bind. See native-recovery-browser-review-2026-10-31.md. That new proof does not grant production reset authority or rewrite this API-only receipt's scope. + + +## Owner return assessed on October 31 + +The bind split and its post-split restore drill are now complete under +NK-WP-0054-T05. Their checks cover sign-in and MFA, not password replacement. +The founder receipt records svc-authelia as strict-readonly. Current read-only +metadata finds that bind in the ConfigMap and both deployed images exactly match +this isolated proof. No current directory membership or production reset was +queried, so this is an unresolved reset-authority return rather than a live +failure claim. See reset-owner-return-assessment-2026-10-31.json. + +The same owner workplan now carries T09 (completed evidence assessment) and T10 +(supported reset authority, reviewed execution and native/recovery acceptance). +CSOC-WP-0015-T06 consumes T10. T05 remains complete for its original migration +scope. Previous personal reset acceptance does not establish behavior after the +bind changed; no new grant is inferred from either the old approval or the +synthetic password-manager success. Preparation may proceed independently of the +pending application image and backup installation. diff --git a/docs/release/reset-owner-return-assessment-2026-10-31.json b/docs/release/reset-owner-return-assessment-2026-10-31.json new file mode 100644 index 0000000..43ad83b --- /dev/null +++ b/docs/release/reset-owner-return-assessment-2026-10-31.json @@ -0,0 +1,111 @@ +{ + "schema": "coulomb.reset-owner-boundary.v1", + "observed_at": "2026-10-31T12:27:58.389484+00:00", + "configmap_uid": "7af1fa0d-1bd4-47e1-8873-0f620788ef3f", + "configmap_resource_version": "82819079", + "configuration_sha256": "0a8bca50641a4c6f27830f476c2c9f413fc3795711becccb8344ee4d6c376e80", + "bind_is_svc_authelia": true, + "reset_disabled_in_config": false, + "deployments": { + "authelia": { + "uid": "dc40a925-2306-4a3b-89c0-e714187503c3", + "generation": 31, + "observed_generation": 31, + "ready_replicas": 1, + "images": [ + "docker.io/authelia/authelia@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54" + ] + }, + "lldap": { + "uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8", + "generation": 9, + "observed_generation": 9, + "ready_replicas": 1, + "images": [ + "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d" + ] + } + }, + "directory_memberships_read_here": false, + "password_reset_attempted": false, + "secret_read": false, + "email_sent": false, + "runtime_changed": false, + "status": "reset_authority_return_incomplete", + "owner_bind_split_task": "NK-WP-0054-T05", + "owner_bind_split_status": "done", + "owner_followthrough": "NK-WP-0054-T10", + "consumer_task": "CSOC-WP-0015-T06", + "owner_membership_receipt_at": "2026-10-30T18:53:16.176849+00:00", + "owner_receipt_bind_groups": [ + "lldap_strict_readonly" + ], + "owner_restore_drill_proves": [ + "independent retrieval", + "directory login", + "Authelia first factor", + "by-user MFA", + "cleanup" + ], + "owner_restore_drill_proves_password_change": false, + "deployed_images_match_isolated_reset_proof": true, + "isolated_readonly_reset_result": "HTTP 200/KO; old password accepted; replacement refused", + "isolated_password_manager_reset_result": "ordinary fixture user changed password; directory group creation refused", + "production_password_reset_failure_claimed": false, + "current_membership_unknown_since_owner_receipt": true, + "prior_personal_reset_acceptance": "preserved as historical; does not prove post-split reset", + "knowledge_orientation": { + "version": "0.2.7", + "digest": "22b7294e24137c0a350764b9cf37f3d4f2e9f35eb7c97a287d9c89477ef24d7a", + "cards": [ + "start", + "identity" + ], + "source_status": "verified-files", + "authority": "orientation only" + }, + "sources": [ + { + "repo": "net-kingdom", + "commit": "55fe8303f31d7476de5180f35c736652f5b4c475", + "path": "workplans/NK-WP-0054-service-identity-policy.md", + "sha256": "1b5411a38583edede8124881825f252b2498335de22559e23d52c787d9c9581a" + }, + { + "repo": "net-kingdom", + "commit": "55fe8303f31d7476de5180f35c736652f5b4c475", + "path": "docs/evidence/2026-10-30-nk54-bind-split/10-verify.json", + "sha256": "b6318e9239623a09d3f6eac1208b0fd7c7a3d3facf55a4c31c8f628454e42ebb" + }, + { + "repo": "net-kingdom", + "commit": "55fe8303f31d7476de5180f35c736652f5b4c475", + "path": "docs/evidence/2026-10-31-identity-restore-drill/drill.json", + "sha256": "47a5a88bdc8a7494d9e5e6687372398367931dca84e38d7de9f6290375caffa2" + }, + { + "repo": "net-kingdom", + "commit": "55fe8303f31d7476de5180f35c736652f5b4c475", + "path": "tools/bootstrap-recovery/isolated-authelia.py", + "sha256": "6139294f6ebdcbaad35934f04b11177d8cf89d134506a730f0c0ebbb3a4967be" + }, + { + "repo": "net-kingdom", + "commit": "55fe8303f31d7476de5180f35c736652f5b4c475", + "path": "sso-mfa/k8s/lldap/bind-split.py", + "sha256": "4decc3bbe65120ff6c4cef5613b9a41a60168ae64ecd4c1380f61a2fcd37b6b3" + }, + { + "repo": "coulomb-social", + "commit": "a786a8050db92807d0f16e637043fbd191ab9335", + "path": "docs/release/native-password-reset-2026-10-30.json", + "sha256": "0666b35773ee3888e12d8d8d877bd8690c9c41b6f98096bf3c5a50195be2294f" + }, + { + "repo": "coulomb-social", + "commit": "a786a8050db92807d0f16e637043fbd191ab9335", + "path": "docs/release/native-recovery-browser-2026-10-31.json", + "sha256": "4d3b644cdf0e76e8ff775a79d8845831f7573a1e2e716e60dd53416343b6e3ba" + } + ] +} diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index a1f0e86..ab2e835 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -245,13 +245,27 @@ Acceptance scope split from T04: current Case B, UC-010 registration and UC-011 ```task id: CSOC-WP-0015-T06 -status: todo +status: progress +depends_on: [NK-WP-0054-T10] priority: high state_hub_task_id: "e17a6d09-1fdd-5fa1-a36d-763802e37f7f" ``` Remaining owner admission from T04 is canonical NK-WP-0054-T05. Isolated native Authelia/LLDAP reset and full browser integration are proven, including read-only-bind refusal and successful scoped password replacement. Obtain and assess the owner native authority/runtime return; preserve MFA/TLS/directory boundaries and link acceptance here. Do not create a duplicate provisioning lane or infer a live grant from the synthetic proof. Evidence: `docs/release/native-recovery-browser-review-2026-10-31.md` and `native-password-reset-review-2026-10-30.md`. +2026-10-31 owner-return assessment: NK-WP-0054-T05 is now done and its founder +bind migration plus restore drill have been inspected. They prove sign-in/MFA, +not post-split password change. Current native ConfigMap uses svc-authelia; both +Ready native images match our isolated proof, and the dated owner receipt assigns +strict-readonly. This establishes an unclosed reset-authority requirement, not a +claimed production reset attempt. NK-WP-0054-T09 records the completed assessment; +T10 now owns supported reset authority, preparation, explicit admission, native +return and recovery verification in the same owner workplan. T06 advanced to +progress for this assessment and consumes T10; it cannot close on T05's done label. +Evidence: docs/release/reset-owner-return-assessment-2026-10-31.json. No credential, +mail, password, directory grant or live configuration was changed. Prior personal +reset acceptance is preserved with its original date and scope. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. From 338340222dccaad0b38d3ab916500e62fb56cd8a Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 13:32:26 +0100 Subject: [PATCH 04/39] Clarify current reset dependency and update work-record index Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 2 +- workplans/CSOC-WP-0015-communication-runtime-acceptance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index d319abd..20f7520 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -116,7 +116,7 @@ | task | CSOC-WP-0015-T03 | done | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | | task | CSOC-WP-0015-T04 | done | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | | task | CSOC-WP-0015-T05 | todo | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | -| task | CSOC-WP-0015-T06 | todo | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | +| task | CSOC-WP-0015-T06 | progress | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | | task | CSOC-WP-0016-T01 | done | — | workplans/CSOC-WP-0016-mfa-setup-link.md | | task | CSOC-WP-0017-T01 | done | — | workplans/CSOC-WP-0017-wave2-resource-right-sizing.md | | intake | CSOC-IN-0001 | closed | blue | docs/intakes/csoc-residuals.md | diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index ab2e835..39d68bf 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -251,7 +251,7 @@ priority: high state_hub_task_id: "e17a6d09-1fdd-5fa1-a36d-763802e37f7f" ``` -Remaining owner admission from T04 is canonical NK-WP-0054-T05. Isolated native Authelia/LLDAP reset and full browser integration are proven, including read-only-bind refusal and successful scoped password replacement. Obtain and assess the owner native authority/runtime return; preserve MFA/TLS/directory boundaries and link acceptance here. Do not create a duplicate provisioning lane or infer a live grant from the synthetic proof. Evidence: `docs/release/native-recovery-browser-review-2026-10-31.md` and `native-password-reset-review-2026-10-30.md`. +Remaining owner admission from T04 is canonical NK-WP-0054-T10, the explicit reset follow-through to the completed T05 bind split. Isolated native Authelia/LLDAP reset and full browser integration are proven, including read-only-bind refusal and successful scoped password replacement. Obtain and assess the owner native authority/runtime return; preserve MFA/TLS/directory boundaries and link acceptance here. Do not create a duplicate provisioning lane or infer a live grant from the synthetic proof. Evidence: `docs/release/native-recovery-browser-review-2026-10-31.md` and `native-password-reset-review-2026-10-30.md`. 2026-10-31 owner-return assessment: NK-WP-0054-T05 is now done and its founder bind migration plus restore drill have been inspected. They prove sign-in/MFA, From f763379882257a99192d8b3a81adbf597431499d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 13:40:10 +0100 Subject: [PATCH 05/39] Verify native reset authority across account classes Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- ...native-password-reset-review-2026-10-30.md | 55 +++- .../reset-authority-matrix-2026-10-31.json | 265 ++++++++++++++++++ ...et-authority-matrix-2026-10-31.sources.txt | 3 + ...reset-authority-validation-2026-10-31.json | 48 ++++ .../reset-default-regression-2026-10-31.json | 62 ++++ ...-default-regression-2026-10-31.sources.txt | 2 + scripts/native_recovery/probe.py | 76 ++++- scripts/rehearse-reset-authority.py | 156 +++++++++++ ...P-0015-communication-runtime-acceptance.md | 11 + 9 files changed, 670 insertions(+), 8 deletions(-) create mode 100644 docs/release/reset-authority-matrix-2026-10-31.json create mode 100644 docs/release/reset-authority-matrix-2026-10-31.sources.txt create mode 100644 docs/release/reset-authority-validation-2026-10-31.json create mode 100644 docs/release/reset-default-regression-2026-10-31.json create mode 100644 docs/release/reset-default-regression-2026-10-31.sources.txt create mode 100644 scripts/rehearse-reset-authority.py diff --git a/docs/release/native-password-reset-review-2026-10-30.md b/docs/release/native-password-reset-review-2026-10-30.md index dbcfbf8..b337791 100644 --- a/docs/release/native-password-reset-review-2026-10-30.md +++ b/docs/release/native-password-reset-review-2026-10-30.md @@ -1,6 +1,6 @@ # Native password-reset bind prerequisite -WP-0015-T04 remains open. An isolated native Authelia/LLDAP test identifies a +WP-0015-T06 owns the remaining native authority return. An isolated native Authelia/LLDAP test identifies a directory permission prerequisite before the full UC-009 browser journey can be accepted. It does not change the existing personal reset acceptance, prove the currently installed bind's privileges or authorize any live permission change. @@ -114,3 +114,56 @@ scope. Previous personal reset acceptance does not establish behavior after the bind changed; no new grant is inferred from either the old approval or the synthetic password-manager success. Preparation may proceed independently of the pending application image and backup installation. + + +## Native authority matrix — October 31 + +`scripts/rehearse-reset-authority.py` extends the synthetic investigation without +changing the application, canonical bind helper, declaration or live directory. +It uses the same pinned native images and network-none loopback confinement. +Run with `--output` naming a fresh local JSON receipt; adjacent source manifest +and receipt refuse overwrite. Disabled assertions refuse before Docker access. + +| Synthetic target | Strict-readonly | Password-manager | +| --- | --- | --- | +| Two ordinary users | Reset refused | Reset succeeds | +| Member of `lldap_admin` | Reset refused | Reset refused | +| Application-admin group member, outside `lldap_admin` | Reset refused | Reset succeeds | +| Other read-only service account, direct directory password API | Refused | Password changes | + +The first four user cases are tested through actual Authelia reset/link flows +and direct directory password changes. Old/replacement password authentication +confirms every outcome; HTTP 200 is insufficient. Unverified requests and consumed +link replay refuse. Service interactive login through Authelia is excluded by the +filter in every scenario. This does not prohibit direct LLDAP web login, and that +filter does not constrain a compromised bind credential's directory password API. + +For each role, seven valid directory mutations refuse: create/delete user, +edit another user's profile, create/delete group, self-promote to directory admin, +and remove another administrator's membership. Matching admin controls succeed, +and complete user/group metadata remains unchanged after the denied operations. +This bounds the tested operations; it does not enumerate every API right. + +The LLDAP-admin restriction agrees with the pinned upstream +[LLDAP v0.6.2 integration guidance](https://github.com/lldap/lldap/blob/v0.6.2/README.md#general-configuration-guide). +The application-admin and service results matter to admission: a password-manager +role is not restricted to Coulomb users or to human accounts. It must not be +presented as restoring all prior reset behavior. No actual account's current +classification has been inferred from these fixtures. + +Owner T11 records this completed rehearsal; T10 retains the supported runtime path. +Before an executable proposal, obtain owner-authorized, value-free classification +of the intended reset subjects and current bind memberships. Review a service-owned +password-write grant against SI-3 and its complete non-LLDAP-admin reach, and a +separate supported recovery route for directory administrators. Preserve the +existing human-admin separation and MFA requirements. A role change alone is not +a complete proposal, and no runtime grant or policy exception is approved here. + +Evidence: reset-authority-matrix-2026-10-31.json and its source manifest; +reset-authority-validation-2026-10-31.json. Ten direct password cases, eight native +Authelia flows, fourteen unrelated-write denials and fourteen matching admin +controls pass. All disposable containers are removed. Both original native reset +scenarios still pass; the full `make check` gate passes 546 tests with zero +failures/errors/skips. These checks add neither productive acceptance nor a +current membership observation. Existing expiry/browser/app proof retains its +separate scope; this matrix does not retest the complete issuer/MFA journey. diff --git a/docs/release/reset-authority-matrix-2026-10-31.json b/docs/release/reset-authority-matrix-2026-10-31.json new file mode 100644 index 0000000..fddbf7e --- /dev/null +++ b/docs/release/reset-authority-matrix-2026-10-31.json @@ -0,0 +1,265 @@ +{ + "schema": "coulomb.native-reset-authority-matrix.v1", + "observed_at": "2026-10-31T12:37:24.533367+00:00", + "data": "synthetic", + "status": "native_authority_matrix_verified", + "images": { + "lldap": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d", + "authelia": "sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54", + "client": "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd" + }, + "production_access": false, + "provider_smtp_contacted": false, + "owner_runtime_or_permission_change_performed": false, + "no_ports_host_mounts_or_external_routes": true, + "backend_container_flags_verified_before_fixture_import": true, + "source_manifest_sha256": "c982e3a47d9e3aec5095a3c11fea7260fd4f5cb6db57b827b915988b432a18d0", + "target_classes": { + "alice": "ordinary user", + "bob": "other ordinary user", + "operator": "lldap_admin member", + "appoperator": "application-admins member, not lldap_admin", + "svc-other": "other service, lldap_strict_readonly" + }, + "findings": { + "scenarios": [ + { + "bind_role": "lldap_strict_readonly", + "directory_writes": { + "positive_controls": 7, + "refused_mutations": [ + "create_user", + "edit_other_user", + "delete_user", + "create_group", + "delete_group", + "self_promote_admin", + "remove_admin_membership" + ], + "directory_metadata_unchanged": true + }, + "direct_password_changes": [ + { + "target": "alice", + "password_changed": false, + "old_password_accepted": true, + "new_password_accepted": false + }, + { + "target": "bob", + "password_changed": false, + "old_password_accepted": true, + "new_password_accepted": false + }, + { + "target": "operator", + "password_changed": false, + "old_password_accepted": true, + "new_password_accepted": false + }, + { + "target": "appoperator", + "password_changed": false, + "old_password_accepted": true, + "new_password_accepted": false + }, + { + "target": "svc-other", + "password_changed": false, + "old_password_accepted": true, + "new_password_accepted": false + } + ], + "authelia_resets": [ + { + "target": "alice", + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + }, + { + "target": "bob", + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + }, + { + "target": "operator", + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + }, + { + "target": "appoperator", + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + } + ] + }, + { + "bind_role": "lldap_password_manager", + "directory_writes": { + "positive_controls": 7, + "refused_mutations": [ + "create_user", + "edit_other_user", + "delete_user", + "create_group", + "delete_group", + "self_promote_admin", + "remove_admin_membership" + ], + "directory_metadata_unchanged": true + }, + "direct_password_changes": [ + { + "target": "alice", + "password_changed": true, + "old_password_accepted": false, + "new_password_accepted": true + }, + { + "target": "bob", + "password_changed": true, + "old_password_accepted": false, + "new_password_accepted": true + }, + { + "target": "operator", + "password_changed": false, + "old_password_accepted": true, + "new_password_accepted": false + }, + { + "target": "appoperator", + "password_changed": true, + "old_password_accepted": false, + "new_password_accepted": true + }, + { + "target": "svc-other", + "password_changed": true, + "old_password_accepted": false, + "new_password_accepted": true + } + ], + "authelia_resets": [ + { + "target": "alice", + "status": "reset_completed", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": null, + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": false, + "new_password_accepted_after": true + }, + { + "target": "bob", + "status": "reset_completed", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": null, + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": false, + "new_password_accepted_after": true + }, + { + "target": "operator", + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + }, + { + "target": "appoperator", + "status": "reset_completed", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": null, + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": false, + "new_password_accepted_after": true + } + ] + } + ], + "cleanup_verified": true + } +} diff --git a/docs/release/reset-authority-matrix-2026-10-31.sources.txt b/docs/release/reset-authority-matrix-2026-10-31.sources.txt new file mode 100644 index 0000000..3dfc54a --- /dev/null +++ b/docs/release/reset-authority-matrix-2026-10-31.sources.txt @@ -0,0 +1,3 @@ +scripts/rehearse-reset-authority.py: 120d3b8d4e54ec87386f9f2d53abe836feab48c7cc883a13e0d4672bcc347b0c +scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d +scripts/native_recovery/probe.py: eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556 diff --git a/docs/release/reset-authority-validation-2026-10-31.json b/docs/release/reset-authority-validation-2026-10-31.json new file mode 100644 index 0000000..31a7150 --- /dev/null +++ b/docs/release/reset-authority-validation-2026-10-31.json @@ -0,0 +1,48 @@ +{ + "schema": "coulomb.reset-authority-validation.v1", + "status": "source_and_isolated_native_verified", + "full_gate": { + "command": "make check", + "tests": 546, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "ff99e36bad4e42a1983bdac2749b834a454a22c708065752ecd31f8be5ac963a" + }, + "native_matrix": { + "roles": 2, + "direct_password_cases": 10, + "authelia_reset_cases": 8, + "unrelated_write_denials": 14, + "valid_admin_write_controls": 14, + "cleanup_verified": true + }, + "legacy_native_reset_regression": "passed both original role scenarios", + "cli_refusals": [ + "disabled assertions", + "existing receipt", + "existing source manifest" + ], + "current_live_membership_verified": false, + "production_reset_verified": false, + "authority_grant_approved": false, + "sources": [ + { + "path": "scripts/rehearse-reset-authority.py", + "sha256": "120d3b8d4e54ec87386f9f2d53abe836feab48c7cc883a13e0d4672bcc347b0c" + }, + { + "path": "scripts/native_recovery/probe.py", + "sha256": "eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556" + }, + { + "path": "docs/release/reset-authority-matrix-2026-10-31.json", + "sha256": "89c555ac2c580153bd81ae03c662d024a0cdbeba173f93e6ebfa041a1a0261c7" + }, + { + "path": "docs/release/reset-default-regression-2026-10-31.json", + "sha256": "82efb79154b5e5003ce4b821ebeba73deb4375a13f4df93eddbd3b8908e705ea" + } + ], + "upstream_reference": "https://github.com/lldap/lldap/blob/v0.6.2/README.md#general-configuration-guide" +} diff --git a/docs/release/reset-default-regression-2026-10-31.json b/docs/release/reset-default-regression-2026-10-31.json new file mode 100644 index 0000000..4879627 --- /dev/null +++ b/docs/release/reset-default-regression-2026-10-31.json @@ -0,0 +1,62 @@ +{ + "schema": "coulomb.native-reset-prerequisite.v1", + "data": "synthetic", + "status": "native_bind_role_reset_boundary_verified", + "observed_at": "2026-10-31T12:36:56.223367+00:00", + "images": { + "lldap": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d", + "authelia": "sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54", + "probe": "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd" + }, + "production_access": false, + "browser_app_or_mfa_acceptance": false, + "provider_smtp_contacted": false, + "notifier": "private_tmpfs_file", + "no_ports_host_mounts_or_external_routes": true, + "backend_container_flags_verified_before_fixture_import": true, + "owner_runtime_or_permission_change_performed": false, + "findings": { + "scenarios": [ + { + "bind_role": "lldap_strict_readonly", + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false, + "directory_group_creation_refused": true, + "native_reset_notification_present": true + }, + { + "bind_role": "lldap_password_manager", + "status": "reset_completed", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": null, + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": false, + "new_password_accepted_after": true, + "directory_group_creation_refused": true, + "native_reset_notification_present": true + } + ], + "cleanup_verified": true + }, + "source_manifest_sha256": "1f3977f01757519dd5d08007b4baffa555f2cad44d9301d5dd484ded46f274e9" +} diff --git a/docs/release/reset-default-regression-2026-10-31.sources.txt b/docs/release/reset-default-regression-2026-10-31.sources.txt new file mode 100644 index 0000000..5cbc725 --- /dev/null +++ b/docs/release/reset-default-regression-2026-10-31.sources.txt @@ -0,0 +1,2 @@ +scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d +scripts/native_recovery/probe.py: eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556 diff --git a/scripts/native_recovery/probe.py b/scripts/native_recovery/probe.py index 425a8b2..737e040 100644 --- a/scripts/native_recovery/probe.py +++ b/scripts/native_recovery/probe.py @@ -14,6 +14,7 @@ def run(v): assert len(Path('/proc/net/route').read_text().splitlines()) == 1 opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) cookie = '' + target = v.get('target', 'alice') def call(port, path, data=None, *, token=None, auth=False): nonlocal cookie @@ -63,9 +64,22 @@ def run(v): admin = login('admin', v['admin_password']) assert admin, 'synthetic_admin_login' if v['mode'] == 'bootstrap': - for user in ['alice', 'svc-auth']: + for user in (['alice', 'svc-auth', 'bob', 'operator', 'appoperator', 'svc-other', + 'victim'] if v.get('authority_matrix') else ['alice', 'svc-auth']): graphql(admin, 'mutation($u:CreateUserInput!){createUser(user:$u){id}}', { 'u': {'id': user, 'email': user + '@example.test', 'displayName': user}}) + if v.get('authority_matrix'): + groups = graphql(admin, '{groups{id displayName}}')['groups'] + admin_id = next(g['id'] for g in groups if g['displayName'] == 'lldap_admin') + for user, group in [('operator', admin_id), ('svc-other', next( + g['id'] for g in groups if g['displayName'] == 'lldap_strict_readonly'))]: + graphql(admin, 'mutation($u:String!,$g:Int!){addUserToGroup(' + 'userId:$u,groupId:$g){ok}}', {'u': user, 'g': group}) + app_id = graphql(admin, 'mutation{createGroup(name:"application-admins"){id}}') + graphql(admin, 'mutation($u:String!,$g:Int!){addUserToGroup(' + 'userId:$u,groupId:$g){ok}}', + {'u': 'appoperator', 'g': app_id['createGroup']['id']}) + graphql(admin, 'mutation{createGroup(name:"victim-group"){id}}') return {'status': 'prepared', 'admin_token': admin} if v['mode'] == 'bind_role': groups = graphql(admin, '{groups{id displayName}}')['groups'] @@ -85,6 +99,54 @@ def run(v): 'query': 'mutation{createGroup(name:"unexpected-admin-rights"){id}}'}, token=service) assert code >= 400 or body.get('errors'), 'directory_group_creation_refused' return {'status': 'role_set', 'directory_group_creation_refused': True} + if v['mode'] == 'authority_context': + service = login('svc-auth', v['bind_password']) + assert service, 'service_directory_login' + return {'status': 'authority_context', 'service_token': service, + 'admin_token': admin} + if v['mode'] == 'directory_authentication': + return {'old_password_accepted': bool(login(v['target'], v['old_password'])), + 'new_password_accepted': bool(login(v['target'], v['new_password']))} + if v['mode'] == 'write_boundaries': + service = login('svc-auth', v['bind_password']) + assert service, 'service_directory_login' + # Positive controls distinguish an authorization refusal from invalid queries. + create_user = 'mutation($u:CreateUserInput!){createUser(user:$u){id}}' + update_user = 'mutation($u:UpdateUserInput!){updateUser(user:$u){ok}}' + delete_user = 'mutation($u:String!){deleteUser(userId:$u){ok}}' + create_group = 'mutation($n:String!){createGroup(name:$n){id}}' + delete_group = 'mutation($g:Int!){deleteGroup(groupId:$g){ok}}' + add = 'mutation($u:String!,$g:Int!){addUserToGroup(userId:$u,groupId:$g){ok}}' + remove = 'mutation($u:String!,$g:Int!){removeUserFromGroup(userId:$u,groupId:$g){ok}}' + graphql(admin, create_user, {'u': {'id': 'control', 'email': 'control@example.test'}}) + graphql(admin, update_user, {'u': {'id': 'control', 'email': 'changed@example.test'}}) + gid = graphql(admin, create_group, {'n': 'control-group'})['createGroup']['id'] + graphql(admin, add, {'u': 'control', 'g': gid}) + graphql(admin, remove, {'u': 'control', 'g': gid}) + graphql(admin, delete_group, {'g': gid}) + graphql(admin, delete_user, {'u': 'control'}) + snapshot_query = '{users{id email displayName groups{id}} groups{id displayName}}' + before = graphql(admin, snapshot_query) + groups = before['groups'] + admin_id = next(g['id'] for g in groups if g['displayName'] == 'lldap_admin') + victim_id = next(g['id'] for g in groups if g['displayName'] == 'victim-group') + mutations = [ + ('create_user', create_user, {'u': {'id': 'unexpected', 'email': 'x@example.test'}}), + ('edit_other_user', update_user, {'u': {'id': 'victim', 'email': 'x@example.test'}}), + ('delete_user', delete_user, {'u': 'victim'}), + ('create_group', create_group, {'n': 'unexpected'}), + ('delete_group', delete_group, {'g': victim_id}), + ('self_promote_admin', add, {'u': 'svc-auth', 'g': admin_id}), + ('remove_admin_membership', remove, {'u': 'operator', 'g': admin_id})] + denials = [] + for label, query, variables in mutations: + code, body = call(17170, '/api/graphql', {'query': query, 'variables': variables}, + token=service) + assert code >= 400 or body.get('errors'), 'unexpected_directory_write_' + label + denials.append(label) + assert graphql(admin, snapshot_query) == before, 'directory_metadata_changed' + return {'positive_controls': len(mutations), 'refused_mutations': denials, + 'directory_metadata_unchanged': True} ready(9091, '/api/health') if v['mode'] == 'reset_finish': code, body = call(9091, '/api/reset-password', {'password': v['new_password']}, auth=True) @@ -102,18 +164,18 @@ def run(v): 'native_identity_finish': True, 'reset_without_verified_challenge_refused': True, 'consumed_identity_token_replay_refused': True, - 'old_password_accepted_after': bool(login('alice', v['old_password'])), - 'new_password_accepted_after': bool(login('alice', v['new_password']))} - assert login('alice', v['old_password']), 'initial_password' - assert not login('alice', v['new_password']), 'replacement_not_initially_accepted' + 'old_password_accepted_after': bool(login(target, v['old_password'])), + 'new_password_accepted_after': bool(login(target, v['new_password']))} + assert login(target, v['old_password']), 'initial_password' + assert not login(target, v['new_password']), 'replacement_not_initially_accepted' code, body = call(9091, '/api/firstfactor', { 'username': 'svc-auth', 'password': v['bind_password'], 'keepMeLoggedIn': False}, auth=True) assert code != 200 or body.get('status') != 'OK', 'interactive_service_login_refused' code, body = call(9091, '/api/firstfactor', { - 'username': 'alice', 'password': v['old_password'], 'keepMeLoggedIn': False}, auth=True) + 'username': target, 'password': v['old_password'], 'keepMeLoggedIn': False}, auth=True) assert code == 200 and body.get('status') == 'OK', 'native_first_factor' cookie = '' - code, _ = call(9091, '/api/reset-password/identity/start', {'username': 'alice'}, auth=True) + code, _ = call(9091, '/api/reset-password/identity/start', {'username': target}, auth=True) assert code == 200, 'reset_start' return {'status': 'reset_requested', 'only_loopback_interface': True, 'external_routes_absent': True, 'old_password_initially_accepted': True, diff --git a/scripts/rehearse-reset-authority.py b/scripts/rehearse-reset-authority.py new file mode 100644 index 0000000..6d86421 --- /dev/null +++ b/scripts/rehearse-reset-authority.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Measure reset rights on disposable native peers; never contact production.""" + +import argparse +import hashlib +import json +import re +import runpy +import secrets +import urllib.parse +import uuid +from datetime import UTC, datetime +from pathlib import Path + +HERE = Path(__file__).resolve().parent +BASE = runpy.run_path(str(HERE / 'rehearse-native-password-reset.py')) +docker, container, archive, probe, configuration = ( + BASE[n] for n in ('docker', 'container', 'archive', 'probe', 'configuration')) + + +def set_password(anchor, token, target, password): + assert target in {'alice', 'bob', 'operator', 'appoperator', 'svc-other', 'svc-auth'} + return docker('exec', '-i', anchor, '/bin/sh', '-c', + 'read -r token; read -r LLDAP_USER_PASSWORD; export LLDAP_USER_PASSWORD; ' + 'exec /app/lldap_set_password --base-url http://127.0.0.1:17170 ' + '--token "$token" --username ' + target, + data=(token + '\n' + password + '\n').encode(), check=False).returncode + + +def rehearse(): + if not __debug__: + raise RuntimeError('acceptance_assertions_must_be_enabled') + context = json.loads(docker('context', 'inspect', 'default').stdout)[0] + assert context['Endpoints']['docker']['Host'] == 'unix:///var/run/docker.sock' + for image in (BASE['LLDAP'], BASE['AUTHELIA'], BASE['CLIENT']): + assert json.loads(docker('image', 'inspect', image).stdout)[0]['Id'] == image + name = 'csoc-reset-authority-' + uuid.uuid4().hex[:12] + auth = name + '-auth' + values = {key: secrets.token_urlsafe(24) for key in ( + 'admin_password', 'bind_password', 'old_password', 'new_password')} + targets = ['alice', 'bob', 'operator', 'appoperator', 'svc-other'] + result = {'scenarios': []} + try: + container(name, BASE['LLDAP'], 'none', { + 'LLDAP_JWT_SECRET_FILE': '/run/fixture/jwt', + 'LLDAP_LDAP_USER_PASS_FILE': '/run/fixture/admin-password', + 'LLDAP_LDAP_BASE_DN': 'dc=example,dc=test', + 'LLDAP_LDAP_HOST': '127.0.0.1', 'LLDAP_HTTP_HOST': '127.0.0.1'}) + docker('exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ + 'jwt': secrets.token_hex(32).encode(), + 'admin-password': values['admin_password'].encode(), + 'lldap.toml': ('database_url = "sqlite:///data/users.db?mode=rwc"\nkey_seed = ' + + json.dumps(secrets.token_hex(32)) + '\n').encode()})) + docker('exec', '-d', name, '/bin/sh', '-c', + 'exec /app/lldap run --config-file /run/fixture/lldap.toml ' + '> /run/fixture/server.log 2>&1') + prepared = probe(name, {**values, 'mode': 'bootstrap', 'authority_matrix': True}) + assert set_password(name, prepared['admin_token'], 'svc-auth', + values['bind_password']) == 0 + for role in ['lldap_strict_readonly', 'lldap_password_manager']: + probe(name, {**values, 'mode': 'bind_role', 'role': role}) + context = probe(name, {**values, 'mode': 'authority_context'}) + writes = probe(name, {**values, 'mode': 'write_boundaries'}) + scenario = {'bind_role': role, 'directory_writes': writes, + 'direct_password_changes': [], 'authelia_resets': []} + for target in targets: + assert set_password(name, context['admin_token'], target, + values['old_password']) == 0 + before = probe(name, {**values, 'mode': 'directory_authentication', + 'target': target}) + assert before == {'old_password_accepted': True, 'new_password_accepted': False} + code = set_password(name, context['service_token'], target, values['new_password']) + after = probe(name, {**values, 'mode': 'directory_authentication', + 'target': target}) + expected = role == 'lldap_password_manager' and target != 'operator' + assert (code == 0) is expected, 'unexpected_direct_password_right' + assert after == {'old_password_accepted': not expected, + 'new_password_accepted': expected} + scenario['direct_password_changes'].append({ + 'target': target, 'password_changed': expected, **after}) + # Separate fresh stores make reset/link checks independent of earlier scenarios. + for target in ['alice', 'bob', 'operator', 'appoperator']: + assert set_password(name, context['admin_token'], target, + values['old_password']) == 0 + container(auth, BASE['AUTHELIA'], 'container:' + name, {}) + docker('exec', '-i', auth, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ + 'configuration.yml': json.dumps( + configuration(values['bind_password'])).encode()})) + docker('exec', '-d', auth, '/bin/sh', '-c', + 'exec /app/authelia --config /run/fixture/configuration.yml ' + '> /run/fixture/server.log 2>&1') + requested = probe(name, {**values, 'mode': 'reset_request', 'target': target}) + notification = docker( + 'exec', auth, 'cat', '/data/notifications.txt').stdout.decode() + links = re.findall(r'https://auth\.recovery\.test/reset-password/step2\?[^\s<>]+', + notification) + assert len(links) == 1, 'one_native_challenge_required' + token = urllib.parse.parse_qs(urllib.parse.urlsplit(links[0]).query)['token'][0] + settled = probe(name, {**values, 'mode': 'reset_finish', 'target': target, + 'reset_token': token}) + expected = role == 'lldap_password_manager' and target != 'operator' + assert settled['new_password_accepted_after'] is expected + assert settled['old_password_accepted_after'] is not expected + assert (settled['status'] == 'reset_completed') is expected + scenario['authelia_resets'].append({'target': target, **requested, **settled}) + docker('rm', '-f', '-v', auth) + result['scenarios'].append(scenario) + finally: + for current in [name + '-probe', auth, name]: + docker('rm', '-f', '-v', current, check=False) + result['cleanup_verified'] = all(not docker( + 'ps', '-aq', '--filter', 'name=^/' + n + '$').stdout.strip() + for n in [name + '-probe', auth, name]) + if not result['cleanup_verified']: + raise RuntimeError('fixture_cleanup_requires_review') + assert not any(value in json.dumps(result) for value in values.values()) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + manifest_path = args.output.with_suffix('.sources.txt') + if args.output.exists() or manifest_path.exists(): + raise SystemExit('Fresh receipt and source manifest required') + findings = rehearse() + paths = [Path(__file__), HERE / 'rehearse-native-password-reset.py', + HERE / 'native_recovery/probe.py'] + manifest = ''.join(str(p.relative_to(HERE.parent)) + ': ' + + hashlib.sha256(p.read_bytes()).hexdigest() + '\n' for p in paths).encode() + receipt = {'schema': 'coulomb.native-reset-authority-matrix.v1', + 'observed_at': datetime.now(UTC).isoformat(), 'data': 'synthetic', + 'status': 'native_authority_matrix_verified', + 'images': {n: BASE[n.upper()] for n in ('lldap', 'authelia', 'client')}, + 'production_access': False, 'provider_smtp_contacted': False, + 'owner_runtime_or_permission_change_performed': False, + 'no_ports_host_mounts_or_external_routes': True, + 'backend_container_flags_verified_before_fixture_import': True, + 'source_manifest_sha256': hashlib.sha256(manifest).hexdigest(), + 'target_classes': {'alice': 'ordinary user', 'bob': 'other ordinary user', + 'operator': 'lldap_admin member', + 'appoperator': 'application-admins member, not lldap_admin', + 'svc-other': 'other service, lldap_strict_readonly'}, + 'findings': findings} + with manifest_path.open('xb') as stream: + stream.write(manifest) + with args.output.open('x') as stream: + json.dump(receipt, stream, indent=2) + stream.write('\n') + print(json.dumps({'status': receipt['status'], 'roles': len(findings['scenarios']), + 'cleanup_verified': findings['cleanup_verified']})) + + +if __name__ == '__main__': + main() diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index 39d68bf..efd0c01 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -266,6 +266,17 @@ Evidence: docs/release/reset-owner-return-assessment-2026-10-31.json. No credent mail, password, directory grant or live configuration was changed. Prior personal reset acceptance is preserved with its original date and scope. +2026-10-31 authority preparation: owner T11 records the native role matrix; +T10 is progress. Password-manager permits ordinary/application-admin/non-admin +service password changes but refuses LLDAP administrators. Seven other directory +writes per role refuse with valid admin controls. Ten direct cases and eight +Authelia flows pass; all containers removed. Full gate546 and original reset +regression pass. Detailed scope and next admission conditions are in +native-password-reset-review-2026-10-30.md and reset-authority-validation-2026-10-31.json. +A complete runtime proposal still needs current subject classification and a +supported directory-admin recovery route. T06 remains progress, with no live +grant, reset or productive acceptance inferred. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. From 028f813434255e8c318b4b89189bd16056b20e4f Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 13:54:50 +0100 Subject: [PATCH 06/39] Bind reset observation to accepted account and prepare operator steps Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- ...native-password-reset-review-2026-10-30.md | 43 ++++++++++- ...reset-observer-preparation-2026-10-31.json | 77 +++++++++++++++++++ ...P-0015-communication-runtime-acceptance.md | 14 +++- 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 docs/release/reset-observer-preparation-2026-10-31.json diff --git a/docs/release/native-password-reset-review-2026-10-30.md b/docs/release/native-password-reset-review-2026-10-30.md index b337791..5a45d57 100644 --- a/docs/release/native-password-reset-review-2026-10-30.md +++ b/docs/release/native-password-reset-review-2026-10-30.md @@ -154,8 +154,8 @@ classification has been inferred from these fixtures. Owner T11 records this completed rehearsal; T10 retains the supported runtime path. Before an executable proposal, obtain owner-authorized, value-free classification of the intended reset subjects and current bind memberships. Review a service-owned -password-write grant against SI-3 and its complete non-LLDAP-admin reach, and a -separate supported recovery route for directory administrators. Preserve the +password-write grant against SI-3 and its complete non-LLDAP-admin reach, and, if the accepted subject is a directory administrator, a +supported recovery route for that subject. Preserve the existing human-admin separation and MFA requirements. A role change alone is not a complete proposal, and no runtime grant or policy exception is approved here. @@ -167,3 +167,42 @@ scenarios still pass; the full `make check` gate passes 546 tests with zero failures/errors/skips. These checks add neither productive acceptance nor a current membership observation. Existing expiry/browser/app proof retains its separate scope; this matrix does not retest the complete issuer/MFA journey. + + +## Current-role observation for the accepted account + +The October6 acceptance record names **tegwick** +(`history/20261006T185411Z-personal-workspace-acceptance.md`). Decision +8770d4ea-8538-4f88-a9f8-8bcfb0542603 corrects the earlier inference that a new route +for all directory administrators must precede this consumer's remedy. Check +tegwick's current membership first. If it is an LLDAP administrator, its supported +recovery path is required; otherwise retain the administrator exclusion under +existing NetKingdom recovery ownership. The admitted password-write scope still +requires explicit review. This preserves the accepted workflow and adds no grant. + +The fixed-subject observer is prepared and tested (owner T12); actual observation +is owner T13. Run these commands in your own workstation terminal: + +```bash +install -d -m 700 /home/worsch/.local/state/net-kingdom/reset-authority +python3 /home/worsch/net-kingdom/sso-mfa/k8s/lldap/reset-authority-observe.py \ + --receipt /home/worsch/.local/state/net-kingdom/reset-authority/roles-20261031.json +``` + +Enter the **human admin account's password** at the hidden prompt. The tool already +selects tegwick, svc-authelia and admin; it asks for no username, token or backup +credential. Do not paste the password into chat. Return only its JSON result. +The existing NetKingdom bind helper uses this human-held input for authenticated +reads; the new observer reuses its bounded transport without credential discovery. + +Expected status is `current_reset_roles_observed`. It creates a mode0600 local +receipt, signs in as admin and performs three read queries. It changes no group, +password, declaration or deployment, sends no reset email, and grants nothing. +TLS failures and unexpected/missing users refuse; do not bypass verification. +An existing receipt refuses without overwrite. A successful result proves current +role metadata only, not Authelia reset success, app authorization or acceptance. + +Evidence: reset-observer-preparation-2026-10-31.json. The observer and shared +transport pass92 checks; four native owner cases pass and the final observer node +passes with unchanged directory metadata and verified fixture cleanup. Production +observation has not been performed during preparation. diff --git a/docs/release/reset-observer-preparation-2026-10-31.json b/docs/release/reset-observer-preparation-2026-10-31.json new file mode 100644 index 0000000..dde7d60 --- /dev/null +++ b/docs/release/reset-observer-preparation-2026-10-31.json @@ -0,0 +1,77 @@ +{ + "schema": "netkingdom.reset-observer-preparation.v1", + "status": "tested_ready_for_attended_read", + "owner_task": "NK-WP-0054-T12", + "operator_return_task": "NK-WP-0054-T13", + "consumer_task": "CSOC-WP-0015-T06", + "accepted_subject": "tegwick", + "acceptance_source": { + "repo": "coulomb-social", + "path": "history/20261006T185411Z-personal-workspace-acceptance.md", + "sha256": "15ac9e1af4d0cb16b854d1ecf5fa89b634b1479174b02143b5c341cb3b5df2c3" + }, + "technical_binding_decision_id": "8770d4ea-8538-4f88-a9f8-8bcfb0542603", + "queries": [ + "human admin role", + "svc-authelia roles", + "tegwick roles" + ], + "private_input": "human admin password via terminal only", + "directory_mutations_available": false, + "production_observation_performed": false, + "password_reset_attempted": false, + "grant_approved": false, + "target_url": "https://lldap.coulomb.social", + "planned_private_receipt": "/home/worsch/.local/state/net-kingdom/reset-authority/roles-20261031.json", + "gates": { + "unit_and_transport": { + "tests": 92, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "7ee639ba82c7100d7805cc01ca5c2a6c502550eac4abdb252ad3c7d8b1ac232b" + }, + "native_split_regression": { + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "b35645a61c52f45d0e826e3986145212f45d4abc065026b42bd651f6f4b5fb41" + }, + "final_native_observer": { + "tests": 1, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "251831ae820081aa6e935d08c4e7854a1ad5cf840b8a41ae8af4add5f5406f2a" + } + }, + "native_fixture": "pinned owner LLDAP fixture; synthetic credentials, loopback-only published ports; container and volume removal verified", + "lint": "passed", + "sources": [ + { + "path": "sso-mfa/k8s/lldap/reset-authority-observe.py", + "sha256": "9f19c5551e2a93319c04c1b2fd0ddc0f7757fdff96ad18b81d7ee7bb393b75b9" + }, + { + "path": "sso-mfa/k8s/lldap/lldap_admin.py", + "sha256": "5e39e2eac53846a97cd25151c0a4af1ffc0230db881a6325efb66de6a51bfc36" + }, + { + "path": "identity-provisioner/provisioner.py", + "sha256": "a303a25d3b3876485c796a9ba0836ddaf0a0bec155b609790e028882ad983138" + }, + { + "path": "identity-provisioner/group_provenance.py", + "sha256": "405e08eaaab7c21d618dff4db5970607806f32ab6c461effb81c737e32182b10" + }, + { + "path": "tests/test_reset_authority_observer.py", + "sha256": "348fab447a4182357bf3f7d058b05c7d6088ae084d8e433e76ce5c39c513e152" + }, + { + "path": "sso-mfa/k8s/lldap/test_bind_split.py", + "sha256": "ae69051a3df4596fe220306d88a28b2c12e42fbfe3587e02135e3684eb5f354a" + } + ] +} diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index efd0c01..4720b1e 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -273,10 +273,20 @@ writes per role refuse with valid admin controls. Ten direct cases and eight Authelia flows pass; all containers removed. Full gate546 and original reset regression pass. Detailed scope and next admission conditions are in native-password-reset-review-2026-10-30.md and reset-authority-validation-2026-10-31.json. -A complete runtime proposal still needs current subject classification and a -supported directory-admin recovery route. T06 remains progress, with no live +A complete runtime proposal still needs current subject classification and, if +that subject is an LLDAP administrator, its supported recovery route. T06 remains progress, with no live grant, reset or productive acceptance inferred. +2026-10-31 observation preparation: the retained personal acceptance identifies +tegwick. Technical decision8770d4ea binds the current-role check to that exact +account and makes administrator recovery conditional on its actual role, preserving +the original contract. Owner T12 is done: a fixed read-only observer passes92 +unit/transport checks and native LLDAP classification/metadata-preservation checks. +T13 waits for the human admin's private terminal authentication and credential-free +JSON return. The concrete command and limits are in the native-reset review; +reset-observer-preparation-2026-10-31.json binds sources/tests. T06 remains progress; +no actual directory-role observation, password change or grant is claimed. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. From 776dfc20a0b53bdd3efaa7a2b825d5e093f20aef Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 14:00:36 +0100 Subject: [PATCH 07/39] Verify attended reset-role receipt for accepted personal account Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- ...native-password-reset-review-2026-10-30.md | 14 +++++ .../reset-role-observation-2026-10-31.json | 52 +++++++++++++++++++ ...t-role-return-verification-2026-10-31.json | 18 +++++++ ...P-0015-communication-runtime-acceptance.md | 10 ++++ 4 files changed, 94 insertions(+) create mode 100644 docs/release/reset-role-observation-2026-10-31.json create mode 100644 docs/release/reset-role-return-verification-2026-10-31.json diff --git a/docs/release/native-password-reset-review-2026-10-30.md b/docs/release/native-password-reset-review-2026-10-30.md index 5a45d57..67cc175 100644 --- a/docs/release/native-password-reset-review-2026-10-30.md +++ b/docs/release/native-password-reset-review-2026-10-30.md @@ -206,3 +206,17 @@ Evidence: reset-observer-preparation-2026-10-31.json. The observer and shared transport pass92 checks; four native owner cases pass and the final observer node passes with unchanged directory metadata and verified fixture cleanup. Production observation has not been performed during preparation. + + +### Attended observation received + +The founder completed the read at12:57:44Z on October31. Independent local receipt +verification confirms its private custody, exact subject/endpoint and all four +prepared source hashes. svc-authelia has only strict-readonly and no additional +groups. tegwick has no built-in directory privilege role and one other group. +Thus the directory-admin exclusion does not block the accepted personal flow on +this observed state. Owner T13 is complete; T10 prepares the reviewed transition, +including the full non-admin reach established by the native matrix. No grant, +reset, app authorization or productive acceptance is contained in this receipt. +Do not rerun the completed observer command. Evidence: reset-role-observation- +2026-10-31.json and reset-role-return-verification-2026-10-31.json. diff --git a/docs/release/reset-role-observation-2026-10-31.json b/docs/release/reset-role-observation-2026-10-31.json new file mode 100644 index 0000000..0abb06c --- /dev/null +++ b/docs/release/reset-role-observation-2026-10-31.json @@ -0,0 +1,52 @@ +{ + "schema": "netkingdom.reset-role-observation.v1", + "status": "current_reset_roles_observed", + "observed_at": "2026-10-31T12:57:44.913538+00:00", + "endpoint_sha256": "21b7c63da95eb01178305e49af9b3143295607b8ad68c522d1fa9545b7ef4d9f", + "selected_subject_sha256": "835958a092b4fcddf93eadb83dc2d21519ed1aeab570d04a3d798a4c0f24c0ba", + "human_admin_role_verified": true, + "bind": { + "builtin_roles": { + "lldap_admin": false, + "lldap_password_manager": false, + "lldap_strict_readonly": true + }, + "other_group_count": 0, + "matches_reviewed_service_name_filter": true, + "application_authority_assessed": false + }, + "selected_subject": { + "builtin_roles": { + "lldap_admin": false, + "lldap_password_manager": false, + "lldap_strict_readonly": false + }, + "other_group_count": 1, + "matches_reviewed_service_name_filter": false, + "application_authority_assessed": false + }, + "bind_has_multiple_builtin_roles": false, + "membership_changed": false, + "password_reset_attempted": false, + "grant_approved": false, + "application_access_verified": false, + "credential_values_recorded": false, + "sources": [ + { + "path": "sso-mfa/k8s/lldap/reset-authority-observe.py", + "sha256": "9f19c5551e2a93319c04c1b2fd0ddc0f7757fdff96ad18b81d7ee7bb393b75b9" + }, + { + "path": "sso-mfa/k8s/lldap/lldap_admin.py", + "sha256": "5e39e2eac53846a97cd25151c0a4af1ffc0230db881a6325efb66de6a51bfc36" + }, + { + "path": "identity-provisioner/provisioner.py", + "sha256": "a303a25d3b3876485c796a9ba0836ddaf0a0bec155b609790e028882ad983138" + }, + { + "path": "identity-provisioner/group_provenance.py", + "sha256": "405e08eaaab7c21d618dff4db5970607806f32ab6c461effb81c737e32182b10" + } + ] +} diff --git a/docs/release/reset-role-return-verification-2026-10-31.json b/docs/release/reset-role-return-verification-2026-10-31.json new file mode 100644 index 0000000..c9bdef0 --- /dev/null +++ b/docs/release/reset-role-return-verification-2026-10-31.json @@ -0,0 +1,18 @@ +{ + "schema": "coulomb.reset-role-return-verification.v1", + "status": "attended_role_receipt_verified", + "observed_at": "2026-10-31T12:57:44.913538+00:00", + "private_receipt_sha256": "bde2c45eda858ea317febfabefe1bd254b62c218c66fd4fb0cbec41db61fb788", + "private_file_owner_mode_single_link_verified": true, + "exact_endpoint_and_accepted_subject_verified": true, + "all_four_prepared_source_hashes_verified": true, + "bind_role": "lldap_strict_readonly", + "bind_other_groups": 0, + "accepted_subject_is_lldap_admin": false, + "accepted_subject_has_other_groups": 1, + "grant_approved": false, + "password_reset_attempted": false, + "owner_task": "NK-WP-0054-T13", + "owner_task_id": "278d5ba8-1c2a-58d9-a24e-2233af2c1aef", + "consumer_task": "CSOC-WP-0015-T06" +} diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index 4720b1e..ce411ce 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -287,6 +287,16 @@ JSON return. The concrete command and limits are in the native-reset review; reset-observer-preparation-2026-10-31.json binds sources/tests. T06 remains progress; no actual directory-role observation, password change or grant is claimed. +2026-10-31 attended role return: founder supplied the12:57:44Z observation. +Independent private-file and source-hash verification passes; owner T13 is done. +svc-authelia has only strict-readonly, while the accepted tegwick subject is not +an LLDAP administrator (one other group, name not disclosed). This resolves the +classification dependency. T10 can prepare the reviewed password-manager transition; +its broad non-admin reach remains part of admission. No directory-admin recovery +route is needed for this accepted flow on the observed roles, and no new policy, +password change or productive reset acceptance is inferred. Evidence: +reset-role-observation-2026-10-31.json and reset-role-return-verification-2026-10-31.json. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. From d5adc4b998387821589dd37b332e90b1705973a9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 14:26:17 +0100 Subject: [PATCH 08/39] Review reset authority transition and same-process role refresh Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- ...native-password-reset-review-2026-10-30.md | 109 +++++++++++++ .../reset-role-refresh-2026-10-31.json | 76 +++++++++ .../reset-role-refresh-2026-10-31.sources.txt | 3 + ...t-transition-consumer-gate-2026-10-31.json | 12 ++ ...eset-transition-validation-2026-10-31.json | 74 +++++++++ scripts/rehearse-reset-role-refresh.py | 147 ++++++++++++++++++ ...P-0015-communication-runtime-acceptance.md | 10 ++ 7 files changed, 431 insertions(+) create mode 100644 docs/release/reset-role-refresh-2026-10-31.json create mode 100644 docs/release/reset-role-refresh-2026-10-31.sources.txt create mode 100644 docs/release/reset-transition-consumer-gate-2026-10-31.json create mode 100644 docs/release/reset-transition-validation-2026-10-31.json create mode 100644 scripts/rehearse-reset-role-refresh.py diff --git a/docs/release/native-password-reset-review-2026-10-30.md b/docs/release/native-password-reset-review-2026-10-30.md index 67cc175..716d0b7 100644 --- a/docs/release/native-password-reset-review-2026-10-30.md +++ b/docs/release/native-password-reset-review-2026-10-30.md @@ -220,3 +220,112 @@ including the full non-admin reach established by the native matrix. No grant, reset, app authorization or productive acceptance is contained in this receipt. Do not rerun the completed observer command. Evidence: reset-role-observation- 2026-10-31.json and reset-role-return-verification-2026-10-31.json. + + +## Reviewed role-transition proposal — 2026-10-31 + +**Prepared, not admitted or executed.** Owner preparation is NK-WP-0054-T14; +founder admission/execution is T15; declaration alignment and productive reset plus +fresh identity restore acceptance are T16. T10 and CSOC-WP-0015-T06 remain open. + +Proposal: `sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f`. Exact controller and plan are +`/home/worsch/net-kingdom/sso-mfa/k8s/lldap/reset-role-transition.py` and +`reset-role-plan.json`. The digest binds the controller, existing transport and +observer sources, current role observation, native rights matrix, pinned runtime +identities/configuration and final validation. No new resources or recurring +capacity are requested; no credential rotation, service rename or restart is +included. Both pinned workloads were Ready when observed. + +### Decision and scope + +The proposed change replaces **only svc-authelia's strict-readonly membership** +with **lldap_password_manager**. This restores the native password-write capability +needed by the accepted tegwick recovery flow. The role can change passwords for +**all non-LLDAP-admin directory identities**, including other service accounts and +application administrators outside lldap_admin. It is not limited to Coulomb or to +human accounts. LLDAP administrators are excluded. Seven tested unrelated writes +remain refused; this is not an exhaustive rights audit. Both roles already permit +own-profile/password operations in the pinned [LLDAP access-control source](https://github.com/lldap/lldap/blob/v0.6.2/crates/auth/src/access_control.rs). + +The same dedicated SERVICE account and single Authelia consumer remain. The plan +contains the proposed declaration: directory read USE plus narrowly described +password ADMINISTER scope, without the read-only bind convention. SI-3 permits a +service-owned write account with its narrowest working scope. The canonical +read-only declaration has not been changed to imply approval. Following a verified +grant, T16 must align it and existing bind verification/repair expectations. Do +not rerun the historical read-only bind migration as reset verification. + +**Rollback limit:** membership rollback restores fresh Authelia reset denial, but +it does not revoke previously issued HTTP tokens or undo changed user passwords. +A native test proves a token obtained during the grant can still change an eligible +password after membership rollback; a freshly issued token cannot. The pinned +[LLDAP token implementation](https://github.com/lldap/lldap/blob/v0.6.2/server/src/auth_service.rs) +embeds groups in a token with a one-day expiry. Treat retained token authority as +lasting up to 24 hours unless separately revoked. This packet includes no token +revocation or incident-containment operation. Admission includes this limitation. + +### Execution and recovery + +Run in the founder's workstation terminal only after accepting the scope above: + +```bash +python3 /home/worsch/net-kingdom/sso-mfa/k8s/lldap/reset-role-transition.py \ + --execute --expect-digest sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f +``` + +The hidden prompt requires the human admin password. Never paste it into chat. +After reviewing the printed scope, enter exactly: + +```text +GRANT sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f +``` + +The controller checks the exact live cluster/namespace/deployment identities, +images and Authelia configuration before private authentication and again after +confirmation. It rechecks the human admin role, tegwick's non-admin classification +and svc-authelia's exact baseline before writing. The old role is removed before +the new one is added; a brief directory lookup failure is possible. Every effect +has a durable intent record. All user IDs and group memberships are compared after +each step; unknown concurrent changes fail verification. Profile fields/passwords +are not copied into the receipt or claimed as independently compared. + +Success is `password_manager_role_verified`. Paste only the returned JSON; Codex +will verify the durable mode0600 receipt in +`~/.local/state/net-kingdom/reset-authority/753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f/journal.json`. +An existing attempt refuses replay. Do not delete its state or repeat the grant. +If the operation is interrupted or reports uncertain status, restore read-only +membership with this separate attended recovery: + +```bash +python3 /home/worsch/net-kingdom/sso-mfa/k8s/lldap/reset-role-transition.py \ + --recover --expect-digest sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f +``` + +Confirm `RECOVER sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f`. For an already completed successful +grant, use `--rollback` instead of `--recover` and confirm +`ROLLBACK sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f`. Both restore only the role and retain +previous journal versions. They preserve unrelated subsequent memberships and +user password changes. Recovery tolerates workload unready status but still +requires pinned identities, images and configuration; runtime drift requires +review, not a bypass. If the directory itself is unreachable, recovery cannot +claim completion until the owner restores access. These are role restoration +commands with the token limitation above, not full credential revocation. + +### Evidence and remaining acceptance + +25 unit checks and four native controller checks pass with no skips/failures. +The native checks include real process termination immediately after removal and +addition, successful recovery without replay, and the retained-token boundary. +A separate confined native rehearsal uses **one running Authelia process** through +read-only → password-manager → read-only: reset refuses, succeeds, then refuses; +old/new passwords and consumed challenge denial are checked each time. No restart +is needed in this tested configuration. Synthetic fixtures and generated passwords +are removed; no provider SMTP or productive password reset was used. + +Evidence: `reset-transition-validation-2026-10-31.json`, +`reset-role-refresh-2026-10-31.json` and its source manifest. A successful role +receipt still leaves T16: source alignment, productive tegwick reset/app/MFA and +fresh identity backup/restore evidence. It does not complete the four goal workplans. + +The final consumer `make check` gate passes546 tests (zero failures/skips;69 +existing warnings). Receipt: reset-transition-consumer-gate-2026-10-31.json. diff --git a/docs/release/reset-role-refresh-2026-10-31.json b/docs/release/reset-role-refresh-2026-10-31.json new file mode 100644 index 0000000..81b4e98 --- /dev/null +++ b/docs/release/reset-role-refresh-2026-10-31.json @@ -0,0 +1,76 @@ +{ + "schema": "coulomb.native-reset-role-refresh.v1", + "observed_at": "2026-10-31T13:23:16.597535+00:00", + "data": "synthetic", + "status": "native_role_refresh_verified", + "images": { + "lldap": "sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d", + "authelia": "sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54", + "client": "sha256:512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd" + }, + "production_access": false, + "provider_smtp_contacted": false, + "owner_runtime_or_permission_change_performed": false, + "no_ports_host_mounts_or_external_routes": true, + "backend_container_flags_verified_before_fixture_import": true, + "source_manifest_sha256": "f3c6d7b5a286d15d3049bba7e4c543052388f6a95a5e3254414f3b51da1285d0", + "findings": { + "scenarios": [ + { + "bind_role": "lldap_strict_readonly", + "same_authelia_process": true, + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + }, + { + "bind_role": "lldap_password_manager", + "same_authelia_process": true, + "status": "reset_completed", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": null, + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": false, + "new_password_accepted_after": true + }, + { + "bind_role": "lldap_strict_readonly", + "same_authelia_process": true, + "status": "reset_refused", + "only_loopback_interface": true, + "external_routes_absent": true, + "old_password_initially_accepted": true, + "new_password_initially_refused": true, + "native_first_factor": true, + "interactive_service_login_refused": true, + "reset_http_status": 200, + "reset_response_status": "KO", + "native_identity_finish": true, + "reset_without_verified_challenge_refused": true, + "consumed_identity_token_replay_refused": true, + "old_password_accepted_after": true, + "new_password_accepted_after": false + } + ], + "cleanup_verified": true + } +} diff --git a/docs/release/reset-role-refresh-2026-10-31.sources.txt b/docs/release/reset-role-refresh-2026-10-31.sources.txt new file mode 100644 index 0000000..8cc15a6 --- /dev/null +++ b/docs/release/reset-role-refresh-2026-10-31.sources.txt @@ -0,0 +1,3 @@ +scripts/rehearse-reset-role-refresh.py: c84751c3ed2893ef25460401ab760f109ac8502b308aae120990d402f85ed42f +scripts/rehearse-native-password-reset.py: 133c8230c524fbbaf7f66e4872e14b826ed2c5a4f4de3bc47fa86cfef7631d5d +scripts/native_recovery/probe.py: eab3bae55626abd4920eb6aa2e329c83559007c54ac74c3aaa1cf41e84eb6556 diff --git a/docs/release/reset-transition-consumer-gate-2026-10-31.json b/docs/release/reset-transition-consumer-gate-2026-10-31.json new file mode 100644 index 0000000..d1eb5e7 --- /dev/null +++ b/docs/release/reset-transition-consumer-gate-2026-10-31.json @@ -0,0 +1,12 @@ +{ + "schema": "coulomb.reset-transition-consumer-gate.v1", + "command": "make check", + "passed": 546, + "failures": 0, + "skipped": 0, + "warnings": 69, + "elapsed_seconds": 55.15, + "junit_sha256": "717412e60cbe12d52c4a14f124fbe60a59789bc52467c0b938645c2b28c24232", + "proposal_digest": "sha256:753f50f139a0d090a8b64ba15174f2a7e8b0e639eb0dd01d17783476b5a3759f", + "production_grant_performed": false +} diff --git a/docs/release/reset-transition-validation-2026-10-31.json b/docs/release/reset-transition-validation-2026-10-31.json new file mode 100644 index 0000000..c1710c3 --- /dev/null +++ b/docs/release/reset-transition-validation-2026-10-31.json @@ -0,0 +1,74 @@ +{ + "schema": "netkingdom.reset-transition-validation.v1", + "status": "tested_proposal_not_granted", + "unit_checks": 25, + "native_controller_checks": 4, + "failures": 0, + "errors": 0, + "skipped": 0, + "junit_sha256": "26911e26b410c242f3dafb1915301875a4bfd3f5d88770be3b7bec5ce4c5c3d5", + "ruff": "passed", + "native_findings": [ + "grant and rollback preserve all other user IDs and group memberships", + "real process death after removal or addition recovers read-only without replaying grant", + "membership rollback leaves prior HTTP token rights active; freshly issued token loses those rights" + ], + "authelia_findings": "same running process refuses reset before grant, allows after grant, refuses after rollback; three fresh consumed challenges, old/new password checks; all confined containers removed", + "runtime_observation": { + "schema": "netkingdom.reset-runtime.v1", + "kube-system_uid": "a553c742-0115-43d4-99a4-a5ca56fe0786", + "sso_uid": "a4a148d8-92ab-41e6-b975-4e67b73edfc0", + "authelia": { + "uid": "dc40a925-2306-4a3b-89c0-e714187503c3", + "images": [ + "docker.io/authelia/authelia@sha256:bd97cff4fcbf715b5ff1f9ae286afbe6033afce385302520b0368122d43a6f54" + ], + "ready": true + }, + "lldap": { + "uid": "743a86bd-5cb0-4ca0-9c56-a3438f4746a8", + "images": [ + "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d" + ], + "ready": true + }, + "authelia_configuration": { + "uid": "7af1fa0d-1bd4-47e1-8873-0f620788ef3f", + "sha256": "0a8bca50641a4c6f27830f476c2c9f413fc3795711becccb8344ee4d6c376e80", + "bind_is_svc_authelia": true + } + }, + "production_membership_changed": false, + "grant_approved": false, + "productive_acceptance": false, + "sources": [ + { + "path": "sso-mfa/k8s/lldap/reset-role-transition.py", + "sha256": "cc405861bb5cc0adf419a1cf98a90872d8395eecbfc7d4d38947cb4008bee217" + }, + { + "path": "sso-mfa/k8s/lldap/reset-role-plan.json", + "sha256": "703e281ee6a17e1ef0a1dffbad35b8025ab3480d51d3fc2c36323f2bc6227106" + }, + { + "path": "tests/test_reset_role_transition.py", + "sha256": "1437761ac60a7aeba16db70ccf07d48a9f5332617a00e7a325e678951985a08e" + }, + { + "path": "sso-mfa/k8s/lldap/test_reset_role_transition_native.py", + "sha256": "13c53e8a757ed0e954ffd28df14283d51dd9d7d3278c1d7d7d5759b419a2a89a" + }, + { + "path": "sso-mfa/k8s/lldap/test_bind_split.py", + "sha256": "ae69051a3df4596fe220306d88a28b2c12e42fbfe3587e02135e3684eb5f354a" + }, + { + "path": "sso-mfa/k8s/lldap/bind-split-rehearsal.py", + "sha256": "9ab873d1e941807fb19a48fb18c3af246a98bf0788660d8d79a14fb0c0e0a067" + }, + { + "path": "docs/evidence/2026-10-31-reset-authority/role-refresh.json", + "sha256": "325f446960134e9510591a2a27f5833bc80fa858d77e6a88082af41307993b65" + } + ] +} diff --git a/scripts/rehearse-reset-role-refresh.py b/scripts/rehearse-reset-role-refresh.py new file mode 100644 index 0000000..8a38167 --- /dev/null +++ b/scripts/rehearse-reset-role-refresh.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Verify role changes with one running Authelia process; synthetic peers only.""" + +import argparse +import hashlib +import json +import re +import runpy +import secrets +import urllib.parse +import uuid +from datetime import UTC, datetime +from pathlib import Path + +HERE = Path(__file__).resolve().parent +BASE = runpy.run_path(str(HERE / 'rehearse-native-password-reset.py')) +docker, container, archive, probe, configuration = ( + BASE[n] for n in ('docker', 'container', 'archive', 'probe', 'configuration')) + + +def set_password(anchor, token, target, password): + assert target in {'alice', 'bob', 'operator', 'appoperator', 'svc-other', 'svc-auth'} + return docker('exec', '-i', anchor, '/bin/sh', '-c', + 'read -r token; read -r LLDAP_USER_PASSWORD; export LLDAP_USER_PASSWORD; ' + 'exec /app/lldap_set_password --base-url http://127.0.0.1:17170 ' + '--token "$token" --username ' + target, + data=(token + '\n' + password + '\n').encode(), check=False).returncode + + +def rehearse(): + if not __debug__: + raise RuntimeError('acceptance_assertions_must_be_enabled') + context = json.loads(docker('context', 'inspect', 'default').stdout)[0] + assert context['Endpoints']['docker']['Host'] == 'unix:///var/run/docker.sock' + for image in (BASE['LLDAP'], BASE['AUTHELIA'], BASE['CLIENT']): + assert json.loads(docker('image', 'inspect', image).stdout)[0]['Id'] == image + name = 'csoc-reset-transition-' + uuid.uuid4().hex[:12] + auth = name + '-auth' + values = {key: secrets.token_urlsafe(24) for key in ( + 'admin_password', 'bind_password', 'old_password', 'new_password')} + result = {'scenarios': []} + try: + container(name, BASE['LLDAP'], 'none', { + 'LLDAP_JWT_SECRET_FILE': '/run/fixture/jwt', + 'LLDAP_LDAP_USER_PASS_FILE': '/run/fixture/admin-password', + 'LLDAP_LDAP_BASE_DN': 'dc=example,dc=test', + 'LLDAP_LDAP_HOST': '127.0.0.1', 'LLDAP_HTTP_HOST': '127.0.0.1'}) + docker('exec', '-i', name, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ + 'jwt': secrets.token_hex(32).encode(), + 'admin-password': values['admin_password'].encode(), + 'lldap.toml': ('database_url = "sqlite:///data/users.db?mode=rwc"\nkey_seed = ' + + json.dumps(secrets.token_hex(32)) + '\n').encode()})) + docker('exec', '-d', name, '/bin/sh', '-c', + 'exec /app/lldap run --config-file /run/fixture/lldap.toml ' + '> /run/fixture/server.log 2>&1') + prepared = probe(name, {**values, 'mode': 'bootstrap', 'authority_matrix': True}) + assert set_password(name, prepared['admin_token'], 'svc-auth', + values['bind_password']) == 0 + container(auth, BASE['AUTHELIA'], 'container:' + name, {}) + docker('exec', '-i', auth, 'tar', '-xf', '-', '-C', '/run/fixture', data=archive({ + 'configuration.yml': json.dumps(configuration(values['bind_password'])).encode()})) + # One Authelia process for all three phases, including rollback. + probe(name, {**values, 'mode': 'bind_role', 'role': 'lldap_strict_readonly'}) + docker('exec', '-d', auth, '/bin/sh', '-c', + 'exec /app/authelia --config /run/fixture/configuration.yml ' + '> /run/fixture/server.log 2>&1') + process_marker = None + tokens = set() + for index, role in enumerate(['lldap_strict_readonly', 'lldap_password_manager', + 'lldap_strict_readonly']): + if index: + probe(name, {**values, 'mode': 'bind_role', 'role': role}) + assert set_password(name, prepared['admin_token'], 'alice', + values['old_password']) == 0 + docker('exec', auth, 'rm', '-f', '/data/notifications.txt') + requested = probe(name, {**values, 'mode': 'reset_request', 'target': 'alice'}) + marker = docker('exec', auth, '/bin/sh', '-c', + 'for p in /proc/[0-9]*; do ' + 'if [ "$(cat "$p/comm" 2>/dev/null)" = authelia ]; ' + 'then cat "$p/stat"; fi; done').stdout + # /proc stat changes CPU counters; compare PID and start ticks only. + fields = marker.decode().split() + current_marker = (fields[0], fields[21]) + if process_marker is None: + process_marker = current_marker + assert current_marker == process_marker, 'authelia_process_restarted' + notification = docker('exec', auth, 'cat', '/data/notifications.txt').stdout.decode() + links = re.findall(r'https://auth\.recovery\.test/reset-password/step2\?[^\s<>]+', + notification) + assert len(links) == 1, ('one_new_native_challenge_required_phase_' + + str(index) + '_count_' + str(len(links))) + token = urllib.parse.parse_qs(urllib.parse.urlsplit(links[-1]).query)['token'][0] + assert token not in tokens, 'fresh_challenge_required' + tokens.add(token) + settled = probe(name, {**values, 'mode': 'reset_finish', 'target': 'alice', + 'reset_token': token}) + expected = role == 'lldap_password_manager' + assert settled['new_password_accepted_after'] is expected + assert settled['old_password_accepted_after'] is not expected + assert (settled['status'] == 'reset_completed') is expected + result['scenarios'].append({'bind_role': role, 'same_authelia_process': True, + **requested, **settled}) + finally: + for current in [name + '-probe', auth, name]: + docker('rm', '-f', '-v', current, check=False) + result['cleanup_verified'] = all(not docker( + 'ps', '-aq', '--filter', 'name=^/' + n + '$').stdout.strip() + for n in [name + '-probe', auth, name]) + if not result['cleanup_verified']: + raise RuntimeError('fixture_cleanup_requires_review') + assert not any(value in json.dumps(result) for value in values.values()) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + manifest_path = args.output.with_suffix('.sources.txt') + if args.output.exists() or manifest_path.exists(): + raise SystemExit('Fresh receipt and source manifest required') + findings = rehearse() + paths = [Path(__file__), HERE / 'rehearse-native-password-reset.py', + HERE / 'native_recovery/probe.py'] + manifest = ''.join(str(p.relative_to(HERE.parent)) + ': ' + + hashlib.sha256(p.read_bytes()).hexdigest() + '\n' for p in paths).encode() + receipt = {'schema': 'coulomb.native-reset-role-refresh.v1', + 'observed_at': datetime.now(UTC).isoformat(), 'data': 'synthetic', + 'status': 'native_role_refresh_verified', + 'images': {n: BASE[n.upper()] for n in ('lldap', 'authelia', 'client')}, + 'production_access': False, 'provider_smtp_contacted': False, + 'owner_runtime_or_permission_change_performed': False, + 'no_ports_host_mounts_or_external_routes': True, + 'backend_container_flags_verified_before_fixture_import': True, + 'source_manifest_sha256': hashlib.sha256(manifest).hexdigest(), + 'findings': findings} + with manifest_path.open('xb') as stream: + stream.write(manifest) + with args.output.open('x') as stream: + json.dump(receipt, stream, indent=2) + stream.write('\n') + print(json.dumps({'status': receipt['status'], 'roles': len(findings['scenarios']), + 'cleanup_verified': findings['cleanup_verified']})) + + +if __name__ == '__main__': + main() diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index ce411ce..058a24a 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -300,3 +300,13 @@ reset-role-observation-2026-10-31.json and reset-role-return-verification-2026-1 ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. + + +2026-10-31 reset transition preparation: owner T14 prepares the source-bound, +crash-recoverable role controller; T15 explicitly holds founder admission and +execution; T16 holds source alignment, productive reset/app/MFA and identity +backup/restore acceptance. The same running Authelia process picks up grant and +rollback in the confined native fixture. Membership rollback does not revoke +HTTP tokens issued during a grant; the review includes their up-to-24-hour scope. +No production role/password changed. T06 remains progress and consumes owner T10, +whose acceptance is preserved rather than closed by fixture proof. From 5fefc0108dfec1b662f93a43d8353fff612ab03d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 14:40:57 +0100 Subject: [PATCH 09/39] Resolve service forge and identity decision provenance Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- .../release/goal-progress-audit-2026-10-31.md | 11 ++ ...service-contract-conformance-2026-10-27.md | 9 +- ...service-provenance-binding-2026-10-31.json | 130 ++++++++++++++++++ ...ce-provenance-reconciliation-2026-10-30.md | 42 ++++++ ...0012-durable-recovery-service-readiness.md | 32 ++++- 5 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 docs/release/service-provenance-binding-2026-10-31.json diff --git a/docs/release/goal-progress-audit-2026-10-31.md b/docs/release/goal-progress-audit-2026-10-31.md index a0592fa..684a31f 100644 --- a/docs/release/goal-progress-audit-2026-10-31.md +++ b/docs/release/goal-progress-audit-2026-10-31.md @@ -139,3 +139,14 @@ is complete and waits on T04 historical custody/capacity and T07. T04 remains in progress for those native returns and historical-copy disposition. The original roughly 40% remaining estimate is unchanged: these source fixes do not establish recurring operation, productive acceptance or full remaining-cohort transfer. + + +## Service provenance checkpoint + +WP-0012-T14 now closes the Forgejo successor and existing founder identity-choice +bindings formerly inside T11. The records include resolved decisions, completed +owner migration and fresh read-only native metadata. T11 advances to progress and +retains actual ops registration, client/app/instance finance attribution/report, +current SLO and stage-1 smoke. This makes the source checklist13of32done; it is an +additional independently verified requirement, not a new product scope or an +updated effort estimate. The rough remaining-work estimate is unchanged. diff --git a/docs/release/service-contract-conformance-2026-10-27.md b/docs/release/service-contract-conformance-2026-10-27.md index 4b5a8b0..7d63f4c 100644 --- a/docs/release/service-contract-conformance-2026-10-27.md +++ b/docs/release/service-contract-conformance-2026-10-27.md @@ -8,8 +8,8 @@ evidence from the returns required to finish WP-0012/0014. | Section | Current evidence | Remaining proof | | --- | --- | --- | | 1: shape/tenancy | Standalone repo, `coulomb_social_db`, dedicated deployment/values in railiance-apps. Space/Member tenant keys and explicit membership joins; tests deny foreign tenant/member access. Helm revision 18 applies migration 0003 and verifies global and tenant slug constraints with unchanged business data. | Reconcile all source grants and prove complete tenant export/consolidation without ambiguous paths; global uniqueness alone does not certify a multi-client migration. | -| 2: identity | OIDC/PKCE behind `apps/identity`; app-local users have unusable passwords. Accepted ADR-0001 specializes the local-account baseline. Historical Case B and personal recovery are recorded in docs/identity/smoke.md and the October 6 receipts. | Current registration Case B and source-cohort identity/grant return. Link the governing State Hub decision for the ADR specialization before claiming §8.3 deviation acceptance; the ADR alone does not establish that link. | -| 3: build/release | Founder storage release verified at Helm revision 18, immutable digest `51281946…`, source `17d0eb6`; all 73 packaged files match. Deployment-owner values and receipt are published at `8434134`, with public health/static and 40 owner/40 outsider checks passing. | Later source changes need their own current-target release acceptance. Old contract §3.2 names Gitea; current delivery uses Forgejo. Resolve the fleet successor provenance rather than silently treating that as identical. | +| 2: identity | OIDC/PKCE behind `apps/identity`; app-local users have unusable passwords. Accepted ADR-0001 specializes the local-account baseline. Historical Case B and personal recovery are recorded in docs/identity/smoke.md and the October 6 receipts. | Current registration Case B and source-cohort identity/grant return. The authentication choice is now bound to existing founder decision8bbce691 under §8.3 (T14); broader ADR implementation and current productive identity returns retain their own gates. | +| 3: build/release | Founder storage release verified at Helm revision 18, immutable digest `51281946…`, source `17d0eb6`; all 73 packaged files match. Deployment-owner values and receipt are published at `8434134`, with public health/static and 40 owner/40 outsider checks passing. | Later source changes need their own current-target release acceptance. Forgejo successor provenance is reconciled under T14 with existing founder decisions and completed owner migration evidence; this does not prove build reproducibility or later release acceptance. | | 4: operations | Deployment Ready, health endpoint/runbooks. October 29 protected provider-only image/tools/pair recovery proved all tables/files and packaged app views in 30.232s, with independent escrow and retained proof. October 30 read-only native operating preflight identifies missing recurring worker mirror receipt; both timers remain absent/inactive. | Current ops-hub client/environment/endpoint registration evidence; admitted recurring *paired* backup with matching mirrored point/receipt, retention/headroom and received failed/stale signals; post-release stage-1 CRUD/copy/transfer smoke and CSOC-IN-0004 reconciliation. Provider-only recovery excludes operator-account recovery and a fresh Docker daemon. Database-only backup does not cover CONTENT_ROOT. | | 5: cost | Resource inventory ID `resource:tenant:coulomb:coulomb-social` (not a valid fin-hub client attribution key), dated workload/demand/labor records in docs/resource-evidence; WP-0017 records current requests 15m/176Mi. | Obtain the authoritative client × app × instance mapping and demonstrate its canonical fin-hub key in current finance ingestion and per-instance infrastructure cost report. Planning labor rates and CPU requests are not booked costs. | | 6: protection | Platform instance isolation, explicit read/write denial, sanitized Markdown, private images, account-local JSON export and successor-aware local exit. October 30 source adds a private operator tenant-dataset ZIP export and schema-derived personal-data inventory; review in client-data-export-review-2026-10-30.md. No credentials copied into these records. | Release and verify coherent current-target client export, resolve external/unattributed data, and agree instance teardown/session/log disposition plus primary/mirror/protected-kit backup expiry (§6.2). Source support does not establish productive offboarding. Productive sharing/portability verdicts and retained-data disposition remain WP-0013. | @@ -54,3 +54,8 @@ files and four record sets without source changes. This closes the release and coherent-export proof portions of §§3/6, not private delivery/offboarding, recurring service evidence, operator ownership, productive verdicts or successor provenance. Evidence: identity-export-release-return-2026-10-31.md. + +October31 provenance reconciliation closes the two previously missing decision/ +successor links under T14. See service-provenance-binding-2026-10-31.json and the +latest section of service-provenance-reconciliation-2026-10-30.md. All remaining +service registration, finance, operating and productive gates above remain open. diff --git a/docs/release/service-provenance-binding-2026-10-31.json b/docs/release/service-provenance-binding-2026-10-31.json new file mode 100644 index 0000000..00e3635 --- /dev/null +++ b/docs/release/service-provenance-binding-2026-10-31.json @@ -0,0 +1,130 @@ +{ + "schema": "coulomb.service-provenance-binding.v1", + "observed_at": "2026-10-31T13:38:26.593082+00:00", + "status": "forge_and_identity_provenance_bound", + "owner_task": "CSOC-WP-0012-T14", + "remaining_service_task": "CSOC-WP-0012-T11", + "native_readonly": { + "observed_at": "2026-10-31T13:38:26.593082+00:00", + "deployment": { + "uid": "2dbe661a-d232-4477-bbd1-833813077fbe", + "images": [ + "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed" + ], + "ready_replicas": 1 + }, + "forge_version": { + "http_status": 200, + "version": "11.0.3+gitea-1.22.0", + "tls_verified": true + } + }, + "source_commit": "23ffe2cf4b214666754a2420558943438e54251a", + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed", + "migration_workplan": { + "id": "84e17675-0d15-4268-a8bd-540124d37018", + "record_id": "RAIL-HO-WP-0005", + "hub_status": "finished", + "legacy_disposition_task": "a63147b0-31d5-4705-89ea-40c10faf779f", + "legacy_disposition_hub_status": "done" + }, + "governing_decisions": [ + { + "id": "6bf3700e-f9fa-4e4e-ab11-2c4e0b10d4d3", + "title": "Forgejo hostname and exposure model", + "status": "resolved", + "decision_type": "made", + "description": "WP-0005 T02 needs the production Forgejo hostname, private/public exposure model, and whether legacy Gitea remains reachable during transition.", + "rationale": "the hostname shall be forgejo.coulomb.social. The exposure model is private repos by default. We will use the transition from gitea to forgejo for closing down public access and establishing credential handling to have convenience when working with the repos. Gitea can and should remain reachable during transition.", + "decided_by": "human", + "decided_at": "2026-07-02T19:19:29.160890Z" + }, + { + "id": "804c1d54-e63f-4d88-8d85-a095a7a9e956", + "title": "Forgejo package registry scope", + "status": "resolved", + "decision_type": "made", + "description": "WP-0005 T02/T07 need the initial package registry scope: container images and generic packages only, or also npm, PyPI, Go, Maven, and Helm packages.", + "rationale": "We should support the full range from the start.", + "decided_by": "human", + "decided_at": "2026-07-02T19:21:18.314775Z" + }, + { + "id": "0cc6f748-c5c0-4ff1-9722-f59e6cd03fa5", + "title": "Forgejo cutover and rollback strategy", + "status": "resolved", + "decision_type": "made", + "description": "WP-0005 T02/T11/T12 need the cutover mode, freeze window, staged vs all-at-once migration choice, rollback window, and criteria for retiring or retaining legacy Gitea.", + "rationale": "We will do a staged migration and lock the gitea repos of transitioned repos but keep gitea until everything has been transfered and just then start a 14day trial phase and after that retire gitea to the backup.", + "decided_by": "human", + "decided_at": "2026-07-02T19:26:16.298551Z" + }, + { + "id": "8bbce691-a787-4140-9cd4-df06e8635a27", + "title": "Approve tegwick personal sign-in repair and persistent Coulomb app release", + "status": "resolved", + "decision_type": "made", + "description": "Prepared review: docs/release/personal-account-2026-10-06.md. Restore historical public coulomb-social OIDC client (authorization_code/PKCE, exact app/apex/loopback callbacks, scopes openid profile email groups, historical ordinary AAL1 mfaRequired=false), CAS patch preserving other clients/Secret data; restart shared sso/keycape (in-flight sign-ins may be interrupted). Then fresh apps-pg CNPG backup, Helm atomic upgrade chart0.2.0 with image digest sha256:bf975939e22b9b0a5d9ac05584ae6265ba201169df6dc48e506ec684b30a9c58, retained 2Gi local-path content PVC, one replica/Recreate. Existing account/DB/env Secret references retained. No real-data import or apex cutover. Requires founder exact go: ADMINISTER @ realm:kubernetes/railiance01, activation=APPROVED. Inspection/dry-runs/tests complete; no live mutations performed.", + "rationale": "Founder explicitly replied: Go on and deploy the interruption is not a problem. Approved exact reviewed client restoration, KeyCape restart, fresh database backup and persistent digest-pinned app upgrade. ADMINISTER @ realm:kubernetes/railiance01, activation=APPROVED. No apex cutover or real-data import.", + "decided_by": "Bernd Worsch (founder)", + "decided_at": "2026-10-06T14:53:19.566637Z" + } + ], + "conclusions": { + "forge_successor_binding_resolved": true, + "legacy_registry_string_is_historical": true, + "legacy_retirement_executed_here": false, + "new_runtime_operation_authorized": false, + "identity_deployment_approval_found": true, + "ops_registration_verified": false, + "finance_mapping_verified": false, + "current_slo_attainment_verified": false, + "full_service_conformance": false, + "identity_authentication_choice_bound_to_existing_founder_decision": true, + "all_adr_statements_implemented_or_approved": false + }, + "limitations": [ + "Migration closeout retained Gitea as read-only fallback; no current legacy teardown claim. July2 decision mentions14days; July9 source records30days. This reconciliation establishes successor ownership, not expiry or retirement permission.", + "October6 founder decision approves the exact OIDC client restoration and app release; it is not evidence that every ADR-0001 statement or service-contract demand trigger was separately assessed.", + "Live observations are deployment/version metadata only; existing retained packaged-source equality proof has its original scope." + ], + "sources": [ + { + "path": "the-custodian/canon/standards/business-app-service-contract_v0.1.md", + "sha256": "83d2799d39f957d88aa1d71c3f04f7c2a0e168390249dc409ce149128d44e80c" + }, + { + "path": "the-custodian/canon/standards/coulombcore-production-freeze_v0.1.md", + "sha256": "8ad6aaa96a23b3498e94d350e7bfac12b0cab49736b7e5929b17a870d2fdd96d" + }, + { + "path": "the-custodian/docs/forgejo-production-decisions.md", + "sha256": "0b9174e28cf056a857f209b09d6cc7cf9d1296d8a118992b17290ec3945cb1b0" + }, + { + "path": "railiance-infra/workplans/archived/260714-RAIL-HO-WP-0005-forgejo-production-migration.md", + "sha256": "c93ae0d5f50afed4f625e3534844ee8ff426d12991327c14b86232e0ffa38485" + }, + { + "path": "railiance-apps/helm/coulomb-social-values.yaml", + "sha256": "63a45d1cfb395eff7cfdf21ed2100fb9562b7a2ab6b896afecbebfaabacae547" + }, + { + "path": "coulomb-social/registry/release-units/coulomb-social.yaml", + "sha256": "309102bdee9d0ec0057c5d679be729ebd7aa3477d588962c2e757a38946761f6" + }, + { + "path": "coulomb-social/docs/release/identity-export-release-return-2026-10-31.md", + "sha256": "e78222c650ff3939bb8e8f550e9b75534c9f43badea612a4ea3a2792fba82d01" + }, + { + "path": "coulomb-social/docs/adr/ADR-0001-netkingdom-identity.md", + "sha256": "7f606904418a1693b80cfda2655d4728ab32b636409ab96b1cd30278bb61a7c0" + }, + { + "path": "coulomb-social/docs/release/personal-account-2026-10-06.md", + "sha256": "2eb5f37a6d9165b6166b175de3323a88cc090f89edc8a30a873ef26537c5e3e0" + } + ], + "technical_binding_decision_id": "7b68c574-aa61-4517-bb31-1ecda554c3e8" +} diff --git a/docs/release/service-provenance-reconciliation-2026-10-30.md b/docs/release/service-provenance-reconciliation-2026-10-30.md index 30d6106..41cd36b 100644 --- a/docs/release/service-provenance-reconciliation-2026-10-30.md +++ b/docs/release/service-provenance-reconciliation-2026-10-30.md @@ -147,3 +147,45 @@ record/mapping and actual per-instance finance report remain required. Native observations and source hashes: `service-owner-boundary-2026-10-31.json` and its `.sources.txt` companion. + + +## October 31: successor and identity decision bindings resolved + +CSOC-WP-0012-T14 records this completed evidence reconciliation. T11 retains the +remaining service contract work. Technical decision +`7b68c574-aa61-4517-bb31-1ecda554c3e8` links existing approvals; it grants no new +runtime action or policy exception. + +The contract's Gitea registry address is historical. Founder decisions +`6bf3700e-f9fa-4e4e-ab11-2c4e0b10d4d3` (Forgejo hostname/exposure), +`804c1d54-e63f-4d88-8d85-a095a7a9e956` (registry scope) and +`0cc6f748-c5c0-4ff1-9722-f59e6cd03fa5` (migration) establish the intended successor. +The active CoulombCore production-freeze canon identifies railiance01 Forgejo as +the canonical forge/registry target. Archived owner RAIL-HO-WP-0005 records completed +cutover and deliberate legacy read-only fallback; State Hub independently returns +finished for that plan and done for its T12. The July2 decision's14-day window and +July9 source's30-day window differ: neither is treated here as permission to delete +legacy state or proof of subsequent teardown. + +Fresh read-only observations at13:38:26Z confirm one Ready Coulomb deployment on +image522bcd7f from `forgejo.coulomb.social/coulomb/coulomb-social`. Its UID, current +release-unit and deployment-owner values match the retained revision19 return. +The anonymous TLS-verified forge version endpoint returns200 and +`11.0.3+gitea-1.22.0`; that compatibility suffix does not make it the retired Gitea +instance. The retained80-file source equality proof keeps its original scope; +this observation does not rebuild the image or prove reproducible dependencies. + +For identity, the missing governing link is the existing founder decision +`8bbce691-a787-4140-9cd4-df06e8635a27`: it explicitly approved the exact historical +NetKingdom OIDC/PKCE client restoration and Coulomb app release. Link this recorded +choice to ADR-0001's authentication specialization under contract§8.3. Requiring +the same product authentication choice to be approved again would disregard that +recorded approval. This does not assert that a§2.4 demand trigger occurred, that +all ADR statements are implemented, or that every authorization/MFA behavior has +been accepted. The later password-reset role grant remains separately unapproved. + +Evidence: `service-provenance-binding-2026-10-31.json` retains selected decisions, +source hashes, native metadata and explicit limits. These two provenance items +are resolved. Authenticated ops registration, actual client/app/instance finance +mapping and current report, current SLO attainment and stage-1 smoke reconciliation +remain T11; no full service conformance is claimed. diff --git a/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md b/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md index 621c3d0..81c896b 100644 --- a/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md +++ b/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md @@ -696,12 +696,12 @@ After T04, T07–T09 and RAPPS-WP-0015-T07, activate the already approved 03:15 ```task id: CSOC-WP-0012-T11 -status: todo +status: progress priority: high state_hub_task_id: "61f5f196-40e4-5901-a10e-318304809a00" ``` -Remaining T03 service-contract §§1–5 evidence: authenticated ops registration, canonical client × app × instance finance mapping and current ingestion/report, Forgejo successor provenance and governing identity-specialization decision, current SLO evidence, and CSOC-IN-0004 stage-1 smoke reconciliation. Consume existing identity/cohort returns from WP-0014/0015 without duplicating owner implementations. Use `docs/release/service-contract-conformance-2026-10-27.md` and `service-provenance-reconciliation-2026-10-30.md`. Resource requests and planning labor are not booked costs. +Remaining T03 service-contract §§1–5 evidence: authenticated ops registration, canonical client × app × instance finance mapping and current ingestion/report, current SLO evidence, and CSOC-IN-0004 stage-1 smoke reconciliation. T14 separately closes the Forgejo successor and existing identity-decision provenance bindings; that source/evidence milestone does not close this operational acceptance task. Consume existing identity/cohort returns from WP-0014/0015 without duplicating owner implementations. Use `docs/release/service-contract-conformance-2026-10-27.md` and `service-provenance-reconciliation-2026-10-30.md`. Resource requests and planning labor are not booked costs. ## T12 — Agree and prove complete client offboarding and expiry @@ -747,3 +747,31 @@ check includes protected browser journeys. No production operation, frozen histo capsule or pending repair/installation/route packet was changed. Future reviewed capsules carry these sources; the existing eight-entry repair remains pending T04, and native recurring evidence remains T09. This task closes source prevention only. + + +## T14 — Reconcile Forgejo successor and identity decision provenance + +```task +id: CSOC-WP-0012-T14 +status: done +priority: medium +needs_human: false +state_hub_task_id: "77c11ad0-3271-563a-9114-750b1e1cee16" +``` + +Completed2026-10-31. Extracted from T11 at its verified boundary, without counting +T11 as another completed outcome. Resolved founder migration/registry decisions, +active freeze canon, completed RAIL-HO-WP-0005 and its independently read-back +Hub state establish Forgejo as the successor to the contract's historical Gitea +address. Fresh TLS/version and read-only deployment metadata match the current +revision19 source/image records; no legacy teardown permission is inferred. + +Existing founder decision8bbce691 explicitly approves Coulomb's NetKingdom +OIDC/PKCE client restoration and app release; it supplies the recorded link for +ADR-0001's authentication choice under service-contract§8.3. Technical decision +7b68c574-aa61-4517-bb31-1ecda554c3e8 binds this evidence without new admission or +claims about all ADR statements, demand triggers, or the pending reset-role grant. +Evidence: docs/release/service-provenance-binding-2026-10-31.json and the latest +service-provenance reconciliation. T11 remains progress for registration, finance, +current SLO and stage-1 smoke; no runtime, credentials, cost facts or acceptance +were fabricated or changed by this audit. From 000ca43f0cbd68c17434a592e30ae5f9c5501b00 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 14:42:05 +0100 Subject: [PATCH 10/39] Record goal task projection readback and updated index Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 3 +- ...service-provenance-binding-2026-10-31.json | 29 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 20f7520..4744f9c 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -96,9 +96,10 @@ | task | CSOC-WP-0012-T08 | wait | — | workplans/CSOC-WP-0012-durable-recovery-service-readiness.md | | task | CSOC-WP-0012-T09 | todo | — | workplans/CSOC-WP-0012-durable-recovery-service-readiness.md | | task | CSOC-WP-0012-T10 | todo | — | workplans/CSOC-WP-0012-durable-recovery-service-readiness.md | -| task | CSOC-WP-0012-T11 | todo | — | workplans/CSOC-WP-0012-durable-recovery-service-readiness.md | +| task | CSOC-WP-0012-T11 | progress | — | workplans/CSOC-WP-0012-durable-recovery-service-readiness.md | | task | CSOC-WP-0012-T12 | todo | — | workplans/CSOC-WP-0012-durable-recovery-service-readiness.md | | task | CSOC-WP-0012-T13 | done | — | workplans/CSOC-WP-0012-durable-recovery-service-readiness.md | +| task | CSOC-WP-0012-T14 | done | — | workplans/CSOC-WP-0012-durable-recovery-service-readiness.md | | task | CSOC-WP-0013-T01 | done | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | | task | CSOC-WP-0013-T02 | progress | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | | task | CSOC-WP-0013-T03 | todo | — | workplans/CSOC-WP-0013-sharing-content-hardening.md | diff --git a/docs/release/service-provenance-binding-2026-10-31.json b/docs/release/service-provenance-binding-2026-10-31.json index 00e3635..fc0d63b 100644 --- a/docs/release/service-provenance-binding-2026-10-31.json +++ b/docs/release/service-provenance-binding-2026-10-31.json @@ -126,5 +126,32 @@ "sha256": "2eb5f37a6d9165b6166b175de3323a88cc090f89edc8a30a873ef26537c5e3e0" } ], - "technical_binding_decision_id": "7b68c574-aa61-4517-bb31-1ecda554c3e8" + "technical_binding_decision_id": "7b68c574-aa61-4517-bb31-1ecda554c3e8", + "goal_task_projection_readback": { + "workplans": [ + { + "id": "CSOC-WP-0012", + "tasks": 14, + "done": 6 + }, + { + "id": "CSOC-WP-0013", + "tasks": 5, + "done": 2 + }, + { + "id": "CSOC-WP-0014", + "tasks": 7, + "done": 2 + }, + { + "id": "CSOC-WP-0015", + "tasks": 6, + "done": 3 + } + ], + "all_task_statuses_match_source": true, + "progress_event_id": "fb1cb027-4bcd-4844-9626-2e1c0640955c", + "source_and_native_validation_only": true + } } From 0c714d3c5650c4283b8aec2a9fbc9860fc1cc834 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 14:54:06 +0100 Subject: [PATCH 11/39] Preserve captured Bubble Visuals in private review trees Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- docs/migration/bubble-auth-and-export.md | 35 +++ .../bubble-visual-preparation-2026-10-31.json | 83 ++++++ .../release/goal-progress-audit-2026-10-31.md | 11 + docs/usecases/sharing-portability-review.md | 12 + scripts/capture-bubble-visuals.py | 277 ++++++++++++++++++ scripts/verify-bubble-visual-preservation.py | 115 ++++++++ scripts/verify-bubble-visuals.mjs | 49 ++++ tests/test_bubble_visuals.py | 157 ++++++++++ ...-WP-0014-cohort-transfer-bubble-offramp.md | 39 ++- 9 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 docs/release/bubble-visual-preparation-2026-10-31.json create mode 100644 scripts/capture-bubble-visuals.py create mode 100644 scripts/verify-bubble-visual-preservation.py create mode 100644 scripts/verify-bubble-visuals.mjs create mode 100644 tests/test_bubble_visuals.py diff --git a/docs/migration/bubble-auth-and-export.md b/docs/migration/bubble-auth-and-export.md index b40deae..01932ab 100644 --- a/docs/migration/bubble-auth-and-export.md +++ b/docs/migration/bubble-auth-and-export.md @@ -142,3 +142,38 @@ Set COULOMB_SOCIAL_CORPUS_DIR to a private directory if needed. Do not put raw exports in the checkout. Default target route is /vw_pages; override via COULOMB_SOCIAL_CAPTURE_ROUTE only after confirming the actual authenticated protocol surface. A successful public-route load is not authentication proof. + + +## Retained Visual capture and review bundle + +The October31 all-space bundle now has a separate private Visual preparation at +`~/.config/coulomb-social/auth/exports/visual-bundle-20261031`. The original +`review-bundle-20261031` remains unchanged.242Visual references were captured from +108URLs on the one recorded Bubble CDN, producing90distinct images.236draft +article pages and67space indexes link local hashed assets. All90images decode in +an offline browser; this is not a source/product appearance verdict or an import. + +The reusable collector is `scripts/capture-bubble-visuals.py ORIGINAL --output NEW`. +It requires a complete hash-bound original bundle, a new private directory outside +repositories, and the exact known CDN over HTTPS. It refuses redirects, alternate +origins, credentials in URLs, active/unknown image formats, source drift, oversized +responses and existing output. No stored browser credentials are read or sent. +A missing final reconciliation.json means partial output, which must be retained +for review rather than used as an import bundle. Original body/date bytes are not +rewritten; only the compiler's empty Visual metadata is replaced. + +Verification for the completed bundle (read-only): + +```bash +python3 scripts/verify-bubble-visual-preservation.py \ + ~/.config/coulomb-social/auth/exports/review-bundle-20261031 \ + ~/.config/coulomb-social/auth/exports/visual-bundle-20261031 +node scripts/verify-bubble-visuals.mjs \ + ~/.config/coulomb-social/auth/exports/visual-bundle-20261031 +``` + +Only aggregate results belong in Git/chat. Images, titles, source URLs and original +records remain private. The new bundle retains all71spaces and the existing +graph/grant/fidelity gates.18body links and one attachment flag are still +unreconciled. Captured Visual bytes do not authorize copying memberships or +productive import. Public proof: docs/release/bubble-visual-preparation-2026-10-31.json. diff --git a/docs/release/bubble-visual-preparation-2026-10-31.json b/docs/release/bubble-visual-preparation-2026-10-31.json new file mode 100644 index 0000000..fee8d0c --- /dev/null +++ b/docs/release/bubble-visual-preparation-2026-10-31.json @@ -0,0 +1,83 @@ +{ + "schema": "coulomb.bubble-visual-preparation.v1", + "status": "captured_and_privately_prepared_not_imported", + "owner_task": "CSOC-WP-0014-T08", + "private_bundle": "~/.config/coulomb-social/auth/exports/visual-bundle-20261031", + "source_report_sha256": "75fcd87d17ea0c5042e903efd60e65839da7716e5399ff431a345486ddd10f01", + "capture": { + "references": 242, + "unique_urls": 108, + "downloaded_bytes": 168139156, + "materialized_pages": 236, + "body_rewriting_performed": false, + "grants_changed": false, + "browser_render_verified": false, + "production_imported": false + }, + "browser": { + "schema": "coulomb.private-visual-decode.v1", + "status": "all_captured_visuals_decoded", + "decoded_images": 90, + "bundle_sha256": "a255df2237d95588b21a5db059487ca0178d1df28c810ebaaf744c5e6491267a", + "browser_network_disabled": true, + "productive_fidelity_accepted": false + }, + "preservation": { + "schema": "coulomb.visual-preservation-proof.v1", + "verified_output_files": 738, + "verified_original_files": 545, + "frontmatter_only_changes": 303, + "all_source_bodies_and_dates_preserved": true, + "spaces_preserved": 71, + "original_blockers_preserved": true, + "unassigned_records_preserved": true, + "private_modes_verified": true, + "private_bundle_sha256": "a255df2237d95588b21a5db059487ca0178d1df28c810ebaaf744c5e6491267a", + "production_imported": false + }, + "boundaries": { + "cdn_origins": 1, + "https_only": true, + "redirects_allowed": false, + "cookies_or_authorization_sent": false, + "live_application_or_grants_changed": false, + "source_completeness_or_fidelity_accepted": false, + "all_other_links_attachments_reconciled": false + }, + "tests": { + "focused_passed": 24, + "full_gate_passed": 570, + "failures": 0, + "errors": 0, + "skipped": 0, + "warnings": 69, + "full_junit_sha256": "d11afa225571077d1d59c68711a5f0eb402de8ceff7f2f50c279fbba7f923349", + "final_verification_script": "native private bundle pass; final all-repo Ruff pass" + }, + "remaining": { + "source_graph_exception_groups": 4, + "unassigned_article_records": 1, + "body_link_references": 18, + "attachment_review_flags": 1, + "identity_and_explicit_grants": "unresolved", + "productive_fidelity_verdict": "pending" + }, + "sources": [ + { + "path": "scripts/capture-bubble-visuals.py", + "sha256": "c16c8cf5013a6f02c9a9f79f444aea82ced7d8642b0fb2d2dc5315fabe92be7c" + }, + { + "path": "scripts/verify-bubble-visuals.mjs", + "sha256": "2cb9625b0738028508957b8bc40a69b7ab2719046d20e0a69a0ac1a9394f56c5" + }, + { + "path": "scripts/verify-bubble-visual-preservation.py", + "sha256": "9fbc212bd668126a0f6ce6a458b1ca3fba1e14d4b44438c0697f63dc34b5bc3d" + }, + { + "path": "tests/test_bubble_visuals.py", + "sha256": "707fc9aa4ac0de5e40d33ee76571d74c141b361154a659f10c723a6597262006" + } + ] +} diff --git a/docs/release/goal-progress-audit-2026-10-31.md b/docs/release/goal-progress-audit-2026-10-31.md index 684a31f..fc34226 100644 --- a/docs/release/goal-progress-audit-2026-10-31.md +++ b/docs/release/goal-progress-audit-2026-10-31.md @@ -150,3 +150,14 @@ retains actual ops registration, client/app/instance finance attribution/report, current SLO and stage-1 smoke. This makes the source checklist13of32done; it is an additional independently verified requirement, not a new product scope or an updated effort estimate. The rough remaining-work estimate is unchanged. + + +## Visual preservation checkpoint + +WP-0014-T08 completes the previously missing private Visual capture and draft-tree +materialization:242references,108URLs,90distinct images,236article pages and67 +space indexes. Offline decoding and independent body/date/source preservation +checks pass. This advances the source checklist to14of33done, not an effort +percentage. Source exceptions, identity/grant mapping, links/attachments and +productive transfer/verdicts remain open; the rough remaining-effort estimate +has not been recalculated from these counts. diff --git a/docs/usecases/sharing-portability-review.md b/docs/usecases/sharing-portability-review.md index 3abaf51..fe86fa8 100644 --- a/docs/usecases/sharing-portability-review.md +++ b/docs/usecases/sharing-portability-review.md @@ -136,3 +136,15 @@ bounds. Incremental provider cost is unpriced; no EUR autopilot budget is claime Application writes pause during paired capture and the Recreate rollout. Evidence: `../release/content-lifecycle-release-preparation-2026-10-31.json`, its proposal, native target/preflight/row-drift receipts, and the candidate recovery receipt. + + +### October31 Visual preservation follow-through + +A new private `~/.config/coulomb-social/auth/exports/visual-bundle-20261031` retains +242Visual references as90distinct local images and populates236draft article +Visuals plus67space indexes. The original rendering cards and source capture are +unchanged. Offline image decoding and exact body/date preservation pass; neither +proves appearance fidelity to Bubble. Use these retained local assets for the +source/product comparison after each mapping is ready. Graph exceptions, grants, +18body links and one attachment flag remain explicit. No productive import or +user verdict is inferred. Evidence: ../release/bubble-visual-preparation-2026-10-31.json. diff --git a/scripts/capture-bubble-visuals.py b/scripts/capture-bubble-visuals.py new file mode 100644 index 0000000..170ef44 --- /dev/null +++ b/scripts/capture-bubble-visuals.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Capture retained Bubble Visual URLs and prepare a private, hash-bound review bundle.""" + +import argparse +import copy +import hashlib +import json +import os +import re +import stat +import sys +import urllib.request +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import urlsplit + +CDN = '13a55be71f3a6a4c9ea356833df823c4.cdn.bubble.io' +MAX_ASSET = 16 * 1024 * 1024 +MAX_TOTAL = 256 * 1024 * 1024 +ROOT = Path(__file__).resolve().parents[1] + + +class Refused(Exception): + pass + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def canonical_url(value): + if not isinstance(value, str) or any(ord(c) <= 32 for c in value): + raise Refused('invalid_visual_url') + value = 'https:' + value if value.startswith('//') else value + try: + url = urlsplit(value) + valid = (url.scheme == 'https' and url.hostname == CDN and url.port in (None, 443) + and url.username is None and url.password is None and not url.query + and not url.fragment and url.path.startswith('/') and '\\' not in value) + except ValueError: + valid = False + if not valid: + raise Refused('outside_reviewed_visual_origin') + return value + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise Refused('visual_redirect_refused') + + +def image_extension(raw, media_type): + if raw.startswith(b'\x89PNG\r\n\x1a\n') and media_type == 'image/png': + return 'png' + if raw.startswith(b'\xff\xd8\xff') and media_type == 'image/jpeg': + return 'jpg' + if (len(raw) >= 12 and raw[:4] == b'RIFF' and raw[8:12] == b'WEBP' + and media_type == 'image/webp'): + return 'webp' + raise Refused('unsupported_visual_bytes_or_type') + + +def fetch(url): + url = canonical_url(url) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + request = urllib.request.Request(url, headers={'User-Agent': 'coulomb-visual-capture/1.0'}) + with opener.open(request, timeout=20) as response: + if response.status != 200 or response.geturl() != url: + raise Refused('unexpected_visual_response') + length = response.headers.get('Content-Length') + if length and (not length.isdigit() or int(length) > MAX_ASSET): + raise Refused('visual_size_limit') + raw = response.read(MAX_ASSET + 1) + if len(raw) > MAX_ASSET: + raise Refused('visual_size_limit') + if length and int(length) != len(raw): + raise Refused('incomplete_visual_response') + extension = image_extension(raw, response.headers.get_content_type()) + return raw, extension + + +def read_private(root, name, expected=None): + relative = Path(name) + if relative.is_absolute() or '..' in relative.parts or not relative.parts: + raise Refused('unsafe_bundle_path') + path = root / relative + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('bundle_symlink_refused') + info = path.stat() + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > MAX_ASSET): + raise Refused('private_bounded_source_required') + raw = path.read_bytes() + if expected is not None and sha(raw) != expected: + raise Refused('source_file_changed') + return raw + + +def publish(root, name, raw): + relative = Path(name) + if relative.is_absolute() or '..' in relative.parts or not relative.parts: + raise Refused('unsafe_output_path') + path = root / relative + if any(p.is_symlink() for p in (path, *path.parents)): + raise Refused('output_symlink_refused') + parent = root + for part in relative.parts[:-1]: + parent = parent / part + parent.mkdir(mode=0o700, exist_ok=True) + info = parent.stat() + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise Refused('private_output_directory_required') + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'wb') as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + if read_private(root, name) != raw: + raise Refused('capture_readback_failed') + + +def with_visual(raw, relative, *, index=False): + # Replace only the compiler's known empty field; never parse/reformat the body. + head, body = raw.split(b'\n---\n', 1) + if not head.startswith(b'---\n'): + raise Refused('draft_frontmatter_required') + new = b'visual: ' + json.dumps(relative).encode() + if index: + if re.search(rb'^visual:', head, re.M): + raise Refused('unexpected_index_visual') + head += b'\n' + new + else: + if len(re.findall(rb'^visual: ""$', head, re.M)) != 1: + raise Refused('empty_draft_visual_required') + head = re.sub(rb'^visual: ""$', lambda _: new, head, flags=re.M) + return head + b'\n---\n' + body + + +def capture(bundle, output, fetcher=fetch): + bundle = Path(os.path.abspath(bundle.expanduser())) + output = Path(os.path.abspath(output.expanduser())) + if (output.is_relative_to(ROOT) or output.is_relative_to(bundle) + or bundle.is_relative_to(output) + or any(p.is_symlink() for p in (output, *output.parents)) + or any((p / '.git').exists() for p in (output, *output.parents))): + raise Refused('private_separate_output_required') + parent = output.parent.stat() + if parent.st_uid != os.getuid() or parent.st_mode & 0o077: + raise Refused('private_owned_output_parent_required') + original = read_private(bundle, 'reconciliation.json') + report = json.loads(original) + if (report.get('schema') != 'coulomb.bubble-review-bundle.v1' + or report.get('status') != 'completed_offline_preparation'): + raise Refused('completed_original_bundle_required') + # Verify every declared file and the captured source bindings before any network read. + files = {name: read_private(bundle, name, digest) + for name, digest in report['file_sha256'].items()} + if any(not name.startswith(('source-bodies/', 'review-trees/')) for name in files): + raise Refused('unexpected_original_bundle_namespace') + for space in report['spaces']: + tree = space['tree'] + if space.get('import_ready') is not False: + raise Refused('unapproved_original_readiness') + if tree is None: + continue + if not re.fullmatch(r'review-trees/bubble-[0-9a-f]{24}', tree): + raise Refused('unexpected_review_tree_path') + for page in space['articles']: + if not re.fullmatch(re.escape(tree) + r'/pages/article-[0-9a-f]{24}\.md', + page['file']): + raise Refused('unexpected_review_page_path') + for name, digest in report['source_sha256'].items(): + files['source-capture/' + name + '.json'] = read_private( + bundle, 'source-capture/' + name + '.json', digest) + visuals = {} + for article in report['articles']: + refs = [ref['source'] for ref in article['references'] if ref['kind'] == 'visual'] + if len(refs) > 1: + raise Refused('ambiguous_visual_reference') + if refs: + visuals[article['id']] = canonical_url(refs[0]) + output.mkdir(mode=0o700) # Existing output always refuses; never replace a capture. + captured, total = {}, 0 + for url in sorted(set(visuals.values())): + raw, extension = fetcher(url) + if len(raw) > MAX_ASSET or total + len(raw) > MAX_TOTAL: + raise Refused('visual_capture_budget_exceeded') + total += len(raw) + name = 'captured-visuals/' + sha(raw) + '.' + extension + if name not in files: + files[name] = raw + captured[url] = {'file': name, 'sha256': sha(raw), 'bytes': len(raw)} + # Checkpoint captured bytes immediately; no final report means incomplete output. + if not (output / name).exists(): + publish(output, name, raw) + updated = copy.deepcopy(report) + by_id = {a['id']: a for a in updated['articles']} + materialized = 0 + for space in updated['spaces']: + tree = space['tree'] + if tree is None: + continue + for page in space['articles']: + url = visuals.get(page['id']) + if not url: + continue + visual = captured[url] + relative = 'assets/' + Path(visual['file']).name + files[tree + '/' + relative] = files[visual['file']] + files[page['file']] = with_visual(files[page['file']], relative) + page['file_sha256'] = sha(files[page['file']]) + materialized += 1 + if page['id'] == space['root_article_id']: + index = tree + '/pages/index.md' + files[index] = with_visual(files[index], relative, index=True) + # Keep all unresolved gates: retained bytes do not certify source/UI fidelity or grants. + space['import_ready'] = False + for identity, url in visuals.items(): + article = by_id[identity] + article['captured_visual'] = captured[url] + for ref in article['references']: + if ref['kind'] == 'visual': + ref['capture_status'] = 'bytes_retained_and_hashed' + ref['resolved'] = False + # Recheck the immutable inputs after fetching; source drift cannot publish completion. + if read_private(bundle, 'reconciliation.json') != original: + raise Refused('source_report_changed_during_capture') + for name, digest in report['file_sha256'].items(): + read_private(bundle, name, digest) + for name, digest in report['source_sha256'].items(): + read_private(bundle, 'source-capture/' + name + '.json', digest) + files['source-capture/reconciliation-before-visuals.json'] = original + for name, raw in sorted(files.items()): + if not name.startswith('captured-visuals/'): + publish(output, name, raw) + updated.update(status='completed_private_visual_preparation', + original_report_sha256=sha(original), + captured_at=datetime.now(UTC).isoformat(), + visual_capture={'references': len(visuals), 'unique_urls': len(captured), + 'downloaded_bytes': total, 'materialized_pages': materialized, + 'body_rewriting_performed': False, 'grants_changed': False, + 'browser_render_verified': False, 'production_imported': False}, + network_requests=len(captured), + file_sha256={name: sha(raw) for name, raw in sorted(files.items())}) + for directory in sorted([output, *(p for p in output.rglob('*') if p.is_dir())], + key=lambda p: len(p.parts), reverse=True): + fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + raw_report = (json.dumps(updated, indent=2, sort_keys=True) + '\n').encode() + publish(output, 'reconciliation.json', raw_report) + fd = os.open(output, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + return updated['visual_capture'] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('bundle', type=Path) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + try: + print(json.dumps(capture(args.bundle, args.output))) + except Exception: # Private URLs, titles and provider errors never enter stdout/stderr. + print('Visual capture refused or incomplete; retain any partial output for review. ' + 'No final reconciliation.json means it is not a completed bundle.', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/verify-bubble-visual-preservation.py b/scripts/verify-bubble-visual-preservation.py new file mode 100644 index 0000000..ca4587c --- /dev/null +++ b/scripts/verify-bubble-visual-preservation.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Independently verify Visual-only frontmatter changes against the original private bundle.""" + +import argparse +import hashlib +import json +import os +import re +from pathlib import Path + + +def read(root, name): + path = root / name + if ( + Path(name).is_absolute() + or ".." in Path(name).parts + or any(p.is_symlink() for p in (path, *path.parents)) + ): + raise ValueError("unsafe source path") + info = path.stat() + if ( + not path.is_file() + or info.st_uid != os.getuid() + or info.st_mode & 0o077 + or info.st_nlink != 1 + or info.st_size > 16 * 1024 * 1024 + ): + raise ValueError("private bounded source required") + return path.read_bytes() + + +def verify(source, out): + if not __debug__: + raise ValueError("verification assertions required") + old = json.loads(read(source, "reconciliation.json")) + new = json.loads(read(out, "reconciliation.json")) + def sha(raw): + return hashlib.sha256(raw).hexdigest() + assert new["original_report_sha256"] == sha(read(source, "reconciliation.json")) + assert read(out, "source-capture/reconciliation-before-visuals.json") == read( + source, "reconciliation.json" + ) + for name, digest in new["file_sha256"].items(): + assert sha(read(out, name)) == digest + changed = 0 + for name, digest in old["file_sha256"].items(): + original = read(source, name) + current = read(out, name) + assert sha(original) == digest + if current != original: + assert "/pages/" in name and name.endswith(".md") + before, body = original.split(b"\n---\n", 1) + after, after_body = current.split(b"\n---\n", 1) + assert body == after_body + assert ( + len(re.findall(rb'^visual: "assets/[a-f0-9]{64}\.(?:png|jpg|webp)"$', after, re.M)) + == 1 + ) + if name.endswith("/index.md"): + restored = re.sub( + rb'\nvisual: "assets/[a-f0-9]{64}\.(?:png|jpg|webp)"$', b"", after + ) + else: + restored = re.sub( + rb'^visual: "assets/[a-f0-9]{64}\.(?:png|jpg|webp)"$', + b'visual: ""', + after, + flags=re.M, + ) + assert restored == before + changed += 1 + for axis in ["unassigned_article_ids", "source_sha256"]: + assert new[axis] == old[axis] + assert len(old["spaces"]) == len(new["spaces"]) + for a, b in zip(old["spaces"], new["spaces"], strict=True): + assert a["blockers"] == b["blockers"] and not b["import_ready"] + for key in a: + if key != "articles": + assert a[key] == b[key], key + for x, y in zip(a["articles"], b["articles"], strict=True): + assert {k: v for k, v in x.items() if k != "file_sha256"} == { + k: v for k, v in y.items() if k != "file_sha256" + } + for p in out.rglob("*"): + assert p.stat().st_mode & 0o077 == 0 + return { + "schema": "coulomb.visual-preservation-proof.v1", + "verified_output_files": len(new["file_sha256"]), + "verified_original_files": len(old["file_sha256"]), + "frontmatter_only_changes": changed, + "all_source_bodies_and_dates_preserved": True, + "spaces_preserved": len(new["spaces"]), + "original_blockers_preserved": True, + "unassigned_records_preserved": True, + "private_modes_verified": True, + "private_bundle_sha256": sha(read(out, "reconciliation.json")), + "production_imported": False, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("original", type=Path) + parser.add_argument("prepared", type=Path) + args = parser.parse_args() + try: + print(json.dumps(verify(args.original, args.prepared))) + except Exception: + print("Private visual preservation verification failed; no acceptance inferred.") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-bubble-visuals.mjs b/scripts/verify-bubble-visuals.mjs new file mode 100644 index 0000000..9ea4d64 --- /dev/null +++ b/scripts/verify-bubble-visuals.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** Decode only locally retained Visual files in an offline disposable browser; emit counts only. */ +import { readFileSync, lstatSync, realpathSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { chromium } from 'playwright'; + +let browser; +try { + const root = resolve(process.argv[2]); + const reportBytes = readFileSync(join(root, 'reconciliation.json')); + const report = JSON.parse(reportBytes); + if (report.status !== 'completed_private_visual_preparation') throw new Error('unfinished_bundle'); + const entries = Object.entries(report.file_sha256).filter(([name]) => name.startsWith('captured-visuals/')); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ serviceWorkers: 'block', offline: true }); + await context.route('**/*', route => route.abort()); + const page = await context.newPage(); + let decoded = 0; + for (const [name, hash] of entries) { + if (!/^captured-visuals\/[a-f0-9]{64}\.(png|jpg|webp)$/.test(name)) throw new Error('invalid_asset_path'); + const path = join(root, name), info = lstatSync(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024 || + (info.mode & 0o077) || info.uid !== process.getuid() || + realpathSync(path) !== path) throw new Error('unsafe_asset'); + const data = readFileSync(path); + if (createHash('sha256').update(data).digest('hex') !== hash) throw new Error('changed_asset'); + const mime = name.endsWith('.jpg') ? 'image/jpeg' : name.endsWith('.webp') ? 'image/webp' : 'image/png'; + const ok = await page.evaluate(async ({data, mime}) => { + const bytes = Uint8Array.from(atob(data), x => x.charCodeAt(0)); + const url = URL.createObjectURL(new Blob([bytes], {type: mime})); + try { + const img = new Image(); img.src = url; + await Promise.race([img.decode(), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 10000))]); + return img.naturalWidth > 0 && img.naturalHeight > 0; + } finally { URL.revokeObjectURL(url); } + }, {data: data.toString('base64'), mime}); + if (!ok) throw new Error('image_decode_failed'); + decoded++; + } + console.log(JSON.stringify({schema: 'coulomb.private-visual-decode.v1', status: 'all_captured_visuals_decoded', + decoded_images: decoded, bundle_sha256: createHash('sha256').update(reportBytes).digest('hex'), + browser_network_disabled: true, productive_fidelity_accepted: false})); +} catch { + console.error('Private visual decode verification failed; no acceptance inferred.'); + process.exitCode = 1; +} finally { + if (browser) await browser.close(); +} diff --git a/tests/test_bubble_visuals.py b/tests/test_bubble_visuals.py new file mode 100644 index 0000000..aadec2b --- /dev/null +++ b/tests/test_bubble_visuals.py @@ -0,0 +1,157 @@ +"""Synthetic visual capture: origin limits, immutable source, complete private output.""" + +import hashlib +import importlib.util +import json +from pathlib import Path + +import pytest + +from coulomb_social.apps.spaces.bubble_bundle import prepare_bundle +from tests.test_bubble_bundle import capture as source_capture + +SPEC = importlib.util.spec_from_file_location( + 'bubble_visuals', Path(__file__).resolve().parents[1] / 'scripts/capture-bubble-visuals.py') +visuals = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(visuals) +URL = 'https://' + visuals.CDN + '/synthetic.png' +PNG = b'\x89PNG\r\n\x1a\nsynthetic-no-decode-claim' + + +def bundle(tmp_path): + tmp_path.chmod(0o700) + source = tmp_path / 'source' + source.mkdir(mode=0o700) + spaces, articles = source_capture() + articles['r']['logo_image'] = '//' + visuals.CDN + '/synthetic.png' + articles['c']['logo_image'] = URL + # Preserve the blocked and unassigned records as well as all eligible draft pages. + articles['orphan'] = {'logo_image': URL, 'content_text': 'Unassigned synthetic body'} + report, files = prepare_bundle(spaces, articles, inventory_spaces={'missing': {}}) + inputs = {k: json.dumps(v).encode() for k, v in [('spaces', spaces), ('articles', articles)]} + report['source_sha256'] = {k: hashlib.sha256(v).hexdigest() for k, v in inputs.items()} + report['status'] = 'completed_offline_preparation' + for name, raw in {**files, **{'source-capture/' + k + '.json': v + for k, v in inputs.items()}}.items(): + visuals.publish(source, name, raw) + visuals.publish(source, 'reconciliation.json', json.dumps(report).encode()) + return source, report, files + + +def test_shared_visuals_private_bundle_preserves_bodies_dates_gates_and_source(tmp_path): + source, report, files = bundle(tmp_path) + before = {str(p.relative_to(source)): p.read_bytes() for p in source.rglob('*') if p.is_file()} + calls = [] + def fetch(url): + calls.append(url) + return PNG, 'png' + out = tmp_path / 'output' + result = visuals.capture(source, out, fetch) + assert calls == [URL] + assert result['references'] == 3 and result['unique_urls'] == 1 + assert result['materialized_pages'] == 2 + after = json.loads((out / 'reconciliation.json').read_bytes()) + assert after['unassigned_article_ids'] == report['unassigned_article_ids'] + assert len(after['spaces']) == len(report['spaces']) + for original, updated in zip(report['spaces'], after['spaces'], strict=True): + assert updated['blockers'] == original['blockers'] and not updated['import_ready'] + for page in updated['articles']: + if page['file']: + raw = (out / page['file']).read_bytes() + assert raw.split(b'\n---\n', 1)[1] == files[page['file']].split(b'\n---\n', 1)[1] + assert b'source_created_at:' in raw and b'visual: "assets/' in raw + for name, digest in after['file_sha256'].items(): + assert visuals.sha((out / name).read_bytes()) == digest + assert before == {str(p.relative_to(source)): p.read_bytes() + for p in source.rglob('*') if p.is_file()} + for p in out.rglob('*'): + assert p.stat().st_mode & 0o077 == 0 + with pytest.raises(FileExistsError): + visuals.capture(source, out, fetch) + assert calls == [URL] + + +@pytest.mark.parametrize('value', ['http://' + visuals.CDN + '/x', + 'https://example.invalid/x', 'https://user@' + visuals.CDN + '/x', + 'https://' + visuals.CDN + ':8443/x', 'https://' + visuals.CDN + '/x?q=secret', + 'https://' + visuals.CDN + '/x#fragment', 'https://' + visuals.CDN + '/x\n', + 'https://' + visuals.CDN + '.example.invalid/x', '//127.0.0.1/x']) +def test_unreviewed_urls_refused(value): + with pytest.raises(visuals.Refused): + visuals.canonical_url(value) + + +def test_redirects_and_active_content_refused(): + with pytest.raises(visuals.Refused): + visuals.NoRedirect().redirect_request(None, None, 302, '', {}, URL) + for raw, kind in [(b'', 'image/svg+xml'), (PNG, 'text/html'), (b'html', 'image/png')]: + with pytest.raises(visuals.Refused): + visuals.image_extension(raw, kind) + + +@pytest.mark.parametrize('defect', ['file-drift', 'outside-tree', 'symlink', 'public-source']) +def test_bad_source_refuses_before_fetch_or_output(tmp_path, defect): + source, report, _ = bundle(tmp_path) + name = next(iter(report['file_sha256'])) + if defect == 'file-drift': + (source / name).write_bytes(b'changed') + elif defect == 'outside-tree': + report['spaces'][0]['tree'] = '/outside' + (source / 'reconciliation.json').write_text(json.dumps(report)) + elif defect == 'symlink': + (source / name).unlink() + (source / name).symlink_to(tmp_path / 'missing') + else: + (source / name).chmod(0o644) + out = tmp_path / 'out' + with pytest.raises((visuals.Refused, KeyError)): + visuals.capture(source, out, lambda u: pytest.fail('network called')) + assert not out.exists() + + +@pytest.mark.parametrize('defect', ['fetch-failure', 'during-fetch-drift', 'budget']) +def test_partial_capture_never_publishes_completion(tmp_path, monkeypatch, defect): + source, report, _ = bundle(tmp_path) + if defect == 'budget': + monkeypatch.setattr(visuals, 'MAX_TOTAL', 1) + def fetch(url): + if defect == 'fetch-failure': + raise RuntimeError('private-url-do-not-print') + if defect == 'during-fetch-drift': + (source / next(iter(report['file_sha256']))).write_bytes(b'changed') + return PNG, 'png' + out = tmp_path / 'out' + with pytest.raises((visuals.Refused, RuntimeError)): + visuals.capture(source, out, fetch) + assert not (out / 'reconciliation.json').exists() + + +@pytest.mark.parametrize('defect', ['status', 'redirected-url', 'oversize-header', + 'oversize-body', 'short-body', 'active-type']) +def test_fetch_rejects_invalid_transport_results_without_followup(monkeypatch, defect): + from email.message import Message + class Response: + status = 404 if defect == 'status' else 200 + headers = Message() + headers['Content-Type'] = 'text/html' if defect == 'active-type' else 'image/png' + headers['Content-Length'] = str( + visuals.MAX_ASSET + 1 if defect == 'oversize-header' else len(PNG) + 1 + if defect == 'short-body' else len(PNG)) + def __enter__(self): + return self + def __exit__(self, *args): + pass + def geturl(self): + return 'https://example.invalid/x' if defect == 'redirected-url' else URL + def read(self, maximum): + assert maximum == visuals.MAX_ASSET + 1 + return b'x' * maximum if defect == 'oversize-body' else PNG + class Opener: + def open(self, request, timeout): + assert request.full_url == URL and timeout == 20 + assert not any(k.lower() in {'authorization', 'cookie', 'referer'} + for k in request.headers) + return Response() + monkeypatch.setattr(visuals.urllib.request, 'build_opener', lambda *args: Opener()) + with pytest.raises(visuals.Refused): + visuals.fetch(URL) diff --git a/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md b/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md index 87baca8..f36d428 100644 --- a/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md +++ b/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md @@ -18,7 +18,7 @@ Residual acceptance from CSOC-WP-0008, split at the user's request. Existing imp ## Current execution map — 2026-10-31 -T05 records completed capture/tooling preparation; T04 now closes the independently verified accepted-cohort date correction. T01 still owns source exceptions, currentness and identity/grant/assets reconciliation. Remaining date verification is T06 and actual further-cohort migration is T07. T02 productive journey/transfer verdicts and T03 the separate cutover decision remain open. Bubble login renewal is currently cleared; four source graph exceptions and one unassigned record remain. +T05 records completed capture/tooling preparation; T08 adds verified private Visual capture and draft-tree materialization. T04 now closes the independently verified accepted-cohort date correction. T01 still owns source exceptions, currentness and identity/grant/assets reconciliation. Remaining date verification is T06 and actual further-cohort migration is T07. T02 productive journey/transfer verdicts and T03 the separate cutover decision remain open. Bubble login renewal is currently cleared; four source graph exceptions and one unassigned record remain. The [four-workplan assessment](../docs/release/goal-progress-audit-2026-10-31.md) records the scope split and effort estimate. Task counts are not effort percentages. @@ -334,3 +334,40 @@ After each source/identity/grant/assets mapping is ready under T01 and content r ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records. + + +## T08 — Capture and prepare the retained Visual images + +```task +id: CSOC-WP-0014-T08 +status: done +priority: high +needs_human: false +state_hub_task_id: "b7464ad5-9087-54a9-b3da-991b87bcefc8" +``` + +Completed2026-10-31. The previous67-tree review bundle retained Visual URLs but +left its Visual fields empty. Captured242 references through108 distinct HTTPS +URLs on the single recorded Bubble CDN;90 distinct binary images are retained +privately (168,139,156 downloaded bytes, including repeated content). No cookies, +authorization headers, arbitrary origins or redirects are used. Private capture +has per-image/aggregate limits and a final completion receipt; partial captures +cannot be mistaken for completed output. No browser login renewal was needed. + +A separate new bundle links local images on236 eligible draft pages and their67 +space indexes. All71 spaces, unassigned records and original gates remain. An +independent verifier checks738 output files against manifests, preserves all545 +original files modulo303 Visual-only frontmatter changes, and proves unchanged +bodies, original dates and all other metadata. All90 images decode in an offline +browser. This does not establish original Bubble rendering fidelity, current source +completeness, grants, or productive acceptance. Blocked/unassigned Visuals remain +retained even when no draft destination exists. Original capture is unchanged. + +Evidence: docs/release/bubble-visual-preparation-2026-10-31.json; private bundle +~/.config/coulomb-social/auth/exports/visual-bundle-20261031.24focused checks and +full make check570pass, no failures/errors/skips; final verifier and Ruff pass. +This extracts completed Visual preservation from T01/T05 without closing the +broader mapping/transfer requirement. T01 retains18body links, one attachment +flag, four graph exception groups, one unassigned record, source currentness, +identity/explicit grants and destination reconciliation. T07 remains unstarted +for productive transfer; T02/WP-0013 retain productive fidelity/verdicts. From 5c8f78ce4374638db2c1572d0c237b075f0b618f Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 14:55:09 +0100 Subject: [PATCH 12/39] Record Visual capture task projection and completion time Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- WORK-RECORDS.md | 1 + .../bubble-visual-preparation-2026-10-31.json | 29 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 4744f9c..723a37f 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -112,6 +112,7 @@ | task | CSOC-WP-0014-T05 | done | — | workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md | | task | CSOC-WP-0014-T06 | progress | — | workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md | | task | CSOC-WP-0014-T07 | todo | — | workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md | +| task | CSOC-WP-0014-T08 | done | — | workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md | | task | CSOC-WP-0015-T01 | done | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | | task | CSOC-WP-0015-T02 | progress | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | | task | CSOC-WP-0015-T03 | done | — | workplans/CSOC-WP-0015-communication-runtime-acceptance.md | diff --git a/docs/release/bubble-visual-preparation-2026-10-31.json b/docs/release/bubble-visual-preparation-2026-10-31.json index fee8d0c..2b210f3 100644 --- a/docs/release/bubble-visual-preparation-2026-10-31.json +++ b/docs/release/bubble-visual-preparation-2026-10-31.json @@ -79,5 +79,32 @@ "path": "tests/test_bubble_visuals.py", "sha256": "707fc9aa4ac0de5e40d33ee76571d74c141b361154a659f10c723a6597262006" } - ] + ], + "captured_at": "2026-10-31T13:48:44.558963+00:00", + "goal_task_projection_readback": { + "workplans": [ + { + "workplan": "CSOC-WP-0012", + "tasks": 14, + "done": 6 + }, + { + "workplan": "CSOC-WP-0013", + "tasks": 5, + "done": 2 + }, + { + "workplan": "CSOC-WP-0014", + "tasks": 8, + "done": 3 + }, + { + "workplan": "CSOC-WP-0015", + "tasks": 6, + "done": 3 + } + ], + "all_task_statuses_match_source": true, + "progress_event_id": "bb485164-d8ff-49cb-8c88-6aebe4e15cf0" + } } From 52168c3bd464811751168f2b4d1358a89edc3f68 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 15:07:15 +0100 Subject: [PATCH 13/39] Inventory retained source access fields without inferring grants Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3 --- docs/migration/bubble-auth-and-export.md | 19 ++ .../bubble-access-audit-2026-10-31.json | 131 ++++++++++++ .../release/goal-progress-audit-2026-10-31.md | 11 + scripts/audit-bubble-access.py | 190 ++++++++++++++++++ tests/test_bubble_access_audit.py | 110 ++++++++++ ...-WP-0014-cohort-transfer-bubble-offramp.md | 15 ++ 6 files changed, 476 insertions(+) create mode 100644 docs/release/bubble-access-audit-2026-10-31.json create mode 100644 scripts/audit-bubble-access.py create mode 100644 tests/test_bubble_access_audit.py diff --git a/docs/migration/bubble-auth-and-export.md b/docs/migration/bubble-auth-and-export.md index 01932ab..51f0f21 100644 --- a/docs/migration/bubble-auth-and-export.md +++ b/docs/migration/bubble-auth-and-export.md @@ -177,3 +177,22 @@ records remain private. The new bundle retains all71spaces and the existing graph/grant/fidelity gates.18body links and one attachment flag are still unreconciled. Captured Visual bytes do not authorize copying memberships or productive import. Public proof: docs/release/bubble-visual-preparation-2026-10-31.json. + +## Private access reconciliation + +`scripts/audit-bubble-access.py ORIGINAL NEW_PRIVATE_DIRECTORY` inventories the +retained source ownership, membership and visibility fields without proposing +target grants. It requires private, hash-bound source snapshots and refuses +source drift, symlinks, public files, hard links, existing output and output +inside the original bundle or this repository. The JSON and escaped, network-free +HTML review are private; stdout contains aggregate counts and hashes only. + +The October 31 review is at +`~/.config/coulomb-social/auth/exports/access-review-20261031/index.html`. +It covers 70 fresh spaces, 245 article records and one inventory-only space. +Three distinct source account references remain to be linked through authoritative +identity evidence. Nine creator references are unresolved. A missing source +membership field is unknown, not an empty list. Source role names and visibility +values are preserved for review; no equivalence with target owner/member/viewer +permissions is assumed. The accepted ReichelAG owner grant remains evidence for +that cohort alone. See `docs/release/bubble-access-audit-2026-10-31.json`. diff --git a/docs/release/bubble-access-audit-2026-10-31.json b/docs/release/bubble-access-audit-2026-10-31.json new file mode 100644 index 0000000..ff0ce96 --- /dev/null +++ b/docs/release/bubble-access-audit-2026-10-31.json @@ -0,0 +1,131 @@ +{ + "schema": "coulomb.bubble-access-audit-result.v1", + "spaces": { + "records": 70, + "field_states": { + "Created By": { + "unresolved_reference": 5, + "observed": 64, + "missing": 1 + }, + "crew_list_user": { + "observed": 69, + "missing": 1 + }, + "members_list_user": { + "observed": 47, + "missing": 23 + }, + "guests_list_user": { + "missing": 69, + "observed": 1 + } + }, + "option_fields": { + "isprivate_boolean": { + "present": 69, + "missing": 1, + "distinct_observed_values": 2 + }, + "addnewusersasmember_boolean": { + "present": 20, + "missing": 50, + "distinct_observed_values": 2 + }, + "addnewusersasguest_boolean": { + "present": 21, + "missing": 49, + "distinct_observed_values": 2 + } + } + }, + "articles": { + "records": 245, + "field_states": { + "Created By": { + "observed": 239, + "unresolved_reference": 4, + "missing": 2 + }, + "_creator_user": { + "missing": 10, + "observed": 235 + }, + "_owner_user": { + "observed": 243, + "missing": 2 + }, + "moders_list_user": { + "missing": 71, + "observed": 174 + } + }, + "option_fields": { + "access_option_visibility": { + "present": 243, + "missing": 2, + "distinct_observed_values": 2 + } + } + }, + "known_spaces": 71, + "fresh_space_records": 70, + "inventory_only_spaces": 1, + "distinct_source_principals": 3, + "target_identity_mappings_established": 0, + "target_grants_proposed": 0, + "production_changed": false, + "source_visibility_semantics_verified": false, + "source_sha256": { + "spaces": "ae20b0cfd1705f486c0b67ef48ac84b396f953cbcef09f9a8f07d07a66f1bf71", + "articles": "f33967c79c0f8a60a7d504f5d5e4082f8bc9e3c34f6a3160cb200fd380831d62", + "retained_spaces": "92038249ec2e4eed4c8d61ffcdbe030232cebc52e59ba005baee506a16930550" + }, + "source_report_sha256": "75fcd87d17ea0c5042e903efd60e65839da7716e5399ff431a345486ddd10f01", + "recorded_at": "2026-10-31T14:05:30.738728+00:00", + "workplan_task": "CSOC-WP-0014-T01", + "task_status": "progress", + "accepted_mapping_scope_evidence": "docs/release/reichelag-imported-2026-10-06.md", + "validation": { + "focused_tests_passed": 13, + "full_gate": "make check passed", + "junit": { + "tests": 583, + "errors": 0, + "failures": 0, + "skipped": 0 + }, + "source_snapshots_unchanged": true, + "private_output_hashes_verified": true, + "output_modes_verified": true + }, + "statehub_readback": { + "task_id": "0002882d-f156-5a5d-b524-d04793f4684d", + "status": "progress", + "access_audit_description_synced": true, + "fix_consistency_exit_code": 0, + "automation_errors": 0, + "assessment_failures": 0, + "warnings": 6 + }, + "output_files": [ + { + "path": "access-inventory.json", + "sha256": "d4f97ccc55867361048325f7227721a50a59bcf70ea1af4dfa3db48e8e703cb2" + }, + { + "path": "index.html", + "sha256": "6c175577f8a34c7bda78f86461d68d2c2167708970517f417bc89d1fde37436b" + } + ], + "implementation_files": [ + { + "path": "scripts/audit-bubble-access.py", + "sha256": "6590a19e54b77521e31455944b085f92e784e2b85737579cb14caaeb8538807b" + }, + { + "path": "tests/test_bubble_access_audit.py", + "sha256": "392a799d62828c7508a1b25133d9c1c8de01959b7f1fec90ae737a7cefce7883" + } + ] +} diff --git a/docs/release/goal-progress-audit-2026-10-31.md b/docs/release/goal-progress-audit-2026-10-31.md index fc34226..2aa3ee4 100644 --- a/docs/release/goal-progress-audit-2026-10-31.md +++ b/docs/release/goal-progress-audit-2026-10-31.md @@ -161,3 +161,14 @@ checks pass. This advances the source checklist to14of33done, not an effort percentage. Source exceptions, identity/grant mapping, links/attachments and productive transfer/verdicts remain open; the rough remaining-effort estimate has not been recalculated from these counts. + +## Access reconciliation checkpoint + +WP-0014-T01 now has a private, source-hash-bound access inventory covering the +70 fresh spaces and 245 articles, while retaining the inventory-only 71st space. +It identifies three distinct source principals and nine unresolved creator +references, preserving missing access fields as unknown. Existing ReichelAG +admission does not establish all-space identity or role mappings. The task remains +progress: this closes an evidence gap inside the task, not productive permission +reconciliation. The checklist stays 14 of 33 done, and the remaining-effort +estimate is unchanged. Evidence: `bubble-access-audit-2026-10-31.json`. diff --git a/scripts/audit-bubble-access.py b/scripts/audit-bubble-access.py new file mode 100644 index 0000000..2c93065 --- /dev/null +++ b/scripts/audit-bubble-access.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Prepare an offline private access inventory; never infer target identities or grants.""" + +import argparse +import hashlib +import html +import json +import os +import re +import stat +import sys +from collections import Counter +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from coulomb_social.apps.spaces.bubble_transfer import normalize_ref # noqa: E402 + +FIELDS = { + 'spaces': {'Created By': False, 'crew_list_user': True, + 'members_list_user': True, 'guests_list_user': True}, + 'articles': {'Created By': False, '_creator_user': False, + '_owner_user': False, 'moders_list_user': True}, +} +OPTIONS = {'spaces': ['isprivate_boolean', 'addnewusersasmember_boolean', + 'addnewusersasguest_boolean'], + 'articles': ['access_option_visibility']} + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def private_read(path): + if any(p.is_symlink() for p in (path, *path.parents)): + raise ValueError('source symlink') + info = path.stat() + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16 * 1024**2): + raise ValueError('private bounded regular source required') + return path.read_bytes() + + +def reference(value): + try: + result = normalize_ref(value) + except (ValueError, TypeError): + return None + return result if re.fullmatch(r'[0-9]+x[0-9]+', result) else None + + +def field_state(row, field, many): + if field not in row: + return {'state': 'missing', 'principals': []} + raw = row[field] + if raw is None: + return {'state': 'null', 'raw': raw, 'principals': []} + if many and not isinstance(raw, list): + return {'state': 'invalid_container', 'raw': raw, 'principals': []} + if not many and not isinstance(raw, str): + return {'state': 'invalid_container', 'raw': raw, 'principals': []} + values = raw if many else [raw] + refs = [reference(v) for v in values] + return {'state': ('empty' if not values else 'unresolved_reference' + if None in refs else 'observed'), + 'raw': raw, 'principals': sorted(set(r for r in refs if r is not None)), + 'unresolved_count': sum(r is None for r in refs)} + + +def audit(inputs): + """Source values remain private; the separate summary contains aggregate counts only.""" + records = {} + principals = set() + summary = {} + for kind in FIELDS: + source = inputs[kind] + if not isinstance(source, dict) or any(not isinstance(v, dict) for v in source.values()): + raise ValueError('record dictionaries required') + records[kind] = [] + counters = {f: Counter() for f in FIELDS[kind]} + for key, row in sorted(source.items()): + fields = {f: field_state(row, f, many) for f, many in FIELDS[kind].items()} + for f, value in fields.items(): + counters[f][value['state']] += 1 + principals.update(value['principals']) + records[kind].append({'source_id': key, 'fields': fields, + 'options': {f: {'present': f in row, 'value': row.get(f)} + for f in OPTIONS[kind]}}) + summary[kind] = { + 'records': len(source), 'field_states': counters, + 'option_fields': {f: { + 'present': sum(f in row for row in source.values()), + 'missing': sum(f not in row for row in source.values()), + 'distinct_observed_values': len({json.dumps(row[f], sort_keys=True) + for row in source.values() if f in row}), + } for f in OPTIONS[kind]}, + } + retained = inputs['retained_spaces'] + if not isinstance(retained, dict): + raise ValueError('retained inventory required') + known = set(retained) | set(inputs['spaces']) + summary.update({'known_spaces': len(known), 'fresh_space_records': len(inputs['spaces']), + 'inventory_only_spaces': len(set(retained) - set(inputs['spaces'])), + 'distinct_source_principals': len(principals), + 'target_identity_mappings_established': 0, + 'target_grants_proposed': 0, 'production_changed': False, + 'source_visibility_semantics_verified': False}) + return {'schema': 'coulomb.bubble-access-inventory.v1', 'summary': summary, + 'source_principals': sorted(principals), 'records': records, + 'inventory_only_space_ids': sorted(set(retained) - set(inputs['spaces'])), + 'limitations': [ + 'Missing fields are unknown, not empty grants.', + 'Source roles and visibility are observed values, not target permissions.', + 'Existing one-space owner admission does not map every source identity.', + 'This capture does not prove complete or current provider membership.', + 'No target identity, role mapping or import manifest is created.']} + + +def review_html(report): + rows = [] + for kind, records in report['records'].items(): + for row in records: + data = json.dumps(row, ensure_ascii=False, indent=2) + rows.append('
' + html.escape(kind + ': ' + row['source_id']) + + '
' + html.escape(data) + '
') + return ('' + '' + 'Private source access inventory' + '

Private source access inventory

Observed source metadata only. ' + 'No target identities or grants have been inferred.

'
+            + html.escape(json.dumps(report['summary'], indent=2)) + '
    ' + + ''.join('
  • ' + html.escape(x) + '
  • ' for x in report['limitations']) + + '
' + ''.join(rows) + '').encode() + + +def prepare(source, out): + raw = {kind: private_read(source / 'source-capture' / (kind + '.json')) + for kind in [*FIELDS, 'retained_spaces']} + receipt_raw = private_read(source / 'reconciliation.json') + receipt = json.loads(receipt_raw) + if (receipt['schema'] != 'coulomb.bubble-review-bundle.v1' + or receipt['status'] != 'completed_offline_preparation'): + raise ValueError('completed review bundle required') + hashes = {k: sha(v) for k, v in raw.items()} + if any(receipt['source_sha256'].get(k) != v for k, v in hashes.items()): + raise ValueError('source digest mismatch') + report = audit({k: json.loads(v) for k, v in raw.items()}) + report['source_sha256'] = hashes + report['source_report_sha256'] = sha(receipt_raw) + if out.exists() or any(p.is_symlink() for p in (out, *out.parents)): + raise ValueError('new private output required') + parent = out.parent.stat() + if (parent.st_uid != os.getuid() or parent.st_mode & 0o077 + or out.resolve().is_relative_to(Path(__file__).resolve().parents[1]) + or out.resolve().is_relative_to(source.resolve())): + raise ValueError('private output outside repository and source required') + for kind, data in raw.items(): + if private_read(source / 'source-capture' / (kind + '.json')) != data: + raise ValueError('source changed during audit') + if private_read(source / 'reconciliation.json') != receipt_raw: + raise ValueError('source report changed during audit') + out.mkdir(mode=0o700) + outputs = {'access-inventory.json': json.dumps(report, indent=2).encode(), + 'index.html': review_html(report)} + for name, data in outputs.items(): + with os.fdopen(os.open(out / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + return {'schema': 'coulomb.bubble-access-audit-result.v1', **report['summary'], + 'source_sha256': hashes, 'source_report_sha256': sha(receipt_raw), + 'output_sha256': {k: sha(v) for k, v in outputs.items()}} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source', type=Path) + parser.add_argument('output', type=Path) + args = parser.parse_args() + try: + result = prepare(args.source, args.output) + except Exception: + print('Access audit refused; no identity or grant inferred.', file=sys.stderr) + return 1 + print(json.dumps(result)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_bubble_access_audit.py b/tests/test_bubble_access_audit.py new file mode 100644 index 0000000..3f75ee1 --- /dev/null +++ b/tests/test_bubble_access_audit.py @@ -0,0 +1,110 @@ +"""Missing access metadata must never turn into empty or approved permissions.""" + +import hashlib +import importlib.util +import json +from pathlib import Path + +import pytest + +SPEC = importlib.util.spec_from_file_location( + 'bubble_access_audit', Path(__file__).resolve().parents[1] / 'scripts/audit-bubble-access.py') +audit = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(audit) + + +def inputs(): + return {'spaces': {'1x1': {'crew_list_user': ['LOOKUP__User__9x9'], + 'members_list_user': [], 'isprivate_boolean': False}}, + 'articles': {'2x2': {'_owner_user': '9x9', 'Created By': 'unresolved'}}, + 'retained_spaces': {'1x1': {}, '3x3': {}}} + + +def test_aliases_collapse_but_no_grants_or_empty_missing_inference(): + report = audit.audit(inputs()) + assert report['source_principals'] == ['9x9'] + fields = report['records']['spaces'][0]['fields'] + assert fields['members_list_user']['state'] == 'empty' + assert fields['guests_list_user']['state'] == 'missing' + created_by = report['records']['articles'][0]['fields']['Created By'] + assert created_by['state'] == 'unresolved_reference' + assert report['inventory_only_space_ids'] == ['3x3'] + assert report['summary']['known_spaces'] == 2 + assert report['summary']['target_grants_proposed'] == 0 + assert report['summary']['target_identity_mappings_established'] == 0 + assert '9x9' not in json.dumps(report['summary']) + + +@pytest.mark.parametrize('raw,expected', [(None, 'null'), ('9x9', 'invalid_container'), + ([], 'empty'), ([{}], 'unresolved_reference'), + (['9x9', None], 'unresolved_reference')]) +def test_field_states(raw, expected): + result = audit.field_state({'members_list_user': raw}, 'members_list_user', True) + assert result['state'] == expected + assert result['raw'] == raw + + +def test_private_html_escapes_source_and_disables_network(): + data = inputs() + data['articles']['2x2']['Created By'] = '' + result = audit.review_html(audit.audit(data)) + assert b'' audit, rendered = audit_body(body) From 075633e4b593adc7511fbae417ebd8fa3478fc4f Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 18:36:07 +0100 Subject: [PATCH 27/39] Verify backup route and inactive installation prerequisites for content acceptance Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- ...llation-independent-return-2026-10-31.json | 371 ++++++++++++++++++ ...p-route-independent-return-2026-10-31.json | 29 ++ docs/usecases/sharing-portability-review.md | 15 + ...0012-durable-recovery-service-readiness.md | 36 +- .../CSOC-WP-0013-sharing-content-hardening.md | 23 ++ 5 files changed, 461 insertions(+), 13 deletions(-) create mode 100644 docs/release/backup-installation-independent-return-2026-10-31.json create mode 100644 docs/release/backup-route-independent-return-2026-10-31.json diff --git a/docs/release/backup-installation-independent-return-2026-10-31.json b/docs/release/backup-installation-independent-return-2026-10-31.json new file mode 100644 index 0000000..8417e00 --- /dev/null +++ b/docs/release/backup-installation-independent-return-2026-10-31.json @@ -0,0 +1,371 @@ +{ + "schema": "coulomb.backup-installation-independent-postflight.v1", + "verified_at": "2026-10-31T17:30:18.219708+00:00", + "status": "installed_files_and_inactive_units_verified", + "proposal_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "receipt_sha256": "96329afbd5662f71bf6d6b05c003f0810be12c10036f9cadb41cc373d3a1108b", + "receipt": { + "schema": "coulomb.backup-source-supervised-installation.v1", + "proposal_digest": "sha256:8f442bf0ebed8bbf3b7f227b52081df52613f3f5f6874c4a59511cfc2ba06d42", + "status": "sources_installed_units_inactive_verified", + "preflight": { + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "not-found", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "", + "UnitFileState": "" + } + }, + "capacity": { + "state_bytes": 11259904, + "state_reserve_bytes": 7532969984, + "disk_reserve_bytes": 8606711808, + "free_bytes": 115579949056, + "free_inodes": 24523113, + "filesystem_blocks_reserved": false + }, + "sdk": { + "boto3": "1.40.18", + "botocore": "1.40.76" + }, + "route": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + } + }, + "timers_activated": false, + "capture_executed": false, + "email_sent": false, + "retained_data_deleted": false, + "started_at": "2026-10-31T17:22:48.757530+00:00", + "capacity_profile": { + "maximum_new_file_bytes": 1073741824, + "state_budget_bytes": 8589934592, + "minimum_free_bytes": 8589934592, + "minimum_free_inodes": 1024, + "control_margin_bytes": 16777216 + }, + "founder_confirmed_profile_and_installation": true, + "route_postflight": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + }, + "verified_targets": 19, + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.service", + "UnitFileState": "static" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.service", + "UnitFileState": "static" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-alert@.service", + "UnitFileState": "static" + } + }, + "updated_at": "2026-10-31T17:22:54.560675+00:00" + }, + "nested_receipt_sha256": "795d9eb672b24495c77ad636a210c5d2022c8c3124d276920db7eda02257e519", + "verified_targets": 19, + "installed_fingerprints": { + "/opt/coulomb-social-backup/paired-backup.py": { + "exists": true, + "sha256": "d161c742d1a6a182460a57d0a1719591ab58fc94006f55169ad3d74442a93630", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 534009, + "device": 2049, + "mtime_ns": 1793467368826077309 + }, + "/opt/coulomb-social-backup/snapshot-content-and-db.py": { + "exists": true, + "sha256": "dcc8dd2a3989f279597595f9260b31b24fcc9b6dc787f04c17ef0491c53d830d", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549255, + "device": 2049, + "mtime_ns": 1793430923413704430 + }, + "/opt/coulomb-social-backup/mirror-paired-backup.py": { + "exists": true, + "sha256": "9ceb26d9083af016c4b787907e4dce7bdf437c782fb2d15eb28d9e334e6e431c", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549201, + "device": 2049, + "mtime_ns": 1793467368831077393 + }, + "/opt/coulomb-social-backup/check-paired-backup-health.py": { + "exists": true, + "sha256": "2d40ec6e4c59ea7fe1d46065d197b9a87ca6811f023cc6f3e20e4184a3490566", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549309, + "device": 2049, + "mtime_ns": 1793467368836077477 + }, + "/opt/coulomb-social-backup/backup_capacity.py": { + "exists": true, + "sha256": "cb699a79e67b129239b5aa5d6f3b0d4ffaa9773a8b073227ad17fdecb5b9d893", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549354, + "device": 2049, + "mtime_ns": 1793467368843077594 + }, + "/opt/coulomb-social-backup/mirror-platform-runtime.json": { + "exists": true, + "sha256": "9253c5f6e956b67f499d3e12d2851be7fabd3bc56b00496b8810bbd0be8209f1", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549355, + "device": 2049, + "mtime_ns": 1793467368847077662 + }, + "/opt/coulomb-social-backup/backup_alerts.py": { + "exists": true, + "sha256": "07522437d4276b92f0a44149adc6c9e81bb18dbe59ce0f1f9e58a48347700d0e", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549359, + "device": 2049, + "mtime_ns": 1793467368887078332 + }, + "/opt/coulomb-social-backup/notify-backup-alert.py": { + "exists": true, + "sha256": "0bd53bc5ad22d8bcb0326fb18a17737744b06416fe785dad077308e45f051d5a", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549360, + "device": 2049, + "mtime_ns": 1793467368891078399 + }, + "/opt/coulomb-social-backup/observe-backup-alerts.py": { + "exists": true, + "sha256": "17caf4fa65283242f618b039ff1d018801c9890ea2a9178912cbdd4b50ee2664", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549361, + "device": 2049, + "mtime_ns": 1793467368895078466 + }, + "/opt/railiance-platform/scripts/application_recovery_identities.py": { + "exists": true, + "sha256": "20f56255ed6eeec4aafe33f142bc3ee03f623d4957a7b0cabb741d340f15f79d", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549356, + "device": 2049, + "mtime_ns": 1793467368852077745 + }, + "/opt/railiance-platform/scripts/application_recovery_mirror.py": { + "exists": true, + "sha256": "d48ab95e89b9eeae36269d853ebe4c44c22e1f57d4e14224f03d0b9aeeb34178", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549357, + "device": 2049, + "mtime_ns": 1793467368857077829 + }, + "/opt/railiance-platform/scripts/application_recovery_resources.py": { + "exists": true, + "sha256": "ab70ec341e738272730eb0daf76840d0bca807caeb3c4e4c2357f162fbed4498", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 549358, + "device": 2049, + "mtime_ns": 1793467368862077913 + }, + "/opt/railiance-platform/scripts/primary_application_archive.py": { + "exists": true, + "sha256": "a3f0a61e869a6012edb4b5288be14e3b0c4a9a1273b090320ab146d12e5aa94d", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 540610, + "device": 2049, + "mtime_ns": 1791537447437616251 + }, + "/etc/systemd/system/coulomb-social-backup-freshness.service": { + "exists": true, + "sha256": "8d6278c2cbca7f48b659c239c1d1e181fa4b9cf023fe7429850e294c6cd84d0e", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19366, + "device": 2049, + "mtime_ns": 1793467368868078014 + }, + "/etc/systemd/system/coulomb-social-paired-backup.service": { + "exists": true, + "sha256": "8b01b788bb08f453fdd4807104185c89411e0b0b4dedee9240f14f110ceb5513", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 14867, + "device": 2049, + "mtime_ns": 1793467368873078098 + }, + "/etc/systemd/system/coulomb-social-backup-freshness.timer": { + "exists": true, + "sha256": "75d1d0721c07fdd0703910c06bb8a678396440e7d95411a37ee7de4938f3ac31", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19367, + "device": 2049, + "mtime_ns": 1793467368878078181 + }, + "/etc/systemd/system/coulomb-social-paired-backup.timer": { + "exists": true, + "sha256": "5e87235e66f89a64a972562d6f163f62420628fc44fb7fd8d603a96c347dfbb9", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19368, + "device": 2049, + "mtime_ns": 1793467368882078248 + }, + "/etc/systemd/system/coulomb-social-backup-alert@.service": { + "exists": true, + "sha256": "4bfa019d29b1d0de278f03553602a5e824de50ed99ebcf3b5035cf4d5b06fe98", + "mode": 420, + "uid": 0, + "gid": 0, + "inode": 19370, + "device": 2049, + "mtime_ns": 1793467368899078533 + }, + "/etc/coulomb-social-backup/alerting.env": { + "exists": true, + "sha256": "509844b5c8ef618b812d52f32e19c3c559623636dca62f7b16f437aa90cc0458", + "mode": 384, + "uid": 0, + "gid": 0, + "inode": 19369, + "device": 2049, + "mtime_ns": 1793467368904078617 + } + }, + "units": { + "coulomb-social-paired-backup.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.service", + "UnitFileState": "static" + }, + "coulomb-social-paired-backup.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-paired-backup.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-freshness.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.service", + "UnitFileState": "static" + }, + "coulomb-social-backup-freshness.timer": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-freshness.timer", + "UnitFileState": "disabled" + }, + "coulomb-social-backup-alert@coulomb-social-paired-backup.service.service": { + "LoadState": "loaded", + "ActiveState": "inactive", + "SubState": "dead", + "FragmentPath": "/etc/systemd/system/coulomb-social-backup-alert@.service", + "UnitFileState": "static" + } + }, + "route": { + "helm_revision": 6, + "runtime_and_complete_resources_verified": true + }, + "capacity": { + "state_bytes": 11259904, + "state_reserve_bytes": 7532969984, + "disk_reserve_bytes": 8606711808, + "free_bytes": 115555618816, + "free_inodes": 24523041, + "filesystem_blocks_reserved": false + }, + "sdk": { + "boto3": "1.40.18", + "botocore": "1.40.76" + }, + "runtime_modified_by_verification": false +} diff --git a/docs/release/backup-route-independent-return-2026-10-31.json b/docs/release/backup-route-independent-return-2026-10-31.json new file mode 100644 index 0000000..fb0a51f --- /dev/null +++ b/docs/release/backup-route-independent-return-2026-10-31.json @@ -0,0 +1,29 @@ +{ + "schema": "coulomb.backup-route-independent-postflight.v1", + "verified_at": "2026-10-31T17:19:36.926769+00:00", + "status": "durable_receipt_and_live_route_verified", + "execution_digest": "sha256:4d8481061dc9ce6835169defc96887945052332a0db8d4234ae84032d63cc328", + "receipt": { + "schema": "rapp-telemetry.backup-route-execution.v1", + "execution_digest": "sha256:4d8481061dc9ce6835169defc96887945052332a0db8d4234ae84032d63cc328", + "proposal_digest": "sha256:4bcad54f7b8e1e77b94112e452cbb58eaf8e882dc10305129c5f9ca69f014a66", + "status": "backup_email_route_deployed_verified", + "timers_activated": false, + "notification_sent_by_helper": false, + "production_data_restored": false, + "started_at": "2026-10-31T17:17:13.743344+00:00", + "helm_revision": 6, + "verified_resource_count": 87, + "after_resources_sha256": "600c4998e54b8e11845849c7d8e4c6b6699556c21f5df6ca79bb86b2e8d1683f", + "live_desired_fields_verified": true, + "runtime_config_verified": true, + "existing_image_and_pvc_preserved": true, + "completed_at": "2026-10-31T17:17:31.488654+00:00" + }, + "receipt_sha256": "c26d636f59109956f281ad46f4885c4db800a9553cf05cac324aa3155ef7ac40", + "original_execution_packet_matches": true, + "live_desired_fields_verified": true, + "runtime_config_verified": true, + "existing_image_and_pvc_preserved": true, + "runtime_modified_by_verification": false +} diff --git a/docs/usecases/sharing-portability-review.md b/docs/usecases/sharing-portability-review.md index a7c7f68..c5c3d93 100644 --- a/docs/usecases/sharing-portability-review.md +++ b/docs/usecases/sharing-portability-review.md @@ -10,6 +10,12 @@ Record the actual release/date with each verdict. Later source changes, includin the attachment interruption fix below, are not implicitly part of that image. Automated checks do not supply these verdicts. +October31 closure update: the reviewed backup route is now executed and +independently verified at revision6. The inactive installer is also founder-executed +and independently verified, including its local capacity profile. A newly identified ordered-list numbering correction +must accompany the attachment fix in a refreshed content release; the historical +lifecycle-only packet below remains evidence, not the final combined proposal. + | Journey | Review steps | Expected result | | --- | --- | --- | | UC-003: authoring | Create a disposable page with title, abstract, visual and body. Save/reopen; edit and reopen again. Open two edit tabs, save in one, then submit the older tab. | Content remains recognizable. Stale submission preserves your text and asks you to reopen/merge. Verify representative custom Markdown/raw HTML/assets separately; sanitization differences need an explicit verdict. | @@ -37,6 +43,15 @@ Current private index: Source exceptions, including the unassigned record, have a separate private index: `~/.config/coulomb-social/auth/exports/source-exception-review-20261031/index.html`. +The refreshed **source-candidate** review is now +`~/.config/coulomb-social/auth/exports/rendering-review-20261031-numbering/index.html`. +It restores15ordered-list starting numbers across4records, with all242source +bodies unchanged. Three custom-topic markup cases remain, as do the three +missing bodies and separate resource questions. The original six-case review +above remains unchanged and describes the previous renderer. Neither index +establishes original Bubble fidelity or the deployed image's acceptance. Source +proof: `../release/ordered-list-fidelity-2026-10-31.json`. + In the private index, review the six markup-change cards first. Each shows the exact captured source beside the current product rendering and provides an explicit link to the original Bubble view. Compare the same record there: diff --git a/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md b/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md index c5bc5d2..ad4ca6d 100644 --- a/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md +++ b/workplans/CSOC-WP-0012-durable-recovery-service-readiness.md @@ -18,7 +18,7 @@ Residual acceptance from CSOC-WP-0008, split at the user's request. Existing imp ## Current execution map — 2026-10-31 -Completed: T01 independent custody, T02 recovery, T05 dual-destination proof and T06 recurring source/rehearsal. T04 owns retention/capacity; T07 the pending attended route; T08 inactive installation; T09 native operational proof; T10 schedule activation/observation. T11/T12 expose the previously buried service-contract work. T03 remains the final service acceptance gate and is not an additional implementation estimate on top of T07–T12. +Completed: T01 independent custody, T02 recovery, T05 dual-destination proof, T06 recurring source/rehearsal, T07 the independently verified attended route and T08 inactive installation. T04's local operating capacity profile is admitted; retention and historical-copy disposition remain. T09 owns native operational proof; T10 schedule activation/observation. T11/T12 expose the previously buried service-contract work. T03 remains the final service acceptance gate and is not an additional implementation estimate on top of T07–T12. The [four-workplan assessment](../docs/release/goal-progress-audit-2026-10-31.md) records the scope split and effort estimate. Task counts are not effort percentages. @@ -558,7 +558,7 @@ status: progress priority: high state_hub_task_id: "a36722a5-5f13-5728-9d58-24f030c2bb00" needs_human: true -intervention_note: "Eight-entry historical custody repair is founder-executed and independently verified under completed T15. Historical pair disposition and retention/capacity admission remain open; the exact inactive installer carries the prepared capacity profile. Existing destinations and schedule are settled." +intervention_note: "Eight-entry historical custody repair and inactive installation are founder-executed and independently verified under T15/T08. The fixed local capacity profile was admitted through INSTALL8f442bf0. Provider retention/expiry and historical pair disposition remain open. Existing destinations and schedule are settled." ``` Remaining scope from T01. Close with owner-admitted capacity/headroom limits, a current generation/expiry plan, and explicit retention or deletion disposition for historical private production-pair copies. Execute only admitted expiry and retain native receipts. Proposed limits and a deletion candidate list are not admission. Evidence: `docs/release/backup-capacity-review-2026-10-30.md` and `generation-retention-post-date-2026-10-31.json`. No deletion has occurred. Existing RPF-WP-0053 custody/expiry ownership remains canonical. @@ -629,14 +629,11 @@ Durable independent channel attempts, retry/replay, failed/pending/stale episode ```task id: CSOC-WP-0012-T07 -status: wait -wait_kind: human +status: done decision_id: "5fa7234f-2a41-4eed-b4fc-9384f493572b" -blocking_reason: "Exact reviewed route is prepared; founder terminal execution and its durable verified receipt are pending." priority: high state_hub_task_id: "8c8e4810-d967-54b2-ae49-25d367eac459" -needs_human: true -intervention_note: "Existing attended route packet4d848106 awaits founder execution and independent verification. Destination/test-send approval is settled; do not resend the one-off proof." +needs_human: false ``` The exact owner execution packet is ready; durable receipt was absent at the 10:04:37Z observation. Founder execution and independent complete-resource/runtime-config postflight remain. Use the existing reviewed owner packet in `/home/worsch/rapp-telemetry/docs/coulomb-backup-route-execution-2026-10-31.md`, digest4d848106; inspect any existing attempt before further action. No renewed recipient approval is needed. Close only on durable verified native success, preserving the existing PVC and unrelated routes. @@ -649,12 +646,21 @@ settled. This fixes decision-queue visibility; it is not deployment, permission send another proof email or monitoring activation. The installation receipt is also absent. Evidence: `docs/release/operator-handoff-check-2026-10-31.json`. +2026-10-31 completed return: founder executed unchanged proposal4d848106 at +17:17:13Z–17:17:31Z. Independent postflight at17:19:36Z reads the durable +receipt and original execution packet and verifies revision6, all87resources, +actual Alertmanager configuration, unchanged image and preserved Grafana PVC. +T07 is done; do not replay the deployment. No test alert, capture or timer +activation occurred. Evidence: `backup-route-independent-return-2026-10-31.json`. +Earlier pending observations above are historical. + ## T08 — Finish the supervised source installer and install inactive units ```task id: CSOC-WP-0012-T08 -status: wait +status: done priority: high +needs_human: false state_hub_task_id: "ca41b874-5e09-538e-9692-d31d990d679b" depends_on: - CSOC-WP-0012-T04 @@ -679,11 +685,15 @@ currently fixes atdcc8dd2a. Both existing packet digests are preserved. Technica sequencing decision: de5a4528-0864-4769-b9d0-2f658b0310c6; this is not image execution approval. -Preparation is complete; task status remains wait for T07's verified attended -route, then a fresh installer preflight and attended installation including the -displayed capacity-profile admission. The eight-entry custody repair is complete -under T15. Installation and independent postflight still prevent T08 from being -done. No installer job has been started. +2026-10-31 completed return: founder executed unchanged proposal8f442bf0 at +17:22:48Z–17:22:54Z, explicitly confirming its local capacity profile. Independent +postflight verifies the outer/nested durable receipts, all19target hashes and +journaled file identities, preserved helpers, all5units loaded/inactive with +timers disabled, no queued jobs, SDK/credential-reference custody, capacity and +the actual revision6 route. T08 is done; do not replay installation. Evidence: +`backup-installation-independent-return-2026-10-31.json`. No capture, email, +deletion or timer activation occurred. T04 retains provider retention/expiry +and historical-copy disposition; T09/T10 retain recurring-service proof. ## T09 — Prove native paired service and recurring failure reception diff --git a/workplans/CSOC-WP-0013-sharing-content-hardening.md b/workplans/CSOC-WP-0013-sharing-content-hardening.md index fbfc3bf..85a2fb9 100644 --- a/workplans/CSOC-WP-0013-sharing-content-hardening.md +++ b/workplans/CSOC-WP-0013-sharing-content-hardening.md @@ -20,6 +20,17 @@ Residual acceptance from CSOC-WP-0008, split at the user's request. Existing imp T01 storage enforcement is complete. T04 now exposes completed fidelity-review preparation. T02 still owns actual corpus/assets fidelity verdicts and final content acceptance; its revision lifecycle detail is T05, now in progress with the retention policy accepted and a tested crash-recovery candidate published. T03 owns four separate UC-003/004/005/006 productive verdicts. No review verdict has been inferred from tests or weekly-call acceptance. +The user selected closure of this workplan as the active goal, completing its +necessary WP-0012 prerequisites first. T07's route and T08's inactive installation +are now founder-executed and independently verified, including local capacity +admission. A newly +verified ordered-list numbering fix under T02 must accompany the lifecycle fix +in the next reviewed image. Original sealed packets remain historical evidence; +the lifecycle-only image packet is not the final combined content release. +Then close fidelity/resource dispositions, lifecycle acceptance and the four +productive journeys. Full migration/cutover and full WP-0012 service acceptance +remain in their existing workplans. + The [four-workplan assessment](../docs/release/goal-progress-audit-2026-10-31.md) records the scope split and effort estimate. Task counts are not effort percentages. Historical notes retain the evidence trail; this map and the task blocks govern @@ -183,6 +194,18 @@ WP-0014-T01. `docs/release/bubble-resource-capture-2026-10-31.json` records the incomplete return and private review; neither resource absence nor fidelity is inferred. T02 remains progress. +2026-10-31 closure follow-through: audit found a concrete numbering defect: +sanitization removed15ordered-list start attributes across4captured records. +The bounded renderer correction permits ol.start while preserving event-handler, +custom-tag and unsafe-URL denial. All650full-gate tests pass, including browser +journeys. Refreshed private cards verify all242bodies unchanged and restore every +start value; structural sanitizer cases fall from6to3, all custom topic fields. +Three missing bodies and unresolved resource flags remain explicit. Evidence: +`docs/release/ordered-list-fidelity-2026-10-31.json`; private review is +`~/.config/coulomb-social/auth/exports/rendering-review-20261031-numbering/index.html`. +This source correction still requires packaged release and productive verdicts. +It neither changes the original capture nor accepts the remaining custom fields. + ## T03 — Review productive sharing and personal portability ```task From d9da608e652fd469df3800b7fdeb1f1a7715093e Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 19:38:10 +0100 Subject: [PATCH 28/39] Add private author-managed attachments with retained revisions Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a192e9-c28b-7533-bf25-e2341364c22a --- coulomb_social/apps/spaces/assets.py | 22 +- coulomb_social/apps/spaces/attachments.py | 196 ++++++++++++++ coulomb_social/apps/spaces/pageops.py | 17 +- coulomb_social/apps/spaces/urls.py | 3 + coulomb_social/apps/spaces/views.py | 80 +++++- .../templates/spaces/attachments.html | 49 ++++ coulomb_social/templates/spaces/detail.html | 6 + scripts/test-attachments-browser.mjs | 94 +++++++ tests/test_attachments.py | 239 ++++++++++++++++++ tests/test_content_recovery.py | 9 + tests/test_source_date_supervisor.py | 13 +- tests/usecases/test_attachments_browser.py | 36 +++ 12 files changed, 752 insertions(+), 12 deletions(-) create mode 100644 coulomb_social/apps/spaces/attachments.py create mode 100644 coulomb_social/templates/spaces/attachments.html create mode 100644 scripts/test-attachments-browser.mjs create mode 100644 tests/test_attachments.py create mode 100644 tests/usecases/test_attachments_browser.py diff --git a/coulomb_social/apps/spaces/assets.py b/coulomb_social/apps/spaces/assets.py index c4d8572..61d77d4 100644 --- a/coulomb_social/apps/spaces/assets.py +++ b/coulomb_social/apps/spaces/assets.py @@ -83,6 +83,13 @@ def open_local_image(space_slug: str, path: str): def local_asset_url(space_slug: str, reference: str) -> str: """Map space-root or page-relative assets to the authenticated app route.""" + path = local_asset_path(reference) + if not path or PurePosixPath(path).suffix.lower() not in _TYPES: + return "" + return reverse("spaces:asset", kwargs={"slug": space_slug, "asset_path": path}) + + +def local_asset_path(reference: str) -> str: try: parsed = urlsplit(reference) except ValueError: @@ -98,11 +105,16 @@ def local_asset_url(space_slug: str, reference: str) -> str: return "" try: asset_parts(path) - if PurePosixPath(path).suffix.lower() not in _TYPES: - return "" except ValueError: return "" - return reverse("spaces:asset", kwargs={"slug": space_slug, "asset_path": path}) + return path + + +def local_download_url(space_slug: str, reference: str) -> str: + path = local_asset_path(reference) + if not path: + return "" + return reverse("spaces:download", kwargs={"slug": space_slug, "asset_path": path}) class _AssetLinks(HTMLParser): @@ -115,7 +127,9 @@ class _AssetLinks(HTMLParser): result = [] for name, value in attrs: if (tag, name) in {("img", "src"), ("a", "href")} and value: - value = local_asset_url(self.space_slug, value) or value + value = (local_asset_url(self.space_slug, value) + or (local_download_url(self.space_slug, value) if tag == "a" else "") + or value) result.append(name if value is None else f'{name}="{escape(value, quote=True)}"') self.output.append("<" + tag + (" " + " ".join(result) if result else "") + ">") diff --git a/coulomb_social/apps/spaces/attachments.py b/coulomb_social/apps/spaces/attachments.py new file mode 100644 index 0000000..8a907c0 --- /dev/null +++ b/coulomb_social/apps/spaces/attachments.py @@ -0,0 +1,196 @@ +"""Page attachments: immutable files, revisioned associations and bounded uploads.""" + +import hashlib +import json +import os +import re +import uuid +from html.parser import HTMLParser +from pathlib import Path + +from django.utils.text import slugify + +from . import pageops +from .assets import ( + asset_parts, + local_asset_path, + local_asset_url, + local_download_url, + open_local_asset, +) + +KEY = "coulomb_attachments" +MAX_UPLOAD_BYTES = 16 * 1024 * 1024 +MAX_ATTACHMENTS = 100 + + +def page_files(space, doc): + """List page associations plus existing body/Visual references, without I/O.""" + from .content import render_markdown + + files = {local_asset_path(e["path"]): {**e, "managed": True} for e in entries(doc.meta)} + + def add_reference(value): + path = local_asset_path(value) + if path and path not in files: + files[path] = {"path": "assets/" + path, "name": Path(path).name, "managed": False} + + class Links(HTMLParser): + def handle_starttag(self, tag, attrs): + for key, value in attrs: + if (tag, key) in {("a", "href"), ("img", "src")} and value: + add_reference(value) + + parser = Links() + parser.feed(render_markdown(doc.body)) + parser.close() + add_reference(doc.visual) + return [{**entry, "download_url": local_download_url(space, entry["path"]), + "open_url": local_asset_url(space, entry["path"])} for entry in files.values()] + + +def entries(meta): + raw = meta.get(KEY, "[]") + try: + result = json.loads(raw) + if not isinstance(result, list) or len(result) > MAX_ATTACHMENTS: + raise ValueError + paths = set() + for entry in result: + if (not isinstance(entry, dict) or set(entry) != {"name", "path"} + or not isinstance(entry["name"], str) or not entry["name"] + or len(entry["name"]) > 200 or not isinstance(entry["path"], str) + or not entry["path"].startswith("assets/")): + raise ValueError + asset_parts(entry["path"][7:]) + if entry["path"] in paths: + raise ValueError + paths.add(entry["path"]) + return result + except (ValueError, TypeError, KeyError) as exc: + raise pageops.PageOpsError("The attachment list needs operator review") from exc + + +def _metadata(space, doc, files): + meta = dict(title=doc.title, abstractor=doc.abstractor, visual=doc.visual, + space=space) + meta.update({k: v for k, v in doc.meta.items() if k not in meta}) + meta[KEY] = json.dumps(files, ensure_ascii=True) + return meta + + +def _current(space, page, revision): + doc = pageops.read_page(space, page) + if not doc.exists: + raise pageops.PageOpsError("Page not found") + if not revision or doc.revision != revision: + raise pageops.PageOpsError("This page changed. Reopen it before changing attachments") + return doc + + +def _save(space, doc, meta): + return pageops.write_page( + space, doc.slug, title=doc.title, abstractor=doc.abstractor, + visual=doc.visual, body=doc.body, extra_meta=meta, expected_revision=doc.revision, + ) + + +@pageops.content_writer +def add(space, page, upload, *, revision): + doc = _current(space, page, revision) + files = entries(doc.meta) + if len(files) >= MAX_ATTACHMENTS: + raise pageops.PageOpsError("This page already has 100 attachments") + if upload is None or upload.size > MAX_UPLOAD_BYTES: + raise pageops.PageOpsError("Choose a file no larger than 16 MiB") + raw = upload.read(MAX_UPLOAD_BYTES + 1) + if not raw or len(raw) > MAX_UPLOAD_BYTES: + raise pageops.PageOpsError("Choose a non-empty file no larger than 16 MiB") + label = Path(upload.name.replace("\\", "/")).name + label = "".join(c for c in label if c.isprintable())[:200] or "attachment" + suffix = Path(label).suffix.lower() + if not re.fullmatch(r"\.[a-z0-9]{1,12}", suffix): + suffix = "" + stem = slugify(Path(label).stem)[:80] or "attachment" + identity = uuid.uuid4().hex + name = f"upload-{identity}-{stem}{suffix}" + path = pageops.assets_dir(space) / name + # Exclusive creation and the shared writer lock preserve every older asset. + if path.exists() or path.is_symlink(): + raise pageops.PageOpsError("Attachment address already exists") + meta = _metadata(space, doc, [*files, {"name": label, "path": "assets/" + name}]) + record = { + "id": identity, "version": 1, "op": "attachment_upload", "phase": "started", + "source_space": space, "dest_space": space, + "source_page": doc.slug, "dest_page": doc.slug, + "source_revision": doc.revision, + "dest_revision": hashlib.sha256( + pageops.dump_frontmatter(meta, doc.body).encode()).hexdigest(), + "assets": [name], "asset_sha256": hashlib.sha256(raw).hexdigest(), + } + pageops._write_record(record) + try: + with open_local_asset(space, name, create=True) as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + pageops._fsync_directory(path.parent) + result = _save(space, doc, meta) + if result.revision != record["dest_revision"]: + raise pageops.PageOpsError("Attachment publication needs operator review") + pageops._clear_record(record) + return result + except BaseException: + pageops._recover_record(record) + raise + + +@pageops.content_writer +def remove(space, page, reference, *, revision): + doc = _current(space, page, revision) + files = entries(doc.meta) + remaining = [entry for entry in files if entry["path"] != reference] + if len(remaining) == len(files): + raise pageops.PageOpsError("Attachment not found on this page") + # File bytes remain available to retained revisions and other page references. + return _save(space, doc, _metadata(space, doc, remaining)) + + +def recover(record): + """Keep revision-referenced complete bytes; discard only this failed upload.""" + try: + identity = record["id"] + if not re.fullmatch(r"[a-f0-9]{32}", identity): + return "held" + space, page = record["source_space"], record["source_page"] + if (record.get("version") != 1 or record.get("phase") != "started" + or record["dest_space"] != space or record["dest_page"] != page + or len(record["assets"]) != 1): + return "held" + name = record["assets"][0] + if not name.startswith(f"upload-{identity}-") or len(asset_parts(name)) != 1: + return "held" + for field in ("source_revision", "dest_revision", "asset_sha256"): + if not re.fullmatch(r"[a-f0-9]{64}", record[field]): + return "held" + current = pageops.page_file(space, page) + if pageops.assets_dir(space).is_symlink(): + return "held" + revision = pageops.content_root() / ".revisions" / space / page / ( + record["dest_revision"] + ".md") + if any(p.is_symlink() for p in (revision, *revision.parents)): + return "held" + if revision.exists(): + if pageops._file_revision(revision) != record["dest_revision"]: + return "held" + with open_local_asset(space, name) as stream: + if hashlib.file_digest(stream, "sha256").hexdigest() != record["asset_sha256"]: + return "held" + elif current.is_file() and pageops._file_revision(current) == record["source_revision"]: + pageops._discard_recorded_assets(space, [name]) + else: + return "held" + pageops._clear_record(record) + return "cleared" + except (KeyError, TypeError, ValueError, OSError, pageops.PageOpsError): + return "held" diff --git a/coulomb_social/apps/spaces/pageops.py b/coulomb_social/apps/spaces/pageops.py index 0ad7b16..cc48cce 100644 --- a/coulomb_social/apps/spaces/pageops.py +++ b/coulomb_social/apps/spaces/pageops.py @@ -448,6 +448,9 @@ def _finish_transfer_record(record, source_path, dest_confirmed): def _recover_record(record): + if isinstance(record, dict) and record.get("op") == "attachment_upload": + from .attachments import recover + return recover(record) if record == "held" or not _record_is_actionable(record): logger.error("content recovery left a journal for operator review") return "held" @@ -661,7 +664,8 @@ _BODY_ASSET = re.compile( ) -def _copy_assets(source_space, dest_space, dest_page, visual, body, before_create=None): +def _copy_assets(source_space, dest_space, dest_page, visual, body, before_create=None, + attached=()): """Copy local Visual and Markdown link/image destinations as one staged set.""" from .assets import asset_parts, open_local_asset @@ -725,7 +729,8 @@ def _copy_assets(source_space, dest_space, dest_page, visual, body, before_creat body = _BODY_ASSET.sub( lambda match: match["prefix"] + copy_reference(match["url"]), body, ) - return visual, body, created + attached = [{**entry, "path": copy_reference(entry["path"])} for entry in attached] + return visual, body, created, attached except BaseException: for path in created: path.unlink(missing_ok=True) @@ -764,8 +769,11 @@ def copy_page( on_asset(name) try: - visual, body, created = _copy_assets( + from .attachments import KEY, entries + + visual, body, created, attached = _copy_assets( source_space, dest_space, dest_page, src.visual, src.body, note_asset, + attached=entries(src.meta), ) new_title = title if title is not None else (src.title or dest_page) if title is None and new_title == src.title and source_space == dest_space: @@ -774,7 +782,8 @@ def copy_page( dest_space, dest_page, title=new_title, abstractor=src.abstractor, visual=visual, body=body, expected_revision="", extra_meta={k: v for k, v in src.meta.items() - if k not in {"title", "abstractor", "visual", "space"}}, + if k not in {"title", "abstractor", "visual", "space", KEY}} + | ({KEY: json.dumps(attached)} if KEY in src.meta else {}), ) if record is not None: record["dest_revision"] = doc.revision diff --git a/coulomb_social/apps/spaces/urls.py b/coulomb_social/apps/spaces/urls.py index 41991f2..17b995f 100644 --- a/coulomb_social/apps/spaces/urls.py +++ b/coulomb_social/apps/spaces/urls.py @@ -12,6 +12,9 @@ urlpatterns = [ ), path("new/", views.space_create, name="create"), path("/assets/", views.space_asset, name="asset"), + path("/downloads/", views.space_download, name="download"), + path("/pages//attachments/", + views.page_attachments, name="page_attachments"), path("/edit/", views.space_edit, name="edit"), path("/sharing/", views.space_sharing, name="sharing"), path("/archive/", views.space_archive, name="archive"), diff --git a/coulomb_social/apps/spaces/views.py b/coulomb_social/apps/spaces/views.py index 2f2056a..7433e55 100644 --- a/coulomb_social/apps/spaces/views.py +++ b/coulomb_social/apps/spaces/views.py @@ -2,6 +2,7 @@ import hashlib import hmac import json import logging +from pathlib import PurePosixPath from django.conf import settings from django.contrib import messages @@ -21,8 +22,8 @@ from django.views.decorators.http import require_http_methods, require_POST, req from coulomb_social.apps.core.principal import build_principal -from . import pageops -from .assets import local_asset_url, open_local_image +from . import attachments, pageops +from .assets import local_asset_url, open_local_asset, open_local_image from .content import ( clear_content_cache_for_repo, clear_content_cache_for_space, @@ -79,6 +80,81 @@ def space_asset(request: HttpRequest, slug: str, asset_path: str) -> HttpRespons return response +@login_required +@require_safe +def space_download(request, slug, asset_path): + principal, forbid = _principal_or_forbid(request, "space_asset") + if forbid: + return forbid + space = get_space_for_member(principal.get("member"), slug) + if space is None or Space.objects.filter(slug=slug).exclude(pk=space.pk).exists(): + return HttpResponseNotFound("Attachment not found.") + try: + file = open_local_asset(slug, asset_path) + except (OSError, ValueError): + return HttpResponseNotFound("Attachment not found.") + response = FileResponse(file, as_attachment=True, filename=PurePosixPath(asset_path).name, + content_type="application/octet-stream") + response["Cache-Control"] = "private, no-store" + response["X-Content-Type-Options"] = "nosniff" + response["Content-Security-Policy"] = "default-src 'none'; sandbox" + return response + + +@login_required +@require_http_methods(["GET", "POST"]) +def page_attachments(request, slug, page_slug): + principal, forbid = _principal_or_forbid( + request, "page_edit" if request.method == "POST" else "space_detail") + if forbid: + return forbid + member = principal.get("member") + space = get_space_for_member(member, slug) + if space is None: + return HttpResponseNotFound("Page not found.") + can_write = can_write_space(member, space) + if request.method == "POST" and not can_write: + return HttpResponseForbidden("You have read-only access to this space.") + try: + doc = pageops.read_page(slug, page_slug) + if not doc.exists: + return HttpResponseNotFound("Page not found.") + if request.method == "POST": + try: + length = int(request.META.get("CONTENT_LENGTH") or 0) + except (ValueError, TypeError): + return HttpResponse("Invalid upload length.", status=400) + if length < 0: + return HttpResponse("Invalid upload length.", status=400) + if length > attachments.MAX_UPLOAD_BYTES + 65536: + return HttpResponse("Choose a file no larger than 16 MiB.", status=413) + action = request.POST.get("action") + if action == "add": + attachments.add(slug, page_slug, request.FILES.get("file"), + revision=request.POST.get("revision")) + messages.success(request, "Attachment added.") + elif action == "remove": + attachments.remove(slug, page_slug, request.POST.get("reference"), + revision=request.POST.get("revision")) + messages.success( + request, "Attachment removed from this page. Earlier revisions retain it.") + else: + raise pageops.PageOpsError("Choose an attachment action") + clear_content_cache_for_space(space) + return redirect("spaces:page_attachments", slug=slug, page_slug=page_slug) + files = attachments.page_files(slug, doc) + except (pageops.PageOpsError, OSError) as exc: + messages.error(request, _mutation_error(exc)) + if request.method == "POST": + return redirect("spaces:page_attachments", slug=slug, page_slug=page_slug) + return HttpResponse("Attachments unavailable.", status=409) + response = render(request, "spaces/attachments.html", { + "space": space, "doc": doc, "files": files, "can_write": can_write, + }) + response["Cache-Control"] = "private, no-store" + return response + + def _principal_or_forbid(request: HttpRequest, resource: str): principal = build_principal(request, authz_resource_id=resource) if not principal["authz_allow"]: diff --git a/coulomb_social/templates/spaces/attachments.html b/coulomb_social/templates/spaces/attachments.html new file mode 100644 index 0000000..5e74aa3 --- /dev/null +++ b/coulomb_social/templates/spaces/attachments.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Attachments — {{ doc.title }}{% endblock %} +{% block content %} +

← {{ doc.title }}

+

Attachments

+

Files for {{ doc.title }}. Access follows this space’s sharing settings.

+
+
    + {% for file in files %} +
  • + {{ file.name }} + {% if file.open_url %}Open image{% endif %} + Download + {% if can_write %} + {% if file.managed %} +
    + {% csrf_token %} + + + + +
    + {% else %} + Edit page reference + {% endif %} + {% endif %} +
  • + {% empty %} +
  • No attachments on this page.
  • + {% endfor %} +
+
+{% if can_write %} +
+

Add attachment

+
+ {% csrf_token %} + + +


+

+ +
+

Removing a file from this page keeps it in earlier revisions. Existing links and visuals can be changed in the page editor.

+
+{% else %} +

Read-only access. Authors can add or remove attachments.

+{% endif %} +{% endblock %} diff --git a/coulomb_social/templates/spaces/detail.html b/coulomb_social/templates/spaces/detail.html index 14f3571..b61e246 100644 --- a/coulomb_social/templates/spaces/detail.html +++ b/coulomb_social/templates/spaces/detail.html @@ -109,6 +109,12 @@ title="Copy the page content to paste into an email" aria-controls="copy-content-status"> Copy content to clipboard + {% if page.source == 'content-plane' %} +
+ Attachments +

{% if can_write %}Open, download or manage attachments{% else %}Open or download attachments{% endif %}

+
+ {% endif %} {% endif %} {% if can_write %} Edit page diff --git a/scripts/test-attachments-browser.mjs b/scripts/test-attachments-browser.mjs new file mode 100644 index 0000000..ab7589d --- /dev/null +++ b/scripts/test-attachments-browser.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import {chromium} from 'playwright'; + +const base = new URL(process.argv[2]); +assert.equal(base.protocol, 'http:'); +assert.ok(['localhost', '127.0.0.1'].includes(base.hostname)); +const browser = await chromium.launch({headless: true}); +try { + const context = await browser.newContext(); + const unexpected = []; + await context.route('**/*', route => { + if (new URL(route.request().url()).origin === base.origin) return route.continue(); + unexpected.push(route.request().url()); + return route.abort(); + }); + const page = await context.newPage(); + page.setDefaultTimeout(10000); + async function login(subject) { + await page.goto(`${base.origin}/auth/dev-login/`); + await page.getByLabel('Subject').fill(subject); + await page.getByLabel('Tenant').fill('tenant:attachment-test'); + await page.getByRole('button', {name: 'Establish session'}).click(); + await page.waitForURL('**/app/'); + } + const shared = `${base.origin}/app/spaces/attachments-shared/`; + const destination = `${base.origin}/app/spaces/attachments-private/`; + const source = `${shared}?page=notes`; + async function menu(url, author) { + await page.goto(url); + await page.locator('summary', {hasText: 'Attachments'}).click(); + await page.getByRole('link', {name: author + ? 'Open, download or manage attachments' : 'Open or download attachments', exact: true}).click(); + await page.waitForURL('**/attachments/'); + } + async function downloadedBytes() { + const pending = page.waitForEvent('download'); + await page.getByRole('link', {name: 'Download', exact: true}).click(); + const stream = await (await pending).createReadStream(); + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + return Buffer.concat(chunks).toString(); + } + await login('file-author'); + await menu(source, true); + await page.getByLabel('File (up to 16 MiB)').setInputFiles({ + name: 'meeting-notes.txt', mimeType: 'text/plain', buffer: Buffer.from('Synthetic attachment bytes'), + }); + await page.getByRole('button', {name: 'Add attachment', exact: true}).click(); + await page.waitForURL('**/attachments/'); + assert.match(await page.locator('main').innerText(), /Attachment added/); + const originalDownload = await page.getByRole('link', {name: 'Download', exact: true}).getAttribute('href'); + assert.equal(await downloadedBytes(), 'Synthetic attachment bytes'); + await login('file-reader'); + await menu(source, false); + assert.equal(await page.getByRole('button', {name: 'Add attachment', exact: true}).count(), 0); + assert.equal(await page.getByRole('button', {name: 'Remove from page', exact: true}).count(), 0); + assert.equal(await downloadedBytes(), 'Synthetic attachment bytes'); + await login('file-outsider'); + assert.equal((await page.goto(new URL(originalDownload, base).href)).status(), 404); + assert.equal((await page.goto(`${shared}pages/notes/attachments/`)).status(), 404); + await login('file-author'); + await page.goto(source); + await page.getByRole('link', {name: 'Duplicate page', exact: true}).click(); + await page.getByLabel('Destination space').selectOption('attachments-private'); + await page.getByLabel('Title', {exact: false}).fill('Independent files'); + await page.getByLabel('New page slug', {exact: false}).fill('copied'); + await page.getByRole('button', {name: 'Create duplicate'}).click(); + await page.waitForURL('**?page=copied'); + await menu(`${destination}?page=copied`, true); + assert.equal(await downloadedBytes(), 'Synthetic attachment bytes'); + const copiedDownload = await page.getByRole('link', {name: 'Download', exact: true}).getAttribute('href'); + assert.notEqual(copiedDownload, originalDownload); + await page.getByRole('button', {name: 'Remove from page', exact: true}).click(); + await page.waitForURL('**/attachments/'); + assert.match(await page.locator('main').innerText(), /No attachments on this page/); + await menu(source, true); + assert.equal(await downloadedBytes(), 'Synthetic attachment bytes'); + await page.goto(source); + await page.getByRole('link', {name: 'Transfer', exact: true}).click(); + await page.getByLabel('Destination space').selectOption('attachments-private'); + await page.getByLabel('Slug in destination', {exact: false}).fill('moved'); + await page.getByRole('button', {name: 'Transfer page', exact: true}).click(); + await page.waitForURL('**?page=moved'); + await menu(`${destination}?page=moved`, true); + assert.equal(await downloadedBytes(), 'Synthetic attachment bytes'); + const movedDownload = await page.getByRole('link', {name: 'Download', exact: true}).getAttribute('href'); + await login('file-reader'); + assert.equal((await page.goto(source)).status(), 404); + assert.equal((await page.goto(new URL(movedDownload, base).href)).status(), 404); + assert.deepEqual(unexpected, []); + console.log('Attachment menu upload, download, independent copy, move and removal passed'); +} finally { + await browser.close(); +} diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 0000000..5533436 --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,239 @@ +"""Author-managed attachments preserve permissions, revisions and crash recovery.""" + +import os +import subprocess +import sys + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.urls import reverse + +from coulomb_social.apps.spaces import attachments, pageops +from coulomb_social.apps.spaces.assets import local_download_url +from coulomb_social.apps.spaces.content import load_space_page +from coulomb_social.apps.spaces.models import SpaceMembership +from coulomb_social.apps.spaces.services import create_space, put_space_page +from tests.test_space_permissions import make_member + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def people(tmp_path, settings, client): + settings.CONTENT_ROOT = str(tmp_path / "content") + owner, reader, outsider = [make_member(name) for name in ("author", "reader", "outsider")] + space = create_space(owner, title="Shared files", slug="shared-files") + SpaceMembership.objects.create(space=space, member=reader, role="viewer") + doc = put_space_page(owner, space, "notes", title="Notes", body="Retained notes", + extra_meta={"source_created_at": "2020-01-02T03:04:05Z"}) + client.force_login(owner.user) + return space, doc, owner, reader, outsider + + +def upload(space, doc, name="Report.pdf", data=b"synthetic PDF"): + return attachments.add(space.slug, doc.slug, SimpleUploadedFile(name, data), + revision=doc.revision) + + +def test_upload_download_and_remove_preserve_source_and_retained_revisions(people, client): + space, doc, _, reader, _ = people + url = reverse("spaces:page_attachments", args=[space.slug, doc.slug]) + result = client.post(url, {"action": "add", "revision": doc.revision, + "file": SimpleUploadedFile("Report.pdf", b"synthetic PDF")}) + assert result.status_code == 302 + added = pageops.read_page(space.slug, doc.slug) + entry, = attachments.entries(added.meta) + assert entry["name"] == "Report.pdf" + assert added.body == doc.body + assert added.meta["source_created_at"] == doc.meta["source_created_at"] + client.force_login(reader.user) + response = client.get(local_download_url(space.slug, entry["path"])) + assert response.status_code == 200 + assert response["Content-Type"] == "application/octet-stream" + assert response["Content-Disposition"].startswith("attachment;") + assert response["Cache-Control"] == "private, no-store" + assert response["X-Content-Type-Options"] == "nosniff" + assert b"".join(response.streaming_content) == b"synthetic PDF" + client.force_login(people[2].user) + assert client.post(url, {"action": "remove", "revision": added.revision, + "reference": entry["path"]}).status_code == 302 + current = pageops.read_page(space.slug, doc.slug) + assert attachments.entries(current.meta) == [] and current.body == doc.body + assert (pageops.space_dir(space.slug) / entry["path"]).read_bytes() == b"synthetic PDF" + retained = pageops.content_root() / ".revisions" / space.slug / doc.slug + assert (retained / (doc.revision + ".md")).is_file() + assert entry["path"] in (retained / (added.revision + ".md")).read_text() + + +def test_attachment_permissions_and_revocation_cover_reads_and_mutations(people, client): + space, doc, owner, reader, outsider = people + added = upload(space, doc) + entry, = attachments.entries(added.meta) + url = reverse("spaces:page_attachments", args=[space.slug, doc.slug]) + download = local_download_url(space.slug, entry["path"]) + foreign = make_member("foreign", tenant="tenant:b") + SpaceMembership.objects.create(space=space, member=foreign, role="owner") + client.force_login(reader.user) + assert client.get(url).status_code == 200 + assert b"Add attachment" not in client.get(url).content + assert client.post(url, {"action": "remove"}).status_code == 403 + for member in (outsider, foreign): + client.force_login(member.user) + assert client.get(url).status_code == client.get(download).status_code == 404 + assert client.post(url, {"action": "add"}).status_code == 404 + SpaceMembership.objects.filter(space=space, member=reader).delete() + client.force_login(reader.user) + assert client.get(url).status_code == client.get(download).status_code == 404 + client.force_login(owner.user) + assert client.post(download).status_code == 405 + space.is_active = False + space.save() + assert client.get(download).status_code == 404 + client.logout() + assert client.get(url).status_code == client.get(download).status_code == 302 + + +def test_stale_missing_or_foreign_attachment_actions_do_not_change_files(people, client): + space, doc, owner, _, _ = people + added = upload(space, doc) + before = {str(p): p.read_bytes() for p in pageops.content_root().rglob('*') if p.is_file()} + for revision in (doc.revision, None, ""): + with pytest.raises(pageops.PageOpsError, match="changed"): + attachments.add(space.slug, doc.slug, SimpleUploadedFile("more.txt", b"more"), + revision=revision) + with pytest.raises(pageops.PageOpsError, match="not found"): + attachments.remove(space.slug, doc.slug, "assets/elsewhere.txt", revision=added.revision) + after = {str(p): p.read_bytes() for p in pageops.content_root().rglob('*') if p.is_file()} + assert after == before + guarded = Client(enforce_csrf_checks=True) + guarded.force_login(owner.user) + url = reverse("spaces:page_attachments", args=[space.slug, doc.slug]) + assert guarded.post(url, {"action": "remove"}).status_code == 403 + + +@pytest.mark.parametrize("path", ["../pages/notes.md", "nested/../../private", "..%2fsecret", + "missing", "link.txt", "folder/link.txt"]) +def test_download_refuses_missing_traversal_and_symlinks(people, client, tmp_path, path): + space, _, _, _, _ = people + secret = tmp_path / "secret.txt" + secret.write_text("private") + (pageops.assets_dir(space.slug) / "link.txt").symlink_to(secret) + (pageops.assets_dir(space.slug) / "folder").symlink_to(tmp_path, target_is_directory=True) + assert client.get(reverse("spaces:download", args=[space.slug, path])).status_code == 404 + + +def test_active_content_is_download_only_and_filenames_are_escaped(people, client): + space, doc, _, _, _ = people + added = upload(space, doc, '.html', b'') + entry, = attachments.entries(added.meta) + response = client.get(local_download_url(space.slug, entry["path"])) + assert response["Content-Disposition"].startswith("attachment;") + assert response["Content-Type"] == "application/octet-stream" + assert "sandbox" in response["Content-Security-Policy"] + assert b"".join(response.streaming_content) == b'' + menu = client.get(reverse("spaces:page_attachments", args=[space.slug, doc.slug])) + assert b"