diff --git a/coulomb_social/apps/spaces/content.py b/coulomb_social/apps/spaces/content.py index b463f4f..dbebc5b 100644 --- a/coulomb_social/apps/spaces/content.py +++ b/coulomb_social/apps/spaces/content.py @@ -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, diff --git a/coulomb_social/apps/spaces/rendering_audit.py b/coulomb_social/apps/spaces/rendering_audit.py new file mode 100644 index 0000000..9886745 --- /dev/null +++ b/coulomb_social/apps/spaces/rendering_audit.py @@ -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 = ('

Open original Bubble view (opens only when clicked)

' + ) if source_id else "" + return ("" + '' + "Private rendering review

" + html.escape(title) + "

" + "Product rendering only; compare with the original Bubble view before accepting. " + "Resources and interactive links within the sample are disabled.

" + original + + "

Sanitization changed HTML: " + + str(audit["sanitization_changed_html"]).lower() + + "

Exact captured source

" + html.escape(body)
+            + "

Current product rendering

" + + rendered + "
").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 = '' + label + "" + 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("
  • " + label + " — " + status + "
  • ") + return ('' + '' + 'Private corpus rendering review' + '

    Private corpus rendering review

    Compare each card with the original ' + 'Bubble view. Sanitizer differences are observations, not acceptance verdicts. ' + 'Body-missing and markup-change records come first.

      ' + + "".join(rows) + '
    ').encode() diff --git a/docs/migration/corpus-import.md b/docs/migration/corpus-import.md index eb312cc..6ad79d4 100644 --- a/docs/migration/corpus-import.md +++ b/docs/migration/corpus-import.md @@ -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 diff --git a/docs/release/corpus-rendering-audit-2026-10-28.json b/docs/release/corpus-rendering-audit-2026-10-28.json new file mode 100644 index 0000000..f5a0d4b --- /dev/null +++ b/docs/release/corpus-rendering-audit-2026-10-28.json @@ -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 + } +} diff --git a/docs/release/recovery-custody-review-2026-10-28.md b/docs/release/recovery-custody-review-2026-10-28.md new file mode 100644 index 0000000..7f0ef07 --- /dev/null +++ b/docs/release/recovery-custody-review-2026-10-28.md @@ -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. diff --git a/docs/usecases/sharing-portability-review.md b/docs/usecases/sharing-portability-review.md index 8e97c2a..d5c8004 100644 --- a/docs/usecases/sharing-portability-review.md +++ b/docs/usecases/sharing-portability-review.md @@ -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. diff --git a/scripts/audit-bubble-rendering.py b/scripts/audit-bubble-rendering.py new file mode 100644 index 0000000..571cd74 --- /dev/null +++ b/scripts/audit-bubble-rendering.py @@ -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()) diff --git a/tests/test_rendering_audit.py b/tests/test_rendering_audit.py new file mode 100644 index 0000000..562ae8d --- /dev/null +++ b/tests/test_rendering_audit.py @@ -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\nPlaceholder
    Text\n' + '' + 'Unsafe') + 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 "
    " 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
    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 = '' + audit, rendered = audit_body(body) + card = review_card("", body, rendered, audit, + source_id='a/"?b').decode() + assert "