Skip to content

Publish Confluence page labels from frontmatter - #141

Merged
willkg merged 16 commits into
mainfrom
page-labels
Sep 12, 2026
Merged

Publish Confluence page labels from frontmatter#141
willkg merged 16 commits into
mainfrom
page-labels

Conversation

@willkg

@willkg willkg commented Sep 12, 2026

Copy link
Copy Markdown
Member

Adds a labels frontmatter field that update/create assert against the live page, fix/read/export recover, info displays, and check validates offline. Fixes #138.

Labels are how Confluence content is actually organized and searched — the SRE space carries 94 distinct labels across 1123 pages — and until now markfluence could not see them at all: a published page had to be labeled by hand afterward, and read/export silently dropped whatever a page already carried. The README's own search --cql 'label = "runbook"' example only paid off for pages someone had tagged manually.

Design and evidence: _plans/036_labels.md and docs/confluence/labels.md.

The field

labels: [ci/cd, howto, runbook]

Either YAML style is accepted — inline, or - lines — and a rewrite keeps the style it found, because a set large enough to want block form is exactly the set whose inline spelling is an unreadable 200-column line. Style is preserved; indentation is not.

present the set is asserted exactly — a label on the page the file does not list is removed
labels: [] remove every managed label
absent the page's labels are not touched. Pinned as makes no label request at all, not merely no write, so there is no path by which a run that never mentioned labels can change them
prefixes global: only. A my:/team: label is shown by info and never written or removed
case lowercased with a warning, because Confluence lowercases server-side and a file that disagreed would never converge
anything else invalid a hard failure before any write

fix is the one verb that fills in an absent key — the only way to adopt a page somebody labeled in the UI. It reconciles the file to the page where update/create reconcile the page to the file, so an absent field means the opposite thing in each direction. That asymmetry is written down as the new law L9 in docs/guarantees.md (status Partial — create still asserts a default page_width for a file that declares none).

Two things #138 got wrong, found by testing against the live API

The write route. #138 specified POST /content/{id}/child/label. That collection is read-only for labels — 405, allow: HEAD,GET,OPTIONS, on the gateway and the site domain alike. The write route has no child/ segment. The unit tests could not catch this: they assert the path markfluence sends, against a fake that answers anything. The first real create published its page, set its width, and reported could not set labels: HTTP 405.

A name-only removal deletes the personal label. Found in review and then measured. Confluence's removal takes a name and nothing else, and on a page carrying the same name under two prefixes it deletes the my: one:

request result
DELETE ?name=probe-dup 204 — my:probe-dup gone, global:probe-dup remains
?name=probe-dup&prefix=global 204, prefix ignored, same outcome
?name=global:probe-dup 404 (a colon cannot appear in a name)
path form /label/probe-dup 204, same outcome

So there is no request that removes the managed label of a colliding pair, and the obvious one destroys the label this feature promises never to touch. A colliding removal is now skipped: the label is reported kept and warned about, failing assert-exactly visibly rather than deleting somebody's personal label quietly.

The larger half: frontmatter sequences

internal/frontmatter errored on any sequence value, so labels: [a, b] failed out of Parse and broke every command that read the file, not just the label-aware ones. Sequence support had to land first and had to be general — the parser learns a kind, not a name, and a test pins that an unrelated reviewers: [ana, bo] survives a write untouched, since #21 and #100 need the same door open.

Three findings from probing goccy, each pinned by a test:

  • An element's origin carries a leading newline whenever the element began on a new line — every block item, and a wrapped inline list. spansLines trims only the right, so reusing it would refuse [a,\n b] for no reason. Elements get their own check, trimmed at both ends.
  • A - |- element reports Literal/|- with an origin that does not span lines, so the node-kind whitelist is the only thing that catches it.
  • x,y and has]bracket pass the existing write-side check and then corrupt a list — the check verifies a value as a mapping value, where a comma and a bracket are inert. Emitted bare into an inline sequence the first becomes two elements and the second ends the sequence. Verification now runs in the style about to be written; inline turns out to be the stricter context, so the style parameter buys minimal quoting and the corrupting direction is a block check standing in for an inline write.

Verified against the live instance

A scratch page in a personal space through the gateway, purged afterward: a declared set applied on create and update including ci/cd; the surplus removed, ci/cd included; a hand-added my: label surviving every run; labels: [] removing only the managed ones; a file with no labels: key touching nothing and reporting labels: null; info showing both rows; read emitting labels: [howto, runbook] sorted between page_id and page_width; fix adopting hand labels and converging on the second run; a block-style list still block-style after a rewrite.

Scopes

Derived from the same OpenAPI documents docs/confluence/api.md's existing table comes from:

route scope
GET /wiki/api/v2/pages/{id}/labels read:page:confluence
POST /wiki/rest/api/content/{id}/label write:confluence-content
DELETE (same path) write:confluence-content

write:confluence-content is the only scope labels add, and the one a token granted before this will lack. The failure is narrow and recognizable: everything keeps working and only the label half of create/update 401s. Note that the granular read:label:confluence exists but belongs to GET /labels and the space-label routes, neither of which markfluence calls — "labels need a label scope" is wrong for the route we use.

Not done

  • No label-* subcommands, no --labels flag (the only natural CLI separators are the two characters Confluence splits on), no --label filter on find/search, no space-wide defaults (markfluence.yaml: support project-wide settings #100).
  • The scope is derived, not exercised by a real scoped token — the live run used an unscoped personal one, and those are scope-blind.
  • Two pre-existing bugs found on the way and deliberately not fixed here: a purged page's v2 404 is a bare "title":"Not Found", which trips RejectedCredential, so info on a purged page blames your credentials; and update reports a version bump Confluence did not make when republishing unchanged content.

Plans #138: a `labels` frontmatter field that `update`/`create` assert against
the live page, `fix`/`read`/`export` recover, `info` displays, and `check`
validates offline.

Two layers, and the lower one is the larger job. `internal/frontmatter` errors
on any sequence value today, so `labels: [a, b]` breaks every command that
reads the file, not just the label-aware ones -- sequence support has to land
first, and has to be general enough that `reviewers: [ana, bo]` works too.

Records what was probed rather than assumed. Confluence splits a label name on
spaces and commas (so `[Runbook Two]` publishes as two labels that read back as
neither, and re-add forever under assert-exactly), refuses a colon, caps names
at 255 UTF-16 units, and only accepts label writes on v1 with removal via
`?name=` -- the path form 400s on the real `ci/cd` label. goccy reads both
sequence styles correctly and a block list survives Normalize intact, so both
are accepted and an author's style is preserved on rewrite; the write-side
self-check has to run per style, because mapping context passes `x,y` and
`has]bracket` while block context turns `? q` into a mapping.
toMap errored on any sequence value, so `labels: [a, b]` -- or any other list
field -- failed out of Parse and broke every command that read the file, not
just the ones that knew about the key. #138 needs the field, and #21/#100 need
the same door opened, so this supports sequences generally: the parser learns a
kind, not a name, and a test pins that an unrelated `reviewers: [ana, bo]`
survives a write untouched.

Scalars stay in Frontmatter and sequences land in Lists, so a key is in exactly
one map -- leaving a flattened sequence in the scalar map is how
MarkdownFile.field would hand update a title of "[a, b]". An empty list is
present-and-empty rather than absent, which a destructive field like labels
depends on: "[]" means remove them all.

Both YAML spellings are read, because goccy parses both correctly and a block
list survives Normalize intact, so refusing one would reject a file that was
understood perfectly. A rewrite keeps the style it found -- a set large enough
to want block form is exactly the set whose flow spelling is an unreadable
single line -- but style only, not formatting: block items are re-emitted at
this package's own indent, not the author's.

Three findings from probing goccy, each now pinned by a test:

- An element's origin carries a *leading* newline whenever the element began on
  a new line, which is every block item and a wrapped flow list. spansLines
  trims only the right, so reusing it would refuse "[a,\n  b]" for no reason.
  elementValue applies the line rule to the origin trimmed at both ends, and
  still refuses an element whose own value runs past its line.
- A "- |-" element reports Literal/"|-" with an origin that does *not* span
  lines, so the node-kind whitelist is the only thing that catches it. The
  whitelist is now shared by scalarValue and elementValue rather than copied.
- "x,y" and "has]bracket" pass readsBackAs -- they are fine as a mapping value
  -- and corrupt a flow sequence, splitting into two elements and ending the
  sequence respectively. readsBackInSeqAs verifies in the style being written;
  verifying in block while writing flow is the direction that corrupts, and the
  hazard corpus now runs through both styles to pin it.

Also corrects two comments the probes proved wrong: a continued plain scalar
does not come back as a "|-" block (it folds onto one line; the block scalar is
the shape that breaks), and dropBlankLines' safety no longer rests on "nothing
spans more than one line", since a passed-through block list does. It rests on
no emittable value carrying a meaningful blank line.
The verified findings behind #138's design, so the client's comments have
something to cite. Probed 2026-09-08 against a scratch page: names POSTed one
at a time via v1, read back through v1 and v2, both delete forms exercised.

The one to read before touching any of this: Confluence splits a label name on
spaces and commas, silently and with a 200. `Runbook Two` becomes `runbook` +
`two`, which under assert-exactly reads back as neither and so is re-added on
every run with no frontmatter spelling that can remove it. Two labels in the
SRE space look like this having already happened to somebody.

Also recorded: the server's own reject set (a colon is in it, so there is no
prefix syntax to offer an author), the 255-**UTF-16-code-unit** cap that a byte
or rune check would get wrong in opposite directions, v2 being read-only for
labels, and the `?name=` removal form -- the path form 400s with a Tomcat HTML
body once a name holds a `/`, and `ci/cd` is a real label in the SRE space, so
that is a real failure on a real page rather than a hypothetical.

