Skip to content

Read page metadata from markfluence.yaml's pages: key - #150

Merged
willkg merged 14 commits into
mainfrom
project-file-pages
Sep 13, 2026
Merged

Read page metadata from markfluence.yaml's pages: key#150
willkg merged 14 commits into
mainfrom
project-file-pages

Conversation

@willkg

@willkg willkg commented Sep 12, 2026

Copy link
Copy Markdown
Member

PR 1 of 2 for #139 — the read half, which is the whole CI use case. A markdown file can now be published while carrying no markfluence keys at all, with its page metadata in a pages: entry in markfluence.yaml. PR 2 adds the write half — create persisting entries. (The plan also gave PR 2 fix migrating inline keys; #151 proposes removing fix altogether, so that half is now an open question rather than planned work.) Design: _plans/039_project-file-pages.md.

This is what it makes possible, and the point is what is absent from the workflow step:

- run: markfluence update docs/**/*.md

or something like this if we build a GitHub action:

- uses: mozilla/markfluence@v1
  with: { command: update, files: "docs/**/*.md", url: …, username: … }
  env:  { CONFLUENCE_TOKEN: ${{ secrets.CONFLUENCE_TOKEN }} }

No matrix, no per-file inputs, no flags. Adding a page is a repository change rather than a workflow change.

space: ENG
page_width: max

pages:
  docs/deploy-runbook.md:
    title: Deploy Runbook
    page_id: 12346
    labels: [runbook, ci/cd]

An entry is a parsed frontmatter block

Entry carries the same two maps frontmatter.MarkdownFile does — Fields map[string]string and Lists map[string][]string. That is the design rather than a convenience, because it is the only shape that works: internal/pagewidth and internal/labels both reach internal/client, which holds a *project.Cache, so internal/project can never import them, and a typed validated entry would need either a broken cycle or a second copy of every field's rules. With two maps it needs neither — labels.Declared(e.Lists, e.Fields), pagewidth.Declared(e.Fields) and pageref.IsDigits(e.Fields["page_id"]) all work on an entry unchanged, in the commands that already call them.

entryFields is the manifest's schema and the only place it is written down. It mirrors frontmatter's fields deliberately: adding one there without adding it here would make a field expressible in a file and not in an entry, which is the "same keys" half of the equivalence.

Frontmatter and an entry are two spellings of one level

Not two levels of a precedence chain — writing it as one is wrong, and the plan says so after I got it wrong first. When both locations speak, the rule is not "the higher wins" but a grading by what a disagreement can destroy:

field on disagreement
page_id, space, parent error, that file fails the clobber case: a page_id pasted from an old file publishes over a live page
title, page_width, labels warning, frontmatter wins visible and recoverable

Agreement is silent, which is what makes migration incremental: copy values into the manifest, verify, delete them from the files later, with everything working throughout. There is no project "mode" and no all-or-nothing switch. Precedence only applies below these two, for a field neither declares — which is where #100's project-wide defaults sit.

A file nothing claims is skipped, not failed

update errored on a file with no page_id. It now reports skipped, ok: true, exit 0, with no request made. Repositories legitimately hold markdown that is not published, drafts are a normal state, and a glob-driven CI run must not go red because somebody added a file.

A file that is registered but has no page_id still fails: somebody claimed it and create has not run. Human output distinguishes the two skips, since "no changes" and "not published by markfluence" are different facts.

update loses --title, --page-id, --page-width

The rule is flags describe the run; files describe the page. The first two were single-FILE-only, so docs/**/*.md could not be expressed at all — which is the entire reason #139 exists. --page-width was the only batch-ok one and so the real loss, but #100's project-wide page_width: says the same thing permanently instead of once per invocation. --message/--force/--dry-run stay, being invocation metadata and behaviour.

overrideNeedsSingleFile goes with them, and a test pins the flags' absence so re-adding one is a deliberate act. This also settles permanently what #138 kept bumping into: there is no --labels, and no flag for whatever field comes after it.

create keeps its flags, for a principled reason rather than squeamishness: it is the verb that establishes metadata, so --space/--parent/--title are how an entry that does not exist yet gets bootstrapped. update only ever consumes metadata, so it should have no way to invent any.

The sharp touchpoint, verified live

