Skip to content

Promote dev to main (2026-10-22) - #17

Merged
pablo-tech merged 41 commits into
mainfrom
promote/auto-2026-10-22
Oct 24, 2026
Merged

pablo-tech merged 41 commits into
mainfrom
promote/auto-2026-10-22

Conversation

@pedalion-ci

@pedalion-ci pedalion-ci Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Automatic promotion: dev has soaked 24h, CI is green on its tip, and no error report names a build in this range.

  • apps/lexitar/wrangler.jsonc is main's, unchanged.

Label this pull request hold to stop it merging.

🤖 Generated with Claude Code

Pablo Rodriguez and others added 30 commits October 18, 2026 10:10
apps/lexitar/wrangler.jsonc is deliberately divergent between the two deploy
branches: Cloudflare Pages reads it on every build, so a branch's copy IS its
environment. This branch binds health-vault, health-identity-dev and
STORE_PREFIX=dev, and turns the corpus on. promote.yml's keep-paths is what
stops a promotion carrying these values to main.

packages/frame's version trails main because the release job bumps main only.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 20-line rationale above the gate asserted "this repository is private
for the duration of the published-key-material remediation". That was true
when it was written and is now read by the public as a claim about the
repository they are looking at.

The mechanism was always right and needed no change: the gate asks the API
for the live visibility, so it reverted to uploading by itself when the
repository went public on 2026-10-18 — which is what "nothing needs
undoing" meant. So the comment now leads with what the gate does and why
(code scanning is free on a public repository and licensed on a private
one, so the upload decision belongs to the live visibility rather than to
a constant somebody has to remember to flip), and keeps the October 2026
remediation as the dated instance that proved the design rather than as the
present condition.

Comment-only: zero non-comment lines in the diff.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`onboarding.spec.ts:13` asserted `.account-trigger` had count 0 on the
onboarding screen, with the comment "app shell not reached yet". The
trigger was a proxy for that, and the proxy stopped tracking what it stood
for on 2026-10-18, when the onboarding screen deliberately gained the
individual's account menu: it is the only route to Google or to a passkey,
and an individual whose vault holds no record yet has no other screen to
reach it from, while the sign-up copy promises both are "removable any time
in Account settings". App.svelte's `individualAccountMenu` is defined once
and rendered twice for exactly that reason, and Onboarding takes it as
`accountArea`.

So the application is correct and the assertion was stale, which is how a
fix reads as a regression. The line now asserts `.sidebar` — what it always
meant — and the trigger is pinned positively where it belongs, which is the
regression test that fix did not have.

Measured, not assumed: this was the only failure in `lexitar` on pull
request #1 — 249 e2e tests passed, and `test` reported 336 unit test files
passing in 23 seconds.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Say what the CodeQL upload gate does, not that the repo is private
`tinytars/lexi` is public, so an Actions log is readable by anyone forever, and
an R2 key is not safe to write into one: `{store}/raw/<account>/<filename>` is
an account id plus the name of a file a person uploaded. Both scripts printed
keys there.

The audit found eight sites, not the one the plan named. Three things widened
it. stderr lands in the same log as stdout, so limiting the search to
`process.stdout.write` missed half of it. A thrown message reaches stderr
through the handler at vault-snapshot.ts:539, so every throw is an output site.
And the plan's prescribed `classifyKey(k)` would not have compiled —
classifyKey takes (store, key).

Redacted, with the class and a 12-hex digest of the key in place of the key:

  vault-sync.ts:383,396,404   R2 get/put/delete failed for <bucket>/<key>, plus
                              the response body, which an S3-compatible error
                              document may echo the key back into
  vault-snapshot.ts:302,311   live object vanished mid-snapshot
  vault-snapshot.ts:318       log object is not UTF-8 round-trippable
  vault-snapshot.ts:409,419,  the three verify lists, redacted where they are
    420,426,438,439,444       BUILT rather than where they are printed
  vault-snapshot.ts:226       refusing to snapshot: N live key(s) have an
                              unrecognised shape

The last of those is the site the plan did not name, and it was the likeliest to
fire of any of them after :383: it printed up to ten raw keys. There the keys
are the diagnostic — classifyKey() cannot be taught a shape nobody can see — so
it branches on process.env.CI, which is the same test assertLiveWriteAllowed()
already applies at vault-sync.ts:159. Raw keys for an operator at their own
terminal; the class, the digest and the local command in CI.

The verify lists are redacted at the producers because that makes VerifyReport
structurally key-free rather than leaving the invariant a property of three
print statements someone must remember. Consumers were checked first: no script
reads missing/extra/sizeMismatch, the only reader is the unit test. Redacting at
the printers was the first pass and was wrong — those entries are
"<key> (<why>)" strings, so keyRef over the whole string would have discarded
the live/snapshot sizes that are the actual diagnostic.

Audited and left alone, so the next reader does not re-derive it: eleven stdout
sites at vault-snapshot.ts:183, 230, 231, 234, 270, 357, 493, 495, 518, 521, 533
print ids, counts, bucket names and a snapshot id, no keys. :210 names a bucket.
:510 quotes a --keep argument. :426 keeps manifest.logBundleKey whole: it is a
snapshot artifact path carrying a snapshot id, not a user object, and which
bundle is missing is the point of the line. assertLiveWriteAllowed() returns at
vault-sync.ts:159 when CI is set, so neither its stderr write nor its throw can
reach a public log at all.

tests/unit/vault-log-hygiene.test.ts pins it, and the last test is the one that
matters: it reads both scripts and fails on any key property access formatted
into a string without a redaction helper around it. Two earlier forms of that
check were discarded against negative controls. A line-scoped version walked
past the unrecognised-shape refusal, whose message was built into a variable one
statement before the throw that printed it; matching the word "key" rather than
a property access drowned in prose about keys. The form that shipped reports the
nine real sites across both files and nothing in the key-building code, and both
controls fail it.

