Feature plan: Step 1 · Step 2 · Step 3 · Step 4

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

CheckpointUTCScoreScope / reference / protocolRater and exposureReason
Step 4 entry2026-11-03T17:10Z0.85v2 / R1 / P2, original baseline fd4d0e0227c18ed840c166e2ce4948233d148769Claude (claude-opus-5-5), single exposed raterStep 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

RequestDisposition and reasonApproval statusOutcome
Rewrite the Nov 2 changelog postSeparate: content changeRequested by maintainerDone before Step 4
Label the composer option "Markdown" instead of "Plain text" (2026-11-03)Changed scope: inverts the Step 2 composer requirementRevised Steps 2 and 3 approved 2026-11-03Stage 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 tickedRevised Steps 2 and 3 approved 2026-11-03Stage 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 publicationFolded 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|plain documented as an optional header in docs/specs/canonical_post_record_v1.md; absent means plain.

    • PostRecord gains bodyFormat (nullable) and rendersMarkdown(); PostRecordParser reads and validates the header.

    • LocalWriteService reads an optional body_format input on create/prepare for threads and replies. Default markdown, plain for QDB quotes; other values are rejected. Builders add the header only when a format is given, so existing builder bytes are unchanged.

    • SignedPostBundleValidator accepts the header in client-built records (after Subject for threads, after Author-Identity-ID for replies). Without this, direct-signed posts carrying the header were rejected as noncanonical.

    • Read model: posts.body_format column in the builder and both incremental insert paths; schema version 44 -> 45.

    • get_post always prints Body-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_post reports 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 prepare runs; Stage 6 checks which applies on zenmemes.com.

    • The public offline snapshot keeps its own posts table 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): string shares 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 at h6) without page ids; links only for absolute http(s) URLs, with rel="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 in tests/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 .md under docs/ (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 $markdown argument: text between image references and embed cards goes through renderPost(); only embed URLs alone on their line become cards, others stay ordinary links. The plain path is the existing code.

    • TemplateRenderer adds $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 a post-markdown class.

    • Full-post queries select posts.body_format (thread posts, single post, reply lists, paned board root as root_post_body_format, authored content).

    • site.css (non-critical part): spacing, headings, lists, quotes, code, tables, rules and task items for .post-markdown, using currentColor so themes supply colors.

  • Verification:

    • New tests: WriteApiSmokeTest::testThreadPagesRenderMarkdownOnlyForMarkdownPostsAndRespectTheFlag (Markdown thread/reply/post page render; plain post shows source; flag off shows source), two MediaEmbedRendererTest cases (standalone embed card plus inline link; flag-off output byte-identical to the old helper), flag list in FeatureFlagEvaluatorTest.

    • 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 LocalAppSmokeTest failures.

    • 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_preview and activity labels go through it in the builder and the incremental updater, so Board, RSS, Forte sorting and every body_preview consumer 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 show Release notes for v3 for 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=plain checkbox, 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.js sends body_format from the checkbox for signed threads and replies (including the phone reply continuation, which reuses the reply field collector); outbox_compose.js stores 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.md gains a Post format section; llms.txt gains "## Post format".

    • Concepts: new post_body_formatting.md; post_presentation.md and shared_composer.md updated.

  • 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/thread with the box ticked writes Body-Format: plain) and LlmsTxtRendererTest::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 --check on 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 and bodyFormat: "" to every Outbox payload, which changed five exact-shape JS tests (BrowserSigningNormalizationTest x3, OfflineOutboxComposeTest x2). Now the field is added only when "Plain text" is ticked, so default submissions and stored Outbox items keep their previous shape; OfflineOutboxComposeTest::testPlainTextChoiceTravelsWithTheDraft covers 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 as f7859fc6; 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 prepare comes 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_post showing Body-Format for 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 hidden body_format=plain fallback, 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.js and outbox_compose.js send markdown or plain from 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 records plain, ticked records markdown. OfflineOutboxComposeTest covers the format travelling with Outbox drafts.

    • MarkdownDetectTest OfflineOutboxComposeTest BrowserSigningNormalizationTest LocalAppSmokeTest plus the composer test: 254 run, 251 passed; the 3 failures are the baseline LocalAppSmokeTest ones.

    • Browser (headless Chrome, New post page, typing simulated with an input event): ## Heading ticks the box; plain words only and 2 * 3 * 4 do 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>.txt plus .asc; headers Record-ID (post-format-<Post-ID>-<timestamp digits>-<8 hex>), Created-At, Post-ID, Body-Format, Actor-Identity-ID, in that order, empty body.

    • PostFormatRecord and PostFormatRecordParser (strict order, derived Record-ID, format and identity checks); CanonicalPathResolver::postFormat(); CanonicalRecordRepository::loadPostFormat() and loadPostFormatsFor($postId).

    • ReadModel\PostFormatChanges: picks the latest record per post and sets posts.body_format, recomputing a thread root's body_preview; retracted and missing posts are skipped. Used by the full rebuild (new index_post_formats phase after thread subjects) and by IncrementalReadModelUpdater::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 same body_format and 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() and createPreparedPostFormat(): prepare builds the canonical record for post_id and body_format (required, markdown or plain) signed by actor_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 returns already_applied.

    • Authority (requireFormattablePost): the post's author, or an approved identity that RetractionAuthority::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_format and /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_post and the read model show plain; an exact replay returns already_applied; a root-approved operator switches it back and the thread page renders the Markdown; an operator switches the header-less fixture root-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-At and 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 LocalAppSmokeTest with the write suite: only the 3 baseline LocalAppSmokeTest failures. LlmsTxtRendererTest ApiRouteCatalogTest PlatformDocsPageTest: pass. node --check on 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-Format from get_post. Merged the new origin/main (f2a79425) into the branch as c21355c4; 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 handler post_retraction.js registers, which had become the format switch. Fixed by registering the switch handler after the retract handler; the test passes (2/2).