Prepare actual-renderer corpus review and independent recovery custody packet
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 8m7s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a17f16-d99c-7ea3-95f0-8212897fba6b
This commit is contained in:
tegwick 2026-10-28 00:54:17 +01:00
parent ed2c8e2f4d
commit 17d0eb67f3
10 changed files with 395 additions and 8 deletions

View file

@ -87,6 +87,15 @@ def page_path_for(space: Space, page_slug: str) -> str:
return f"{root}/{filename}" if root else filename
def render_markdown_unfiltered(text: str) -> str:
"""Intermediate HTML for sanitization/auditing; never serve this directly."""
return md_lib.markdown(
text,
extensions=["fenced_code", "tables", "nl2br", "sane_lists"],
output_format="html",
)
def render_markdown(text: str) -> str:
"""Render the supported Markdown subset.
@ -95,13 +104,8 @@ def render_markdown(text: str) -> str:
Relative asset URLs are kept so copied page images still resolve. This is
not acceptance of a private corpus or of arbitrary Bubble markup.
"""
raw_html = md_lib.markdown(
text,
extensions=["fenced_code", "tables", "nl2br", "sane_lists"],
output_format="html",
)
return bleach.clean(
raw_html,
render_markdown_unfiltered(text),
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRS,
protocols=_ALLOWED_PROTOCOLS,

View file

@ -0,0 +1,103 @@
"""Private corpus rendering evidence using the actual product renderer."""
from __future__ import annotations
import hashlib
import html
from collections import Counter
from html.parser import HTMLParser
from urllib.parse import quote
from .content import render_markdown, render_markdown_unfiltered
class _Structure(HTMLParser):
def __init__(self, value):
super().__init__(convert_charrefs=True)
self.tags = Counter()
self.attributes = Counter()
self.text = []
self.feed(value)
def handle_starttag(self, tag, attrs):
self.tags[tag] += 1
self.attributes.update((tag, key, value) for key, value in attrs)
handle_startendtag = handle_starttag
def handle_data(self, value):
self.text.append(value)
def audit_body(body: str):
raw = render_markdown_unfiltered(body)
rendered = render_markdown(body)
before, after = _Structure(raw), _Structure(rendered)
removed_attrs = before.attributes - after.attributes
text_changed = " ".join("".join(before.text).split()) != (
" ".join("".join(after.text).split()))
markup_changed = before.tags != after.tags or before.attributes != after.attributes
return {
"body_sha256": hashlib.sha256(body.encode()).hexdigest(),
"rendered_sha256": hashlib.sha256(rendered.encode()).hexdigest(),
"sanitization_changed_html": raw != rendered,
"markup_changed_by_sanitization": markup_changed,
"serialization_only_change": raw != rendered and not markup_changed and not text_changed,
"removed_tag_counts": dict(sorted((before.tags - after.tags).items())),
"added_tag_counts": dict(sorted((after.tags - before.tags).items())),
"removed_attributes": [
{"tag": tag, "attribute": key, "value": value, "count": count}
for (tag, key, value), count in sorted(
removed_attrs.items(), key=lambda item: repr(item[0]))
],
"visible_text_changed_by_sanitization": text_changed,
"original_bubble_render_compared": False,
"productive_fidelity_accepted": False,
}, rendered
def review_card(title: str, body: str, rendered: str, audit: dict, *, source_id="") -> bytes:
# Private review files must not load source images, frames, styles or scripts.
# Inert also prevents links/forms in the rendered sample from being activated.
original = ('<p><a target="_blank" rel="noopener noreferrer" href="'
+ html.escape("https://coulomb.social/pg_chunk/" + quote(source_id, safe=""),
quote=True)
+ '">Open original Bubble view</a> (opens only when clicked)</p>'
) if source_id else ""
return ("<!doctype html><html><head><meta charset=\"utf-8\">"
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; '
'style-src \'unsafe-inline\'; base-uri \'none\'; form-action \'none\'">'
"<title>Private rendering review</title><style>"
"body{font:16px system-ui;margin:2em}main{display:grid;grid-template-columns:1fr 1fr;"
"gap:2em}pre{white-space:pre-wrap;overflow-wrap:anywhere}section{min-width:0}"
"</style></head><body><h1>" + html.escape(title) + "</h1><p>"
"Product rendering only; compare with the original Bubble view before accepting. "
"Resources and interactive links within the sample are disabled.</p>" + original
+ "<p>Sanitization changed HTML: "
+ str(audit["sanitization_changed_html"]).lower()
+ "</p><main><section><h2>Exact captured source</h2><pre>" + html.escape(body)
+ "</pre></section><section><h2>Current product rendering</h2><div inert>"
+ rendered + "</div></section></main></body></html>").encode()
def review_index(records: list[dict]) -> bytes:
rows = []
for record in sorted(records, key=lambda r: (
not (r.get("markup_changed_by_sanitization") or r["status"] == "body_missing"), r["id"]
)):
label = html.escape(record["title"])
if record.get("card"):
label = '<a href="' + html.escape(record["card"], quote=True) + '">' + label + "</a>"
status = ("body missing" if record["status"] == "body_missing" else (
"markup change — review" if record["markup_changed_by_sanitization"] else (
"text change — review" if record["visible_text_changed_by_sanitization"]
else "no structural sanitizer change; original comparison still required")))
rows.append("<li>" + label + " — " + status + "</li>")
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 corpus rendering review</title></head><body>'
'<h1>Private corpus rendering review</h1><p>Compare each card with the original '
'Bubble view. Sanitizer differences are observations, not acceptance verdicts. '
'Body-missing and markup-change records come first.</p><ol>'
+ "".join(rows) + '</ol></body></html>').encode()

