Skip to content

feat(pii): per-user DEK envelope encryption for event-log PII (spec 19 stage A)#514

Merged
PaulDotterer merged 18 commits into
mainfrom
feat/spec-19-pii-envelope
Jul 5, 2026
Merged

feat(pii): per-user DEK envelope encryption for event-log PII (spec 19 stage A)#514
PaulDotterer merged 18 commits into
mainfrom
feat/spec-19-pii-envelope

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Part 1 of 4 for #502 (spec 19 — audit retention & crypto-shred erasure). Covers ACs 1–6: the PII envelope. The crypto-shred delete (PR B), retention/prune (PR C), and doctor checks (PR D) follow.

Design (per spec 19)

Every user gets one random 32-byte DEK, stored KEK-wrapped (under the spec-20 single enc:v1 AAD format, bound to the owning user) in the new user_encryption_keys table — classified operational with the durable-non-recoverable justification (AC 5): THE sole non-event-sourced, non-recoverable Postgres state (ADR 0030 amends 0029). PII fields carry pii:"true" on the typed payload structs; the sealer walks them by reflection at append (fail-closed — AC 6), the opener decrypts at projection build (AC 4), so the immutable log holds only ciphertext while projections stay queryable plaintext.

  • Subject resolution: user-stream events → stream_id; off-stream PII (identity links, terminal-admin membership) → the payload's user_id field; unresolvable → refuse the append.
  • Tagged set (19 fields): user emails/names/preferred_username/picture/linux_username, identity-link external_email/external_name, TerminalAdminMembershipRevoked.linux_username (the spec's explicit decision). Untagged with reasons in the commit: password_hash (secret, not identity), ssh keys + locale (revisit), provider slugs, non-string ids.
  • AC 3 guard (pii_guard_test.go): AST-discovers every tagged field (matches-zero ≥10), forces a registry entry per tagged struct whose fixture round-trips every field through a DEK and resolves a subject; stale entries fail. Red-checked.
  • AC 9/10 groundwork: missing DEK row → pii.ErrErased (graceful; PR B projects the sentinel); present-but-unwrappable → hard abort, so a KEK fault can never masquerade as erasure.
  • Minting (AC 1): store.MintUserDEK seam; all four provisioning paths mint BEFORE the creation event (API CreateUser, SCIM createUser, SSO linker auto-create, bootstrap admin). First-write-wins: a re-mint can never replace a key that already sealed PII.
  • The identity-link projector decoders switched from untagged inline structs to the tagged shared payloads structs — an untagged decode shape would have let ciphertext into projections silently.

Tests (real Postgres)

  • Round-trips: user-stream + off-stream — ciphertext in events, plaintext in projections, rebuild reproduces 1:1 (AC 2/4).
  • Fail-closed append: subject without a key → error, zero rows written (AC 6).
  • AC 1 per path: API CreateUser mints exactly one wrapped enc:v1 DEK and its creation event carries no plaintext email; linker auto-create asserts mint-before-append; SCIM covered via the (now-minting) factories through the full SCIM suite.
  • Crypto unit layer: cross-user/cross-field/tampered/wrong-KEK refusals, empty-field + legacy-plaintext passthrough, field-AAD red-checked.

Notes

  • Audit reads now show pii:v1: ciphertext for tagged fields — the privacy-correct default; audit UX/export is audit-log: richer web UI + filtering + export #501 (out of scope here).
  • internal/pii joined the store-projectors CI shard (the self-discovering CI-coverage guard caught the gap).
  • Full gate: gofmt/vet/staticcheck/docref clean, 34 packages, 0 failures. Local coderabbit review was started but has been rate-limit-stalled >30 min; the PR-side review is the gate.

Summary by CodeRabbit

  • New Features

    • Added spec-19 per-user PII envelope encryption: sensitive event fields are now stored sealed and opened during projections.
    • Introduced per-user encryption key lifecycle (mint at user creation, first-write-wins) plus redaction behavior when keys are erased.
    • Wired PII sealing during control-server boot and ensured SSO/SCIM user provisioning mints keys before emitting user-creation events.
  • Bug Fixes

    • Prevented plaintext PII from being written to the immutable event log.
    • Fail-closed behavior when PII cannot be sealed/opened, with safe redaction when DEKs are shredded.
  • Tests

    • Added/expanded unit and Postgres integration tests for sealing/opening, redaction, key minting, and projection round-trips, plus CI coverage adjustments.

…ge A, #502)

- user_encryption_keys migration (010): one KEK-wrapped DEK per user;
  classified operational with the durable-non-recoverable justification
  (AC 5) — THE sole non-event-sourced non-recoverable state, per ADR
  0030/0029.
- crypto: GenerateWrappedDEK / UnwrapDEK (wrap AAD-bound to the owning
  user via RowAAD(user, user-dek)); DEK.SealField/OpenField seal PII
  values under a distinct pii:v1 tag with field-level AAD binding —
  no cross-user, cross-field, or cross-row relocation opens. Legacy
  plaintext passes through OpenField (documented beta no-backfill
  stance); a present-but-unwrappable DEK errors (AC 10 groundwork:
  unwrap-failure is a fault, never erasure).
- Red-checked: field-AAD neutralized -> cross-field test fails.
… (spec 19 stage A, #502)

- crypto: PIIFieldNames / SealPayloadPII / OpenPayloadPII walk a typed
  payload's pii:"true" string/*string fields by reflection, sealing
  under the subject's DEK with the field's json wire name as the AAD
  binding. Seal returns a copy (the caller's payload is never mutated);
  Open works in place for the projector decode path; an unsupported
  tagged field kind fails loudly. The PII set is self-discovered from
  the tags — no hand list.
- store: UserEncryptionKeyRepo (Mint first-write-wins so a race can
  never REPLACE a DEK that already sealed PII; Get with ErrNotFound as
  the graceful erased state; idempotent Shred) + sqlc queries + wiring
  into the Repos facade.
…mpleteness guard (spec 19 stage A, #502)

Tags (19 fields): UserCreatedWithRoles / UserProfileUpdated (email,
display/given/family name, preferred_username, picture,
linux_username), UserEmailChanged, UserLinuxUsernameChanged,
IdentityLinked + IdentityLinkLoginUpdated (external_email,
external_name — off-stream PII, owner = payload user_id), and
TerminalAdminMembershipRevoked.linux_username (explicit spec decision).
Deliberately untagged: password_hash (secret, not identity), role_ids/
linux_uid (non-string identifiers), ssh public keys + locale (revisit
if a DPO requires it), UserLoggedIn.provider (an IdP slug).

TestPIITagGuard_EveryTaggedFieldRoundTripsAndResolvesASubject (AC 3):
AST-discovers every tagged field in the package (matches-zero floored
at 10), requires a registry entry per tagged struct whose fixture (a)
seals to pii:v1 ciphertext and opens back identically through a DEK for
EVERY tagged field, and (b) resolves a subject — stream_id for
user-stream payloads, a populated owner field otherwise. Registry
entries without a tag in source fail as stale. Red-checked in the
missing-entry direction.
…age A, #502)

- store: PIISealer seam — AppendEvent/AppendEventWithVersion seal PII
  before marshalling, FAIL-CLOSED (AC 6): any sealing error aborts the
  append, plaintext PII never reaches the immutable log as a fallback.
  Nil-safe when unwired (bootstrap/tests that predate the wiring).
- internal/pii: Sealer resolves the subject (user-stream → stream_id;
  off-stream → payload user_id field, erroring when unresolvable),
  loads + unwraps the DEK (missing AND unwrappable are both append
  faults), seals via the crypto walker. Opener mirrors it for the
  projector decode path with the AC 9/10 split: missing DEK row →
  ErrErased (graceful, caller sentinels), unwrappable → hard error
  (a KEK fault must never masquerade as erasure).
- crypto.HasSealedPII: fast path so legacy plaintext events and
  factory-seeded map payloads never need a DEK lookup.
…ng path, decrypt-on-project, AC 1/2/4/6 integration tests (spec 19 stage A, #502)

- store: SetPIIMinter/MintUserDEK seam (fail-closed when unwired) so
  every provisioning path mints without constructor plumbing. Minting
  added to: API CreateUser, SCIM createUser, the SSO linker auto-create
  (EventAppender grows MintUserDEK; adapter forwards to the store), and
  the bootstrap admin seed — each BEFORE the creation event, which
  itself carries PII the sealer needs the key for.
- projectors: decodePayloadPII/openSealedPII + SetPIIOpener; ctx
  threaded through the six PII-bearing FromEvent decoders; the identity
  link decoders switched from untagged inline structs to the TAGGED
  shared payloads structs (an untagged shape would have projected
  ciphertext silently). Refusing to project ciphertext when sealed PII
  appears with no opener wired.
- testutil: SetupPostgres wires sealer/opener/minter under the fixed
  test KEK (WirePIIEnvelope, exported for the bootstrap-order test);
  CreateTestUser and the SCIM passwordless factory mint.
- cmd/control: boot wires sealer+opener+minter after initEncryptor.
- CI: internal/pii joins the store-projectors integration shard (the
  self-discovering CI-coverage guard caught the gap).
- Terminal-admin revocation test updated to the new contract: the
  event's linux_username is sealed pii:v1 ciphertext, never plaintext
  (the spec's explicit decision); user_id stays plaintext (it addresses
  the DEK).

Integration tests (real Postgres): user-stream round-trip (ciphertext
in events, plaintext in projections, rebuild 1:1 — AC 2/4), off-stream
identity-link round-trip via payload user_id subject (AC 2/4),
fail-closed append for a subject without a key + nothing written
(AC 6), CreateUser mints exactly one wrapped DEK and the creation
event carries no plaintext email (AC 1/2), linker auto-create mints
before appending (AC 1), re-mint never replaces an existing DEK.
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PaulDotterer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bf78637a-c4df-4e54-9480-531adb9f116a

📥 Commits

Reviewing files that changed from the base of the PR and between fe6ccbd and 6fb91f1.

📒 Files selected for processing (2)
  • internal/crypto/pii_walk.go
  • internal/crypto/pii_walk_test.go
📝 Walkthrough

Walkthrough

This PR adds per-user DEK-based PII envelope encryption, wires sealing into event append paths, opens or redacts sealed PII during projection decoding, stores wrapped DEKs per user, and mints keys before user-creation events across the main creation flows.

Changes

PII Envelope Encryption

Layer / File(s) Summary
DEK and field crypto primitives
internal/crypto/pii.go, internal/crypto/pii_walk.go, internal/crypto/pii_test.go, internal/crypto/pii_walk_test.go
Adds wrapped DEK generation and unwrapping, field-level seal/open helpers, reflection-based PII field walking, and tests for round-trips, tampering, passthrough, and redaction.
PII payload contracts and guard test
internal/eventtypes/payloads/*.go, internal/eventtypes/payloads/pii_guard_test.go
Marks identity, terminal, and user payload fields as PII and adds an AST/runtime guard that checks tagged fields, field names, and subject resolution.
UserEncryptionKey storage and store wiring
internal/store/migrations/010_v2026_08.sql, internal/store/queries/user_encryption_keys.sql, internal/store/user_encryption_key.go, internal/store/postgres/user_encryption_key.go, internal/store/postgres/wire.go, internal/store/repos.go, internal/store/schema_classification_test.go
Adds the per-user encryption-key table, queries, repository interface/implementation, and store wiring for the new repo.
Sealer, Minter, Opener, and projector decode support
internal/pii/pii.go, internal/pii/opener.go, internal/projectors/decode.go
Implements append-time PII sealing, user DEK minting, decode-time opening/redaction, and sealed-PII detection/wiring for projectors.
Projector decoders and listeners
internal/projectors/identity_provider.go, internal/projectors/identity_provider_listener.go, internal/projectors/user.go, internal/projectors/user_listener.go, related tests
Threads context through identity/user projector decoders and listeners, and switches them to PII-aware payload decoding/opening.
DEK minting at user-creation entry points
cmd/control/admin_user.go, internal/api/user_handler.go, internal/api/sso_handler.go, internal/idp/linker.go, internal/scim/users_create.go, related tests
Mints a user DEK before emitting UserCreatedWithRoles across bootstrap, API, SSO, SCIM, and auto-create flows, with tests updated to seed or assert minting.
Boot-time PII wiring
cmd/control/main.go
Initializes the sealer, minter, and opener during startup and registers them on store/projector wiring.
Test wiring and integration coverage
internal/testutil/postgres.go, internal/testutil/factories_user.go, cmd/control/bootstrap_test.go, internal/scim/users_link_test.go, internal/api/user_dek_mint_test.go, internal/api/system_actions_terminal_admin_test.go, internal/pii/envelope_integration_test.go, .github/workflows/test.yml
Adds shared PII test wiring, integration tests for round-trip/fail-closed/redaction/idempotency behavior, updates existing assertions for sealed payloads, and includes internal/pii in the CI shard.

Estimated code review effort: 4 (Complex) | ~75 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is clear, concise, and accurately summarizes the main change: per-user DEK envelope encryption for event-log PII in spec 19 stage A.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/spec-19-pii-envelope

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/pii/envelope_integration_test.go (1)

21-28: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Global (non-stream-scoped) event lookup — fine given per-test DB isolation, but fragile if reused elsewhere.

eventField selects the most recent event of a given event_type across the whole events table with no stream_id/stream_type filter. This is safe today because each test gets its own database and only appends one event of a given type, but if this helper is reused in a test that appends multiple events of the same type, it will silently pick the wrong row instead of failing loudly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/pii/envelope_integration_test.go` around lines 21 - 28, The helper
eventField currently looks up the latest row by event_type across the entire
events table, which is fragile outside isolated tests. Update eventField to
scope the query by stream identifier as well (using the stream_id/stream_type
available from the test context or helper inputs), or otherwise make it fail
explicitly when multiple matches exist. Keep the change localized to eventField
and any callers that need the additional filter parameters.
internal/crypto/pii.go (1)

93-134: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider guarding SealField against re-sealing an already-sealed value.

SealField treats any non-empty input as plaintext to encrypt. The shown caller (Sealer.SealEvent, internal/pii/pii.go:56-74) only skips sealing when there are no tagged fields at all — it never checks HasSealedPII first. If an append is ever retried with the same in-memory Event.Data (e.g. after a transient store error), the field would be sealed twice: the outer OpenField on read would only unwrap once, yielding the still-sealed inner string as the "plaintext" instead of failing loudly the way the rest of this file is designed to (e.g. UnwrapDEK's AC 10 fail-closed stance).

Given this file explicitly favors "fail loudly rather than silently corrupt" elsewhere, the same posture would help here.

🔒 Proposed guard against double-sealing
 func (d *DEK) SealField(plaintext, field string) (string, error) {
 	if plaintext == "" {
 		return "", nil
 	}
+	if strings.HasPrefix(plaintext, piiPrefix) {
+		return "", fmt.Errorf("crypto: refusing to re-seal an already-sealed PII field %s", field)
+	}
 	if field == "" {
 		return "", errors.New("crypto: refusing to seal PII without a field binding")
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/crypto/pii.go` around lines 93 - 134, SealField currently encrypts
any non-empty input, so already-sealed values can be sealed again if
Sealer.SealEvent retries with the same Event.Data. Add a guard in SealField to
detect and reject or skip inputs already marked as sealed (using the
piiPrefix/HasSealedPII check), and update Sealer.SealEvent to avoid calling
SealField on values that are already sealed. Keep the behavior fail-closed like
UnwrapDEK by ensuring double-sealing does not silently produce nested
ciphertext.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/projectors/decode.go`:
- Around line 89-103: Handle the redaction sentinel in the PII decode path
before it reaches listener dispatch or rebuild flow: in `decodePayloadPII` and
the call sites that feed `RebuildAll`/listeners, detect `pii.ErrErased` and
route it to the sentinel projection path instead of returning it as a hard
failure. Use the existing `openSealedPII`, `decodePayloadPII`, and any event
dispatch helpers around `RebuildAll` to ensure erased-user events still project
the erased state rather than aborting the projection pipeline.

---

Nitpick comments:
In `@internal/crypto/pii.go`:
- Around line 93-134: SealField currently encrypts any non-empty input, so
already-sealed values can be sealed again if Sealer.SealEvent retries with the
same Event.Data. Add a guard in SealField to detect and reject or skip inputs
already marked as sealed (using the piiPrefix/HasSealedPII check), and update
Sealer.SealEvent to avoid calling SealField on values that are already sealed.
Keep the behavior fail-closed like UnwrapDEK by ensuring double-sealing does not
silently produce nested ciphertext.

In `@internal/pii/envelope_integration_test.go`:
- Around line 21-28: The helper eventField currently looks up the latest row by
event_type across the entire events table, which is fragile outside isolated
tests. Update eventField to scope the query by stream identifier as well (using
the stream_id/stream_type available from the test context or helper inputs), or
otherwise make it fail explicitly when multiple matches exist. Keep the change
localized to eventField and any callers that need the additional filter
parameters.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1f866c94-e13f-45a1-91d2-b6885cacc342

📥 Commits

Reviewing files that changed from the base of the PR and between 16a1150 and 23487e0.

⛔ Files ignored due to path filters (2)
  • internal/store/generated/models.go is excluded by !**/generated/**
  • internal/store/generated/user_encryption_keys.sql.go is excluded by !**/generated/**
📒 Files selected for processing (40)
  • .github/workflows/test.yml
  • cmd/control/admin_user.go
  • cmd/control/bootstrap_test.go
  • cmd/control/main.go
  • internal/api/sso_handler.go
  • internal/api/system_actions_terminal_admin_test.go
  • internal/api/user_dek_mint_test.go
  • internal/api/user_handler.go
  • internal/crypto/pii.go
  • internal/crypto/pii_test.go
  • internal/crypto/pii_walk.go
  • internal/crypto/pii_walk_test.go
  • internal/eventtypes/payloads/identity_link.go
  • internal/eventtypes/payloads/pii_guard_test.go
  • internal/eventtypes/payloads/terminal.go
  • internal/eventtypes/payloads/user.go
  • internal/idp/linker.go
  • internal/idp/linker_test.go
  • internal/pii/envelope_integration_test.go
  • internal/pii/opener.go
  • internal/pii/pii.go
  • internal/projectors/decode.go
  • internal/projectors/identity_provider.go
  • internal/projectors/identity_provider_listener.go
  • internal/projectors/identity_provider_test.go
  • internal/projectors/user.go
  • internal/projectors/user_listener.go
  • internal/projectors/user_test.go
  • internal/scim/users_create.go
  • internal/scim/users_link_test.go
  • internal/store/migrations/010_v2026_08.sql
  • internal/store/postgres/user_encryption_key.go
  • internal/store/postgres/wire.go
  • internal/store/queries/user_encryption_keys.sql
  • internal/store/repos.go
  • internal/store/schema_classification_test.go
  • internal/store/store.go
  • internal/store/user_encryption_key.go
  • internal/testutil/factories_user.go
  • internal/testutil/postgres.go

Comment thread internal/projectors/decode.go
…t (CR finding, #502)

CodeRabbit (major): decodePayloadPII documented an ErrErased →
sentinel path but the listeners/RebuildAll bubbled it as a hard
failure, so replaying a PII event for a crypto-shredded user would
abort a live projection or a full rebuild instead of projecting the
erased state (AC 9).

Handle it at the decode seam once, for all callers: on pii.ErrErased,
crypto.RedactPayloadPII collapses every tagged field to the shared
RedactionSentinel ('[redacted]', never null/ciphertext) and decode
SUCCEEDS. Any OTHER open failure (unwrappable DEK, tampered
ciphertext) still aborts (AC 10 — a KEK fault must never masquerade
as erasure). Integration test: shred a user's DEK, rebuild completes
and reproduces the sentinel (red-checked by neutralizing the catch);
crypto unit tests cover the redaction walker.

projectors now imports internal/pii for the ErrErased sentinel — not
circular (pii imports only store+crypto); the earlier decoupling
comment is corrected.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/pii/envelope_integration_test.go (1)

176-177: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer the exported crypto.RedactionSentinel over the hardcoded literal.

The production redaction value is defined as an exported constant (crypto.RedactionSentinel == "[redacted]" in internal/crypto/pii_walk.go). Asserting against the literal here decouples the test from the source of truth, so a future change to the sentinel would silently pass in production but require a manual test edit.

♻️ Use the exported constant
-	assert.Equal(t, "[redacted]", u2.DisplayName,
-		"an erased subject's PII must reproduce as the redaction sentinel")
+	assert.Equal(t, crypto.RedactionSentinel, u2.DisplayName,
+		"an erased subject's PII must reproduce as the redaction sentinel")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/pii/envelope_integration_test.go` around lines 176 - 177, The
assertion in the envelope integration test is hardcoded to the redaction string
literal instead of using the shared source of truth. Update the test that checks
the erased subject’s DisplayName to reference crypto.RedactionSentinel so it
stays aligned with the production value defined in the redaction logic, and keep
the assertion message unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/crypto/pii_walk.go`:
- Around line 44-64: RedactPayloadPII currently skips any pii:"true" field that
is not a string or *string, so unsupported tagged fields are left unchanged
instead of failing closed. Update the redaction logic in the PII walk code to
mirror walkPII by detecting any tagged field with an unsupported kind and
returning an error rather than continuing, while keeping the existing string and
*string redaction behavior for supported fields.

---

Nitpick comments:
In `@internal/pii/envelope_integration_test.go`:
- Around line 176-177: The assertion in the envelope integration test is
hardcoded to the redaction string literal instead of using the shared source of
truth. Update the test that checks the erased subject’s DisplayName to reference
crypto.RedactionSentinel so it stays aligned with the production value defined
in the redaction logic, and keep the assertion message unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9038d32e-a1c4-4c70-9b3e-14561fcccc87

📥 Commits

Reviewing files that changed from the base of the PR and between 23487e0 and fe6ccbd.

📒 Files selected for processing (4)
  • internal/crypto/pii_walk.go
  • internal/crypto/pii_walk_test.go
  • internal/pii/envelope_integration_test.go
  • internal/projectors/decode.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/projectors/decode.go

Comment thread internal/crypto/pii_walk.go
… (CR finding, #502)

CodeRabbit (minor): RedactPayloadPII silently skipped a pii:"true"
field that is neither string nor *string, while walkPII (seal/open)
fails loudly on the same. A future non-string PII field would then be
left un-redacted in an 'erased' row. Add the matching default-case
error so the two walkers agree. The AC 3 guard already blocks such a
field from merging (its seal/open round-trip fails), so this is
defense in depth. Test pins the fail-closed behavior.
@PaulDotterer PaulDotterer merged commit 265298a into main Jul 5, 2026
11 checks passed
@PaulDotterer PaulDotterer deleted the feat/spec-19-pii-envelope branch July 5, 2026 06:58
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