Skip to content

Record an object against whose record it is, not against who wrote it - #19

Merged
pablo-tech merged 1 commit into
devfrom
fix/record-subject-attribution
Oct 24, 2026
Merged

pablo-tech merged 1 commit into
devfrom
fix/record-subject-attribution

Conversation

@pablo-tech

Copy link
Copy Markdown
Contributor

What is wrong today

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, regen-log/[id].ts. A
clinician holding a live grant writes on the patient's behalf, so the row named the wrong account.

Erasure is where it hurt. erasure.ts counts objects inside the subject's namespaces, finds one
attributed 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: false for ever, their plaintext PDF
still in the bucket, with no route that could ever resolve it.

The fix

recordSubject(access, callerAccountId) in functions/_lib/raw-owner.ts, applied at all five sites.
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. 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 an oversight.

Production is clean

Measured before anything was changed, read-only, account ids only, no decryption, against
health-identity-prod: 44 raw_objects rows 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 IGNORE could not have done anyway. Zero orphaned-chat rows is what bounds it: that is
the one key the escape hatch always writes fresh, so INSERT OR IGNORE can never have masked its
mis-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_PASSPHRASE
and decryptVault appear nowhere in it. functions/_lib/namespace-key.ts is the parser it 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, derived rather than restated

  • tests/unit/namespace-key.test.ts 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.
  • tests/unit/raw-object-attribution.test.ts 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 — 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.
  • tests/unit/erasure-function.test.ts 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.

Verification

4305 tests over 347 files pass on both backends (Miniflare, and TEST_BACKEND=node); oxlint and
tsc --noEmit clean. 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.

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/functions return nothing — but the
fixed form is recordRawObject(env.DB, key, recordSubject(access, session.accountId)), which
contains that string. It would have reported PASS on the unfixed tree too. The guard that actually
distinguishes them needs a negative lookahead:

grep -rnP 'recordRawObject\((?:(?!recordSubject).)*session\.accountId' apps/lexitar/functions/

In the fixed form the span between recordRawObject( and session.accountId contains
recordSubject, so there is no match; in the broken form there is. Validated in both directions.

Two of the three x-replace-unreadable route tests the plan asks for already exist. It says the
string "appears nowhere in the repo"; in fact 1676c70 (19 Oct) added a whole block to
tests/unit/chat-history-function.test.ts covering the retention copy under orphaned-chat/ and a
failed retention put leaving the original in place, more thoroughly than the plan described. The
one genuinely missing third was attribution of that retained copy, which is covered here.

🤖 Generated with Claude Code

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>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying health-dash-main with  Cloudflare Pages  Cloudflare Pages

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

View logs

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying health-dash-dev with  Cloudflare Pages  Cloudflare Pages

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

View logs

@pablo-tech
pablo-tech merged commit 9a2955b into dev Oct 24, 2026
17 checks passed
@pablo-tech
pablo-tech deleted the fix/record-subject-attribution branch October 24, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant