// 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 }