Also, and separately, the capacity half the same snapshot run needs:
CONCURRENCY 8 -> 24 and snapshot.yml's timeout 30 -> 60 minutes. The dev leg's
own successful run copied 4,201 audit-log objects at about three a second and
spent 23m20s of a 30-minute cap, in a store that grew 32x in seven weeks, so the
cap was the next thing it would hit. The copy is one GET plus one PUT per object
with no ordering between them, which is why raising this is the cheap half. The
real fix is that 4,201 R2 objects for 4,201 audit events is the wrong shape, and
that carries a migration and a retention decision of its own.

Verified in this worktree after a real `npm ci`, with
`readlink -f node_modules/@tinytars/frame` confirmed to resolve inside it:
oxlint clean, svelte-check and tsc 0 errors, 337 test files and 3,825 tests
passed.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ops.yml offered thirteen operations and twelve of them could not run. Each of the
eight r2-ops operations plus raw-backfill, raw-pages-backfill, raw-encrypt-backfill
and recovery-approve reaches scripts/org-unwrap.ts, so each needs the org PRIVATE
key, and the key file never arrives on a hosted runner: scripts/org-key.ts:33
resolves ORG_KEY_PATH or a credential store's org-key.json with no in-repo fallback,
and no workflow sets either. A dispatch therefore checked out the repository, billed
the minutes and failed on the key, every time. orphan-sweep is the one that does not
reach the key and is the one that stays.

Putting the org key in Actions secrets would have fixed it and is declined: it gives
every workflow-dispatch a recovery envelope into every vault, which is the capability
the October 2026 remediation was for. So the menu shrinks to what can actually run.

And the form named a person. This repository is public, so a dispatch's inputs and
its run page are readable by anyone: `client` took an account id — the patient's own
EMAIL ADDRESS for recovery-approve, by its own input description — and `reason` took
free text, and .github/actions/lexitar-op echoed both into $GITHUB_STEP_SUMMARY as
"### <op> — <client>" and "Reason: <reason>". A run page saying who an operation was
run for is a disclosure whether or not the run then failed on the missing key, and
twelve of thirteen could only fail. Both inputs are gone from the workflow and from
the composite action, along with the concurrency group `vault-${{ inputs.client }}`,
which spelled an account id into a public run's metadata. force, marker, name and
purge_unreadable go too, for the duller reason that nothing reads them any more.

The twelve case arms were the only written copy of how to run those operations.
r2-ops-cli.ts prints no usage text and no document covered them, so deleting the arms
would have destroyed the runbook. apps/lexitar/docs/OPS-LOCAL.md carries it, in
docs/RECORD-EXPORT.md's form, including the semantics only the arms held: --all-markers
against --marker, the --confirm the three backfills need, --record for a real
orphan-sweep, --purge-unreadable's "the nightly snapshot is the only undo",
--dry-run's no-Anthropic-call contract, and --id/--key having no menu entry anywhere.

The header rule gains a clause. "A new operational flow is an entry in this file,
never a second workflow" now also reads that an entry is a CLAIM THE OPERATION CAN RUN
ON A HOSTED RUNNER — the missing half, since that rule is what put twelve unrunnable
entries on the form in the first place.

What this change made untrue, and what it cost to fix:

liveWriteRefusal (vault-sync.ts:148) refused a workstation write and told the operator
to dispatch ops.yml instead, "which serializes it with concurrency: vault-{client}".
Both halves are now wrong, and the first was ALREADY wrong before this commit for
every caller but orphan-sweep's delete: the lane it recommended could not run them.
It now states the hazard (`wrangler r2 object put` has no conditional option, so a
browser save landing mid-write is lost silently), sends orphan-sweep's delete to CI
where it does run, and sends everything else to OPS-LOCAL.md and the override. The
rationale comments in vault-sync.ts and vault-ops.ts said the per-client concurrency
group was what serialized these writes; for these eight it never had, so they now say
nothing serializes them and the operator does.

live-write-guard.test.ts asserted the message contained "ops.yml" — it now asserts the
message names OPS-LOCAL.md and orphan-sweep, which is what keeps the dead lane out.

OPS-LOCAL.md documents PLOVER_ALLOW_LOCAL_VAULT_WRITE=1, which every real run of the
twelve needs and which nothing previously told an operator about, per command rather
than exported, since an ambient acknowledgement is not one.

Four documents described removal or sealing as going "through the ops.yml lane" —
MODERATION.md:81, DPGA.md:514, VAULT.md:283 — and CREDENTIALS.md:177 said the workflow
"records which five ops are operator-only" while citing org-key.ts:28. Five was twelve,
and :28 is the comment block; the resolver is :33. All five now point at OPS-LOCAL.md.

Verified in the worktree with @tinytars/frame resolving inside it (readlink -f):
oxlint --deny-warnings exit 0, svelte-check + tsc 0 errors / 22 warnings (unchanged),
337 test files and 3825 tests passed. Both YAML files parse under yaml.safe_load, and
`grep -c` reads 0 for inputs.client and inputs.reason across both.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stop the vault scripts and the ops form disclosing R2 keys and account ids into public logs
…in's hook shim

Two things `dev` carries that a promote would take to `main`, both found by
reading the dev..main diff before handing the merge over, neither of them this
warp's work.

`packages/frame/package.json` declares **0.1.26** on `dev` where `main` declares
0.1.57 and npm's latest is 0.1.53. The promote merge takes dev's file — only
`apps/lexitar/wrangler.jsonc` is in `keep-paths` — so `main` would land on
0.1.26, `ci.yml`'s `release` job would bump it to 0.1.27, and `npm publish`
would move the `latest` tag of `@tinytars/frame` **backwards by 26 patch
versions**. npm does not take a version back.

