From d3c63e6493105d86713484906c7c4b32d8fa8593 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 01:10:06 +0400 Subject: [PATCH] ipc: a seed_event method, step-up gated, refused by the store (V-518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot --- internal/auth/policy.go | 13 +++++++++++ internal/ipc/api.go | 42 +++++++++++++++++++++++++++++++++++ internal/ipc/client.go | 8 +++++++ internal/ipc/coreapi.go | 7 ++++++ internal/ipc/server.go | 3 +++ internal/ipc/storeapi.go | 8 +++++++ internal/ipc/unimplemented.go | 3 +++ internal/ipc/wire.go | 1 + 8 files changed, 85 insertions(+) diff --git a/internal/auth/policy.go b/internal/auth/policy.go index ede1e77..510a1f1 100644 --- a/internal/auth/policy.go +++ b/internal/auth/policy.go @@ -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: diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 6e8d572..2726bd0 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -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). // diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 4a36322..6029f84 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -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 { diff --git a/internal/ipc/coreapi.go b/internal/ipc/coreapi.go index 481c517..8cd65ab 100644 --- a/internal/ipc/coreapi.go +++ b/internal/ipc/coreapi.go @@ -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. diff --git a/internal/ipc/server.go b/internal/ipc/server.go index d055274..bd6df2b 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -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 { diff --git a/internal/ipc/storeapi.go b/internal/ipc/storeapi.go index f67ac90..ec99b7d 100644 --- a/internal/ipc/storeapi.go +++ b/internal/ipc/storeapi.go @@ -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") } diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 4279c67..4c245f2 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -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 } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index 50e2f15..2e12df7 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -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