`canWrite` is the grant's LEVEL and carries no scope term. Its docblock said
"May this caller ADD to that record", and the client hook republished it as
`canAdd`, so fourteen controls across measurements, medications, labs, illness
and the dashboard gated a create on a boolean that answers `true` for a WRITE
grant scoped to the document vault — a grant whose writable-section list is
empty, because the vault takes no delegated write at any level.
Nothing shipped was visibly wrong before the capture picker, because the nav
drops a section a grant does not open. That is a property of one surface, not
of the answer, and the answer is the thing many controls read.
So the coarse answer is gone from the hook, and every consumer asks
`canWriteDomain` for its own section instead: `measurements` on the two
measurement surfaces and the dashboard's empty state, `medications` on the
medication list, table, ledger, card menu and side effects, `labs` on the lab
list and biomarker detail, `illness` on the episode list. Each of those is the
section its own route declares. `canWrite` keeps its name and its wire shape —
native clients read it — and its docblock now says what it is: whether the
grant is a reading one, with the per-section lists beside it for the question a
control actually has.
The hook table gained the vault-scoped WRITE grant as its own leg: level true,
every section false. The take-all-due fixture described a grant the server
never mints — `canWrite: true` over an empty writable list — and now derives
its list from the same table the server derives it from.
useRecordCapabilities answers per section now. canWriteDomain and
canManageDomain read the two lists the account payload publishes; in
one's own record they are always true, and under a refused, pending or
unproven context always false. canManage becomes the coarse switch,
true when any section is manageable, and is what the banner and the
navigation read.
Every add, edit and delete control names its own section: labs, mind,
profile, illness, cycle, medications, measurements, documents. Creates
that a WRITE route answers (visits, practitioners) ask canWriteDomain.
The generic delete button, the selection bar, the capture picker, the
dashboard quick entry and the bottom nav take the section from their
callers. Controls whose routes resolve the caller rather than the
record (settings shortcuts, chart overlay preferences, custom metrics,
the Today rail, OCR, efficacy targets, credential tabs) ask for the
caller's own record instead, and the customise-shortcut guard now
requires that shape.
The vault has no delegated write route, so its controls stay withheld
under every grant, and the documents page says so in one sentence
where the upload control would be, in all seven locales.
The eight journeys were enumerated and one of them was wired. The other
seven existed as claims in a fixture file, which is the gap the enumeration
was written to close: a browser suite reports what it ran, never what it did
not, and a journey nobody wired reads exactly like a journey that passed.
All eight are wired now, and the list is checked in both directions — an
enumerated journey missing from the wiring fails, not just a wiring entry
naming a journey nobody declared. Before this, a subset was legal, so a
dropped journey and a not-yet-written one looked the same.
A journey names more than one file where more than one file carries it. J1
claims a grant runs through invite, accept, switch, expiry, revoke and a
direct URL; the browser proves the switch, what each level opens and the
direct-URL refusal, and the transitions either side are database facts
proved against real Postgres. Folding that into one file would mean a
browser spec driving expiry through the clock or an integration file
asserting what a page renders, and both are worse tests than the two that
exist.
Three journeys ride Playwright and five ride Vitest, so a green run of this
guard means all eight are claimed and here is which have executed in the
ordinary gate — never that all eight passed. The runner is derived from the
path rather than trusted, so it cannot become a label set to whichever value
made the split read better.
The extractor now reads `it(` as well as `test(`, and the parameterised
`it.each(table)("title")` form alongside the single-call one. It saw neither
before: five of the journeys are proved by Vitest files, every per-file leg
is over a matched set, and an empty match set satisfied them all. The
synthetic spec that proves the parse grew a Vitest half, and it is what
caught the parameterised form being invisible. What the matcher still cannot
see is written into its own doc comment.
Runs the legacy whole-record decoder over the entries `resolveAccountAccess`
actually produces, rather than over the fixture objects that describe them: the
existing case proves both ends and never the assembly between them, which is the
shape of test this project keeps rediscovering. Whole READ and WRITE decode,
MANAGE decodes as write and never above the level it confers, a scoped grant is
refused rather than read as the whole record, and a stored scope the server
cannot parse reads as nothing on both ends.
The canonical readers are driven over the same server-built entry, so
`level`, `sections` and `recordKind` are proved to survive the trip to the
capability resolver. The account-payload guard gains the pair for
`recordSession`: a field whose only reader is the transport type is exactly
what that guard exists to catch.