mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
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:
+15
-1
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user