Short answer: treat every bidder notification as an authorized, versioned event whose audience is decided from a snapshot, then re-check that decision when a client reconnects. For a logistics auction workspace, this keeps “who is online” useful without allowing a revoked bidder to learn a lane update through a stale socket.
The hard part is not opening a WebSocket. It is preserving the same authorization meaning across delivery, replay, and audit. I design the contract before choosing a transport: an event has an immutable auction and workspace identity, an explicit recipient scope, a policy version, and a sequence that can be reconciled later. Presence is then a derived view, not proof that a user may see every message.
The decision record: authorize facts, not connections
In an auction, a bidder can belong to a workspace while being excluded from a particular lane, carrier group, or sealed phase. A connection-level check (“this token joined the room”) cannot express that distinction. The event must carry enough data for a subscriber service to make a deterministic decision, and enough metadata for an auditor to explain the decision months later.
I use four invariants:
-
subjectidentifies the bidder or service principal, whileworkspace_id,auction_id, andlane_ididentify the business scope. -
audienceis a narrow rule such asbidder:{id}orrole:dispatcher; it is never an informal implication from a channel name. -
policy_versionrecords the authorization snapshot used to publish the event. -
event_idplus a monotonically increasingsequencesupports idempotent delivery and replay.
The notification payload can be public within a lane, private to one bidder, or a presence update that contains no bid amount. Keeping those classes explicit prevents a later UI change from turning a harmless “online” signal into a data leak.
How should realtime authorization data contracts handle auction bidder notifications?
The contract should separate authentication, authorization, and freshness. Authentication proves which principal presented the credential. Authorization evaluates the principal against the auction snapshot. Freshness determines whether the result is still valid when the notification is delivered.
Here is a deliberately small Go model. The names are boring; that is a feature. Boring fields are easier to map into a ledger and easier to review during an incident.
package notifications
import "time"
type Audience struct {
Kind string `json:"kind"` // bidder, role, workspace
ID string `json:"id"`
}
type AuctionEvent struct {
EventID string `json:"event_id"`
Sequence int64 `json:"sequence"`
WorkspaceID string `json:"workspace_id"`
AuctionID string `json:"auction_id"`
LaneID string `json:"lane_id,omitempty"`
Type string `json:"type"`
Audience Audience `json:"audience"`
PolicyVersion string `json:"policy_version"`
OccurredAt time.Time `json:"occurred_at"`
Body []byte `json:"body"`
}
The publisher evaluates policy once, records the version, and appends the event. The subscriber evaluates again before sending bytes to a socket. Those checks are intentionally redundant: a policy change between append and delivery is a normal state transition, not an exceptional error. A denied event is acknowledged internally, omitted from the client stream, and retained in the audit trail with a reason code.
Presence follows the same contract. A heartbeat can say that bidder b-17 is connected to workspace w-4, but it must not imply access to auction a-9. The UI can show “online in workspace” while the notification service still filters lane-specific updates.
Failure boundaries that matter more than transport choice
Three boundaries deserve explicit tests.
First, revocation races with delivery. Suppose a bidder loses lane access at sequence 882, while sequence 881 is buffered in a broker. The delivery worker must read the current policy before forwarding 881; otherwise a perfectly valid old event becomes an unauthorized late disclosure. It is acceptable for the bidder to miss an event after revocation. It is not acceptable to send it because the event was already queued.
Second, reconnects create a replay request. The client should present its last acknowledged sequence and contract version. The server checks authorization against the current snapshot, then replays only events still allowed. If the snapshot is incompatible, return a resynchronization marker and let the client fetch a scoped projection over an authenticated request. Do not silently continue with an old interpretation.
Third, duplicate delivery is expected. Brokers retry; mobile networks reconnect; workers restart. Consumers should store the highest accepted sequence per stream and treat a repeated event_id as an idempotent no-op. Exactly-once effects belong in the consumer's state transition, not in a promise that the network can make exactly one delivery.
One short rule: deny by default.
For observability, log the decision tuple rather than the bid payload: principal, scope, event ID, policy version, decision, and reason. Hash or omit sensitive fields. Auditability has to coexist with data minimization and retention limits imposed by privacy and financial-control programs; an audit log is not permission to retain every message forever.
Comparing implementation shapes
The choice is usually between a brokered fan-out, a stateful gateway, and a polling projection. None is universally correct. The table captures the trade-off I use in an architecture review.
| Shape | Authorization point | Reconnect behavior | Presence accuracy | Main cost |
|---|---|---|---|---|
| Brokered fan-out plus stateless gateways | Before publish and again before send | Sequence replay from a durable log | Good, if heartbeats expire quickly | More policy reads and broker operations |
| Stateful gateway with room membership | On join and on policy change | Gateway must rebuild membership | Medium during failover | Complex invalidation and sticky routing |
| Authenticated polling projection | Every request | Natural cursor or timestamp | Lower between polls | Extra latency and request volume |
For auction bidder notifications, I normally start with the brokered shape when a durable sequence already exists. It makes the failure boundary visible and lets a new gateway recover without trusting in-memory membership. The stateful design is reasonable for a small, single-region workspace where sub-second presence is less important than operational simplicity. Polling is the honest answer for low-frequency auctions or clients that cannot maintain long-lived connections.
The catch is that brokered fan-out is unsuitable when policy evaluation cannot meet the delivery budget or when the organization cannot operate a durable log with controlled retention. In that case, stick with a scoped polling projection and accept its staleness explicitly; do not call a 30-second poll “realtime.”
A critical path with an idempotent send
The delivery worker below shows the ordering I want reviewers to see: deduplicate, check current policy, send, then advance the cursor. Allow is an interface so the policy engine can be replaced without changing the event contract.
package notifications
import "context"
type Policy interface {
Allow(ctx context.Context, subject string, e AuctionEvent) (bool, string, error)
}
type CursorStore interface {
Seen(ctx context.Context, subject, eventID string) (bool, error)
Mark(ctx context.Context, subject, eventID string) error
}
type Sink interface {
Send(ctx context.Context, subject string, body []byte) error
}
func Deliver(ctx context.Context, subject string, e AuctionEvent, p Policy, c CursorStore, s Sink) error {
seen, err := c.Seen(ctx, subject, e.EventID)
if err != nil || seen {
return err
}
allowed, reason, err := p.Allow(ctx, subject, e)
if err != nil {
return err
}
if !allowed {
_ = reason // record reason in the audit sink; never send the body
return c.Mark(ctx, subject, e.EventID)
}
if err := s.Send(ctx, subject, e.Body); err != nil {
return err
}
return c.Mark(ctx, subject, e.EventID)
}
Marking a denied event as seen is a policy choice: it prevents an old unauthorized event from being sent after a later reconnect. If your audit requirements need a replayable denial record, store that record separately; the client cursor still advances. Transaction boundaries should make the send and cursor update recoverable, or the sink must tolerate a retry.
Testing and rollout without guessing
Contract tests should generate a matrix of principals, auction phases, lane scopes, and policy versions. Assert that a revoked bidder receives neither a bid event nor a private presence detail, while an allowed bidder receives the same event_id exactly once in the resulting state. Add property tests for cursor monotonicity and replay filtering.
During rollout, dual-write the new envelope beside the legacy message, compare authorization decisions in metrics, and sample mismatches into a redacted audit stream. Keep the old consumer until the replay window has passed. I am not sure every team needs a full event-sourcing system; the deciding evidence is whether you can reconstruct an authorization decision from a bounded log and a policy version.
The practical success metric is not socket count. It is the rate of stale or over-broad notifications, measured alongside reconnect latency and policy-read latency. A presence indicator that is 200 ms late is often acceptable; a bidder seeing a lane they left is not.
Top comments (1)
Solid write-up on enforcing data contracts for real-time systems. Managing strict payload boundaries and authorization states across event-driven auction notifications is crucial for preventing race conditions and security leaks at scale. Great architectural breakdown!