oficina / memoria Public

🤖 memoria: enforce sibling custody on document routes

Draft implementation under settled access rules; independent design and full release review remain pending. Reuses existing notices and preserves local document behavior.
Co-Authored-By: Codex <noreply@openai.com>
Paul Campbell pushed by paul@keymail.dev a012c8fb14b41599c15777858f939611ebf1aa03 parent e4b178f
7 files changed, +424 −60
  • internal/memoria/custody_test.go +1 −2
  • internal/memoria/handlers.go +44 −40
  • internal/memoria/reader.go +152 −0
  • internal/memoria/store.go +8 −7
  • internal/memoria/templates/index.html +6 −3
  • internal/memoriatest/custody_routes_test.go +203 −7
  • tools/drive/main.go +10 −1

CI passed — run details

diff --git a/internal/memoria/custody_test.go b/internal/memoria/custody_test.go
index 9b35870..8c30c7f 100644
--- a/internal/memoria/custody_test.go
+++ b/internal/memoria/custody_test.go
@@ -83,8 +83,7 @@ func storeWithASiblingDocument(t *testing.T, custody Custody) *Store {
return s
}
-// §4.1 end to end, through the only method that returns a renderable
-// passage.
+// §4.1 end to end through Search.
//
// This is the gate the sibling package's unit tests cannot be: they prove
// `Decide` maps values and `Rule` fans out, and neither proves that the
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 9e70f4e..dd2296b 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -17,6 +17,7 @@ import (
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/flash"
"github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
"amadan.net/oficina/memoria/internal/memoria/query"
)
@@ -131,15 +132,16 @@ func (a *app) UseAuth(x *auth.Auth) { a.auth = x }
// because the layout states them on every screen: a corpus count is how
// a person tells "nothing matched" from "nothing indexed".
type page struct {
- Signed bool
- Chunks int
- Query string
- Searched bool
- Flash string
- FlashKind string
- Results []Result
- Documents []Summary
- Drop dropZone
+ Signed bool
+ Chunks int
+ Query string
+ Searched bool
+ Flash string
+ FlashKind string
+ Results []Result
+ Documents []Summary
+ DocumentNotices []string
+ Drop dropZone
// Notices is §6.5's loud withholding: what this page could not show
// and which app it came from. **Never a count of what the reader may
@@ -537,6 +539,11 @@ func (a *app) chunkCount() int {
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
+ addr, ok := a.identity(r)
+ if !ok || addr == "" {
+ http.NotFound(w, r)
+ return
+ }
q := strings.TrimSpace(r.URL.Query().Get("q"))
drop := newDropZone()
p := page{
@@ -547,11 +554,12 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
Drop: drop,
}
a.runSearch(r, &p)
- docs, err := a.store.Summaries(r.Context())
+ docs, notices, err := a.store.ReaderSummaries(r.Context(), suite.Subject{Kind: suite.SubjectAddress, Value: addr})
if err != nil {
a.logger.Error("list documents", "err", err)
+ notices = []string{"could not read that document"}
}
- p.Documents = docs
+ p.Documents, p.DocumentNotices = docs, notices
if f := r.URL.Query().Get("stored"); f != "" {
p.Flash, p.FlashKind = drop.outcomeCounts(count(f), count(r.URL.Query().Get("refused")))
}
@@ -1024,42 +1032,30 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- title, body, err := a.store.Body(r.Context(), id)
- if err != nil {
- // A row that is not there 404s. It never 403s, so a document
- // that exists but is not yours is indistinguishable from one
- // that does not exist.
+ addr, ok := a.identity(r)
+ if !ok || addr == "" {
http.NotFound(w, r)
return
}
- // Read the STORED chunks. Re-chunking here would derive ordinals
- // from the current chunker while every citation carries the ordinal
- // from the one that ran at ingest.
- stored, err := a.store.Passages(r.Context(), id)
+ doc, notices, err := a.store.ReadDocument(r.Context(), suite.Subject{Kind: suite.SubjectAddress, Value: addr}, id)
if err != nil {
- a.logger.Error("read passages", "id", id, "err", err)
- http.Error(w, "could not read that document", http.StatusInternalServerError)
+ if len(notices) > 0 {
+ http.Error(w, strings.Join(notices, "\n"), http.StatusServiceUnavailable)
+ return
+ }
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ http.NotFound(w, r)
+ } else {
+ http.Error(w, "could not read that document", http.StatusInternalServerError)
+ }
return
}
- passages := make([]passage, 0, len(stored))
- for _, p := range stored {
+ passages := make([]passage, 0, len(doc.Passages))
+ for _, p := range doc.Passages {
passages = append(passages, passage{Ordinal: p.Ordinal, Heading: p.Heading, Text: p.Text})
}
- source, revision, nbytes, err := a.store.Meta(r.Context(), id)
- if err != nil {
- http.NotFound(w, r)
- return
- }
- _ = body
- render(w, "document", documentPage{
- Signed: true,
- Chunks: a.chunkCount(),
- Title: title,
- Source: source,
- Bytes: nbytes,
- Revision: revision,
- Passages: passages,
- })
+ render(w, "document", documentPage{Signed: true, Chunks: a.chunkCount(), Title: doc.Title,
+ Source: doc.Source, Bytes: doc.Bytes, Revision: doc.Revision, Passages: passages})
}
func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) {
@@ -1068,7 +1064,15 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- if err := a.store.Delete(r.Context(), id); err != nil {
+ if addr, ok := a.identity(r); !ok || addr == "" {
+ http.NotFound(w, r)
+ return
+ }
+ if err := a.store.DeleteOwned(r.Context(), id); err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ http.NotFound(w, r)
+ return
+ }
// The delete did not happen, and this used to say nothing about
// that: the error was logged and the redirect issued exactly as
// if it had. Someone who asked for a passage to stop being
diff --git a/internal/memoria/reader.go b/internal/memoria/reader.go
new file mode 100644
index 0000000..50d0a65
--- /dev/null
+++ b/internal/memoria/reader.go
@@ -0,0 +1,152 @@
+package memoria
+
+import (
+ "context"
+ "errors"
+
+ "amadan.net/oficina/suite"
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/memoria/internal/memoria/seal"
+ "amadan.net/oficina/memoria/internal/memoria/sibling"
+)
+
+func (s *Store) documentCandidate(d Document) (Result, error) {
+ r := Result{DocumentID: d.ID, Source: d.Source}
+ if len(d.SiblingItemID) == 0 && d.SiblingRevision == 0 {
+ return r, nil
+ }
+ if len(d.SiblingItemID) == 0 || d.SiblingRevision == 0 {
+ return Result{}, errors.New("ingest: a sibling document needs both an item id and a revision")
+ }
+ id, err := s.sealer.Open(seal.PurposeItemID, d.SiblingItemID)
+ if err != nil {
+ return Result{}, err
+ }
+ if len(id) == 0 {
+ return Result{}, errors.New("ingest: a sibling document needs both an item id and a revision")
+ }
+ r.item = sibling.Candidate{Item: suite.ItemRevision{ItemID: string(id), Revision: d.SiblingRevision}}
+ return r, nil
+}
+
+func (s *Store) ReaderSummaries(ctx context.Context, asker suite.Subject) ([]Summary, []string, error) {
+ var docs []Document
+ var counts []struct {
+ DocumentID int64
+ Total int
+ }
+ if err := s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
+ if err := tx.Order("updated_at desc").Find(&docs).Error; err != nil {
+ return err
+ }
+ return tx.Model(&Chunk{}).Select("document_id, COUNT(*) AS total").Group("document_id").Scan(&counts).Error
+ }); err != nil {
+ return nil, nil, err
+ }
+ candidates := make([]Result, 0, len(docs))
+ for _, d := range docs {
+ candidate, err := s.documentCandidate(d)
+ if err != nil {
+ return nil, nil, err
+ }
+ candidates = append(candidates, candidate)
+ }
+ ruled := s.rule(ctx, asker, candidates)
+ allowed := make(map[int64]bool, len(ruled.Results))
+ for _, r := range ruled.Results {
+ allowed[r.DocumentID] = true
+ }
+ chunkCounts := make(map[int64]int, len(counts))
+ for _, c := range counts {
+ chunkCounts[c.DocumentID] = c.Total
+ }
+ out := make([]Summary, 0, len(allowed))
+ for _, d := range docs {
+ if !allowed[d.ID] {
+ continue
+ }
+ title, err := s.sealer.Open(seal.PurposeTitle, d.SealedTitle)
+ if err != nil {
+ return nil, nil, err
+ }
+ var path string
+ if len(d.SealedPath) > 0 {
+ opened, err := s.sealer.Open(seal.PurposePath, d.SealedPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ path = string(opened)
+ }
+ out = append(out, Summary{ID: d.ID, Source: d.Source, Title: string(title), Path: path,
+ Bytes: d.Bytes, Chunks: chunkCounts[d.ID], Updated: d.UpdatedAt.Format("2 Jan 2006, 15:04"),
+ CanDelete: len(d.SiblingItemID) == 0 && d.SiblingRevision == 0})
+ }
+ return out, ruled.Notices, nil
+}
+
+type ReaderDocument struct {
+ Title string
+ Source string
+ Revision int
+ Bytes int
+ Passages []Passage
+}
+
+func (s *Store) ReadDocument(ctx context.Context, asker suite.Subject, id int64) (ReaderDocument, []string, error) {
+ var d Document
+ var rows []Chunk
+ if err := s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
+ if err := tx.Where("id = ?", id).First(&d).Error; err != nil {
+ return err
+ }
+ return tx.Where("document_id = ?", id).Order("ordinal").Find(&rows).Error
+ }); err != nil {
+ return ReaderDocument{}, nil, err
+ }
+ candidate, err := s.documentCandidate(d)
+ if err != nil {
+ return ReaderDocument{}, nil, err
+ }
+ ruled := s.rule(ctx, asker, []Result{candidate})
+ if len(ruled.Results) == 0 {
+ return ReaderDocument{}, ruled.Notices, gorm.ErrRecordNotFound
+ }
+ title, err := s.sealer.Open(seal.PurposeTitle, d.SealedTitle)
+ if err != nil {
+ return ReaderDocument{}, nil, err
+ }
+ out := ReaderDocument{Title: string(title), Source: d.Source, Revision: d.Revision, Bytes: d.Bytes, Passages: make([]Passage, 0, len(rows))}
+ for _, row := range rows {
+ text, err := s.sealer.Open(seal.PurposeChunk, row.SealedText)
+ if err != nil {
+ return ReaderDocument{}, nil, err
+ }
+ heading, err := s.sealer.Open(seal.PurposeTitle, row.SealedHeading)
+ if err != nil {
+ return ReaderDocument{}, nil, err
+ }
+ out.Passages = append(out.Passages, Passage{Ordinal: row.Ordinal, Heading: string(heading), Text: string(text)})
+ }
+ return out, nil, nil
+}
+
+func (s *Store) DeleteOwned(ctx context.Context, id int64) error {
+ return s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
+ guard := tx.Exec(`UPDATE documents SET id = id WHERE id = ?
+ AND (sibling_item_id IS NULL OR length(sibling_item_id) = 0) AND sibling_revision = 0`, id)
+ if guard.Error != nil {
+ return guard.Error
+ }
+ if guard.RowsAffected != 1 {
+ return gorm.ErrRecordNotFound
+ }
+ if err := s.ix.RemoveDocument(id); err != nil {
+ return err
+ }
+ if err := tx.Where("document_id = ?", id).Delete(&Chunk{}).Error; err != nil {
+ return err
+ }
+ return tx.Where("id = ?", id).Delete(&Document{}).Error
+ })
+}
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index 56350fb..5860c0a 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -102,13 +102,14 @@ type Page struct {
// Summary is a document as the list shows it.
type Summary struct {
- ID int64
- Source string
- Title string
- Path string
- Bytes int
- Chunks int
- Updated string
+ CanDelete bool
+ ID int64
+ Source string
+ Title string
+ Path string
+ Bytes int
+ Chunks int
+ Updated string
}
// externalID keys a document by its stable source identifier.
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html
index 6ced7c3..b6447e8 100644
--- a/internal/memoria/templates/index.html
+++ b/internal/memoria/templates/index.html
@@ -172,9 +172,12 @@
</section>
<section rst-section rst-refresh="documents">
+ {{if .DocumentNotices}}<div rst-callout rst-tone="warning" rst-withheld>
+ {{range .DocumentNotices}}<p rst-callout-body>{{.}}</p>{{end}}
+ </div>{{end}}
<h2>{{len .Documents}} document{{if ne (len .Documents) 1}}s{{end}}</h2>
{{if not .Documents}}
- <p rst-cell-mut>Nothing stored yet.</p>
+ {{if not .DocumentNotices}}<p rst-cell-mut>Nothing stored yet.</p>{{end}}
{{else}}
<table rst-table>
<thead><tr><th>Title</th><th>Source</th><th>Passages</th><th>Updated</th><th></th></tr></thead>
@@ -186,9 +189,9 @@
<td rst-cell-mut>{{.Chunks}}</td>
<td rst-cell-mut>{{.Updated}}</td>
<td>
- <form method="post" action="/documents/{{.ID}}/delete" rst-inline>
+ {{if .CanDelete}}<form method="post" action="/documents/{{.ID}}/delete" rst-inline>
<button rst-btn="quiet compact" class="rst-danger">Delete</button>
- </form>
+ </form>{{end}}
</td>
</tr>
{{end}}
diff --git a/internal/memoriatest/custody_routes_test.go b/internal/memoriatest/custody_routes_test.go
index 6e19678..0181fc1 100644
--- a/internal/memoriatest/custody_routes_test.go
+++ b/internal/memoriatest/custody_routes_test.go
@@ -6,9 +6,11 @@ import (
"io"
"log/slog"
"net/http"
+ "net/http/httptest"
"path/filepath"
"strings"
"testing"
+ "time"
"github.com/carlosframework/rastrillo/db"
@@ -18,16 +20,21 @@ import (
)
type routeCustody struct {
- answer suite.Authorization
- asks []suite.Subject
- pairs []suite.ItemRevision
+ unresolved bool
+ onRule func()
+ answer suite.Authorization
+ asks []suite.Subject
+ pairs []suite.ItemRevision
}
func (c *routeCustody) Sibling(source string) (sibling.Sibling, bool) {
- return sibling.Sibling{Name: "Docs"}, source == "Docs"
+ return sibling.Sibling{Name: "Docs"}, source == "Docs" && !c.unresolved
}
func (c *routeCustody) Rule(_ context.Context, who suite.Subject, candidates []sibling.Candidate) []sibling.Disposition {
+ if c.onRule != nil {
+ c.onRule()
+ }
c.asks = append(c.asks, who)
out := make([]sibling.Disposition, len(candidates))
for i, candidate := range candidates {
@@ -37,7 +44,7 @@ func (c *routeCustody) Rule(_ context.Context, who suite.Subject, candidates []s
return out
}
-func custodyRouteApp(t *testing.T, custody memoria.Custody) (http.Handler, *memoria.Store, int64, int64) {
+func custodyRouteApp(t *testing.T, custody memoria.Custody, alter ...func(*db.DB)) (http.Handler, *memoria.Store, int64, int64) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
@@ -67,12 +74,19 @@ func custodyRouteApp(t *testing.T, custody memoria.Custody) (http.Handler, *memo
if err != nil {
t.Fatal(err)
}
+ for _, change := range alter {
+ change(d)
+ }
return h, s, siblingDoc, localDoc
}
// Baseline regression evidence: these HTTP routes ingest actual sibling
// coordinates, while retaining local content as a positive control.
-// Independent reviewer mutations will be recorded after design approval.
+// MUTATION-TESTED 2026-09-07: independently selected by the coordinator.
+// Forcing list inclusion, ignoring the document ruling, rereading chunks
+// after S2, dropping the delete predicate, treating malformed coordinates
+// as local, and enabling sibling Delete each failed on its named state.
+// Each mutation was reverted separately before the final green run.
func TestSiblingDocumentHTTPRequiresCustody(t *testing.T) {
for _, answer := range []suite.Authorization{suite.NOT_VISIBLE, suite.STALE, suite.UNKNOWN, suite.VISIBLE} {
for _, route := range []string{"/", "/?q=quarterly", "/search?q=quarterly", "/documents/%d"} {
@@ -92,10 +106,25 @@ func TestSiblingDocumentHTTPRequiresCustody(t *testing.T) {
} else {
for _, secret := range []string{"SiblingTitleCanary", "SiblingHeadingCanary", "SiblingBodyCanary"} {
if strings.Contains(body, secret) {
- t.Errorf("unruled sibling field rendered: %s", secret)
+ t.Errorf("unruled sibling field rendered at HTTP %d: %s", r.Code, secret)
}
}
}
+ if answer == suite.NOT_VISIBLE && strings.Contains(body, "rst-withheld") {
+ t.Error("NOT_VISIBLE produced a notice")
+ }
+ if (answer == suite.UNKNOWN || answer == suite.STALE) && !strings.Contains(body, "Docs ") {
+ t.Error("withholding was silent")
+ }
+ if route == "/documents/%d" && answer == suite.NOT_VISIBLE && r.Code != http.StatusNotFound {
+ t.Errorf("denial status%d", r.Code)
+ }
+ if route == "/documents/%d" && (answer == suite.UNKNOWN || answer == suite.STALE) && r.Code != http.StatusServiceUnavailable {
+ t.Errorf("withholding status%d", r.Code)
+ }
+ if route == "/" && strings.Contains(body, fmt.Sprintf("action=\"/documents/%d/delete\"", id)) {
+ t.Error("sibling delete affordance rendered")
+ }
if len(custody.asks) == 0 {
t.Error("route did not obtain custody ruling")
}
@@ -148,3 +177,170 @@ func TestSiblingDocumentHTTPManualDeleteHasNoAuthority(t *testing.T) {
t.Error("local deletion did not remove row")
}
}
+
+func TestSiblingDocumentHTTPUnresolvedCustody(t *testing.T) {
+ for _, custody := range []memoria.Custody{nil, &routeCustody{answer: suite.VISIBLE, unresolved: true}} {
+ h, _, id, _ := custodyRouteApp(t, custody)
+ for _, target := range []string{"/", fmt.Sprintf("/documents/%d", id)} {
+ r := get(t, h, target)
+ if strings.Contains(r.Body.String(), "SiblingTitleCanary") || strings.Contains(r.Body.String(), "SiblingBodyCanary") {
+ t.Error("unresolved custody disclosed content")
+ }
+ if !strings.Contains(r.Body.String(), "Docs did not answer") {
+ t.Error("unresolved custody was silent")
+ }
+ }
+ }
+}
+
+func TestSiblingDocumentHTTPRechecksVisibility(t *testing.T) {
+ for _, target := range []string{"/", "/documents/%d"} {
+ custody := &routeCustody{answer: suite.VISIBLE}
+ h, _, id, _ := custodyRouteApp(t, custody)
+ if strings.Contains(target, "%d") {
+ target = fmt.Sprintf(target, id)
+ }
+ if !strings.Contains(get(t, h, target).Body.String(), "SiblingTitleCanary") {
+ t.Fatal("visible control missing")
+ }
+ before := len(custody.asks)
+ custody.answer = suite.NOT_VISIBLE
+ if strings.Contains(get(t, h, target).Body.String(), "SiblingTitleCanary") {
+ t.Error("revocation did not withhold")
+ }
+ if len(custody.asks) <= before {
+ t.Error("next request did not reauthorize")
+ }
+ }
+}
+
+func TestSiblingDocumentHTTPConcurrentIngestCannotReplaceRuledSnapshot(t *testing.T) {
+ for _, route := range []string{"/", "/documents/%d"} {
+ t.Run(route, func(t *testing.T) {
+ custody := &routeCustody{answer: suite.VISIBLE}
+ h, s, id, _ := custodyRouteApp(t, custody)
+ target := route
+ if strings.Contains(target, "%d") {
+ target = fmt.Sprintf(target, id)
+ }
+ entered, resume := make(chan struct{}), make(chan struct{})
+ defer func() {
+ select {
+ case <-resume:
+ default:
+ close(resume)
+ }
+ }()
+ custody.onRule = func() { close(entered); <-resume }
+ done := make(chan *httptest.ResponseRecorder, 1)
+ go func() {
+ r := httptest.NewRecorder()
+ h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, target, nil))
+ done <- r
+ }()
+ select {
+ case <-entered:
+ case <-time.After(3 * time.Second):
+ t.Fatal("request never reached S2")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, err := s.Ingest(ctx, memoria.IngestSpec{Source: "Docs", Key: "route-sibling", Title: "UnruledNewTitle", Body: "UnruledNewBody", ItemID: "sibling-item-route", SiblingRevision: 20})
+ if err != nil {
+ t.Fatalf("concurrent ingest: %v", err)
+ }
+ close(resume)
+ var r *httptest.ResponseRecorder
+ select {
+ case r = <-done:
+ case <-time.After(3 * time.Second):
+ t.Fatal("request did not finish")
+ }
+ if strings.Contains(r.Body.String(), "UnruledNewTitle") || strings.Contains(r.Body.String(), "UnruledNewBody") {
+ t.Error("new revision substituted after old ruling")
+ }
+ if !strings.Contains(r.Body.String(), "SiblingTitleCanary") {
+ t.Error("authorized snapshot control missing")
+ }
+ for _, pair := range custody.pairs {
+ if pair.Revision != 19 {
+ t.Errorf("wrong revision asked: %d", pair.Revision)
+ }
+ }
+ _, body, err := s.Body(context.Background(), id)
+ if err != nil || body != "UnruledNewBody" {
+ t.Error("concurrent ingest did not actually commit")
+ }
+ })
+ }
+}
+
+func TestSiblingDocumentHTTPVisibleReadCannotGrantDelete(t *testing.T) {
+ custody := &routeCustody{answer: suite.VISIBLE}
+ h, s, id, _ := custodyRouteApp(t, custody)
+ if get(t, h, fmt.Sprintf("/documents/%d", id)).Code != http.StatusOK {
+ t.Fatal("visible read failed")
+ }
+ if post(t, h, fmt.Sprintf("/documents/%d/delete", id), nil).Code != http.StatusNotFound {
+ t.Error("read grant allowed delete")
+ }
+ if _, _, err := s.Body(context.Background(), id); err != nil {
+ t.Error("visible sibling row removed")
+ }
+}
+
+func TestSiblingDocumentHTTPMalformedCoordinateNeverBecomesLocal(t *testing.T) {
+ for _, assignment := range []string{"sibling_item_id = NULL", "sibling_revision = 0", "sibling_item_id = x'010203'"} {
+ t.Run(assignment, func(t *testing.T) {
+ custody := &routeCustody{answer: suite.VISIBLE}
+ h, s, id, localID := custodyRouteApp(t, custody, func(d *db.DB) {
+ if err := d.G.Exec("UPDATE documents SET "+assignment+" WHERE source = ?", "Docs").Error; err != nil {
+ t.Fatal(err)
+ }
+ })
+ for _, target := range []string{"/", fmt.Sprintf("/documents/%d", id)} {
+ r := get(t, h, target)
+ if strings.Contains(r.Body.String(), "SiblingTitleCanary") || strings.Contains(r.Body.String(), "SiblingBodyCanary") {
+ t.Error("malformed coordinate disclosed content")
+ }
+ }
+ if get(t, h, fmt.Sprintf("/documents/%d", localID)).Code != http.StatusOK {
+ t.Error("local control failed")
+ }
+ if post(t, h, fmt.Sprintf("/documents/%d/delete", id), nil).Code != http.StatusNotFound {
+ t.Error("malformed sibling deletion not refused")
+ }
+ if _, _, err := s.Body(context.Background(), id); err != nil {
+ t.Error("malformed sibling row deleted")
+ }
+ })
+ }
+}
+
+func TestLocalDeleteSQLFailureKeepsDocumentWithoutLiveCitation(t *testing.T) {
+ h, s, _, id := custodyRouteApp(t, nil, func(d *db.DB) {
+ if err := d.G.Exec(`CREATE TRIGGER refuse_local_delete BEFORE DELETE ON documents
+ WHEN OLD.source = 'upload' BEGIN SELECT RAISE(ABORT, 'fixture refusal'); END`).Error; err != nil {
+ t.Fatal(err)
+ }
+ })
+ r := post(t, h, fmt.Sprintf("/documents/%d/delete", id), nil)
+ if r.Code != http.StatusSeeOther {
+ t.Errorf("delete failure status%d", r.Code)
+ }
+ _, body, err := s.Body(context.Background(), id)
+ if err != nil || !strings.Contains(body, "LocalBodyControl") {
+ t.Error("failed SQL delete lost document")
+ }
+ found, err := s.Search(context.Background(), suite.Subject{Kind: suite.SubjectAddress, Value: "reader@example.test"}, "LocalBodyControl", 10)
+ if err != nil || len(found.Results) != 0 {
+ t.Errorf("failed delete left live citation: %v", err)
+ }
+ if _, err := s.Rebuild(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ found, err = s.Search(context.Background(), suite.Subject{Kind: suite.SubjectAddress, Value: "reader@example.test"}, "LocalBodyControl", 10)
+ if err != nil || len(found.Results) != 1 {
+ t.Error("rebuild did not restore retained document to index")
+ }
+}
diff --git a/tools/drive/main.go b/tools/drive/main.go
index 0bb1d58..9a75026 100644
--- a/tools/drive/main.go
+++ b/tools/drive/main.go
@@ -20,6 +20,7 @@ import (
"flag"
"fmt"
"log/slog"
+ "net"
"net/http"
"os"
"path/filepath"
@@ -189,7 +190,7 @@ func seedSiblings(app interface {
{Name: "Sheets", BaseURL: "https://drive.sheets.oficina.works", Token: "drive"},
{Name: "Calendar", BaseURL: "https://drive.calendar.oficina.works", Token: "drive"},
}
- custody, err := memoria.NewSiblings("drive", nil, apps)
+ custody, err := memoria.NewSiblings("drive", func(context.Context, string, string) (net.Conn, error) { return nil, net.ErrClosed }, apps)
if err != nil {
fmt.Fprintln(os.Stderr, "seed siblings:", err)
return
@@ -197,6 +198,14 @@ func seedSiblings(app interface {
app.UseSiblings(custody)
store := app.Store()
+ if summaries, err := store.Summaries(ctx); err == nil && len(summaries) > 0 {
+ title, body, err := store.Body(ctx, summaries[0].ID)
+ if err == nil {
+ if _, err := store.Ingest(ctx, memoria.IngestSpec{Source: "Docs", Key: "drive-docs", Title: title, Body: body, ItemID: "drive-docs", SiblingRevision: 7}); err != nil {
+ panic(err)
+ }
+ }
+ }
now := time.Now().UTC()
// Docs: quiet, and polled a moment ago.