The scope for the two v1 write routes goes in the table as unverified;
api.md already records that v1 scopes cannot be looked up.
Reads are v2, writes are v1 -- the same split attachments have, and not a
choice: v2 answers POST and DELETE on a page's label collection with a 405.

ListLabels returns the list **unfiltered**. Callers asserting a frontmatter
field filter to the "global" prefix themselves, because info shows every label
a page carries including the my:/team: ones no frontmatter could express, and a
client that dropped them would make that impossible.

RemoveLabel puts the name in the query string, never the path. The path form
works right up until a name holds a "/", which answers 400 with a Tomcat HTML
body however the slash is encoded, while ?name= answers 204 for the same label
-- and "ci/cd" is a real label in the SRE space, so this is a real failure on a
real page. The test asserts the path and the query separately, since a name
appended to the path would leave the query empty and still 204 against a
lenient fake.

Its 404 check goes through notFound rather than comparing the status, because a
rejected credential is also a 404 with a body that names nothing. Read as
"already gone", that would report a whole batch of removals as a success
against a page nobody can read. Pinned by a test that fails if the gate is
weakened to a status comparison.

AddLabels batches into one request and makes none at all for an empty list, so
publishing a file with no labels costs nothing. Every name must already be
valid when it gets here: Confluence splits a name on spaces and commas with no
warning and a 200, so the validation that prevents it lives in internal/labels
and runs before any write.
The vocabulary package for the labels frontmatter field, modeled on
internal/pagewidth: Declared reads and validates, Diff answers what to add and
remove, Apply/Plan run it against the client. Set arithmetic lives here because
three commands need the same answer and three copies of a set difference is
three chances to leave a label behind.

Validation mirrors Confluence's own reject set rather than inventing an
allowlist, so ci/cd, dataops_reports and héllo-wörld publish unchanged. It
refuses rather than repairs, because the input it exists to catch is silently
destructive: Confluence splits a name on a space or comma with a 200 and no
warning, so `labels: [Runbook Two]` publishes two labels that read back as
neither and are re-added on every run, forever, with no frontmatter spelling
that removes them. The error quotes the reject set, since half of it is
surprising -- a "." means a version-shaped label is impossible.

Case is the one repair, with a warning naming the label as written, because
Confluence lowercases server-side and a file that disagreed would never
converge. A leading or trailing space is *not* trimmed: a space is a separator
there, so trimming would quietly repair the exact class of input that produced
the "continuous" + "delivery" pair in the SRE space.

Length is counted in UTF-16 code units, pinned at the three points measured
against the server (255 accented runes pass, 256 fail, 128 emoji fail). A byte
check fails the first and a rune check passes the last, so the test catches a
drift in either direction.

Three shapes kept deliberately distinct:

- absent: the page's labels are untouched, so a hand-labeled page survives a
  run that never mentioned labels;
- `[]`: remove every managed label;
- a scalar, including every null spelling: an error. The field is destructive,
  so `labels:` with nothing after it must not be read as "strip this page" on a
  file where an author typed a key and got distracted. Those cases run through
  frontmatter.Parse rather than a hand-built map, since the parser is what
  flattens "~" and "null" to "" and a test that passed "" three times would
  prove one case and claim three.

Only the global prefix is managed. A my: label is personal and a team: one
belongs to a space's vocabulary; neither has a frontmatter spelling, so
removing one would delete data the file could not have expressed.
An invalid label is the same class as an invalid page_width -- a guaranteed
publish defect visible with no network -- and worse in one respect, which is
why check is the command that should catch it. A page_width Confluence refuses
fails loudly. A label name holding a space *succeeds*: Confluence splits it,
stores the pieces, and returns 200, so the file now declares a label that reads
back as neither piece and is re-added on every run with no spelling that
removes it. There is no later opportunity to catch that one.

So an invalid label is status: failed with code: VALIDATION, beside the
page_width check, and the case repair is a warning carrying both spellings so
an author can make the file say what will actually happen.

Also fixes the message to quote the label as written. Validation ran after
lowercasing, so a file saying "Runbook Two" was told about `label "runbook
two"` -- a string that is not in their file. It now validates the raw name
first for the message, and re-checks the normalized one because case folding
can change UTF-16 length ("İ" lowercases to two code points).

Label warnings lead the warnings list, since they are a property of the
frontmatter and hold whatever the converter went on to find in the body.
Declared means exact: a label on the page that the file does not list is
removed. Absent means untouched, which mirrors page_width's existing asymmetry
and is what keeps a hand-labeled page from being stripped by a run that never
mentioned labels.

That last part is pinned as "makes no label request at all", not "makes no
write". No request means there is no path by which a run that said nothing
about labels can decide to change them, which makes it a property rather than
an implementation detail.

Ordering, both halves deliberate. Validation runs before any request and is
fatal, because the defect it catches is not recoverable afterwards: a name
holding a space publishes *successfully* as several labels that read back as
none of what the file says. Application runs last and is non-fatal, matching
pagewidth.Apply -- the body is published by then, so failing the result would
report that the publish did not happen. A failed application leaves labels null
rather than claiming a set that is not there.

--json gets `labels`: the full declared set every run, not just the changes, so
a consumer can read a page's labels off a publish without a second call. It is
`*[]Label` rather than a slice because an empty declared set and an absent key
are different answers -- "[]" means the labels were removed, null means they
were never touched -- and a nil slice cannot say which. In a dry run the same
field carries the actions that would be taken, read-only.

