retention: the record plane is durable

cmd/amld built the ledger with retention.New() — the memory shelf, whose own
header says it 'does not survive a restart, so it is not what an instance
serves from'. retention.NewBase(app) has existed all along and had no caller,
while the transaction history thirty lines below was already Base-backed.

So every retained record — the sealed transactions, the relationships whose
clocks Art. 77(3) starts, the assessments a case is closed against — lived
until the next rollout. A restart is not an event the five-year obligation
makes an exception for.

Wired the same way history is: collections first, then a ledger over them,
inside OnServe where the app exists. TestRetainedRecordsSurviveARestart is
the property as a test — retain, take the bytes as they are on disk, drop the
instance, open a second one over them, and ask Art. 78's question again.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-30 18:38:09 -07:00
co-authored by Hanzo Dev
parent eb4408ac3e
commit 4aa9887950
2 changed files with 134 additions and 1 deletions
+15 -1
View File
@@ -109,7 +109,6 @@ func main() {
// an instance without it reports itself unfit and refuses to process a
// transaction it could not record — the right failure for a control whose whole
// job is to have kept the record.
records := retention.New()
keys := token.NewKeyring(token.Env("AML_TOKEN_KEY"))
// The behavioural plane. Sliding aggregates are the substrate every behavioural
@@ -143,6 +142,21 @@ func main() {
return fmt.Errorf("refusing to start, the transaction record cannot be created: %w", err)
}
events := history.NewBase(app)
// The retained record plane, on the same terms as the transaction
// record above: collections first, then a ledger over them.
//
// It is Base-backed and not the memory shelf, which is the whole
// obligation. Records have to be kept for five years after the
// relationship ends (AMLR Art. 77(3)); a shelf that empties on restart
// keeps them until the next rollout, and a `kubectl rollout restart`
// is not an event the law makes an exception for. The memory shelf is
// for tests and says so.
if err := retention.Ensure(app); err != nil {
return fmt.Errorf("refusing to start, the retained record cannot be created: %w", err)
}
records := retention.NewBase(app)
rates := reference.RatesFromEnv()
eng := engine.New(engine.Providers{
+119
View File
@@ -0,0 +1,119 @@
package retention
import (
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/hanzoai/base/tests"
)
// TestRetainedRecordsSurviveARestart is the obligation stated as a test.
//
// A record has to be kept for five years after the relationship it sits inside
// ends (AMLR Art. 77(3)). The memory shelf keeps it until the process exits,
// which is not the same thing and is not an exception the law makes for a
// rollout — so the property worth asserting is not "the ledger can store a
// record" but "the bytes on disk still answer after the instance that wrote
// them is gone".
//
// The second app is a fresh instance over the first one's data directory: a new
// bootstrap, new handles, nothing carried over in memory. That is what a
// restarted pod reads.
func TestRetainedRecordsSurviveARestart(t *testing.T) {
first, err := tests.NewTestApp()
if err != nil {
t.Fatalf("first instance: %v", err)
}
if err := Ensure(first); err != nil {
t.Fatalf("Ensure: %v", err)
}
occurred := time.Now().UTC().Add(-time.Hour)
id, err := NewBase(first).Retain(Record{
Org: "hanzo/acme",
Class: ClassRelationship,
Trigger: TriggerRelationshipEnd,
Ref: "rel-restart-1",
Nature: "correspondent banking",
Parties: []string{"pseudonym-a"},
Occurred: occurred,
Body: []byte("sealed"),
})
if err != nil {
t.Fatalf("retain: %v", err)
}
if id == "" {
t.Fatal("retain returned no id")
}
// Take the bytes as they are on disk, then let the first instance go. The
// copy is what survives the process, which is the whole question.
dir := copyTree(t, first.DataDir())
first.Cleanup()
// The restart.
second, err := tests.NewTestApp(dir)
if err != nil {
t.Fatalf("second instance: %v", err)
}
t.Cleanup(second.Cleanup)
answer, err := NewBase(second).Lookback(PurposeDisclosure, "hanzo/acme", "pseudonym-a", time.Now().UTC())
if err != nil {
t.Fatalf("lookback after restart: %v", err)
}
if !answer.Maintained {
t.Fatal("the relationship retained before the restart is not there after it — the record plane is not durable")
}
var found bool
for _, r := range answer.Records {
if r == id {
found = true
}
}
if !found {
t.Fatalf("record %q is not among %v after the restart", id, answer.Records)
}
if len(answer.Natures) == 0 || answer.Natures[0] != "correspondent banking" {
t.Fatalf("the nature Art. 78 requires did not survive: %v", answer.Natures)
}
}
// copyTree copies a data directory as it stands, so a second instance can be
// opened over the same bytes after the first one is gone.
func copyTree(t *testing.T, src string) string {
t.Helper()
dst := t.TempDir()
err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(target)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
if err != nil {
t.Fatalf("copy data dir: %v", err)
}
return dst
}