Repository navigation
Record an object against whose record it is, not against who wrote it - #19
Merged
Merged
Conversation
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>
Deploying health-dash-main with
|
| Latest commit: |
0bf75ba
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f6b679fb.health-dash-prod.pages.dev |
| Branch Preview URL: | https://fix-record-subject-attributi.health-dash-prod.pages.dev |
Deploying health-dash-dev with
|
| Latest commit: |
0bf75ba
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://850247d0.health-dash-aex.pages.dev |
| Branch Preview URL: | https://fix-record-subject-attributi.health-dash-aex.pages.dev |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is wrong today
Five routes write a
raw_objectsrow for every object they store, and all five named the caller:chat-history/[id].tstwice,document-extract.ts,raw/[[path]].ts,regen-log/[id].ts. Aclinician holding a live grant writes on the patient's behalf, so the row named the wrong account.
Erasure is where it hurt.
erasure.tscounts objects inside the subject's namespaces, finds oneattributed elsewhere, 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: falsefor ever, their plaintext PDFstill in the bucket, with no route that could ever resolve it.
The fix
recordSubject(access, callerAccountId)infunctions/_lib/raw-owner.ts, applied at all five sites.It reads
subjectAccountIdand notownerAccountId: the latter is only the namespace's firstwriter, which on a clinician-first namespace is the clinician — the very account the row must stop
naming.
claimNamespaceis left alone; its caller decides.It does not reach the first-writer case, where a clinician who claimed the namespace resolves as
ownerrather thangranted. That is the gaprawAccessFor's own comment already tracks, and it isnow pinned by a test so the next reader meets it as a decision rather than an oversight.
Production is clean
Measured before anything was changed, read-only, account ids only, no decryption, against
health-identity-prod: 44raw_objectsrows over 5 client namespaces, 0 rows in no namespace,0 namespaces with more than one attributed account — so no production row needs moving, which
INSERT OR IGNOREcould not have done anyway. Zeroorphaned-chatrows is what bounds it: that isthe one key the escape hatch always writes fresh, so
INSERT OR IGNOREcan never have masked itsmis-attribution behind a pre-existing row the way a re-saved chat key could.
The audit script is committed rather than discarded, because a measurement nobody can re-run is an
assertion.
scripts/raw-attribution-audit.ts,npm run raw:attribution-audit;ORG_KEY_PASSPHRASEand
decryptVaultappear nowhere in it.functions/_lib/namespace-key.tsis the parser it needed:two answers from one regex, because
orphaned-chat/is deliberately absent fromnamespacePrefixes(the escape hatch's retained copy must be claimable by nobody) and deliberatelypresent in
erasure.ts'sNAMESPACED_PREFIXES— so an object can sit in a patient's namespacefor erasure while belonging to no claimable namespace at all.
Tests, derived rather than restated
tests/unit/namespace-key.test.tsreads owned directories offnamespacePrefixes' own returnvalue and the erased set out of
erasure.ts's source, so adding a prefix to either fails here atthe commit that adds it, and asserts the erasure set stays strictly larger.
tests/unit/raw-object-attribution.test.tsderives its own coverage from a parenthesis-balancedscan of the real
recordRawObject(call sites, so a sixth write site cannot be added untested. Itdrives all five routes on a real D1 and R2 — a fake
raw_objectscan be made to agree withwhatever the route writes, which is the failure being fixed.
recordSubject's unit test gives thegranted branch different values for
ownerAccountIdandsubjectAccountId, so a helper readingthe wrong field cannot pass.
tests/unit/erasure-function.test.tsgains the inverse it had no case for: a granted clinician'supload, 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 foreignobject still counts and still is not deleted.
Verification
4305 tests over 347 files pass on both backends (Miniflare, and
TEST_BACKEND=node);oxlintandtsc --noEmitclean. Proved to detect the defect rather than only to pass beside it: with thefour 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.
Two corrections to the plan this came from
The plan's verification grep cannot fail. It asks that
git grep -n 'recordRawObject(.*session.accountId' apps/lexitar/functionsreturn nothing — but thefixed form is
recordRawObject(env.DB, key, recordSubject(access, session.accountId)), whichcontains that string. It would have reported PASS on the unfixed tree too. The guard that actually
distinguishes them needs a negative lookahead:
In the fixed form the span between
recordRawObject(andsession.accountIdcontainsrecordSubject, so there is no match; in the broken form there is. Validated in both directions.Two of the three
x-replace-unreadableroute tests the plan asks for already exist. It says thestring "appears nowhere in the repo"; in fact
1676c70(19 Oct) added a whole block totests/unit/chat-history-function.test.tscovering the retention copy underorphaned-chat/and afailed retention
putleaving the original in place, more thoroughly than the plan described. Theone genuinely missing third was attribution of that retained copy, which is covered here.
🤖 Generated with Claude Code