Unmanaged labels survive, tested: a my: or team: label has no frontmatter
spelling, so a set that cannot mention it must not remove it.
#127's guarantee applied to labels: a defect in the file must not leave a
created page behind. It matters more here than for most phase-1 checks, because
the defect is not recoverable afterwards -- a label Confluence splits on a space
publishes *successfully*, so a page created before the check would carry labels
no later run can remove, and the author would have a page_id written into their
file to undo by hand. Pinned by a test asserting no page is created, no label
request is made, and no page_id is written back.

Validated beside resolveWidth rather than beside the preflight conversion,
which is where the plan put it. The width check is the closer precedent: both
are offline reads of one frontmatter field, so neither spends a request to find
out, and the page_id precedence the conversion had to be sequenced around
doesn't apply to a local check. A bad label takes newFailure's fallback and so
reports VALIDATION -- not CONVERT, since the converter never saw it, and not a
request code, since no request was made.

The validated set is carried on the record rather than re-read at publish, so
the two phases cannot disagree about what was checked. Application is last and
non-fatal, matching the width: the page exists and carries its content by then,
so failing the result would report that it does not.

The dry run previews labels without asking for the live set, unlike update's.
There is no page yet, so every declared label is an add and there is nothing to
remove -- looking it up would be a request against an id that does not exist.
This is the only way to adopt a page somebody labeled in the UI, which is why
it reconciles labels even for a file with no labels key -- the opposite of
update and create, where an absent key means "leave the page alone". The
directions are not symmetric and should not be: fix reconciles the file to the
page, so the page is the authority here in exactly the way the file is there.

Compared as sets, so a file that merely orders its labels differently, repeats
one, or spells one in the wrong case is left alone and keeps the author's own
ordering. When a write is needed the list is emitted sorted and deduplicated,
since neither label GET returns a useful order.

The live read is best-effort like the width read, and nil carries a meaning an
empty slice cannot: "not known" rather than "the page has no labels". A failed
read plans nothing, because the alternative is a transient failure proposing
that every label be stripped from the file. Tested both ways.

A block list stays a block list through the write, end to end, via
UpdateListField. The change struct carries newList alongside the display
string, so --json's `new` stays a string ("[a, b]") and the reported shape of a
change does not vary by field -- no new schema case for one list field.

Also: an invalid label in the file is reconciled rather than refused, the one
place fix repairs something check would have failed. The live set is what gets
written and it came from the server, so it is valid by construction -- which is
what lets an author recover a file that already suffered the space-splitting
bug. A convergence test pins the property that pair in the SRE space is the
absence of: reconcile once, and the second run has nothing to do.
Two rows, the second appearing only when there is something in it per info's
existing "empty fields omitted" rule:

    page_width:        max (Confluence default)
    labels:            ci/cd, howto, runbook
    labels/unmanaged:  my:mine, team:eng

info is the one command that shows what markfluence will not touch, which is
why client.ListLabels returns the list unfiltered: a my: label is personal and
a team: one belongs to a space's own vocabulary, neither has a frontmatter
spelling, and a reader looking at a page's labels should see all of them rather
than the subset markfluence happens to manage.

The fetch is best-effort like the width's -- a page nobody can label is still
worth describing. In --json, [] means the page genuinely has none and null
means the fetch failed, the distinction the report keeps labelsKnown for;
collapsing them would make a transient failure indistinguishable from a fact
about the page.

`managed` travels with each label rather than being left to a consumer to
derive, so the prefix rule can only be wrong in one place.
Through pagedoc, so read and export gain it together and cannot drift -- the
same reason Render/Options exist. A round-tripped tree now republishes its
labels instead of dropping them, which is what made an export lossy for the one
piece of metadata people actually search on.

Global labels only: a my: or team: label has no frontmatter spelling, so
emitting one would produce a file that cannot publish what it says. Sorted,
because neither label GET returns a useful order and a tree that reshuffles its
own frontmatter between runs is noise in every later diff.

