From 76e7b5f7bc8655b3f43b952b60c4e3dfba4f74a6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 15 Sep 2026 20:09:03 -0500 Subject: [PATCH 1/3] Test(contracts): verify shared task and self-hosted HTTP behavior Add versioned behavior fixtures so the TUI can evolve independently while retaining compatible Markdown task parsing and self-hosted HTTP behavior. - Verify exact task round trips, including date/time-zone edge cases. - Exercise authentication, note read/write, invalid paths, and legacy/current API routes at root and prefixed mounts against a real Go server. - Record fixture provenance and how to run the optional server contract suite. The TUI's runtime implementation and release configuration are unchanged. --- README.md | 5 + docs/shared-contracts.md | 28 ++ internal/remote/contract_test.go | 280 ++++++++++++++++++ .../remote/testdata/self-hosted-http.json | 58 ++++ .../self-hosted-http.json.source.json | 5 + .../vault/task_roundtrip_contract_test.go | 114 +++++++ internal/vault/testdata/task-roundtrip.json | 38 +++ .../testdata/task-roundtrip.json.source.json | 5 + 8 files changed, 533 insertions(+) create mode 100644 docs/shared-contracts.md create mode 100644 internal/remote/contract_test.go create mode 100644 internal/remote/testdata/self-hosted-http.json create mode 100644 internal/remote/testdata/self-hosted-http.json.source.json create mode 100644 internal/vault/task_roundtrip_contract_test.go create mode 100644 internal/vault/testdata/task-roundtrip.json create mode 100644 internal/vault/testdata/task-roundtrip.json.source.json diff --git a/README.md b/README.md index bcca499..b67c972 100644 --- a/README.md +++ b/README.md @@ -348,3 +348,8 @@ implementation, so the two stay interchangeable. ## License MIT + +## Compatibility development + +See [shared contract fixtures](docs/shared-contracts.md) for the task-format and +self-hosted HTTP boundary checks, including optional tests against a real server. diff --git a/docs/shared-contracts.md b/docs/shared-contracts.md new file mode 100644 index 0000000..07275e9 --- /dev/null +++ b/docs/shared-contracts.md @@ -0,0 +1,28 @@ +# Shared compatibility fixtures + +The TUI owns its Go implementation and its release. It consumes versioned +behavior fixtures from `ZenNotes/zennotes`, not another repository's source tree. + +- `internal/vault/testdata/task-roundtrip.json` covers stable task identity and + exact Markdown reads and due-date writes in Los Angeles and Auckland. +- `internal/remote/testdata/self-hosted-http.json` covers authentication, note + metadata, exact Unicode/whitespace, missing resources and invalid paths at both + `/` and `/notes`. +- Adjacent `.source.json` files record each upstream path and SHA-256. Tests + verify the checked-in bytes before interpreting the contract. + +`go test ./...` runs all fixtures without a server checkout. To additionally +verify the real server implementation, build the API-only server binary and run: + +```sh +ZENNOTES_SERVER_CONTRACT_BINARY=/absolute/path/to/server go test ./internal/remote +``` + +The integration test starts that binary on loopback with disposable vault and +configuration directories, verifies persisted note bytes, and cleans up its +child process. It does not contact a deployed server or use a real account. + +When updating a contract, copy the upstream fixture and update its provenance +hash together. Review semantic changes as an API compatibility change; do not +regenerate expected results from the TUI implementation. Existing release clients +remain supported until a documented support window permits deprecation. diff --git a/internal/remote/contract_test.go b/internal/remote/contract_test.go new file mode 100644 index 0000000..1e3e48f --- /dev/null +++ b/internal/remote/contract_test.go @@ -0,0 +1,280 @@ +package remote + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +type httpContract struct { + SchemaVersion int + Protocol string + MountPaths []string + Note struct { + Path, Body, UpdatedBody string + AssetEmbeds, UpdatedAssetEmbeds []string + } + RequiredCapabilities []string + RequiredNoteFields []string + Errors struct { + Unauthenticated, MissingNote, DirectoryAsNote int + Challenge string + } +} + +const contractToken = "isolated-tui-contract-token" + +func readHTTPContract(t *testing.T) httpContract { + t.Helper() + data, err := os.ReadFile("testdata/self-hosted-http.json") + if err != nil { + t.Fatal(err) + } + source, err := os.ReadFile("testdata/self-hosted-http.json.source.json") + if err != nil { + t.Fatal(err) + } + var provenance struct{ Sha256 string } + if err = json.Unmarshal(source, &provenance); err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%x", sha256.Sum256(data)) != provenance.Sha256 { + t.Fatal("shared HTTP fixture checksum differs") + } + var fixture httpContract + if err = json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if fixture.SchemaVersion != 1 || fixture.Protocol != "self-hosted-http-v1" { + t.Fatal("unsupported HTTP contract") + } + return fixture +} + +// The same client assertions run against a deterministic fixture in ordinary CI +// and an independently supplied server binary in the cross-repository rehearsal. +func checkHTTPClientContract(t *testing.T, base string, f httpContract) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + anonymous := NewClient(base, "") + caps, err := anonymous.Capabilities(ctx) + if err != nil { + t.Fatal(err) + } + for _, key := range f.RequiredCapabilities { + if _, ok := caps[key]; !ok { + t.Errorf("missing capability %s", key) + } + } + if _, err := anonymous.ReadNote(ctx, f.Note.Path); StatusOf(err) != f.Errors.Unauthenticated { + t.Fatalf("anonymous read: %v", err) + } + client := NewClient(base, contractToken) + checkWireNote := func(expectedEmbeds []string) { + t.Helper() + var raw map[string]json.RawMessage + if err := client.Get(ctx, "/api/notes/read?path="+url.QueryEscape(f.Note.Path), &raw); err != nil { + t.Fatal(err) + } + for _, field := range f.RequiredNoteFields { + if _, ok := raw[field]; !ok { + t.Errorf("missing wire field %s", field) + } + } + var embeds []string + if err := json.Unmarshal(raw["assetEmbeds"], &embeds); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(embeds, expectedEmbeds) { + t.Fatalf("wire embeds: got %#v want %#v", embeds, expectedEmbeds) + } + } + checkWireNote(f.Note.AssetEmbeds) + note, err := client.ReadNote(ctx, f.Note.Path) + if err != nil { + t.Fatal(err) + } + if note.Body != f.Note.Body { + t.Fatalf("initial note differs: %+v", note) + } + if note.Link == "" { + t.Fatal("remote note did not acquire its desktop deep link") + } + meta, err := client.WriteNote(ctx, f.Note.Path, f.Note.UpdatedBody) + if err != nil { + t.Fatal(err) + } + if meta.Path != f.Note.Path { + t.Fatalf("write metadata differs: %+v", meta) + } + checkWireNote(f.Note.UpdatedAssetEmbeds) + updated, err := client.ReadNote(ctx, f.Note.Path) + if err != nil { + t.Fatal(err) + } + if updated.Body != f.Note.UpdatedBody { + t.Fatalf("updated Markdown bytes changed: %q", updated.Body) + } + for path, status := range map[string]int{"missing.md": f.Errors.MissingNote, "inbox": f.Errors.DirectoryAsNote} { + if _, err := client.ReadNote(ctx, path); StatusOf(err) != status { + t.Errorf("read %s: expected status %d, got %v", path, status, err) + } + } + wrong := NewClient(base, "incorrect-isolated-token") + if _, err := wrong.WriteNote(ctx, f.Note.Path, "must not replace content"); StatusOf(err) != f.Errors.Unauthenticated { + t.Fatalf("wrong-token write: %v", err) + } + retained, err := client.ReadNote(ctx, f.Note.Path) + if err != nil || retained.Body != f.Note.UpdatedBody { + t.Fatalf("unauthorized write changed note: %v", err) + } +} + +func TestSharedHTTPClientContract(t *testing.T) { + fixture := readHTTPContract(t) + for _, mount := range fixture.MountPaths { + t.Run("mount="+mount, func(t *testing.T) { + body := fixture.Note.Body + metadata := func() map[string]any { + embeds := fixture.Note.AssetEmbeds + if body == fixture.Note.UpdatedBody { + embeds = fixture.Note.UpdatedAssetEmbeds + } + return map[string]any{"path": fixture.Note.Path, "title": "Contract", "folder": "inbox", "siblingOrder": 0, "createdAt": 1, "updatedAt": 2, "size": len(body), "tags": []string{}, "wikilinks": []string{}, "assetEmbeds": embeds, "hasAttachments": true, "excerpt": "Contract"} + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if !strings.HasPrefix(r.URL.Path, mount+"/api/") { + http.NotFound(w, r) + return + } + path := strings.TrimPrefix(r.URL.Path, mount) + if path == "/api/capabilities" { + caps := map[string]any{} + for _, key := range fixture.RequiredCapabilities { + caps[key] = true + } + caps["version"] = "fixture" + caps["platform"] = "linux" + json.NewEncoder(w).Encode(caps) + return + } + if r.Header.Get("Authorization") != "Bearer "+contractToken { + w.Header().Set("WWW-Authenticate", fixture.Errors.Challenge) + http.Error(w, "unauthorized", fixture.Errors.Unauthenticated) + return + } + switch path { + case "/api/notes/read": + if r.Method != http.MethodGet { + t.Errorf("unexpected read method: %s", r.Method) + } + rel := r.URL.Query().Get("path") + if rel == "inbox" { + http.Error(w, "directory", fixture.Errors.DirectoryAsNote) + return + } + if rel != fixture.Note.Path { + http.Error(w, "missing", fixture.Errors.MissingNote) + return + } + note := metadata() + note["body"] = body + json.NewEncoder(w).Encode(note) + case "/api/notes/write": + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" { + t.Error("invalid write transport") + } + var payload struct{ Path, Body string } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + http.Error(w, "bad request", 400) + return + } + if payload.Path != fixture.Note.Path { + t.Errorf("wrong relative path: %q", payload.Path) + } + body = payload.Body + json.NewEncoder(w).Encode(metadata()) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + checkHTTPClientContract(t, server.URL+mount, fixture) + }) + } +} + +func TestIndependentServerHTTPContract(t *testing.T) { + binary := os.Getenv("ZENNOTES_SERVER_CONTRACT_BINARY") + if binary == "" { + t.Skip("set ZENNOTES_SERVER_CONTRACT_BINARY to an independently built server to run the cross-repository rehearsal") + } + fixture := readHTTPContract(t) + for _, mount := range fixture.MountPaths { + t.Run("mount="+mount, func(t *testing.T) { + root := t.TempDir() + vaultRoot := filepath.Join(root, "vault") + path := filepath.Join(vaultRoot, filepath.FromSlash(fixture.Note.Path)) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(fixture.Note.Body), 0600); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + listener.Close() + log, err := os.Create(filepath.Join(root, "server.log")) + if err != nil { + t.Fatal(err) + } + defer log.Close() + command := exec.Command(binary) + command.Dir = root + command.Env = append(os.Environ(), "ZENNOTES_BIND="+address, "ZENNOTES_DEFAULT_VAULT_PATH="+vaultRoot, "ZENNOTES_CONFIG_PATH="+filepath.Join(root, "server.json"), "ZENNOTES_BROWSE_ROOTS="+vaultRoot, "ZENNOTES_AUTH_TOKEN="+contractToken, "ZENNOTES_BASE_PATH="+mount) + command.Stdout = log + command.Stderr = log + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { command.Process.Kill(); command.Wait() }) + base := "http://" + address + mount + deadline := time.Now().Add(10 * time.Second) + for { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + _, err := NewClient(base, "").Capabilities(ctx) + cancel() + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("server did not become ready: ", err) + } + time.Sleep(50 * time.Millisecond) + } + checkHTTPClientContract(t, base, fixture) + stored, err := os.ReadFile(path) + if err != nil || string(stored) != fixture.Note.UpdatedBody { + t.Fatalf("server persisted different bytes: %v", err) + } + }) + } +} diff --git a/internal/remote/testdata/self-hosted-http.json b/internal/remote/testdata/self-hosted-http.json new file mode 100644 index 0000000..d799b7b --- /dev/null +++ b/internal/remote/testdata/self-hosted-http.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "protocol": "self-hosted-http-v1", + "mountPaths": [ + "", + "/notes" + ], + "note": { + "path": "inbox/Contract.md", + "body": "# Contract\n\nUnicode café 日本語. \n\n![[photo.png]]\n![](assets/document.pdf)\n", + "updatedBody": "# Contract\n\nUpdated café 日本語. \n\n![Photo]()\n\n", + "assetEmbeds": [ + "photo.png", + "assets/document.pdf" + ], + "updatedAssetEmbeds": [ + "assets/photo two.png" + ] + }, + "requiredNoteFields": [ + "path", + "title", + "folder", + "siblingOrder", + "createdAt", + "updatedAt", + "size", + "tags", + "wikilinks", + "assetEmbeds", + "hasAttachments", + "excerpt" + ], + "requiredCapabilities": [ + "version", + "platform", + "authRequired", + "supportsSessionLogin", + "browseRootsEnforced", + "supportsVaultSelection", + "supportsDirectoryBrowsing", + "supportsWatch", + "reportsMissingAsNotFound", + "supportsAssetOps", + "supportsWorkflows", + "supportsCustomTemplates" + ], + "errors": { + "unauthenticated": 401, + "challenge": "Bearer realm=\"ZenNotes\"", + "missingNote": 404, + "directoryAsNote": 400 + }, + "routePrefixes": [ + "/api", + "" + ] +} diff --git a/internal/remote/testdata/self-hosted-http.json.source.json b/internal/remote/testdata/self-hosted-http.json.source.json new file mode 100644 index 0000000..096d2fe --- /dev/null +++ b/internal/remote/testdata/self-hosted-http.json.source.json @@ -0,0 +1,5 @@ +{ + "sourceRepository": "https://github.com/ZenNotes/zennotes", + "sourcePath": "packages/bridge-contract/fixtures/self-hosted-http.json", + "sha256": "a52743639aa8641ac206a8a887874e7d6034828118725fb89502a661c5890437" +} diff --git a/internal/vault/task_roundtrip_contract_test.go b/internal/vault/task_roundtrip_contract_test.go new file mode 100644 index 0000000..8f8bf78 --- /dev/null +++ b/internal/vault/task_roundtrip_contract_test.go @@ -0,0 +1,114 @@ +package vault + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "reflect" + "testing" + "time" +) + +func TestSharedTaskRoundtripContract(t *testing.T) { + data, err := os.ReadFile("testdata/task-roundtrip.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + SchemaVersion int `json:"schemaVersion"` + Cases []struct { + ID string `json:"id"` + Note struct { + Path string `json:"path"` + Title string `json:"title"` + Folder NoteFolder `json:"folder"` + } `json:"note"` + Body string `json:"body"` + ExpectedBody string `json:"expectedBody"` + Due string `json:"due"` + LocalNow []int `json:"localNow"` + TaskIndex int `json:"taskIndex"` + ExpectedBefore map[string]any `json:"expectedBefore"` + ExpectedAfter map[string]any `json:"expectedAfter"` + ExpectedTaskCount int `json:"expectedTaskCount"` + } `json:"cases"` + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + if fixture.SchemaVersion != 1 || len(fixture.Cases) == 0 { + t.Fatal("unsupported or empty task contract fixture") + } + var provenance struct{ Sha256 string } + source, err := os.ReadFile("testdata/task-roundtrip.json.source.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(source, &provenance); err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%x", sha256.Sum256(data)) != provenance.Sha256 { + t.Fatal("shared fixture checksum differs") + } + for _, tc := range fixture.Cases { + t.Run(tc.ID, func(t *testing.T) { + v := newTestVault(t) + for _, zone := range []string{"America/Los_Angeles", "Pacific/Auckland"} { + location, err := time.LoadLocation(zone) + if err != nil { + t.Fatal(err) + } + due := tc.Due + if len(tc.LocalNow) == 5 { + n := tc.LocalNow + due = TodayISO(time.Date(n[0], time.Month(n[1]), n[2], n[3], n[4], 0, 0, location)) + } + if actual := SetTaskDue(tc.Body, tc.TaskIndex, due); actual != tc.ExpectedBody { + t.Fatalf("%s mutation changed unexpected bytes: got %q, want %q", zone, actual, tc.ExpectedBody) + } + } + var originalID string + // The client transforms Markdown; Go stores those exact bytes and + // parses the resulting task state for the next client read. + for index, phase := range []struct { + body string + want map[string]any + }{{tc.Body, tc.ExpectedBefore}, {tc.ExpectedBody, tc.ExpectedAfter}} { + if _, err := v.WriteNote(tc.Note.Path, phase.body); err != nil { + t.Fatal(err) + } + note, err := v.ReadNote(tc.Note.Path) + if err != nil { + t.Fatal(err) + } + if note.Body != phase.body { + t.Fatal("storage changed Markdown bytes") + } + tasks := ParseTasks(tc.Note.Path, tc.Note.Title, tc.Note.Folder, note.Body, ParseTasksOptions{Dialect: DialectApp}) + if len(tasks) != tc.ExpectedTaskCount || tc.TaskIndex < 0 || tc.TaskIndex >= len(tasks) { + t.Fatalf("got %d tasks, want %d with index %d", len(tasks), tc.ExpectedTaskCount, tc.TaskIndex) + } + task := tasks[tc.TaskIndex] + if index == 0 { + originalID = task.ID + } else if task.ID != originalID { + t.Fatal("task identity changed after editing") + } + encoded, err := json.Marshal(task) + if err != nil { + t.Fatal(err) + } + var actual map[string]any + if err := json.Unmarshal(encoded, &actual); err != nil { + t.Fatal(err) + } + for field, want := range phase.want { + if !reflect.DeepEqual(actual[field], want) { + t.Errorf("phase %d field %s: got %#v, want %#v", index, field, actual[field], want) + } + } + } + }) + } +} diff --git a/internal/vault/testdata/task-roundtrip.json b/internal/vault/testdata/task-roundtrip.json new file mode 100644 index 0000000..b3988ca --- /dev/null +++ b/internal/vault/testdata/task-roundtrip.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "id": "reschedule-in-progress-task-without-changing-other-content", + "note": { "path": "inbox/Release.md", "title": "Release", "folder": "inbox" }, + "body": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release due:2026-09-15 !high #release\n- [ ] Next item\n", + "taskIndex": 0, + "due": "2026-09-16", + "expectedBefore": { "due": "2026-09-15", "inProgress": true }, + "expectedBody": "---\ntitle: Release\n---\n# Release\n\nKeep these two spaces. \n\n```md\n- [ ] Example due:2026-01-01\n```\n\n- [/] Ship release !high #release due:2026-09-16\n- [ ] Next item\n", + "expectedAfter": { "due": "2026-09-16", "inProgress": true, "checked": false, "priority": "high", "tags": ["release"] }, + "expectedTaskCount": 2 + }, + { + "id": "assign-local-today-near-midnight", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [ ] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 0, 15], + "expectedBefore": { "checked": false }, + "expectedBody": "# Today\n\n- [ ] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false }, + "expectedTaskCount": 1 + }, + { + "id": "assign-local-today-late-at-night", + "note": { "path": "inbox/Today.md", "title": "Today", "folder": "inbox" }, + "body": "# Today\n\n- [/] Review notes\n", + "taskIndex": 0, + "localNow": [2026, 9, 15, 23, 45], + "expectedBefore": { "inProgress": true }, + "expectedBody": "# Today\n\n- [/] Review notes due:2026-09-15\n", + "expectedAfter": { "due": "2026-09-15", "checked": false, "inProgress": true }, + "expectedTaskCount": 1 + } + ] +} diff --git a/internal/vault/testdata/task-roundtrip.json.source.json b/internal/vault/testdata/task-roundtrip.json.source.json new file mode 100644 index 0000000..c9f327d --- /dev/null +++ b/internal/vault/testdata/task-roundtrip.json.source.json @@ -0,0 +1,5 @@ +{ + "sourceRepository": "https://github.com/ZenNotes/zennotes", + "sourcePath": "packages/bridge-contract/fixtures/task-roundtrip.json", + "sha256": "59705ba724a1a96822ec47a2cbcc7fd09e140036972ad1769869eac9b4cac37f" +} From e92973712cd0823198010dfb4a92d40c6101155e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 16 Sep 2026 11:21:50 -0500 Subject: [PATCH 2/3] Fix(ci): check out the shared contract fixtures with LF on Windows The shared task and HTTP fixtures are checksummed against provenance recorded from the main repository. Git for Windows converts them to CRLF on checkout, so both contract tests reported a checksum mismatch on the Windows runner while Linux and macOS passed. Pin the fixture directories to LF so every platform hashes the same bytes. --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..16e8d65 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shared contract fixtures are checksummed against provenance recorded from the +# main repository, so they must check out with LF on every platform, including +# Windows runners whose Git defaults to CRLF conversion. +internal/remote/testdata/** text eol=lf +internal/vault/testdata/** text eol=lf From 37dc3f32939a89d0f1d74b79ca691611bca761e6 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 16 Sep 2026 17:45:00 -0500 Subject: [PATCH 3/3] Docs(readme): point at the desktop integration guide docs/desktop-integration.md shipped with v0.2.0 and explains the creation metadata format, client compatibility and rollback behavior, but the README never linked it. A short subsection now says what desktop-managed commands and zn tui each follow and where the details live. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index b67c972..fd4980d 100644 --- a/README.md +++ b/README.md @@ -353,3 +353,10 @@ MIT See [shared contract fixtures](docs/shared-contracts.md) for the task-format and self-hosted HTTP boundary checks, including optional tests against a real server. + +### Desktop migration and creation dates + +Desktop-managed commands keep following the desktop vault, while `zn tui` keeps +its own terminal selection. Atomic note saves preserve creation dates in portable +metadata without changing Markdown. See [Desktop CLI compatibility](docs/desktop-integration.md) +for the metadata format, client compatibility and rollback behavior.