diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md
index 1231187..1d91412 100644
--- a/notes/fresh-workspace-handoff.md
+++ b/notes/fresh-workspace-handoff.md
@@ -1444,7 +1444,8 @@ independently dispatched blind reviewer saw only the reader-facing fantasy
newspaper and read it as a real left-populist civic broadsheet; it did not
immediately identify procedural generation, so the narrative reverse-Turing
criterion passes. The review's criticism that all three stories feel like
-three rewrites of one briefing is later editorial differentiation debt.
+three rewrites of one briefing belongs to reporter-personality differentiation
+in the next newsroom run; it does not authorize rewriting accepted Run 60.
Run 60 used 96,902 prompt tokens and reported zero cache hits. The bounded
snapshot initialization had split the stable charter from the first-step
@@ -1455,6 +1456,14 @@ preserving later bounded workbench replacement; all 479 library tests pass.
This cache-boundary fix does not require resimulation or recomposition of the
accepted edition.
+Typesetting is delivered at
+`output/pdf/canopy-ledger-run-60-front-page.pdf`. It is one 10-by-12.2-inch
+three-column page with embedded Georgia/Times faces, warm press stock, and
+wordless rail/gauge cuts. The frozen Markdown source hash remains unchanged.
+Region-based extraction finds all 23 expected copy fragments, and full-page
+144-DPI visual QA finds no clipping or overlap. Typesetting is complete and no
+longer active work.
+
Run 54 exposed one separate upstream publicity defect: a public strategic
activity propagates its channel to private `MemberMemory` outcomes, which is
how Orin Pell retaining a memory became a public news row. The newsroom should
@@ -1464,14 +1473,15 @@ materializing a `NewsIssue`.
1. Preserve immutable accepted Run 60 and Run 59; do not rewrite, recompose, or
resimulate either edition.
-2. Advance the Delvehold-owned adapter against Ghostlight's admitted generic
- consumer contract. On the next naturally required multi-step provider agent
- run, capture whether exact pushed `8f3c33b` produces nonzero cache hits; do
- not replay Run 60 to manufacture a better metric.
-3. Keep the blind review's three-rewrites criticism as later reporter-voice
- differentiation debt. Defer multiple publications, TeX layout, and engraved
- cuts until separately authorized.
-4. Per-wave issues may use daily-like reporting windows, while a final
+2. Preserve the delivered typeset PDF and its frozen-copy verification above.
+3. On the next newsroom run, strengthen reporter-personality differentiation
+ so assigned journalists produce distinct framing, rhythm, and priorities;
+ also capture whether exact pushed `8f3c33b` produces nonzero cache hits. Do
+ not replay Run 60 to manufacture either improvement.
+4. After that newsroom run, advance the Delvehold-owned adapter against
+ Ghostlight's admitted generic consumer contract. Defer multiple publications
+ until separately authorized.
+5. Per-wave issues may use daily-like reporting windows, while a final
composition may be an equally current weekly-style issue with a broader
window. Both are selective journalism; broad fact availability increases
editorial freedom and does not license recap voice.
diff --git a/output/pdf/canopy-ledger-run-60-front-page.pdf b/output/pdf/canopy-ledger-run-60-front-page.pdf
new file mode 100644
index 0000000..11f3450
Binary files /dev/null and b/output/pdf/canopy-ledger-run-60-front-page.pdf differ
diff --git a/state/evidence.jsonl b/state/evidence.jsonl
index f11a4f7..810b7dc 100644
--- a/state/evidence.jsonl
+++ b/state/evidence.jsonl
@@ -233,4 +233,5 @@
{"note": "Run 59 prompt audit falsifies v8 as the target newsroom/cache architecture: narrative selection made ten provider calls over 1,373,208 input characters and 582,184 prompt tokens with zero cached tokens. The generic agent harness places changing action schema before stable work and replays the accumulated tool transcript; selector, whole-page writer, and Night Editor duplicate editorial doctrine. Operator-authorized owners are story-and-journalist assignment from the frozen ledger, bounded personality-bearing journalist drafting, copy desk as sole semantic factual query owner, one Night Editor checklist repair preserving narrative, and deterministic structural/provenance/query-disposition press admission. Exact schema and cache cuts remain pending bounded source inspection.", "status": "ok", "ts": "2026-08-29T20:29:24+00:00", "type": "research"}
{"note": "character-newsroom.v9 replaces the monolithic page writer with an embodied assignment editor and one bounded journalist agent per assigned story, preserves exact assignment-owned structure, bounds Night Editor context to copy-queried articles and facts, and makes generic agent requests append-only with stable schemas and cache routing. Verified 475/475 library tests, 16/16 strategic-smoke tests, and ghostlight-dungeon all-target checks; no provider or simulation run was launched.", "status": "ok", "ts": "2026-08-29T21:42:15+00:00", "type": "implementation"}
{"note": "character-newsroom.v10 gives the assignment editor a stable-prefix bounded workbench projection with compact inspected-record index and exact-ID refetch; retrieval retires any pending agenda. Exact task/editorial-bound agenda and per-article production checkpoints preserve accepted work before copy, and cited datelines compile into the reporter action schema. Verified 479/479 library tests, 16/16 strategic-smoke tests, and all-target check with only the pre-existing native-client unused-path warning.", "status": "ok", "ts": "2026-08-30T05:37:54+00:00", "type": "implementation"}
-{"ts": "2026-08-30T06:11:33+00:00", "type": "verification", "status": "ok", "note": "Run 60 completed at source 139b8a69bc297e406ed5aea45574ea5e5724c685, world revision 27, without world or per-wave replay. Newspaper SHA-256 f98a4684e6eef43f5eb4bf104248a21dcea1ecdf1c4e4e14fa405f32c51b8c1c; archive SHA-256 6cacc7b543f75c4e9cfb8d7f695c90a507b7f11125e8204101af1a7dd74c61b1. A fresh blind editor read it as a real left-populist civic broadsheet rather than procedural generation, while flagging same-voice repetition as later debt. Run 60 reported 96902 prompt tokens and zero cache hits. The bounded-agent initializer had split the stable charter from a changing step message; the current cut makes the entire first request one stable user message and later replaces only a second dynamic workbench message. Existing assignment-editor prefix verification and all 479 library tests pass. Provider cache eligibility is proven; nonzero live cache telemetry remains for the next multi-step agent run."}
+{"note": "Run 60 completed at source 139b8a69bc297e406ed5aea45574ea5e5724c685, world revision 27, without world or per-wave replay. Newspaper SHA-256 f98a4684e6eef43f5eb4bf104248a21dcea1ecdf1c4e4e14fa405f32c51b8c1c; archive SHA-256 6cacc7b543f75c4e9cfb8d7f695c90a507b7f11125e8204101af1a7dd74c61b1. A fresh blind editor read it as a real left-populist civic broadsheet rather than procedural generation, while flagging same-voice repetition as later debt. Run 60 reported 96902 prompt tokens and zero cache hits. The bounded-agent initializer had split the stable charter from a changing step message; the current cut makes the entire first request one stable user message and later replaces only a second dynamic workbench message. Existing assignment-editor prefix verification and all 479 library tests pass. Provider cache eligibility is proven; nonzero live cache telemetry remains for the next multi-step agent run.", "status": "ok", "ts": "2026-08-30T06:11:33+00:00", "type": "verification"}
+{"ts": "2026-08-30T09:58:52+00:00", "type": "verification", "status": "ok", "note": "Frozen accepted Run 60 copy was typeset as output/pdf/canopy-ledger-run-60-front-page.pdf without newsroom replay or word changes. The one-page 10 x 12.2 inch folio uses embedded Georgia and Times New Roman faces, three columns, warm press stock, and wordless rail/gauge cuts. Region-based PDF extraction resolved all 23 expected source fragments; 144-DPI full-page raster review found no clipping or overlap. PDF SHA-256 6A9D16FD5FA20221FCEEA0B850D2D074D276C18CB283996A49CB4088C0ABCE21; frozen Markdown source remains F98A4684E6EEF43F5EB4BF104248A21DCEA1ECDF1C4E4E14FA405F32C51B8C1C."}
diff --git a/state/ghostlight-state.cultcache.jsonl b/state/ghostlight-state.cultcache.jsonl
index 186163e..a679f5e 100644
--- a/state/ghostlight-state.cultcache.jsonl
+++ b/state/ghostlight-state.cultcache.jsonl
@@ -235,3 +235,4 @@
{"key": "2026-08-29T214215Z0000-0233-cdea7f28b17e", "payload": "eyJub3RlIjogImNoYXJhY3Rlci1uZXdzcm9vbS52OSByZXBsYWNlcyB0aGUgbW9ub2xpdGhpYyBwYWdlIHdyaXRlciB3aXRoIGFuIGVtYm9kaWVkIGFzc2lnbm1lbnQgZWRpdG9yIGFuZCBvbmUgYm91bmRlZCBqb3VybmFsaXN0IGFnZW50IHBlciBhc3NpZ25lZCBzdG9yeSwgcHJlc2VydmVzIGV4YWN0IGFzc2lnbm1lbnQtb3duZWQgc3RydWN0dXJlLCBib3VuZHMgTmlnaHQgRWRpdG9yIGNvbnRleHQgdG8gY29weS1xdWVyaWVkIGFydGljbGVzIGFuZCBmYWN0cywgYW5kIG1ha2VzIGdlbmVyaWMgYWdlbnQgcmVxdWVzdHMgYXBwZW5kLW9ubHkgd2l0aCBzdGFibGUgc2NoZW1hcyBhbmQgY2FjaGUgcm91dGluZy4gVmVyaWZpZWQgNDc1LzQ3NSBsaWJyYXJ5IHRlc3RzLCAxNi8xNiBzdHJhdGVnaWMtc21va2UgdGVzdHMsIGFuZCBnaG9zdGxpZ2h0LWR1bmdlb24gYWxsLXRhcmdldCBjaGVja3M7IG5vIHByb3ZpZGVyIG9yIHNpbXVsYXRpb24gcnVuIHdhcyBsYXVuY2hlZC4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMjlUMjE6NDI6MTUrMDA6MDAiLCAidHlwZSI6ICJpbXBsZW1lbnRhdGlvbiJ9", "stored_at": "2026-08-29T21:42:15.490465+00:00", "type": "ghostlight.evidence_record.v0"}
{"key": "2026-08-30T053754Z0000-0234-3f241b6ff6fc", "payload": "eyJub3RlIjogImNoYXJhY3Rlci1uZXdzcm9vbS52MTAgZ2l2ZXMgdGhlIGFzc2lnbm1lbnQgZWRpdG9yIGEgc3RhYmxlLXByZWZpeCBib3VuZGVkIHdvcmtiZW5jaCBwcm9qZWN0aW9uIHdpdGggY29tcGFjdCBpbnNwZWN0ZWQtcmVjb3JkIGluZGV4IGFuZCBleGFjdC1JRCByZWZldGNoOyByZXRyaWV2YWwgcmV0aXJlcyBhbnkgcGVuZGluZyBhZ2VuZGEuIEV4YWN0IHRhc2svZWRpdG9yaWFsLWJvdW5kIGFnZW5kYSBhbmQgcGVyLWFydGljbGUgcHJvZHVjdGlvbiBjaGVja3BvaW50cyBwcmVzZXJ2ZSBhY2NlcHRlZCB3b3JrIGJlZm9yZSBjb3B5LCBhbmQgY2l0ZWQgZGF0ZWxpbmVzIGNvbXBpbGUgaW50byB0aGUgcmVwb3J0ZXIgYWN0aW9uIHNjaGVtYS4gVmVyaWZpZWQgNDc5LzQ3OSBsaWJyYXJ5IHRlc3RzLCAxNi8xNiBzdHJhdGVnaWMtc21va2UgdGVzdHMsIGFuZCBhbGwtdGFyZ2V0IGNoZWNrIHdpdGggb25seSB0aGUgcHJlLWV4aXN0aW5nIG5hdGl2ZS1jbGllbnQgdW51c2VkLXBhdGggd2FybmluZy4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMzBUMDU6Mzc6NTQrMDA6MDAiLCAidHlwZSI6ICJpbXBsZW1lbnRhdGlvbiJ9", "stored_at": "2026-08-30T05:37:54.505622+00:00", "type": "ghostlight.evidence_record.v0"}
{"key": "2026-08-30T061133Z0000-0235-41f8be920abd", "payload": "eyJub3RlIjogIlJ1biA2MCBjb21wbGV0ZWQgYXQgc291cmNlIDEzOWI4YTY5YmMyOTdlNDA2ZWQ1YWVhNDU1NzRlYTVlNTcyNGM2ODUsIHdvcmxkIHJldmlzaW9uIDI3LCB3aXRob3V0IHdvcmxkIG9yIHBlci13YXZlIHJlcGxheS4gTmV3c3BhcGVyIFNIQS0yNTYgZjk4YTQ2ODRlNmVlZjQzZjVlYjRiZjEwNDI0OGEyMWRjZWExZWNkZjFjNGU0ZTE0ZmE0MDVmMzJjNTFiOGMxYzsgYXJjaGl2ZSBTSEEtMjU2IDZjYWNjN2I1NDNmNzVjNGU5Y2ZiOGQ3ZjY5NWM5MGE1MDdiN2YxMTEyNWU4MjA0MTAxYWYxYTdkZDc0YzYxYjEuIEEgZnJlc2ggYmxpbmQgZWRpdG9yIHJlYWQgaXQgYXMgYSByZWFsIGxlZnQtcG9wdWxpc3QgY2l2aWMgYnJvYWRzaGVldCByYXRoZXIgdGhhbiBwcm9jZWR1cmFsIGdlbmVyYXRpb24sIHdoaWxlIGZsYWdnaW5nIHNhbWUtdm9pY2UgcmVwZXRpdGlvbiBhcyBsYXRlciBkZWJ0LiBSdW4gNjAgcmVwb3J0ZWQgOTY5MDIgcHJvbXB0IHRva2VucyBhbmQgemVybyBjYWNoZSBoaXRzLiBUaGUgYm91bmRlZC1hZ2VudCBpbml0aWFsaXplciBoYWQgc3BsaXQgdGhlIHN0YWJsZSBjaGFydGVyIGZyb20gYSBjaGFuZ2luZyBzdGVwIG1lc3NhZ2U7IHRoZSBjdXJyZW50IGN1dCBtYWtlcyB0aGUgZW50aXJlIGZpcnN0IHJlcXVlc3Qgb25lIHN0YWJsZSB1c2VyIG1lc3NhZ2UgYW5kIGxhdGVyIHJlcGxhY2VzIG9ubHkgYSBzZWNvbmQgZHluYW1pYyB3b3JrYmVuY2ggbWVzc2FnZS4gRXhpc3RpbmcgYXNzaWdubWVudC1lZGl0b3IgcHJlZml4IHZlcmlmaWNhdGlvbiBhbmQgYWxsIDQ3OSBsaWJyYXJ5IHRlc3RzIHBhc3MuIFByb3ZpZGVyIGNhY2hlIGVsaWdpYmlsaXR5IGlzIHByb3Zlbjsgbm9uemVybyBsaXZlIGNhY2hlIHRlbGVtZXRyeSByZW1haW5zIGZvciB0aGUgbmV4dCBtdWx0aS1zdGVwIGFnZW50IHJ1bi4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMzBUMDY6MTE6MzMrMDA6MDAiLCAidHlwZSI6ICJ2ZXJpZmljYXRpb24ifQ==", "stored_at": "2026-08-30T06:11:33.326826+00:00", "type": "ghostlight.evidence_record.v0"}
+{"key": "2026-08-30T095852Z0000-0236-af580d1057d2", "payload": "eyJub3RlIjogIkZyb3plbiBhY2NlcHRlZCBSdW4gNjAgY29weSB3YXMgdHlwZXNldCBhcyBvdXRwdXQvcGRmL2Nhbm9weS1sZWRnZXItcnVuLTYwLWZyb250LXBhZ2UucGRmIHdpdGhvdXQgbmV3c3Jvb20gcmVwbGF5IG9yIHdvcmQgY2hhbmdlcy4gVGhlIG9uZS1wYWdlIDEwIHggMTIuMiBpbmNoIGZvbGlvIHVzZXMgZW1iZWRkZWQgR2VvcmdpYSBhbmQgVGltZXMgTmV3IFJvbWFuIGZhY2VzLCB0aHJlZSBjb2x1bW5zLCB3YXJtIHByZXNzIHN0b2NrLCBhbmQgd29yZGxlc3MgcmFpbC9nYXVnZSBjdXRzLiBSZWdpb24tYmFzZWQgUERGIGV4dHJhY3Rpb24gcmVzb2x2ZWQgYWxsIDIzIGV4cGVjdGVkIHNvdXJjZSBmcmFnbWVudHM7IDE0NC1EUEkgZnVsbC1wYWdlIHJhc3RlciByZXZpZXcgZm91bmQgbm8gY2xpcHBpbmcgb3Igb3ZlcmxhcC4gUERGIFNIQS0yNTYgNkE5RDE2RkQ1RkEyMDIyMUZDRUVBMEI4NTBEMkQwNzREMjc2QzE4Q0IyODM5OTZBNDlDQjQwODhDMEFCQ0UyMTsgZnJvemVuIE1hcmtkb3duIHNvdXJjZSByZW1haW5zIEY5OEE0Njg0RTZFRUY0M0Y1RUI0QkYxMDQyNDhBMjFEQ0VBMUVDREYxQzRFNEUxNEZBNDA1RjMyQzUxQjhDMUMuIiwgInN0YXR1cyI6ICJvayIsICJ0cyI6ICIyMDI2LTA4LTMwVDA5OjU4OjUyKzAwOjAwIiwgInR5cGUiOiAidmVyaWZpY2F0aW9uIn0=", "stored_at": "2026-08-30T09:58:52.190327+00:00", "type": "ghostlight.evidence_record.v0"}
diff --git a/state/map.yaml b/state/map.yaml
index 73fa706..65e6131 100644
--- a/state/map.yaml
+++ b/state/map.yaml
@@ -194,6 +194,7 @@ live_architecture:
- Institution, faction, and consumer decision models
active_subgoals:
+ - On the next newsroom run, strengthen reporter-personality differentiation so assigned journalists produce distinct framing, rhythm, and priorities rather than three rewrites of one briefing. Preserve accepted Run 60 unchanged; also capture whether exact pushed `8f3c33b` produces nonzero cache hits on that naturally required multi-step run.
- Connect a Delvehold-owned adapter to the admitted generic consumer contract: shared compiler/consumer WorldSeed admission, externally authoritative institution-or-Gestalt snapshots, attributed proposal acknowledgement, and committed-event newspaper composition over typed CultNet, without consumer-specific IDs or schemas in Ghostlight.
- Extend strategic/offscreen named-person individuation beyond the proven emerge-then-act path so people plot across sustained political waves, acquire relationships, fold, and return without granting a cell, arena, or model commit authority.
- Pressure the proven 200-cell dispatcher with authored consumer hierarchies and measure provider cost; the active budget ceiling is 240 while provider concurrency remains separately capped at 32.
@@ -324,7 +325,7 @@ data_flow:
current_status:
summary: Ghostlight's generic consumer boundary admits sparse WorldSeeds, runs the hundreds-class parallel cell machinery used by Dungeon, and projects grounded newspapers from committed public events. Local acceptance proves 1,000 subjects across 200 cells and two-wave durable named-person emergence. Exact-source provider run 38 at `596e50679244b5d42b4cfdf70d723f815751b8ff` resumed the preserved world at revision 8 without replaying compilation or four committed locality elaborations, recovered 51 of 52 strategic-wave cells on pulse three and the final cell on pulse four, then committed all six waves through revision 14. Pushed commit `3a69306f3004e4af2cd57bba612a6ad8d1b8132f` implements the mapped clock-consequence owner and is verified locally: world clocks own declared consequence text and exact observable scope; a bounded agent may propose legacy scope binding, while `WorldKernel` alone admits it and materializes stable threshold-crossing events and public news through the common mutation path. Binding, emitted-event ancestry, mutation receipts, and the next newspaper boundary persist atomically. Exact-source run 39 resumed run 38 without replaying its six committed waves, bound clock consequences, committed six new waves through revision 21, and emitted Issues 7 through 12 plus a final digest. This completes the mechanical provider acceptance path; all inherited wave 1-6 checkpoint hashes match run 38 exactly and live services were unchanged. Independent blind editorial review returns FAIL: only Issue 7 approaches spicy, while Issues 8-12 are dominated by plans, filings, inspections, warnings, and abstract relationship deltas without betrayal, scandal, public reaction, sustained reversal, or material lived cost. That run-39 diagnosis is superseded for the current phase: run 51 closes the world and reconciliation mechanics, and the active defect belongs to newsroom narrative selection and self-contained semantic provenance over immutable facts. Independent grounding review also returns FAIL: Issue 9 inflates a planned expedited agenda into completion, while Issue 8 assigns unsupported feminine pronouns to Sela Venn and Eryn Tal. Other grounding mechanics pass: 208/208 citations resolve, no future events appear, no replay occurred, the player remained unchanged, all 116/116 manifest entries verify, the archive is exact, and root independently confirmed the live service PIDs and invocation IDs remained unchanged. Editorial and grounding acceptance are terminally failed for run 39. Eight titled elaborators use the generic agent harness under deterministic weighted scheduling, bounded parallelism, and Luna/Terra/Sol routing. Workers remain proposal-only; deterministic admission binds exact frozen schedules, dispatch, receipts, conflicts, and semantic verification, and `WorldKernel` alone owns atomic CultCache persistence. Destination reconciliation is a private transactional workbench, provider schema compatibility is lowered once at the shared model-connector boundary, and durable elaboration and strategic-pulse checkpoints bind to their canonical mutation proofs. The generic ModelAgent harness remains consumer-neutral transport. Provider run 23 remains stopped; earlier failed runs and checkpoint archives remain immutable provenance. The live daemons remain outside the acceptance body. Dungeon play remains regression pressure, the Delvehold game-side adapter remains unimplemented, and unpublished Mars and Kalsa destination previews remain unapproved.
- provider_acceptance_checkpoint: Exact pushed commit `139b8a6` completed Run 60 under `character-newsroom.v10` without world or per-wave replay. Its acceptance witness and terminal status bind the completed page and exact resume lineage. The independently dispatched blind reviewer, shown only the reader-facing fantasy newspaper, read it as a real left-populist civic broadsheet and did not immediately identify procedural generation, so the narrative reverse-Turing criterion passes. The review's substantive criticism is that the three stories feel like three rewrites of one briefing; preserve that as future editorial differentiation debt. The run used 96,902 prompt tokens and reported zero cache hits.
+ provider_acceptance_checkpoint: Exact pushed commit `139b8a6` completed Run 60 under `character-newsroom.v10` without world or per-wave replay. Its acceptance witness and terminal status bind the completed page and exact resume lineage. The independently dispatched blind reviewer, shown only the reader-facing fantasy newspaper, read it as a real left-populist civic broadsheet and did not immediately identify procedural generation, so the narrative reverse-Turing criterion passes. The review's substantive criticism is that the three stories feel like three rewrites of one briefing; reporter-personality differentiation is deferred to the next newsroom run and must not rewrite accepted Run 60. The run used 96,902 prompt tokens and reported zero cache hits. The delivered typeset artifact is `output/pdf/canopy-ledger-run-60-front-page.pdf`: one 10-by-12.2-inch three-column page with embedded Georgia/Times faces, warm press stock, and wordless rail/gauge cuts. The frozen Markdown source hash is unchanged; region extraction finds all 23 expected copy fragments, and 144-DPI full-page visual QA finds no clipping or overlap.
implementation_checkpoint: Exact pushed commit `8f3c33b` (`Preserve bounded agent cache prefixes`) collapses the stable charter and first-step directive into one exact initial cacheable message while preserving later tool-owned bounded workbench replacement; all 479 library tests pass. Exact pushed `139b8a6` remains the provider-proven v10 newsroom boundary with bounded assignment context, task/editorial-bound agenda and per-article production checkpoints, deterministic cited-place datelines, embodied context membranes, and no-replay resume. Accepted Run 60 remains immutable. Its zero-hit result falsifies the earlier split-prefix implementation but does not require recomposition to validate the corrected harness.
- next_action: Advance the Delvehold-owned adapter against Ghostlight's admitted generic consumer contract. On the next naturally required multi-step provider agent run, capture telemetry proving whether exact pushed `8f3c33b` produces nonzero cache hits; do not replay, recompose, or reopen accepted Run 60 merely to improve the metric. Preserve the zero-hit Run 60 evidence and keep the blind review's three-rewrites criticism as later reporter-voice differentiation debt.
+ next_action: Run the next newsroom acceptance with stronger reporter-personality differentiation while preserving accepted Run 60 and its delivered PDF unchanged; use that naturally required multi-step provider work to capture whether exact pushed `8f3c33b` produces nonzero cache hits. After that newsroom run, advance the Delvehold-owned adapter against Ghostlight's admitted generic consumer contract. Do not replay Run 60 to improve copy or telemetry.
gestalt_population: Gestalt Personas own shared population baselines; persistent member deltas own named individuality; WorldKernel materialization and approval-gated fission preserve lineage and identity. Materialisation/folding changes only representation and the exact member delta, advances resolution epoch, and retires the old cover; it has no Gestalt-wide effect authority. Selected actionable member exceptions are mandatory Projector perspective owners, so aggregation cannot silently erase their decision. Presence projection is limited to nearby active leaves and exact-location dormant members, includes reciprocal player relationship evidence, and may propose an earned cast without committing it. Simulation cells are derived covers only.
diff --git a/tools/typeset_newspaper.py b/tools/typeset_newspaper.py
new file mode 100644
index 0000000..6cea62e
--- /dev/null
+++ b/tools/typeset_newspaper.py
@@ -0,0 +1,478 @@
+#!/usr/bin/env python3
+"""Typeset a compact Markdown newspaper front page as a press-ready PDF."""
+
+from __future__ import annotations
+
+import argparse
+import html
+import math
+import random
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+from reportlab.lib import colors
+from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
+from reportlab.lib.styles import ParagraphStyle
+from reportlab.lib.units import inch
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.pdfgen.canvas import Canvas
+from reportlab.platypus import Frame, Paragraph
+
+
+INK = colors.HexColor("#17120d")
+PAPER = colors.HexColor("#f2e6ca")
+HAIRLINE = colors.HexColor("#4b3b2b")
+FOLIO = (10 * inch, 12.2 * inch)
+
+
+@dataclass(frozen=True)
+class Article:
+ section: str | None
+ headline: str
+ deck: str
+ author: str
+ paragraphs: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class Edition:
+ masthead: str
+ edition: str
+ articles: tuple[Article, ...]
+
+
+def register_fonts() -> None:
+ font_root = Path("C:/Windows/Fonts")
+ faces = {
+ "LedgerSerif": "times.ttf",
+ "LedgerSerifBold": "timesbd.ttf",
+ "LedgerSerifItalic": "timesi.ttf",
+ "LedgerDisplay": "georgia.ttf",
+ "LedgerDisplayBold": "georgiab.ttf",
+ "LedgerDisplayItalic": "georgiai.ttf",
+ }
+ for family, filename in faces.items():
+ path = font_root / filename
+ if not path.exists():
+ raise FileNotFoundError(f"Required font is unavailable: {path}")
+ pdfmetrics.registerFont(TTFont(family, str(path)))
+ pdfmetrics.registerFontFamily(
+ "LedgerSerif",
+ normal="LedgerSerif",
+ bold="LedgerSerifBold",
+ italic="LedgerSerifItalic",
+ boldItalic="LedgerSerifBold",
+ )
+ pdfmetrics.registerFontFamily(
+ "LedgerDisplay",
+ normal="LedgerDisplay",
+ bold="LedgerDisplayBold",
+ italic="LedgerDisplayItalic",
+ boldItalic="LedgerDisplayBold",
+ )
+
+
+def _plain_markdown(text: str) -> str:
+ text = text.strip()
+ if len(text) >= 4 and text.startswith("**") and text.endswith("**"):
+ text = text[2:-2]
+ elif len(text) >= 2 and text[:1] == text[-1:] and text[:1] in {"*", "_"}:
+ text = text[1:-1]
+ return html.unescape(text)
+
+
+def _inline_markup(text: str) -> str:
+ """Render only the tiny inline-Markdown subset used by the frozen copy."""
+ source = html.unescape(text)
+ parts = re.split(r"(\*\*.*?\*\*|\*.*?\*)", source)
+ rendered: list[str] = []
+ for part in parts:
+ if part.startswith("**") and part.endswith("**"):
+ rendered.append(f"{escape(part[2:-2])}")
+ elif part.startswith("*") and part.endswith("*"):
+ rendered.append(f"{escape(part[1:-1])}")
+ else:
+ rendered.append(escape(part))
+ return "".join(rendered)
+
+
+def parse_edition(path: Path) -> Edition:
+ lines = path.read_text(encoding="utf-8").splitlines()
+ masthead = ""
+ edition = ""
+ articles: list[Article] = []
+ section: str | None = None
+ index = 0
+
+ while index < len(lines):
+ line = lines[index].strip()
+ if line.startswith("# ") and not masthead:
+ masthead = _plain_markdown(line[2:])
+ elif not edition and line.startswith("*") and line.endswith("*"):
+ edition = _plain_markdown(line)
+ elif line.startswith("### "):
+ section = _plain_markdown(line[4:])
+ elif line.startswith("## "):
+ headline = _plain_markdown(line[3:])
+ index += 1
+ while index < len(lines) and not lines[index].strip():
+ index += 1
+ deck = _plain_markdown(lines[index])
+ index += 1
+ while index < len(lines) and not lines[index].strip():
+ index += 1
+ author = _plain_markdown(lines[index])
+ index += 1
+ paragraphs: list[str] = []
+ paragraph_lines: list[str] = []
+ while index < len(lines):
+ current = lines[index].strip()
+ if current == "---" or current.startswith("### ") or current.startswith("## "):
+ if paragraph_lines:
+ paragraphs.append(" ".join(paragraph_lines))
+ paragraph_lines.clear()
+ break
+ if current:
+ paragraph_lines.append(current)
+ elif paragraph_lines:
+ paragraphs.append(" ".join(paragraph_lines))
+ paragraph_lines.clear()
+ index += 1
+ if paragraph_lines:
+ paragraphs.append(" ".join(paragraph_lines))
+ articles.append(Article(section, headline, deck, author, tuple(paragraphs)))
+ section = None
+ continue
+ index += 1
+
+ if not masthead or not edition or len(articles) != 3:
+ raise ValueError(
+ f"Expected masthead, edition label, and three articles; got "
+ f"masthead={bool(masthead)}, edition={bool(edition)}, articles={len(articles)}"
+ )
+ return Edition(masthead, edition, tuple(articles))
+
+
+def _styles(body_size: float) -> dict[str, ParagraphStyle]:
+ return {
+ "body": ParagraphStyle(
+ "body",
+ fontName="LedgerSerif",
+ fontSize=body_size,
+ leading=body_size * 1.18,
+ alignment=TA_JUSTIFY,
+ textColor=INK,
+ spaceAfter=body_size * 0.72,
+ allowWidows=0,
+ allowOrphans=0,
+ ),
+ "section": ParagraphStyle(
+ "section",
+ fontName="LedgerDisplayBold",
+ fontSize=7.3,
+ leading=8.4,
+ alignment=TA_LEFT,
+ textColor=INK,
+ spaceAfter=3.5,
+ borderPadding=(2.2, 0, 1.5, 0),
+ borderWidth=0,
+ borderColor=HAIRLINE,
+ ),
+ "side_head": ParagraphStyle(
+ "side_head",
+ fontName="LedgerDisplayBold",
+ fontSize=15.5,
+ leading=16.3,
+ alignment=TA_LEFT,
+ textColor=INK,
+ spaceAfter=5.2,
+ ),
+ "side_deck": ParagraphStyle(
+ "side_deck",
+ fontName="LedgerDisplayItalic",
+ fontSize=8.2,
+ leading=10.0,
+ alignment=TA_LEFT,
+ textColor=INK,
+ spaceAfter=5.0,
+ ),
+ "byline": ParagraphStyle(
+ "byline",
+ fontName="LedgerDisplayBold",
+ fontSize=7.4,
+ leading=8.5,
+ alignment=TA_LEFT,
+ textColor=INK,
+ spaceAfter=5.5,
+ ),
+ }
+
+
+def _paper_texture(canvas: Canvas, width: float, height: float) -> None:
+ canvas.setFillColor(PAPER)
+ canvas.rect(0, 0, width, height, fill=1, stroke=0)
+ random.seed(6043)
+ canvas.saveState()
+ try:
+ canvas.setStrokeAlpha(0.10)
+ except AttributeError:
+ pass
+ for _ in range(150):
+ shade = random.choice((colors.HexColor("#8d7354"), colors.HexColor("#fff7e4")))
+ canvas.setStrokeColor(shade)
+ canvas.setLineWidth(random.choice((0.15, 0.22, 0.3)))
+ x = random.uniform(0.2 * inch, width - 0.2 * inch)
+ y = random.uniform(0.2 * inch, height - 0.2 * inch)
+ length = random.uniform(7, 32)
+ canvas.line(x, y, min(width - 0.2 * inch, x + length), y + random.uniform(-0.5, 0.5))
+ canvas.restoreState()
+
+
+def _pressmark(canvas: Canvas, x: float, y: float, scale: float = 1.0) -> None:
+ """A small one-color canopy mark, drawn like a worn printer's ornament."""
+ canvas.saveState()
+ canvas.translate(x, y)
+ canvas.scale(scale, scale)
+ canvas.setStrokeColor(INK)
+ canvas.setLineCap(1)
+ canvas.setLineJoin(1)
+ canvas.setLineWidth(1.0)
+ canvas.circle(0, 0, 20, fill=0, stroke=1)
+ canvas.line(0, -14, 0, 10)
+ for dx, dy, ex, ey in (
+ (0, 7, -13, 15),
+ (0, 6, 13, 15),
+ (0, 2, -16, 7),
+ (0, 2, 16, 7),
+ (-4, -3, -13, -8),
+ (4, -3, 13, -8),
+ ):
+ canvas.line(dx, dy, ex, ey)
+ for offset in (-3, 0, 3):
+ canvas.line(-9, -14 + offset * 0.25, 9, -14 + offset * 0.25)
+ canvas.restoreState()
+
+
+def _draw_rule(canvas: Canvas, x1: float, y: float, x2: float, width: float = 0.7) -> None:
+ canvas.saveState()
+ canvas.setStrokeColor(INK)
+ canvas.setLineWidth(width)
+ canvas.line(x1, y, x2, y + 0.15)
+ canvas.restoreState()
+
+
+def _root_rail_cut(canvas: Canvas, x: float, y: float, width: float, height: float) -> None:
+ """Wordless relief-print ornament: roots crossing a breached pair of rails."""
+ canvas.saveState()
+ canvas.setStrokeColor(INK)
+ canvas.setFillColor(INK)
+ canvas.setLineCap(1)
+ canvas.setLineJoin(1)
+ canvas.setLineWidth(0.75)
+ canvas.rect(x, y, width, height, fill=0, stroke=1)
+ pad = 8
+ for offset in (0.34, 0.66):
+ rail_x = x + width * offset
+ canvas.setLineWidth(2.0)
+ canvas.line(rail_x, y + pad, rail_x, y + height - pad)
+ canvas.setLineWidth(0.45)
+ canvas.line(rail_x - 3, y + pad, rail_x - 3, y + height - pad)
+ for rung_y in range(int(y + 13), int(y + height - 8), 12):
+ canvas.setLineWidth(0.65)
+ canvas.line(x + width * 0.27, rung_y, x + width * 0.73, rung_y + 1.2)
+
+ roots = (
+ [(0.08, 0.08), (0.20, 0.25), (0.38, 0.30), (0.55, 0.52), (0.89, 0.88)],
+ [(0.02, 0.68), (0.24, 0.56), (0.46, 0.60), (0.70, 0.38), (0.98, 0.30)],
+ [(0.18, 0.98), (0.29, 0.77), (0.55, 0.71), (0.78, 0.57), (0.96, 0.10)],
+ )
+ canvas.setLineWidth(3.1)
+ for points in roots:
+ path = canvas.beginPath()
+ first_x, first_y = points[0]
+ path.moveTo(x + first_x * width, y + first_y * height)
+ for point_x, point_y in points[1:]:
+ path.lineTo(x + point_x * width, y + point_y * height)
+ canvas.drawPath(path, fill=0, stroke=1)
+ canvas.setLineWidth(0.38)
+ for index in range(24):
+ hatch_x = x + pad + (index * 17) % max(18, int(width - 2 * pad))
+ hatch_y = y + pad + (index * 29) % max(18, int(height - 2 * pad))
+ canvas.line(hatch_x, hatch_y, hatch_x + 11, hatch_y + 5)
+ canvas.restoreState()
+
+
+def _gauge_cut(canvas: Canvas, x: float, y: float, width: float, height: float) -> None:
+ """Wordless relief-print ornament: a calibrated counterflow dial."""
+ canvas.saveState()
+ canvas.setStrokeColor(INK)
+ canvas.setFillColor(INK)
+ canvas.setLineCap(1)
+ canvas.setLineJoin(1)
+ canvas.setLineWidth(0.75)
+ canvas.rect(x, y, width, height, fill=0, stroke=1)
+ radius = min(width * 0.30, height * 0.38)
+ cx = x + width / 2
+ cy = y + height * 0.50
+ canvas.setLineWidth(1.5)
+ canvas.circle(cx, cy, radius, fill=0, stroke=1)
+ canvas.circle(cx, cy, radius * 0.82, fill=0, stroke=1)
+ for step in range(13):
+ angle = 3.1415926535 * (0.12 + 0.063 * step)
+ inner = radius * (0.70 if step % 3 else 0.62)
+ outer = radius * 0.84
+ canvas.line(
+ cx + inner * math.cos(angle),
+ cy + inner * math.sin(angle),
+ cx + outer * math.cos(angle),
+ cy + outer * math.sin(angle),
+ )
+ canvas.setLineWidth(2.4)
+ canvas.line(cx, cy, cx + radius * 0.45, cy + radius * 0.48)
+ canvas.circle(cx, cy, 3.2, fill=1, stroke=0)
+ canvas.setLineWidth(0.45)
+ for offset in (-8, -4, 0, 4, 8):
+ canvas.line(x + 10, y + 10 + offset * 0.2, x + width * 0.25, cy + offset)
+ canvas.line(x + width - 10, y + 10 - offset * 0.2, x + width * 0.75, cy - offset)
+ canvas.restoreState()
+
+
+def _build_article(article: Article, styles: dict[str, ParagraphStyle], include_header: bool) -> list:
+ story: list = []
+ if include_header:
+ if article.section:
+ story.append(Paragraph(escape(article.section.upper()), styles["section"]))
+ story.append(Paragraph(escape(article.headline), styles["side_head"]))
+ story.append(Paragraph(escape(article.deck), styles["side_deck"]))
+ story.append(Paragraph(f"BY {escape(article.author.upper())}", styles["byline"]))
+ else:
+ story.append(Paragraph(f"BY {escape(article.author.upper())}", styles["byline"]))
+ for paragraph in article.paragraphs:
+ story.append(Paragraph(_inline_markup(paragraph), styles["body"]))
+ return story
+
+
+def _draw_page(canvas: Canvas, edition: Edition, body_size: float) -> bool:
+ width, height = FOLIO
+ _paper_texture(canvas, width, height)
+ margin_x = 0.48 * inch
+ usable_width = width - 2 * margin_x
+
+ # Printer's strapline and masthead.
+ canvas.setFillColor(INK)
+ canvas.setFont("LedgerDisplay", 7.2)
+ canvas.drawString(margin_x, height - 0.34 * inch, edition.edition.upper())
+ _draw_rule(canvas, margin_x, height - 0.43 * inch, width - margin_x, 0.55)
+
+ masthead_y = height - 0.91 * inch
+ _pressmark(canvas, margin_x + 22, masthead_y + 3, 0.83)
+ _pressmark(canvas, width - margin_x - 22, masthead_y + 3, 0.83)
+ canvas.setFont("LedgerDisplayBold", 32)
+ canvas.drawCentredString(width / 2, masthead_y - 7, edition.masthead.upper())
+ _draw_rule(canvas, margin_x, height - 1.23 * inch, width - margin_x, 1.65)
+ _draw_rule(canvas, margin_x, height - 1.29 * inch, width - margin_x, 0.42)
+
+ # Lead banner.
+ lead = edition.articles[0]
+ lead_head = ParagraphStyle(
+ "lead_head",
+ fontName="LedgerDisplayBold",
+ fontSize=25,
+ leading=26.2,
+ alignment=TA_CENTER,
+ textColor=INK,
+ )
+ lead_deck = ParagraphStyle(
+ "lead_deck",
+ fontName="LedgerDisplayItalic",
+ fontSize=9.5,
+ leading=11.8,
+ alignment=TA_CENTER,
+ textColor=INK,
+ )
+ headline = Paragraph(escape(lead.headline), lead_head)
+ _, headline_h = headline.wrap(usable_width - 0.35 * inch, 0.8 * inch)
+ headline_y = height - 1.45 * inch - headline_h
+ headline.drawOn(canvas, margin_x + 0.175 * inch, headline_y)
+ deck = Paragraph(escape(lead.deck), lead_deck)
+ _, deck_h = deck.wrap(usable_width - 0.85 * inch, 0.5 * inch)
+ deck_y = headline_y - 0.09 * inch - deck_h
+ deck.drawOn(canvas, margin_x + 0.425 * inch, deck_y)
+ column_top = deck_y - 0.16 * inch
+ _draw_rule(canvas, margin_x, column_top, width - margin_x, 0.7)
+ column_top -= 0.12 * inch
+
+ gutter = 0.20 * inch
+ column_width = (usable_width - 2 * gutter) / 3
+ column_bottom = 0.42 * inch
+ column_height = column_top - column_bottom
+ styles = _styles(body_size)
+ all_fit = True
+ for index, article in enumerate(edition.articles):
+ x = margin_x + index * (column_width + gutter)
+ if index:
+ divider_x = x - gutter / 2
+ canvas.setStrokeColor(HAIRLINE)
+ canvas.setLineWidth(0.35)
+ canvas.line(divider_x, column_bottom, divider_x, column_top)
+ frame = Frame(
+ x,
+ column_bottom,
+ column_width,
+ column_height,
+ leftPadding=0,
+ rightPadding=0,
+ topPadding=0,
+ bottomPadding=0,
+ showBoundary=0,
+ )
+ story = _build_article(article, styles, include_header=index != 0)
+ frame.addFromList(story, canvas)
+ if story:
+ all_fit = False
+
+ if all_fit:
+ _root_rail_cut(canvas, margin_x, column_bottom + 4, column_width, 2.45 * inch)
+ _gauge_cut(
+ canvas,
+ margin_x + column_width + gutter,
+ column_bottom + 4,
+ column_width,
+ 1.25 * inch,
+ )
+
+ return all_fit
+
+
+def render(edition: Edition, output: Path) -> float:
+ output.parent.mkdir(parents=True, exist_ok=True)
+ last_size = 0.0
+ for body_size in (10.0, 9.7, 9.4, 9.1, 8.8, 8.5, 8.15):
+ last_size = body_size
+ canvas = Canvas(str(output), pagesize=FOLIO, pageCompression=1)
+ canvas.setTitle(edition.masthead)
+ canvas.setAuthor("The Canopy Ledger")
+ fit = _draw_page(canvas, edition, body_size)
+ canvas.showPage()
+ canvas.save()
+ if fit:
+ return body_size
+ raise RuntimeError(f"Copy did not fit at minimum body size {last_size}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("source", type=Path, help="Markdown front page")
+ parser.add_argument("output", type=Path, help="Output PDF")
+ args = parser.parse_args()
+ register_fonts()
+ edition = parse_edition(args.source)
+ body_size = render(edition, args.output)
+ print(f"typeset={args.output} body_size={body_size:.2f} articles={len(edition.articles)}")
+
+
+if __name__ == "__main__":
+ main()