# YourPHR on ngdpbase — direction > __Lead document: the source of truth for YourPHR's direction relative to ngdpbase.__ The direction, the decisions, the constraints and the plan live here. Issues, other planning documents and AGENTS.md link here rather than restate it; a document covering one part links back until that part is finished. When a decision changes, it changes here first. > > __Last updated 2026-10-16.__ Below the direction sections is the analysis record (2026-08-30 onward) that the direction rests on. Its superseded claims are struck through with dated notes, not deleted. ## Direction __YourPHR is built as ngdpbase add-ons, under ngdpbase's one add-on model__ ([ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810)), decided 2026-10-15. The first add-on is `phr` (npm `@yourphr/phr-addon`). Its code lives in this repo under `addons/`, it's published compiled to npm from GitHub Actions, and a plain ngdpbase site turns it on by config. YourPHR stops copying ngdpbase managers by hand. Copying is what makes every fix land twice: the overlap table was closed by six ports in three weeks, and each copy has drifted since (*Where it stands, 2026-10-14*). Anything phr needs that core doesn't offer is a PR to ngdpbase. [#920](https://github.com/jwilleke/yourphr/issues/920) now measures the cost and the order of the move, not whether to make it. Going live with real records is still gated (*Open*). Until cutover, the standing rule from [#697](https://github.com/jwilleke/yourphr/issues/697) applies: __default to ngdpbase__ (AGENTS.md § *TypeScript stack: DEFAULT TO ngdpbase*). __Where it stands, 2026-10-16:__ the `phr` skeleton runs inside a plain ngdpbase v5.10.0 on localhost, with test data only. It takes core's tools through `register(engine, config, host)` ([ngdpbase#1813](https://github.com/jwilleke/ngdpbase/issues/1813)), has one page showing who is signed in, and holds no records yet (branch `yourphr-addon`). ## Product direction (proposed 2026-10-15) > __Status: proposed, awaiting Jim's acceptance.__ This section is about *what yourPHR is for*. The sections around it are about *how it's built* (on ngdpbase). The two are independent. __yourPHR makes sense of the records you already have.__ Its main promise becomes *"give it whatever records you have, and get one understandable picture of your health back."* That works on day one, with no vendor's cooperation. Live connections (Epic, Oracle, NextGen and others) become one more source feeding the same picture, not the foundation. Why, from the operator's own records (2026-10-14/15, structure only, no contents read): - __A personal archive kept over years:__ 646 files, of which about 2 are in an exchange format. The rest are PDFs (1,107 pages, 34 scanned), a hospital CD of DICOM images, an old CCR, and hand-kept notes and spreadsheets. - __The hospital group's EHI Export__ ("Export All My Data", FollowMyHealth): 6,634 FHIR records, 84% of them document entries, covering about the last 8 years from one organization. Some dates are unusable (a 2019 migration date on 5,893 records; year 0001 on 4). - __A 76-year lifetime across several places__, while portals and networks reach about the last 10 years. Dentists and some specialists hardly take part. Old records are destroyed once retention runs out. For most people, the only lifetime health record is the one they keep themselves. A week of vendor-by-vendor registration work (Epic's 522 per-organization activations, FollowMyHealth's `unauthorized_client`, MEDITECH's per-hospital vetting) only confirms it: connections can't be the foundation. __Four inputs, one timeline:__ | Source | Covers | |---|---| | Your memory: a guided lifetime history, patient-reported | The whole lifetime, roughly | | Your folder: PDFs, scans, CDs, portal exports | The years you kept, in detail | | Live connections and network access | The last ~10 years, structured | | Requests to past providers | Whatever still exists | __Principles:__ - __Nothing guessed:__ facts only where they're stated, each linked to its file and page. - __Nothing silently merged:__ duplicates are grouped, originals kept, near-matches confirmed by the patient. - __Extracted facts are labelled as extracted__ (FHIR Provenance). - __Shared the standard ways:__ FHIR export, IPS, SMART Health Links, print. Tracked as epic [#935](https://github.com/jwilleke/yourphr/issues/935): [#737](https://github.com/jwilleke/yourphr/issues/737) (PDF/DICOM/image upload), [#936](https://github.com/jwilleke/yourphr/issues/936) (OCR), [#937](https://github.com/jwilleke/yourphr/issues/937) (fact extraction), [#938](https://github.com/jwilleke/yourphr/issues/938) (duplicates), [#939](https://github.com/jwilleke/yourphr/issues/939) (refreshable folder source), [#940](https://github.com/jwilleke/yourphr/issues/940) (trends), [#941](https://github.com/jwilleke/yourphr/issues/941) (CCR), [#942](https://github.com/jwilleke/yourphr/issues/942) (imaging studies), [#943](https://github.com/jwilleke/yourphr/issues/943) (lifetime history), [#944](https://github.com/jwilleke/yourphr/issues/944) (records request kit), [#945](https://github.com/jwilleke/yourphr/issues/945) (sharing). Reaching providers at scale is still worth doing, but behind this. See [`../EHR-vendors/registration-comparison.md`](../EHR-vendors/registration-comparison.md) for the vendor-by-vendor routes, and [`../health-Interoperability-Frameworks.md`](../health-Interoperability-Frameworks.md) and [`../TEFCA.md`](../TEFCA.md) for networks. ## Constraints - __The project stays at yourphr.org and in `jwilleke/yourphr`.__ The domain is paid for and the GitHub name is owned. The add-ons follow ngdpbase's one add-on model ([ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810)): their code lives in this repo under `addons/` and is published compiled; yourphr.org runs a plain ngdpbase clone or image whose config names the yourPHR add-ons with a version range. There's no yourPHR Dockerfile and no site image (this supersedes the earlier geohazardwatch-style Dockerfile). Never fold YourPHR into the ngdpbase repo or serve it from an ngdpbase-branded host. - __Add-ons are optional, by configuration.__ `ngdpbase.addons.phr.enabled` defaults to false; yourphr.org's own configuration turns it on. The add-on fails soft when a dependency is missing, so each deploy needs a smoke check proving it is on. - __PHI stays in the add-on's encrypted database__ (`phr.db`), behind `PhrManager`, and never in ngdpbase pages. PHR files (PDFs, DICOM, scans) live in one PHR vault per person (2026-10-15). A missing database key must refuse to start, not open unencrypted. - __Nothing new is named "spike".__ ## Decisions | Date | Decision | Recorded | |---|---|---| | 2026-10-13 | Default to ngdpbase: take its shape as built, file improvements, diverge only where the architecture-principles document names it. The add-on question stays open. | [#697](https://github.com/jwilleke/yourphr/issues/697), AGENTS.md | | 2026-10-14 | yourphr.org and `jwilleke/yourphr` remain the home under every option. | Constraints | | 2026-10-14 | Measure, don't argue: one Sources-page slice as an add-on, on branch `yourphr-addon`, one-week time-box. | [#920](https://github.com/jwilleke/yourphr/issues/920) | | 2026-10-14 | No new ngdpbase manager is ported into `src/framework/` until #920 reports. | [#920](https://github.com/jwilleke/yourphr/issues/920) | | 2026-10-14 | Angular keeps the patient record screens. ngdpbase takes sign-in and MFA, OIDC consent, `/profile`, `/admin`, help and notifications. Our ~15 chrome components and `UsersManager`, `SessionsManager`, `AgentTokensManager`, `SettingsManager` are deleted at cutover. | [#920](https://github.com/jwilleke/yourphr/issues/920#issuecomment-6174353470) | | 2026-10-14 | Auth and chrome issues on our side are parked until #920 reports; [#814](https://github.com/jwilleke/yourphr/issues/814) is judged on its own as a possible live exposure. Patient-domain work continues. | [#920](https://github.com/jwilleke/yourphr/issues/920#issuecomment-6174353470) | | 2026-10-14 | A "go" requires ngdpbase [#1723](https://github.com/jwilleke/ngdpbase/issues/1723) (database backup), ported from current yourphr `main`. | blocked-by on [#920](https://github.com/jwilleke/yourphr/issues/920) | | 2026-10-14 | Cutover follows the Go move's rules: rehearse the swap, not only the code; household instance first, demo second; migration tooling in the image; stop rule — copied and add-on code coexist one release cycle at most. | [#920](https://github.com/jwilleke/yourphr/issues/920#issuecomment-6174419416), [`getting-off-go.md`](../archive/getting-off-go.md) | | 2026-10-15 | __Proposed, not yet accepted:__ yourPHR's main promise becomes making sense of the records you already have; connections become one source among four. See *Product direction*. | epic [#935](https://github.com/jwilleke/yourphr/issues/935) | | 2026-10-15 | __Simpler goal first:__ health records stay in the shared records database under the instance key. The operator is trusted; records are encrypted against disk and backup theft, not against the operator. Per-person encryption (a `phr` private store, or per-person database keys) is "maybe later" (ngdpbase#1811, #1812, deferred). Document upload ([#737](https://github.com/jwilleke/yourphr/issues/737)) is __not__ built in the current stack: it's platform work that ngdpbase does anyway, so it waits on [#920](https://github.com/jwilleke/yourphr/issues/920). Next step: the #920 Sources slice. | [#935](https://github.com/jwilleke/yourphr/issues/935#issuecomment-6188832791) | | 2026-10-15 | __yourPHR is built as ngdpbase add-ons, under ngdpbase's one add-on model__ ([ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810)). The code lives in `yourphr/addons/`. Development is drop-in, into a local plain ngdpbase clone (`ngdpbase-yourphr/`) with synthetic data. yourphr.org is a plain ngdpbase deployment that enables the add-ons by config, with no site Dockerfile. | [#920](https://github.com/jwilleke/yourphr/issues/920), [ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810) | | 2026-10-15 | __The add-on's slug is `phr`.__ It sets the config keys (`ngdpbase.addons.phr.*`), URL paths, store name and database table prefix (`phr_`). | this document | | 2026-10-15 | __No new Angular screens.__ ngdpbase's layout (left menu) and terminology are preferred over yourPHR's Angular screens, which are inconsistent in layout and wording. New patient-facing UI is built as ngdpbase pages in the `phr` add-on (Chart.js for charts, plain JS where needed). Existing Angular gets fixes only until cutover. This supersedes the 2026-10-14 "Angular keeps the patient screens" row; dropping Angular entirely is the direction, not yet final. | operator, 2026-10-15 | | 2026-10-15 | __Add-ons are published to npm only from GitHub Actions__, with npm trusted publishing and provenance: no stored npm token, each package linked to one repo and workflow, releases by version tag, and every release carrying a signed record of repo and commit that ngdpbase can check before installing. The scope and install pinning were decided the same day (below). | [ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810) | | 2026-10-15 | __yourPHR's add-ons publish under the npm scope `@yourphr`__ (`@yourphr/phr-addon`). This is yourPHR's choice only; other projects pick their own scope. The free npm organisation `yourphr` was created by the operator on 2026-10-15, under the `jswilleke` account. | [ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810) | | 2026-10-15 | __Add-on installs are pinned.__ ngdpbase records the exact version and checksum at install and reinstalls exactly that. A newer version arrives only when an administrator presses Update (proposed: on Admin → Add-ons, with a notice when one is available, audited, and checked against provenance and checksum before install). | [ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810) | | 2026-10-15 | __Add-ons get core's tools from core__ ([ngdpbase#1813](https://github.com/jwilleke/ngdpbase/issues/1813)): ngdpbase imports the add-on package and hands it what core offers, `register(engine, config, host)`. The add-on never imports ngdpbase, and anything core doesn't offer is a PR to ngdpbase. `phr` is written against a local stand-in of that shape. | [ngdpbase#1813](https://github.com/jwilleke/ngdpbase/issues/1813) | | 2026-10-15 | __A registry outage never stops a site.__ A restart uses the pinned installed copy and doesn't touch npm. An Update with npm down stays on the current version and tells the admin. A first install with npm down starts the site without that add-on and tells the admin. Installed add-on copies are part of the site backup, so a restore needs no registry. | [ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810) | | 2026-10-15 | __Access to records goes through one door, and core decides.__ `PhrManager` is the only PEP (policy enforcement point) for records: routes, page plugins, sync jobs, imports, search and export all go through it, and nothing else in `phr` opens `phr.db` (opened by core's `DatabaseManager.openAddonDatabase('phr')`) or writes SQL; a lint keeps it that way. Every table has a required `owner` column. For each request `PhrManager` reads the owner from storage itself, never from the caller, URL or browser, and sends owner plus the request context (who is asking, acting for whom by proxy grant or job) to core's PIP/PDP. Core decides: the owner rule first ("the owner or their delegate, never a role"), then policy (`phr-read`, `phr-write`, …). `PhrManager` enforces and logs every read and write, allowed or refused. Lists and searches are decided once per owner, then queried with that owner filled in by the door. Needs from core: extend the owner rule (today private pages and files only) to any resource whose request carries an owner; the parallel write-side fix for pages is [ngdpbase#1815](https://github.com/jwilleke/ngdpbase/issues/1815). | operator, 2026-10-15 | | 2026-10-15 | __Owner vs custodian.__ The owner of a record is the person it is about (Alice owns her PHR records). The owner is read from the stored row, never from the request. `phr` is the custodian: it stores and guards (it's the PEP), holds no rights of its own, and never decides. The default is deny. | [ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431#issuecomment-6193262203) | | 2026-10-15 | __PHR files live in one PHR vault per person.__ PDFs, DICOM images and scans are files, not attachments and not database rows. Everything in the vault is sensitive, with no separate vaults by sensitivity. Vault access is core's existing vault sharing and access control, with nothing PHR-specific. The `phr-record` describing a document (FHIR `DocumentReference`) points at its file. | [ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431#issuecomment-6193262203) | | 2026-10-15 | __Core's decisions are explicit.__ The PDP answers `allow` (with a list filter as structured data, never SQL) or `deny` (with a reason), never empty. An undecidable request throws and fails closed. A refused list is a refusal, not zero rows. Conditions (subject vs resource) and obligations (e.g. emergency access: a stated reason, notify the owner) are defined in policy or configuration, read through `ConfigurationManager`, never in code. Every decision, allow and deny, is audited. | [ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431#issuecomment-6193262203) | | 2026-10-15 | __Declared through existing keys.__ `phr` declares its resource type `phr-record` in its `config/default-config.json` under `ngdpbase.access.resource-types`, with `attributes` (`owner` is reserved; `category` and others as needed). Its actions are permissions (`ngdpbase.permissions.definitions`); its rules are policies (`ngdpbase.access.policies`). No new keys. | [ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431#issuecomment-6193262203) | | 2026-10-15 | ~~__Alice sees who accessed her PHR:__ a PHR access log covering her `phr-record`s and her PHR vault files, listing everyone allowed access (herself, proxies, shares, jobs, emergency access). Denials are audited but not shown to her.~~ Superseded 2026-10-16 (next row). | [ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431#issuecomment-6193262203) | | 2026-10-16 | __A person's access view is a core function, rendered from core's audit log.__ It isn't a second log, and it isn't PHR-only: it covers everything the person owns (pages, vaults, attachments, any add-on's records). Reading it goes through the PDP, so a person sees entries about their own data and nobody else's. Each add-on supplies the plain-language description of its own resources ("your lab results from Licking Memorial"). Two views, with a toggle: __Normal__ (the default) lists __everyone who ever read or changed her data__ other than herself, including proxies, shares, clinicians, emergency access, administrators and system jobs, even where she can't do anything about it, plus refused attempts by other people; it hides only her own actions and her own refusals. __All__ shows every entry. Nothing is deleted from the audit log either way. | operator, 2026-10-16 | | 2026-10-16 | __Each person turns health records on for themselves, off by default.__ `phr` adds a "Health records" section to `/profile` (an add-on profile section, ngdpbase#534). While it's off, the person sees no PHR menu entries or pages and has no PHR vault. Turning it on shows phr to them; their vault is created when they first add a record or file. The site-wide category and vault declaration (`ngdpbase.system-category.phr` in phr's `config/default-config.json`, [ngdpbase#1559](https://github.com/jwilleke/ngdpbase/issues/1559)) stays the same for everyone, including `encrypt`, which is fixed when each person's vault is created. | operator, 2026-10-16 | | 2026-10-17 | __phr declares its database; no host tool.__ phr declares `phr.db` in its own `config/default-config.json` and gets it through `engine.getManager('DatabaseManager')`, passing its migrations (code). `PhrManager` alone controls access. [ngdpbase#1824](https://github.com/jwilleke/ngdpbase/issues/1824) (host tool) is closed in favour of [ngdpbase#1860](https://github.com/jwilleke/ngdpbase/issues/1860). __`DatabaseManager` owns all database backups, add-ons' included; phr writes no backup code__ ([ngdpbase#1723](https://github.com/jwilleke/ngdpbase/issues/1723)). Details: [`phr-database-plan.md`](phr-database-plan.md). | operator, 2026-10-17 | | 2026-10-17 | __All of jwilleke's add-ons are public__ (public repos, public npm packages), and phr is GPL-3.0-only like the rest of yourPHR. __How add-ons publish is worked out jointly__ by the yourPHR, ngdpbase and accounting sessions, for the operator's review; ngdpbase v5.14.0's installer ([ngdpbase#1830](https://github.com/jwilleke/ngdpbase/issues/1830)) accepts only packages with npm provenance from their own repository. | operator, 2026-10-17 | | 2026-10-17 | __ngdpbase publishes a types package; add-ons use it instead of their own copies.__ Its TypeScript type declarations for the add-on API (host tools, managers an add-on uses) are published to npm with each release, through the same publish workflow. Add-ons depend on it as a development dependency pinned to their `minVersion`; it never ships to sites. phr deletes the shapes it declares itself in `addons/phr/host.ts`, and accounting drops its `../../dist` type imports. Why: one definition, so a change in core makes every add-on's build agree or fail loudly, instead of private copies drifting (the usual practice: VS Code's `@types/vscode`, Obsidian's `obsidian` package). | operator, 2026-10-17 | | 2026-10-17 | __The add-on publishing process is approved__ ([`addon-publish-plan.md`](addon-publish-plan.md), [ngdpbase#1863](https://github.com/jwilleke/ngdpbase/issues/1863)): an ngdpbase-generated `publish.yml` in each add-on repo, publishing on `-v` tags with npm provenance and no stored token; the operator does a one-time npm bootstrap per package. The types package is `@jwilleke/ngdpbase-types` ([ngdpbase#1865](https://github.com/jwilleke/ngdpbase/issues/1865)). | operator, 2026-10-17 | | 2026-10-18 | __Records under the site key, files sealed per person (D2 of [`phr-database-plan.md`](phr-database-plan.md)).__ `phr.db` is encrypted with the site's one database key: it protects a stolen disk or a backup without the `.env`, not against the operator or anyone with shell access, who can read every person's records. Each person's files are sealed in their own vault. Per-person sealing of records ([ngdpbase#1812](https://github.com/jwilleke/ngdpbase/issues/1812), [#1811](https://github.com/jwilleke/ngdpbase/issues/1811)) comes later, before yourphr.org hosts other people's records. This confirms the 2026-10-15 "simpler goal first" row. | operator, 2026-10-18 | | 2026-10-18 | __Add-ons keep step-up off for now.__ No phr permission is marked `"step-up": true`: a form loses its fields across the fresh sign-in step-up asks for. Core's own step-up window goes from 15 to 60 minutes. | operator, 2026-10-18 | | 2026-10-18 | __A site-critical add-on that can't load takes the site into maintenance, in core (D3, [#948](https://github.com/jwilleke/yourphr/issues/948), [ngdpbase#1883](https://github.com/jwilleke/ngdpbase/issues/1883)).__ `site-critical` is an add-on configuration entry (`ngdpbase.addons..site-critical: true`), and phr sets it. If a site-critical add-on fails to load for any reason (no database key, a failed migration, an integrity check), the whole site starts in maintenance mode: visitors see "we're having temporary issues", and only admins can sign in. Any add-on that fails to load, site-critical or not, sends admins a critical notification with the details. phr's database also declares `encrypt: "required"`. | operator, 2026-10-18 | | 2026-10-19 | __Records come in by upload, through ngdpbase's own upload; no watched folder for now__ ([#939](https://github.com/jwilleke/yourphr/issues/939)). A person uploads single files (FHIR, C-CDA, PDF, images…), a zip, or a folder picked in the browser onto a page in their PHR vault; core stores each file there, encrypted, and phr imports from it. Files phr can't read yet are kept and listed. A server-side watched folder is not built for now. | operator, 2026-10-19 | | 2026-10-15 | __Anyone with records has an account.__ There are no dependent or managed accounts: a parent with records has their own account and shares it by proxy access. Sharing and survivorship are covered by earlier work ([ngdpbase#1762](https://github.com/jwilleke/ngdpbase/issues/1762), [ngdpbase#1545](https://github.com/jwilleke/ngdpbase/issues/1545)). | [ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431#issuecomment-6193262203) | | 2026-10-15 | __Thresholds and other values used in conditions are configuration keys__, merged and read by `ConfigurationManager`, so each site chooses its own. | operator, 2026-10-16 | | 2026-10-16 | __For the move to ngdpbase: vendor APIs paused, patient downloads first.__ `phr`'s first record source is the files a patient downloads themselves: FHIR zips (EHI Export, "Export All My Data"), C-CDA documents, and Medicare/plan Blue Button text and PDF reports, dropped into a refreshable "my records folder" ([#939](https://github.com/jwilleke/yourphr/issues/939)). That route needs no vendor's approval. Live connections (Epic, Oracle, Medicare Blue Button API and others) wait until after it, and the CMS production demo is on hold. Why: every API route ran into a gate this week (Epic's per-organization activation, CMS's review, Medicare Advantage claims held by the plan), while the operator's own downloads have worked for years. | operator, 2026-10-16 | | 2026-10-21 | __C-CDA is converted inside `phr`, with Medplum's library (`@medplum/ccda`), on trial.__ No converter service in the cluster ("we can not rely on a cluster based item"), so yourPHR's sidecar and its named-internal-origin exception don't carry over. The Patient id is derived as yourPHR derives it. Gaps found on real files (older CCD 1.0 medications, discharge medications, values Medplum fills in that the document doesn't state) are on [#939](https://github.com/jwilleke/yourphr/issues/939). | [#939](https://github.com/jwilleke/yourphr/issues/939) | | 2026-10-22 | __Import goes through core's one engine, ICE__ (DirXML model): core unzips, fingerprints, skips unchanged files, reports per file and keeps the original. `phr` supplies FHIR and C-CDA shims that hand records to `PhrManager`, whose own rule decides. phr's temporary engine (`recordsFolder.ts`, `phr_source_files`) goes when ICE ships. | [ngdpbase#1979](https://github.com/jwilleke/ngdpbase/issues/1979), [#939](https://github.com/jwilleke/yourphr/issues/939) | | 2026-10-22 | __One audit store, never cleared by anyone__ (only the retention cutoff removes entries). A patient's access view is that log filtered to entries about them. This reverses the separate, patient-trimmable access log ([#840](https://github.com/jwilleke/yourphr/issues/840), [#507](https://github.com/jwilleke/yourphr/issues/507)). | [ngdpbase#1537](https://github.com/jwilleke/ngdpbase/issues/1537), [#840](https://github.com/jwilleke/yourphr/issues/840) | | 2026-10-22 | __Consent through core's ConsentManager.__ `phr` ships its own `phr-terms-of-service` and `phr-privacy-policy` pages (front matter `version:` and `effective:`) and registers them. A person sees and accepts them before their phr switch turns on, and again when phr raises the version. Not built yet. | [ngdpbase#1603](https://github.com/jwilleke/ngdpbase/issues/1603) | | 2026-10-22 | __Known devices for sign-in__ use a device cookie. A person is asked again on a new device, after a country change, or after 90 days. Core's; not built yet. | [ngdpbase#1532](https://github.com/jwilleke/ngdpbase/issues/1532) | ## Plan 1. __Done (2026-10-15):__ the `phr` skeleton runs inside a plain ngdpbase v5.10.0 on localhost, using core's host tools. It has one page and the `whoami` route, and no records. 2. __Core work phr waits on__, filed with ngdpbase: - __Access control for add-on records__ ([ngdpbase#1823](https://github.com/jwilleke/ngdpbase/issues/1823), from the [ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431#issuecomment-6193262203) decisions): add-ons ask the PDP through `host`; resource types with attributes and the `owner` rule; list filters; conditions and obligations. Blocks `PhrManager`. - __Declared add-on databases__ ([ngdpbase#1860](https://github.com/jwilleke/ngdpbase/issues/1860), replacing the host-tool idea [#1824](https://github.com/jwilleke/ngdpbase/issues/1824), closed): phr declares `phr.db` in its config and gets it through `engine.getManager('DatabaseManager')`; core refuses undeclared owners and an unencrypted "required" database. Blocks `phr.db`. Plan: [`phr-database-plan.md`](phr-database-plan.md). - __A person's access view in core__ ([ngdpbase#1825](https://github.com/jwilleke/ngdpbase/issues/1825), decided 2026-10-16): rendered from the audit log, which needs the owner indexed; read through the PDP; each add-on describing its own resources; Normal and All views. - __Creating a person's PHR vault from an add-on__: [ngdpbase#1559](https://github.com/jwilleke/ngdpbase/issues/1559), add-ons declare their own category and vault in their manifest (open). Accounting has no vault. Blocks storing documents. 3. __`PhrManager` and `phr.db`:__ port today's FHIR store with a required `owner` column on every table, the one door with its lint, and tests proving A can't read B. 4. __The first slice: patient downloads, as ngdpbase pages in `phr`__ (no Angular). A "my records folder" source ([#939](https://github.com/jwilleke/yourphr/issues/939)) with importers for the formats the operator actually has: FHIR zips, C-CDA, and Blue Button text and PDF reports. Files go into the person's PHR vault, records into `phr.db`, and import runs as a background job. 5. __Later: live connections__ (vendor APIs paused, 2026-10-16): the hospital directory, connecting through the SMART relay, sync as a background job, and secrets through Admin → Configuration. 6. __Measure__ (the list is on [#920](https://github.com/jwilleke/yourphr/issues/920)): hours, upstream changes needed, SSE under ngdpbase, the deploy smoke check, and the size of the data move. 7. __The data move:__ `records.db` into `phr.db`; accounts, credentials and sessions into ngdpbase's user store (compatible password hashes or a reset); a decision about agent tokens and device grants already issued. 8. __Publishing:__ phr's GitHub Actions publish workflow, and installing it from npm on a site ([ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810)). 9. __Cutover__ under the 2026-10-14 rules: rehearse the swap on yourphr.org, household instance first, then the demo, then delete the copies under the stop rule. ## Open — blockers and questions - __Blocks `PhrManager`:__ the core items in *Plan* step 2: [ngdpbase#1823](https://github.com/jwilleke/ngdpbase/issues/1823), [#1860](https://github.com/jwilleke/ngdpbase/issues/1860), [#1825](https://github.com/jwilleke/ngdpbase/issues/1825), [#1559](https://github.com/jwilleke/ngdpbase/issues/1559). - __Blocks going live with real records:__ ngdpbase [#1723](https://github.com/jwilleke/ngdpbase/issues/1723), since no database is in ngdpbase's backups yet. - __Blocks deploying `phr` beyond a dev machine:__ installing add-ons from npm ([ngdpbase#1810](https://github.com/jwilleke/ngdpbase/issues/1810)). Until then, drop-in from a folder is the only way to load an add-on, and it needs `tsc` run first. - __Raised with the operator, undecided:__ - Whether the owner rule ("the owner or their delegate, never a role") stays fixed in core beneath policy, or becomes a policy condition. If it's a condition, a policy that omits it gives a role everyone's records. A startup check refusing such policies is one mitigation; how strict it is would be an implementation choice. - __Proxy access to encrypted vaults__ waits on slice 5 of [ngdpbase#1762](https://github.com/jwilleke/ngdpbase/issues/1762). Until then, a proxy can't open an encrypted PHR vault. - __Data migration:__ none of our 26 tables carries an add-on prefix; `phr` tables are `phr_…`. Users, credentials, sessions, agent tokens and device grants move to ngdpbase's user store, needing compatible password hashes or a reset, and a decision about tokens already issued. - __Keys:__ one key covers every ngdpbase database; YourPHR holds a separate backup key (#1723 proposes one). - __SSE__ under ngdpbase's process model is untested. - __Self-hosters:__ an upgrade page like `upgrading-v2-to-v3.md`. Deployment follows ngdpbase's targets ([#641](https://github.com/jwilleke/yourphr/issues/641), on hold). - __Carried from the record:__ whether the access log's day-folding is the granularity [#614](https://github.com/jwilleke/yourphr/issues/614) wants. ## Linked documents and issues | What | Role | |---|---| | [`architecture-principles-typescript.md`](https://github.com/jwilleke/ngdpbase/blob/master/docs/planning/architecture-principles-typescript.md) (ngdpbase) | Governing architecture; names the legitimate divergences | | [#920](https://github.com/jwilleke/yourphr/issues/920), branch `yourphr-addon` | The `phr` add-on work and its measurement | | [ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431#issuecomment-6193262203) | Access-control decisions for add-on records (2026-10-15) | | [ngdpbase#1813](https://github.com/jwilleke/ngdpbase/issues/1813) | Host tools for add-ons, shipped in v5.10.0 | | [`getting-off-go.md`](../archive/getting-off-go.md) | Complete; its cutover rules are reused here | | [ngdp-accounting-addons](https://github.com/jwilleke/ngdp-accounting-addons) | Reference shape for a complex, optional add-on | | [#591](https://github.com/jwilleke/yourphr/issues/591), [#608](https://github.com/jwilleke/yourphr/issues/608) | Parity epic; the ngdpbase-model architecture already applied | ## Analysis record How the direction was reached, 2026-08-30 onward. Provenance of the original analysis: > __Status: analysis, 2026-08-30. Not adopted, nothing decided.__ Session notes from stepping through the question *"can YourPHR clone ngdpbase and work within it?"* Written down because the question keeps recurring and has been answered from structure rather than from evidence each time — see [yourphr#697](https://github.com/jwilleke/yourphr/issues/697), which argues it from repo layout alone. > > __Amended 2026-09-02__ with the security-posture comparison, and two claims it superseded — see that section and the strikethrough under *What flows the other way*. > > __Re-verified 2026-09-21__ against ngdpbase `ed5f8a15` (v4.20.0, 559 commits after the 2026-09-02 amendment) and YourPHR `9964cc22b`. Much of the security comparison had gone stale because ngdpbase fixed what this document filed: the global context slot ([ngdpbase#1132](https://github.com/jwilleke/ngdpbase/issues/1132)), the token-ceiling bypass ([ngdpbase#1164](https://github.com/jwilleke/ngdpbase/issues/1164)), the unguarded fetches ([ngdpbase#1133](https://github.com/jwilleke/ngdpbase/issues/1133)) and the maintenance toggle ([ngdpbase#1147](https://github.com/jwilleke/ngdpbase/issues/1147)) are all closed. Superseded findings are struck through with a dated note rather than deleted, as before; plain facts (counts, line numbers, issue states) are corrected in place. The D18 open question was answered and is a live defect — [#751](https://github.com/jwilleke/yourphr/issues/751). Everything the re-check found is recorded in *Corrections made while writing this*. > > __Re-verified 2026-09-25__ against ngdpbase `2f02a12f` and YourPHR `4dc9ac26a`. Four days of shipping moved YourPHR under the document: two rows of the overlap table below describe gaps that have since been filled, and two capabilities the table never listed turned out to be the ones the current design work needed. Counts re-measured. Jim's framing, recorded here because it changes what this document is for: __YourPHR is eventually expected to be a set of addons within an ngdpbase implementation__ — so the question is no longer "should we adopt it" but "which of these do we build twice". > > __Re-checked 2026-10-14__ against ngdpbase `v5.8.0-7-g32faa26c` and YourPHR `v3.21.0`. [#697](https://github.com/jwilleke/yourphr/issues/697) closed 2026-10-13 with the "default to ngdpbase" rule and left the add-on question open. The question this document posed on 2026-09-25 — *which of these do we build twice* — has been answered by default: most of them. See *Where it stands, 2026-10-14*, and the measurement now under way in [#920](https://github.com/jwilleke/yourphr/issues/920). The sections below it are not re-verified this round. > > Read with [`architecture-principles-typescript.md`](https://github.com/jwilleke/ngdpbase/blob/master/docs/planning/architecture-principles-typescript.md), which now lives in ngdpbase. That document is the governing one and already answers part of this. ### Where it stands, 2026-10-14 #### The overlap table was closed by copying Every row answered since 2026-09-25 was answered by porting ngdpbase's code into `src/framework/`, not by adopting it: | Issue | Closed by | |---|---| | [#536](https://github.com/jwilleke/yourphr/issues/536) outbound mail | Port of `EmailManager` | | Token-expiry notice (deferred on [#695](https://github.com/jwilleke/yourphr/issues/695)) | Port of `NotificationManager` | | [#775](https://github.com/jwilleke/yourphr/issues/775) one markdown module | Port of `FilterManager` / `SecurityFilter` | | [#714](https://github.com/jwilleke/yourphr/issues/714) maintenance mode | Rebuilt here from ngdpbase's design | | [#631](https://github.com/jwilleke/yourphr/issues/631) backups restore the instance | Our own `BackupManager` | | [#856](https://github.com/jwilleke/yourphr/issues/856) background jobs | Port of `BackgroundJobManager` | `src/framework/managers/` now holds 13 managers; 37 files under `src/framework/` name ngdpbase as their source. The backend grew from 16,802 to 22,697 lines, and `src/server.ts` from 1,345 to 1,844 lines (103 route matches). Still open and next in line to be copied: help ([#632](https://github.com/jwilleke/yourphr/issues/632), [#633](https://github.com/jwilleke/yourphr/issues/633)), themes ([#436](https://github.com/jwilleke/yourphr/issues/436), [#499](https://github.com/jwilleke/yourphr/issues/499), [#502](https://github.com/jwilleke/yourphr/issues/502)), per-user settings ([#709](https://github.com/jwilleke/yourphr/issues/709)), patient notes on records ([#353](https://github.com/jwilleke/yourphr/issues/353)). #### The copies drift, in both directions A port is a snapshot. ngdpbase moved 717 commits in the same window, and kept changing the very managers that had been copied: - __Jobs.__ After the `BackgroundJobManager` port, ngdpbase added a scheduler (RRULE), resume after restart, retry with backoff and checkpoints, an admin page, and automatic backups as a scheduled job. None of it is in the copy. - __Markdown.__ After the 2026-09-30 filter port, ngdpbase fixed CommonMark code spans and brackets inside link text, and moved to one HTML policy for save and render (`3070db12`). - __Notifications.__ The copy has been fixed twice here ([#869](https://github.com/jwilleke/yourphr/issues/869), [#919](https://github.com/jwilleke/yourphr/issues/919)); neither fix exists upstream. - __Auth.__ Being designed in both repos at once. This is the cost the "copy the shape" rule does not show when it is adopted: every later fix is made twice or the copies diverge. The operator's description of it on 2026-10-14 was *fighting two battles at once*. #### A constraint on every option __The project stays at yourphr.org and in `jwilleke/yourphr`__ (Jim, 2026-10-14: the domain is paid for and the GitHub name is owned). That rules out nothing above. The add-on route is the geohazardwatch shape: its own repo and domain, a Dockerfile `FROM ghcr.io/jwilleke/ngdpbase:`, the domain shipped as `@jwilleke/yourphr-addon`. What it does rule out is folding YourPHR into the ngdpbase repo or serving it from an ngdpbase-branded host. #### The options, and the measurement 1. __Keep copying.__ The cost grows with each port and needs a standing upstream-diff sync per copied manager. 2. __Pull the framework out of ngdpbase into a package__ YourPHR depends on — the governing document's own *"extract the package second"*. It ends the copying for back-end services, but not for required-pages help or the admin screens. 3. __YourPHR as an ngdpbase add-on.__ It ends the double work. The cost is the `src/server.ts` rewrite, which has grown by a third since this document sized it. #### The reference add-on, and what changed in core [ngdp-accounting-addons](https://github.com/jwilleke/ngdp-accounting-addons) is the working example of a complex add-on. It is optional by configuration (`ngdpbase.addons.accounting.enabled`, default false; its defaults merge only while on). It fails soft: with no database it mounts nothing and says why in `status()`. `LedgerManager` is the one door, calling `requirePermission` on the request's `ApiContext`. Permissions carry their own audit rule (`on-failure: refuse`), its role sits at `required-aal: 2`, and the riskiest actions use step-up. For YourPHR that turns "audit refuses to boot" into per-permission refusal, and puts `ngdpbase.addons.yourphr.enabled: true` in yourphr.org's own config. A deploy smoke check then has to prove the add-on is actually on. __Core now has encrypted SQLite.__ `DatabaseManager` ([ngdpbase#1536](https://github.com/jwilleke/ngdpbase/issues/1536), ported from YourPHR) runs SQLCipher on `better-sqlite3-multiple-ciphers`. Since `9832f05a` (2026-10-07) every add-on can open its own file through `openAddonDatabase(owner, …)`, with owner-prefixed tables and migration ledger. The *Encrypted SQLite* section below and the first row of *Core improvements* are therefore superseded: the encrypted store has flowed up. What has not flowed up is __backup__. `DatabaseManager` inherits `BaseManager`'s unimplemented `backup()`/`restore()`, so no database is in ngdpbase's backups. [ngdpbase#1723](https://github.com/jwilleke/ngdpbase/issues/1723) ports YourPHR's `sqlite-backup.ts` and now blocks the "go" outcome of [#920](https://github.com/jwilleke/yourphr/issues/920). Two more gaps for PHI: one key covers every database, and a missing key opens unencrypted with only a warning. #### The front end: Angular for the patient, ngdpbase for the chrome The Angular app is about 32.9k lines in 170 components, bigger than the backend. About 155 of those components are the patient's record screens, the product, with no ngdpbase analogue; they stay. ngdpbase already ships what the other ~15 duplicate: sign-in with MFA (password, passkeys, second factor, magic link, recovery, device approval, outside sign-in), OIDC consent and approved apps, `/profile` (agent tokens, delegations, proxy grants) and 27 `/admin` views. Those, plus `UsersManager`, `SessionsManager`, `AgentTokensManager` and `SettingsManager`, are deleted at cutover. Auth and chrome issues on this side are parked until #920 reports (the list is on #920). The first thing #920 tests is whether the add-on can serve the Angular bundle on ngdpbase's session. Option 3 is now being measured instead of argued: [#920](https://github.com/jwilleke/yourphr/issues/920) runs the Sources slice (mapped below under *One vertical slice*) as an add-on on the branch `yourphr-addon`, time-boxed to one week. Until it reports, no further ngdpbase manager is ported into `src/framework/`. If it says no, option 2 is the fallback. ### The question, stated properly Not *"should YourPHR use ngdpbase's ideas"* — `AGENTS.md` already mandates that, below `KIT:END`: > __DEFAULT TO ngdpbase__ — the code, not its docs. When this stack needs a mechanism ngdpbase already has, take ngdpbase's shape as it is built. __Improvements are FILED, not built in flight.__ A divergence is legitimate only if that document names it. The open question is the stronger one: __does YourPHR become an ngdpbase deployment__ — cloning the platform and shipping the PHR as an add-on — rather than a separate application that copies its shapes? ### How ngdpbase is actually consumed Checked against the two live consumers rather than assumed. - __ngdpbase is not on npm.__ `npm view ngdpbase` returns 404. It is a *clone-and-extend* platform. - __Consumers take a base image, or a clone.__ `geohazardwatch` builds `FROM ghcr.io/jwilleke/ngdpbase:${NGDPBASE_VERSION}` and installs its domain as a published add-on package (`@jwilleke/geohazardwatch-addon`). Neither `geohazardwatch` nor `fairways-gen2-website` has ngdpbase as a dependency. Not every consumer takes the image, though: `fairways-gen2-website` has no Dockerfile and is *"a direct install — a clone of ngdpbase run on the host"* (its `SETUP.md`). Both shapes are clone-and-extend; the image is one way to ship the clone. - __An add-on is a full application module__: its own managers, Express routes, background jobs, views, plugins, config and data. Not a markup macro. One shape is explicitly ruled out by the governing document, and it is the one people assume: > `src/plugins/` — JSPWiki-style __markup macros__, not application modules. __"YourPHR as an ngdpbase plugin" is not an available shape — this was checked.__ ### Scale Non-test TypeScript, counted 2026-09-25 (2026-09-21 figure in brackets): | | Lines | |---|---| | ngdpbase `src/` | 125,955 (121,558) | | — its managers alone | 27,909 (27,830) | | YourPHR `src/` (whole backend) | 16,802 (14,641) | | — `src/framework/` | 3,799 (3,734) | ngdpbase has 36 `*Manager.ts` files (39 files in `src/managers/`); YourPHR has 14. Adoption means carrying the platform to reach roughly 4k lines' worth of horizontal services — unless the vertical features below are worth the freight. YourPHR grew 2,161 lines in four days while ngdpbase grew 4,397 — worth noting only because it is the shape of the trap this document describes: both ends move, and a comparison written once is wrong within the week. ### What it would buy: the open-issue overlap The strongest argument, and the one missing from [yourphr#697](https://github.com/jwilleke/yourphr/issues/697). These are __open YourPHR issues that ngdpbase core already implements__: | Open issue | ngdpbase core capability | |---|---| | [#632](https://github.com/jwilleke/yourphr/issues/632) In-app help pages — markdown shipped with the code (__P1__) | *"Required pages — committed to repo, auto-installed on first run"* + Markdown pages with frontmatter | | [#633](https://github.com/jwilleke/yourphr/issues/633) Help content — the pages a patient needs (__P1__) | Content authoring, no code | | [#436](https://github.com/jwilleke/yourphr/issues/436) Bootstrap and themes (__P1__) | Theme System — `themes/core.css`, per-theme variable overrides, admin switcher | | [#499](https://github.com/jwilleke/yourphr/issues/499) instance default light/dark | Light / dark / system-preference via CSS variables | | [#500](https://github.com/jwilleke/yourphr/issues/500) `ui.theme-name` wired to nothing | ThemeManager lists themes automatically | | [#502](https://github.com/jwilleke/yourphr/issues/502) Azia BS4 → Bootstrap 5.3 colour modes | The same theme system, already built | | [#536](https://github.com/jwilleke/yourphr/issues/536) Outbound mail transport (__P1__) | `EmailManager` | | ~~[#687](https://github.com/jwilleke/yourphr/issues/687) "Email this summary" 404s~~ __Resolved 2026-09-24 without it__ (577becde3). The decision was that the instance does not send: it hands the person the file and says so. `EmailManager` would have answered the question this row assumed — how do we send mail — rather than the one that mattered, which was whether we should. [#536](https://github.com/jwilleke/yourphr/issues/536) still wants a transport for its own reasons. | `EmailManager` | | [#631](https://github.com/jwilleke/yourphr/issues/631) Backups must restore the instance (__P1__) | Backup and restore, instance-level | | [#709](https://github.com/jwilleke/yourphr/issues/709) per-user settings have no store | `UserManager` + `NotificationManager` per-user state | | ~~[#719](https://github.com/jwilleke/yourphr/issues/719) agent tokens — no minting screen~~ __Built 2026-09-24__ (25504a4c4): mint with scopes, live table, revoke, renew, hidden when the feature is off. The row stood for three weeks and was answered in a day once someone looked at it, which is the honest counterweight to every "ngdpbase already has this" line in this table. | `AgentTokenManager` + the `profile.ejs` card, mint form, live table, revoke, admin oversight | | Token-expiry notification, deferred in [#695](https://github.com/jwilleke/yourphr/issues/695) (no issue of its own) | `NotificationManager` — per-user, with expiry and dismiss | | [#714](https://github.com/jwilleke/yourphr/issues/714) Maintenance mode — no way to say the instance is briefly not itself | The gate, the page, the toggle, the notification and the config, all shipped | Ten still-open issues, four of them P1, plus one deferred item with no issue of its own — two rows having been answered in YourPHR since. (Originally counted as thirteen, with #695 appearing twice.) #### Two capabilities the table missed, found by needing them Both surfaced on 2026-09-25 from design work that had nothing to do with adoption, which is the more useful way for this list to grow — a capability you reach for is better evidence than one you notice. | Open issue | ngdpbase core capability | |---|---| | [#775](https://github.com/jwilleke/yourphr/issues/775) markdown: one module, CommonMark, sanitised before storage (__P1__) — blocks [#353](https://github.com/jwilleke/yourphr/issues/353) and [#632](https://github.com/jwilleke/yourphr/issues/632) | `FilterManager` (ngdpbase#1117), `SecurityFilter`, `FilterChain`, on `markdown-it`. It already draws the distinction YourPHR's rule needs — `SecurityFilter.renderFiltering` is documented as *"Filter RENDERED output. Separate from save-time blocking (ngdpbase#1037)"* — and its docblock names the exact failure YourPHR was about to repeat: filters *"had no contract, no owner and no contributed path"*, reached from two entry points and owned by nobody. | | [#353](https://github.com/jwilleke/yourphr/issues/353) patient comments on records | `CommentManager` — comments as a capability: config-gated (`comments.allow`), audit event on write, actor context. The storage does not transfer (its comments are files per page; YourPHR's are FHIR records), but the shape does. | Neither is a row this document could have predicted. Both were found by designing a feature and discovering the design already existed, which is the argument for reading ngdpbase before building rather than after. #### The gap nobody had filed: maintenance mode Worth separating from the rest of the table, because it was found the opposite way round. Every other row is an issue somebody wrote down and ngdpbase happens to answer. This one had __no issue at all__ until [#714](https://github.com/jwilleke/yourphr/issues/714), and it was invisible for the reason missing capabilities usually are: nothing fails, so nothing gets filed. The product simply has no way to say *"this instance is briefly not itself"*, and every operation that needs to say it has quietly worked around the absence. It surfaced from [#713](https://github.com/jwilleke/yourphr/issues/713). A rebuild of the search index over 20,000 resources measures __48.5 seconds__, and `reindexAll()` empties both derived tables before repopulating them — so search returns nothing throughout. Without a maintenance mode the options are all bad: rebuild under live traffic and let a household member watch search break and heal, or stop the process, which on Kubernetes means scaling to zero and back. The ordinary answer — put up a page, do the work, take it down — was not available. ngdpbase has all of it: the gate in `src/app.ts` (after the session resolves so it knows whether the caller is an admin), the toggle `adminToggleMaintenance()` in `WikiRoutes.ts` (`admin-system` gated and logged), `views/maintenance.ejs`, `NotificationManager.createMaintenanceNotification` (every user told), and four config keys. Since [ngdpbase#1432](https://github.com/jwilleke/ngdpbase/issues/1432) the gate is a general __availability__ gate — maintenance, business hours and holidays, each with its own message — and the admin bypass is the `admin-system` permission rather than the admin role ([ngdpbase#1198](https://github.com/jwilleke/ngdpbase/issues/1198)). The details that carry the design are the unglamorous ones — the exemption list for `/admin`, `/login` and static assets, without which the admin cannot reach the switch that turns it off; `allow-admins` defaulting true; and a real `503` rather than a `200`. ~~__One defect to fix on adoption rather than after.__ `adminToggleMaintenance()` mutates `engine.config` in memory and nothing persists it, so a pod restart during maintenance — a rollout, an OOM kill, a node drain — brings the instance back live with nobody told. For an index rebuild that is worse than having no maintenance mode: the index would be neither old-and-complete nor new-and-complete, and the instance would be serving from it. This stack has a persisting configuration overlay, so writing through it is a small change. Worth filing upstream as well, since every ngdpbase consumer has the same hole.~~ __Superseded — already fixed before this was written.__ [ngdpbase#1147](https://github.com/jwilleke/ngdpbase/issues/1147) closed 2026-09-01: the toggle now writes through `configManager.setProperty()`, which persists to `app-custom-config.json`, and its comment names exactly this restart defect. The lesson about which mistake not to make still stands for [#714](https://github.com/jwilleke/yourphr/issues/714). The general lesson is the one this document keeps running into from the other direction. The core-versus-add-on section below is mostly about what YourPHR would __contribute__; this is the clearest case of what it would __receive__, and it took a performance measurement on an unrelated bug to notice a capability that was never missed because nothing ever demanded it out loud. __Document search is a real want, not a wiki artefact.__ [#599](https://github.com/jwilleke/yourphr/issues/599) shipped full-text search over *record text*. Help content is a different corpus needing a different index, and ngdpbase's Lunr `SearchProvider` is exactly that. ### The front end is where it breaks — and how far The obvious shape is: ngdpbase serves its EJS chrome, the Angular bundle is served as add-on static assets. __Two front ends. That was rejected on sight, and rightly.__ Two navigations, two session surfaces, two search boxes, a patient who clicks *Help* and lands somewhere that looks like a different product. But the theming half of that objection turns out to be wrong, and it matters: - ngdpbase themes are __Bootstrap 5 native__. `themes/default/css/variables.css` sets `--bs-body-bg`, `--bs-link-color`, `--bs-border-color` and layers semantic vars over them; the file says so: *"Bootstrap 5 native variables — set here so Bootstrap components and our semantic vars below both stay consistent."* `themes/core.css` uses 25 `--bs-*` properties. - ngdpbase loads Bootstrap __5.3__ (5.3.0 and 5.3.3 by CDN; not a package.json dependency). - YourPHR's Angular app is on `bootstrap@5.3.8` with `@ng-bootstrap@21` (Angular 22.1.7 since 2026-09-21). Same framework, same minor, same custom-property contract. __One `variables.css` can theme both surfaces__ with no token-unification project — and [#502](https://github.com/jwilleke/yourphr/issues/502) is precisely the migration that would let the Angular side consume it. So the front-end objection is narrower than "two front ends": it is __navigation, session and search__, not styling. ### What it would actually take In order of size. 1. __The HTTP layer.__ `src/server.ts` is 1,345 hand-rolled `node:http` lines carrying the session cookie, the Bearer path, the agent-token gate, the demo gate and the access-log gate as inline middleware. ngdpbase is Express. That gets dismantled into add-on routes plus Express middleware. This is the bulk of the work. 2. __PHI stays in the add-on and cannot become pages.__ ngdpbase pages were plain Markdown on disk when this was written, and core stated plainly: *"Encryption at rest for private pages (planned future enhancement)"*. __Changed since:__ the private-stores epic [ngdpbase#1382](https://github.com/jwilleke/ngdpbase/issues/1382) (open, largely landed) adds per-user encrypted page stores — user KEK, store DEK, 12-word recovery, sealed page bytes kept out of shared indexes and caches (`src/utils/privateStoreCrypto.ts`). The stale sentence still sits in ngdpbase's `docs/platform/platform-core-capabilities.md`. Sealed pages are still not a FHIR store, so the rule below stands. Records keep their SQLCipher store behind YourPHR's own managers. That is normal for an add-on, but *"private pages must never hold PHI"* becomes a stated rule, not an assumption. 3. __One front end has to be chosen.__ Either the Angular app consumes help content over an API and renders it in its own chrome, or help lives in EJS and the patient crosses a boundary. Unresolved, and the deciding question is below. 4. __Dependency surface.__ 120k lines and ngdpbase's dependency tree join the supply chain of a PHI store. A posture decision to make deliberately. __The SSRF boundary is not the blocker it appears to be.__ YourPHR's hard rule is that all outbound network access goes through `src/http`, enforced by `scripts/check-http-boundary.sh`. ngdpbase core has __three__ genuine server-side outbound call sites: `ImportManager.ts:958`, `WikiRoutes.ts:1591`, `WikiRoutes.ts:13094`. The other matches are client-side `fetch()` emitted into browser JS (`CommentsPlugin`, `FootnotesPlugin`) and a plugin lifecycle method that happens to be named `fetch`. Routing three call sites through a guarded fetch is a day, not a programme. __Done upstream since__ — see *What flows the other way*: all three now go through `guardedFetch`, and ngdpbase's ESLint forbids a bare `fetch` outside `src/http`, add-ons included ([ngdpbase#1239](https://github.com/jwilleke/ngdpbase/issues/1239)). ### Encrypted SQLite: the seam exists, the implementation does not > __Superseded 2026-10-14.__ ngdpbase's `DatabaseManager` ([ngdpbase#1536](https://github.com/jwilleke/ngdpbase/issues/1536)) now runs SQLCipher and opens a database per add-on; only backup is missing ([ngdpbase#1723](https://github.com/jwilleke/ngdpbase/issues/1723)). See *Where it stands, 2026-10-14*. Kept as the record of how it was sized. The provider architecture would accept an encrypted SQLite store — that much is right, and it is the reason this is worth doing at all. But the base is emptier than it looks, and the direction of travel is the opposite of what it seems: - __ngdpbase uses no SQLite today__ — no `sqlite`, `sqlcipher`, `better-sqlite3`, `knex` or `pg` dependency, and no `node:sqlite` import anywhere in `src/`, `addons/` or `scripts/`. Counting dependencies was the wrong test, though: Node 24 ships SQLite built in (`node:sqlite`, `DatabaseSync`), so adding SQLite would cost ngdpbase __no__ dependency at all. What the built-in lacks is encryption. Tested on Node v24.19.0 (SQLite 3.53.3): `node:sqlite` accepts `PRAGMA key` without error and ignores it — the file on disk starts with the plaintext `SQLite format 3` header and a stored value is readable in it, where the same write through `better-sqlite3-multiple-ciphers` 13.0.3 leaves neither. (`PRAGMA cipher_version` is not a usable probe: it is SQLCipher-only and returns nothing on the multiple-ciphers build too, even in `cipher='sqlcipher'` mode.) So a store holding PHI still needs `better-sqlite3-multiple-ciphers`, and a silently ignored `PRAGMA key` on the built-in is the failure to guard against — code that reads as encrypting and writes plaintext. __Encryption is the gap, not SQLite.__ Non-PHI stores (audit, sessions) could sit on the built-in with no new dependency. Every shipped *store of record* is file-based: `FileSystemProvider`, `VersioningFileProvider`, `FileUserProvider`, `FileAuditProvider`, `FileBackupProvider`; the cache, search and cloud-audit providers (`RedisCacheProvider`, `ElasticsearchSearchProvider`, `CloudAuditProvider`) are the exceptions. - __`DatabaseAuditProvider` is a scaffold__, not an implementation: 196 lines carrying thirteen `TODO`s, including *"TODO: Implement database client initialization"*. The governing document already says an operator who configures it *"gets an instance that believes it has an audit trail and has none"*. - __YourPHR holds the working implementation.__ SQLCipher, `ATTACH DATABASE … KEY …` plus a hand-written copy loop (the SQLite3MultipleCiphers build does not ship `sqlcipher_export()`; see `src/app/providers/sqlite-backup.ts`) for encrypted consistent backups under a separately-held key, and a database provider that owns the connection. So encrypted SQLite is not something YourPHR __receives__ from ngdpbase. It is the largest single thing YourPHR __contributes__, and it lands in a seam ngdpbase has kept open and never filled. Worth naming plainly, because "the base handles it" is the assumption that would set the schedule wrong. ### Has `src/server.ts` outlived its usefulness? #### What it actually is 68 route matches across seven labelled sections (the counting method was not recorded; on 2026-09-21 the file has 63 exact `pathname ===` and 12 `pathname.match` routes), and every section is a __product feature__, not infrastructure: the account page ([#596](https://github.com/jwilleke/yourphr/issues/596)), agent tokens ([#695](https://github.com/jwilleke/yourphr/issues/695)), Users ([#604](https://github.com/jwilleke/yourphr/issues/604)), the provider catalog ([#603](https://github.com/jwilleke/yourphr/issues/603)), Sources ([#594](https://github.com/jwilleke/yourphr/issues/594)), the dashboard and record pages ([#595](https://github.com/jwilleke/yourphr/issues/595)), practitioners ([#683](https://github.com/jwilleke/yourphr/issues/683)). That reframes the rewrite. It is not 1,345 lines of bespoke HTTP machinery to be deleted — it is the product's API surface, which survives the move and is re-expressed as Express handlers. The five gates become middleware. __Mechanical, not conceptual__, and the mechanical part is where the effort is. #### Multi-tenancy is the wrong word, and the distinction matters The hand-rolled server can fairly be called past its usefulness: it was built to prove a spike could serve the Angular contract with no framework, and that is proven. Express plus ngdpbase's middleware would carry the same 68 routes with less bespoke code. But __per-user isolation is not multi-tenancy overhead, and a family instance needs it most.__ A household has several people in it. A spouse's records and a teenager's records are exactly what must not be visible to each other by default. The governing document draws the line: *"Compartments are whose records, not which actions."* YourPHR measured 6/6 on per-user isolation. (`repoForUser` was the pre-[#608](https://github.com/jwilleke/yourphr/issues/608) mechanism and is gone from `src/` — it survives only in three test harnesses. The live shape is narrowing *inside the manager*: see the slice below.) So: __the requirement survives the move; the implementation is negotiable.__ Per-request repository resolution may be heavier than a family instance needs, and that is a fair thing to test. What the move does __not__ hand over is the compartment model itself. The governing document is explicit that ngdpbase's ACL secures *pages*, and *"the `PolicyEvaluator` shape transfers; its subject model does not."* Users, sessions and roles come from the host; deciding whose records these are stays YourPHR's, inside the add-on. ### One vertical slice, mapped: the Sources page Taken end to end so the "mostly mechanical" claim above is tested rather than asserted. Source: `src/server.ts:845-941` (was 812-890), `src/app/managers/SourcesManager.ts`. #### What the slice contains Ten routes when mapped; twelve now. [#736](https://github.com/jwilleke/yourphr/issues/736) added `GET /api/secure/source/cda-converter/status` and `POST /api/secure/source/manual`, and the upload route reads configuration and parses the multipart body itself — the one exception to *"no domain logic"* below. Of the original ten, eight are one line each, of the identical shape: ```ts send(res, 200, {success: true, data: await src.listShaped(ctx)}); ``` A single regex carries six of them — `^/api/secure/source/([^/]+)(?:/(summary|sync|disconnect|remove-data|export))?$` — dispatching to `getShaped`, `remove`, `summary`, `syncNow`, `disconnect`, `removeData`, `exportBundle`. Every one takes `ctx` and an id and returns data. The route layer holds __no domain logic at all__: it decodes, calls one manager method, and shapes a 404 or a 500. Two routes are not plain JSON: - __Server-sent events__ — `/api/secure/events/stream`, Go's framing, a 15-second keep-alive, and `unsubscribe()` on client close. - __A file download__ — `export`, which sets `application/fhir+json` and a `Content-Disposition` attachment filename and writes the body itself. #### How it maps | YourPHR today | Under ngdpbase | Cost | |---|---|---| | Manual `url.pathname ===` matching | `app.get('/api/secure/source', …)` | Mechanical | | One regex, six actions, method checks by hand | `/:id`, `/:id/summary`, `/:id/sync`, … as Express params | Mechanical, and a simplification | | `send(res, 200, {success, data})` | `res.json({success, data})` | Mechanical | | SSE by hand | Precedent in host: two `text/event-stream` endpoints in `WikiRoutes.ts`, both using `res.flushHeaders()` | Direct | | `Content-Disposition` download | Precedent in host: the audit-log export | Direct | | `SourcesManager` and its provider | Unchanged — add-on manager, `ctx` passed in | __Zero__ | | The five request gates | Express middleware, but each needs a host equivalent or a port | Real work, done once for all slices | __The managers do not move.__ `SourcesManager` already takes `ctx` on every call and narrows inside itself. That is the whole point of [#608](https://github.com/jwilleke/yourphr/issues/608), and it is what makes the host swappable. #### Isolation is already in the right place Worth correcting an earlier assumption in this document. Compartmentalisation is __not__ a per-request repository handed down from the server any more. `repoForUser` is gone from `src/` — it survives only in `scripts/provenance-tests.ts`, `scripts/worker-tests.ts` and `scripts/migrate-tests.ts`. The live mechanism is inside the manager: ```ts // RecordsManager private who(ctx: ApiContext): string { ctx.requireAuthenticated(); return ctx.username; } const userId = this.who(ctx); await this.provider.search(userId, …); // SourcesManager return (await this.provider.list()).filter((s) => s.userId === ctx.username); if (source.userId !== ctx.username) throw new ApiError(403, '…'); ``` This is exactly the governing document's clause (b) — *narrow inside the door, never at the route* — so __isolation survives a host swap untouched__. The server carries none of it. #### The one genuine hazard > __Resolved upstream 2026-09-02.__ [ngdpbase#1132](https://github.com/jwilleke/ngdpbase/issues/1132) deleted the process-global slot (`285dbad3`), and [ngdpbase#1399](https://github.com/jwilleke/ngdpbase/issues/1399) went further: the caller's context is now mandatory and positional, enforced by a lint, and `AsyncLocalStorage` is gone. The hazard and the `engine.context` lint proposed below no longer apply. Kept as the record of why it was filed. ngdpbase had __both__ context shapes in the tree: - `src/context/ApiContext.ts` with `static from(req, engine)` — per request, the good one. - `WikiEngine.setContext(context)` / `get context()` — __a process-global slot__, still present at `src/WikiEngine.ts:103-120`. The governing document already names the second as one of the five mechanisms to tighten, in these terms: *"Request state on a singleton; the session-scoped trap this document names, one concurrent request from a cross-user leak."* YourPHR diverged deliberately and has no global context. Hosting the PHR inside ngdpbase puts a PHI add-on in the same process as a global context slot the host's own wiki routes use. That is not a blocker — the add-on takes `ctx` explicitly everywhere and need never read `engine.context` — but it must become __a stated rule with a lint behind it__, in the same family as `check-store-boundary.sh` and `check-http-boundary.sh`: *nothing under the add-on reads `engine.context`.* Delete the rule, prove CI goes red. #### What the slice says about the whole For this slice the estimate holds: the manager and its provider move unchanged, the routes are a mechanical re-expression, and both non-JSON responses have working precedent in the host. The irreducible work is the __gate layer__ — session and Bearer, the agent-token gate, the demo gate, the access-log gate — which is written once and then serves all seven sections. (~~plus the context lint above~~ — no longer needed; see the resolved note.) The slice is representative of six of the seven sections. It is __not__ representative of `/api/secure/events/stream`'s subscription model under a multi-worker Express deployment, which is a question this slice raises and does not answer. ### What flows the other way YourPHR has mechanisms ngdpbase lacks. These are filed against ngdpbase, per the working agreement. - ~~__`scripts/check-http-boundary.sh` and the SSRF guard (47 checks).__ ngdpbase has no outbound boundary at all, and its three call sites are unguarded.~~ __Superseded 2026-09-02.__ ngdpbase now ships `src/http/{egressPolicy,guardedFetch,guardedLookup,ssrf}.ts` and its own `scripts/check-http-boundary.ts`, with no raw `fetch(` left outside tests. It went __further__ than this stack: [ngdpbase#1133](https://github.com/jwilleke/ngdpbase/issues/1133) added operator-configurable `ngdpbase.security.egress.allowed-ranges` / `denied-ranges`, reconciled by longest-prefix match with deny winning a tie (D8 of the security-posture record) — a layer YourPHR does not have, since `src/http/ssrf.ts` hardcodes the inward ranges. ~~The issue itself is still open, so this is the boundary being built rather than the work being closed.~~ [ngdpbase#1133](https://github.com/jwilleke/ngdpbase/issues/1133) closed 2026-09-04. The only raw server-side `fetch(` left is a dev script (`scripts/performance-test.ts`). - __`scripts/check-store-boundary.sh`.__ The governing document's own known-gaps table still records this lint as *"Not written. Until it exists, the invariant is documentation"* — YourPHR wrote it. - __Audit refuses to boot.__ YourPHR never had ngdpbase's `NullAuditProvider` fallback; `AuditManager` states the divergence deliberately. (Corrected 2026-09-21: ngdpbase's #1118 made the fallback loud and recorded, not gone. `degradeToNull()` still falls back to Null on provider failure by default, because `ngdpbase.audit.on-failure` defaults to `continue`; only `refuse-boot` makes it fatal.) - __Agent-token scopes as auditable access categories.__ A surface that cannot be logged cannot be scoped — structurally stronger than ngdpbase's *owner minus admin* rule. __Sharpened 2026-09-02__, and the enforceable half is the other one: YourPHR runs the ceiling __once, at the HTTP edge__, so an unnamed surface is refused by inheritance. ~~ngdpbase enforces at two call sites reached by convention, which is how [ngdpbase#1164](https://github.com/jwilleke/ngdpbase/issues/1164) happened — twelve places bypass it by passing a username string instead of a context.~~ __Superseded 2026-09-02:__ [ngdpbase#1164](https://github.com/jwilleke/ngdpbase/issues/1164) closed via #1173. The string overload of `hasPermission` was deleted (the compiler then found 17 call sites, not twelve), the ceiling now runs once in `src/security/PolicyDecisionPoint.ts` ([ngdpbase#1431](https://github.com/jwilleke/ngdpbase/issues/1431)), and `src/security/tokenRouteMap.ts` refuses any route not in the map for any token. ngdpbase now has __default-deny at one choke point__ too, so this is no longer something YourPHR contributes. What remains YourPHR's is the read-only category map, which was always add-on. - __Demo mode's guards are a method gate, not an absent permission.__ ngdpbase's `demo-admin` is read-only because the role holds only `admin-read`; YourPHR refuses by HTTP method at the edge (`DemoManager.refuseUnlessRead`) and again at named manager doors. Same asymmetry as the agent-token ceiling above — permission-absence fails open on a route that forgot to require a write permission. YourPHR additionally __verifies__ the demo password against the stored hash and regenerates on drift, which has no upstream analogue. Per-IP rate limiting on the sign-in routes is core and already flowed down, so it is not a gap. - __It cannot boot with auditing off.__ ngdpbase's `AuditManager` still loads `NullAuditProvider` when `ngdpbase.audit.enabled` is false — a deliberate opt-out upstream, beside the failure fallback noted above. YourPHR ships no Null provider, so the choice does not exist here. - __Encryption at rest.__ SQLCipher on both databases (`SqliteDatabaseProvider.ts:18`, `SqliteFhirRepository.ts:108`) plus `backup.encryption.key`. ngdpbase has no `encryption.key` and no SQLCipher — downstream of it having no relational store, so this is the *"large, into declared holes"* row of the core-improvements table rather than a defect. (Since 2026-09-02 ngdpbase does encrypt __private page stores__ — [ngdpbase#1382](https://github.com/jwilleke/ngdpbase/issues/1382) — so the gap is narrower: encryption of a relational store, not encryption at rest as such.) - __Audit writes are durable per event, and undefended.__ `SqliteDatabaseProvider.ts:16` opens the app database — which holds `access_events` — setting neither `journal_mode` nor `synchronous`, so SQLite's defaults apply and every `record()` commits and fsyncs before it returns. ngdpbase's `FileAuditProvider` queues in memory and flushes on a 30-second timer, with only the critical tier fsynced since its #1158 (the tier has since been renamed `on-failure: refuse`, [ngdpbase#1218](https://github.com/jwilleke/ngdpbase/issues/1218)). So YourPHR is __ahead on the axis its own audit is weakest elsewhere__ — but by accident, not decision: nothing states durability was the reason, and `SqliteFhirRepository.ts:111` already sets `journal_mode = WAL` on the *records* database. Copying that one line to the app database for performance drops the log to WAL + `synchronous=NORMAL`, where commits are not fsynced, and no test would notice. __Pin `synchronous = FULL` explicitly, with the reason in a comment.__ - __Refuse all raw HTML at save, not a deny-list of dangerous tags.__ YourPHR's port of `FilterManager`/`SecurityFilter` ([#775](https://github.com/jwilleke/yourphr/issues/775), 2026-09-29) keeps `blankOutCode` and one-error-per-line, but replaces the six enumerated rules with *no raw HTML tag outside code* plus *no javascript:/vbscript:/data:/file: link*. Upstream's list leaves `