Skip to content

Add a page_status frontmatter field for the lozenge beside the page title - #169

Merged
willkg merged 13 commits into
mainfrom
page-status
Sep 18, 2026
Merged

willkg merged 13 commits into
mainfrom
page-status

Conversation

@willkg

@willkg willkg commented Sep 18, 2026

Copy link
Copy Markdown
Member

Closes #168. Plan: _plans/044_page-status.md. Findings: docs/confluence/page-status.md.

Confluence shows a coloured lozenge beside a page title — Rough draft, In progress, Ready for review, Verified. markfluence could not see it, set it, or report it, so a tree published from markdown lost a signal its readers use and a page adopted from Confluence silently dropped it. This adds one frontmatter field:

---
title: Deploy Runbook
page_id: 123456
page_status: Ready for review
---

update and create assert it, read and export emit it, info reports it, diff compares it, check validates what it can offline. Absent means untouched — literally: a file with no page_status makes no state request at all, read included, which is the property labels pins and what keeps this free for trees that do not use it.

The safety property the whole design rests on

A name is never sent to Confluence; the wire format is an id. The PUT body's id is optional, and a request without one takes a create path indistinguishable in shape and status code from the set path: {"name":"ready for review","color":"#57d9a3"} against a space spelling it Ready for review answers 200 and creates a new custom status — which no API route can delete (DELETE /rest/api/content-states/{id} 404s, the collection 405s). Two such states are still stuck on my account from probing this.

So markfluence resolves the name locally and writes {"id": N} and nothing else, which makes that outcome unreachable rather than something validation has to remember to prevent. TestSetPageStateSendsOnlyTheID fails the build if a name or color ever appears in that body.

What a second account changed

Most of this was built against one (unscoped, space-owner) token. Testing with a scoped service-account token given collaborator access falsified two premises the implementation rested on, and the last three commits are the correction.

The statuses a page may be given are per (caller, page), not per space. Same account, same space: four statuses offered on a page it had created, three on a page it had not — and the write enforces the difference with User is not permitted to use this ContentState on this content. Two things followed. The vocabulary cache, keyed by space id, could hand one page another page's answer; it is gone, and Resolve now takes the page it is asking about and holds no state. And create could not validate up front at all, because the only authoritative page is the one it has not made yet — probing the parent or the space homepage was measured refusing a name the new page then accepted.

So create resolves the name after the page exists, knowingly paying #127's cost for this one field: a misspelled status is a warning on a created page rather than a refusal before anything is made. That is the right trade only because the alternative refuses files whose status is fine — and because the offline half, a present-but-empty value, is still caught in preflight.

state/available needs edit permission on the page it is asked about, consistent with the write scope it sits behind. A collaborator who can create pages in a space often cannot edit its homepage, which is exactly what broke the original probe. Useful side effect: it is a free, write-free "can this account edit this page?" check, which is how the permission testing here was done without creating anything.

A status write bumps the page version, and that reaches the action log

Unlike a label write or a content-property write, setting a status produces a full page version — minorEdit: false, empty message, an ordinary edit in history. Two consequences, both found by running it rather than reading it:

Apply reads and compares before writing, so an unchanged status produces no PUT. Without that, every run would add a version to every page carrying the field.

