event: order the journal by notice time, and keep it to what arrived

The ring is insertion-ordered and the page called itself newest first
while printing OccurredAt, which is when the thing happened. A cold feed
read publishes a week of items in feed order and the ambient relay
stamps a 09:00 notification with an 18:00 meeting, so the timestamp
column ran forwards and backwards on the same page. Events now carry
NoticedAt, filled by the bus and not by the caller, and the page sorts
and labels by it while still showing when the thing itself happened.

Four writers on that page had not arrived from anywhere: the feed
watermark, the crawl hash, the praxis trace of an act she performed and
a quiet-hours toggle he pressed. On a cold start with a few feeds they
could evict real intake out of a 512-entry ring. The decorator now skips
Maven's own bookkeeping.

Priority was the only surviving trace of confidence, and it inverts:
a relayed meeting at 0.6 read as low while an rss watermark at 1.0 read
as normal. The fact's own kind, its confidence and the id it voids now
travel in Payload, which was unused. A retraction is marked as one and
scored low, instead of publishing an envelope indistinguishable from a
fresh reading of the same key.

Smaller: SourceKind no longer maps every email source to a task, so a
future fact under an email prefix is not journalled as one; newEventBus
is quiet when it is handed no config at all; and morningTmpl has its own
doc comment back.
Found in review of #78.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 14:10:36 +04:00
parent d62ba093f5
commit 9e383eb751
8 changed files with 274 additions and 21 deletions
+8 -2
View File
@@ -95,8 +95,14 @@ func (b *Bus) Subscribe(fn func(Event)) {
b.subs = append(b.subs, fn)
}
// Recent returns up to limit events, newest first. limit <= 0 returns
// everything held. Safe on a nil receiver (returns nil).
// Recent returns up to limit events, most recently NOTICED first. limit <= 0
// returns everything held. Safe on a nil receiver (returns nil).
//
// The order is the ring's insertion order, which is NoticedAt order, and it is
// deliberately not OccurredAt order: a cold feed read publishes a week of items
// in feed order, so sorting by when things happened would put a six-day-old
// item above one that arrived before it. Readers must label the column
// accordingly.
func (b *Bus) Recent(limit int) []Event {
if b == nil {
return nil
+18 -3
View File
@@ -79,6 +79,18 @@ type Event struct {
// 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"`
}
@@ -143,6 +155,8 @@ func (e Event) Normalize(now time.Time) Event {
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
}
@@ -184,13 +198,14 @@ func truncateRunes(s string, n int) string {
// 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).
// 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, "email:"):
return KindTask
case strings.HasPrefix(source, "probe:"), strings.HasPrefix(source, "health:"):
return KindHealth
}
+43 -3
View File
@@ -78,9 +78,11 @@ func TestValid(t *testing.T) {
func TestSourceKind(t *testing.T) {
cases := map[string]string{
"rss:tech": KindNote,
"crawl:kernel": KindNote,
"email:inbox": KindTask,
"rss:tech": KindNote,
"crawl:kernel": KindNote,
// No email rule: a WriteFact under an email source is a fact, not a
// captured task. The mail path builds its own task envelope.
"email:inbox": KindFact,
"probe:netdata": KindHealth,
"ambient:notif": KindFact,
"tap:voice": KindFact,
@@ -183,3 +185,41 @@ func TestBusConcurrentPublish(t *testing.T) {
t.Errorf("Len = %d, want 160", b.Len())
}
}
// The ring is insertion-ordered and the page calls itself newest first, so the
// two only agree if the column is notice time. A cold feed read publishes a
// week of items in feed order, which used to make the OccurredAt column walk
// forwards and backwards on the same page.
func TestRecentIsOrderedByNoticeTimeNotByWhenThingsHappened(t *testing.T) {
b := NewBus(8)
// Published in feed order, six days old first, and a mark stamped now.
for i, e := range []Event{
{Source: "rss:t", Kind: KindNote, Title: "six days ago", OccurredAt: testNow.Add(-6 * 24 * time.Hour)},
{Source: "rss:t", Kind: KindNote, Title: "two days ago", OccurredAt: testNow.Add(-2 * 24 * time.Hour)},
{Source: "ambient:notif", Kind: KindFact, Title: "a meeting at six", OccurredAt: testNow.Add(9 * time.Hour)},
} {
b.Publish(e, testNow.Add(time.Duration(i)*time.Second))
}
got := b.Recent(0)
want := []string{"a meeting at six", "two days ago", "six days ago"}
for i, w := range want {
if got[i].Title != w {
t.Errorf("Recent()[%d] = %q, want %q (notice order)", i, got[i].Title, w)
}
}
// Notice time is monotone down the page even where occurrence time is not.
for i := 1; i < len(got); i++ {
if got[i].NoticedAt.After(got[i-1].NoticedAt) {
t.Errorf("NoticedAt is not descending at %d", i)
}
}
if got[0].OccurredAt.Before(got[1].OccurredAt) {
t.Fatal("this fixture is supposed to have occurrence time out of order")
}
// A caller does not get to claim when Maven noticed something.
forged := Event{Source: "s", Kind: KindFact, Title: "t", NoticedAt: testNow.Add(100 * time.Hour)}
b.Publish(forged, testNow)
if n := b.Recent(1)[0].NoticedAt; !n.Equal(testNow) {
t.Errorf("NoticedAt = %v, want the publish instant %v", n, testNow)
}
}
+4
View File
@@ -685,6 +685,10 @@ type IntakeEvent struct {
Body string `json:"body,omitempty"`
Priority string `json:"priority"`
OccurredAt time.Time `json:"occurred_at"`
// NoticedAt — when the journal took it. This is the order the ring returns
// and the one a page must sort and label by; OccurredAt is when the thing
// happened, which for a cold feed read is a week before it arrived.
NoticedAt time.Time `json:"noticed_at"`
}
// --- Rule trace / explanation DTOs ---