Repository navigation
fix(core): seal a person's fields only under the key the state read them under - #219
Merged
Merged
Conversation
…hem under
A command that loaded a person's data before forget could append after
another command had given them a fresh key, and seal the old data under
it, readable again. The state now records the fingerprint of the key each
person's data was read under, from the events the load opened or from the
snapshot, and sealing under any other key throws ConcurrencyError, so the
command is decided again. Snapshots and remembered rejections are written
only while every person they name still holds that key.
The personal key store keeps each key's fingerprint, so the SQL guards
compare it in the same statement. Persons become { person, fingerprint },
with null for data staged by the same unit of work and not sealed yet,
which also lets a unit that registers a person and decides another command
on the same stream commit.
Everything this branch changes is unreleased, so the minor changeset of the feature describes it instead of a changeset of its own.
benatespina
force-pushed
the
fix/personal-key-identity
branch
from
October 31, 2026 09:59
4504be1 to
bda4150
Compare
benatespina
added a commit
that referenced
this pull request
Oct 31, 2026
…hem under (#219) * fix(core): seal a person's fields only under the key the state read them under A command that loaded a person's data before forget could append after another command had given them a fresh key, and seal the old data under it, readable again. The state now records the fingerprint of the key each person's data was read under, from the events the load opened or from the snapshot, and sealing under any other key throws ConcurrencyError, so the command is decided again. Snapshots and remembered rejections are written only while every person they name still holds that key. The personal key store keeps each key's fingerprint, so the SQL guards compare it in the same statement. Persons become { person, fingerprint }, with null for data staged by the same unit of work and not sealed yet, which also lets a unit that registers a person and decides another command on the same stream commit. * chore: describe personal key identity in the personal data changeset Everything this branch changes is unreleased, so the minor changeset of the feature describes it instead of a changeset of its own.
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.
Stacked on #213 (
feat/personal-data).The gap
7ca0640refuses an append whose loaded state held a person who now has no key. It only checked that a key exists: between a command's load and its append,forget(P)could run and another stream's event could give P a fresh key; the append then sealed P's pre-forget data under that key, readable again. Snapshots and remembered rejections had the same hole in their guardedput(existence only).The fix
KeyedPerson { person, fingerprint }replacesstringinAppendArgs.persons,LoadedAggregate.persons,Snapshot.personsandIdempotencyRecord.persons. The fingerprint is the SHA-256 of the key the person's data was read under.withPersonalData.loadreports inLoadResult.fingerprintswhich key opened each person's envelopes; the loader pairs it with the people the state holds, and with a snapshot's own pairs. A person read under two keys (snapshot beforeforget, events after) keeps both, so every guard refuses them.ConcurrencyErrorwhen their key is missing or different.personal_keyskeeps each key's fingerprint (PersonalKeyStoredeals in{ key, fingerprint }), so the snapshot and idempotencyputcompare it in the same statement: SQLite and the Durable Object have no SHA-256 in SQL.personsis JSON in SQLite andjsonbin PostgreSQL (wastext[]).fingerprint: nullmarks data read from events staged by the same unit of work, not sealed yet: no key to compare. This also fixes a bug in feat(core): personal data in events, forgotten by crypto-shredding #213: a unit of work that registered a new person and then decided another command on the same stream could never commit (its batch named the person, who had no key yet, so every commit threwConcurrencyError).Verified vs judgement
Verified by tests:
7ca0640semantics fail the new unit-of-work test.Judgement:
jsonbin PostgreSQL over parallel arrays: one column, the same shape as SQLite.LoadResult.fingerprintssits on the adapter-facing interface, likeAppendArgs.persons: stores leave it out.Checks
pnpm checkgreen (exit code 0, Docker running: 2240 tests, PostgreSQL suite included).encode("json")→"", equivalent with this driver, as foroutcomealready).