diff --git a/internal/events/bus.go b/internal/events/bus.go index d096370bd..102af34e2 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -42,8 +42,12 @@ const ( ItemStarred = "item_starred" ItemUnstarred = "item_unstarred" - // Composite events - ItemUpdatedWithComment = "item_updated_with_comment" + // Composite events: NONE. item_updated_with_comment was declared here + // and never published by anything — its only producer was a hand-called + // webhook dispatch using the dot-form name, retired under SPEC-3 v1.2 + // (Dave's ruling, day-48) because it folds into item.updated + + // comment.created. The constant went with it rather than being left as + // a name a future publisher could reach for. // Batch events. Emitted once for a whole bulk mutation (TASK-1668) // instead of one ItemUpdated/ItemArchived per row — the lane-header diff --git a/internal/kernelevents/taxonomy.go b/internal/kernelevents/taxonomy.go index 2f5f56d81..9a6f740df 100644 --- a/internal/kernelevents/taxonomy.go +++ b/internal/kernelevents/taxonomy.go @@ -147,6 +147,26 @@ const ( type eventSpec struct { subject string payload string + + // sse is the snake_case name this event publishes under on the SSE + // surface, or "" when the event has no SSE surface at all. + // + // SPEC-3 §"The choke point owns the canonical→surface name mapping": + // SSE (snake_case) and webhooks (dot-form) drifted because nothing tied + // their vocabularies together. This field is the tie. SPEC-3 v1.5 pins + // what "derive" means — NAME derivation, not delivery path: webhooks + // deliver through the drain, SSE stays direct-published at the mutation + // site (it carries request-scoped Actor/ActorName/Source that a frozen + // payload deliberately does not hold) and takes only its NAME from here. + // + // The empty string is a real value, not a gap: attachment, member and + // pack events have no SSE surface, and SurfaceSSE reports false for them + // so a caller cannot mistake silence for a name. Several canonical events + // map onto ONE SSE name — status_changed and moved both surface as + // item_updated — because the SSE vocabulary is coarser than events/1 and + // the UI has never distinguished them. That is a mapping, not a loss: + // the fine-grained name is what the webhook wire and bindings receive. + sse string } // canonical is the closed events/1 set. Adding an entry here is the ONLY way @@ -186,22 +206,22 @@ const ( ) var canonical = map[string]eventSpec{ - ItemCreated: {SubjectItem, PayloadItemSnapshot}, - ItemUpdated: {SubjectItem, PayloadItemSnapshot}, - ItemStatusChanged: {SubjectItem, PayloadItemSnapshot}, - ItemMoved: {SubjectItem, PayloadItemSnapshot}, - ItemDeleted: {SubjectItem, PayloadItemSnapshot}, - ItemRestored: {SubjectItem, PayloadItemSnapshot}, - ItemBulkUpdated: {SubjectItemBatch, PayloadItemBatch}, - CommentCreated: {SubjectComment, PayloadCommentSnapshot}, - CommentUpdated: {SubjectComment, PayloadCommentSnapshot}, - CommentDeleted: {SubjectComment, PayloadRefOnly}, - AttachmentAdded: {SubjectAttachment, PayloadAttachmentSnapshot}, - AttachmentRemoved: {SubjectAttachment, PayloadRefOnly}, - MemberJoined: {SubjectMember, PayloadMember}, - PackInstalled: {SubjectPack, PayloadPack}, - PackUpgraded: {SubjectPack, PayloadPack}, - PackDisabled: {SubjectPack, PayloadPack}, + ItemCreated: {SubjectItem, PayloadItemSnapshot, "item_created"}, + ItemUpdated: {SubjectItem, PayloadItemSnapshot, "item_updated"}, + ItemStatusChanged: {SubjectItem, PayloadItemSnapshot, "item_updated"}, + ItemMoved: {SubjectItem, PayloadItemSnapshot, "item_updated"}, + ItemDeleted: {SubjectItem, PayloadItemSnapshot, "item_archived"}, + ItemRestored: {SubjectItem, PayloadItemSnapshot, "item_restored"}, + ItemBulkUpdated: {SubjectItemBatch, PayloadItemBatch, "items_bulk_updated"}, + CommentCreated: {SubjectComment, PayloadCommentSnapshot, "comment_created"}, + CommentUpdated: {SubjectComment, PayloadCommentSnapshot, "comment_updated"}, + CommentDeleted: {SubjectComment, PayloadRefOnly, "comment_deleted"}, + AttachmentAdded: {SubjectAttachment, PayloadAttachmentSnapshot, ""}, + AttachmentRemoved: {SubjectAttachment, PayloadRefOnly, ""}, + MemberJoined: {SubjectMember, PayloadMember, ""}, + PackInstalled: {SubjectPack, PayloadPack, ""}, + PackUpgraded: {SubjectPack, PayloadPack, ""}, + PackDisabled: {SubjectPack, PayloadPack, ""}, } // PayloadFamily returns the payload shape a canonical event carries, and false @@ -247,3 +267,23 @@ func Canonical() []string { } return names } + +// SurfaceSSE returns the SSE wire name a canonical event publishes under, and +// false when it has none — either because the name is not canonical, or +// because the event is deliberately absent from the SSE surface. +// +// ONE BOOL FOR BOTH CASES, deliberately. A caller has the same job either way: +// do not publish. Splitting them would invite a caller to branch on +// "canonical but unmapped" and invent a name, which is the drift this table +// exists to end. +// +// The webhook wire name needs no accessor: it IS the canonical name. That +// asymmetry is the point of SPEC-3's mapping sentence — the dot-form vocabulary +// is the contract, and SSE's snake_case is a surface rendering of it. +func SurfaceSSE(name string) (string, bool) { + spec, ok := canonical[name] + if !ok || spec.sse == "" { + return "", false + } + return spec.sse, true +} diff --git a/internal/server/events_surface.go b/internal/server/events_surface.go new file mode 100644 index 000000000..97854032e --- /dev/null +++ b/internal/server/events_surface.go @@ -0,0 +1,58 @@ +package server + +import ( + "fmt" + + "github.com/PerpetualSoftware/pad/internal/kernelevents" +) + +// The SSE wire names, DERIVED from the events/1 taxonomy rather than written +// out again here. +// +// SPEC-3 §"The choke point owns the canonical→surface name mapping" is the +// reason this file exists: SSE's snake_case vocabulary and the webhook +// dot-form vocabulary drifted because nothing tied them together, each being +// hand-passed at its own call sites. v1.5 pins what "derive" means — NAME +// derivation, not delivery path. Webhooks deliver through the outbox drain; +// SSE stays direct-published at the mutation site, because it carries +// request-scoped attribution (Actor / ActorName / Source) that a frozen outbox +// payload deliberately does not hold. What changes is only where the NAME +// comes from. +// +// DERIVED AT INIT, not per publish, and that is the whole safety argument. A +// canonical event with no SSE surface is a programming error at these call +// sites — every one of them is a compile-time constant — so the check belongs +// where a failure is impossible to miss and impossible to reach a user: +// process start. A per-request lookup would have to decide what to do with the +// "no surface" answer in a void helper, and every available answer (log and +// drop, publish under an empty name) is worse than not starting. +// +// Several canonical events derive the SAME name: status_changed and moved both +// surface as item_updated, because the SSE vocabulary is coarser than events/1 +// and the UI has never distinguished them. The finer name is what the webhook +// wire and bindings receive. +var ( + sseItemCreated = mustSurfaceSSE(kernelevents.ItemCreated) + sseItemUpdated = mustSurfaceSSE(kernelevents.ItemUpdated) + sseItemMoved = mustSurfaceSSE(kernelevents.ItemMoved) + sseItemArchived = mustSurfaceSSE(kernelevents.ItemDeleted) + sseItemRestored = mustSurfaceSSE(kernelevents.ItemRestored) + sseItemsBulk = mustSurfaceSSE(kernelevents.ItemBulkUpdated) + sseCommentCreated = mustSurfaceSSE(kernelevents.CommentCreated) + sseCommentUpdated = mustSurfaceSSE(kernelevents.CommentUpdated) +) + +// mustSurfaceSSE resolves a canonical event's SSE wire name or panics. +// +// The panic is deliberate and it fires at package init, before the server +// listens: a canonical event that reaches here without an SSE surface means +// the taxonomy and this file disagree, and the only honest outcomes are +// "publish nothing" (silently breaking the live UI) or "refuse to start". +// Refusing to start is the one a person notices. +func mustSurfaceSSE(canonical string) string { + name, ok := kernelevents.SurfaceSSE(canonical) + if !ok { + panic(fmt.Sprintf("kernelevents: %q has no SSE surface name", canonical)) + } + return name +} diff --git a/internal/server/events_surface_test.go b/internal/server/events_surface_test.go new file mode 100644 index 000000000..be140ae75 --- /dev/null +++ b/internal/server/events_surface_test.go @@ -0,0 +1,44 @@ +package server + +import ( + "testing" + + "github.com/PerpetualSoftware/pad/internal/events" +) + +// TestDerivedSSENamesMatchTheLegacyWireVocabulary is the compat guard for the +// rewire: deriving the SSE names from the events/1 taxonomy must reproduce the +// strings the bus already published, byte for byte. +// +// This is what makes the rewire a REFACTOR rather than a wire change. Every +// live client — the web app's SSE handler, delta-sync's cursor logic — matches +// on these literals, so a derivation that quietly produced "item.created" or +// "item_deleted" would break the UI while every Go test still passed. +// +// The assertion runs against events.* rather than against fresh literals on +// purpose: those constants are what the CLIENTS are pinned to, so comparing +// against them is comparing against the thing that actually has to hold. +func TestDerivedSSENamesMatchTheLegacyWireVocabulary(t *testing.T) { + for _, tc := range []struct { + name string + derived string + legacy string + }{ + {"item created", sseItemCreated, events.ItemCreated}, + {"item updated", sseItemUpdated, events.ItemUpdated}, + {"item moved surfaces as updated", sseItemMoved, events.ItemUpdated}, + {"item deleted surfaces as archived", sseItemArchived, events.ItemArchived}, + {"item restored", sseItemRestored, events.ItemRestored}, + {"bulk", sseItemsBulk, events.ItemsBulkUpdated}, + {"comment created", sseCommentCreated, events.CommentCreated}, + {"comment updated", sseCommentUpdated, events.CommentUpdated}, + } { + if tc.derived != tc.legacy { + t.Errorf("%s: derived SSE name = %q, legacy wire name = %q — clients match the legacy string", + tc.name, tc.derived, tc.legacy) + } + if tc.derived == "" { + t.Errorf("%s: derived SSE name is empty", tc.name) + } + } +} diff --git a/internal/server/handlers_comments.go b/internal/server/handlers_comments.go index f76fb754e..7a81c25ea 100644 --- a/internal/server/handlers_comments.go +++ b/internal/server/handlers_comments.go @@ -136,7 +136,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) { } // Publish SSE event - s.publishCommentEvent(events.CommentCreated, workspaceID, item.ID, comment.ID, item.Title, item.CollectionSlug, actor, source) + s.publishCommentEvent(sseCommentCreated, workspaceID, item.ID, comment.ID, item.Title, item.CollectionSlug, actor, source) s.dispatchWebhook(workspaceID, "comment.created", comment) if s.watchEvents != nil { @@ -263,7 +263,7 @@ func (s *Server) handleUpdateComment(w http.ResponseWriter, r *http.Request) { title = item.Title collSlug = item.CollectionSlug } - s.publishCommentEvent(events.CommentUpdated, workspaceID, updated.ItemID, updated.ID, title, collSlug, actor, source) + s.publishCommentEvent(sseCommentUpdated, workspaceID, updated.ItemID, updated.ID, title, collSlug, actor, source) s.dispatchWebhook(workspaceID, "comment.updated", updated) writeJSON(w, http.StatusOK, updated) @@ -365,7 +365,7 @@ func (s *Server) handleCreateReply(w http.ResponseWriter, r *http.Request) { replyItemRef = replyItem.Ref replyCollID = replyItem.CollectionID } - s.publishCommentEvent(events.CommentCreated, workspaceID, parentComment.ItemID, comment.ID, parentComment.ItemTitle, replyCollSlug, actor, source) + s.publishCommentEvent(sseCommentCreated, workspaceID, parentComment.ItemID, comment.ID, parentComment.ItemTitle, replyCollSlug, actor, source) // TASK-2533 (codex round 1, finding 2): a reply is a SEPARATE code // path from handleCreateComment — it calls store.CreateComment diff --git a/internal/server/handlers_item_links.go b/internal/server/handlers_item_links.go index cc7f4d5ba..7e7be5374 100644 --- a/internal/server/handlers_item_links.go +++ b/internal/server/handlers_item_links.go @@ -266,5 +266,13 @@ func (s *Server) publishStructuralLinkSourceEvent(r *http.Request, workspaceID, return } actor, source := actorFromRequest(r) + // events.ItemUpdated LITERAL, not derived from the taxonomy, and that is + // the decision rather than an oversight. Item-link mutations are SILENT in + // events/1 (SPEC-3 v1.5): a link is plausibly its own subject kind, and + // faking item.updated would ship a snapshot in which nothing the consumer + // caches has changed. There is no canonical event to derive a name from + // here, so this publish stays a pure SSE nudge for the live UI. TASK-2723 + // carries the link.created / link.removed shape and this call site with + // it. s.publishItemEventWithName(events.ItemUpdated, workspaceID, item.ID, item.Title, item.CollectionSlug, actor, actorNameFromRequest(r), source, item.Seq) } diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index 43dca1319..5eebaa346 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -807,7 +807,7 @@ func (s *Server) createItemChecked(r *http.Request, workspaceID string, coll *mo actor, source := actorFromRequest(r) actorNameForCreate := actorNameFromRequest(r) s.logActivity(workspaceID, item.ID, "created", r) - s.publishItemEventWithName(events.ItemCreated, workspaceID, item.ID, item.Title, coll.Slug, actor, actorNameForCreate, source, item.Seq) + s.publishItemEventWithName(sseItemCreated, workspaceID, item.ID, item.Title, coll.Slug, actor, actorNameForCreate, source, item.Seq) s.dispatchWebhook(workspaceID, "item.created", item) // Assignment-at-creation (TASK-2533): unlike an update, a freshly @@ -1694,7 +1694,7 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) { actor, source := actorFromRequest(r) activityID, _ := s.logActivityWithMetaReturningID(workspaceID, updated.ID, "updated", r, meta) actorNameForUpdate := actorNameFromRequest(r) - s.publishItemEventWithName(events.ItemUpdated, workspaceID, updated.ID, updated.Title, updated.CollectionSlug, actor, actorNameForUpdate, source, updated.Seq) + s.publishItemEventWithName(sseItemUpdated, workspaceID, updated.ID, updated.Title, updated.CollectionSlug, actor, actorNameForUpdate, source, updated.Seq) s.dispatchWebhook(workspaceID, "item.updated", updated) s.publishWatchNotifications(workspaceID, updated, actor, actorNameForUpdate) @@ -1715,12 +1715,15 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) { slog.Warn("failed to create comment on item update", "item_id", updated.ID, "error", cerr) } if cerr == nil && comment != nil { - s.publishCommentEvent(events.CommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source) - s.dispatchWebhook(workspaceID, "item.updated_with_comment", map[string]interface{}{ - "item": updated, - "comment": comment, - "changes": meta, - }) + s.publishCommentEvent(sseCommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source) + // item.updated_with_comment is RETIRED (SPEC-3 v1.2, Dave's + // ruling): a hand-rolled composite with no flood argument, + // which folds into item.updated + comment.created — both of + // which this path already emits, transactionally, from the + // store. It is never emitted under events/1, with no + // deprecation window, because webhooks are CLI-registered + // only today (no UI surface) and there were no known + // consumers at ruling time. // TASK-2533: this comment is created via a DIFFERENT code // path than handleCreateComment (store.CreateComment called // directly, not through POST .../comments) — a bypass Rider @@ -1791,7 +1794,7 @@ func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) { actor, source := actorFromRequest(r) s.logActivity(workspaceID, item.ID, "archived", r) - s.publishItemEventWithName(events.ItemArchived, workspaceID, item.ID, item.Title, item.CollectionSlug, actor, actorNameFromRequest(r), source, deleteSeq) + s.publishItemEventWithName(sseItemArchived, workspaceID, item.ID, item.Title, item.CollectionSlug, actor, actorNameFromRequest(r), source, deleteSeq) s.dispatchWebhook(workspaceID, "item.deleted", item) w.WriteHeader(http.StatusNoContent) @@ -1844,7 +1847,7 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) { actor, source := actorFromRequest(r) s.logActivity(workspaceID, restored.ID, "restored", r) - s.publishItemEventWithName(events.ItemRestored, workspaceID, restored.ID, restored.Title, restored.CollectionSlug, actor, actorNameFromRequest(r), source, restored.Seq) + s.publishItemEventWithName(sseItemRestored, workspaceID, restored.ID, restored.Title, restored.CollectionSlug, actor, actorNameFromRequest(r), source, restored.Seq) restoreVisIDs, _ := s.visibleCollectionIDs(r, workspaceID) if err := s.enrichItemForResponse(restored, restoreVisIDs); err != nil { @@ -2128,7 +2131,7 @@ func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) { // Publish events for both old and new collections actorNameForMove := actorNameFromRequest(r) - s.publishItemEventWithName(events.ItemUpdated, workspaceID, moved.ID, moved.Title, targetColl.Slug, actor, actorNameForMove, source, moved.Seq) + s.publishItemEventWithName(sseItemMoved, workspaceID, moved.ID, moved.Title, targetColl.Slug, actor, actorNameForMove, source, moved.Seq) s.dispatchWebhook(workspaceID, "item.moved", moved) s.publishWatchNotifications(workspaceID, moved, actor, actorNameForMove) diff --git a/internal/server/handlers_items_bulk.go b/internal/server/handlers_items_bulk.go index 58ce911cb..b7700502a 100644 --- a/internal/server/handlers_items_bulk.go +++ b/internal/server/handlers_items_bulk.go @@ -697,7 +697,7 @@ func (s *Server) publishBulkItemsEvent(workspaceID, op, collection string, count return } s.events.Publish(events.Event{ - Type: events.ItemsBulkUpdated, + Type: sseItemsBulk, WorkspaceID: workspaceID, Collection: collection, Op: op, diff --git a/internal/server/handlers_items_copy.go b/internal/server/handlers_items_copy.go index dc50c25e1..3e89a56b9 100644 --- a/internal/server/handlers_items_copy.go +++ b/internal/server/handlers_items_copy.go @@ -8,7 +8,6 @@ import ( "net/http" "runtime/debug" - "github.com/PerpetualSoftware/pad/internal/events" "github.com/PerpetualSoftware/pad/internal/models" "github.com/PerpetualSoftware/pad/internal/store" ) @@ -662,7 +661,7 @@ func (s *Server) emitCopyFanout(r *http.Request, res *store.CrossWorkspaceCopyRe // --- Destination: always all three. --- targetWorkspaceID := res.Item.WorkspaceID s.logActivity(targetWorkspaceID, res.Item.ID, "created", r) - s.publishItemEventWithName(events.ItemCreated, targetWorkspaceID, res.Item.ID, res.Item.Title, + s.publishItemEventWithName(sseItemCreated, targetWorkspaceID, res.Item.ID, res.Item.Title, res.TargetCollection.Slug, actor, actorName, actorSource, res.Item.Seq) s.dispatchWebhook(targetWorkspaceID, "item.created", res.Item) @@ -674,7 +673,7 @@ func (s *Server) emitCopyFanout(r *http.Request, res *store.CrossWorkspaceCopyRe return } s.logActivity(res.SourceWorkspaceID, res.Source.ID, "archived", r) - s.publishItemEventWithName(events.ItemArchived, res.SourceWorkspaceID, res.Source.ID, res.Source.Title, + s.publishItemEventWithName(sseItemArchived, res.SourceWorkspaceID, res.Source.ID, res.Source.Title, res.SourceCollection.Slug, actor, actorName, actorSource, *res.SourceSeq) s.dispatchWebhook(res.SourceWorkspaceID, "item.deleted", res.Source) } diff --git a/internal/store/event_outbox_test.go b/internal/store/event_outbox_test.go index 01718e8b7..de415946a 100644 --- a/internal/store/event_outbox_test.go +++ b/internal/store/event_outbox_test.go @@ -1375,23 +1375,23 @@ func TestCanonicalEventsAreFullyDeclared(t *testing.T) { // The events/1 set at SPEC-3 v1.4. Adding, removing or re-homing an entry // here is a CONTRACT CHANGE: update the spec version and the taxonomy's // doc comment in the same commit. - want := map[string]struct{ subject, family string }{ - "item.created": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot}, - "item.updated": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot}, - "item.status_changed": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot}, - "item.moved": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot}, - "item.deleted": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot}, - "item.restored": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot}, - "item.bulk_updated": {kernelevents.SubjectItemBatch, kernelevents.PayloadItemBatch}, - "comment.created": {kernelevents.SubjectComment, kernelevents.PayloadCommentSnapshot}, - "comment.updated": {kernelevents.SubjectComment, kernelevents.PayloadCommentSnapshot}, - "comment.deleted": {kernelevents.SubjectComment, kernelevents.PayloadRefOnly}, - "attachment.added": {kernelevents.SubjectAttachment, kernelevents.PayloadAttachmentSnapshot}, - "attachment.removed": {kernelevents.SubjectAttachment, kernelevents.PayloadRefOnly}, - "member.joined": {kernelevents.SubjectMember, kernelevents.PayloadMember}, - "pack.installed": {kernelevents.SubjectPack, kernelevents.PayloadPack}, - "pack.upgraded": {kernelevents.SubjectPack, kernelevents.PayloadPack}, - "pack.disabled": {kernelevents.SubjectPack, kernelevents.PayloadPack}, + want := map[string]struct{ subject, family, sse string }{ + "item.created": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_created"}, + "item.updated": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_updated"}, + "item.status_changed": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_updated"}, + "item.moved": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_updated"}, + "item.deleted": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_archived"}, + "item.restored": {kernelevents.SubjectItem, kernelevents.PayloadItemSnapshot, "item_restored"}, + "item.bulk_updated": {kernelevents.SubjectItemBatch, kernelevents.PayloadItemBatch, "items_bulk_updated"}, + "comment.created": {kernelevents.SubjectComment, kernelevents.PayloadCommentSnapshot, "comment_created"}, + "comment.updated": {kernelevents.SubjectComment, kernelevents.PayloadCommentSnapshot, "comment_updated"}, + "comment.deleted": {kernelevents.SubjectComment, kernelevents.PayloadRefOnly, "comment_deleted"}, + "attachment.added": {kernelevents.SubjectAttachment, kernelevents.PayloadAttachmentSnapshot, ""}, + "attachment.removed": {kernelevents.SubjectAttachment, kernelevents.PayloadRefOnly, ""}, + "member.joined": {kernelevents.SubjectMember, kernelevents.PayloadMember, ""}, + "pack.installed": {kernelevents.SubjectPack, kernelevents.PayloadPack, ""}, + "pack.upgraded": {kernelevents.SubjectPack, kernelevents.PayloadPack, ""}, + "pack.disabled": {kernelevents.SubjectPack, kernelevents.PayloadPack, ""}, } // The name constants are pinned to their wire strings separately, because @@ -1444,6 +1444,17 @@ func TestCanonicalEventsAreFullyDeclared(t *testing.T) { if !kernelevents.IsCanonical(name) { t.Errorf("IsCanonical(%q) = false for an event Canonical() returned", name) } + // The SSE surface name, including the events that deliberately have + // none. An empty want.sse asserts SILENCE — SurfaceSSE must report + // false rather than handing back a name a caller would publish under. + sse, sseOK := kernelevents.SurfaceSSE(name) + if expected.sse == "" { + if sseOK || sse != "" { + t.Errorf("SurfaceSSE(%q) = (%q, %v), want (\"\", false) — this event has no SSE surface", name, sse, sseOK) + } + } else if !sseOK || sse != expected.sse { + t.Errorf("SurfaceSSE(%q) = (%q, %v), want (%q, true)", name, sse, sseOK, expected.sse) + } } for name := range want { if !seen[name] { @@ -1464,6 +1475,9 @@ func TestCanonicalEventsAreFullyDeclared(t *testing.T) { if kernelevents.IsCanonical(name) { t.Errorf("IsCanonical(%q) = true", name) } + if _, ok := kernelevents.SurfaceSSE(name); ok { + t.Errorf("SurfaceSSE(%q) reported ok for a non-canonical name", name) + } } }