An empty list and a failed fetch are written the same way -- no labels: key at
all -- and that is the one place this deliberately does *not* mirror how update
reads the field. An emitted "labels: []" would tell the next publish to remove
every label, so a read of a page with no labels would quietly assert that on
the author's behalf and strip anything added in the UI in between. Pinned by a
test covering both cases.

read --json gets `labels` as a plain sorted string array, a different shape
from info's on purpose: info describes the page, read describes the document it
produced. null when the fetch failed, so "none" and "unknown" stay
distinguishable.

Costs one extra request per page on an export walk, which is the price of the
round trip being complete.
README: a `labels` row in the frontmatter table, the assert/absent asymmetry in
the `update` and `fix` sections, `labels` in what `read`/`export` emit and what
`check` validates, and a note on the existing `--cql 'label = "runbook"'`
example -- that search only pays off for pages someone labeled, which is the
gap this closes. The frontmatter description no longer says "no lists".

guarantees.md gets **L9** (`declared-metadata-is-asserted`), status **Partial**,
and the gap is `page_width` rather than `labels`: `update` honours it for both,
but `create` asserts a default width of `max` for a file that declares none, so
an omitted field is not left alone there. `labels` holds in both verbs. `fix` is
outside the law rather than violating it -- it reconciles the file to the page,
so an absent field is filled in, which is the only way to adopt a page labeled
by hand.

C2's wording now admits sequences, and its prose records what extending the
writer's self-check cost, because that was the closest thing to a repeat of
#130: the scalar check verifies a value as a *mapping* value, and `x,y` and
`has]bracket` pass it and then split or end a flow sequence. Flow turns out to
be the stricter context, so verifying there is always safe and the style
parameter buys minimal quoting -- the corrupting direction is a block check
standing in for a flow write. Also corrects two claims the probes disproved: a
continued plain scalar folds onto one line rather than returning as a `|-`
block, and `dropBlankLines`' safety no longer rests on "nothing spans more than
one line", since a passed-through block list does.
The refusal message explained the separator behaviour for every character in
the reject set, so `labels: [v1.2]` was told it "would publish as several
labels" -- which it would not. A dot is simply refused. Being wrong about the
case in front of the reader is how the accurate warning, which is about a real
and unrecoverable bug, stops being read.

Two messages now: the splitting explanation for a space or comma, and a plain
refusal otherwise. Both still quote the reject set, since a "." making a
version-shaped label impossible is the part nobody guesses.
Found by testing against the live instance, which is the only thing that could
have found it: the unit tests asserted the path markfluence sent, against a
fake that answered anything. The first real `create` published its page, set
its width, and reported "could not set labels: HTTP 405".

`child/` is the collection attachments, pages and comments hang off, and for
labels it is **read-only** -- POST and DELETE there answer 405 with
`allow: HEAD,GET,OPTIONS`, identically on the gateway and on the site domain,
with an empty body. The write route has no `child/` segment. The attachment
routes are what make the wrong guess easy: those really are `child/attachment`.

Everything else #138 recorded about the forms is confirmed against the live API
and now carries its own measurement: the path form removes a plain name and
400s with a Tomcat HTML body on `ci%2Fcd`, the `?name=` form does both, and an
absent name is a 404 whose body *names the label*, which is what lets notFound
tell it from a rejected credential.

labels.md gains the corrected routes at the top and an end-to-end section: a
declared set applied on create and update, the surplus removed including
`ci/cd`, a hand-added `my:` label surviving every run, `labels: []` removing
only the managed ones, an absent key touching nothing at all, `fix` adopting
hand labels and converging on the second run, and a block-style list still
block-style after a rewrite. All against a scratch page in a personal space,
purged afterward.

It also records an unrelated bug found on the way out: a **purged** page's v2
404 body is a bare `"title":"Not Found"` naming nothing, which is the shape
RejectedCredential uses for a revoked token -- so `info` on a purged page blames
the credentials. The claim that "every genuine v2 404 names what it could not
find" holds for a page that never existed and for a trashed one, not a purged
one. Not fixed here.
Looked up the same way the rest of the scope table was (api.md's "Derived
2026-08-20" method): the label operations in Atlassian's v1 and v2 OpenAPI
documents, taking their Current x-atlassian-oauth2-scopes entries.

    GET  /wiki/api/v2/pages/{id}/labels     read:page:confluence
    POST /wiki/rest/api/content/{id}/label  write:confluence-content
    DELETE  (same path)                     write:confluence-content

So #138's guess was right, and `write:confluence-content` is now in the union
block as the one scope labels add. It is also the entry most likely to be
missing from a token granted before labels existed, and the failure is narrow
enough to recognize: every command keeps working and only the label half of
create/update 401s, because the read side is covered by read:page:confluence,
which any working token already has.

Two things worth knowing before the obvious assumption gets made. The granular
`read:label:confluence` does exist as Current -- on `GET /labels` and the
space-label routes, neither of which markfluence calls -- so "labels need a
label scope" is wrong for the route we use. And the Beta granular pair for the
writes is `write:label:confluence`, with `read:label:confluence` also on the
POST, the same both-halves shape the property writes have.

labels.md's open question narrows accordingly: the scope is no longer unknown,
only untested against a real scoped token, since the end-to-end run used an
unscoped personal one and those are scope-blind.
**labels.Apply refused to be prefix-safe, and the API cannot be.** Confluence's
removal route takes a name and nothing else, and on a page carrying the same
name under two prefixes it deletes the **personal** one. Measured: a page with
global:probe-dup and my:probe-dup answered `DELETE ?name=probe-dup` with 204 and
left global:probe-dup in place; `&prefix=global` is ignored, a colon-qualified
name 404s, the path form behaves identically, and a second delete then takes the
global one. So the removal markfluence issued for a surplus label would destroy
exactly the label this package guarantees it never touches. There is no request
that expresses the intended one, so a colliding removal is now skipped: the
label is reported `kept` (new in the schema enum) and warned about, failing
assert-exactly visibly rather than deleting somebody's personal label quietly.
The earlier end-to-end run missed it because `my:mine` collided with nothing.

**frontmatter read a scalar field written as a list as *absent*.** Sequence
support routed every sequence key into Lists, so `parent:` written as a list --
an error before this branch -- silently became "no parent": create published
the page at the space root and then wrote `parent: null` over the author's
intent, with nothing reported anywhere. Same silence for `title:` and
`page_width:`. The five fields markfluence reads as single values now refuse a
sequence; unknown keys stay permissive both ways, so `reviewers: [ana, bo]`
still works and #21/#100 still have their door.

**create dropped the label case warning.** The warning is raised in preflight
and create never re-reads the field at publish, so it was computed and thrown
away -- leaving an author with a page labelled "runbook", a file still saying
"Runbook", and nothing said about either, since --persist does not write labels
back.

**fix would not correct a label case mismatch.** It normalized case before
comparing, so `labels: [Runbook]` against a page carrying `runbook` reported
"already consistent" -- while update and check warned "Update the file to match"
on every run. The command whose job is that updating was the one command that
would not do it.

**fix would not repair a scalar labels: value** unless the page happened to
carry labels, so a file that check, update and create all reject was reported
"already consistent" -- inconsistently, since the same file was repaired when
the page had labels.

**info had a dead store**, assigning an empty slice that the next statement
overwrote.

Each fix has a test; the collision guard is pinned by one that fails if the
guard is weakened.
@willkg
willkg merged commit 6918ac6 into main Sep 12, 2026
1 check passed
@willkg
willkg deleted the page-labels branch September 12, 2026 01:39
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.

Support Confluence page labels via frontmatter

1 participant