linkindex.Build read each sibling's own frontmatter for page_id/title, so a manifest-only file had no index entry — and every link to it would degrade to the "exists on disk, not published yet" warning and republish as plain text. It resolves through pagemeta now.

Verified against mozilla-hub.atlassian.net with a pristine tree (no frontmatter in any file, a shared asset above the pages, a draft). Published storage for docs/deploy-runbook.md:

<p>See the <a href="https://…/pages/3069509651/mf139+On-call+Handbook">handbook</a>.</p>
<p><ac:image ac:alt="arch"><ri:attachment ri:filename="architecture.png" /></ac:image></p>

That link target is only knowable from the manifest. The whole glob in one command:

published  src=manifest     docs/deploy-runbook.md
published  src=manifest     docs/on-call-handbook.md
skipped    src=None         docs/scratch.md

Pages purged afterwards.

Elsewhere

internal/frontmatterDialect.MaxDepth, because pages: needs two levels and the fenced block must keep refusing nesting entirely. A depth rather than a list of keys allowed to nest, so the reader still learns a kind and not a name; past the allowance a mapping falls through to the scalar path and is refused there, so a flat dialect behaves exactly as before rather than by a parallel branch that could drift. MaxDepth defaults to 0, which blockReader keeps, and two tests pin that from both sides.

internal/pagemeta (new) — Resolve, the one merge update, create, check and linkindex share, because a per-command copy is how two commands come to publish one file to two different pages.

check — validates a file's metadata wherever it lives, and only for a file the invocation names, so one bad entry never blocks checking the rest of a repository. Two locations naming different pages is an error. The half-and-half lint (inline keys in a project that uses pages:) is a warning, never an error — agreement is legal, so "keep it in one place" has to be sayable without becoming a wall somebody hits mid-migration.

--jsonmetadata_source on update/create: "frontmatter", "manifest", or null. Debugging "why did it publish to that page" in a CI log otherwise means reproducing the resolution by hand.

Every command that takes a page accepts a pristine file

Added after review, from an audit rather than from the review itself. internal/pageref.Resolve read mf.PageID() alone, so seven commands could not name a file update could publishinfo, read, children, export and the three attachment-* verbs. They failed safely (no page_id in frontmatter of docs/a.md), but the page argument meant one thing to update and another to everything else, which is the inconsistency that package exists to prevent.

Fixed in pageref rather than at the seven call sites: Resolve already stats the file, so it discovers the root from that file's own directory — the same per-file discovery every other read does — and resolves through pagemeta. No signature change, no call-site churn. One deliberate asymmetry: a disagreement between the two locations is fatal there (it is the question being asked), while a malformed markfluence.yaml is not, since a project file that resolver never consults must not make info 123 fail.

fix also changed, and more than the plan expected. It had no project concept at all, being the one verb that reads the page and writes the file; it now resolves through the root, which is what lets it locate a pristine registered page. And it refuses to write into a file whose metadata lives only in the manifest — reconciling that means editing a pages: entry, and writing frontmatter instead is precisely the bug review caught in create. A file carrying some inline keys is still fixed. Its planners take the resolved maps rather than a *MarkdownFile, so they reconcile what the file effectively declares.

Things that took a second pass

Recorded because each was wrong first and the wrong version passed its tests. These are the ones found while building; adversarial review then found nine more and an audit found the pageref class above — all fixed, and detailed in the comments below rather than here.

A file carrying only keys markfluence preserves read as claimed. Counting any frontmatter key at all as "this file declares metadata" meant a file whose only frontmatter was, say, reviewers: [ana, bo] reported as managed — and update would then fail it for want of a page_id. That is a shape markfluence explicitly supports: internal/frontmatter deliberately keeps keys it does not understand, pinned by a test on exactly that key, because a labels special case "would satisfy every other test and break #21/#100". Contributed metadata is now counted only over fields project.IsPageField knows. (An earlier version of this description justified it with Jekyll and Hugo frontmatter, which is a scenario nobody here has; the reason is inside markfluence, and the code comments say so now.)

Source and Managed need different predicates. Source answers "who contributed metadata", for reporting; Managed answers "should update act on this file", which is true when an entry exists or a page_id is named. A page_id is the only field that identifies a page, and #139 is precise that a registered file is one with an entry — so a.md: {} is a claim that must fail for want of an id rather than be skipped, and a file carrying title: alone has not said which page it is.

A disagreement must not cost a file its anchors. The first linkindex version skipped the whole file, matching what an unreadable one gets. A test caught why that is worse: FileExists goes false, so every link to that file becomes LINK BROKEN across the tree, where the right answer is the ordinary unpublished warning. It costs the file its page entry and nothing else.

Plus two found by their own tests: check assigned r.warnings from the label and converter warnings, silently discarding the new lint; and labels was compared in order, so [a, b] vs [b, a] warned about a difference Confluence cannot represent.

Also

The metadata_source addition first went in via a JSON round-trip, which reformatted the entire schema — every compact inline object exploded, 1728 lines of diff in a hand-formatted file. Reverted and applied as text: 10 lines, two definitions, plus a shared metadataSourceOrNull $def beside the other *OrNull refs.

docs/github-actions.md changes most, since it is the document this exists to make true: its example was update --page-id=12345 --force docs/some_doc.md.

make check clean. Out of scope and deliberate: creating pages in CI (a workflow would have to commit the new page_id back), export writing entries, a --persist-to flag, and update enforcing space/parent (#10).

Design for #139: a pages: key mapping a root-relative path to that file's
page metadata, so a .md can be published while staying pristine. Lands on
#100's loader and makes #29's Action usable -- one step, a glob, no per-file
inputs.

The core move is making #139's "an entry is a whole frontmatter block that
lives elsewhere" literal: an entry carries the same two maps
frontmatter.MarkdownFile does. That is not just tidy, it is the only shape
that works -- internal/project cannot import internal/pagewidth or
internal/labels, both of which reach internal/client, which holds a
*project.Cache, so a typed validated Entry would need a broken cycle or a
second copy of every field's rules. With the two-map shape,
labels.Declared(e.Lists, e.Fields) and pagewidth.Declared(e.Fields) work
unchanged in the commands that already call them.

Facts from the code that shaped it. linkindex.Build already takes the root and
already keys idx.pages by the exact root-relative slash path pages: uses, so
the merge needs no signature change. The dialect refuses nesting and must keep
refusing it for frontmatter, so nesting becomes a MaxDepth on Dialect rather
than a key-name list. create writes each file's frontmatter as that page
publishes, inside the loop, so a crash leaves every already-created page
recorded -- the manifest writer has to keep that.

Landing as two PRs: read and consume first (the whole CI use case), then the
nested surgical writer plus create and fix. The writer is the part most likely
to need a second pass, and holding the read half hostage to it would mean
re-reviewing all of it.

Also records a considered-and-rejected alternative -- pages: as a nested
hierarchy -- because the question will come back. It cannot replace parent:
(which is null, an opaque page or folder id, or a path, and nesting expresses
only the third), every consumer looks up by path so a hierarchy is flattened
on load anyway, and the correctness argument does not survive: cycles are
already rejected in create's topological sort.

Two follow-ups came out of the discussion and are filed: #148 (a status
command) and #149 (update silently overwriting a Confluence-side edit).
markfluence.yaml's pages: key needs two levels -- pages, then a path, then
that page's fields -- where the fenced frontmatter block must keep refusing
nesting entirely.

So the allowance is a depth on Dialect rather than a list of keys permitted to
nest, which keeps the reader learning a kind and not a name: the project file
asks for two levels and says so, and a mapping under a key whose own table
says scalar is refused by that table with its own message. MaxDepth defaults
to zero, which is what blockReader keeps, so every message and refusal the
fenced block makes is unchanged -- two tests pin that from both sides.

Past the allowance a mapping falls through to the scalar path and is refused
there, so a flat dialect behaves exactly as the reader did before nesting
existed rather than by a separate branch that could drift from it.

Item gains Map, and the nil-ness of List and Map is what says which of the
three shapes a value has. Both goccy spellings of a one-key mapping are
accepted (MappingValueNode as well as MappingNode, the same split
soleMappingPair exists for on the write side), or a single-field entry would
read as a broken scalar.
An Entry is shaped exactly like a parsed frontmatter block -- the same two
maps MarkdownFile carries, scalars by key and sequences by key. That is the
design and not a convenience: internal/pagewidth and internal/labels both
reach internal/client, which holds a *project.Cache, so this package can
never import them, and a typed validated Entry would need a broken cycle or a
second copy of every field's rules. With two maps it needs neither --
labels.Declared(e.Lists, e.Fields) and pagewidth.Declared(e.Fields) work on
an entry unchanged, in the commands that already call them.

entryFields is the manifest's schema and the only place it is written down. It
mirrors frontmatter's fields deliberately: adding one there without adding it
here would make a field expressible in a file and not in an entry, which is
the "same keys" half of the equivalence.

What is checked at load is structure -- a mapping of mappings, legal paths,
known field names, right shapes. What is not checked is any field's value,
because #139 requires a semantically bad entry to be reported only when its
file is one of the arguments, and this function has no idea which files the
command was given. An unknown field *name* is the exception and is load-time:
it is the same typo class as an unknown top-level setting, and it means the
manifest was written against a different markfluence.

NormalizePageKey is used on both sides -- the manifest's keys and a file being
published -- because a mismatch means a silent skip rather than an error, so
the two have to agree exactly. Lexical only, no symlink resolution (L2 forbids
a key whose meaning depends on the checkout's layout); an escaping or absolute
key and two keys normalizing to one are load-time errors naming both
spellings, since YAML only catches literal duplicates.

Config.Pages is nil when there is no pages: key and empty-non-nil for
`pages: {}`, which is how a command tells "has not chosen the manifest" from
"has, and has registered nothing" -- D9's write-destination inference turns on
exactly that.

One existing message changed: `space:` holding a mapping is now refused by the
settings table ("must be a single value, not a mapping") rather than by the
reader, since the project dialect allows depth for pages: and the value
reaches the table. That is the intended split and the table's wording is the
better one.
…nifest

The merge update, create, fix, check and linkindex all need. A package rather
than a helper because a per-command copy is exactly how two commands come to
publish one file to two different pages. It imports only frontmatter and
project, so it stays out of every cycle -- which is also why it validates no
value: pagewidth and labels are unreachable from here, and the commands that
need them already call them on the maps this returns.

Frontmatter and an entry are two spellings of *one* level, not two levels of a
precedence chain. When both speak the rule is not "the higher wins" but
grading: page_id/space/parent are coordinates and a disagreement fails the
file, while title/page_width/labels are visible and recoverable, so they warn
and frontmatter wins. Agreement is silent, which is what makes migration
incremental.

Three things that took a second pass to get right.

Source and Managed are computed from different predicates, deliberately.
Source answers "who contributed metadata" for --json's metadata_source;
Managed answers "should update act on this file". A file is claimed when it
has an entry or names a page_id -- a page_id is the only field that identifies
a page, and #139 is precise that a *registered* file is one with an entry. So
`a.md: {}` is a claim that must error for want of a page_id rather than be
skipped.

Contributed metadata is counted over the fields markfluence knows, via a new
project.IsPageField, not over any key at all. A docs tree carrying Jekyll or
Hugo frontmatter (layout:, date:, draft:) has said nothing about Confluence,
and counting every key would have read a whole such tree as claimed.

labels is compared as a set. Confluence has no label order, so [a, b] and
[b, a] say the same thing and warning that one "overrides" the other would be
noise about a difference that cannot reach the page.

A blank value is not a disagreement: every null spelling already reads as "",
so a key with nothing after it says no more than no key.
Build read each sibling's own frontmatter for page_id/title, so a file whose
coordinates live in markfluence.yaml's pages: block had no entry in the index
-- and every link to it would degrade to the "exists on disk, not published
yet" warning and republish as plain text. #139 calls this out as the sharp
touchpoint, and it is sharp in consequence rather than in plumbing: Build
already takes the root and already keys idx.pages by the exact root-relative
slash path pages: uses, so the change is to resolve through pagemeta instead
of from mf alone.

Resolving per path is also what makes a half-migrated tree correct throughout,
with some files declaring their own coordinates and some leaving them to the
manifest.

A disagreement costs the file its page *entry* and nothing else. The first
attempt skipped the whole file, matching what an unreadable or malformed one
gets, and a test caught why that is worse: the anchors go unrecorded too, so
FileExists goes false and every link *to* that file becomes LINK BROKEN across
the tree, where the right answer is the ordinary unpublished warning.
…page flags

update now takes each file's title and page id from its own frontmatter or
from a pages: entry for it, indistinguishably. Both places are legal and
agreement is silent, which is what makes migration incremental; a coordinate
disagreement (page_id/space/parent) fails that file, and a soft one
(title/page_width/labels) warns with frontmatter winning.

--title, --page-id and --page-width are gone. The rule is that flags describe
the run and files describe the page: the first two were single-FILE-only, so
docs/**/*.md could not be expressed at all, which is the whole reason #139
exists. --page-width was the only batch-ok one and so the real loss, but a
project-wide page_width: says the same thing permanently instead of once per
invocation. overrideNeedsSingleFile goes with them, and a test pins their
absence so re-adding one is a deliberate act.

A file neither place mentions is skipped, ok, exit 0, with no request made.
Repositories legitimately hold markdown that is not published and drafts are a
normal state, so a glob-driven CI run must not go red because somebody added
a file. A file that *is* registered but has no page id still fails -- somebody
claimed it and create has not run -- and human output distinguishes the two
skips, since "no changes" and "not published by markfluence" are different
facts.

create reads entries too, which is read-side work and belongs here rather
than in the write PR: ignoring an entry's page_id would publish a second page
and leave the entry pointing at the first, which is #18's bug reached from the
other direction. Its flags are unchanged -- create establishes metadata, so
--space/--parent/--title are how an entry that does not exist yet gets
bootstrapped.

--json gains metadata_source on both commands ("frontmatter", "manifest", or
null), because debugging "why did it publish to *that* page" in a CI log
otherwise means reproducing the resolution by hand. Required and nullable, no
omitempty.
check now resolves each file's metadata through pagemeta before validating it,
so an entry's page_width, page_id, labels and title are checked exactly as a
file's own are -- and only for a file the invocation names, which is #139's
per-file scoping made real: one bad entry never blocks checking the rest of a
repository.

Two locations naming different pages is an error, and offline-visible, which
is the kind of defect check exists to catch before a publish does.

The half-and-half lint is a *warning*, never an error, and that is the whole
point: agreement between the two locations is legal, so "keep it in one
place" has to be sayable without becoming a wall somebody hits halfway
through a migration. fix moving the keys is the remedy (PR 2).

One bug found by its own test: r.warnings was *assigned* from the label and
converter warnings, so anything set earlier -- the new lint -- was silently
discarded. It accumulates now.
docs/root-model.md gains a pages: section beside the settings: that an entry
is a frontmatter block living elsewhere, the disagreement grading, that an
unclaimed file is skipped rather than failed, and the path-key rules --
lexical, root-relative, no case folding, with an escaping or duplicate key
failing the load.

docs/github-actions.md is the one that changes most, because it was the
document #139 exists to make true. Its example was `update --page-id=12345
--force docs/some_doc.md`; it is now `update docs/**/*.md` with no per-file
inputs at all, plus the pristine-markdown shape, and notes on the skip, on
metadata_source, and on why creating pages stays a human act.

docs/guarantees.md extends the L2 note: a pages: entry is #100's argument
taken further, and it is *why* the keys are lexical -- resolving a symlink
would make a key mean different things on two checkouts. update losing its
three page flags narrows L2's carve-out for flags, since which page a file
publishes to no longer depends on how the command was invoked.

CLAUDE.md gets an internal/pagemeta bullet, and internal/project's gains
pages: -- including the three things that took a second pass: Source and
Managed computed from different predicates, contributed metadata counted only
over known fields (or a Jekyll tree reads as claimed), and labels compared as
a set. docs/json-output.md documents metadata_source.

The README block and docs/markdown_file.md both point at the new section
rather than restating the field table.
The metadata_source addition went in via a JSON round-trip, which reformatted
the whole document -- every compact inline object exploded across lines, for a
1728-line diff in a file that is hand-formatted and read by people. Reverted
and applied as text: 10 lines, and only the two definitions that change.

The enum also becomes a shared metadataSourceOrNull $def rather than being
written twice, matching stringOrNull and the other *OrNull refs beside it.
…ntry

Two merge blockers, both in create, which "read entries" for
title/space/page_id/page_width/labels while two other paths still read
frontmatter alone.

**A .md parent could not resolve in a manifest project, and this plan's own
example was one of the failing cases.** Two independent halves.
resolveParent joined the parent path onto the *file's* directory, so a
root-relative spelling looked for docs/docs/engineering-docs.md; and it read
the parent's id with pmf.PageID(), frontmatter only, so a parent whose
page_id lived in its own entry reported "not yet published". The second is
#139's linkindex trap in a second place, where it fails a create rather than
degrading a link.

A path in the manifest is root-relative, like every pages: key, and #139's own
example spells parent: that way -- but a parent: path everywhere else in
markfluence is relative to the file that names it. pagemeta now translates at
the boundary, so the manifest stays internally consistent and nothing
downstream has to learn where a value came from. The plan claimed the two
resolved "exactly the same way", which was self-contradictory and was
implemented as neither; it is corrected.

**create no longer persists frontmatter into a file a pages: entry claims.**
It was writing page_id and a resolved `parent: <id>` into a file whose entry
said something else, so the next update or check failed as a coordinate
disagreement. D9's rule is that new metadata goes where that file's metadata
already is, and PR 1 has no manifest writer -- so the right behaviour is not
to write, and to say so: the result carries a warning naming the new page id
and the entry to put it in. D13 called the cost "a page_id copied by hand";
this was silently creating the thing that needed repairing.

Also from review: a present-but-blank frontmatter key no longer vanishes when
an entry exists (the same file was broken without one and clean with one);
metadata_source is set after the Managed() check, so an unmanaged skip reports
null as the schema says; and create reports meta.Warnings, which D6 promises
wherever metadata is resolved and only update delivered.

Nothing in cmd/create exercised pagemeta at all, which is how both blockers
hid. Both halves of the parent fix are sabotage-checked.
check's project-width lint read mf.Frontmatter rather than the resolved
metadata, so a file whose *entry* declared a width was failed over the
project's invalid one -- the exact false positive the surrounding comment
forbids, while update published the same file fine.

check also dropped meta.Warnings, so a soft disagreement between the two
locations was reported only by update, against D6's promise that it is
reported wherever metadata is resolved.

Two more from review, both in the same class of "the code and its own comment
disagreed": NormalizePageKey judged absoluteness before normalizing
separators, so `\foo.md`, `C:\foo.md` and a UNC path were accepted as keys
KeyFor can never produce -- a silently unreachable entry, worse than an error
because nothing says so. And failure.metadataSource was never assigned while
its comment claimed it was populated, so it is gone and abortedResult's null
is explained.

create's "no title given" and the --parent conflict message now name the
entry as a place a value can live.

Tests: the width false positive, the check-side warning, the Windows absolute
forms, that a kind is never zero (IsPageField reads 0 as absent), that a
valueless `pages:` is refused, the enum values of metadata_source against the
schema, the unmanaged skip's own document, and that the two skips render
differently -- which the PR claimed and nothing checked.
_plans/039 gains the nine findings and the corrected parent claim: an entry's
.md parent is root-relative, translated at the boundary, which is *not* "the
same way as frontmatter" however much the equivalence elsewhere holds.

Separately: the comments justifying IsPageField cited Jekyll and Hugo
frontmatter as if that were a known scenario here. It is not, and the grounded
version is inside markfluence -- internal/frontmatter deliberately preserves
keys it does not understand, pinned by a test on `reviewers: [ana, bo]`, so a
file carrying only such keys is a shape markfluence explicitly supports and
must not read as claimed. No third-party tool required, and verifiable in this
repo.
@willkg

willkg commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Adversarial review found nine; all fixed in 3 commits. Two were merge blockers and both were in create, which is the seam this PR left weakest: it "read entries" for title/space/page_id/page_width/labels while two other paths still read frontmatter alone. Nothing in cmd/create's tests exercised pagemeta at all, which is how both hid.

1. A .md parent could not resolve in a manifest project — including the plan's own example

Two independent halves. resolveParent joined the parent path onto the file's directory, so the root-relative spelling looked for docs/docs/engineering-docs.md; and it read the parent's id with pmf.PageID() — frontmatter only — so a parent whose page_id lived in its own entry reported parent not yet published. The second is #139's linkindex trap in a second place, where it fails a create rather than degrading a link.

The underlying question the plan got wrong: a path in the manifest is root-relative (like every pages: key, and as #139's own example spells parent:), while a parent: path everywhere else in markfluence is relative to the file that names it. My plan claimed the two resolved "exactly the same way", which is self-contradictory — and it was implemented as neither. pagemeta now translates at the boundary, so the manifest stays internally consistent and nothing downstream has to learn where a value came from.

Verified live, the exact shape that failed:

pages:
  docs/parent.md:
    title: mf139v2 Parent
    page_id: 3069116502
  docs/child.md:
    title: mf139v2 Child
    parent: docs/parent.md
created 3069804547 | parent: 3069116502 | parent_file: parent.md | src: manifest

2. create persisting frontmatter poisoned every later run of a registered file

Publishing wrote page_id and a resolved parent: <id> into a file whose entry said something else, so the next update or check failed as a coordinate disagreement. D9's rule is that new metadata goes where that file's metadata already is, and PR 1 has no manifest writer — so the correct behaviour is not to write, and to say so. D13 called the cost "a page_id copied by hand"; the code was silently creating the thing that needed repairing.

parent: created 3069116502 | persisted: False
  warn: this file's metadata lives in markfluence.yaml, so no frontmatter was written;
        add "page_id: 3069116502" to its pages: entry

The file is untouched afterwards — verified.

3-6, one-line consistency fixes

  • check's project-width lint read mf.Frontmatter, not the resolved metadata, so a file whose entry declared a width was failed over the project's invalid one — the exact false positive the surrounding comment forbids, while update published the same file fine.
  • Soft-disagreement warnings reached only update. check and create dropped meta.Warnings, against D6's promise that they are reported wherever metadata is resolved.
  • metadata_source was "frontmatter" on an unmanaged skip, set before the Managed() check — contradicting the schema, docs/json-output.md, and the unmanaged field's own comment about --json telling the two skips apart. It can now.
  • A present-but-blank frontmatter key vanished when an entry existed, so title: (empty) was reported broken with no entry and clean with one. A blank is not a disagreement, but it is still something the author wrote.

7-8

NormalizePageKey judged absoluteness before normalizing separators, so \foo.md, C:\foo.md and \\server\share\a.md were accepted as keys KeyFor can never produce — a silently unreachable entry, worse than an error because nothing says so. And failure.metadataSource was never assigned while its comment claimed it was populated; it is gone. create's "no title given" and the --parent conflict message now name the entry as a place a value can live.

Test quality

All four gaps closed. cmd/create now exercises pagemeta (the manifest parent, the plan's example end to end); the conformance fixtures left metadata_source empty so its enum values were never validated against the schema, and the unmanaged skip had no document validated at all — both added; and the human "not published by markfluence" line was untested despite this PR claiming the two skips are distinguishable.

Both halves of finding 1 were sabotage-checked: reverting each independently fails the new tests.

One framing correction, not from the review

The comments justifying IsPageField cited Jekyll and Hugo frontmatter as though that were a known scenario here. It isn't, and I shouldn't have dressed a generic concern as a specific one. The grounded version is inside markfluence: internal/frontmatter deliberately preserves keys it does not understand — pinned by a test on reviewers: [ana, bo], because a labels special case "would satisfy every other test and break #21/#100". So a file whose only frontmatter is unknown keys is a shape markfluence explicitly supports, and counting any key at all read it as claimed. No third-party tool needed, and verifiable in this repo.

Checked and sound

The dialect's depth (both goccy mapping spellings at every depth, anchors refused, past-depth message unchanged, fenced block pinned), linkindex's anchors-not-lost behaviour, IsPageField (sound only because kind starts at iota + 1 — now pinned), entryFor(""), KeyFor under --root, update's ordering (no local check moved after a network call, mtime skip still before indexes.Get), summarize, and create's batch abort matching D6.

One behaviour worth stating plainly since it wasn't in the PR body: a frontmatter-only file with a blank page_id: now exits 0 skipped where it used to fail. D7 covers it, and "registered" is entry-only by design.

make check clean. Scratch pages purged.

Found by asking what the review's pattern implied rather than by the review
itself, which was scoped to the diff: every one of its nine findings was at a
seam -- a call site updated incompletely, an ordering, or old code receiving
new inputs -- and not one was in the new core. That shape is enumerable, so
one grep over every place page metadata is read from a file turned up a whole
class still unfixed.

pageref.Resolve read mf.PageID() and nothing else, so seven commands could
not name a file update could publish: info, read, children, export and the
three attachment verbs. They failed with "no page_id in frontmatter", safely
-- but the page argument meant one thing to update and another to everything
else, which is the inconsistency this package exists to prevent.

Fixed in pageref rather than at seven call sites. Resolve already stats the
file, so it discovers the root from that file's own directory -- the same
per-file discovery every other read does -- and resolves through pagemeta. No
signature change, no call-site churn, no import cycle.

One deliberate asymmetry: a malformed markfluence.yaml is *not* fatal here. A
disagreement between the two locations is, since that is exactly the question
being asked and either id would be a guess; but a project file this function
never consults should not make `info` fail, and the commands that bound reads
by the root report the problem themselves.

fix had no project concept at all, being the one verb that reads the page and
writes the file. It gets one, which is what lets it locate a pristine
registered page -- and it refuses to write into a file whose metadata lives
only in the manifest, because reconciling that means editing a pages: entry
and attempting it would reproduce the create bug review caught. A file
carrying some inline keys is still fixable: those keys exist already, and the
two locations agreed about the coordinates or resolution would have failed
first. Its planners take the resolved maps rather than a MarkdownFile, so
they reconcile what the file effectively declares.
CLAUDE.md's internal/pageref bullet said a .md argument resolves by its
frontmatter page_id, which is now only half of it -- and the reason the lookup
belongs there rather than at the seven call sites is worth writing down, since
that is what keeps the page argument meaning the same thing to update and to
info. The malformed-vs-disagreement asymmetry goes with it.

fix's passage gains what it refuses and why: it can locate a page whose
coordinates live in an entry, but writing frontmatter into such a file is the
bug review caught in create, so it says so instead.

docs/root-model.md notes the reader-facing consequence: `markfluence info
docs/deploy-runbook.md` works on a file that says nothing about Confluence.
@willkg

willkg commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Fixed the seam the review's pattern pointed at but its scope didn't cover.

All nine review findings were at seams — a call site updated incompletely, an ordering, or old code receiving new inputs — and none were in the new core. That shape is enumerable, so I enumerated it: one grep over every place page metadata is read from a file outside internal/pagemeta. It found a whole class still unfixed.

Seven commands could not name a file update could publish

internal/pageref.Resolve read mf.PageID() and nothing else:

info             ✗ no page_id in frontmatter of docs/a.md
read             ✗ no page_id in frontmatter of docs/a.md
children         ✗ no page_id in frontmatter of docs/a.md
attachment-list  ✗ no page_id in frontmatter of docs/a.md
export           ✗ no page_id in frontmatter of docs/a.md

They failed safely — an error, never a wrong action — but the page argument meant one thing to update and another to everything else, which is the exact inconsistency internal/pageref's doc comment says the package exists to prevent.

Fixed in pageref rather than at the seven call sites. Resolve already stats the file, so it discovers the root from that file's own directory — the same per-file discovery every other read does — and resolves through pagemeta. No signature change, no call-site churn, no import cycle. All five above now resolve to 12345 from the entry and go on to make their request.

One deliberate asymmetry: a disagreement between the two locations is fatal here, since that is exactly the question being asked and either id would be a guess — but a malformed markfluence.yaml is not, because a project file this resolver never consults must not make info 123 fail. The commands that bound reads by the root report that themselves.

fix had no project concept at all

It's the one verb that reads the page and writes the file, and it located pages straight from mf.Frontmatter. It now resolves through the root, which is what lets it find a pristine registered page — and it refuses to write into a file whose metadata lives only in the manifest:

fix  ✗ [docs/a.md] this file's metadata lives in markfluence.yaml; fix cannot
       reconcile a pages: entry yet, so there is nothing here to correct

Reconciling an entry needs the write half, and writing frontmatter instead is finding 2 reproduced in a second command. A test asserts the file is byte-identical afterwards.

A file carrying some inline keys is still fixed, and usefully: those keys exist already, and the two locations agreed about the coordinates or resolution would have failed first. plannedChanges/labelChange now take the resolved maps rather than a *MarkdownFile, so fix reconciles what the file effectively declares.

Scope, restated

PR 1 is three seams wide, not two: read, consume, and name. What's left for PR 2 is the writer (frontmatter's node helper, project.SetPageEntry, create persisting entries) plus fix reconciling an entry once there's something to reconcile it with.

make check clean. _plans/039, CLAUDE.md and docs/root-model.md updated — including the reader-facing consequence that markfluence info docs/deploy-runbook.md works on a file that says nothing about Confluence.

@willkg
willkg merged commit 7e2cd70 into main Sep 13, 2026
1 check passed
@willkg
willkg deleted the project-file-pages branch September 13, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant