Inventory retained source access fields without inferring grants
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
parent
5c8f78ce43
commit
52168c3bd4
6 changed files with 476 additions and 0 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
131
docs/release/bubble-access-audit-2026-10-31.json
Normal file
131
docs/release/bubble-access-audit-2026-10-31.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
190
scripts/audit-bubble-access.py
Normal file
190
scripts/audit-bubble-access.py
Normal file
|
|
@ -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('<details><summary>' + html.escape(kind + ': ' + row['source_id'])
|
||||
+ '</summary><pre>' + html.escape(data) + '</pre></details>')
|
||||
return ('<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
|
||||
'base-uri \'none\'; form-action \'none\'">'
|
||||
'<title>Private source access inventory</title></head><body>'
|
||||
'<h1>Private source access inventory</h1><p>Observed source metadata only. '
|
||||
'No target identities or grants have been inferred.</p><pre>'
|
||||
+ html.escape(json.dumps(report['summary'], indent=2)) + '</pre><ul>'
|
||||
+ ''.join('<li>' + html.escape(x) + '</li>' for x in report['limitations'])
|
||||
+ '</ul>' + ''.join(rows) + '</body></html>').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())
|
||||
110
tests/test_bubble_access_audit.py
Normal file
110
tests/test_bubble_access_audit.py
Normal file
|
|
@ -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'] = '<script>alert(1)</script>'
|
||||
result = audit.review_html(audit.audit(data))
|
||||
assert b'<script>' not in result
|
||||
assert b'<script>' in result
|
||||
assert b"default-src 'none'" in result
|
||||
assert b"form-action 'none'" in result
|
||||
|
||||
|
||||
def source_bundle(tmp_path):
|
||||
tmp_path.chmod(0o700)
|
||||
source = tmp_path / 'source'
|
||||
source.mkdir(mode=0o700)
|
||||
capture = source / 'source-capture'
|
||||
capture.mkdir(mode=0o700)
|
||||
hashes = {}
|
||||
for kind, values in inputs().items():
|
||||
raw = json.dumps(values).encode()
|
||||
path = capture / (kind + '.json')
|
||||
path.write_bytes(raw)
|
||||
path.chmod(0o600)
|
||||
hashes[kind] = hashlib.sha256(raw).hexdigest()
|
||||
path = source / 'reconciliation.json'
|
||||
path.write_text(json.dumps({'schema': 'coulomb.bubble-review-bundle.v1',
|
||||
'status': 'completed_offline_preparation', 'source_sha256': hashes}))
|
||||
path.chmod(0o600)
|
||||
return source
|
||||
|
||||
|
||||
def test_source_binding_private_modes_and_no_overwrite(tmp_path):
|
||||
source = source_bundle(tmp_path)
|
||||
before = {p: p.read_bytes() for p in source.rglob('*') if p.is_file()}
|
||||
out = tmp_path / 'review'
|
||||
result = audit.prepare(source, out)
|
||||
assert all(p.read_bytes() == raw for p, raw in before.items())
|
||||
assert not out.stat().st_mode & 0o077
|
||||
for name, digest in result['output_sha256'].items():
|
||||
assert hashlib.sha256((out / name).read_bytes()).hexdigest() == digest
|
||||
assert not (out / name).stat().st_mode & 0o077
|
||||
with pytest.raises(ValueError):
|
||||
audit.prepare(source, out)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('defect', ['drift', 'public', 'symlink', 'hardlink', 'nested-output'])
|
||||
def test_unsafe_custody_or_drift_refused(tmp_path, defect):
|
||||
source = source_bundle(tmp_path)
|
||||
path = source / 'source-capture/articles.json'
|
||||
out = tmp_path / 'review'
|
||||
if defect == 'drift':
|
||||
path.write_text('{}')
|
||||
elif defect == 'public':
|
||||
path.chmod(0o644)
|
||||
elif defect == 'symlink':
|
||||
saved = path.with_suffix('.saved')
|
||||
path.rename(saved)
|
||||
path.symlink_to(saved)
|
||||
elif defect == 'hardlink':
|
||||
path.with_suffix('.linked').hardlink_to(path)
|
||||
else:
|
||||
out = source / 'review'
|
||||
with pytest.raises(ValueError):
|
||||
audit.prepare(source, out)
|
||||
assert not out.exists()
|
||||
|
|
@ -116,6 +116,21 @@ body and new grants have not been inferred. No productive import occurred.
|
|||
Evidence: docs/release/bubble-source-continuation-2026-10-31.json and its source
|
||||
manifest. T01 remains progress; the login-renewal gate is cleared.
|
||||
|
||||
2026-10-31 access reconciliation: a hash-bound offline inventory now separates
|
||||
observed, missing, null, empty and unresolved access fields across all 70 fresh
|
||||
space records and 245 article records; the retained 71st space remains explicitly
|
||||
inventory-only. Source references normalize to three distinct principals. Nine
|
||||
Created By references cannot be normalized; absent membership fields remain
|
||||
unknown rather than empty grants. Space privacy and article visibility each have
|
||||
two observed values, whose authorization semantics are not established by this
|
||||
capture. The October 6 accepted import admits tegwick as owner of ReichelAG only;
|
||||
it does not establish a general source-account or role mapping for other spaces.
|
||||
Private per-record review: `~/.config/coulomb-social/auth/exports/access-review-20261031/index.html`.
|
||||
Aggregate proof: `docs/release/bubble-access-audit-2026-10-31.json`. No target
|
||||
identity mapping, grant proposal or import manifest was inferred. T01 remains
|
||||
progress until source exceptions and the actual identity/grant/assets/link
|
||||
reconciliation are resolved; no extra completion task was added for this audit.
|
||||
|
||||
## T02 — Review remaining journeys and record transfer decisions
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue