items 5-7: passkey step-up, tools enable/disable, note RAG — end to end

Completes the three in-flight open items and fixes the away-fallthrough bug.

Item 7 — passkey step-up (WebAuthn):
- internal/webauthn: ES256/P-256 register + assert with real ecdsa signature
  verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert,
  decays after TTL). Drop the RS256 offer we can't verify (register-ok/
  assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is
  the step-up gesture. Round-trip test with negative cases (tampered sig,
  missing UV, wrong origin).
- cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do
  a WebAuthn gesture) + the four begin/finish endpoints. Without this the
  daemon's PasskeySession swap leaves /tools enable permanently blocked.
- daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates
  MethodAssertStepUp at AuthRead.

Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a
disable action and a link to the passkey page. Lifecycle test.

Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k
notes, raw-notes fallback); IntentQuery routes through it. Stub returns a
deterministic summary.

Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes
through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop)
instead of silently dropping / mis-routing to the present-list remainder.
Covers DispatchNudge + DispatchReminder. 4 tests.

Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix
missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc,
gitignore /mavcaldav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-03 18:41:13 +04:00
parent 36233058dd
commit 6239eca243
21 changed files with 1492 additions and 50 deletions
+21 -4
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/store"
)
// NudgeRecorder — the seam the store implements. the dispatcher records one
@@ -87,7 +88,8 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
c := pn.Candidate
channels := ChannelsFor(c.Severity, c.State.Presence)
var out []Dispatch
for _, ch := range channels {
for i := 0; i < len(channels); i++ {
ch := channels[i]
if ch == ChannelDrop {
continue
}
@@ -107,7 +109,16 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
}
if err := sink.Send(ctx, s); err != nil {
if errors.Is(err, ErrVoiceNoSession) {
log.Printf("dispatcher: no live voice session for %s, falling through", c.Rule.Name)
// voice was assumed reachable (presence=present) but no live
// session exists — the presence guess was wrong. reroute through
// the AWAY table per § away-channel fallthrough: sev3→ntfy,
// sev4→telegram-repeat-til-ack, sev≤2→drop. voice is always the
// first present channel, so nothing has been sent yet; replace
// the remaining list wholesale. away channels never include
// voice, so this can't re-trigger.
log.Printf("dispatcher: no live voice session for %s, rerouting to away channels", c.Rule.Name)
channels = ChannelsFor(c.Severity, store.Away)
i = -1
continue
}
return out, fmt.Errorf("send %s: %w", ch, err)
@@ -143,7 +154,8 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
rd := pr.Decision
channels := ChannelsForReminder(rd.State.Presence)
var out []Dispatch
for _, ch := range channels {
for i := 0; i < len(channels); i++ {
ch := channels[i]
s := Sendable{
Channel: ch,
Kind: KindReminder,
@@ -158,7 +170,12 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
}
if err := sink.Send(ctx, s); err != nil {
if errors.Is(err, ErrVoiceNoSession) {
log.Printf("dispatcher: no live voice session for reminder %d, falling through", rd.Reminder.ID)
// presence guess was wrong — reroute reminder to the away
// channel (ntfy). voice is the only present channel, so nothing
// has been sent yet.
log.Printf("dispatcher: no live voice session for reminder %d, rerouting to away channels", rd.Reminder.ID)
channels = ChannelsForReminder(store.Away)
i = -1
continue
}
return out, fmt.Errorf("send %s: %w", ch, err)
+110
View File
@@ -391,6 +391,116 @@ func TestDispatchReminderFailedSendNotMarkedFired(t *testing.T) {
}
}
// ------------------ voice no-session → away-channel reroute -----------------
//
// When the routing table picks voice (presence=present) but no live session
// exists at push time, the presence guess was wrong. The dispatcher must
// reroute through the AWAY table (§ away-channel fallthrough), not silently
// drop or fall to the wrong channel.
func TestDispatchNudgeVoiceNoSessionSev3RoutesNtfy(t *testing.T) {
// present sev3 → [voice]. voice has no session → away sev3 = ntfy.
voice := &fakeSink{err: ErrVoiceNoSession}
ntfy := &fakeSink{}
telegram := &fakeSink{}
rec := &fakeNudgeRecorder{}
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Nudges: rec})
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("cert_expiring", loop.Sev3, store.Present),
Body: "cert expiring", Summary: "cert expiring",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(ntfy.sends) != 1 {
t.Fatalf("sev3 voice-no-session: want 1 ntfy send, got %d", len(ntfy.sends))
}
if len(telegram.sends) != 0 {
t.Fatalf("sev3 must not hit telegram, got %d", len(telegram.sends))
}
if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
t.Fatalf("want 1 ntfy dispatch, got %+v", out)
}
}
func TestDispatchNudgeVoiceNoSessionSev4RoutesTelegramRepeatUntilAck(t *testing.T) {
// present sev4 → [voice, ntfy]. voice has no session → away sev4 =
// telegram-repeat-til-ack (NOT the present-list ntfy remainder).
voice := &fakeSink{err: ErrVoiceNoSession}
ntfy := &fakeSink{}
telegram := &fakeSink{}
ack := newFakeAck()
rec := &fakeNudgeRecorder{}
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Ack: ack, Nudges: rec})
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("service_down", loop.Sev4, store.Present),
Body: "backup down", Summary: "backup down",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(telegram.sends) != 1 {
t.Fatalf("sev4 voice-no-session: want 1 telegram send, got %d", len(telegram.sends))
}
if len(ntfy.sends) != 0 {
t.Fatalf("sev4 away reroute must not fall to ntfy, got %d", len(ntfy.sends))
}
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || !out[0].Sendable.RepeatUntilAck {
t.Fatalf("want 1 telegram RepeatUntilAck dispatch, got %+v", out)
}
if _, ok := ack.lastSent["service_down"]; !ok {
t.Fatalf("repeat-til-ack reroute must MarkSent in the ack tracker")
}
}
func TestDispatchNudgeVoiceNoSessionSev2Drops(t *testing.T) {
// present sev2 → [voice]. voice has no session → away sev2 = drop.
voice := &fakeSink{err: ErrVoiceNoSession}
ntfy := &fakeSink{}
telegram := &fakeSink{}
rec := &fakeNudgeRecorder{}
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Nudges: rec})
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
Candidate: candidate("water", loop.Sev2, store.Present),
Body: "drink water", Summary: "drink water",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(ntfy.sends) != 0 || len(telegram.sends) != 0 || len(out) != 0 {
t.Fatalf("sev2 voice-no-session must drop silently, got ntfy=%d telegram=%d out=%d",
len(ntfy.sends), len(telegram.sends), len(out))
}
}
func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) {
// present reminder → [voice]. voice has no session → away = ntfy.
voice := &fakeSink{err: ErrVoiceNoSession}
ntfy := &fakeSink{}
rc := &fakeReminderCompleter{}
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Reminders: rc})
rd := loop.ReminderDecision{
Reminder: store.Reminder{ID: 99, Status: "pending"},
State: loop.State{Now: refNow(), Presence: store.Present},
}
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
Decision: rd, Body: "wake up", Summary: "wake up",
}, refNow())
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(ntfy.sends) != 1 || len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
t.Fatalf("reminder voice-no-session: want 1 ntfy, got ntfy=%d out=%+v", len(ntfy.sends), out)
}
if len(rc.marked) != 1 || rc.marked[0].status != "fired" {
t.Fatalf("rerouted reminder must be marked fired: %+v", rc.marked)
}
}
// ----------------------------- repeat-til-ack -------------------------------
func TestShouldRepeat(t *testing.T) {