The guard that normally absorbs this is the bump step's own loop, which skips a
version whose `frame-v<version>` tag already exists. It cannot help here: the
2026-10-18 republish discarded all 57 tags, so there is nothing for it to skip
and `dev` being far behind becomes a published regression rather than a no-op.
Raising dev's declared version to main's makes the merge a no-op on that file
and the release a 0.1.58, which is above npm's latest. The lockfile's
`packages/frame` entry moves with it, because `npm ci` fails when the two
disagree.

`.githooks/pre-commit` on `dev` is the shim without the hub resolution: it reads
`$HOME/.claude/infra/git-hooks` outright, where main's asks git which worktree
holds `main`, and it drops the fail-closed rationale and the bypass hint.
Restored from `main` byte for byte, so the promote stops being a downgrade of
the local guard chain.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stop the promote publishing @tinytars/frame backwards, and restore main's hook shim
…a way out

A DEK rotation re-encrypted the vault, re-wrapped the fresh DEK to every
principal and DELETEd the old envelopes in the same transaction — and never
walked `chat-<id>.enc`. The rotation reported success. The vault then opened
under the new DEK while the chat blob stayed sealed under a key that no longer
exists anywhere, `loadThreads` caught the decrypt failure, and the W71 guard
that refuses to overwrite an unreadable history made every save throw forever.
Seven chat histories across dev and prod were left that way by the 2026-10-06
and 2026-10-07 rotations, behind a banner with no action under it.

Two fixes, and they are independent: the first stops it happening again, the
second gets the seven accounts saving again through the product.

**The rotation now covers chat, and the ordering is the safety argument.** A
chat blob is addressed by its client id and nothing in D1 points at it, so
unlike the vault it cannot be written to a new key and repointed. Both in-place
orderings lose data — re-seal before the commit and a crash leaves chat under a
DEK the envelopes do not hold; re-seal after it and a crash leaves chat under a
DEK nothing references. So the re-seal goes to `{store}/rekey-chat/<new vault
id>/` before the D1 transaction and verifies its round trip there, and the
promotion onto the live keys happens after the repoint is proved from D1 — by
which point it is a pure object copy needing no key at all. Residue from an
interrupted run is read out before the first write of the next one, while the
two kinds can still be told apart.

**An unreadable history is now a recoverable state rather than a terminal one.**
`PUT /api/chat-history/{id}` takes `X-Replace-Unreadable: 1`, which copies the
stored ciphertext to `{store}/orphaned-chat/{id}/<ts>.enc` and only then
overwrites — retention first is the whole reason it is safe to offer, so a failed
copy fails the request. The write is unconditional by necessity: a tab that could
not read the blob holds no etag for it. The session exposes the refusal as
`unreadable`, distinct from `saveError`, and the chat banner grows one button
behind a confirm that says what survives. Three independent guards keep it
unofferable where nothing is wrong, and the pending debounced save is cancelled
before the write so a staler snapshot cannot land after it.

Neither retention prefix begins `chat-`, because orphan-sweep.ts lists
`${store}/chat-` and deletes what it cannot resolve in D1 — which both are by
construction. Both are taught to `classifyKey`, or the nightly snapshot refuses
to run at all.

No operator DELETE anywhere, and the ciphertext outlives every click: a key
recovered later still opens what was set aside.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's erasure-completeness guard refused the previous commit, and it was right to.
It derives the set of R2 key classes from `storeKey()`'s real call sites in
`functions/` precisely so a new one cannot be added without an answer, and
`orphaned-chat/` arrived with none.

The answer is to erase it, not to excuse it. `logs/` is excused because it holds
no record content; `phi_access_events` is kept because deleting it would damage
another patient's audit trail. A retention copy is neither — it is the subject's
own conversation, written by design to OUTLIVE the overwrite that displaces it,
which makes it the one class where an erasure that missed it would leave behind
exactly the content the subject was told had gone.

Mechanically it was already deleted: the route records the kept object in
`raw_objects` as it writes it, so the key-driven pass picks it up. What was
missing is the half that decides `complete` — the namespace scan that counts what
an erasure did not delete, which ran over `raw/` and `text/` alone. Ownership is
first-writer-wins, so a clinician reading this patient's record can be the one who
clicks the hatch and the copy is recorded to them; without the scan it is neither
deleted nor counted, and the subject is told the sweep was clean. That is W75's
finding arriving in a namespace that did not exist when it was made.

The two prefixes were two literals in one file, and the drift between them is the
failure itself, so they are now one named list used by both.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LexiTar: a DEK rotation re-keys chat too, and a stranded history has a way out
Cloudflare answered `error code: 1102` as text/plain on dev's /api/chat six
times in the 10 AM PT hour on 2026-10-19 — the isolate killed for exceeding its
memory, so no function of ours ran and log.ts never fired. res.json() therefore
threw on the client, the handler was left holding {}, and ChatTab rendered the
bare status for a condition whose entire answer is to send the message again.

Measured rather than inferred: pagesFunctionsInvocationsAdaptiveGroups reports
status=exceededResources x6 on the dev Pages worker and 0 on production, and CPU
on the failures (10 ms p50, 481 ms p99) is indistinguishable from the successes
(421 ms p99) — so the resource exceeded was memory, not compute, and this 503 is
neither model-errors.ts's ai_busy nor throttling nor credit.

ChatTab was the one renderer hand-rolling its own wording. Allergies, Family,
Notes, Study, MarkerChart, UnifiedTreatment and HypothesisTopicCard all reach
describeAiError already; it now does too, so a 503 with no body says the same
sentence everywhere instead of showing a patient a status code.

The status-to-code mapping gets one home. ai-error.ts held 402 and 503 inline
inside describeAiError, reachable only by a caller that already had an AiError to
throw; aiErrorCodeForStatus exposes it to a caller holding a Response, and
describeAiError reads it rather than keeping a second copy. It answers undefined
for every other status deliberately, so an unclassified failure keeps falling
through to its own message instead of being relabelled — which is what keeps 413
"conversation too large" reading as itself, pinned by a test.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(lexitar): a body-less 503 reads as busy, not "request failed (503)"
…asured

The in-flight corpus budget had never refused anything, in either environment.
`openReportCorpus` tested `r.bytes !== null` to decide whether a record's size
was known, so a row carrying `0` counted as measured and the reservation came
out at zero: every request admitted however many were already assembling, and
the isolate was killed for memory -- a body-less Cloudflare 1102 taking every
unrelated request in flight with it -- in place of one classified `corpus_busy`
the caller could retry on. That is the exact mode the comment on
MAX_IN_FLIGHT_CORPUS_BYTES predicted, and a `null` would have been safe: it
reserves the whole cap.

Measured rather than reasoned, on 2026-10-19: 61 of dev's 63 PDF rows and all
21 of production's carry a zero, no row in either carries a null, and no PDF is
zero bytes -- every one begins `%PDF-`. The zeros are residue from a writer that
no longer exists in this tree; every current one records a real length or leaves
the column alone.

`isMeasured` gives "what counts as a measurement" one home, beside the
reservation that is the only thing turning on it.

The data half is `--sizes`, in the backfill script rather than a second one so
that what `bytes` means keeps one authority -- the note at the existing write,
which says the column records what STORAGE holds. It reads sizes from one R2
listing, so no object is fetched, no envelope opened and no org key loaded,
which is also why it is correct for a sealed file. `pages` is deliberately
absent from its SET, the rows it reaches being images and spreadsheets as well
as PDFs, and an object R2 really does hold zero bytes of is reported and left
alone rather than written into the very value that means unmeasured.

`main()` also now calls `assertTargetConsistent`, which this file has always
wanted and never had: it writes to a live store, and a tree whose
`wrangler.jsonc` disagrees with its branch would read one store's objects
against the other store's rows.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read a zero byte count as unmeasured, and record the sizes nothing measured
A record could not be saved. `PUT /api/vault/{id}` answers 413 above a 5 MB
ceiling (functions/api/vault/[id].ts:31) and one record's stored blob had
reached 5,163,393 bytes — 98.5% of the cap — so every save failed and the
browser reported `save failed (413): vault blob too large`. Nothing in the
record the patient can see is that big: the next largest blob is 663,157
bytes, and the envelope does not compress, so the 5.16 MB is raw JSON text.

All of it is `Client.regenLog` — one `RegenEvent` per Translation
regeneration, each keeping BOTH the before and the after prose of every
rewritten Finding section. It has one writer (src/lib/leaf-regen-client.ts)
and no reader, so it grew without bound inside the one object that has a
size limit, and the limit is reached by a field nothing displays.

src/lib/regen-log.ts:14 already said what to do about this if size ever
became the binding constraint: move the log out of the vault, which is a
migration and recoverable, rather than truncate it, which is not. So:

  - `regen-log/{clientKey}/{iso}-{8 hex}.enc`, one immutable R2 object per
    append batch, each holding `RegenEvent[]` sealed with the same
    `encryptVaultV2` under the same vault DEK. Immutability is what buys
    the simplicity: no etag, no 412, no merge, and append stays O(1)
    however long the log gets.
  - The server mints the key and the browser never chooses one, so a name
    the route could not have minted itself is a 400 (and `..` cannot be
    written or read).
  - Segments are written BEFORE the stripped record is saved, and on a
    failed write the events go back into the vault being saved. A save
    never trades a 413 for silent data loss, and the migration is just the
    first such save — there is no separate drain to run.
  - A module-level `WeakSet` is what keeps an event from being written
    twice, rather than identity-stripping: both `createVaultPrincipals`
    and the conflict resolver assign `vault = v` themselves after a save
    resolves, which would undo any strip.

The new namespace is registered everywhere a namespace has to be known:
ownership (raw-owner.ts), rekey (scripts/commands/rekey.ts, which re-seals
every segment to staging before the D1 transaction and promotes only after
the repoint is proved), sync, snapshot, the orphan sweep, and account
erasure — the last of which is the one that would have failed quietly, the
log having no reader to break.

`functions/api/vault/[id].ts`'s 413 now says the size and the cap, so the
next one is diagnosable from the response instead of from the bucket.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Move the regeneration log out of the vault blob, into R2 segments
Every authorized `/api/raw/*` and `/api/chat-history/*` request on dev has
answered 500 since 8d23841 deployed, with `D1_ERROR: LIKE or GLOB pattern too
complex: SQLITE_ERROR` thrown from `ownerOfClientNamespace` — the one lookup
both routes pass through. The browser reported ~30 failed attachment fetches,
a failed file listing and a failed chat-history load on one record, and
`/api/leaf-regen` answered 502 downstream with its attachments gone. The sink
filed it as plover-factory#71 and #72.

D1 sets SQLite's `SQLITE_LIMIT_LIKE_PATTERN_LENGTH` to 50 bytes, a thousandth
of SQLite's own default, and says only "too complex" when a pattern crosses it.
Measured against health-identity-dev: 50 accepted, 51 refused. The ownership
query matched each namespace with `LIKE 'prefix%'`, and the prefixes were

  dev/raw/{uuid}/%        47
  dev/text/{uuid}/%       48
  dev/chat-{uuid}.enc     -- an equality test, never a pattern
  dev/regen-log/{uuid}/%  52   <- registered by 40103cc

so the three that fitted fitted by two bytes, and the fourth did not. Prod's
prefix is a byte longer again. The same query ran green in the suite, node's
SQLite carrying the 50,000-byte default, and no fixture can reproduce a hosted
limit — which is why the guard added here is structural.

So no prefix match on an R2 key goes through LIKE any more. `keyRange(prefix)`
returns the half-open range `[prefix, prefix-with-its-last-character-advanced)`,
which has no length ceiling, needs no escaping — `_` and `%` are wildcards in a
pattern and literals in a comparison, and a client id is a name the patient
chose that may contain either — and reads the `r2_key` index instead of testing
every row. Verified against the live dev database: the deployed four-namespace
form is refused, the range form is accepted and resolves the same owner the
working three-namespace form did.

