ipc: a seed_event method, step-up gated, refused by the store (V-518)

The pattern detector needs four events for one action+object spread by at
least two hours before it proposes a routine. The only writer in the tree is
a fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
same missing step. This is the wire half of the seam that unblocks them.

The request takes a fact — key, value, timestamp — not an event, so
pattern.Extract runs for real on the daemon side and a key the extractor
ignores seeds nothing. The response says which of those happened, because a
caller that assumed a seed always yields an event would read four silent
successes as a broken detector.

AuthStepUp, the same rung as mutating the tool allowlist, and not because
backdating is privileged in the usual sense: every other write records when
something happened and this one asserts it. StoreAPI refuses outright — the
method needs the daemon's detect-and-propose step, and a direct store caller
would write a fact and quietly skip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
This commit is contained in:
2026-08-05 01:10:06 +04:00
parent c586346a60
commit d3c63e6493
8 changed files with 85 additions and 0 deletions
+13
View File
@@ -91,6 +91,19 @@ func Requirement(m ipc.Method) Authority {
// can do is make Maven stop recognising someone, which is the state the
// box ships in anyway.
return AuthWrite
case ipc.MethodSeedEvent:
// The one backdating write path in the tree (Vikunja #518). AuthStepUp,
// the same rung as mutating the tool allowlist, and for a reason that is
// not about privilege: every other write records when something actually
// happened, and this one asserts it. A caller who can place a fact in the
// past can manufacture a routine Maven will then act on forever, which is
// the tick loop obeying evidence nobody produced.
//
// Step-up is not the real gate and is not meant to be. mavend refuses the
// method entirely unless started with -allow-seed, so the ordinary state
// of the box is that no gesture reaches it. This rung is what stops a
// module from calling it on a box where QA left the flag on.
return AuthStepUp
case ipc.MethodWriteFact:
return AuthWrite
case ipc.MethodIngestMail:
+42
View File
@@ -184,6 +184,48 @@ type CaptureTaskResp struct {
Promoted bool `json:"promoted,omitempty"`
}
// SeedEventReq — write one fact at a caller-supplied timestamp and run the
// pattern path over it, so a recurring routine can be produced on demand
// instead of over real days (Vikunja #518).
//
// This is the ONLY backdating write path in the tree, and it exists for one
// reason: the detector needs four events spread over hours before it proposes
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
// running daemon at all. A store fixture would have exercised the detector
// without the wiring those tasks doubt.
//
// Two things hold it shut. It is AuthStepUp in the authority table, the same
// rung as mutating the tool allowlist. And mavend refuses it outright unless
// started with -allow-seed, so a box nobody is testing carries no live
// backdating path even for a caller who cleared the gate.
//
// Key and Value are a fact, not an event: extraction runs for real, so a key
// the extractor ignores seeds nothing and says so. That is deliberate — a
// seam that accepted action and object directly would let QA prove a detector
// against events no utterance could ever produce.
type SeedEventReq struct {
Key string `json:"key"`
Value string `json:"value"`
Ts time.Time `json:"ts"`
}
// SeedEventResp — what the seed produced. Extracted is false when the fact was
// written but yielded no event, which is the extractor declining rather than a
// failure. Proposed is true only when this seed completed a pattern; the first
// three seeds of a run return false with no routine.
type SeedEventResp struct {
FactID int64 `json:"fact_id"`
EventID int64 `json:"event_id,omitempty"`
Extracted bool `json:"extracted"`
Action string `json:"action,omitempty"`
Object string `json:"object,omitempty"`
Proposed bool `json:"proposed"`
RoutineID int64 `json:"routine_id,omitempty"`
// IntervalDays — the median the detector settled on, echoed so QA can
// check it against the spacing it asked for.
IntervalDays float64 `json:"interval_days,omitempty"`
}
// IngestMailReq — one message a mail reader has fetched, handed to core for
// extraction (Vikunja #246).
//
+8
View File
@@ -487,6 +487,14 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
return r.Routines, nil
}
func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
var r SeedEventResp
if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil {
return SeedEventResp{}, err
}
return r, nil
}
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
var r CaptureTaskResp
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
+7
View File
@@ -110,6 +110,13 @@ type RoutineAPI interface {
// loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id int64) error
// SeedEvent writes a backdated fact and runs extraction and detection over
// it, so a proposal can be produced in one sitting rather than over real
// days (Vikunja #518). See SeedEventReq for why this exists and what keeps
// it shut. Daemon-computed, like MorningStatus — the store adapter refuses
// it, because the detect-and-propose step lives in mavend.
SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error)
// MorningStatus returns each configured morning routine's current
// checklist state (see internal/morning): active today/now, which items
// are done, which are still missing.
+3
View File
@@ -524,6 +524,9 @@ var methodTable = map[Method]handlerFunc{
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
return api.CaptureTask(ctx, p)
}),
MethodSeedEvent: withParams(func(ctx context.Context, api CoreAPI, p SeedEventReq) (SeedEventResp, error) {
return api.SeedEvent(ctx, p)
}),
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
out, err := api.ListTasks(ctx, p.Status)
if err != nil {
+8
View File
@@ -265,6 +265,14 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
// SeedEvent — same shape as MorningStatus: writing the fact is a store call,
// but extraction and detect-and-propose live in mavend, and a seed that wrote
// the fact without running them would be the one thing this seam must not be,
// a way to prove a detector that never ran (Vikunja #518).
func (a *storeAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
return SeedEventResp{}, errors.New("store: seed event not available via direct store API")
}
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
return nil, errors.New("store: morning status not available via direct store API")
}
+3
View File
@@ -105,6 +105,9 @@ func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
return CaptureTaskResp{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
return SeedEventResp{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
return nil, ErrNotImplemented
}
+1
View File
@@ -67,6 +67,7 @@ const (
MethodListSpeakers Method = "list_speakers"
MethodForgetSpeaker Method = "forget_speaker"
MethodRecentEvents Method = "recent_events"
MethodSeedEvent Method = "seed_event"
// MethodPing — liveness, and the only method that answers in locked mode
// without a passkey assertion. It reaches no store, takes no arguments and