seed: mavend implements it, off unless -allow-seed (V-518)
SeedEvent writes the fact at the caller's timestamp, extracts an event from it, and runs the same detectAndPropose the voice path runs. What a seed proves is therefore the daemon's own wiring, not the detector in isolation — which is what an eval-lab fixture would have proved, and is not what the four blocked tasks doubt. The flag is the real lock, not the authority rung. -allow-seed defaults off, and off means daemonAPI.seedStore is nil: the method has nothing to write with rather than permission to refuse. A box that can rewrite its own past says so in its boot log. Seeded facts carry source "seed:qa" and no Subject, so they never queue a Nexus resolution and stay identifiable for the wipe in V-494. Nothing else in the tree writes that source. Best-effort is not the shape here, unlike detectPattern: a seed that half worked is a QA result nobody can trust, so every step reports its own failure. Extraction declining is not a failure, and Extracted says so. Tests cover all four: refused with no flag, four spaced seeds propose and three do not, a value outside the lexicon writes the fact and claims no event, a zero timestamp is refused rather than defaulted to now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
This commit is contained in:
@@ -127,8 +127,10 @@ func run(args []string) error {
|
||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
allowSeedOnStart = *allowSeed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -338,6 +340,7 @@ func run(args []string) error {
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
@@ -605,6 +608,7 @@ func run(args []string) error {
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// mavend/seed.go — the backdated-fact seam (Vikunja #518).
|
||||
//
|
||||
// The pattern detector needs four events for one action+object, spread by at
|
||||
// least pattern.MinIntervalDays, before it proposes a routine. Nothing could
|
||||
// produce that against a running daemon in one sitting: the only writer is a
|
||||
// fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
|
||||
// same missing step and had been stopped there since they were filed.
|
||||
//
|
||||
// This is the write path that unblocks them, and it is deliberately the narrow
|
||||
// one. It takes a fact, not an event, so pattern.Extract runs for real and a
|
||||
// key the extractor ignores seeds nothing. It runs detectAndPropose, so what a
|
||||
// seed proves is the daemon's own wiring rather than the detector in isolation
|
||||
// — which is what an eval-lab fixture would have proved, and is not what those
|
||||
// four tasks doubt.
|
||||
//
|
||||
// It is off unless mavend was started with -allow-seed, and AuthStepUp in the
|
||||
// authority table besides. See ipc.SeedEventReq and auth.Requirement.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// errSeedDisabled — what a caller gets on an ordinary box. Named rather than
|
||||
// inline so the mavweb route can tell "not allowed here" apart from "the seed
|
||||
// ran and the extractor declined", which look the same to a reader otherwise.
|
||||
var errSeedDisabled = errors.New("mavend: seeding is off (start with -allow-seed)")
|
||||
|
||||
// seedSource — every seeded fact carries this, and no other writer uses it.
|
||||
// The point is that seeded data stays identifiable forever: a fact that came
|
||||
// from a QA sitting must never be mistaken for something he said, either by a
|
||||
// person reading /history or by the wipe in V-494 when it lands.
|
||||
const seedSource = "seed:qa"
|
||||
|
||||
// seedStoreIfAllowed returns st only when -allow-seed was passed, and logs the
|
||||
// fact loudly when it does. A box that can rewrite its own past should say so
|
||||
// in its boot log, so nobody reads a seeded routine months later as evidence of
|
||||
// something he actually did.
|
||||
func seedStoreIfAllowed(st *store.Store) *store.Store {
|
||||
if !allowSeedOnStart {
|
||||
return nil
|
||||
}
|
||||
log.Printf("seed: -allow-seed is ON — backdated fact writes are permitted under source %q (Vikunja #518)", seedSource)
|
||||
return st
|
||||
}
|
||||
|
||||
// SeedEvent writes the fact at the caller's timestamp, extracts an event from
|
||||
// it, and runs the same detect-and-propose step the voice path runs.
|
||||
//
|
||||
// Best-effort is NOT the shape here, unlike detectPattern: a seed that half
|
||||
// worked is a QA result nobody can trust, so every step reports its own
|
||||
// failure. Extraction declining is not a failure — it is the extractor's
|
||||
// documented answer for a value outside its lexicon, and Extracted says so.
|
||||
func (d *daemonAPI) SeedEvent(ctx context.Context, req ipc.SeedEventReq) (ipc.SeedEventResp, error) {
|
||||
if d.seedStore == nil {
|
||||
return ipc.SeedEventResp{}, errSeedDisabled
|
||||
}
|
||||
if req.Key == "" || req.Value == "" {
|
||||
return ipc.SeedEventResp{}, errors.New("mavend: seed needs a key and a value")
|
||||
}
|
||||
if req.Ts.IsZero() {
|
||||
return ipc.SeedEventResp{}, errors.New("mavend: seed needs an explicit timestamp")
|
||||
}
|
||||
|
||||
// No Subject, unlike the voice path: a seeded key must not queue a Nexus
|
||||
// resolution. QA data has no business reaching the ecosystem.
|
||||
factID, err := d.seedStore.WriteFact(ctx, req.Ts, store.KindSelf, req.Key, req.Value, seedSource, 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
return ipc.SeedEventResp{}, fmt.Errorf("seed write fact: %w", err)
|
||||
}
|
||||
resp := ipc.SeedEventResp{FactID: factID}
|
||||
|
||||
ev := pattern.Extract(factID, req.Key, req.Value, req.Ts)
|
||||
if ev == nil {
|
||||
// The fact is written and stays written. Saying so matters: a caller
|
||||
// that assumed a seed always produces an event would otherwise read
|
||||
// four silent successes and conclude the detector is broken.
|
||||
log.Printf("seed: %s=%s wrote fact %d, no event (value outside the action lexicon)", req.Key, req.Value, factID)
|
||||
return resp, nil
|
||||
}
|
||||
resp.Extracted, resp.Action, resp.Object = true, ev.Action, ev.Object
|
||||
|
||||
eventID, err := d.seedStore.CreateEvent(ctx, factID, ev.Action, ev.Object, req.Ts)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("seed create event: %w", err)
|
||||
}
|
||||
resp.EventID = eventID
|
||||
|
||||
r, routineID, err := detectAndPropose(ctx, d.seedStore, ev.Action, ev.Object, req.Ts)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("seed detect: %w", err)
|
||||
}
|
||||
if r == nil {
|
||||
return resp, nil // too few events yet, too irregular, or already decided
|
||||
}
|
||||
resp.Proposed, resp.RoutineID, resp.IntervalDays = true, routineID, r.IntervalDays
|
||||
log.Printf("seed: proposed routine %d — %s/%s every %.1f days", routineID, r.Action, r.Object, r.IntervalDays)
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// Off is the default and it must mean "nothing to write with", not "permission
|
||||
// to refuse later". A daemonAPI with no seedStore writes no fact at all.
|
||||
func TestSeedRefusedWithoutTheFlag(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||
_, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain", Value: "заправил", Ts: time.Now(),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("seed succeeded with no seedStore")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "-allow-seed") {
|
||||
t.Errorf("error does not name the flag: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the task: four seeds spread past the detector's floor
|
||||
// produce a proposal against the real daemon path, which is what nobody could
|
||||
// do before (Vikunja #518). Three seeds must NOT propose — MinEvents is four,
|
||||
// and a test that only checked the happy end would pass on an off-by-one.
|
||||
func TestSeedFourEventsProposesARoutine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
now := time.Now()
|
||||
|
||||
var last ipc.SeedEventResp
|
||||
// Oldest first, three hours apart — past MinIntervalDays (two hours).
|
||||
for i := 3; i >= 0; i-- {
|
||||
var err error
|
||||
last, err = d.SeedEvent(ctx, ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain",
|
||||
Value: "заправил",
|
||||
Ts: now.Add(-time.Duration(i) * 3 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
if !last.Extracted {
|
||||
t.Fatalf("seed %d: no event extracted from a lexicon verb", i)
|
||||
}
|
||||
if i > 0 && last.Proposed {
|
||||
t.Fatalf("proposed after only %d events, MinEvents is 4", 4-i)
|
||||
}
|
||||
}
|
||||
if !last.Proposed {
|
||||
t.Fatal("four spaced events did not propose a routine")
|
||||
}
|
||||
if last.Action != "refill" || last.Object != "cat_water_fountain" {
|
||||
t.Errorf("wrong pair: %s/%s", last.Action, last.Object)
|
||||
}
|
||||
if last.IntervalDays < 0.1 {
|
||||
t.Errorf("interval %v — the detector saw a burst, not a rhythm", last.IntervalDays)
|
||||
}
|
||||
|
||||
// The proposal is readable through the same list the /routines page uses,
|
||||
// which is the wiring an eval-lab fixture would not have proved.
|
||||
proposed, err := d.seedStore.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(proposed) != 1 {
|
||||
t.Fatalf("expected 1 proposed routine, got %d", len(proposed))
|
||||
}
|
||||
}
|
||||
|
||||
// A value outside the action lexicon writes the fact and says it seeded
|
||||
// nothing. Silence here would read as four working seeds and a broken
|
||||
// detector.
|
||||
func TestSeedReportsWhenExtractionDeclines(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
resp, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "mood", Value: "ok", Ts: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if resp.FactID == 0 {
|
||||
t.Error("fact was not written")
|
||||
}
|
||||
if resp.Extracted || resp.EventID != 0 || resp.Proposed {
|
||||
t.Errorf("claimed an event for a non-action value: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// A seed with no timestamp is refused rather than defaulting to now: the only
|
||||
// reason this seam exists is the caller choosing when, so a zero Ts is a bug in
|
||||
// the caller and must not silently write a fact at the wrong time.
|
||||
func TestSeedRequiresAnExplicitTimestamp(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||
if _, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||
Key: "cat_water_fountain", Value: "заправил",
|
||||
}); err == nil {
|
||||
t.Fatal("seed accepted a zero timestamp")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||
@@ -22,6 +23,11 @@ type daemonAPI struct {
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
|
||||
// the whole off-switch for the backdated write path (Vikunja #518), and it
|
||||
// is a store rather than a bool so that leaving the flag off means the
|
||||
// method has nothing to write with, not merely permission to refuse.
|
||||
seedStore *store.Store
|
||||
}
|
||||
|
||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||
|
||||
@@ -552,6 +552,11 @@ func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
|
||||
// runReembed.
|
||||
var reembedOnStart bool
|
||||
|
||||
// allowSeedOnStart is the -allow-seed flag (set in run()). Opt-in, and the
|
||||
// default is the one that matters: a box nobody is testing has no live path to
|
||||
// write a fact into the past. See seed.go and Vikunja #518.
|
||||
var allowSeedOnStart bool
|
||||
|
||||
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
||||
// wrote the vectors already in the DB (Vikunja #378).
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user