And the merge base (#149) has to name where the page ended up. The first live create recorded the body PUT's version, the status write moved the page past it, and the very next update refused the file as diverged — a conflict markfluence caused itself. pagestatus.VersionAfter re-reads it (a read, not +1: the PUT response carries no version, and inferring one would be wrong exactly when somebody else edited in between, which is the case the base exists to catch). Its failure is a warning rather than a swallowed error, because the fallback is a version known to be behind.

Scopes

This also settles the question #167 left open. user-find's scope is read:content-details:confluence, granular, and implied by nothing — measured with a token holding fifteen other scopes including read:confluence-content.all, which still got 401 scope does not match while every other command worked.

The README's copy-pasteable list was separately missing write:confluence-content, which labels have needed since #138 and page status needs now. Both are added. Also recorded in api.md: a scope and a permission fail differently — a missing scope is a 401 saying so, a missing permission is a 403 naming it or a 404 on a v2 route — so a token with every scope still writes nothing until its account has Confluence permission on the space.

Smaller decisions worth knowing

The match is case-insensitive and nothing is rewritten, departing from labels' warn-and-lowercase for a precise reason: labels sends the author's string to the server, this sends an id, so a case variant cannot reach the page and is not worth a warning. Two of a page's own statuses differing only in case are refused as ambiguous.

There is no way to clear a status from a file. labels: [] can mean "remove them all" because a sequence has an empty spelling distinguishable from a null scalar; a scalar has none — page_status:, page_status: ~ and page_status: null all read as "", which is what an unfinished edit looks like. So an empty value is a validation failure. guarantees.md records this under L9 as a missing declaration rather than an unasserted one; the law is unweakened and its status unchanged.

No project-level default, unlike page_width: a width is house style, a status is a claim about one page, and a project-wide Rough draft would assert something false about most of a tree on every publish.

info renames its page_status field to content_status. That key already meant current/archived, and with three things wearing the word, neither can just be "status". --json's page_status_available is documented at length as what this page can be given for this account — not a space property — because the schema is the artifact that outlives the help text, and a consumer who asks a convenient page about another page gets a write refused.

check validates the shape and not the name, and its Long says so: this is the first frontmatter field whose vocabulary is per-space, per-page server state, so a green check would otherwise read as a promise the status will publish.

Testing

make check clean. Verified end to end against mozilla-hub with both tokens — the space owner's and the scoped service account's: create (including --dry-run, and specifically a status the parent does not offer but the new page does), update (body, status, labels, width, unchanged-skip, refused name), read, export, diff, info, check, and --json throughout.

Two rounds of /code-review. The first found the version-reporting split, a swallowed error that would have produced false conflicts, and diff going silent on an unpublishable file. The second found no control-flow bugs but a cluster of comments and docs — including the published schema — left describing the pre-reversal design, plus a create publish-phase coverage gap on the exact path where the live bug had been.

Four fixture pages are left in my personal space, two owned by the service account, which are the only evidence of the per-page finding on that instance.

The lozenge next to a page title is a v1-only "content state", and three
findings from probing it decide the whole design: a PUT naming a state the
server does not recognise creates one that no route can delete, the only
trustworthy vocabulary route is per-page and behind a write scope, and a state
write bumps the page version.

For #168.
A page status -- the coloured lozenge beside the title -- is Atlassian's
"content state", and v2 exposes nothing about it, so state.go is v1 only.

SetPageState sends the id and nothing else, which is a safety property rather
than economy: the body's id is optional, and a PUT naming a status the server
does not recognise creates one, with a 200, that no API route can delete. With
an id present the name and colour are ignored outright.

AvailableStates keeps the space's statuses and the caller's own custom ones in
separate fields. Custom statuses follow the account, not the space, so only the
first are valid for a committed file; the second are carried so a refusal can
say why.

ResolveSpaceHomepage rides the /spaces request ResolveSpaceID already makes.
create needs some page id in the target space to ask what statuses the space
offers, at a point where the page it is about to make does not exist.

For #168.
Declared is pure and offline, which is what lets check use it. Resolve turns
the name a file declares into the status to write, against the space the page
is in; Apply reads before writing, because a status write is the one metadata
pass that bumps the page version.

Three decisions that are not obvious from the code:

The match is case-insensitive with nothing rewritten, departing from labels,
which repairs case and warns. Labels sends the author's string to the server;
this sends an id, so a case variant cannot reach the page.

A name matching only one of the caller's custom statuses gets its own message.
Those follow the account rather than the space, so the file publishes for its
author and fails for everyone else -- and a message listing only the space's
statuses, while the author can see theirs in Confluence's own picker, is how
that becomes an unresolvable bug report.

An empty value is a validation failure rather than an instruction. Every null
spelling of a scalar reads as "", indistinguishable from an unfinished edit, so
there is no way to clear a status from a file; labels: [] can mean "remove them
all" only because a sequence has an empty spelling a scalar lacks.

For #168.
The frontmatter reader needs the single-line scalar guarantee for it, like the
other five fields whose readers return a plain string with nowhere to put an
error; the project file needs it in entryFields, or a field would be
expressible in a file and not in a pages: entry.

pagemeta needs no change: coordinates is a whitelist, so a new field is graded
as visible -- a disagreement between the two locations warns and frontmatter
wins -- which is right for a status and is now pinned by a test.

For #168.
A page_status: line is asserted; an absent one makes no state request at all,
read included, which is the same property labels pins and is what keeps this
free for every tree not using the field.

update resolves the name once the page is known, before any write, so a name
the space does not offer fails the file rather than half-publishing it. create
resolves it in preflight, against the space homepage -- the vocabulary route is
per-page and the page being created does not exist yet -- so a bad name aborts
the batch before anything is reserved, which is #127's reasoning: there is no
later run that repairs a created page with no status.

The version handling is the part that is not obvious, and it came out of the
first live create. A status write is the only metadata pass that bumps the page
version -- a content-property write and a label write both leave it alone -- so
recording the body PUT's version as the merge base left the page one version
ahead of its own base, and the next update refused the file as diverged.
pagestatus.VersionAfter re-reads it, and only when a status was actually
written.

create does not persist page_status, alongside labels and for both of that
field's reasons: persist records what create resolved, a status is something
the author already wrote down, and writing it back would rewrite their spelling
to the space's canonical one.

For #168.
info grows two rows, which is the browse path for "what can I write here":
the page's own status, and page_status/available, the statuses its space
offers. A space's statuses are its own configuration rather than a fixed
vocabulary, so nothing in --help can document them and completion cannot offer
them -- it may not call Confluence.

info's existing status row and --json page_status field are renamed to
content_status. That key was already taken by Confluence's content status
(current/archived/trashed), and with three things wearing the word, neither of
these can just be called "status".

read and export emit the key, omitted both for a page with no status and for a
failed read. That is labels' rule, and here it needs no argument of its own:
nothing clears a status, so an emitted empty value would not parse as an
instruction at all.

check reports a present-but-empty value and nothing else, which is the
present-but-empty title exception's reasoning -- both verbs reject it, so no
verb makes it valid. The name it cannot check, and its Long now says so: this
is the first frontmatter field whose vocabulary is per-space server state, and
a green check would otherwise read as a promise the status will publish.

diff compares the resolved id, not the spelling, which is parent's precedent:
the match is case-insensitive and only an id travels, so two spellings of one
status are not a difference. A failed page-side read is comparable: false
rather than a difference.

For #168.
…notes

markdown_file.md gets the field row and the reason there is no project-wide
default: a width is house style, a status is a claim about one page.
json-output.md gets the two shapes and the content_status/page_status split.
guarantees.md records that page_status holds L9 in both verbs, with the
set-but-never-clear asymmetry against labels: [] written down rather than left
to be inferred. api.md gets the three routes and their scopes, both of which a
working token already carries -- and the note that the vocabulary route is a
read behind a write scope, which is Atlassian's oddity.

docs/commands/ is regenerated.

For #168.
Two versions were being reported for one run: the log recorded where the page
ended up, --json and the success line reported where the body PUT left it. A
consumer comparing version.new against a later info therefore saw a mismatch
for a bump markfluence made itself.

versionFinal is now what both the base and --json report, and versionNew stays
the body publish own version, which is what the "Updating (vA -> vB)" line is
about; a status write shows as its own line and one more version.

VersionAfter also returns its error instead of folding it into the fallback.
Failing there is not benign: the status write has already landed, so the
fallback is a version known to be behind the page, and recording it makes the
next update of that file refuse it as diverged -- a conflict markfluence caused
itself, with nothing to explain it. Both call sites now warn.

And the name resolution moves after the divergence check. It has to stay before
any request that could change the page, but a file about to be refused should
pay for nothing, which is the rule the merge-base read above it already
follows.

For #168.
statusDifference read the local value through declared(), which treats a
present-but-empty value as silence -- so a file with a bare page_status:
produced no row and no warning and diff exited 0 "in sync" about a file neither
update nor create will publish and check calls broken.

That is the bug the labels comparison was fixed for, in the same function, with
the reason written above it. pagestatus.Declared is the predicate that knows an
empty value is a failure rather than silence.

For #168.
…osts

read and export both list the frontmatter fields they emit, and both had gone
stale: they write page_status now. --help is this project reference and
docs-check cannot catch this, since it regenerates from the same strings.

The homepage lookup was described as free in three places. It reads the same
/spaces response ResolveSpaceID reads, but it is a second request --
resolveSpace is not memoized -- bounded to once per space per run by create own
cache and skipped for a file declaring no status.

Also noted: because a create preflight failure aborts the batch, a tree
exported from one space and created in another fails on the first file whose
status the destination space does not offer. And two help paragraphs are
rewrapped, one of which check gained a ragged line.

For #168.
Measured with a scoped service-account token, which is what settled the
question users.md left open: user-find needs read:content-details:confluence,
and nothing implies it -- the token held fifteen other scopes, including
read:confluence-content.all, and still got "scope does not match". Every other
command worked.

The README copy-pasteable list was also missing write:confluence-content,
which labels have needed since #138 and page status needs now. The hint on an
auth failure points at that list, so it has to be the whole list.

Also recorded: a scope and a permission fail differently. A missing scope is a
401 saying so; a missing permission is a 403 naming it, or a 404 on a v2
route. A token with every scope here still writes nothing until its account
has Confluence permission on the space.
… space

Testing with a second account settled something the first could not. The
statuses a page may be given depend on the caller and the page, not on the
space: one account was offered four on a page it had created and three on a
page in the same space that it had not, and the write enforces it with "User
is not permitted to use this ContentState on this content."

Two things rested on the wrong premise and are gone.

The vocabulary cache was keyed by space id, so a batch could hand one page
another page answer -- accepting a status the second page will refuse, or
refusing one it would take. There is no cache now; Resolve takes the page it
is asking about and holds no state. One request per page that declares the
field, which is what the field being declared already gates.

And create validated the name against the space homepage, which was wrong
twice over: the homepage answers for itself rather than for the page about to
be created (measured refusing Verified for a page that then accepted it), and
the route needs edit permission on the page it is asked of -- which a
collaborator who can create pages often lacks on a homepage, so create with a
page_status failed outright for exactly the token this was tested with. The
name is now resolved after the page exists, against the only page that can
answer. That pays #127 cost for this one field -- a misspelled status is a
warning on a created page rather than a refusal -- knowingly, because the
alternative refuses files whose status is fine, and because the offline half
is still caught in preflight.

ResolveSpaceHomepage goes with it; it had no other caller.

Messages now say "this page can be given ..." rather than "this space offers
...", which would send an author to space settings for a difference that is
not there.

For #168.
Moving create resolution out of preflight left its reasoning behind in several
places, and a specification that contradicts itself is worse than one that is
merely out of date. The record comment still described a resolved-in-preflight
status field that no longer exists, immediately above the comment saying the
opposite; publishOne opened with "the name was resolved to an id in preflight,
so nothing here can fail", three lines above the code handling exactly that
failure; and CLAUDE.md described the rejected design as the shipped one while
contradicting itself two bullets earlier about a cache that is gone.

The same drift reached the published contract. info Long, its --json comment,
docs/json-output.md and the schema all called page_status_available what the
space offers. It is what THIS page can be given, for THIS account -- the
finding the whole reversal was about -- and a consumer who reads the schema
and asks a convenient page about another page gets an answer the write then
refuses. The schema is the artifact that outlives the help text, so it says so
at length.

Two real fixes with it: unknownStatus ran its custom-status branch before the
empty-space guard, so a page that can be given nothing ended the sentence
"This page can be given " and trailed off; and the frontmatter examples in
docs and README ordered page_status after page_width, which is not what
RenderFrontmatter emits -- it sorts, so read and export produce the opposite.

And the coverage gap that matters: create publish phase had no tests at all,
though it is where the name is now resolved and where the version-behind bug
that motivated the reversal actually lived. Four, including the create half of
TestAStatusWriteIsRecordedAtThePagesFinalVersion.

For #168.
@willkg
willkg merged commit 39f63f5 into main Sep 18, 2026
1 check passed
@willkg
willkg deleted the page-status branch September 18, 2026 22:58
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.

Add a page_status frontmatter field for the lozenge next to the page title

1 participant