Repository navigation
Promote dev to main (2026-10-27) - #27
Conversation
The release job bumped packages/frame/package.json and pushed that commit straight to `main`. Since 18 October `protect-main` refuses it - every landing is a pull request and the bypass list is empty - so the first promote after that, pull request #17 today, died at the push with GH013 and published nothing. Rescheduling or re-running cannot help: the branch is the obstacle. So stop writing to the branch. The next version is now the patch above the highest of the committed floor and everything npm already holds, which makes the registry the counter - and npm cannot take a version back, so it is the one record that cannot drift. The committed number stays put as a floor: apps/lexitar asks for ^0.1.5 and package-lock.json resolves that to packages/frame itself, so it only has to keep satisfying that range for the workspace link to hold. A tag therefore names the commit a version shipped from while that commit's tree still declares the previous number; the alternative is a person merging a pull request for every patch, and provenance already binds each tarball to its exact commit and run. Publishing now precedes tagging. The old order left frame-v0.1.54 through v0.1.57, cut 6-8 October, naming versions npm never received: `--provenance` refuses a private source repository and lexi was private for the re-key, so the tag was pushed and the publish then failed with E422. Those tags went with the republish, and the ordering is what stops the next set. Verified: the workflow parses and `release` is the only job that differs; the job contains no `git commit`, no `git add` and no push to a branch, and its one `git push` carries the tag; neither ruleset targets a tag, both being branch rulesets on main and dev. Run against the live registry, the step computes 0.1.58 from a 0.1.57 floor and a 0.1.53 registry, and 0.1.59 once 0.1.58 lands. Signed-off-by: Pablo Rodriguez <contact@promontory.studio> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Release from the registry's count, so nothing has to be pushed to main
Five routes write a `raw_objects` row for every object they store, and all five named the CALLER: chat-history/[id].ts twice, document-extract.ts, raw/[[path]].ts and regen-log/[id].ts. A clinician holding a live grant writes on the patient's behalf, so that row named the wrong account. Where it hurt is erasure. `erasure.ts` counts objects inside the subject's namespaces, finds one attributed to another account, and by design never deletes by prefix - two accounts can share a client slug, so a prefix delete would destroy a third party's only copy. So it reports the object and leaves it: a patient who asked to be erased got `complete: false` for ever, their plaintext PDF still in the bucket, with no route that could ever resolve it. `erasure.ts:160` names the shape; W75 built the detection and left the cause. `recordSubject(access, callerAccountId)` is the fix, one helper rather than five ternaries, and it reads `subjectAccountId` and NOT `ownerAccountId`: the latter is only the namespace's FIRST WRITER, which on a clinician-first namespace is the clinician - the very account the row must stop naming. Every other kind records the caller, who is the subject there. `claimNamespace` is left alone; its caller decides. It does not reach the first-writer case, where a clinician who claimed the namespace resolves as `owner` rather than `granted`. That is the gap `rawAccessFor`'s own comment already tracks, and it is now pinned by a test so the next reader meets it as a decision rather than as an oversight. Two files come from the production measurement that preceded this. The audit script is committed rather than discarded because a measurement nobody can re-run is an assertion: it groups `raw_objects` by namespace, prints account ids only, and `ORG_KEY_PASSPHRASE` and `decryptVault` appear nowhere in it, so it cannot decrypt. Against `health-identity-prod` it read 44 rows over 5 namespaces, 0 in no namespace, 0 namespaces with more than one attributed account - so no production row needs moving, which is what `INSERT OR IGNORE` could not have done anyway. namespace-key.ts is the parser that audit needed: two answers from one regex, because `orphaned-chat/` is deliberately absent from `namespacePrefixes` (the escape hatch's retained copy must be claimable by nobody) and deliberately present in `erasure.ts`'s NAMESPACED_PREFIXES - so an object can sit in a patient's namespace for erasure while belonging to no claimable namespace at all. Tests derive their expectations from the other side rather than restating them. The parser test reads owned directories off `namespacePrefixes`' own return value and the erased set out of `erasure.ts`'s source, so adding a prefix to either fails here at the commit that adds it, and asserts the erasure set stays strictly larger. The attribution test derives its own coverage from a parenthesis-balanced scan of the real `recordRawObject(` call sites, so a sixth write site cannot be added untested; it drives all five routes on a real D1 and R2, because a fake `raw_objects` can be made to agree with whatever the route writes, which is the failure being fixed. `recordSubject`'s unit test gives the granted branch different values for `ownerAccountId` and `subjectAccountId`, so a helper reading the wrong field cannot pass. The erasure suite gains the inverse it had no case for: a granted clinician's upload, made through the route, is deleted by the patient's erasure and the report reads `complete: true`. Its two existing cases stay as written and stay green - a genuinely foreign object still counts and still is not deleted. Verified. 4305 tests over 347 files pass on both backends, Miniflare and `TEST_BACKEND=node`; oxlint and tsc are clean. And proved to detect the defect rather than only to pass beside it: with the four route files reverse-applied and the helper left in place, 10 of the 18 attribution tests fail and the erasure inverse fails, while the controls and the helper's own unit tests pass either way. Signed-off-by: Pablo Rodriguez <contact@promontory.studio> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An in-app vault key rotation re-keys the vault blob and nothing else. It is reached
twice -- revoking an active support principal, and App's boot-time resume of a
deferred rotation -- and in both cases the selected client never changes and nothing
in that path touches `chat-{id}.enc`.
So the conversation stays encrypted under the key that was just discarded, and the
tab holding it is the last place its plaintext exists. `hydrate()`'s early return
(`if (!id || !key || chatLoadedForId === id) return;`) is what threw that copy away:
the effect re-ran with a new key and did nothing with it. Every later reader -- the
next session, a second tab -- then gets ciphertext it cannot open, chat-store latches
the client unreadable, and from then on every save of that conversation is refused
behind a banner. That is the fault that stranded seven histories in the 2026-10-06
and 2026-10-07 rotations.
W71 shipped the recovery for a blob already stranded, and that recovery stays exactly
as it is: `setAsideUnreadableHistory` and the `x-replace-unreadable` route are still
the only answer once the key is provably gone. This is the prevention, and the two do
not overlap.
THE FIX. `chatOpenedWithKey` records the DEK the conversation now in memory was
decrypted out of storage under. On a `hydrate()` for the same client with a different
key, the conversation is written back under the new key rather than returned from. It
deliberately does not re-read: the stored blob is under the discarded key, so a load
would only prove it unopenable and latch the store's refusal to write over it.
Only a conversation that CAME OUT OF STORAGE may be re-keyed. The marker is null
until a load actually restores one, so a placeholder thread can never overwrite a
blob this tab has not read -- the one way a prevention could cost more than the fault
it prevents. It is not `$state`: nothing renders from it, and a reactive read inside
the hydrate effect would make the effect depend on the value it writes.
TESTS, AT THE TWO LAYERS THAT ANSWER DIFFERENT QUESTIONS.
`tests/unit/chat-rekey-on-dek-change.test.ts` asks whether the conversation survives,
and asks it of the STORE rather than of the in-memory thread list: real WebCrypto, a
fake `/api/chat-history/{id}` with etag and 412 semantics, and a second module
instance standing in for the next reader, which holds only the new key. Its second
case pins the other direction -- a tab that never opened a stored conversation writes
nothing at all. Taken from #4, whose change this is; its base predates
`chatUnreadable` so the patch no longer applies, and its `zz-scratch-probe.test.ts`
(a `console.log` asserting `expect(true).toBe(true)`) is deliberately not here.
`tests/unit/chat-thread-session.test.ts` asks whether the DECISION is right, in the
three directions it can be got wrong: a changed key writes, an unrestored
conversation does not, and an unchanged key does not. Cheap against the module's
existing fake store, and it is where a future early return would be reintroduced.
`:67` -- load once per client id -- stays green, the same key object both times.
VERIFIED. 346 test files / 4273 tests pass on both backends (Miniflare 131s,
TEST_BACKEND=node 44s); `oxlint --deny-warnings` clean; `tsc --noEmit` exit 0.
Proved to detect the defect rather than only to pass beside it: with the source
change reverse-applied, the round-trip case fails at
`chat-rekey-on-dek-change.test.ts:106` -- the next reader's restored titles read
empty -- and passes with it. A rotation test that never saw the bug proves nothing.
`chatOpenedWithKey` appears nowhere on `origin/dev`, in `src` or in `tests`, so the
premise was re-measured against the live branch rather than taken from the plan.
Replaces #4.
Signed-off-by: Pablo Rodriguez <contact@promontory.studio>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Record an object against whose record it is, not against who wrote it
Re-key the open conversation when the DEK moves, instead of stranding it
Bumps the actions group with 4 updates: [actions/setup-node](https://github.com/actions/setup-node), [actions/upload-artifact](https://github.com/actions/upload-artifact), [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action). Updates `actions/setup-node` from 7.0.0 to 7.1.0 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](actions/setup-node@8207627...949feb2) Updates `actions/upload-artifact` from 7.0.1 to 7.0.2 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](actions/upload-artifact@043fb46...cf430e0) Updates `github/codeql-action/init` from 4.38.2 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](github/codeql-action@2892aa5...9b32540) Updates `github/codeql-action/analyze` from 4.38.2 to 4.39.1 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](github/codeql-action@2892aa5...9b32540) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: github/codeql-action/init dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: github/codeql-action/analyze dependency-version: 4.39.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* Take #21 and #22 together, through three breaking migrations Dependabot opened the SDK major and the minor-and-patch batch as two pull requests, and neither could land on its own: #22 failed `npm ci` at install because its lockfile predates the override this change removes, and #21 passed `test` while failing the app's type-check in twenty places. Both are the same work seen twice, so they are taken as one. Three migrations are bundled inside them, each established from the installed packages rather than inferred: @anthropic-ai/sdk 0.127.0 -> 1.3.0 admitted a system-role turn into `MessageParam`, made `Usage`'s counts optional, and removed `temperature` and `top_p` outright. akesi 0.1.70 moved the domain-free half of its benchmark harness out to @promontory-studio/dokimasia and declares no SDK peer at all. So the app names akesi's port where it talks to the port, which it already did in three files and now does in nine. `src/lib/model-port.ts` is the one home for the two statements this app makes beyond what the port states: a request's turns are user and assistant only, which is narrowing to what is actually sent rather than widening the port — `validateMessages` refuses a system turn from the browser and the system prompt rides in the request's own `system` field — and a stream event's text delta, read in one place by both the streaming route and the adapter test. The nullable token counts collapse to `tokenUsage` in `functions/_lib/log.ts`, beside the non-nullable type it feeds and following that file's own rule about `latencyMs`: an omitted count is honest, a 0 is a measurement that never happened. `leaf-regen-anthropic.ts` is the one exception and says why — an accumulator cannot omit, so it adds nothing and that is what the provider reported. Three dependency decisions, none of them a bump: - The root `overrides` pin on @anthropic-ai/sdk is removed. `tsconfig.app.json` recorded why it existed — to satisfy akesi's optional SDK peer range, which made npm lay down a second copy that TS treats as a distinct nominal type — and akesi 0.1.70 declares no such peer, so the accommodation has no subject. Measured after removal: one copy, at 1.3.0. Both tsconfigs' `paths` entries existed only to undo it and go with it. - @promontory-studio/dokimasia ^0.3.4 is added as a devDependency of apps/lexitar, because akesi moved the probe loop, the budget, the scoring and the statistics there and consumes it as an optional peer. - svelte-check is held at ^4.4.8 rather than bumped to 4.7.7. No published version accepts this repo's TypeScript: 4.4.8 declares `typescript >=5.0.0`, 4.7.7 declares `^5.0.0 || ^6.0.0`, and the repo pins ~7.0.2. Gates: `check` clean on both halves, 4310 tests green under Miniflare and again under the filesystem backend, `lint` clean, and packages/frame's own typecheck and 87 tests green. Signed-off-by: Pablo Rodriguez <contact@promontory.studio> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Declare the coverage provider at the root, where vitest can resolve it The lexitar job failed on `npm run coverage` with Cannot find package '@vitest/coverage-v8' imported from node_modules/vitest/dist/chunks/coverage.cTDc4uSS.js and the cause is a placement, not a version. vitest loads the provider by a dynamic import from its own installed location, so Node walks up from node_modules/vitest/ — and a copy at apps/lexitar/node_modules/ is not on that path however the command is invoked. Running it from the workspace directory makes no difference, which is why this reproduced locally with the same message. coverage-v8 5.0.1 happened to hoist because its obug range matched what @sveltejs/vite-plugin-svelte already pulled to the root; 5.0.5 needs obug 3 and so takes a private copy, which drags the provider down with it. Declaring the provider where its consumer lives decides the placement outright instead of leaving it to a transitive range — the app still has no direct import of it, and `vitest run --coverage` in the workspace resolves it from the root as designed. Measured on a clean install: 348 files / 4310 tests pass under coverage, and the same suite passes on both backends; svelte-check reads 0 errors over 1719 files and packages/frame 0 over 436. Signed-off-by: Pablo Rodriguez <contact@promontory.studio> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(lexitar): name the chart toggle for what it expands, and record axe 4.14's new rule The dependency bumps moved axe-core 4.13.0 -> 4.14.0, and the markers view went from 1 violation to 2 on markup this branch does not touch. An A/B on one unchanged tree settles that: 4.13.0 reads color-contrast x48 and nothing else, 4.14.0 reads color-contrast x41 plus label-content-name-mismatch x7. The seven nodes are the .mc-figure-toggle buttons, and chasing the rule found a real defect underneath it that no engine reported. The button's bare aria-label REPLACED the inner <svg role="img">'s own for name computation, so the marker's latest value, status and date were announced nowhere: a screen-reader user on the Markers page heard "Expand details for ApoB, button" and no reading. chartLabel is now part of the button's name, which is the fix worth having. It does not clear the rule, and nothing written there could. A live DOM read of the first toggle returns the plot's axis ticks - "Jan", "Apr", "60.0", "90.0" - as the button's visible text, with axe still reporting the mismatch even under role="presentation" aria-hidden="true", so WCAG 2.5.3 as 4.14 implements it asks a chart-inside-a-button's accessible name to contain its tick marks. Marking the svg presentational was measured (still 7) and reverted, having bought nothing. So the markers notch goes 1 -> 2 with that measurement written beside it, which is the escape the spec's own docblock provides and the same posture it already takes toward color-contrast x41. Signed-off-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>
…a guide (#26) Three things the Google Ad Grant rests on, in one change. **The phrase.** `apps/BRANDING.md` — the copy manual for every LexiTar surface, in the websites repository — forbids "decision support", and the app said it twice: the About overlay opened "This is a decision-support system that analyzes one patient's ... data", and the print footer of every page a user takes to an appointment read "LexiTar-Generated decision support, not a diagnosis". Both are also the FDA's own term of art for the category `LIABILITY.md` argues this tool sits outside of, so the app was describing itself as the one thing its liability analysis says it is not. The opening sentence additionally failed the manual's criterion-2 diagnostic boundary ("analyzes your") and is rewritten with its §4 lexicon — compile, translate, map, reference. "patient" and "clinical" are avoided in the replacement and nowhere else: both stay wherever they describe the data model or quote a regulation. What they do on a screen is invite a skimming reviewer to file the Foundation as a hospital, which Google for Nonprofits excludes — an eligibility matter rather than a style one. **The guide.** `DPGA.md` §5 carried "No end-user guide. Nothing explains, for a patient, how to import a report, read a translation, or share with a clinician" as a DPG Standard gap, and nothing in the repository or the product closed it. Four plain-language steps now sit in the About overlay — bring your record in, see what changed, ask in your own words, walk in prepared — each naming the surface it refers to, and each claim checked against the code rather than written from the feature list: Import's accepted types are capability-gated, so the step says the pane names what this deployment can read. The overlay is the guide's one home, because the user is its audience and it is the surface they already open. `DPGA.md` 5.3 moves ❌ → 🟡 and names what an assessor would still ask for: sharing with a clinician is still unexplained, and a guide living in a Svelte component is one a repository-only reviewer reads as source. **Two ranges.** Found while retiring the phrase, and the same category of claim: two rendered section blurbs said "your healthy ranges" and "your personal healthy range". §4 forbids exactly that — a range is a demographic reference, never a statement of what is healthy *for you*, which is a judgement only a physician makes. The arithmetic is untouched; `PersonalizedRange` still adjusts for age, gender, height and recorded conditions. What moved is the claim. **Three documents deleted.** `FEATURES.md`, `PASSPORT.md` and `TRAVEL.md` are in `finca-madre/corporate-vault` at `tars-501c3/grant/`, and the deletion was gated on proving it rather than asserting it: all three blobs are byte-identical to the vault's `origin/main` copies (`02e4626aa728`, `7283f8586636`, `7aae629b4772`). The last two are a priced commercial travel product and its channel strategy, which do not belong in a public repository under a 501(c)(3)-owned app folder and are out of scope for a grant that may not advertise a commercial product. No file in this repository links to any of the three — measured, not assumed. `LIABILITY.md`'s travel-product comparison is self-contained and unaffected. Measured: `npm run check` 1719 files / 0 errors; `oxlint` clean; `npm test` 4310 passed (348 files); `npm run build` clean; and the one e2e that asserts the overlay passes against a real browser with the assertion inverted — it used to require "decision-support system" and now requires "health literacy tool" and forbids /decision[ -]support/i, so it breaks when the positioning regresses rather than when the copy is edited. Two occurrences survive in the built bundle and neither is this repository's: `@pablotech/akesi`'s model prompt tells the model "your output is decision support, not a diagnosis" and lists a "Decision Support" input section. Prompt text is not rendered copy, so no reviewer reads it — but the system prompt and the disclaimer now disagree, which is a finding for `pilos` and not a change that can be made here. 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>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying health-dash-dev with
|
| Latest commit: |
61eae31
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4df9b29a.health-dash-aex.pages.dev |
| Branch Preview URL: | https://promote-auto-2026-10-27.health-dash-aex.pages.dev |
Deploying health-dash-main with
|
| Latest commit: |
61eae31
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://b8110255.health-dash-prod.pages.dev |
| Branch Preview URL: | https://promote-auto-2026-10-27.health-dash-prod.pages.dev |
There was a problem hiding this comment.
Checked the promote batch end to end: recordSubject attribution at all five raw_objects write sites (granted → subject, every other kind → caller, with the first-writer gap documented and pinned), the two namespace-key parsers against raw-owner's namespacePrefixes and erasure's NAMESPACED_PREFIXES, the DEK-rotation re-key path in chat-thread-session (marker only set from a restored load, persist uses the new key), the akesi 0.1.70 / SDK 1.x port shims (ModelTurns, textDelta, nullable tokenUsage) against every input_tokens/output_tokens reader, the dokimasia split and the tsconfig paths removal, and the release job's publish-then-tag ordering and registry-derived version floor. No blocking issues; one non-blocking note: a rotation landing while the initial loadThreads is still in flight leaves chatOpenedWithKey holding the discarded key with no further trigger, so that narrow window is still only recovered by the next ordinary save.
Automatic promotion:
devhas soaked 24h, CI is green on its tip, and no error report names a build in this range.apps/lexitar/wrangler.jsoncis main's, unchanged.Label this pull request
holdto stop it merging.🤖 Generated with Claude Code