Skip to content

refactor: typed event payload structs + roundtrip tests (PR F of cleanup)#192

Merged
PaulDotterer merged 3 commits into
mainfrom
refactor/typed-event-payloads
May 9, 2026
Merged

refactor: typed event payload structs + roundtrip tests (PR F of cleanup)#192
PaulDotterer merged 3 commits into
mainfrom
refactor/typed-event-payloads

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented May 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Server-only refactor; no SDK or proto changes.

Promotes the projector decoder Raw structs to exported payload structs in a shared internal/eventtypes/payloads/ package. Handler emit sites now construct typed structs instead of map[string]any literals. Catches handler/projector schema drift at compile time — exactly the bug class behind issue #135's partial-write hazard.

One enabler change

store.Event.Data type is broadened from map[string]any to any so call sites can pass typed structs. AppendEvent's existing JSON marshal accepts any JSON-marshalable value; legacy map-literal call sites continue to compile unchanged.

Files created

  • internal/eventtypes/payloads/{user,device,action,assignment,execution,lps_password,luks_key}.go — 36 typed payload structs across 7 stream types
  • internal/eventtypes/payloads/doc.go — package docs (field-tag rules, omitempty/pointer rationale)
  • internal/eventtypes/payloads/payloads_test.go — 32 roundtrip tests (one per payload), each marshal → unmarshal → assert.Equal

Files modified

  • internal/store/store.goEvent.Data: any
  • internal/projectors/{user,device,action,assignment,execution,lps_password,luks_key}.go — decoders use payloads.X structs
  • ~30 handler emit sites converted to typed payloads:
    • internal/api/user_handler.go — 11 sites
    • internal/api/device_handler.go — 6 sites
    • internal/api/registration_handler.go — DeviceRegistered
    • internal/api/certificate_handler.go — DeviceCertRenewed
    • internal/api/system_actions.go — AssignmentCreated + UserSystemActionLinked
    • internal/control/inbox_worker.go — 5 execution sites + introduced commandOutputPayload helper

Deferred to PR G

~50 emit sites still use map[string]any. They continue to work because Event.Data is now any, but they don't get the compile-time schema-drift guarantee. PR G (final tech-debt audit) will sweep them mechanically.

Security note

lps_password and luks_key payload structs preserve the masked LogValue() method from the projector originals so neither password nor passphrase can leak into slog output.

Test plan

  • go build ./..., go vet ./..., staticcheck ./..., gofmt -l . all clean.
  • 32 new roundtrip tests pass.
  • All projector + handler tests still pass.
  • CR-CLI returned 0 findings before push.

Part of the 2026.06 cleanup-release sweep. PR G (final tech-debt audit + remaining emit-site sweep + map[string]string sweep) follows.

Summary by CodeRabbit

  • Chores
    • Replaced ad‑hoc event payloads with structured, typed event payloads across the system and unified event storage to accept general payloads, improving consistency and safety.
  • Tests
    • Added comprehensive round‑trip tests for event payloads and tests ensuring sensitive fields are redacted in logs.
  • Documentation
    • Added package‑level documentation describing payload JSON conventions.

…nup)

Server-only refactor; no SDK or proto changes.

Promotes the projector decoder Raw structs to exported payload
structs in a shared internal/eventtypes/payloads/ package. Handler
emit sites now construct typed structs instead of map[string]any
literals. Catches handler/projector schema drift at compile time
— exactly the bug class behind issue #135's partial-write hazard.

One enabler change: store.Event.Data type is broadened from
map[string]any to any so call sites can pass typed structs.
AppendEvent's existing JSON marshal accepts any JSON-marshalable
value; legacy map-literal call sites continue to compile unchanged.

Files created:
- internal/eventtypes/payloads/{user,device,action,assignment,
  execution,lps_password,luks_key}.go — 36 typed payload structs
  across 7 stream types
- internal/eventtypes/payloads/doc.go — package docs
- internal/eventtypes/payloads/payloads_test.go — 32 roundtrip
  tests (one per payload), each marshal → unmarshal → assert.Equal

Files modified:
- internal/store/store.go — Event.Data: any
- internal/projectors/{user,device,action,assignment,execution,
  lps_password,luks_key}.go — decoders use payloads.X structs
- ~30 handler emit sites converted to typed payloads:
  - internal/api/user_handler.go — 11 sites
  - internal/api/device_handler.go — 6 sites
  - internal/api/registration_handler.go — DeviceRegistered
  - internal/api/certificate_handler.go — DeviceCertRenewed
  - internal/api/system_actions.go — AssignmentCreated +
    UserSystemActionLinked
  - internal/control/inbox_worker.go — 5 execution sites +
    introduced commandOutputPayload helper