Three queries carried the defect and all three are converted, not only the one
that crossed the limit: the two listing queries behind `?files=1` and the corpus
were at 47 bytes with a uuid client id, and a client id of forty characters
would have taken them over the same cliff one upload at a time.
`scripts/raw-cipher-cli.ts` holds a hand copy of the ownership query and is
converted with them, divergence there being how this returns.

`tests/unit/key-range.test.ts` pins the renderer and sweeps every `.ts` under
`functions/` for a bound LIKE parameter or a hand-built `%` pattern, with a
positive control so the sweep cannot pass by finding nothing, and holds any
remaining literal pattern to the measured limit.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`tinytars/lexi` is public, so an Actions run page is world-readable, and the two
workflows that run against the live store printed object keys into it. A key is
identifying: `{store}/raw/{client}/{document}` carries the patient's own name
for one of their clients and the filename of a medical document, and an orphan
sweep printed the bare client namespace as a heading. Nothing was disclosed —
both workflows have been `disabled_manually` since the audit — but re-enabling
them as they stand is what would disclose it, and they are Phase 7.

  scripts/vault-snapshot.ts    MISSING / SIZE / deleted-since-the-copy-started
  scripts/orphan-sweep.ts      the namespace heading, and `deleted <key>`

So `scripts/key-label.ts`: a key rendered for a reader who is not entitled to
the person it names, keeping the store and the class and replacing the rest with
a stable 8-hex digest of the whole key.

  prod/raw/{client}/{document}  ->  prod/raw/#1f3c9a2b
  prod/data-{account}.enc       ->  prod/data-#4c7e0d11

The digest is what makes it useful rather than merely safe: an alarm on a run
page matches the same object in a local `--keys` run, so an operator can go from
the redacted line to the real key without the hosted log ever holding one.

Redacted is the DEFAULT and `--keys` is the opt-in, rather than redacting when
CI is detected, because the two mistakes cost differently: a terminal's output
gets pasted into an issue, and a run the author believed was local is exactly
the run whose keys reach the public page. Where anything was withheld the output
says so and how to see it, so a reader cannot mistake a redacted report for a
clean one.

`tests/unit/key-label.test.ts` pins the renderer and then sweeps both scripts
for a bare key interpolated into anything they write, with a positive control so
the sweep cannot pass by finding nothing, and asserts the withheld-keys notice.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: pedalion-ci[bot] <pedalion-ci[bot]@users.noreply.github.com>
…plan

`ownerOfClientNamespace` orders by `created_at` and takes one row, and `created_at` is an ISO string
at millisecond resolution — so two rows in one namespace routinely tie, the two inserts behind a
single upload sharing a millisecond on any quick host. A tie leaves the answer to the query PLAN,
and the previous commit changed it: a `LIKE` scanned the table and answered in insertion order,
while a prefix range SEARCHes the `r2_key` index and answers in KEY order.

First writer by filename is not the rule the module states, and the consequence is not a near miss.
With the owner flipped, a clinician reading their patient's file resolves as `{ kind: "owner" }` —
the branch that deliberately records nothing, actor and subject being the same account — so the
disclosure goes unaudited. That is what CI caught in `raw-owner-subject.test.ts`, which passed here
and failed there: both hosts tie, and they disagreed about which row the plan reaches first.

`rowid` IS insertion order, which is what "first writer" has meant all along, and it makes the
answer independent of the plan rather than dependent on a different one. The operator script's hand
copy of the query takes it too.

The new test pins it without depending on timing at all: two rows with the same `created_at`, the
first writer's key chosen to sort LAST, so key order picks the wrong account and only insertion
order picks the right one. Verified to fail on both test backends with the tie-break removed.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Redact object keys by default in what the hosted workflows print
Match an R2 key prefix by range, D1 refusing a long LIKE pattern
Both defects of 2026-10-20 were shipped by a commit whose whole suite was green, and
each one was a class rather than an accident. This adds the guard for each class,
scoped so a green run is not an empty one.

1. A HOSTED-ONLY LIMIT, which no fixture can reproduce. tests/hosted/ sends the app's
   own query functions — ownerOfClientNamespace, listRawPdfsUnder, listRawObjectsUnder,
   listRawObjectsForAccount, listRecentAccessEventsForSubject — to the hosted database
   the branch's own wrangler.jsonc names, with ids that match nothing, and asserts zero
   rows. Both store prefixes always, so prod's extra byte is caught from a dev branch;
   the worst client id the product allows, not a typical one; read-only by construction,
   the adapter refusing any statement that is not a SELECT. It re-measures
   SQLITE_LIMIT_LIKE_PATTERN_LENGTH and fails if it has narrowed below
   D1_LIKE_PATTERN_LIMIT, passing with a note if it has widened.

   Measured: 22/22 against health-identity-dev in 46s, the limit still exactly 50 bytes.
   Negative control, prefixMatch reverted to the `LIKE` it replaced: 5 of 6 ownership
   probes fail. Four are the platform refusing the pattern — the outage itself. The
   fifth is the other half of why key-range.ts exists: a client id made of `%` and `_`
   is a literal in a comparison and a wildcard in a pattern, so under `LIKE` it matched
   another namespace's rows and the zero-row assertion caught it as a disclosure.