View file

@ -136,6 +136,22 @@ existing destinations and explicit tenant/identity/grants are reconciled. Keep
all drafts private. Do not feed the whole bundle to `import_content_corpus` or
replace a live tree to backfill dates.
Prepare private source/product rendering cards without contacting Bubble:
```bash
uv run python scripts/audit-bubble-rendering.py /private/checkpoint/articles.json \
--output /private/new-rendering-review
```
The tool invokes the actual product renderer under integration-disabled test
settings. It records body/render/card hashes and structural sanitizer changes,
retains missing-body records, and writes private `index.html`/cards plus a final
`audit.json` completion receipt. Output is new, outside the checkout, mode 0700
with files mode 0600. Samples have a restrictive resource policy and inert body
links/forms. The original Bubble view opens only through an explicit review link.
An unchanged sanitizer structure is not an original-rendering comparison or
productive fidelity acceptance. Use the UC-003–006 review walkthrough for verdicts.
Packaged SQL migration rehearsal (internal disposable PostgreSQL, no production):
```bash

View file

@ -0,0 +1,27 @@
{
"captured_records": 244,
"date": "2026-10-28",
"markup_changed_by_sanitization": 6,
"missing_bodies": 3,
"original_bubble_render_compared": false,
"previous_renderer_comparison_bodies": 241,
"previous_renderer_output_mismatches": 0,
"previous_renderer_source_commit": "ed2c8e2f4d3882b2933aa8b57f82af5654286de0",
"private_audit_sha256": "8363c619bfaf663f502a80f94108d3a4d5e26c997c51579c090bc8fea145a744",
"production_content_modified": false,
"productive_fidelity_accepted": false,
"removed_attributes_total": 17,
"removed_tags_total": 3,
"rendered_bodies": 241,
"renderer_policy_unchanged": true,
"sanitization_changed_html": 6,
"schema": "coulomb.corpus-rendering-summary.v1",
"serialization_only_changes": 0,
"source_sha256": "b00f84adb5de60c724b004e3f05d64f44c8f4ca4bd0f5fef826c4e323841e87a",
"visible_text_changed_by_sanitization": 0,
"browser_card_check": {
"review_cards_opened": 6,
"external_request_attempts": 0,
"card_samples_inert": true
}
}

View file

@ -0,0 +1,41 @@
# Coulomb independent recovery custody — review packet
WP-0012 cannot accept the first offsite recovery point until its key, version
receipt, image/tools and recovery read authority survive loss of both the primary
node and this workstation. The scoped publisher and its own pair delivery are
already admitted under decision 9557e2b8-2b37-5b94-a5e6-e5fe08d0351e; this packet
does not reopen that approval or the daily 03:15 UTC schedule.
## Proposed additional admission
| Boundary | Concrete proposed result |
| --- | --- |
| Owner | Railiance-platform, with platform-root as attended recovery custodian and failure recipient, matching the existing owner-operated recovery mechanism. |
| Application scope | Only `platform-pg/application-archives/coulomb-social/` in the existing primary bucket; no email-connect or native database archive access. |
| Recovery reader | A separately held provider caller allowed only exact-prefix/version reads and necessary listing. Prove denied creation, deletion/version deletion, sibling access and IAM/bucket administration. Do not reuse the scheduled publisher pair as the independent reader. |
| Encryption | A dedicated Coulomb age recipient/identity, generated and retained through platform-owned custody. The worker receives only the public recipient. Do not retrieve or reuse email-connect's private identity or reader. |
| Independent kit | A Coulomb-only protected location outside the primary node/workstation stores the private identity under custodian access, exact image/recovery tools and public version/hash/size receipts. The publisher cannot read escrow or alter/delete protected recovery material. |
| Retention | Rolling archives: retain the existing proposed 14 daily/4 weekly selections only after lifecycle/headroom compatibility is proved. Store the exact last successfully recovered point and its kit outside rolling expiration; protected kit/point has no automatic expiry until replaced and verified. |
| Local historical copies | Preserve existing private production-pair/drill copies. Inventory their locations/counts privately; obtain explicit owner disposition rather than deleting them as part of activation. |
| Acceptance | Native positive/negative caller checks, independently retrieved key/kit/receipt, exact-version archive retrieval, decryption and isolated app/content/membership/denied-read rehearsal, measured timing and received failure/stale evidence. Only then activate timers. |
This adds recovery custody and read authority; the previous publisher approval
does not itself admit these boundaries. No new identity, provider grant, key,
protected location, capture/upload, timer or lifecycle has been created by this
packet. No credentials belong in a reply.
## Execution after admission
Reuse RPF-WP-0053-T02/T03/T04 and its governed platform custody front door.
Record exact owner approval and source references before provisioning/delivery.
Choose and record actual provider/project/protected-prefix coordinates through
that owner, verify their boundaries, then bind the public recipient and recovery
kit to the consumer. Do not infer a current grant from the older generic Nextcloud
route or from the separately accepted email-ledger recovery.
Run the already admitted coherent first capture/publication with the application
resumed before transfer. Retain its exact object version/hash receipt independently,
retrieve through the new reader, decrypt only in the custodian environment and
restore into an isolated instance. No production restore is included. Refresh
the snapshot helper's pinned image after any later app release, and repeat the
paired recovery proof when its schema changes materially.

View file

@ -18,3 +18,25 @@ For each journey reply `accepted`, `gap` or `deferred`, with a short explanation
An observed gap becomes a live task before that journey is accepted. Weekly-call
acceptance remains UC-008 only. A source-date check for newly migrated pages and
all their chunks is also required under the user's October 27 migration instruction.
## Private corpus rendering cards — 2026-10-28
`scripts/audit-bubble-rendering.py` now prepares private source/product cards and
an index using the actual product renderer. The current capture has 244 article
records: 241 bodies render and three bodies are missing. Six records change
markup under sanitization (three removed custom tags and 17 removed attributes
in total); no extracted-text change was observed between intermediate Markdown
HTML and sanitized HTML. These counts do not compare with Bubble's own rendering
and do not supply an acceptance verdict. Source text/bytes remain preserved.
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:
meaning and placeholders, line breaks, lists/tables, Visual and any body-linked
resources. Record `accepted`, `gap` or `deferred` per record, keeping its private
source ID in the private report. Open gaps remain WP-0013-T02; resolve or explicitly
accept intended differences before marking the corresponding cohort transferred.
Review the body-missing records as source exceptions; never substitute an empty
page. Card samples disable resource loading and interaction, so image/resource
fidelity needs the separate authorized asset review. Then apply UC-003–006 above
to disposable pages on the exact intended release.

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Offline private rendering cards and lossiness report; never grants acceptance."""
import argparse
import hashlib
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# The standalone tool uses an integration-disabled, in-memory configuration.
os.environ["DJANGO_SETTINGS_MODULE"] = "coulomb_social.settings.test"
import django # noqa: E402
django.setup()
from coulomb_social.apps.spaces.bubble_transfer import _records # noqa: E402
from coulomb_social.apps.spaces.rendering_audit import ( # noqa: E402
audit_body,
review_card,
review_index,
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("articles", type=Path, help="Private captured articles.json")
parser.add_argument("--output", type=Path, required=True, help="New private directory")
args = parser.parse_args()
try:
output = args.output.expanduser().resolve()
source = args.articles.expanduser().resolve()
repo = Path(__file__).resolve().parents[1]
if output.is_relative_to(repo) or source.is_relative_to(output):
raise ValueError("Unsafe output location")
captured = source.read_bytes()
records = _records(json.loads(captured))
report = {"schema": "coulomb.bubble-rendering-audit.v1",
"source_sha256": hashlib.sha256(captured).hexdigest(),
"original_bubble_render_compared": False,
"productive_fidelity_accepted": False, "records": []}
cards = {}
for identity, article in sorted(records.items()):
body = article.get("content_text")
title = str(article.get("nomer_text") or identity)
if not isinstance(body, str):
report["records"].append({"id": identity, "title": title, "status": "body_missing"})
continue
audit, rendered = audit_body(body)
card = hashlib.sha256(identity.encode()).hexdigest() + ".html"
cards[card] = review_card(title, body, rendered, audit, source_id=identity)
report["records"].append({"id": identity, "title": title,
"status": "rendered", "card": card,
"card_sha256": hashlib.sha256(cards[card]).hexdigest(),
**audit})
output.mkdir(mode=0o700)
for name, data in sorted({**cards, "index.html": review_index(report["records"])}.items()):
fd = os.open(output / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as stream:
stream.write(data)
report["status"] = "completed_offline_rendering_audit"
fd = os.open(output / "audit.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as stream:
json.dump(report, stream, indent=2, sort_keys=True)
stream.write("\n")
print(json.dumps({
"records": len(records), "rendered_bodies": len(cards),
"sanitization_changed_html": sum(r.get("sanitization_changed_html", False)
for r in report["records"]),
"markup_changed_by_sanitization": sum(r.get("markup_changed_by_sanitization", False)
for r in report["records"]),
"serialization_only_changes": sum(r.get("serialization_only_change", False)
for r in report["records"]),
"productive_fidelity_accepted": False,
}))
except (ValueError, OSError, TypeError):
print("Rendering audit refused; private files retained. "
"Without audit.json output is incomplete.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,68 @@
"""Rendering review uses synthetic notes and never accepts private corpus fidelity."""
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
from coulomb_social.apps.spaces.content import render_markdown
from coulomb_social.apps.spaces.rendering_audit import audit_body, review_card
def test_audit_uses_product_renderer_and_reports_custom_tags_attributes_and_schemes():
body = ('# Synthetic\n\n<topic>Placeholder</topic><br>Text\n'
'<img src="assets/picture.png" onerror="synthetic()">'
'<a href="javascript:synthetic()">Unsafe</a>')
audit, rendered = audit_body(body)
assert rendered == render_markdown(body)
assert audit["sanitization_changed_html"]
assert audit["markup_changed_by_sanitization"]
assert not audit["serialization_only_change"]
assert audit["removed_tag_counts"] == {"topic": 1}
assert {a["attribute"] for a in audit["removed_attributes"]} == {"onerror", "href"}
assert "assets/picture.png" in rendered and "<br>" in rendered
assert audit["body_sha256"] == hashlib.sha256(body.encode()).hexdigest()
assert not audit["original_bubble_render_compared"]
assert not audit["productive_fidelity_accepted"]
def test_serialized_line_break_difference_is_not_reported_as_lost_markup():
audit, _ = audit_body("First<br/>Second")
assert audit["serialization_only_change"]
assert not audit["markup_changed_by_sanitization"]
assert not audit["visible_text_changed_by_sanitization"]
def test_review_card_escapes_source_and_blocks_resources_and_interaction():
body = '<img src="https://example.invalid/synthetic.png"><script>synthetic()</script>'
audit, rendered = audit_body(body)
card = review_card("</h1><script>synthetic()</script>", body, rendered, audit,
source_id='a/"?b').decode()
assert "<script>" not in card
assert "&lt;script&gt;" in card
assert "default-src 'none'" in card
assert "base-uri 'none'" in card and "form-action 'none'" in card
assert "<div inert>" in card
assert 'https://coulomb.social/pg_chunk/a%2F%22%3Fb' in card
def test_cli_keeps_missing_bodies_and_private_permissions_and_refuses_overwrite(tmp_path):
repo = Path(__file__).resolve().parents[1]
source = tmp_path / "articles.json"
source.write_text(json.dumps({"a": {"content_text": "Synthetic", "nomer_text": "Private"},
"b": {"_id": "b"}}))
output = tmp_path / "audit"
cmd = [sys.executable, str(repo / "scripts/audit-bubble-rendering.py"), str(source),
"--output", str(output)]
result = subprocess.run(cmd, capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert "Private" not in result.stdout
report = json.loads((output / "audit.json").read_text())
assert len(report["records"]) == 2
assert report["records"][1]["status"] == "body_missing"
assert (output / "index.html").is_file()
assert os.stat(output).st_mode & 0o777 == 0o700
assert all(os.stat(p).st_mode & 0o777 == 0o600 for p in output.iterdir())
assert subprocess.run(cmd, capture_output=True).returncode == 1

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: coulomb-social
created: "2026-10-08"
updated: "2026-10-27"
updated: "2026-10-28"
origin: residual
origin_ref: CSOC-WP-0008
state_hub_workstream_id: "0cd1f513-f26e-5707-b646-8b4bbe6369fc"
@ -44,6 +44,14 @@ with the same ServiceAccount name also denies403, no data or sibling credential
read. Receipts: docs/release/backup-version-delete-boundary-2026-10-27.json and
backup-wrong-namespace-boundary-2026-10-27.json.
2026-10-28: Prepared docs/release/recovery-custody-review-2026-10-28.md for the
additional independent custody boundary: dedicated Coulomb age identity,
separate prefix-read-only recovery caller, platform-root recovery/failure owner,
protected off-node/workstation kit and last recovered point, and explicit rolling
retention/headroom compatibility. Requested that additional admission, without
reopening the existing publisher/executor or schedule approval. No recovery key,
grant, kit location, capture, timer or lifecycle created while it is pending.
## T02 — Activate and prove the first encrypted paired recovery point
```task

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: coulomb-social
created: "2026-10-08"
updated: "2026-10-27"
updated: "2026-10-28"
origin: residual
origin_ref: CSOC-WP-0008
state_hub_workstream_id: "70370f4e-7cba-5b0a-853b-fedcd2bd49a5"
@ -72,6 +72,19 @@ discards an unconfirmed destination. The source is deleted only when its bytes
still match the journal. Alias changes do not grant destination access. The
live content root has not been purged or recovered.
2026-10-28: The refreshed all-space capture supplies privately scoped corpus
examples without sending note bodies to inference. Added deterministic audit/cards
using the actual product renderer and the same intermediate Markdown path, with
no renderer policy change. All 244 captured article records remain represented;
241 bodies render, three are missing. Six records have structural sanitizer
changes: three removed custom tags and 17 removed attributes. Extracted text is
unchanged between intermediate and sanitized HTML, which does not establish
fidelity to Bubble. Private source/product cards prioritize those exceptions,
disable sample resources/interaction and link explicitly to the original view.
No productive verdict inferred. Hash/count-only receipt:
docs/release/corpus-rendering-audit-2026-10-28.json. Exact review steps are appended
to docs/usecases/sharing-portability-review.md. User comparison/verdicts remain open.
## T03 — Review productive sharing and personal portability
```task