Deferred to PR G (final tech-debt audit): ~50 emit sites still
use map[string]any. They continue to work because Event.Data is
now any, but they don't get the compile-time schema-drift
guarantee. Mechanical follow-up: add payload structs in remaining
files (action.go, internal_handler.go, idp/sso/auth handlers,
SCIM, role/group handlers, settings, totp, terminal, compliance)
and update emit sites.

Note: lps_password and luks_key payload structs preserve the
masked LogValue() method from the projector originals so neither
password nor passphrase can leak into slog output.

Part of the 2026.06 cleanup-release sweep. PR G (final audit)
follows.
@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 273c7a96-0b62-49d1-a1df-2ad6f0ea7e11

📥 Commits

Reviewing files that changed from the base of the PR and between 6d3fd0a and 62f7633.

📒 Files selected for processing (1)
  • internal/eventtypes/payloads/payloads_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/eventtypes/payloads/payloads_test.go

📝 Walkthrough

Walkthrough

This PR introduces a shared typed payloads package, broadens Event.Data to any, updates handlers to emit typed payload structs, refactors projectors to unmarshal those types, and adds comprehensive JSON roundtrip tests.

Changes

Typed Event Payloads Migration

Layer / File(s) Summary
Event Data Contract
internal/store/store.go
Event.Data field type broadened from map[string]any to any to support typed payload structs while preserving legacy map compatibility.
Typed Payload Struct Definitions
internal/eventtypes/payloads/doc.go, internal/eventtypes/payloads/user.go, internal/eventtypes/payloads/device.go, internal/eventtypes/payloads/action.go, internal/eventtypes/payloads/assignment.go, internal/eventtypes/payloads/execution.go, internal/eventtypes/payloads/lps_password.go, internal/eventtypes/payloads/luks_key.go
New package defines typed JSON wire payload structs for user, device, action, assignment, execution, and secret-rotation events with pointer fields and json.RawMessage for selective omission and arbitrary JSON blobs.
Handler Event Emission Updates
internal/api/user_handler.go, internal/api/device_handler.go, internal/api/certificate_handler.go, internal/api/registration_handler.go, internal/api/system_actions.go, internal/control/inbox_worker.go
Handlers updated to construct and emit payloads.* typed structs instead of map[string]any; inbox worker introduces commandOutputPayload helper and removes legacy map mutation.
Projector Payload Decoding Refactoring
internal/projectors/user.go, internal/projectors/device.go, internal/projectors/action.go, internal/projectors/assignment.go, internal/projectors/execution.go, internal/projectors/lps_password.go, internal/projectors/luks_key.go
Projectors now unmarshal persisted event JSON into shared payloads.* types, removing local raw decode structs; device.go adds parseOptionalRFC3339 helper for timestamp parsing.
Roundtrip JSON Compatibility Tests
internal/eventtypes/payloads/payloads_test.go
New test file with many roundtrip tests validating json.Marshal then json.Unmarshal equality for payload types; tests also cover RawCommandOutput omission and LogValue redaction.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through bytes and typed each field,

No more maps—clear shapes revealed,
Handlers emit, projectors read,
Tests ensure each JSON seed,
A tidy hop where events yield.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: introducing typed event payload structs and roundtrip tests as part of a cleanup refactor series.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/typed-event-payloads

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

@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.

🧹 Nitpick comments (3)
internal/eventtypes/payloads/lps_password.go (1)

26-39: ⚡ Quick win

Add explicit redaction tests for secret LogValue() outputs.

Please add focused tests asserting LpsPasswordRotated.LogValue() (and the sibling LuksKeyRotated.LogValue()) never emit raw credential values. This is a small guardrail with strong security payoff.