2. A PARTIAL ORDER, whose answer is the query plan. tests/unit/query-determinism.test.ts
   asserts every ORDER BY in functions/ is total, deriving each table's unique columns
   from migrations/*.sql rather than holding a copy of the schema — single-column
   PRIMARY KEY/UNIQUE and single-column non-partial unique indexes, plus rowid, with a
   composite key contributing none of its columns alone. It enumerates the five real
   queries as named cases. Negative control, `, rowid` removed from raw-owner.ts: it
   names the file, the columns and the fix. scripts/ is deliberately out of scope —
   several of those queries order by a column unique only within their own WHERE.

3. A CONSTANT WITH NO ONE RE-MEASURING IT. D1_LIKE_PATTERN_LIMIT moves to
   functions/_lib/key-range.ts, the module that exists because of it, and the test that
   held its own copy now imports it. Its two readers are named in its docstring: the
   offline suite and the hosted one.

`npm run test:hosted` is not in `npm test`, which must stay runnable on a clone with no
credential store. hosted-query-check.yml runs it on same-repo pull requests (the only
trigger that can refuse the change before the deploy), on pushes to dev and main, daily,
and on demand — notifying through the same pinned pedalion-ci workflow error-pipeline.yml
already calls, and only for the unattended triggers. It is deliberately not a required
context: a network-dependent check that blocks every merge buys an outage of its own.

Gates: lint 0, check 1568 files / 0 errors, npm test 343 files / 4193 tests on workerd
and again on TEST_BACKEND=node, 0 failed on both.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test: catch the three defect classes behind the raw/chat-history outage
`LEXITAR_BASE_URL` is the only thing that decides which deployment a script
signs in to, and nothing compared it to anything. The three guards already in
`scripts/target.ts` all read the worktree — `targetMismatch` reads the fields of
`wrangler.jsonc`, `branchMismatch` reads the branch that file sits on — and the
base URL is in neither place: it is typed on the command line at the moment of
use. One variable stood between the dev worktree and a real person's record.

`envOfBaseUrl` resolves a host to an environment, `baseUrlMismatch` is the
refusal text in the same shape as its two siblings, and
`assertBaseUrlMatchesTarget` is called from `origin()` in `api-session.ts` —
the one place the variable is read, and the last point at which nothing has
happened yet. Proved live from this worktree: aiming at
`literacy.tinytars.foundation` refuses before any fetch, `health-dash-aex`
still resolves.

Two decisions worth naming. localhost is `either`, because `dev:functions` is
started from whichever worktree you stand in and binds that tree's
`wrangler.jsonc` — the local server IS the worktree. And an UNRECOGNISED host
is not a refusal: the incident this closes is a recognised production origin
reached from the wrong tree, so refusing an unknown host would break a tunnel
or a test harness while catching nothing.

`CUTOVER.md` opened by saying in the present tense that `health-dash-dev`
holds the live beta users' vaults. It has not since the cutover, and in a
document whose whole subject is which deployment holds real PHI that is the
most expensive kind of stale — a reader acting on it aims a recovery at the
wrong store.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Guard the deployment axis: refuse a base URL the worktree does not own
…k reading as granted (#14)

* feat(lexitar): let a clinician ask for access, and stop a pending link reading as granted

A clinician could not ask a patient for access at all — only an internal support
account could — so the only way a doctor was ever added was the patient finding
them unprompted. And had such a request existed, the patient's own privacy screen
would have shown it as access ALREADY GRANTED: `activeAccess` was computed as the
negation of `pendingSupport`, so an invited clinician fell into it, rendered with
Revoke and no Approve. The patient would have been told someone could read their
record who could not, and given no way to let them. Nothing had hit it because
nothing could create such a link; this creates one, and fixes the line first.

One handler, two mounts. The obvious alternative — widen `/api/support/request`
to accept clinicians — is one line and makes the path lie about who may call it,
with two shipped consumers naming it. So the body moves to
`functions/_lib/routes/access-request.ts` and is mounted at the unchanged
`/api/support/request` and a new `/api/providers/request`, the shape
`support-approve.ts` and `providers-approve-support.ts` already use. The
hardcoded `role: "support"` becomes the caller's own role, so the row records who
asked; primary→primary is a 400, and a support target is a 400.

`access:request` is a new capability held by `["primary","support"]`, deliberately
not a widening of `support:queue` — which also gates `/api/support/requests` and
the audited `/api/support/access`. Asking is the one thing a clinician needs and
the only thing they get: who may ANSWER an ask is settled by the target's own
browser, not by the table.

`/api/providers` attaches `publicKeyJwk` to any invited link rather than only a
support one. Without it the patient presses Approve and watches nothing happen —
the controller's early return is silent, and there is nothing to wrap the DEK to.

Two new audit names, and a split. `provider_access_requested` is new; so is
`provider_access_granted`, because `providers/grant.ts` audited nothing today
while its mirror `provider_access_revoked` was written — a patient granting their
own clinician left no row on their own access screen, so the log read as if the
access had always been there. Neither is a privileged read, and
`auditPrivilegedRead`'s owner exclusion is right for a disclosure and wrong for a
consent change, so the frozen vocabulary splits into `PRIVILEGED_READ_ACTIONS`
and `CONSENT_LIFECYCLE_ACTIONS` and the chokepoint's field narrows to the first.
The vocabulary stays closed because `erasure.ts` never deletes those rows.

Approval is the existing grant route, deliberately not `/api/support/approve`:
that one is `grantBreakGlass` and stamps an `expiresAt` only `checkBreakGlass`
reads, so a time-boxed clinician link would be silently permanent.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Gate the clinician ask form on a confirmed clinician session

A support account is also a provider, and `enterAccount` raises `isProvider`
before `getMyAccount()` answers — so for the length of that round trip a support
login renders the clinician console, and the ask form added in ee73c98 was the
form a support agent's own typing landed in. The support console then mounted and
detached it mid-click: `providers-support.spec.ts` resolved the button enabled,
lost it to the DOM, and found the support console's own button disabled because
its field was still empty.

`isClinician` is a second flag rather than `isProvider && !isSupportSession`,
because during that window the two are not complements. It is raised only on the
branch the server has already classified, and nothing a person can act on should
be gated on `isProvider` alone.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…for a refusal

Expiry has been enforced for every role since W71 — `getActiveProviderLink` rejects a link
past its `expires_at` and every non-owner read goes through it. What was missing is that
nothing ever WROTE one for a clinician: `grant.ts` set status and envelope and left the
column NULL, so "time-boxed access" existed in the schema and in the gate and nowhere a
patient could reach.

`POST /api/providers/grant` now takes an optional `ttlDays`, defaulting to 30 and clamped
to 1..365. It is a COUNT, not a date: the window is computed from the server's clock, so a
wrong clock in a browser cannot move a consent decision. `null` means no end date, which is
what every existing grant already has and keeps. The body is validated before `putEnvelope`,
so a malformed request leaves no half-made grant for the next read to find.

The re-grant branch stops calling `updateProviderLinkStatus`, whose statement is
`SET status = ?` alone: a renewal through it would flip the link active and silently keep
whatever date it already carried, which is the exact shape of a grant that reads as ended and
is not. It uses the store write that sets status, expiry and consent together — re-exported as
`setLinkGrant`, renamed from a `grantSupportLink` that had no call sites, because a clinician
renewal should not be calling something named for support.

`GET /api/providers/patients` returns `expiresAt`, so both ends of one grant can print the
same date. Without it the clinician could only be told they have access, and would learn the
window existed by losing it.

A refused envelope read now carries `errorCode`, classified after the gate has already said
no — the gate does not move. `access_expired` and `forbidden` are both "you cannot read this",
but only one of them is renewable, so a revoked link must not come back as expired or the
patient is offered a Renew button for access they deliberately pulled.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pablo Rodriguez and others added 11 commits October 20, 2026 15:19
…rd clock

Every principal in the access UI wears the same grey row today, so a clinician, a support
agent and a stored password are visually the same thing. These are the primitives that let
one row shape serve all of them, with the only colour in the row saying which it is.

`PrincipalChip` is `.persona-tag` promoted to a component. It carries its colours as custom
properties because `theme.css` tints a tag through a DESCENDANT selector — a standalone row
has no `.p-owner` ancestor to inherit from.

`PrincipalRow` fixes the reading order at chip, name, one sentence, actions, and takes the
chip's tone as the card's border, so the kind of row is legible before any word is read. The
sentence is always a prop: a component that built its own would need to know the domain, and
this package is held domain-neutral by its own test suite. Dimming an ended row recolours
rather than dropping opacity, which would take the text below contrast.

`DurationChoice` is radios and not a `<select>`. With two options a select hides the choice
behind a tap and announces nothing until opened, where radios announce "30 days, selected,
1 of 2". The unit is hours, with 0 for no end date, because the support windows this also
serves are shorter than a day.

`principal-tone.ts` names tones for the token pair rather than for the role — `provider`,
not `support` — so the mapping from a domain word to a colour stays in the app.

`time-until.ts` is new rather than an extension of `time-ago.ts`, which is past-only and
answers "just now" for every future date. It is the complement, not a rewrite: a grant's end
is always ahead of now.

`grantProviderUntil` posts the duration alongside the wrapped key. It lives here, as
`access-request.ts`'s header already explains for its siblings, because `@tinytars/vault`'s
`grantProvider` would need a release and a cross-repo step to learn one field.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renewing access is spelled as re-granting it: moving a window goes through
/api/providers/grant like any other consent decision, which means the browser wraps the DEK
again, which means it needs the provider's public key. The list route withheld it from every
link that was not pending — exactly the rows a renewal acts on — so Renew, Extend and Make
ongoing would all have returned early and done nothing visible, which is the same silent
failure the `role === "support"` gate produced before it.

The test that pinned the old behaviour is flipped rather than deleted, and its comment records
which half of its reasoning expired. "Nothing is wrapped for an active link" was true until
renewal existed. "The key would be a disclosure" was never the objection it read as: it is a
public key, and the patient wrapped their DEK to it already. Revoked links never reach the
loop, so nothing is handed out for access that has been withdrawn.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`packages/frame/vault-principals.svelte.ts:107` was one `$state(72)` serving every pending row
on the patient's screen AND the clinician console's roster approvals. Changing the second
row's select changed the first row's, silently, so the number an owner read beside one name
was the number another name was granted. It is replaced by a per-link map keyed by linkId,
exposed as `chosenHours` / `setChosenHours` / `chosenSnapshot`. A plain object and not the
`Map` it wants to be, because `Modal`'s dirty check is `JSON.stringify` and a `Map`
stringifies to `{}` — every unsent choice would read as no choice and close without warning.

`activeAccess` becomes the positive test it was always meant to be — `status === "active"`
and not past its end date — with `endedAccess` beside it. An ended grant stays on the screen:
an owner who gave a clinician thirty days should see that it lapsed and be able to renew,
where a row that simply disappeared would be indistinguishable from one they revoked.

`approve` sends the chosen window, routing a clinician through `grantProviderUntil`, and
`renew` re-expresses a grant with a new one. One method and not three: "Renew", "Extend 30
days" and "Make ongoing" are three labels over one act, because the route writes status,
expiry and consent in one statement.

The comment that said expiry is checked only on the support read path is corrected here, in
its sibling in App.svelte, and in the test that repeated it. All three were already false —
`getActiveProviderLink` has refused a lapsed link of any role since W71 — and believing them
is the reason nothing ever wrote an end date for a clinician.

The two `<select>`s in App.svelte are bridged onto the map rather than left broken; the
redesigned rows replace them in a later commit.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The privacy story was split across two modals that could not be open at the same
time. "Access" said who can read the record; "Account", four screens down, held
LexiTar's recovery key and the log of who actually had read it — and the sign-in
methods beside them used the same row markup, so a stored password rendered
identically to a human being with access to a medical record.

AccessPanel is three bands in the order the questions are asked: waiting on you,
who can read your record, who has read it. Every principal — the owner, a
clinician, a support agent, the recovery key, a grant that has lapsed — is one
PrincipalRow, read chip, name, one sentence, actions. The recovery key is a row
in band 2 rather than a settings block, carrying its own log in an expander
because the sentence above it promises every use is listed.

access-vocabulary.ts is the whole string layer, pure and now-parameterised, so
the test asserts every sentence without mounting anything — including that no
output carries "primary" or "owner", which is the defect this exists to fix.

The password stays in Account, where it belongs: there is nobody at the other
end of it. Its rows move off the people markup onto FormGrid.

Both owner panels now load the recovery state and the access events, not just
Account — a row moved without its load reads "never used" until the owner
happens to open the other modal. And the modal finally passes a snapshot, so
closing it with an unsent duration choice asks first.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A clinician's console printed `provider_links.role` at them too: a support
agent's pending ask read "owner · awaiting approval", which names the column for
the one reader who cannot know what it means. The dates beside it were raw
toLocaleString() — a timestamp to the second for a window measured in days, and
a different string from the one the other end of the same grant was shown.

The clinician console's support blocks and patient roster are now the same
PrincipalRow the patient page uses, with the same duration radios and the same
sentences, and the roster rows finally say when each grant ends — RosterPatient
carries expiresAt, which the route has been sending since the grant commit.

One window formatter serves both subjects, so a grant that ends on the 19th
cannot be a record ending on the 19th and a roster ending on the 20th; the test
asserts the two sentences are the same string. The duration option lists moved
into the vocabulary for the same reason: "30 days" meaning a different number of
hours on two surfaces is a bug nobody sees until a grant ends on the wrong day.

The app reads one clock that ticks each minute rather than one captured at boot,
because this tab stays open across midnight and these dates are read to the day.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"1 request to read your record" is wrong for half of what the badge counts: a
support agent asks a clinician for their roster, not to read a record, and the
same number covers both. What is true of every one of them is that somebody is
waiting on an answer.

The disclosure log had the same gap in the other direction — every visit was a
bare name, so a support agent's visit and a clinician's read were one line with
nothing to tell them apart. accessActorName now returns what the actor IS
alongside their name, and the log wears the chip the rest of the page uses. An
actor who cannot be resolved still gets their row and gets no chip: the one
thing worse than an unnamed actor on this page is a mislabelled one.

The three specs that pinned the old badge wording move with it.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelve e2e specs addressed the old access UI by its CSS classes — `.access-add`,
`.access-list`, `.access-revoke`, `.access-pending`, `.roster-list`, `.roster-name`,
`.roster-remove`. Those shapes are gone, so the specs move onto the explicit testids the
new components carry. Four locators that appeared in eleven places are one helper each in
`_synthetic.ts` (`patientRosterRows`, `patientRosterRow`, `clinicianRosterLoaded`,
`openPatientRecord`), so the next markup change is one edit rather than eleven.

Three couplings are kept on purpose: the literals "Add provider" and "Support agents with
roster access", the `access-request-badge` testid, and `support-provider-roster.spec.ts`'s
two `.roster-name` selectors — that spec addresses the *support* console, whose markup was
deliberately not converted.

Running them found a real defect the redesign introduced, and it is the reason this commit
touches `packages/frame`. `Modal.svelte` opens with `showModal()`, which puts the dialog in
the browser's TOP LAYER — above every z-index in the document, not merely above a large one
— while `anchoredMenu` portalled its panel to `<body>`. So inside a modal the panel rendered
*behind* it and every click was swallowed by the dialog's own content: Playwright read the
menuitem as "visible, enabled and stable" while `.leaf-card-head` "intercepts pointer
events", and retried for the full 30s. On the new access page that is not a test artifact —
Revoke access, Remove from this list, Report this account and Remove recovery key are all in
that menu, so a patient could not revoke anyone. The panel now hosts in
`anchor.closest("dialog") ?? document.body`; a dialog is not a containing block for
`position: fixed` without a transform/filter/will-change, and `.modal-panel` has none, so
placement and the escape from its `overflow-y: auto` are unchanged. Three unit cases pin the
host, which no test had asked about while `<body>` was the only answer.

One assertion of mine was wrong rather than the product: the patient's grant row was matched
with `/Ends \d/`, which holds in en-GB ("Ends 19 Nov") and fails in the en-US the browser
actually runs ("Ends Nov 19"). It now asserts the sentence and not the date's spelling.

frame 87 unit / 0 errors, app 4268 unit / 0 errors, svelte-check 1582 files 0 errors and
frame 435 files 0 errors, 30 e2e across the thirteen affected specs.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-authored-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One page that says who can read your record — and a clinician grant that ends
The clinician console led with the support agents holding roster access, so the
list a clinician opened the page for sat third, under a section about somebody
else. The order now matches the questions in the order they are asked: who is
waiting on me, who are my patients, who else can see them, and then the form
that adds one.

A pure move of the active-support block — fifteen lines, unchanged text, below
the patients list and above the Request access form. No spec selects on
position: the four e2e specs that touch these sections address them by test id
or by heading text, both of which travel with the block.

Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A clinician's own patients come first, then who else can see them
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Deploying health-dash-dev with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2c41777
Status: ✅  Deploy successful!
Preview URL: https://0e93ab43.health-dash-aex.pages.dev
Branch Preview URL: https://promote-auto-2026-10-22.health-dash-aex.pages.dev

View logs

@pedalion-ci
pedalion-ci Bot requested a review from pablo-tech as a code owner October 22, 2026 18:38
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying health-dash-main with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2c41777
Status: ✅  Deploy successful!
Preview URL: https://591a6201.health-dash-prod.pages.dev
Branch Preview URL: https://promote-auto-2026-10-22.health-dash-prod.pages.dev

View logs

@pablo-tech
pablo-tech merged commit 07336c0 into main Oct 24, 2026
17 checks passed
@pablo-tech
pablo-tech deleted the promote/auto-2026-10-22 branch October 24, 2026 09:26
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