Skip to content

fix(core): seal a person's fields only under the key the state read them under - #219

Merged
benatespina merged 2 commits into
mainfrom
fix/personal-key-identity
Oct 31, 2026
Merged

benatespina merged 2 commits into
mainfrom
fix/personal-key-identity

Conversation

@benatespina

Copy link
Copy Markdown
Member

Stacked on #213 (feat/personal-data).

The gap

7ca0640 refuses 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 guarded put (existence only).

The fix

  • KeyedPerson { person, fingerprint } replaces string in AppendArgs.persons, LoadedAggregate.persons, Snapshot.persons and IdempotencyRecord.persons. The fingerprint is the SHA-256 of the key the person's data was read under.
  • withPersonalData.load reports in LoadResult.fingerprints which 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 before forget, events after) keeps both, so every guard refuses them.
  • Sealing a person the state held under a key throws the same ConcurrencyError when their key is missing or different.
  • personal_keys keeps each key's fingerprint (PersonalKeyStore deals in { key, fingerprint }), so the snapshot and idempotency put compare it in the same statement: SQLite and the Durable Object have no SHA-256 in SQL. persons is JSON in SQLite and jsonb in PostgreSQL (was text[]).
  • fingerprint: null marks 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 threw ConcurrencyError).

Verified vs judgement

Verified by tests:

  • The old existence-only guard fails the new unit test and both new end-to-end tests (load from the stream, load from a snapshot).
  • The exact 7ca0640 semantics fail the new unit-of-work test.
  • New storage-contract cases (snapshot and idempotency stores refuse a person named under another key, or under two keys) run on memory, SQLite, PostgreSQL and workerd.

Judgement:

  • A list of pairs over a person → fingerprint map: it keeps a person read under two keys visible to the guard.
  • A fingerprint column over hashing in SQL.
  • jsonb in PostgreSQL over parallel arrays: one column, the same shape as SQLite.
  • LoadResult.fingerprints sits on the adapter-facing interface, like AppendArgs.persons: stores leave it out.

Checks

  • pnpm check green (exit code 0, Docker running: 2240 tests, PostgreSQL suite included).
  • Stryker on touched files: core 98 % (survivors equivalent or on unchanged lines), postgresql 96 % (only encode("json") → "", equivalent with this driver, as for outcome already).

Base automatically changed from feat/personal-data to main October 31, 2026 09:57
…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
benatespina force-pushed the fix/personal-key-identity branch from 4504be1 to bda4150 Compare October 31, 2026 09:59
@benatespina
benatespina merged commit b540e34 into main Oct 31, 2026
6 checks passed
@benatespina
benatespina deleted the fix/personal-key-identity branch October 31, 2026 10:14
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.
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