🤖 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/eventtypes/payloads/lps_password.go` around lines 26 - 39, Add unit
tests that construct LpsPasswordRotated and LuksKeyRotated instances with known
secret values and call their LogValue() methods, then assert the returned
slog.Value does not contain the original secret and that the respective fields
("password" for LpsPasswordRotated, "key" or equivalent for LuksKeyRotated)
equal the explicit redaction token (e.g. "[REDACTED]"). Locate the LogValue
implementations on LpsPasswordRotated and LuksKeyRotated and in tests
extract/format the slog.Value or traverse its group entries to verify absence of
the raw secret and presence of the redacted string; fail the test if the raw
credential is found or the redaction token differs. Ensure tests cover both
positive (redacted) and negative (would-be leak) checks using concrete sample
secrets.
internal/eventtypes/payloads/payloads_test.go (2)

421-460: ⚡ Quick win

Add explicit redaction tests for the secret-bearing payloads.

These cases only verify JSON round-trip. They do not pin the masking contract for LpsPasswordRotated / LuksKeyRotated, so a future regression could still leak Password or Passphrase into structured logs while this file stays green. A focused LogValue()/logging assertion here would cover one of the most security-sensitive guarantees in the PR.

🤖 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/eventtypes/payloads/payloads_test.go` around lines 421 - 460, Add
focused redaction tests that assert LpsPasswordRotated and LuksKeyRotated do not
expose secrets when converted to log-friendly form: for each type
(payloads.LpsPasswordRotated and payloads.LuksKeyRotated) construct a value
containing a clear secret in Password/Passphrase, call the struct's logging
representation (LogValue() or the method used by your logging helpers) and
assert the returned map/fields do not contain the raw secret and instead contain
a redacted marker (or omitted), then keep the existing JSON round-trip checks;
reference the types LpsPasswordRotated, LuksKeyRotated and their
LogValue()/logging helper to locate where to add the new assertions.

462-484: ⚡ Quick win

Cover RawCommandOutput directly, not just CommandOutput.

TestRoundtrip_CommandOutput validates the struct tags, but it never goes through payloads.RawCommandOutput, so it would not catch a regression where the helper starts producing a quoted JSON string or stops omitting nil output. A small helper-focused test would lock down the exact nested-wire contract used by ExecutionTerminal and ExecutionTimedOut.

