diff --git a/cmd/mavweb/history.html b/cmd/mavweb/history.html
index bd39934..08dea56 100644
--- a/cmd/mavweb/history.html
+++ b/cmd/mavweb/history.html
@@ -4,7 +4,7 @@
maven · history
maven · command history
{{len .Facts}} facts shown (newest first)
+
-| when | kind | key | value | source | conf |
+| when | kind | key | value | source | conf | |
{{range .Facts}}
| {{if .VoidsID}}⨯ voided{{end}}{{.Ts.Format "2006-01-02 15:04"}} |
{{.Kind}} |
@@ -29,5 +35,23 @@
{{.Value}} |
{{.Source}} |
{{printf "%.2f" .Confidence}} |
+{{if not .VoidsID}}{{end}} |
{{end}}
+
diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go
index aecc72d..422262a 100644
--- a/cmd/mavweb/main.go
+++ b/cmd/mavweb/main.go
@@ -133,6 +133,9 @@ func main() {
mux.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) {
handleHistory(w, r, core)
})
+ mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
+ handleRevert(w, r, core)
+ })
// ----- passkey (WebAuthn) endpoints -----
// Wired when both -core and a configured origin are present. The origin
// must match the browser's view of mavweb (e.g. https://maven.kvmx.ru).
@@ -385,6 +388,36 @@ func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
}
}
+func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "POST only", http.StatusMethodNotAllowed)
+ return
+ }
+ if core == nil {
+ http.Error(w, "revert disabled (no -core)", http.StatusServiceUnavailable)
+ return
+ }
+ key := strings.TrimSpace(r.FormValue("key"))
+ if key == "" {
+ http.Error(w, "key required", http.StatusBadRequest)
+ return
+ }
+ ctx := r.Context()
+ newID, err := core.RevertFact(ctx, key)
+ if err != nil {
+ log.Printf("revert %q: %v", key, err)
+ if errors.Is(err, ipc.ErrNoFact) {
+ http.Error(w, "no fact to revert", http.StatusNotFound)
+ return
+ }
+ http.Error(w, "revert failed", http.StatusBadGateway)
+ return
+ }
+ log.Printf("reverted fact for key=%s, new_id=%d", key, newID)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID})
+}
+
// handleTools serves the enable surface (GET) and applies an enable (POST).
// POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is
// whitespace-split — argv with embedded spaces isn't supported (ponytail: no
diff --git a/internal/ipc/api.go b/internal/ipc/api.go
index 5ccf484..4b82e90 100644
--- a/internal/ipc/api.go
+++ b/internal/ipc/api.go
@@ -121,6 +121,9 @@ type outcomesReq struct {
type nReq struct {
N int `json:"n"`
}
+type revertReq struct {
+ Key string `json:"key"`
+}
type writeNoteReq struct {
Ts time.Time `json:"ts"`
Text string `json:"text"`
@@ -229,6 +232,7 @@ type CoreAPI interface {
DisableTool(ctx context.Context, name string) error
LookupTool(ctx context.Context, name string) (Tool, error)
ListTools(ctx context.Context, status string) ([]Tool, error)
+ RevertFact(ctx context.Context, key string) (int64, error)
}
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
diff --git a/internal/ipc/client.go b/internal/ipc/client.go
index 8003c09..1633f7c 100644
--- a/internal/ipc/client.go
+++ b/internal/ipc/client.go
@@ -345,5 +345,15 @@ func (c *Client) ListTools(ctx context.Context, status string) ([]Tool, error) {
return r.Tools, nil
}
+func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
+ var result struct {
+ NewID int64 `json:"new_id"`
+ }
+ if err := c.call(ctx, MethodRevertFact, map[string]string{"key": key}, &result); err != nil {
+ return 0, err
+ }
+ return result.NewID, nil
+}
+
// Compile-time check: *Client satisfies CoreAPI.
var _ CoreAPI = (*Client)(nil)
\ No newline at end of file
diff --git a/internal/ipc/server.go b/internal/ipc/server.go
index 7ad83ba..9a73f73 100644
--- a/internal/ipc/server.go
+++ b/internal/ipc/server.go
@@ -165,6 +165,11 @@ func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
return toTool(t), nil
}
+func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
+ _, newID, err := a.s.VoidLatestFact(ctx, key, "feedback", time.Now())
+ return newID, mapErr(err)
+}
+
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
ts, err := a.s.ListTools(ctx, status)
if err != nil {
@@ -607,6 +612,19 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
}
return marshalResult(listToolsResp{Tools: out}), nil
+ case MethodRevertFact:
+ var p struct {
+ Key string `json:"key"`
+ }
+ if err := unmarshalParams(req.Params, &p); err != nil {
+ return nil, err
+ }
+ newID, err := s.api.RevertFact(ctx, p.Key)
+ if err != nil {
+ return nil, err
+ }
+ return marshalResult(map[string]int64{"new_id": newID}), nil
+
case MethodAssertStepUp:
if s.StepUp != nil {
return marshalResult(nil), s.StepUp(ctx)
diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go
index 1cc24c6..76c7748 100644
--- a/internal/ipc/wire.go
+++ b/internal/ipc/wire.go
@@ -34,6 +34,7 @@ const (
MethodAssertStepUp Method = "assert_stepup"
MethodLookupTool Method = "lookup_tool"
MethodListTools Method = "list_tools"
+ MethodRevertFact Method = "revert_fact"
)
// Request — one frame from module to core. Params is the JSON-encoded argument
diff --git a/internal/store/facts.go b/internal/store/facts.go
index f048770..646d398 100644
--- a/internal/store/facts.go
+++ b/internal/store/facts.go
@@ -166,6 +166,48 @@ func (s *Store) CorrectValue(ctx context.Context, key, source string, value any,
return newID, nil
}
+// VoidLatestFact voids the latest non-voided fact for key. It writes a new
+// fact with voids_id pointing at the old one, keeping the audit trail intact.
+// Returns the voided fact's ID and the new void-marker fact's ID.
+// If no fact exists for the key, returns ErrNoFact.
+func (s *Store) VoidLatestFact(ctx context.Context, key, source string, ts time.Time) (oldID, newID int64, err error) {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, 0, err
+ }
+ defer func() {
+ if err != nil {
+ _ = tx.Rollback()
+ }
+ }()
+ err = tx.QueryRowContext(ctx, `
+ SELECT id FROM facts
+ WHERE key = ?
+ AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
+ ORDER BY ts DESC, id DESC LIMIT 1`, key).Scan(&oldID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, 0, ErrNoFact
+ }
+ if err != nil {
+ return 0, 0, fmt.Errorf("void: find latest: %w", err)
+ }
+ res, err := tx.ExecContext(ctx,
+ `INSERT INTO facts (ts, kind, key, value, source, confidence, voids_id) VALUES (?,?,?,?,?,?,?)`,
+ ts.UnixMilli(), string(KindSelf), key, `"voided"`, source, 1.0,
+ sql.NullInt64{Int64: oldID, Valid: true})
+ if err != nil {
+ return 0, 0, fmt.Errorf("void: write void-marker: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, 0, err
+ }
+ newID, err = res.LastInsertId()
+ if err != nil {
+ return 0, 0, fmt.Errorf("void: last insert id: %w", err)
+ }
+ return oldID, newID, nil
+}
+
type rowScanner interface {
Scan(dest ...any) error
}