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) + '
Observed source metadata only. ' + 'No target identities or grants have been inferred.
' + + html.escape(json.dumps(report['summary'], indent=2)) + '