🤖 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/eventtypes/payloads/payloads_test.go` around lines 462 - 484,
TestRoundtrip_CommandOutput only exercises payloads.CommandOutput struct tags
and misses the actual nested-wire contract produced/consumed by
payloads.RawCommandOutput; add a focused test that constructs a
payloads.RawCommandOutput (using the same helper/path that ExecutionTerminal and
ExecutionTimedOut use), marshal it to JSON and unmarshal back, and assert the
wire shape exactly matches the legacy keys (stdout, stderr, exit_code), that
nil/empty fields are omitted as expected and that the value is a JSON object
(not a quoted JSON string). Reference payloads.RawCommandOutput and the
execution payloads ExecutionTerminal/ExecutionTimedOut when locating the helper
to produce the RawCommandOutput.
🤖 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.

Nitpick comments:
In `@internal/eventtypes/payloads/lps_password.go`:
- Around line 26-39: Add unit tests that construct LpsPasswordRotated and
LuksKeyRotated instances with known secret values and call their LogValue()
methods, then assert the returned slog.Value does not contain the original
secret and that the respective fields ("password" for LpsPasswordRotated, "key"
or equivalent for LuksKeyRotated) equal the explicit redaction token (e.g.
"[REDACTED]"). Locate the LogValue implementations on LpsPasswordRotated and
LuksKeyRotated and in tests extract/format the slog.Value or traverse its group
entries to verify absence of the raw secret and presence of the redacted string;
fail the test if the raw credential is found or the redaction token differs.
Ensure tests cover both positive (redacted) and negative (would-be leak) checks
using concrete sample secrets.

In `@internal/eventtypes/payloads/payloads_test.go`:
- Around line 421-460: Add focused redaction tests that assert
LpsPasswordRotated and LuksKeyRotated do not expose secrets when converted to
log-friendly form: for each type (payloads.LpsPasswordRotated and
payloads.LuksKeyRotated) construct a value containing a clear secret in
Password/Passphrase, call the struct's logging representation (LogValue() or the
method used by your logging helpers) and assert the returned map/fields do not
contain the raw secret and instead contain a redacted marker (or omitted), then
keep the existing JSON round-trip checks; reference the types
LpsPasswordRotated, LuksKeyRotated and their LogValue()/logging helper to locate
where to add the new assertions.
- Around line 462-484: TestRoundtrip_CommandOutput only exercises
payloads.CommandOutput struct tags and misses the actual nested-wire contract
produced/consumed by payloads.RawCommandOutput; add a focused test that
constructs a payloads.RawCommandOutput (using the same helper/path that
ExecutionTerminal and ExecutionTimedOut use), marshal it to JSON and unmarshal
back, and assert the wire shape exactly matches the legacy keys (stdout, stderr,
exit_code), that nil/empty fields are omitted as expected and that the value is
a JSON object (not a quoted JSON string). Reference payloads.RawCommandOutput
and the execution payloads ExecutionTerminal/ExecutionTimedOut when locating the
helper to produce the RawCommandOutput.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a4c26867-1a07-4c1e-aa79-f257fd29b5ba

📥 Commits

Reviewing files that changed from the base of the PR and between 6630773 and da3f0c0.

📒 Files selected for processing (23)
  • internal/api/certificate_handler.go
  • internal/api/device_handler.go
  • internal/api/registration_handler.go
  • internal/api/system_actions.go
  • internal/api/user_handler.go
  • internal/control/inbox_worker.go
  • internal/eventtypes/payloads/action.go
  • internal/eventtypes/payloads/assignment.go
  • internal/eventtypes/payloads/device.go
  • internal/eventtypes/payloads/doc.go
  • internal/eventtypes/payloads/execution.go
  • internal/eventtypes/payloads/lps_password.go
  • internal/eventtypes/payloads/luks_key.go
  • internal/eventtypes/payloads/payloads_test.go
  • internal/eventtypes/payloads/user.go
  • internal/projectors/action.go
  • internal/projectors/assignment.go
  • internal/projectors/device.go
  • internal/projectors/execution.go
  • internal/projectors/lps_password.go
  • internal/projectors/luks_key.go
  • internal/projectors/user.go
  • internal/store/store.go

)

Three nitpick test additions from CR review:

- TestLpsPasswordRotated_LogValueMasksPassword: locks the
  security guarantee — routing the payload through slog (e.g.
  slog.Warn("...", "payload", payload)) must NEVER emit the raw
  Password value. Asserts both 'NotContains(secret)' and
  'Contains([REDACTED])'.
- TestLuksKeyRotated_LogValueMasksPassphrase: sibling for LUKS.
- TestRawCommandOutput_NilOmitted +
  TestRawCommandOutput_ProducesObject: locks the wire contract
  used by ExecutionTerminal / ExecutionTimedOut. Nil input
  produces nil so omitempty fires; non-nil input produces a
  JSON object (NOT a quoted string), with the exact legacy keys
  (stdout, stderr, exit_code), and round-trips cleanly through
  ExecutionTerminal.

@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: 2

🤖 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/eventtypes/payloads/payloads_test.go`:
- Around line 477-484: The test only checks presence of legacy keys but allows
extra keys; after unmarshalling into asMap use a strict check on the key set
(e.g. require.Len(t, asMap, 3) and keep/assert the three contains for "stdout",
"stderr", "exit_code" or assert that the map keys exactly equal that slice) to
enforce the legacy wire shape; apply the same change to the second occurrence
around the asMap check at the other block (the one noted as also applying to
lines 514-520) so both tests fail if any extra keys are present.
- Around line 486-493: The test only checks RawCommandOutput(nil) == nil but
should also assert the parent payload omits the field when marshaled; update
TestRawCommandOutput_NilOmitted to construct an ExecutionTerminal (and/or
ExecutionTimedOut) payload with the command output set to
payloads.RawCommandOutput(nil) (or left nil), json.Marshal that payload, and
assert the resulting JSON does not contain the command output field name (so
omitempty behavior is enforced); reference the payloads.RawCommandOutput helper
and the ExecutionTerminal/ExecutionTimedOut types when adding the
marshal-and-assert step.
🪄 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: f3ef33c7-58f6-4a63-b050-a3c5fd937306

📥 Commits

Reviewing files that changed from the base of the PR and between da3f0c0 and 6d3fd0a.

📒 Files selected for processing (1)
  • internal/eventtypes/payloads/payloads_test.go

Comment thread internal/eventtypes/payloads/payloads_test.go Outdated
Comment thread internal/eventtypes/payloads/payloads_test.go
…atches on PR #192)

Two more nitpick test tightenings from CR review:

- TestRoundtrip_CommandOutput + TestRawCommandOutput_ProducesObject:
  added assert.Len(asMap, 3) so an extra key (regression where a
  new field gets added to CommandOutput) would fail the test.
- TestRawCommandOutput_NilOmitted: added end-to-end omitempty
  contract assertion via ExecutionTerminal — marshal a payload
  with Output: RawCommandOutput(nil) and assert the wire JSON
  does NOT contain the 'output' key. Without this the helper
  could regress to []byte('null') and we'd only catch it via
  downstream projector failures.
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