Skip to content

Commit 62f1f14

Browse files
authored
refactor(projectors): single-source decodePayload[T] + DRY completeness guard (#438)
WS16b DRY-discipline. The ~90 *FromEvent decoders in internal/projectors/ each hand-rolled the identical decode shape: a (stream_type, event_type) guard returning ErrIgnoredEvent, an empty-payload guard, json.Unmarshal(e.Data, &p), and the canonical "projector: invalid <event> payload: %w" wrap. Collapse the boilerplate into one generic decodePayload[T] helper; per-event field validation stays in the caller. 40 decoders converted (-305 LOC net). TestDecodePayloadHelperUsedByAllProjectors AST-walks the package and fails the build if any *FromEvent hand-rolls json.Unmarshal without routing through the helper, unless it is a recorded exception (empty-payload-valid, multi-stream / runtime-eventType, or non-canonical DB-params shape). The allowlist is guarded against stale entries and a vacuous walk. Decoders that route through a shared per-cluster sub-decoder carry no direct json.Unmarshal and are neither flagged nor allowlisted. ADR 0021 records the single-source-helper + self-discovering-test DRY policy and the decision to defer the noisy `dupl` CI backstop in favor of specific completeness tests. Closes #437
1 parent 3b5bf61 commit 62f1f14

22 files changed

Lines changed: 431 additions & 462 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# 0021 — Single-source helpers + DRY discipline
2+
3+
- Status: accepted
4+
- Date: 2026-06-15
5+
- Related: WS16b of the SECURITY_HARDENING_WORKPLAN
6+
(manchtools/power-manage-server#437; the cert/CSR fixture row lands in the
7+
sdk + agent repos); ADR 0020 (the WS16 fail-closed sweep, whose silent-decode
8+
fixes motivated centralizing the projector decode).
9+
10+
## Context
11+
12+
DRY in this codebase is enforced *as code is written* — "extract before you
13+
duplicate" is the Definition of Done, and each feature stream collapses the
14+
duplication clusters in the files it touches, in the same PR. WS16b owns only
15+
the residual clusters no single feature stream spans:
16+
17+
1. **Projector payload decode.** ~90 `*FromEvent` decoders in
18+
`internal/projectors/` each hand-rolled the identical shape: a
19+
`(stream_type, event_type)` guard returning `ErrIgnoredEvent`, an
20+
empty-payload guard, a `json.Unmarshal(e.Data, &p)`, and the canonical
21+
`"projector: invalid <event> payload: %w"` error wrap. No feature stream
22+
touches all projectors, so the duplication never collapsed inline.
23+
2. **Cert/CSR test fixtures.** ECDSA P-256 CA/cert generation was re-implemented
24+
in sdk and agent test files (device/user fixtures were already centralized in
25+
`server/internal/testutil`).
26+
27+
## Decision
28+
29+
- **The strongest DRY mechanism is a single-source helper plus a
30+
self-discovering completeness test, not a generic similarity tool.** Each
31+
cluster collapses to one helper that everything consumes, and an AST guard
32+
makes the *specific* duplication structurally impossible to reintroduce.
33+
34+
- **`decodePayload[T]` (server).** One generic helper centralizes the
35+
projector decode boilerplate; per-event field validation stays in the caller.
36+
`TestDecodePayloadHelperUsedByAllProjectors` AST-walks the package and fails
37+
the build if any `*FromEvent` hand-rolls `json.Unmarshal` without routing
38+
through the helper, unless it is a recorded exception. The exception
39+
allowlist is guarded against stale entries and against a vacuous (zero-match)
40+
walk. The legitimate exceptions are decoders whose payload is empty-valid
41+
(the event carries no body), whose guard spans two stream types or a runtime
42+
event-type parameter, or that build a DB-params struct keyed off the
43+
envelope — shapes the fixed-argument helper cannot express. Decoders that
44+
route through a shared per-cluster sub-decoder carry no direct
45+
`json.Unmarshal` and are neither flagged nor allowlisted.
46+
47+
- **Shared cert/CSR test fixtures (sdk-first).** The CA/cert builders move to a
48+
shared exported helper in the sdk so agent and sdk tests consume one
49+
implementation.
50+
51+
- **The `dupl` CI backstop is deferred, not adopted.** The work plan specified a
52+
`dupl` duplication-detection CI job as a *secondary, allowlisted* backstop,
53+
explicitly blunt on legitimately-similar code. The primary mechanism — the
54+
single-source helper + self-discovering completeness test above — is stronger
55+
and noise-free, so we ship that and do not add the `dupl` job (it would
56+
generate false positives on the many structurally-similar projector/handler
57+
functions). If a future cluster cannot be guarded by a specific completeness
58+
test, the `dupl` job is the documented fallback.
59+
60+
## Consequences
61+
62+
- A new projector decoder cannot hand-roll the decode — it must use
63+
`decodePayload[T]` or justify an allowlist entry under review. The projector
64+
decode boilerplate shrank by ~300 lines.
65+
- Centralizing the decode also centralizes the error-message form, so a future
66+
change to the projector decode contract happens in one place.
67+
- The exception allowlist is the honest record of the shapes the helper does
68+
not cover; the stale-entry guard keeps it shrinking as those shapes are
69+
refactored.

internal/projectors/action.go

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,9 @@ type ActionCreatedPayload struct {
5353
// for any other (stream, event_type) so the listener wrapper can
5454
// silently no-op.
5555
func ActionCreatedFromEvent(e store.PersistedEvent) (ActionCreatedPayload, error) {
56-
if e.StreamType != "action" || e.EventType != string(eventtypes.ActionCreated) {
57-
return ActionCreatedPayload{}, ErrIgnoredEvent
58-
}
59-
if len(e.Data) == 0 {
60-
return ActionCreatedPayload{}, fmt.Errorf("projector: empty ActionCreated payload")
61-
}
62-
var raw payloads.ActionCreated
63-
if err := json.Unmarshal(e.Data, &raw); err != nil {
64-
return ActionCreatedPayload{}, fmt.Errorf("projector: invalid ActionCreated payload: %w", err)
56+
raw, err := decodePayload[payloads.ActionCreated](e, "action", eventtypes.ActionCreated)
57+
if err != nil {
58+
return ActionCreatedPayload{}, err
6559
}
6660
if raw.Name == "" {
6761
return ActionCreatedPayload{}, fmt.Errorf("projector: ActionCreated requires name")
@@ -108,15 +102,9 @@ type ActionRenamedPayload struct {
108102

109103
// ActionRenamedFromEvent decodes ActionRenamed.
110104
func ActionRenamedFromEvent(e store.PersistedEvent) (ActionRenamedPayload, error) {
111-
if e.StreamType != "action" || e.EventType != string(eventtypes.ActionRenamed) {
112-
return ActionRenamedPayload{}, ErrIgnoredEvent
113-
}
114-
if len(e.Data) == 0 {
115-
return ActionRenamedPayload{}, fmt.Errorf("projector: empty ActionRenamed payload")
116-
}
117-
var raw payloads.ActionRenamed
118-
if err := json.Unmarshal(e.Data, &raw); err != nil {
119-
return ActionRenamedPayload{}, fmt.Errorf("projector: invalid ActionRenamed payload: %w", err)
105+
raw, err := decodePayload[payloads.ActionRenamed](e, "action", eventtypes.ActionRenamed)
106+
if err != nil {
107+
return ActionRenamedPayload{}, err
120108
}
121109
if raw.Name == "" {
122110
return ActionRenamedPayload{}, fmt.Errorf("projector: ActionRenamed requires name")

internal/projectors/action_set.go

Lines changed: 15 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,9 @@ type actionSetCreatedRaw struct {
4141
// ErrIgnoredEvent for any other (stream, event_type) so the listener
4242
// wrapper can silently no-op.
4343
func ActionSetCreatedFromEvent(e store.PersistedEvent) (ActionSetCreatedPayload, error) {
44-
if e.StreamType != "action_set" || e.EventType != string(eventtypes.ActionSetCreated) {
45-
return ActionSetCreatedPayload{}, ErrIgnoredEvent
46-
}
47-
if len(e.Data) == 0 {
48-
return ActionSetCreatedPayload{}, fmt.Errorf("projector: empty ActionSetCreated payload")
49-
}
50-
var raw actionSetCreatedRaw
51-
if err := json.Unmarshal(e.Data, &raw); err != nil {
52-
return ActionSetCreatedPayload{}, fmt.Errorf("projector: invalid ActionSetCreated payload: %w", err)
44+
raw, err := decodePayload[actionSetCreatedRaw](e, "action_set", eventtypes.ActionSetCreated)
45+
if err != nil {
46+
return ActionSetCreatedPayload{}, err
5347
}
5448
if raw.Name == "" {
5549
return ActionSetCreatedPayload{}, fmt.Errorf("projector: ActionSetCreated requires name")
@@ -89,15 +83,9 @@ type actionSetRenamedRaw struct {
8983

9084
// ActionSetRenamedFromEvent decodes ActionSetRenamed.
9185
func ActionSetRenamedFromEvent(e store.PersistedEvent) (ActionSetRenamedPayload, error) {
92-
if e.StreamType != "action_set" || e.EventType != string(eventtypes.ActionSetRenamed) {
93-
return ActionSetRenamedPayload{}, ErrIgnoredEvent
94-
}
95-
if len(e.Data) == 0 {
96-
return ActionSetRenamedPayload{}, fmt.Errorf("projector: empty ActionSetRenamed payload")
97-
}
98-
var raw actionSetRenamedRaw
99-
if err := json.Unmarshal(e.Data, &raw); err != nil {
100-
return ActionSetRenamedPayload{}, fmt.Errorf("projector: invalid ActionSetRenamed payload: %w", err)
86+
raw, err := decodePayload[actionSetRenamedRaw](e, "action_set", eventtypes.ActionSetRenamed)
87+
if err != nil {
88+
return ActionSetRenamedPayload{}, err
10189
}
10290
if raw.Name == "" {
10391
return ActionSetRenamedPayload{}, fmt.Errorf("projector: ActionSetRenamed requires name")
@@ -190,15 +178,9 @@ type actionSetMemberRaw struct {
190178

191179
// ActionSetMemberAddedFromEvent decodes ActionSetMemberAdded.
192180
func ActionSetMemberAddedFromEvent(e store.PersistedEvent) (ActionSetMemberAddedPayload, error) {
193-
if e.StreamType != "action_set" || e.EventType != string(eventtypes.ActionSetMemberAdded) {
194-
return ActionSetMemberAddedPayload{}, ErrIgnoredEvent
195-
}
196-
if len(e.Data) == 0 {
197-
return ActionSetMemberAddedPayload{}, fmt.Errorf("projector: empty ActionSetMemberAdded payload")
198-
}
199-
var raw actionSetMemberRaw
200-
if err := json.Unmarshal(e.Data, &raw); err != nil {
201-
return ActionSetMemberAddedPayload{}, fmt.Errorf("projector: invalid ActionSetMemberAdded payload: %w", err)
181+
raw, err := decodePayload[actionSetMemberRaw](e, "action_set", eventtypes.ActionSetMemberAdded)
182+
if err != nil {
183+
return ActionSetMemberAddedPayload{}, err
202184
}
203185
if raw.ActionID == "" {
204186
return ActionSetMemberAddedPayload{}, fmt.Errorf("projector: ActionSetMemberAdded requires action_id")
@@ -223,15 +205,9 @@ type actionSetMemberRemovedRaw struct {
223205

224206
// ActionSetMemberRemovedFromEvent decodes ActionSetMemberRemoved.
225207
func ActionSetMemberRemovedFromEvent(e store.PersistedEvent) (ActionSetMemberRemovedPayload, error) {
226-
if e.StreamType != "action_set" || e.EventType != string(eventtypes.ActionSetMemberRemoved) {
227-
return ActionSetMemberRemovedPayload{}, ErrIgnoredEvent
228-
}
229-
if len(e.Data) == 0 {
230-
return ActionSetMemberRemovedPayload{}, fmt.Errorf("projector: empty ActionSetMemberRemoved payload")
231-
}
232-
var raw actionSetMemberRemovedRaw
233-
if err := json.Unmarshal(e.Data, &raw); err != nil {
234-
return ActionSetMemberRemovedPayload{}, fmt.Errorf("projector: invalid ActionSetMemberRemoved payload: %w", err)
208+
raw, err := decodePayload[actionSetMemberRemovedRaw](e, "action_set", eventtypes.ActionSetMemberRemoved)
209+
if err != nil {
210+
return ActionSetMemberRemovedPayload{}, err
235211
}
236212
if raw.ActionID == "" {
237213
return ActionSetMemberRemovedPayload{}, fmt.Errorf("projector: ActionSetMemberRemoved requires action_id")
@@ -247,15 +223,9 @@ type ActionSetMemberReorderedPayload = ActionSetMemberAddedPayload
247223
// Same field set as ActionSetMemberAdded plus the same default-zero
248224
// behaviour for sort_order.
249225
func ActionSetMemberReorderedFromEvent(e store.PersistedEvent) (ActionSetMemberReorderedPayload, error) {
250-
if e.StreamType != "action_set" || e.EventType != string(eventtypes.ActionSetMemberReordered) {
251-
return ActionSetMemberReorderedPayload{}, ErrIgnoredEvent
252-
}
253-
if len(e.Data) == 0 {
254-
return ActionSetMemberReorderedPayload{}, fmt.Errorf("projector: empty ActionSetMemberReordered payload")
255-
}
256-
var raw actionSetMemberRaw
257-
if err := json.Unmarshal(e.Data, &raw); err != nil {
258-
return ActionSetMemberReorderedPayload{}, fmt.Errorf("projector: invalid ActionSetMemberReordered payload: %w", err)
226+
raw, err := decodePayload[actionSetMemberRaw](e, "action_set", eventtypes.ActionSetMemberReordered)
227+
if err != nil {
228+
return ActionSetMemberReorderedPayload{}, err
259229
}
260230
if raw.ActionID == "" {
261231
return ActionSetMemberReorderedPayload{}, fmt.Errorf("projector: ActionSetMemberReordered requires action_id")

internal/projectors/assignment.go

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,9 @@ type AssignmentCreatedPayload struct {
4040
// failure surface in the listener log instead of producing a half-
4141
// applied row.
4242
func AssignmentCreatedFromEvent(e store.PersistedEvent) (AssignmentCreatedPayload, error) {
43-
if e.StreamType != "assignment" || e.EventType != string(eventtypes.AssignmentCreated) {
44-
return AssignmentCreatedPayload{}, ErrIgnoredEvent
45-
}
46-
if len(e.Data) == 0 {
47-
return AssignmentCreatedPayload{}, fmt.Errorf("projector: empty AssignmentCreated payload")
48-
}
49-
var raw payloads.AssignmentCreated
50-
if err := json.Unmarshal(e.Data, &raw); err != nil {
51-
return AssignmentCreatedPayload{}, fmt.Errorf("projector: invalid AssignmentCreated payload: %w", err)
43+
raw, err := decodePayload[payloads.AssignmentCreated](e, "assignment", eventtypes.AssignmentCreated)
44+
if err != nil {
45+
return AssignmentCreatedPayload{}, err
5246
}
5347
switch {
5448
case raw.SourceType == "":

internal/projectors/compliance.go

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,9 @@ type complianceResultUpdatedRaw struct {
4747
// Returns ErrIgnoredEvent for any other (stream, event_type) so the
4848
// listener wrapper can silently no-op.
4949
func ComplianceResultUpdatedFromEvent(e store.PersistedEvent) (ComplianceResultUpdatedPayload, error) {
50-
if e.StreamType != "compliance" || e.EventType != string(eventtypes.ComplianceResultUpdated) {
51-
return ComplianceResultUpdatedPayload{}, ErrIgnoredEvent
52-
}
53-
if len(e.Data) == 0 {
54-
return ComplianceResultUpdatedPayload{}, fmt.Errorf("projector: empty ComplianceResultUpdated payload")
55-
}
56-
var raw complianceResultUpdatedRaw
57-
if err := json.Unmarshal(e.Data, &raw); err != nil {
58-
return ComplianceResultUpdatedPayload{}, fmt.Errorf("projector: invalid ComplianceResultUpdated payload: %w", err)
50+
raw, err := decodePayload[complianceResultUpdatedRaw](e, "compliance", eventtypes.ComplianceResultUpdated)
51+
if err != nil {
52+
return ComplianceResultUpdatedPayload{}, err
5953
}
6054
if raw.DeviceID == "" {
6155
return ComplianceResultUpdatedPayload{}, fmt.Errorf("projector: ComplianceResultUpdated requires device_id")
@@ -95,15 +89,9 @@ type complianceResultRemovedRaw struct {
9589

9690
// ComplianceResultRemovedFromEvent decodes ComplianceResultRemoved.
9791
func ComplianceResultRemovedFromEvent(e store.PersistedEvent) (ComplianceResultRemovedPayload, error) {
98-
if e.StreamType != "compliance" || e.EventType != string(eventtypes.ComplianceResultRemoved) {
99-
return ComplianceResultRemovedPayload{}, ErrIgnoredEvent
100-
}
101-
if len(e.Data) == 0 {
102-
return ComplianceResultRemovedPayload{}, fmt.Errorf("projector: empty ComplianceResultRemoved payload")
103-
}
104-
var raw complianceResultRemovedRaw
105-
if err := json.Unmarshal(e.Data, &raw); err != nil {
106-
return ComplianceResultRemovedPayload{}, fmt.Errorf("projector: invalid ComplianceResultRemoved payload: %w", err)
92+
raw, err := decodePayload[complianceResultRemovedRaw](e, "compliance", eventtypes.ComplianceResultRemoved)
93+
if err != nil {
94+
return ComplianceResultRemovedPayload{}, err
10795
}
10896
if raw.DeviceID == "" {
10997
return ComplianceResultRemovedPayload{}, fmt.Errorf("projector: ComplianceResultRemoved requires device_id")

internal/projectors/compliance_policy.go

Lines changed: 15 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,9 @@ type compliancePolicyCreatedRaw struct {
3434
// Returns ErrIgnoredEvent for any other (stream, event_type) so the
3535
// listener wrapper can silently no-op.
3636
func CompliancePolicyCreatedFromEvent(e store.PersistedEvent) (CompliancePolicyCreatedPayload, error) {
37-
if e.StreamType != "compliance_policy" || e.EventType != string(eventtypes.CompliancePolicyCreated) {
38-
return CompliancePolicyCreatedPayload{}, ErrIgnoredEvent
39-
}
40-
if len(e.Data) == 0 {
41-
return CompliancePolicyCreatedPayload{}, fmt.Errorf("projector: empty CompliancePolicyCreated payload")
42-
}
43-
var raw compliancePolicyCreatedRaw
44-
if err := json.Unmarshal(e.Data, &raw); err != nil {
45-
return CompliancePolicyCreatedPayload{}, fmt.Errorf("projector: invalid CompliancePolicyCreated payload: %w", err)
37+
raw, err := decodePayload[compliancePolicyCreatedRaw](e, "compliance_policy", eventtypes.CompliancePolicyCreated)
38+
if err != nil {
39+
return CompliancePolicyCreatedPayload{}, err
4640
}
4741
if raw.Name == "" {
4842
return CompliancePolicyCreatedPayload{}, fmt.Errorf("projector: CompliancePolicyCreated requires name")
@@ -73,15 +67,9 @@ type compliancePolicyRenamedRaw struct {
7367

7468
// CompliancePolicyRenamedFromEvent decodes CompliancePolicyRenamed.
7569
func CompliancePolicyRenamedFromEvent(e store.PersistedEvent) (CompliancePolicyRenamedPayload, error) {
76-
if e.StreamType != "compliance_policy" || e.EventType != string(eventtypes.CompliancePolicyRenamed) {
77-
return CompliancePolicyRenamedPayload{}, ErrIgnoredEvent
78-
}
79-
if len(e.Data) == 0 {
80-
return CompliancePolicyRenamedPayload{}, fmt.Errorf("projector: empty CompliancePolicyRenamed payload")
81-
}
82-
var raw compliancePolicyRenamedRaw
83-
if err := json.Unmarshal(e.Data, &raw); err != nil {
84-
return CompliancePolicyRenamedPayload{}, fmt.Errorf("projector: invalid CompliancePolicyRenamed payload: %w", err)
70+
raw, err := decodePayload[compliancePolicyRenamedRaw](e, "compliance_policy", eventtypes.CompliancePolicyRenamed)
71+
if err != nil {
72+
return CompliancePolicyRenamedPayload{}, err
8573
}
8674
if raw.Name == "" {
8775
return CompliancePolicyRenamedPayload{}, fmt.Errorf("projector: CompliancePolicyRenamed requires name")
@@ -148,15 +136,9 @@ type compliancePolicyRuleAddedRaw struct {
148136

149137
// CompliancePolicyRuleAddedFromEvent decodes CompliancePolicyRuleAdded.
150138
func CompliancePolicyRuleAddedFromEvent(e store.PersistedEvent) (CompliancePolicyRuleAddedPayload, error) {
151-
if e.StreamType != "compliance_policy" || e.EventType != string(eventtypes.CompliancePolicyRuleAdded) {
152-
return CompliancePolicyRuleAddedPayload{}, ErrIgnoredEvent
153-
}
154-
if len(e.Data) == 0 {
155-
return CompliancePolicyRuleAddedPayload{}, fmt.Errorf("projector: empty CompliancePolicyRuleAdded payload")
156-
}
157-
var raw compliancePolicyRuleAddedRaw
158-
if err := json.Unmarshal(e.Data, &raw); err != nil {
159-
return CompliancePolicyRuleAddedPayload{}, fmt.Errorf("projector: invalid CompliancePolicyRuleAdded payload: %w", err)
139+
raw, err := decodePayload[compliancePolicyRuleAddedRaw](e, "compliance_policy", eventtypes.CompliancePolicyRuleAdded)
140+
if err != nil {
141+
return CompliancePolicyRuleAddedPayload{}, err
160142
}
161143
if raw.ActionID == "" {
162144
return CompliancePolicyRuleAddedPayload{}, fmt.Errorf("projector: CompliancePolicyRuleAdded requires action_id")
@@ -188,15 +170,9 @@ type compliancePolicyRuleRemovedRaw struct {
188170

189171
// CompliancePolicyRuleRemovedFromEvent decodes CompliancePolicyRuleRemoved.
190172
func CompliancePolicyRuleRemovedFromEvent(e store.PersistedEvent) (CompliancePolicyRuleRemovedPayload, error) {
191-
if e.StreamType != "compliance_policy" || e.EventType != string(eventtypes.CompliancePolicyRuleRemoved) {
192-
return CompliancePolicyRuleRemovedPayload{}, ErrIgnoredEvent
193-
}
194-
if len(e.Data) == 0 {
195-
return CompliancePolicyRuleRemovedPayload{}, fmt.Errorf("projector: empty CompliancePolicyRuleRemoved payload")
196-
}
197-
var raw compliancePolicyRuleRemovedRaw
198-
if err := json.Unmarshal(e.Data, &raw); err != nil {
199-
return CompliancePolicyRuleRemovedPayload{}, fmt.Errorf("projector: invalid CompliancePolicyRuleRemoved payload: %w", err)
173+
raw, err := decodePayload[compliancePolicyRuleRemovedRaw](e, "compliance_policy", eventtypes.CompliancePolicyRuleRemoved)
174+
if err != nil {
175+
return CompliancePolicyRuleRemovedPayload{}, err
200176
}
201177
if raw.ActionID == "" {
202178
return CompliancePolicyRuleRemovedPayload{}, fmt.Errorf("projector: CompliancePolicyRuleRemoved requires action_id")
@@ -226,15 +202,9 @@ type compliancePolicyRuleUpdatedRaw struct {
226202

227203
// CompliancePolicyRuleUpdatedFromEvent decodes CompliancePolicyRuleUpdated.
228204
func CompliancePolicyRuleUpdatedFromEvent(e store.PersistedEvent) (CompliancePolicyRuleUpdatedPayload, error) {
229-
if e.StreamType != "compliance_policy" || e.EventType != string(eventtypes.CompliancePolicyRuleUpdated) {
230-
return CompliancePolicyRuleUpdatedPayload{}, ErrIgnoredEvent
231-
}
232-
if len(e.Data) == 0 {
233-
return CompliancePolicyRuleUpdatedPayload{}, fmt.Errorf("projector: empty CompliancePolicyRuleUpdated payload")
234-
}
235-
var raw compliancePolicyRuleUpdatedRaw
236-
if err := json.Unmarshal(e.Data, &raw); err != nil {
237-
return CompliancePolicyRuleUpdatedPayload{}, fmt.Errorf("projector: invalid CompliancePolicyRuleUpdated payload: %w", err)
205+
raw, err := decodePayload[compliancePolicyRuleUpdatedRaw](e, "compliance_policy", eventtypes.CompliancePolicyRuleUpdated)
206+
if err != nil {
207+
return CompliancePolicyRuleUpdatedPayload{}, err
238208
}
239209
if raw.ActionID == "" {
240210
return CompliancePolicyRuleUpdatedPayload{}, fmt.Errorf("projector: CompliancePolicyRuleUpdated requires action_id")

0 commit comments

Comments
 (0)