// Package event is the unified intake envelope (Vikunja #283). // // # The problem it solves // // Things arrive at Maven from a lot of directions: a relayed Android // notification (POST /api/ambient), a mail the reader extracted candidates // from (ingest_mail), an RSS item, a changed page the crawler noticed, a // zenmoney spend, a CalDAV event, a wg handshake that means he is home, a // photo he sent, a meeting she was asked to record. Each of those grew its own // shape, its own storage decision and its own log line. Nothing could answer // "what came in today, from where" without reading eight packages. // // An Event is that answer: one flat, source-agnostic description of "something // arrived". It is deliberately NOT a new storage layer and NOT a new transport. // Every intake path keeps writing exactly what it wrote before — a fact, a // note, a candidate task — and additionally describes what it did as an Event. // The envelope is a VIEW over intake, not a replacement for it, which is why // adopting it did not require touching eight callers. // // # What it is not // // - Not a command. An Event is a report of something that happened; nothing // in Maven executes one. Digestion may read them; it may not be driven by // an event alone, because "a thing arrived" is not "a thing must be said". // - Not durable. The bus is a bounded in-memory ring. An event's durable // consequence is the fact/note/task the intake path already wrote; the // envelope is the recent-history window on top. A restart losing the ring // loses nothing that mattered. // - Not a secret store. Body carries what the intake path was already willing // to log or store. Nothing puts a mail body, an IMAP password or a // voiceprint in here, and callers must keep it that way. package event import ( "encoding/json" "strings" "time" ) // Event — one thing that arrived, normalized. // // The field set is the one recorded in the backlog, and it is intentionally // small: anything source-specific goes in Payload, so adding a source never // widens the struct and never breaks a reader. type Event struct { // Source — provenance, in the facts vocabulary already used across the // repo: "ambient:notif", "caldav:personal", "poll:zenmoney", "rss:", // "crawl:", "email:", "infer:wg", "tap:voice". Same string // the fact or note was written under, so an event and its row can be // matched up by eye. Source string `json:"source"` // Kind — what sort of thing arrived, from the closed set below. This is the // field digestion switches on; Source is for provenance and display. Kind string `json:"kind"` // EntityIDs — Nexus entity ids this event is about, when the intake path // knew any. Usually empty: most intake happens before enrichment resolves a // subject to an entity. EntityIDs []string `json:"entity_ids,omitempty"` // Title — one short line, safe to show on a page. For a fact it is the key, // for a note the first line, for a task the task text. Title string `json:"title"` // Body — optional detail, already truncated by the caller. Body string `json:"body,omitempty"` // Priority — one of PriorityLow / PriorityNormal / PriorityHigh. It is a // hint about attention, not a delivery instruction: nothing here decides // whether Maven speaks. That stays with internal/loop and internal/delivery, // where the severity/presence routing table lives. Priority string `json:"priority"` // OccurredAt — when the thing happened, NOT when Maven noticed it. A wg // handshake carries the handshake instant; an RSS item carries its publish // time. Intake paths already make this distinction when writing facts, and // the envelope must not flatten it. OccurredAt time.Time `json:"occurred_at"` // NoticedAt — when Maven saw it. This is the ring's own ordering and the // one the page sorts and labels by. // // It exists because OccurredAt must stay truthful and therefore cannot be // an arrival order. A feed's first read returns twenty items spread over a // week and publishes them in feed order; the ambient relay writes a 09:00 // notification about an 18:00 meeting. Ordering the journal by OccurredAt // while walking the ring backwards made the timestamp column run forwards // and backwards on the same page. Normalize fills it from now, always: a // caller does not get to say when Maven noticed something. NoticedAt time.Time `json:"noticed_at"` // Payload — source-specific extra, opaque here. Optional. Payload json.RawMessage `json:"payload,omitempty"` } // Kinds. Closed set: a reader may switch on these exhaustively. A new intake // path picks the closest existing kind before it adds one — the point of the // envelope is that digestion has a small stable input. const ( // KindFact — something was written to the facts table: a calendar read, a // zenmoney window, a presence probe, a crawler watermark. KindFact = "fact" // KindNote — something was written to the notes table: an RSS item, a // changed page, a meeting transcript, an image description. KindNote = "note" // KindTask — a candidate task was captured: the mail reader, the web form, // the voice path. KindTask = "task" // KindMessage — an inbound message on a reach channel. Nothing produces // this yet (telegram is send-only today); the kind exists so the bridge, // when it lands, is a constructor and not a schema change. KindMessage = "message" // KindHealth — a service or probe reported its own state. KindHealth = "health" ) // Priorities. const ( PriorityLow = "low" PriorityNormal = "normal" PriorityHigh = "high" ) // TitleMaxRunes / BodyMaxRunes bound what an envelope carries. The ring is // in memory and served to a web page; a 40 KB crawled article has no business // in either. Cut on a rune boundary — most of this text is Russian and half a // cyrillic letter is a broken line. const ( TitleMaxRunes = 120 BodyMaxRunes = 400 ) // Normalize returns e with its fields put in range: whitespace collapsed out // of Title, Title and Body truncated, an unknown or empty Priority forced to // PriorityNormal, and a zero OccurredAt filled from now. // // It takes now as a parameter rather than reading the clock, so the whole // package stays pure and the simulator (Vikunja #284) can replay intake against // a scripted clock. func (e Event) Normalize(now time.Time) Event { e.Title = truncateRunes(strings.Join(strings.Fields(e.Title), " "), TitleMaxRunes) e.Body = truncateRunes(strings.TrimSpace(e.Body), BodyMaxRunes) if !validPriority(e.Priority) { e.Priority = PriorityNormal } if e.Kind == "" { e.Kind = KindFact } if e.OccurredAt.IsZero() { e.OccurredAt = now } // Notice time is not a caller's to set: it is the instant the bus took it. e.NoticedAt = now return e } // Valid reports whether e carries the minimum a reader can rely on: a source, // a known kind, a title and a time. The bus drops anything that fails — an // envelope with no provenance is worse than no envelope, because it looks like // evidence. func (e Event) Valid() bool { return e.Source != "" && validKind(e.Kind) && e.Title != "" && !e.OccurredAt.IsZero() } func validKind(k string) bool { switch k { case KindFact, KindNote, KindTask, KindMessage, KindHealth: return true } return false } func validPriority(p string) bool { switch p { case PriorityLow, PriorityNormal, PriorityHigh: return true } return false } // truncateRunes cuts s to n runes, marking the cut. func truncateRunes(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" } // SourceKind guesses the Kind for a source string when the caller has not said // otherwise. It exists so the one intake decorator in cmd/mavend does not need // a switch per writer: the source prefix already tells you what arrived. // // Unknown prefixes get fallback, which is what the caller was going to write // anyway (a WriteFact call knows it is a fact). There is deliberately no // "email:" rule: the mail path constructs its task envelope itself, so mapping // the prefix here would have journalled any future fact or note written under // an email source as a captured task. func SourceKind(source, fallback string) string { switch { case strings.HasPrefix(source, "rss:"), strings.HasPrefix(source, "crawl:"): return KindNote case strings.HasPrefix(source, "probe:"), strings.HasPrefix(source, "health:"): return KindHealth } return fallback }