feat(privacy): audited redaction that keeps history's shape and removes its content
Rows in append-only history are never deleted; their content can now be made irrecoverable through one audited path. A redaction replaces the content columns of an event, memory, message, model call, belief or assertion with a fixed marker, keeps identifiers, links, timestamps, scores and breakdowns, writes an append-only ledger row with a SHA-256 of the removed content, appends a redaction event, optionally cascades to derived rows and to the events that quote them, and scrubs stored snapshot archives before re-signing them. noema_redact() is the only write the append-only trigger admits, for UPDATE only, under a transaction-local flag the application never sets; plain updates and all deletes stay refused. Replay labels redacted stimuli instead of inventing them. Cryptographic erasure was considered and deferred with reasons (ADR-0009); docs/privacy.md defines retained, redacted, irrecoverable and deleted and states what backups and earlier exports are not covered. Migration 0023.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/andydixon/noema/internal/api"
|
||||
"github.com/andydixon/noema/internal/auth"
|
||||
"github.com/andydixon/noema/pkg/ids"
|
||||
)
|
||||
|
||||
func (u *UI) privacyRoutes(m *http.ServeMux) {
|
||||
m.Handle("GET /minds/{mind}/redactions", u.require(auth.RoleOperator, u.redactionsPage))
|
||||
m.Handle("POST /minds/{mind}/redactions", u.require(auth.RoleAdministrator, u.redact))
|
||||
}
|
||||
|
||||
func (u *UI) redactionsPage(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := u.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, _ := u.deps.Privacy.List(r.Context(), m.ID, 200)
|
||||
u.render(w, r, "redactions", m.DisplayName+" · Redactions", map[string]any{"Mind": m, "Redactions": list, "Kind": r.URL.Query().Get("kind"), "ID": r.URL.Query().Get("id")})
|
||||
}
|
||||
|
||||
func (u *UI) redact(w http.ResponseWriter, r *http.Request) {
|
||||
m, ok := u.mind(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := ids.Parse(r.PostFormValue("id"))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", "Bad id.", "/minds/"+m.Slug+"/redactions")
|
||||
return
|
||||
}
|
||||
cascade, _ := strconv.ParseBool(r.PostFormValue("cascade"))
|
||||
p, c, err := u.studyF().Redact(r, m.ID, api.RedactionInput{Kind: r.PostFormValue("kind"), ID: id, Reason: r.PostFormValue("reason"), Policy: r.PostFormValue("policy"), Cascade: cascade}, username(r))
|
||||
if err != nil {
|
||||
u.flash(w, r, "error", err.Error(), "/minds/"+m.Slug+"/redactions")
|
||||
return
|
||||
}
|
||||
u.flash(w, r, "ok", "Redacted "+string(p.Kind)+" "+p.TargetID.String()[len(p.TargetID.String())-8:]+" and "+itoa(len(c))+" dependant row(s). The content is irrecoverable; hash "+p.ContentHash[:23]+"… attests what was removed.", "/minds/"+m.Slug+"/redactions")
|
||||
}
|
||||
@@ -133,6 +133,7 @@ func (u *UI) parse(fsys fs.FS) error {
|
||||
"duration": func(s int64) string { return (time.Duration(s) * time.Second).Truncate(time.Second).String() },
|
||||
"bytes": humanBytes,
|
||||
"pct": func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) },
|
||||
"list": func(xs ...string) []string { return xs },
|
||||
"conf": func(v any) string {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
@@ -388,6 +389,7 @@ func (u *UI) Register(m *http.ServeMux) {
|
||||
u.integrationRoutes(m)
|
||||
u.dreamRoutes(m)
|
||||
u.studyRoutes(m)
|
||||
u.privacyRoutes(m)
|
||||
|
||||
for _, p := range []struct{ path, title, note string }{} {
|
||||
m.Handle("GET "+p.path, u.require(auth.RoleObserver, func(w http.ResponseWriter, r *http.Request) { u.render(w, r, "placeholder", p.title, p.note) }))
|
||||
|
||||
Reference in New Issue
Block a user