oficina / docs Public

Add unmounted principal ledger with explicit membership creation seam

Paul Campbell pushed by paul@keymail.dev 8f6ca79132c9569a14f6b52786369c464417b43e parent 5fe0447
7 files changed, +612 −0
  • internal/docs/principalstore/migrations/0001_principals.sql +47 −0
  • internal/docs/principalstore/store.go +208 −0
  • internal/docs/principalstore/store_test.go +245 −0
  • internal/docs/principalstore/testdata/compat_main.go.txt +44 −0
  • internal/docs/principalstore/testdata/compat_new.go.txt +8 −0
  • internal/docs/principalstore/testdata/compat_old.go.txt +3 −0
  • scripts/check-principal-compat.py +57 −0
diff --git a/internal/docs/principalstore/migrations/0001_principals.sql b/internal/docs/principalstore/migrations/0001_principals.sql
new file mode 100644
index 0000000..70a90fd
--- /dev/null
+++ b/internal/docs/principalstore/migrations/0001_principals.sql
@@ -0,0 +1,47 @@
+CREATE TABLE docs_principal_preflight (ok INTEGER CHECK(ok=1));
+INSERT INTO docs_principal_preflight SELECT CASE WHEN
+ NOT EXISTS(SELECT 1 FROM people WHERE id <= 0 OR id >= 4503599627370496)
+ AND NOT EXISTS(SELECT 1 FROM documents d WHERE d.owner_id < 0 OR (d.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM people p WHERE p.id=d.owner_id)))
+ AND NOT EXISTS(SELECT 1 FROM grants g WHERE g.created_by < 0 OR (g.created_by > 0 AND NOT EXISTS(SELECT 1 FROM people p WHERE p.id=g.created_by)))
+ AND NOT EXISTS(SELECT 1 FROM pragma_foreign_key_check)
+ AND (SELECT foreign_keys FROM pragma_foreign_keys)=1
+ THEN 1 ELSE 0 END;
+DROP TABLE docs_principal_preflight;
+CREATE TABLE docs_principals (
+ id INTEGER PRIMARY KEY,
+ kind TEXT NOT NULL CHECK((kind='local' AND id BETWEEN 1 AND 4503599627370495) OR (kind='home' AND id BETWEEN 4503599627370496 AND 9007199254740991)),
+ created_at DATETIME NOT NULL
+);
+INSERT INTO docs_principals SELECT id,'local',created_at FROM people;
+CREATE TABLE docs_home_identities (
+ issuer TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ principal_id INTEGER NOT NULL UNIQUE REFERENCES docs_principals(id),
+ PRIMARY KEY(issuer,subject)
+);
+CREATE TABLE docs_home_allocator (
+ singleton INTEGER PRIMARY KEY CHECK(singleton=1),
+ high_water INTEGER NOT NULL CHECK(typeof(high_water)='integer' AND high_water BETWEEN 4503599627370495 AND 9007199254740991)
+);
+INSERT INTO docs_home_allocator VALUES(1,4503599627370495);
+CREATE TRIGGER docs_people_mirror AFTER INSERT ON people BEGIN
+ SELECT CASE WHEN NEW.id < 1 OR NEW.id >= 4503599627370496 THEN RAISE(ABORT,'docs principal: refused') END;
+ INSERT INTO docs_principals VALUES(NEW.id,'local',NEW.created_at);
+END;
+CREATE TRIGGER docs_people_keep_id BEFORE UPDATE OF id ON people WHEN NEW.id != OLD.id BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_people_keep_row BEFORE DELETE ON people BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_principals_keep_identity BEFORE UPDATE OF id,kind ON docs_principals WHEN NEW.id != OLD.id OR NEW.kind != OLD.kind BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_principals_keep_row BEFORE DELETE ON docs_principals BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_allocator_monotonic BEFORE UPDATE ON docs_home_allocator WHEN NEW.singleton != OLD.singleton OR NEW.high_water <= OLD.high_water BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_allocator_keep_row BEFORE DELETE ON docs_home_allocator BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_home_identity_kind BEFORE INSERT ON docs_home_identities WHEN NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.principal_id AND kind='home') BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_home_identity_unique BEFORE INSERT ON docs_home_identities WHEN EXISTS(SELECT 1 FROM docs_identity_links WHERE issuer=NEW.issuer AND subject=NEW.subject) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_local_identity_unique BEFORE INSERT ON docs_identity_links WHEN EXISTS(SELECT 1 FROM docs_home_identities WHERE issuer=NEW.issuer AND subject=NEW.subject) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_home_identities_keep_update BEFORE UPDATE ON docs_home_identities BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_home_identities_keep_delete BEFORE DELETE ON docs_home_identities BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_identity_links_keep_update BEFORE UPDATE ON docs_identity_links BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_identity_links_keep_delete BEFORE DELETE ON docs_identity_links BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_documents_principal_insert BEFORE INSERT ON documents WHEN NEW.owner_id < 0 OR (NEW.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.owner_id)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_documents_principal_update BEFORE UPDATE OF owner_id ON documents WHEN NEW.owner_id < 0 OR (NEW.owner_id > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.owner_id)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_grants_principal_insert BEFORE INSERT ON grants WHEN NEW.created_by < 0 OR (NEW.created_by > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.created_by)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
+CREATE TRIGGER docs_grants_principal_update BEFORE UPDATE OF created_by ON grants WHEN NEW.created_by < 0 OR (NEW.created_by > 0 AND NOT EXISTS(SELECT 1 FROM docs_principals WHERE id=NEW.created_by)) BEGIN SELECT RAISE(ABORT,'docs principal: refused'); END;
diff --git a/internal/docs/principalstore/store.go b/internal/docs/principalstore/store.go
new file mode 100644
index 0000000..b9d727d
--- /dev/null
+++ b/internal/docs/principalstore/store.go
@@ -0,0 +1,208 @@
+// Package principalstore allocates identity records only. A principal is not
+// membership, an authenticated session, or permission to read a document.
+package principalstore
+
+import (
+ "context"
+ "database/sql"
+ "embed"
+ "errors"
+ "net/url"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+//go:embed migrations/*.sql
+var migrations embed.FS
+
+// Schema follows Docs and linkstore schemas. It remains unmounted until the
+// combined migration, admission and rollback release has been reviewed.
+var Schema = migrate.MustFromFS(migrations, "docs_principal")
+
+const HomeIDMin int64 = 1 << 52
+const HomeIDMax int64 = 1<<53 - 1
+
+var (
+ ErrRefused = errors.New("docs principal: refused")
+ ErrUnavailable = errors.New("docs principal: unavailable")
+)
+
+type Kind string
+
+const (
+ Local Kind = "local"
+ Home Kind = "home"
+)
+
+type Principal struct {
+ ID int64
+ Kind Kind
+ // Nil means no verified local address. Never substitute an empty or invented
+ // address in allowlists, sample creation or grant equality queries.
+ VerifiedAddress *string
+}
+
+// HomeIdentity is supplied only after shared handoff verification and replay
+// consumption. The store cannot authenticate a browser or an assertion.
+type HomeIdentity struct{ Issuer, Subject string }
+type MembershipRequest struct{ Issuer, Team, Subject string }
+type Config struct {
+ Issuer, Team string
+ // RequireMembership consults the authoritative roster cache outside the SQL
+ // transaction. Missing/expired/untrusted snapshot is ErrUnavailable; a fresh
+ // complete snapshot lacking the person is ErrRefused. Success is a bounded
+ // observation, not a durable grant. Each later access must recheck authority.
+ RequireMembership func(context.Context, MembershipRequest) error
+}
+type Store struct {
+ db *sql.DB
+ cfg Config
+}
+
+func New(db *sql.DB, cfg Config) (*Store, error) {
+ if db == nil || !origin(cfg.Issuer) || !opaque(cfg.Team, 64) {
+ return nil, ErrRefused
+ }
+ return &Store{db: db, cfg: cfg}, nil
+}
+
+func (s *Store) Person(ctx context.Context, id int64) (Principal, error) {
+ if id <= 0 || id > HomeIDMax {
+ return Principal{}, ErrRefused
+ }
+ return readPerson(s.db.QueryRowContext(ctx, `SELECT p.id,p.kind,l.email FROM docs_principals p LEFT JOIN people l ON l.id=p.id WHERE p.id=?`, id))
+}
+func readPerson(row *sql.Row) (Principal, error) {
+ var p Principal
+ var address sql.NullString
+ err := row.Scan(&p.ID, &p.Kind, &address)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Principal{}, ErrRefused
+ }
+ if err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ switch p.Kind {
+ case Local:
+ if p.ID <= 0 || p.ID >= HomeIDMin || !address.Valid {
+ return Principal{}, ErrRefused
+ }
+ a := strings.ToLower(strings.TrimSpace(address.String))
+ if a == "" {
+ return Principal{}, ErrRefused
+ }
+ p.VerifiedAddress = &a
+ case Home:
+ if p.ID < HomeIDMin || p.ID > HomeIDMax || address.Valid {
+ return Principal{}, ErrRefused
+ }
+ default:
+ return Principal{}, ErrRefused
+ }
+ return p, nil
+}
+
+// Resolve reads either an existing local link or a Home-only identity. It does
+// not create a record and returns no standing from a stored mapping.
+func (s *Store) Resolve(ctx context.Context, issuer, subject string) (Principal, error) {
+ if issuer != s.cfg.Issuer || !opaque(subject, 512) {
+ return Principal{}, ErrRefused
+ }
+ var id int64
+ var count int
+ err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MIN(id),0),COUNT(*) FROM (SELECT person_id AS id FROM docs_identity_links WHERE issuer=? AND subject=? UNION ALL SELECT principal_id AS id FROM docs_home_identities WHERE issuer=? AND subject=?)`, issuer, subject, issuer, subject).Scan(&id, &count)
+ if err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ if count != 1 {
+ return Principal{}, ErrRefused
+ }
+ return s.Person(ctx, id)
+}
+
+// EnsureHome requires a current membership check even if another call has
+// already created this Home-only identity. Use Resolve for identity-only reads.
+func (s *Store) EnsureHome(ctx context.Context, identity HomeIdentity) (Principal, error) {
+ if identity.Issuer != s.cfg.Issuer || !opaque(identity.Subject, 512) || s.cfg.RequireMembership == nil {
+ return Principal{}, ErrRefused
+ }
+ if err := s.cfg.RequireMembership(ctx, MembershipRequest{identity.Issuer, s.cfg.Team, identity.Subject}); err != nil {
+ if errors.Is(err, ErrRefused) {
+ return Principal{}, ErrRefused
+ }
+ return Principal{}, ErrUnavailable
+ }
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ defer tx.Rollback()
+ // Take the writer lock before reading either mapping. The no-op write leaves
+ // the allocator untouched and makes concurrent first arrivals one winner.
+ if _, err = tx.ExecContext(ctx, `UPDATE docs_home_allocator SET high_water=high_water WHERE singleton=0`); err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ var local int
+ if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM docs_identity_links WHERE issuer=? AND subject=?`, identity.Issuer, identity.Subject).Scan(&local); err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ if local != 0 {
+ return Principal{}, ErrRefused
+ }
+ var id int64
+ err = tx.QueryRowContext(ctx, `SELECT principal_id FROM docs_home_identities WHERE issuer=? AND subject=?`, identity.Issuer, identity.Subject).Scan(&id)
+ if err == nil {
+ p, err := readPerson(tx.QueryRowContext(ctx, `SELECT p.id,p.kind,l.email FROM docs_principals p LEFT JOIN people l ON l.id=p.id WHERE p.id=?`, id))
+ if err != nil {
+ return Principal{}, err
+ }
+ if p.Kind != Home {
+ return Principal{}, ErrRefused
+ }
+ if tx.Commit() != nil {
+ return Principal{}, ErrUnavailable
+ }
+ return p, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return Principal{}, ErrUnavailable
+ }
+ if err = tx.QueryRowContext(ctx, `SELECT high_water FROM docs_home_allocator WHERE singleton=1`).Scan(&id); err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ if id < HomeIDMin-1 || id >= HomeIDMax {
+ return Principal{}, ErrRefused
+ }
+ id++
+ if _, err = tx.ExecContext(ctx, `UPDATE docs_home_allocator SET high_water=? WHERE singleton=1`, id); err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ if _, err = tx.ExecContext(ctx, `INSERT INTO docs_principals(id,kind,created_at) VALUES(?,'home',?)`, id, time.Now().UTC()); err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ if _, err = tx.ExecContext(ctx, `INSERT INTO docs_home_identities(issuer,subject,principal_id) VALUES(?,?,?)`, identity.Issuer, identity.Subject, id); err != nil {
+ return Principal{}, ErrUnavailable
+ }
+ if tx.Commit() != nil {
+ return Principal{}, ErrUnavailable
+ }
+ return Principal{ID: id, Kind: Home}, nil
+}
+func opaque(s string, max int) bool {
+ if s == "" || len(s) > max || !utf8.ValidString(s) {
+ return false
+ }
+ for _, r := range s {
+ if r <= 32 || r == 127 || r == '@' {
+ return false
+ }
+ }
+ return true
+}
+func origin(s string) bool {
+ u, e := url.Parse(s)
+ return e == nil && len(s) <= 2048 && u.Scheme == "https" && u.Host != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.String() == s && !strings.ContainsAny(s, "\\\r\n\t ")
+}
diff --git a/internal/docs/principalstore/store_test.go b/internal/docs/principalstore/store_test.go
new file mode 100644
index 0000000..715f234
--- /dev/null
+++ b/internal/docs/principalstore/store_test.go
@@ -0,0 +1,245 @@
+package principalstore_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+ "github.com/carlosframework/docs/internal/docs/linkstore"
+ p "github.com/carlosframework/docs/internal/docs/principalstore"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+const issuer = "https://id.oficina.works"
+
+func fixture(t *testing.T, apply bool) (*db.DB, *p.Store) {
+ t.Helper()
+ d, e := db.Open(filepath.Join(t.TempDir(), "docs.db"), nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, e = migrate.Apply(context.Background(), d, migrate.Merge(docs.Schema, linkstore.Schema)); e != nil {
+ t.Fatal(e)
+ }
+ if apply {
+ if _, e = migrate.Apply(context.Background(), d, p.Schema); e != nil {
+ t.Fatal(e)
+ }
+ }
+ s, e := p.New(d.Writer(), p.Config{Issuer: issuer, Team: "A1", RequireMembership: func(_ context.Context, r p.MembershipRequest) error {
+ if r.Issuer != issuer || r.Team != "A1" || r.Subject == "absent" {
+ return p.ErrRefused
+ }
+ return nil
+ }})
+ if e != nil {
+ t.Fatal(e)
+ }
+ return d, s
+}
+func exec(t *testing.T, d *db.DB, q string, args ...any) {
+ t.Helper()
+ if _, e := d.Writer().Exec(q, args...); e != nil {
+ t.Fatal(e)
+ }
+}
+func local(t *testing.T, d *db.DB, address string) int64 {
+ t.Helper()
+ r, e := d.Writer().Exec(`INSERT INTO people(email,created_at)VALUES(?,CURRENT_TIMESTAMP)`, address)
+ if e != nil {
+ t.Fatal(e)
+ }
+ id, e := r.LastInsertId()
+ if e != nil {
+ t.Fatal(e)
+ }
+ return id
+}
+func TestAllocationIsolationAddressAbsenceAndNoSamples(t *testing.T) {
+ d, s := fixture(t, false)
+ a := local(t, d, "alice@example.invalid")
+ exec(t, d, `INSERT INTO docs_identity_links VALUES(?,?,?)`, issuer, "alice", a)
+ if _, e := migrate.Apply(context.Background(), d, p.Schema); e != nil {
+ t.Fatal(e)
+ }
+ old, e := s.Resolve(context.Background(), issuer, "alice")
+ if e != nil || old.ID != a || old.Kind != p.Local || old.VerifiedAddress == nil || *old.VerifiedAddress != "alice@example.invalid" {
+ t.Fatal(old, e)
+ }
+ h, e := s.EnsureHome(context.Background(), p.HomeIdentity{Issuer: issuer, Subject: "new-home"})
+ if e != nil || h.ID != p.HomeIDMin || h.Kind != p.Home || h.VerifiedAddress != nil {
+ t.Fatal(h, e)
+ }
+ b := local(t, d, "bob@example.invalid")
+ if b != a+1 {
+ t.Fatal("trigger changed LastInsertId", a, b)
+ }
+ got, e := s.Person(context.Background(), b)
+ if e != nil || got.ID != b || got.Kind != p.Local {
+ t.Fatal(got, e)
+ }
+ resolved, e := s.Resolve(context.Background(), issuer, "new-home")
+ if e != nil || resolved.ID != h.ID || resolved.VerifiedAddress != nil {
+ t.Fatal(resolved, e)
+ }
+ for _, table := range []string{"documents", "grants"} {
+ var n int
+ if e = d.Writer().QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); e != nil || n != 0 {
+ t.Fatal(table, n, e)
+ }
+ }
+ if _, e = s.EnsureHome(context.Background(), p.HomeIdentity{Issuer: issuer, Subject: "alice"}); !errors.Is(e, p.ErrRefused) {
+ t.Fatal("merged local account", e)
+ }
+ if _, e = d.Writer().Exec(`INSERT INTO docs_identity_links VALUES(?,?,?)`, issuer, "new-home", b); e == nil {
+ t.Fatal("cross-table collision accepted")
+ }
+}
+func TestMembershipSeamAndIdentityOnlyResolve(t *testing.T) {
+ d, s := fixture(t, true)
+ ctx := context.Background()
+ for _, failure := range []error{p.ErrRefused, p.ErrUnavailable, errors.New("secret diagnostic")} {
+ cfg := p.Config{Issuer: issuer, Team: "A1", RequireMembership: func(context.Context, p.MembershipRequest) error { return failure }}
+ blocked, e := p.New(d.Writer(), cfg)
+ if e != nil {
+ t.Fatal(e)
+ }
+ _, e = blocked.EnsureHome(ctx, p.HomeIdentity{Issuer: issuer, Subject: "new"})
+ want := p.ErrUnavailable
+ if failure == p.ErrRefused {
+ want = p.ErrRefused
+ }
+ if e != want {
+ t.Fatal(e)
+ }
+ }
+ none, _ := p.New(d.Writer(), p.Config{Issuer: issuer, Team: "A1"})
+ if _, e := none.EnsureHome(ctx, p.HomeIdentity{issuer, "new"}); e != p.ErrRefused {
+ t.Fatal(e)
+ }
+ var n int
+ d.Writer().QueryRow(`SELECT COUNT(*) FROM docs_principals`).Scan(&n)
+ if n != 0 {
+ t.Fatal("unapproved principal", n)
+ }
+ h, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "member"})
+ if e != nil {
+ t.Fatal(e)
+ }
+ got, e := none.Resolve(ctx, issuer, "member")
+ if e != nil || got.ID != h.ID {
+ t.Fatal(got, e)
+ }
+ for _, id := range []p.HomeIdentity{{"https://foreign.example", "member"}, {issuer, "mail@example.invalid"}, {issuer, ""}} {
+ if _, e = s.EnsureHome(ctx, id); e != p.ErrRefused {
+ t.Fatal(e)
+ }
+ }
+}
+func TestConcurrentCreationAndImmutableIDs(t *testing.T) {
+ d, s := fixture(t, true)
+ ctx := context.Background()
+ var wg sync.WaitGroup
+ ids := make(chan int64, 16)
+ errs := make(chan error, 16)
+ for range 16 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ v, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "same"})
+ if e != nil {
+ errs <- e
+ } else {
+ ids <- v.ID
+ }
+ }()
+ }
+ wg.Wait()
+ close(ids)
+ close(errs)
+ for e := range errs {
+ t.Fatal(e)
+ }
+ for id := range ids {
+ if id != p.HomeIDMin {
+ t.Fatal(id)
+ }
+ }
+ a := local(t, d, "local@example.invalid")
+ for _, q := range []string{
+ `DELETE FROM people`, `UPDATE people SET id=id+1`, `DELETE FROM docs_principals`, `UPDATE docs_principals SET kind='local' WHERE kind='home'`,
+ `DELETE FROM docs_home_identities`, `UPDATE docs_home_identities SET subject='other'`, `UPDATE docs_home_allocator SET high_water=high_water-1`,
+ fmt.Sprintf(`INSERT INTO people(id,email,created_at) VALUES(%d,'collision@example.invalid',CURRENT_TIMESTAMP)`, p.HomeIDMin),
+ fmt.Sprintf(`INSERT INTO docs_home_identities VALUES('%s','wrong-kind',%d)`, issuer, a),
+ `INSERT INTO docs_identity_links VALUES('https://id.oficina.works','missing',12345)`,
+ } {
+ if _, e := d.Writer().Exec(q); e == nil {
+ t.Fatalf("accepted mutation: %s", q)
+ }
+ }
+ if _, e := migrate.Apply(ctx, d, p.Schema); e != nil {
+ t.Fatal(e)
+ }
+ if _, e := migrate.Apply(ctx, d, migrate.Merge(docs.Schema, linkstore.Schema)); e != nil {
+ t.Fatal("older known migration set rejected newer ledger", e)
+ }
+ rows, e := d.Writer().Query(`PRAGMA foreign_key_check`)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer rows.Close()
+ if rows.Next() {
+ t.Fatal("foreign key damage")
+ }
+}
+func TestSentinelsReferencesAndExhaustion(t *testing.T) {
+ d, s := fixture(t, true)
+ ctx := context.Background()
+ h, e := s.EnsureHome(ctx, p.HomeIdentity{issuer, "owner"})
+ if e != nil {
+ t.Fatal(e)
+ }
+ for i, owner := range []int64{0, h.ID} {
+ exec(t, d, `INSERT INTO documents(id,sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES(?,?,?,?,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)`, i+1, fmt.Sprint(i), "Title", "guid", owner)
+ }
+ exec(t, d, `INSERT INTO grants(doc_id,subject,role,created_by)VALUES(1,'reader@example.invalid','reader',0)`)
+ exec(t, d, `UPDATE documents SET owner_id=owner_id,title=title`)
+ exec(t, d, `UPDATE grants SET created_by=created_by`)
+ for _, q := range []string{`UPDATE documents SET owner_id=1`, `UPDATE grants SET created_by=1`, `UPDATE documents SET owner_id=-1`} {
+ if _, e = d.Writer().Exec(q); e == nil {
+ t.Fatal(q)
+ }
+ }
+ exec(t, d, `UPDATE docs_home_allocator SET high_water=?`, p.HomeIDMax)
+ if _, e = s.EnsureHome(ctx, p.HomeIdentity{issuer, "past-cap"}); e != p.ErrRefused {
+ t.Fatal(e)
+ }
+ if _, e = s.Resolve(ctx, issuer, "past-cap"); e != p.ErrRefused {
+ t.Fatal(e)
+ }
+}
+func TestPreflightIsAtomicAndPreservesExistingSchema(t *testing.T) {
+ for _, q := range []string{
+ fmt.Sprintf(`INSERT INTO people VALUES(%d,'range@example.invalid',CURRENT_TIMESTAMP)`, p.HomeIDMin),
+ `INSERT INTO documents(sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES('x','Title','g',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,12)`,
+ `INSERT INTO grants(doc_id,subject,role,created_by)VALUES(1,'reader@example.invalid','reader',12)`,
+ } {
+ t.Run(q, func(t *testing.T) {
+ d, _ := fixture(t, false)
+ exec(t, d, q)
+ if _, e := migrate.Apply(context.Background(), d, p.Schema); e == nil {
+ t.Fatal("bad preflight accepted")
+ }
+ var n int
+ if e := d.Writer().QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'docs_principal%' OR name LIKE 'docs_home_%'`).Scan(&n); e != nil || n != 0 {
+ t.Fatal("partial migration", n, e)
+ }
+ })
+ }
+}
diff --git a/internal/docs/principalstore/testdata/compat_main.go.txt b/internal/docs/principalstore/testdata/compat_main.go.txt
new file mode 100644
index 0000000..f973ce8
--- /dev/null
+++ b/internal/docs/principalstore/testdata/compat_main.go.txt
@@ -0,0 +1,44 @@
+package main
+import(
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log/slog"
+ "os"
+ "io"
+ "github.com/carlosframework/docs/internal/docs"
+ "github.com/carlosframework/docs/internal/core"
+ "github.com/carlosframework/rastrillo/db"
+)
+func must(e error){if e!=nil{panic(e)}}
+func main(){
+ path:=flag.String("db","","disposable database");address:=flag.String("address","","synthetic local account");phase:=flag.String("phase","","phase");flag.Parse()
+ ctx:=context.Background();app,e:=docs.New(docs.Config{DBPath:*path,Env:"development",Origin:"https://compat.example",SampleDocuments:"false",Logger:slog.New(slog.NewTextHandler(io.Discard,nil))});must(e);defer app.Close()
+ d,e:=db.Open(*path,nil);must(e);defer d.Close()
+ id,e:=app.Store().EnsurePerson(ctx,*address);must(e)
+ principal:=principalStep(ctx,d,*phase)
+ if principal>0 {var n int;must(d.Writer().QueryRow("SELECT COUNT(*) FROM documents WHERE owner_id=?",principal).Scan(&n));if n==0{_,e:=app.Store().Create(ctx,principal,"home-owned");must(e)}}
+ var sqid string;e=d.Writer().QueryRow(`SELECT sqid FROM documents WHERE title='kept-content'`).Scan(&sqid)
+ if e!=nil{
+ doc,e:=app.Store().Create(ctx,id,"kept-content");must(e);sqid=doc.Sqid
+ update,e:=os.ReadFile("fixtures/vectors/fragment/hello.update.bin");must(e)
+ _,e=app.Store().ApplyUpdate(ctx,doc,docs.Actor{PersonID:id,Subject:*address},update);must(e)
+ must(app.Store().Grant(ctx,id,sqid,"reader@example.invalid",docs.RoleWriter))
+ _,e=d.Writer().Exec(`INSERT INTO doc_snapshots(doc_id,seq,state,abi_version,created_at)VALUES(?,1,?,?,CURRENT_TIMESTAMP)`,doc.ID,update,core.ABIVersion);must(e)
+ _,e=d.Writer().Exec(`INSERT INTO documents(sqid,title,guid,client_id,created_at,updated_at,owner_id)VALUES('orphan','orphan','orphan',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)`);must(e)
+ _,e=d.Writer().Exec(`INSERT INTO grants(doc_id,subject,role,created_by)VALUES(0,'sentinel@example.invalid','reader',0)`);must(e)
+ }
+ var owner int64;must(d.Writer().QueryRow(`SELECT owner_id FROM documents WHERE sqid=?`,sqid).Scan(&owner))
+ doc,e:=app.Store().Get(ctx,owner,sqid);must(e);text,e:=app.Store().Text(ctx,doc);must(e);if text!="hello world"{panic(text)}
+ if _,e=d.Writer().Exec(`UPDATE documents SET owner_id=owner_id`);e!=nil{panic(e)}
+ if _,e=d.Writer().Exec(`UPDATE grants SET created_by=created_by`);e!=nil{panic(e)}
+ rows,e:=d.Writer().Query(`SELECT update_bin,content_encoding FROM doc_updates WHERE doc_id=(SELECT id FROM documents WHERE title='kept-content') UNION ALL SELECT state,content_encoding FROM doc_snapshots WHERE doc_id=(SELECT id FROM documents WHERE title='kept-content')`);must(e);h:=sha256.New();encodings:=[]int{};for rows.Next(){var b []byte;var encoding int;must(rows.Scan(&b,&encoding));h.Write(b);encodings=append(encodings,encoding)};must(rows.Err());rows.Close()
+ var author int64;must(d.Writer().QueryRow(`SELECT created_by FROM grants WHERE subject='reader@example.invalid'`).Scan(&author))
+ var homeCount int;must(d.Writer().QueryRow(`SELECT COUNT(*) FROM documents WHERE owner_id>=4503599627370496`).Scan(&homeCount))
+ var ledger int;d.Writer().QueryRow(`SELECT COUNT(*) FROM docs_principals`).Scan(&ledger)
+ out:=map[string]any{"phase":*phase,"local_id":id,"home_id":principal,"owner":owner,"grant_author":author,"sqid":sqid,"content_hash":hex.EncodeToString(h.Sum(nil)),"encodings":encodings,"text":text,"principal_count":ledger,"home_owned":homeCount}
+ b,e:=json.Marshal(out);must(e);fmt.Println(string(b))
+}
diff --git a/internal/docs/principalstore/testdata/compat_new.go.txt b/internal/docs/principalstore/testdata/compat_new.go.txt
new file mode 100644
index 0000000..23a23be
--- /dev/null
+++ b/internal/docs/principalstore/testdata/compat_new.go.txt
@@ -0,0 +1,8 @@
+package main
+import("context";"github.com/carlosframework/rastrillo/db";"github.com/carlosframework/rastrillo/migrate";"github.com/carlosframework/docs/internal/docs/linkstore";p "github.com/carlosframework/docs/internal/docs/principalstore")
+func principalStep(ctx context.Context,d *db.DB,phase string)int64{
+ _,e:=migrate.Apply(ctx,d,migrate.Merge(linkstore.Schema,p.Schema));must(e)
+ s,e:=p.New(d.Writer(),p.Config{Issuer:"https://id.oficina.works",Team:"A1",RequireMembership:func(context.Context,p.MembershipRequest)error{return nil}});must(e)
+ v,e:=s.EnsureHome(ctx,p.HomeIdentity{Issuer:"https://id.oficina.works",Subject:"synthetic-member"});must(e)
+ if v.VerifiedAddress!=nil{panic("invented address")};return v.ID
+}
diff --git a/internal/docs/principalstore/testdata/compat_old.go.txt b/internal/docs/principalstore/testdata/compat_old.go.txt
new file mode 100644
index 0000000..c16bb3c
--- /dev/null
+++ b/internal/docs/principalstore/testdata/compat_old.go.txt
@@ -0,0 +1,3 @@
+package main
+import("context";"github.com/carlosframework/rastrillo/db")
+func principalStep(context.Context,*db.DB,string)int64{return 0}
diff --git a/scripts/check-principal-compat.py b/scripts/check-principal-compat.py
new file mode 100755
index 0000000..1e78f44
--- /dev/null
+++ b/scripts/check-principal-compat.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+"""Build frozen old/new store probes and exercise disposable rollback databases."""
+import argparse
+import io
+import json
+import pathlib
+import subprocess
+import tarfile
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--artifacts", required=True)
+args = parser.parse_args()
+repo = pathlib.Path(__file__).resolve().parents[1]
+out = pathlib.Path(args.artifacts).resolve()
+out.mkdir(parents=True, exist_ok=False)
+current = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=repo, text=True).strip()
+versions = {"old876": "87662f5", "old8a": "8a126727", "old3ed": "3ed7665", "new": current}
+fixtures = repo / "internal/docs/principalstore/testdata"
+records = []
+for name, rev in versions.items():
+ sha = subprocess.check_output(["git", "rev-parse", rev], cwd=repo, text=True).strip()
+ src = out / name
+ src.mkdir()
+ archive = subprocess.check_output(["git", "archive", sha], cwd=repo)
+ with tarfile.open(fileobj=io.BytesIO(archive)) as tree:
+ tree.extractall(src, filter="data")
+ cmd = src / "cmd/principal-compat"
+ cmd.mkdir()
+ (cmd / "main.go").write_bytes((fixtures / "compat_main.go.txt").read_bytes())
+ step = "new" if name == "new" else "old"
+ (cmd / "step.go").write_bytes((fixtures / f"compat_{step}.go.txt").read_bytes())
+ with (out / f"{name}-build.log").open("wb") as log:
+ subprocess.run(["go", "build", "-o", str(out / f"{name}-proof"), "./cmd/principal-compat"], cwd=src, stdout=log, stderr=log, check=True)
+ records.append({"name": name, "sha": sha})
+(out / "builds.json").write_text(json.dumps(records, indent=2))
+results = {}
+for old in ["old876", "old8a", "old3ed"]:
+ db = out / f"{old}-cycle.db"
+ phases = []
+ for index, binary in enumerate([old, "new", old, "new"]):
+ phase = f"{old}-{index}"
+ with (out / f"{phase}.stderr").open("wb") as log:
+ raw = subprocess.check_output([str(out / f"{binary}-proof"), "--db", str(db), "--address", f"synthetic-{index}@example.invalid", "--phase", phase], cwd=out / binary, stderr=log)
+ (out / f"{phase}.json").write_bytes(raw)
+ result = json.loads(raw)
+ if result["local_id"] != index + 1:
+ raise RuntimeError("old/new allocation or LastInsertId changed")
+ if index and any(result[k] != phases[0][k] for k in ["owner", "grant_author", "sqid", "content_hash", "encodings", "text"]):
+ raise RuntimeError("owner/grant/content changed across rollback")
+ if binary == "new" and (result["home_id"] != 2**52 or result["home_owned"] != 1):
+ raise RuntimeError("Home identity/ownership changed")
+ if index == 2 and result["home_owned"] != 1:
+ raise RuntimeError("old binary lost Home ownership")
+ phases.append(result)
+ results[old] = phases
+(out / "verified.json").write_text(json.dumps({"candidate": current, "versions": records, "cycles": results}, indent=2))
+print(out / "verified.json")