Repository source: docs/plans/post_markdown/post_markdown_step4_implementation_summary.md
Debug mode: enabled (inherited from Step 3)
Effort study: railspike-effort / v3/post_markdown (ledger); standing enrollment P2
Extended estimates: enabled
Cycle estimate: 0.85
Branch: feature/post-markdown, planning commit 51e1a8e9.
Step 4 entry
| Checkpoint | UTC | Score | Scope / reference / protocol | Rater and exposure | Reason |
|---|---|---|---|---|---|
| Step 4 entry | 2026-11-03T17:10Z | 0.85 | v2 / R1 / P2, original baseline fd4d0e0227c18ed840c166e2ce4948233d148769 | Claude (claude-opus-5-5), single exposed rater | Step 3 projection reaffirmed after the planning commit; no new understanding |
Original stage-work estimates copied from Step 3 (not new ratings): S1 0.20, S2 0.20, S3 0.20, S4 0.10, S5 0.15, S6 0.10.
Requests
| Request | Disposition and reason | Approval status | Outcome |
|---|---|---|---|
| Rewrite the Nov 2 changelog post | Separate: content change | Requested by maintainer | Done before Step 4 |
| Label the composer option "Markdown" instead of "Plain text" (2026-11-03) | Changed scope: inverts the Step 2 composer requirement | Revised Steps 2 and 3 approved 2026-11-03 | Stage 7 |
| Markdown box starts unchecked and a naive JS detector ticks it when the draft looks like Markdown (2026-11-03) | Changed scope: composer default becomes plain unless detected or ticked | Revised Steps 2 and 3 approved 2026-11-03 | Stage 7 |
| Change an existing post's format after posting, e.g. switch old posts to Markdown (2026-11-03) | Changed scope: new signed format-change record (like thread subject changes), read-model and UI support; Step 2 excluded editing after publication | Folded into this cycle; revised Steps 2 and 3 approved 2026-11-03 (authors and root-approved operators may switch) | Stages 8–10 |
Stage 1 - Record format
Original estimate: 0.20 Retrospective assessment: 0.20
Changes:
Body-Format: markdown|plaindocumented as an optional header indocs/specs/canonical_post_record_v1.md; absent means plain.PostRecordgainsbodyFormat(nullable) andrendersMarkdown();PostRecordParserreads and validates the header.LocalWriteServicereads an optionalbody_formatinput on create/prepare for threads and replies. Defaultmarkdown,plainfor QDB quotes; other values are rejected. Builders add the header only when a format is given, so existing builder bytes are unchanged.SignedPostBundleValidatoraccepts the header in client-built records (afterSubjectfor threads, afterAuthor-Identity-IDfor replies). Without this, direct-signed posts carrying the header were rejected as noncanonical.Read model:
posts.body_formatcolumn in the builder and both incremental insert paths; schema version 44 -> 45.get_postalways printsBody-Format: markdown|plain.
Verification:
Baseline full suite before any change: 1833 run, 1817 passed, 16 failed (FrmClient x8, LocalAppSmoke x3, ApprovalSeedCommand, ReadModelBuilderTiming, ReactionRecordFormatParity, TerminalOperatorUiCommand, CheckoutsCommand real-git acceptance). Recorded as pre-existing.
php tests/run.php CanonicalRecordParsersTest WriteApiSmokeTest SignedPostBundleValidatorTest SignedPostTransactionTest SingleRequestPostingTest AgentPostingApiTest ReadModelBuilderTimingTest ReactionRecordFormatParityTest IncrementalReadModelUpdaterTest ReadModelProvenanceTest AgentApiWorkedExampleTest: all pass except the two baseline failures (ReadModelBuilderTimingTest,ReactionRecordFormatParityTest).New tests: header optional/validated, builders append only when given, API default/plain/invalid/reply,
get_postreports plain for legacy fixtures, prepared and direct-signed paths with and without the header.Two exact-bytes prepare tests updated to expect
Body-Format: markdown(intended change).
Notes:
Read-model rebuild: the schema fingerprint changes, so the app rebuilds the read model on the next request after deploy. A high-traffic deployment instead refuses until
./v3 high-traffic prepareruns; Stage 6 checks which applies on zenmemes.com.The public offline snapshot keeps its own
poststable without the format; posts there stay plain. Not in this cycle's scope; noted as a follow-up.scripts/publish_text.*build direct records without the header, so their posts stay plain unless updated later.
Estimate details: original scope unchanged; no revision. Retrospective matches: the signed-bundle canonical check was the one unplanned discovery and was small.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 2 - Post-safe renderer
Original estimate: 0.20 Retrospective assessment: 0.15
Changes:
PublicMarkdownRenderer::renderPost(string): stringshares the docs parser through a post-mode switch; docs mode is untouched.Post mode: no front matter, no hidden comments, no raw
<img>line handling (all shown as escaped text); headings nested two levels down (#->h3, capped ath6) without page ids; links only for absolute http(s) URLs, withrel="nofollow ugc noopener", other targets left as visible source; bare http(s) URLs linked with trailing punctuation kept outside; emphasis requires text against both markers; single newlines become<br>. Any internal failure falls back to escaped text with line breaks.
Verification:
New
tests/PostMarkdownRendererTest.php(registered intests/run.php): common syntax, heading nesting, hashtag lines stay text, line breaks, bare/explicit links, URLs in code and parentheses, arithmetic asterisks, twelve hostile inputs (raw HTML,javascript:/data:/protocol-relative/docs links, attribute injection, images, comments, front matter) with no unsafe markup or event-handler attributes, docs mode unchanged after a post render.php tests/run.php PostMarkdownRendererTest MarkdownListStructureTest MarkdownTaskListTest MarkdownPipeTableTest PlatformDocsPageTest PlatformDocsCatalogTest: 61 run, 61 passed.Docs output comparison: every
.mdunderdocs/(1798 files) rendered with the Stage 1 renderer and the Stage 2 renderer; 0 differ.Manual render of the Nov 2 changelog post text: lists, links and the hashtag line render as intended.
Notes:
The paragraph-then-list shape agents tend to write (a heading-like line followed directly by
- items) renders as a paragraph and a list, which suits changelogs.
Estimate details: original scope unchanged; no revision. Retrospective lower than the original: the shared parser needed only a mode switch, not restructuring.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 3 - Thread pages
Original estimate: 0.20 Retrospective assessment: 0.20
Changes:
New site flag
FORUM_POST_MARKDOWN_ENABLED("Markdown in posts", Rendering group, on by default, site-mutable).MediaEmbedRenderer::render()takes a$markdownargument: text between image references and embed cards goes throughrenderPost(); only embed URLs alone on their line become cards, others stay ordinary links. The plain path is the existing code.TemplateRendereradds$isMarkdownPost($record, $formatKey)and$renderPostBody($record, $body, $formatKey); the four full-post templates (post_card,thread_root_card,paned_thread_reply_tree,paned_board_content_article) use them and add apost-markdownclass.Full-post queries select
posts.body_format(thread posts, single post, reply lists, paned board root asroot_post_body_format, authored content).site.css(non-critical part): spacing, headings, lists, quotes, code, tables, rules and task items for.post-markdown, usingcurrentColorso themes supply colors.
Verification:
New tests:
WriteApiSmokeTest::testThreadPagesRenderMarkdownOnlyForMarkdownPostsAndRespectTheFlag(Markdown thread/reply/post page render; plain post shows source; flag off shows source), twoMediaEmbedRendererTestcases (standalone embed card plus inline link; flag-off output byte-identical to the old helper), flag list inFeatureFlagEvaluatorTest.Related suites (BoardList, BoardListingQuery, FeatureFlag x3, Forte x2, LocalAppSmoke, MediaEmbed x7, MusicMediaEmbed, PostMarkdownRenderer, Qdb x7, RecentPost, TemplateRendererMediaEmbedsScript, ThreadTitle): 322 run, 319 passed; the 3 failures are the baseline
LocalAppSmokeTestfailures.Byte-identical corpus check: every
/threads/{id}and/posts/{id}page for both post fixtures (parity_minimal_v1,retraction_pin_legacy) rendered with the Stage 2 commit and with this stage; outputs identical after normalizing asset fingerprints.Browser check (headless Chrome against a local dev server, one Markdown thread with the Nov 2 changelog text and one reply with heading, inline code, code block, table, quote and task items): Light, Word97, Chicago and Midnight at 1280px and 390px. Lists, links, code, tables and quotes read correctly in all eight; the first pass showed task checkboxes stretched by global form styles, fixed with scoped rules before commit. Screenshots kept locally, not committed.
Notes:
Post styles sit after the critical-CSS marker, so they load with the deferred stylesheet like other card styles.
Real zenmemes.com posts are checked live in Stage 6 (an existing post must look unchanged).
Estimate details: original scope unchanged; no revision.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 4 - Previews
Original estimate: 0.10 Retrospective assessment: 0.15
Changes:
New
ForumRewrite\Support\PostText::plainPreview(string $body, ?string $format): string: plain and legacy posts pass through unchanged; Markdown posts lose heading, quote, task, emphasis, inline-code and link markup, fence and delimiter lines, and table pipes, keeping their words line by line.Read model: thread
body_previewand activity labels go through it in the builder and the incremental updater, so Board, RSS, Forte sorting and everybody_previewconsumer see plain words.Titles and excerpts built from raw bodies use it too: thread titles (
Application,TemplateRenderer,BoardPageController), Forte content titles, the reply composer's thread title, Recent excerpts and context titles (Recent now selects the post and root formats), and profile post lists (AuthoredContentRepository).Templates:
$plainText($record, $text, $formatKey)helper used for the Board card preview, quote cards, the QDB welcome news body, the QDB quote root body and the reply composer's parent.
Verification:
New
tests/PostTextTest.php(registered): passthrough for plain/legacy, every markup element, fence-only bodies.New
WriteApiSmokeTest::testPreviewsShowMarkdownPostsAsPlainWords: all-threads Board, Recent and the reply composer showRelease notes for v3for a## Release **notes** for \v3\`post and never the markup; a plain post's literal## ... stars` text stays as typed.Related suites (Board x2, Forte x2, Qdb x7, RecentPost, ThreadTitle, PostText, PostMarkdownRenderer, Profile, Activity, TagPagination): 107 run, 107 passed. Full
WriteApiSmokeTest: 203 run, 203 passed.
Notes:
Retrospective above the original: more title/excerpt paths derive text from raw bodies than the Step 3 grep found (compose title, Forte titles, Recent context titles, activity labels).
Board search (
?q=) still matches against the raw body, so searching for**or##can match Markdown posts; left as is.
Estimate details: original scope unchanged; scope of call sites larger than understood at Step 3 (new understanding, not changed scope).
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 5 - Composer, API and docs
Original estimate: 0.15 Retrospective assessment: 0.15
Changes:
Shared composer: the ··· menu gains "Plain text (no Markdown)" (
body_format=plaincheckbox, unchecked) and a "Formatting help" link to the syntax reference, so every composer (new post, compact board/QDB add, replies, paned dialog) has it.browser_signing.jssendsbody_formatfrom the checkbox for signed threads and replies (including the phone reply continuation, which reuses the reply field collector);outbox_compose.jsstores the choice in Outbox drafts and the Outbox send path forwards it. Unsigned no-script submissions carry it as an ordinary form field.compose.css: menu rows for the checkbox and link.Docs: new
docs/reference/markdown_syntax.md; linked from the renderer runbook and the post record spec; spec's client-signed subset allows the optional header;agent_api.mdgains a Post format section; llms.txt gains "## Post format".Concepts: new
post_body_formatting.md;post_presentation.mdandshared_composer.mdupdated.
Verification:
New
WriteApiSmokeTest::testComposersOfferPlainTextAndTheUnsignedFormPathHonoursIt(checkbox and help link on the New post page, a thread page reply composer and the all-threads Board; unsigned/compose/threadwith the box ticked writesBody-Format: plain) andLlmsTxtRendererTest::testDocumentsTheOptionalPostFormat.LlmsTxtRendererTest AgentApiWorkedExampleTest PlatformDocsPageTest PlatformDocsCatalogTest: 35 run, 35 passed (before the new llms test); with the new tests 9/9 in the focused run.node --checkon both edited scripts.Browser check: New post page with the menu open in Word97 at 1280px and Midnight at 390px; the checkbox and link sit with the other menu rows, and clicking the checkbox does not close the menu (only menu buttons close it).
Notes:
Not verified in a browser: an actual signed submission with the box ticked (needs a browser key). Stage 6 posts through the live composer.
"Discard draft" leaves the checkbox as it was; drafts restored from local storage do not remember it.
Correction after the full suite (separate commit): the first version added
body_format: ""to every collected field set andbodyFormat: ""to every Outbox payload, which changed five exact-shape JS tests (BrowserSigningNormalizationTestx3,OfflineOutboxComposeTestx2). Now the field is added only when "Plain text" is ticked, so default submissions and stored Outbox items keep their previous shape;OfflineOutboxComposeTest::testPlainTextChoiceTravelsWithTheDraftcovers the ticked case.
Estimate details: original scope unchanged; no revision.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Branch verification before handoff
Merged
origin/main(223c8a8e, including app-owned sessions and the mobile composer) into the branch asf7859fc6; the only conflict was the plan index, resolved by keeping both entries.Full suite on the merged tree: 1870 run, 1850 passed, 20 failed. 15 are the baseline failures; the other 5 were the JS field-shape tests fixed in the Stage 5 correction (84/84 after the fix in
BrowserSigningNormalizationTest OfflineOutboxComposeTest).Branch pushed to
origin/feature/post-markdown.
Stage 6 - Live checks on zenmemes.com
Original estimate: 0.10
Status: pending. It needs the branch merged and deployed (Tools -> Deploy, operator session), which the maintainer does. After deploy, the read model rebuilds on the next request because the schema changed (44 -> 45), unless the site runs in high-traffic mode, where
./v3 high-traffic preparecomes first.Checks to run after deploy: one Markdown thread and one plain thread from the composer, one agent post through the API with no
body_format,get_postshowingBody-Formatfor each, and an existing post (the Nov 2 changelog thread) looking unchanged. Test posts are retracted afterwards unless the maintainer keeps them.Retrospective and ledger closeout are recorded with this stage.
Stage 7 - Markdown box and detector
Original estimate: 0.10 Retrospective assessment: 0.10
Changes:
Composer menu: a Markdown checkbox (
body_format=markdown), unticked by default, after a hiddenbody_format=plainfallback, so an unticked box posts plain text with or without JavaScript. "Plain text (no Markdown)" is gone.New
public/assets/markdown_detect.js, loaded on every page from the layout:ForumMarkdownDetect.looksLikeMarkdown(text)checks for headings, bullet/numbered lists, quotes, code fences, table rows,**bold**,*emphasis*,[label](https://...)links and inline code. It ticks the box as the author types until they change the box by hand; Discard draft resets that. Composers inserted later bind on first focus.browser_signing.jsandoutbox_compose.jssendmarkdownorplainfrom the box for composer forms; forms without the box (and older Outbox drafts) still leave the server default.API default unchanged (Markdown). Syntax reference and both concept entries describe the box.
Verification:
New
tests/MarkdownDetectTest.php(registered): 11 Markdown drafts detected; 11 ordinary drafts (2 * 3 * 4,a*b*c, hashtags, a bare URL,-, an email address) not detected; a manual choice sticks and Discard draft resets it.WriteApiSmokeTest::testComposersOfferTheMarkdownBoxAndTheUnsignedFormPathHonoursIt: pages carry the hidden fallback and the box; unticked unsigned post recordsplain, ticked recordsmarkdown.OfflineOutboxComposeTestcovers the format travelling with Outbox drafts.MarkdownDetectTest OfflineOutboxComposeTest BrowserSigningNormalizationTest LocalAppSmokeTestplus the composer test: 254 run, 251 passed; the 3 failures are the baselineLocalAppSmokeTestones.Browser (headless Chrome, New post page, typing simulated with an input event):
## Headingticks the box;plain words onlyand2 * 3 * 4do not.
Notes:
The box lives in the ··· menu, so an author sees whether it ticked only by opening the menu. If that turns out to be too hidden, a small visible "Markdown" label beside Post is an easy follow-up.
Estimate details: original scope unchanged; no revision.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 8 - Post-format records in the read model
Original estimate: 0.25 Retrospective assessment: 0.20
Changes:
New spec
docs/specs/post_format_record_v1.md:records/post-formats/<Record-ID>.txtplus.asc; headersRecord-ID(post-format-<Post-ID>-<timestamp digits>-<8 hex>),Created-At,Post-ID,Body-Format,Actor-Identity-ID, in that order, empty body.PostFormatRecordandPostFormatRecordParser(strict order, derived Record-ID, format and identity checks);CanonicalPathResolver::postFormat();CanonicalRecordRepository::loadPostFormat()andloadPostFormatsFor($postId).ReadModel\PostFormatChanges: picks the latest record per post and setsposts.body_format, recomputing a thread root'sbody_preview; retracted and missing posts are skipped. Used by the full rebuild (newindex_post_formatsphase after thread subjects) and byIncrementalReadModelUpdater::applyPostFormatWrite($postId, $commitSha).
Verification:
New
tests/PostFormatRecordTest.php(registered): parser accepts the canonical record and rejects five variants; an old header-less post switched Markdown -> plain -> Markdown plus a reply switched once give the samebody_formatand thread previews after incremental updates and after a full rebuild; records for missing posts and unparseable files are ignored.PostFormatRecordTest CanonicalRecordParsersTest ReadModelBuilderTimingTest ReactionRecordFormatParityTest ReadModelMetadataTest ReadModelProvenanceTest RecentPostTest BoardListTest: 85 run, 83 passed; the 2 failures are baseline.
Notes:
Activity labels derived from a root's first line are not recomputed on a switch; they show the format at posting time until the next rebuild.
The read model does not re-verify signatures; the writer (Stage 9) is the only path that commits these records.
Estimate details: original scope unchanged; retrospective lower because the thread-subject pattern carried over directly.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 9 - Signed format switches
Original estimate: 0.20 Retrospective assessment: 0.20
Changes:
LocalWriteService::preparePostFormat()andcreatePreparedPostFormat(): prepare builds the canonical record forpost_idandbody_format(required,markdownorplain) signed byactor_identity_id; create checks the prepared token, record and metadata, re-checks authority, verifies the detached signature against the actor's key, commits the record and its.asc, applies it incrementally (or rebuilds), and invalidates the thread's static pages. An exact replay after token cleanup returnsalready_applied.Authority (
requireFormattablePost): the post's author, or an approved identity thatRetractionAuthority::currentOperator()accepts, the same rule as operator retraction. Header-less and anonymous posts can be switched by operators. Missing or retracted posts and identity/approval/invitation posts are refused.Routes
POST /api/prepare_post_formatand/api/create_prepared_post_format, listed in the API catalog.
Verification:
New
WriteApiSmokeTest::testPostFormatSwitchesAreSignedByTheAuthorOrAnOperator: another member is refused at prepare; an invalid format is refused; the author's switch to plain is refused with another key's signature and with none, then accepted;get_postand the read model showplain; an exact replay returnsalready_applied; a root-approved operator switches it back and the thread page renders the Markdown; an operator switches the header-less fixtureroot-001; an identity/approval post is refused; a full rebuild agrees.WriteApiSmokeTest ApiRouteCatalogTest LlmsTxtRendererTest PostFormatRecordTest: 219 run, 219 passed.
Notes:
Operators are checked at prepare and again at create, so losing operator status between the two steps refuses the switch.
Estimate details: original scope unchanged; no revision.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Stage 10 - Switch control and docs
Original estimate: 0.15 Retrospective assessment: 0.20
Changes:
The post's ··· panel (the retract control, shown on thread roots, replies and post pages to the author and to operators) gains Show as Markdown or Show as plain text, whichever is the other format. Clicking it signs the switch in the browser (
ForumBrowserSigning.submitSignedPostFormat) and reloads the page; errors show in the control's feedback line.llms.txt describes the switch and lists the two routes for agents;
agent_api.md, the syntax reference and the concept entry describe it.Stage 9 correction found while running this stage's suites: two switches within the same second could sort either way, because order comes from
Created-Atand the tie-break was random. The writer now issues each record strictly after the latest one for that post (moving a second forward if needed) and refuses to commit a prepared switch that is no longer the latest ("Prepare the change again"). The spec says so.
Verification:
New
WriteApiSmokeTest::testFormatSwitchAppearsBesideRetractForAuthorsAndOperatorsOnly: the author and an operator see the control with the right label; another member does not.Stage 9 test extended: a stale prepare is refused after a newer switch, and three switches in quick succession end in the last one chosen; repeated runs pass.
WriteApiSmokeTest PostFormatRecordTest: 209 run, 209 passed.BrowserSigningNormalizationTest OfflineOutboxComposeTest LocalAppSmokeTestwith the write suite: only the 3 baselineLocalAppSmokeTestfailures.LlmsTxtRendererTest ApiRouteCatalogTest PlatformDocsPageTest: pass.node --checkon both edited scripts.
Notes:
Not checked in a real browser: signing a switch needs a browser key on a running site. Stage 6 covers it live on zenmemes.com.
The page reloads after a switch rather than re-rendering the post in place.
Estimate details: original scope unchanged; retrospective higher because of the Stage 9 ordering correction.
— Claude · 2026-11-03 UTC · Model: claude-opus-5-5
Branch verification after Stage 10
PR #12 (Stages 1–5) was merged to main and deployed before Stages 7–10 finished; zenmemes.com already reports
Body-Formatfromget_post. Merged the neworigin/main(f2a79425) into the branch asc21355c4; the only conflict was the uncommitted ledger, kept with both additions.Full suite on that tree: 1879 run, 1863 passed, 16 failed. 15 are baseline failures; the 16th,
PostRetractionScriptTest::testRetractButtonConfirmsSubmitsAndReportsEachOutcome, came from Stage 10: its harness drives the first click handlerpost_retraction.jsregisters, which had become the format switch. Fixed by registering the switch handler after the retract handler; the test passes (2/2).